1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Plugins: Improved note change event handling. Also added tests and improved debugging plugins.

This commit is contained in:
Laurent Cozic 2020-12-01 14:08:41 +00:00
parent eed3dc8617
commit 05e9000087
25 changed files with 3934 additions and 64 deletions

View File

@ -86,6 +86,9 @@ packages/app-cli/tests/models_Note.js.map
packages/app-cli/tests/models_Setting.d.ts
packages/app-cli/tests/models_Setting.js
packages/app-cli/tests/models_Setting.js.map
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
@ -1124,6 +1127,9 @@ packages/lib/services/plugins/utils/executeSandboxCall.js.map
packages/lib/services/plugins/utils/loadContentScripts.d.ts
packages/lib/services/plugins/utils/loadContentScripts.js
packages/lib/services/plugins/utils/loadContentScripts.js.map
packages/lib/services/plugins/utils/makeListener.d.ts
packages/lib/services/plugins/utils/makeListener.js
packages/lib/services/plugins/utils/makeListener.js.map
packages/lib/services/plugins/utils/manifestFromObject.d.ts
packages/lib/services/plugins/utils/manifestFromObject.js
packages/lib/services/plugins/utils/manifestFromObject.js.map

6
.gitignore vendored
View File

@ -77,6 +77,9 @@ packages/app-cli/tests/models_Note.js.map
packages/app-cli/tests/models_Setting.d.ts
packages/app-cli/tests/models_Setting.js
packages/app-cli/tests/models_Setting.js.map
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.d.ts
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js
packages/app-cli/tests/services/plugins/api/JoplinWorkspace.js.map
packages/app-cli/tests/services/plugins/sandboxProxy.d.ts
packages/app-cli/tests/services/plugins/sandboxProxy.js
packages/app-cli/tests/services/plugins/sandboxProxy.js.map
@ -1115,6 +1118,9 @@ packages/lib/services/plugins/utils/executeSandboxCall.js.map
packages/lib/services/plugins/utils/loadContentScripts.d.ts
packages/lib/services/plugins/utils/loadContentScripts.js
packages/lib/services/plugins/utils/loadContentScripts.js.map
packages/lib/services/plugins/utils/makeListener.d.ts
packages/lib/services/plugins/utils/makeListener.js
packages/lib/services/plugins/utils/makeListener.js.map
packages/lib/services/plugins/utils/manifestFromObject.d.ts
packages/lib/services/plugins/utils/manifestFromObject.js
packages/lib/services/plugins/utils/manifestFromObject.js.map

View File

@ -37,7 +37,8 @@
"tsc": "lerna run tsc --stream --parallel",
"updateIgnored": "gulp updateIgnoredTypeScriptBuild",
"updatePluginTypes": "./packages/generator-joplin/updateTypes.sh",
"watch": "lerna run watch --stream --parallel"
"watch": "lerna run watch --stream --parallel",
"i": "lerna add --no-bootstrap --scope"
},
"husky": {
"hooks": {

View File

@ -5,6 +5,7 @@ import BasePluginRunner from '@joplin/lib/services/plugins/BasePluginRunner';
import executeSandboxCall from '@joplin/lib/services/plugins/utils/executeSandboxCall';
import Global from '@joplin/lib/services/plugins/api/Global';
import mapEventHandlersToIds, { EventHandlers } from '@joplin/lib/services/plugins/utils/mapEventHandlersToIds';
import uuid from '@joplin/lib/uuid';
function createConsoleWrapper(pluginId: string) {
const wrapper: any = {};
@ -31,6 +32,7 @@ function createConsoleWrapper(pluginId: string) {
export default class PluginRunner extends BasePluginRunner {
private eventHandlers_: EventHandlers = {};
private activeSandboxCalls_: any = {};
constructor() {
super();
@ -45,7 +47,13 @@ export default class PluginRunner extends BasePluginRunner {
private newSandboxProxy(pluginId: string, sandbox: Global) {
const target = async (path: string, args: any[]) => {
return executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
const callId = `${pluginId}::${path}::${uuid.createNano()}`;
this.activeSandboxCalls_[callId] = true;
const promise = executeSandboxCall(pluginId, sandbox, `joplin.${path}`, mapEventHandlersToIds(args, this.eventHandlers_), this.eventHandler);
promise.finally(() => {
delete this.activeSandboxCalls_[callId];
});
return promise;
};
return {
@ -69,10 +77,25 @@ export default class PluginRunner extends BasePluginRunner {
vm.runInContext(plugin.scriptText, vmSandbox);
} catch (error) {
reject(error);
// this.logger().error(`In plugin ${plugin.id}:`, error);
// return;
}
});
}
public async waitForSandboxCalls(): Promise<void> {
const startTime = Date.now();
return new Promise((resolve: Function, reject: Function) => {
const iid = setInterval(() => {
if (!Object.keys(this.activeSandboxCalls_).length) {
clearInterval(iid);
resolve();
}
if (Date.now() - startTime > 4000) {
clearInterval(iid);
reject(new Error(`Timeout while waiting for sandbox calls to complete: ${JSON.stringify(this.activeSandboxCalls_)}`));
}
}, 10);
});
}
}

View File

@ -0,0 +1,52 @@
import PluginService from '@joplin/lib/services/plugins/PluginService';
const { newPluginService, newPluginScript, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } = require('../../../test-utils');
const Note = require('@joplin/lib/models/Note');
const Folder = require('@joplin/lib/models/Folder');
const ItemChange = require('@joplin/lib/models/ItemChange');
describe('JoplinWorkspace', () => {
beforeEach(async (done) => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
done();
});
afterEach(async () => {
await afterEachCleanUp();
});
test('should listen to noteChange events', async () => {
const service = new newPluginService() as PluginService;
const pluginScript = newPluginScript(`
joplin.plugins.register({
onStart: async function() {
await joplin.workspace.onNoteChange(async (event) => {
await joplin.data.post(['folders'], null, { title: JSON.stringify(event) });
});
},
});
`);
const note = await Note.save({});
await ItemChange.waitForAllSaved();
const plugin = await service.loadPluginFromJsBundle('', pluginScript);
await service.runPlugin(plugin);
await Note.save({ id: note.id, body: 'testing' });
await ItemChange.waitForAllSaved();
const folder = (await Folder.all())[0];
const result: any = JSON.parse(folder.title);
expect(result.id).toBe(note.id);
expect(result.event).toBe(ItemChange.TYPE_UPDATE);
await service.destroy();
});
});

View File

@ -22,9 +22,7 @@ function newPluginService(appVersion: string = '1.4') {
service.initialize(
appVersion,
{
joplin: {
workspace: {},
},
joplin: {},
},
runner,
{

View File

@ -20,4 +20,4 @@
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
}
}
}

View File

@ -50,6 +50,8 @@ const KeychainServiceDriver = require('@joplin/lib/services/keychain/KeychainSer
const KeychainServiceDriverDummy = require('@joplin/lib/services/keychain/KeychainServiceDriver.dummy').default;
const md5 = require('md5');
const S3 = require('aws-sdk/clients/s3');
const PluginRunner = require('../app/services/plugins/PluginRunner').default;
const PluginService = require('@joplin/lib/services/plugins/PluginService').default;
const { Dirnames } = require('@joplin/lib/services/synchronizer/utils/types');
const sharp = require('sharp');
@ -677,6 +679,39 @@ async function createTempDir() {
return tempDirPath;
}
function newPluginService(appVersion = '1.4') {
const runner = new PluginRunner();
const service = new PluginService();
service.initialize(
appVersion,
{
joplin: {},
},
runner,
{
dispatch: () => {},
getState: () => {},
}
);
return service;
}
function newPluginScript(script) {
return `
/* joplin-manifest:
{
"id": "org.joplinapp.plugins.PluginTest",
"manifest_version": 1,
"app_min_version": "1.4",
"name": "JS Bundle test",
"version": "1.0.0"
}
*/
${script}
`;
}
// TODO: Update for Jest
// function mockDate(year, month, day, tick) {
@ -757,4 +792,4 @@ class TestApp extends BaseApplication {
}
}
module.exports = { synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };
module.exports = { newPluginService, newPluginScript, synchronizerStart, afterEachCleanUp, syncTargetName, setSyncTargetName, syncDir, createTempDir, isNetworkSyncTarget, kvStore, expectThrow, logger, expectNotThrow, resourceService, resourceFetcher, tempFilePath, allSyncTargetItemsEncrypted, msleep, setupDatabase, revisionService, setupDatabaseAndSynchronizer, db, synchronizer, fileApi, sleep, clearDatabase, switchClient, syncTargetId, objectsEqual, checkThrowAsync, checkThrow, encryptionService, loadEncryptionMasterKey, fileContentEqual, decryptionWorker, asyncTest, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp };

View File

@ -1,11 +1,5 @@
// import { EditorCommand } from '@joplin/lib/services/plugins/api/types';
import bridge from '../bridge';
// interface JoplinWorkspace {
// execEditorCommand(command:EditorCommand):Promise<string>
// }
interface JoplinViewsDialogs {
showMessageBox(message: string): Promise<number>;
}
@ -15,7 +9,6 @@ interface JoplinViews {
}
interface Joplin {
// workspace: JoplinWorkspace;
views: JoplinViews;
}

View File

@ -11,6 +11,17 @@ const ipcRenderer = require('electron').ipcRenderer;
const logger = Logger.create('PluginRunner');
// Electron error messages are useless so wrap the renderer call and print
// additional information when an error occurs.
function ipcRendererSend(message: string, args: any) {
try {
return ipcRenderer.send(message, args);
} catch (error) {
logger.error('Could not send IPC message:', message, ': ', args, error);
throw error;
}
}
enum PluginMessageTarget {
MainWindow = 'mainWindow',
Plugin = 'plugin',
@ -46,7 +57,7 @@ function mapEventIdsToHandlers(pluginId: string, arg: any) {
callbackPromises[callbackId] = { resolve, reject };
});
ipcRenderer.send('pluginMessage', {
ipcRendererSend('pluginMessage', {
callbackId: callbackId,
target: PluginMessageTarget.Plugin,
pluginId: pluginId,
@ -132,7 +143,7 @@ export default class PluginRunner extends BasePluginRunner {
// Don't log complete HTML code, which can be long, for setHtml calls
const debugMappedArgs = fullPath.includes('setHtml') ? '<hidden>' : mappedArgs;
logger.debug(`Got message (3): ${fullPath}: ${debugMappedArgs}`);
logger.debug(`Got message (3): ${fullPath}`, debugMappedArgs);
let result: any = null;
let error: any = null;
@ -142,7 +153,7 @@ export default class PluginRunner extends BasePluginRunner {
error = e ? e : new Error('Unknown error');
}
ipcRenderer.send('pluginMessage', {
ipcRendererSend('pluginMessage', {
target: PluginMessageTarget.Plugin,
pluginId: plugin.id,
pluginCallbackId: message.callbackId,

View File

@ -1,13 +1,5 @@
<html>
<head>
<script src="./plugin_index.js"></script>
<script>
// joplin.plugins.register({
// onStart: async function() {
// alert('PLUGIN STARTED');
// },
// });
</script>
</head>
</head>
</html>

View File

@ -3,6 +3,15 @@
const sandboxProxy = require('../../node_modules/@joplin/lib/services/plugins/sandboxProxy.js').default;
const ipcRenderer = require('electron').ipcRenderer;
const ipcRendererSend = (message, args) => {
try {
return ipcRenderer.send(message, args);
} catch (error) {
console.error('Could not send IPC message:', message, ': ', args, error);
throw error;
}
};
const urlParams = new URLSearchParams(window.location.search);
const pluginId = urlParams.get('pluginId');
@ -42,7 +51,7 @@
callbackPromises[callbackId] = { resolve, reject };
});
ipcRenderer.send('pluginMessage', {
ipcRendererSend('pluginMessage', {
target: 'mainWindow',
pluginId: pluginId,
callbackId: callbackId,
@ -71,7 +80,7 @@
}
if (message.callbackId) {
ipcRenderer.send('pluginMessage', {
ipcRendererSend('pluginMessage', {
target: 'mainWindow',
pluginId: pluginId,
mainWindowCallbackId: message.callbackId,

View File

@ -1,9 +0,0 @@
{
"generator-node": {
"promptValues": {
"authorName": "Laurent Cozic",
"authorEmail": "laurent@cozic.net",
"authorUrl": ""
}
}
}

View File

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2020 Laurent Cozic <laurent@cozic.net>
Copyright (c) 2020 Laurent Cozic
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@ -1,6 +1,6 @@
const events = require('events');
class EventManager {
export class EventManager {
private emitter_: any;
private appStatePrevious_: any;

View File

@ -0,0 +1,13 @@
module.exports = {
testMatch: [
'**/*.test.js',
],
testPathIgnorePatterns: [
'<rootDir>/node_modules/',
'<rootDir>/rnInjectedJs/',
'<rootDir>/vendor/',
],
testEnvironment: 'node',
};

View File

@ -1,6 +1,7 @@
const BaseModel = require('../BaseModel').default;
const Mutex = require('async-mutex').Mutex;
const shim = require('../shim').default;
const eventManager = require('../eventManager').default;
class ItemChange extends BaseModel {
static tableName() {
@ -22,10 +23,25 @@ class ItemChange extends BaseModel {
const release = await ItemChange.addChangeMutex_.acquire();
try {
await this.db().transactionExecBatch([{ sql: 'DELETE FROM item_changes WHERE item_id = ?', params: [itemId] }, { sql: 'INSERT INTO item_changes (item_type, item_id, type, source, created_time, before_change_item) VALUES (?, ?, ?, ?, ?, ?)', params: [itemType, itemId, type, changeSource, Date.now(), beforeChangeItemJson] }]);
await this.db().transactionExecBatch([
{
sql: 'DELETE FROM item_changes WHERE item_id = ?',
params: [itemId],
},
{
sql: 'INSERT INTO item_changes (item_type, item_id, type, source, created_time, before_change_item) VALUES (?, ?, ?, ?, ?, ?)',
params: [itemType, itemId, type, changeSource, Date.now(), beforeChangeItemJson],
},
]);
} finally {
release();
ItemChange.saveCalls_.pop();
eventManager.emit('itemChange', {
itemType: itemType,
itemId: itemId,
eventType: type,
});
}
}
@ -62,6 +78,6 @@ ItemChange.TYPE_DELETE = 3;
ItemChange.SOURCE_UNSPECIFIED = 1;
ItemChange.SOURCE_SYNC = 2;
ItemChange.SOURCE_DECRYPTION = 2;
ItemChange.SOURCE_DECRYPTION = 2; // CAREFUL - SAME ID AS SOURCE_SYNC!
module.exports = ItemChange;

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
{
"name": "@joplin/lib",
"version": "1.0.9",
"description": "> TODO: description",
"author": "Laurent Cozic <laurent@cozic.net>",
"description": "Joplin Core library",
"author": "Laurent Cozic",
"homepage": "",
"license": "ISC",
"publishConfig": {
@ -11,9 +11,13 @@
"scripts": {
"tsc": "node node_modules/typescript/bin/tsc --project tsconfig.json",
"watch": "node node_modules/typescript/bin/tsc --watch --project tsconfig.json",
"generatePluginTypes": "rm -rf ./plugin_types && node node_modules/typescript/bin/tsc --declaration --declarationDir ./plugin_types --project tsconfig.json"
"generatePluginTypes": "rm -rf ./plugin_types && node node_modules/typescript/bin/tsc --declaration --declarationDir ./plugin_types --project tsconfig.json",
"test": "jest",
"test-ci": "npm run test"
},
"devDependencies": {
"@types/jest": "^26.0.15",
"jest": "^26.6.3",
"@types/node": "^14.14.6",
"typescript": "^4.0.5"
},

View File

@ -8,4 +8,8 @@ export default abstract class BasePluginRunner extends BaseService {
throw new Error(`Not implemented: ${plugin} / ${sandbox}`);
}
public async waitForSandboxCalls(): Promise<void> {
throw new Error('Not implemented: waitForSandboxCalls');
}
}

View File

@ -385,4 +385,8 @@ export default class PluginService extends BaseService {
return newSettings;
}
public async destroy() {
await this.runner_.waitForSandboxCalls();
}
}

View File

@ -37,7 +37,7 @@ export default class Joplin {
constructor(implementation: any, plugin: Plugin, store: any) {
this.data_ = new JoplinData();
this.plugins_ = new JoplinPlugins(plugin);
this.workspace_ = new JoplinWorkspace(implementation.workspace, store);
this.workspace_ = new JoplinWorkspace(store);
this.filters_ = new JoplinFilters();
this.commands_ = new JoplinCommands();
this.views_ = new JoplinViews(implementation.views, plugin, store);

View File

@ -1,13 +1,31 @@
import { ModelType } from '../../../BaseModel';
import eventManager from '../../../eventManager';
import makeListener from '../utils/makeListener';
import { Disposable } from './types';
/**
* @ignore
*/
const Note = require('../../../models/Note');
enum ItemChangeEventType {
Create = 1,
Update = 2,
Delete = 3,
}
interface ItemChangeEvent {
id: string;
event: ItemChangeEventType;
}
type ItemChangeHandler = (event: ItemChangeEvent)=> void;
/**
* The workspace service provides access to all the parts of Joplin that are being worked on - i.e. the currently selected notes or notebooks as well
* as various related events, such as when a new note is selected, or when the note content changes.
* The workspace service provides access to all the parts of Joplin that
* are being worked on - i.e. the currently selected notes or notebooks as
* well as various related events, such as when a new note is selected, or
* when the note content changes.
*
* [View the demo plugin](https://github.com/laurent22/joplin/tree/dev/packages/app-cli/tests/support/plugins)
*/
@ -15,45 +33,68 @@ export default class JoplinWorkspace {
// TODO: unregister events when plugin is closed or disabled
private store: any;
// private implementation_:any;
constructor(_implementation: any, store: any) {
constructor(store: any) {
this.store = store;
// this.implementation_ = implementation;
}
/**
* Called when a new note or notes are selected.
*/
async onNoteSelectionChange(callback: Function) {
public async onNoteSelectionChange(callback: Function): Promise<Disposable> {
eventManager.appStateOn('selectedNoteIds', callback);
return {};
// return {
// dispose: () => {
// eventManager.appStateOff('selectedNoteIds', callback);
// }
// };
}
/**
* Called when the content of a note changes.
* @deprecated Use `onNoteChange()` instead, which is reliably triggered whenever the note content, or any note property changes.
*/
public async onNoteContentChange(callback: Function) {
eventManager.on('noteContentChange', callback);
}
/**
* Called when the content of a note changes.
*/
async onNoteContentChange(callback: Function) {
eventManager.on('noteContentChange', callback);
public async onNoteChange(handler: ItemChangeHandler): Promise<Disposable> {
const wrapperHandler = (event: any) => {
if (event.itemType !== ModelType.Note) return;
handler({
id: event.itemId,
event: event.eventType,
});
};
return makeListener(eventManager, 'itemChange', wrapperHandler);
}
/**
* Called when an alarm associated with a to-do is triggered.
*/
async onNoteAlarmTrigger(callback: Function) {
eventManager.on('noteAlarmTrigger', callback);
public async onNoteAlarmTrigger(callback: Function): Promise<Disposable> {
return makeListener(eventManager, 'noteAlarmTrigger', callback);
}
/**
* Called when the synchronisation process has finished.
*/
async onSyncComplete(callback: Function) {
eventManager.on('syncComplete', callback);
public async onSyncComplete(callback: Function): Promise<Disposable> {
return makeListener(eventManager, 'syncComplete', callback);
}
/**
* Gets the currently selected note
*/
async selectedNote(): Promise<any> {
public async selectedNote(): Promise<any> {
const noteIds = this.store.getState().selectedNoteIds;
if (noteIds.length !== 1) { return null; }
return Note.load(noteIds[0]);
@ -62,7 +103,7 @@ export default class JoplinWorkspace {
/**
* Gets the IDs of the selected notes (can be zero, one, or many). Use the data API to retrieve information about these notes.
*/
async selectedNoteIds(): Promise<string[]> {
public async selectedNoteIds(): Promise<string[]> {
return this.store.getState().selectedNoteIds.slice();
}
}

View File

@ -189,6 +189,10 @@ export interface Script {
onStart?(event: any): Promise<void>;
}
export interface Disposable {
// dispose():void;
}
// =================================================================
// Menu types
// =================================================================

View File

@ -0,0 +1,22 @@
import { EventManager } from '../../../eventManager';
import { Disposable } from '../api/types';
export default function(eventManager: EventManager, eventName: string, callback: Function): Disposable {
eventManager.on(eventName, callback);
return {};
// Note: It is not currently possible to return an object with a dispose() function because function cannot be serialized when sent via IPC. So it would need send callback mechanism as for plugin functions.
//
// Or it could return a simple string ID, which can then be used to stop listening to the event. eg:
//
// const listenerId = await joplin.workspace.onNoteChange(() => {});
// // ... later:
// await joplin.workspace.removeListener(listenerId);
// return {
// dispose: () => {
// eventManager.off(eventName, callback);
// }
// };
}