diff --git a/.eslintignore b/.eslintignore index d612f5f78..23c9957f5 100644 --- a/.eslintignore +++ b/.eslintignore @@ -110,6 +110,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/JoplinSettings.d.ts +packages/app-cli/tests/services/plugins/api/JoplinSettings.js +packages/app-cli/tests/services/plugins/api/JoplinSettings.js.map packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.d.ts packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map diff --git a/.gitignore b/.gitignore index fc906e788..6c3fd8a69 100644 --- a/.gitignore +++ b/.gitignore @@ -99,6 +99,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/JoplinSettings.d.ts +packages/app-cli/tests/services/plugins/api/JoplinSettings.js +packages/app-cli/tests/services/plugins/api/JoplinSettings.js.map packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.d.ts packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js packages/app-cli/tests/services/plugins/api/JoplinViewMenuItem.js.map diff --git a/packages/app-cli/tests/services/plugins/api/JoplinSettings.ts b/packages/app-cli/tests/services/plugins/api/JoplinSettings.ts new file mode 100644 index 000000000..12eee95a5 --- /dev/null +++ b/packages/app-cli/tests/services/plugins/api/JoplinSettings.ts @@ -0,0 +1,69 @@ +import Setting from '@joplin/lib/models/Setting'; +import PluginService from '@joplin/lib/services/plugins/PluginService'; +const { waitForFolderCount, newPluginService, newPluginScript, setupDatabaseAndSynchronizer, switchClient, afterEachCleanUp } = require('../../../test-utils'); +const Folder = require('@joplin/lib/models/Folder'); + +describe('JoplinSettings', () => { + + beforeEach(async (done) => { + await setupDatabaseAndSynchronizer(1); + await switchClient(1); + done(); + }); + + afterEach(async () => { + await afterEachCleanUp(); + }); + + test('should listen to setting change event', async () => { + const service = new newPluginService() as PluginService; + + const pluginScript = newPluginScript(` + joplin.plugins.register({ + onStart: async function() { + await joplin.settings.registerSetting('myCustomSetting1', { + value: 1, + type: 1, + public: true, + label: 'My Custom Setting 1', + }); + + await joplin.settings.registerSetting('myCustomSetting2', { + value: 2, + type: 1, + public: true, + label: 'My Custom Setting 2', + }); + + joplin.settings.onChange((event) => { + joplin.data.post(['folders'], null, { title: JSON.stringify(event.keys) }); + }); + }, + }); + `); + + const plugin = await service.loadPluginFromJsBundle('', pluginScript); + await service.runPlugin(plugin); + + Setting.setValue('plugin-org.joplinapp.plugins.PluginTest.myCustomSetting1', 111); + Setting.setValue('plugin-org.joplinapp.plugins.PluginTest.myCustomSetting2', 222); + + // Also change a global setting, to verify that the plugin doesn't get + // notifications for non-plugin related events. + Setting.setValue('locale', 'fr_FR'); + + Setting.emitScheduledChangeEvent(); + + await waitForFolderCount(1); + + const folder = (await Folder.all())[0]; + + const settingNames: string[] = JSON.parse(folder.title); + settingNames.sort(); + + expect(settingNames.join(',')).toBe('myCustomSetting1,myCustomSetting2'); + + await service.destroy(); + }); + +}); diff --git a/packages/app-cli/tests/test-utils.ts b/packages/app-cli/tests/test-utils.ts index b7c4b390c..d151ed52f 100644 --- a/packages/app-cli/tests/test-utils.ts +++ b/packages/app-cli/tests/test-utils.ts @@ -768,6 +768,17 @@ function newPluginScript(script: string) { `; } +async function waitForFolderCount(count: number) { + const timeout = 2000; + const startTime = Date.now(); + while (true) { + const folders = await Folder.all(); + if (folders.length >= count) return; + if (Date.now() - startTime > timeout) throw new Error('Timeout waiting for folders to be created'); + await msleep(10); + } +} + // TODO: Update for Jest // function mockDate(year, month, day, tick) { @@ -853,4 +864,4 @@ class TestApp extends BaseApplication { } } -module.exports = { afterAllCleanUp, exportDir, 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, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp }; +module.exports = { waitForFolderCount, afterAllCleanUp, exportDir, 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, currentClientId, id, ids, sortedIds, at, createNTestNotes, createNTestFolders, createNTestTags, TestApp }; diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 11c12938e..756b781a4 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -1,6 +1,7 @@ import shim from '../shim'; import { _, supportedLocalesToLanguages, defaultLocale } from '../locale'; import { ltrimSlashes } from '../path-utils'; +import eventManager from '../eventManager'; const BaseModel = require('../BaseModel').default; const { Database } = require('../database.js'); const SyncTargetRegistry = require('../SyncTargetRegistry.js'); @@ -79,8 +80,10 @@ class Setting extends BaseModel { private static keys_: string[] = null; private static cache_: CacheItem[] = []; private static saveTimeoutId_: any = null; + private static changeEventTimeoutId_: any = null; private static customMetadata_: SettingItems = {}; private static customSections_: SettingSections = {}; + private static changedKeys_: string[] = []; static tableName() { return 'settings'; @@ -92,8 +95,10 @@ class Setting extends BaseModel { static async reset() { if (this.saveTimeoutId_) shim.clearTimeout(this.saveTimeoutId_); + if (this.changeEventTimeoutId_) shim.clearTimeout(this.changeEventTimeoutId_); this.saveTimeoutId_ = null; + this.changeEventTimeoutId_ = null; this.metadata_ = null; this.keys_ = null; this.cache_ = []; @@ -1070,6 +1075,8 @@ class Setting extends BaseModel { static load() { this.cancelScheduleSave(); + this.cancelScheduleChangeEvent(); + this.cache_ = []; return this.modelSelectAll('SELECT * FROM settings').then(async (rows: any[]) => { this.cache_ = []; @@ -1154,6 +1161,8 @@ class Setting extends BaseModel { if (c.value === value) return; + this.changedKeys_.push(key); + // Don't log this to prevent sensitive info (passwords, auth tokens...) to end up in logs // this.logger().info('Setting: ' + key + ' = ' + c.value + ' => ' + value); @@ -1169,6 +1178,7 @@ class Setting extends BaseModel { }); this.scheduleSave(); + this.scheduleChangeEvent(); return; } } @@ -1184,7 +1194,10 @@ class Setting extends BaseModel { value: this.formatValue(key, value), }); + this.changedKeys_.push(key); + this.scheduleSave(); + this.scheduleChangeEvent(); } static incValue(key: string, inc: any) { @@ -1424,6 +1437,36 @@ class Setting extends BaseModel { this.logger().info('Settings have been saved.'); } + static scheduleChangeEvent() { + if (this.changeEventTimeoutId_) shim.clearTimeout(this.changeEventTimeoutId_); + + this.changeEventTimeoutId_ = shim.setTimeout(() => { + this.emitScheduledChangeEvent(); + }, 1000); + } + + static cancelScheduleChangeEvent() { + if (this.changeEventTimeoutId_) shim.clearTimeout(this.changeEventTimeoutId_); + this.changeEventTimeoutId_ = null; + } + + public static emitScheduledChangeEvent() { + if (!this.changeEventTimeoutId_) return; + + shim.clearTimeout(this.changeEventTimeoutId_); + this.changeEventTimeoutId_ = null; + + if (!this.changedKeys_.length) { + // Sanity check - shouldn't happen + this.logger().warn('Trying to dispatch a change event without any changed keys'); + return; + } + + const keys = this.changedKeys_.slice(); + this.changedKeys_ = []; + eventManager.emit('settingsChange', { keys }); + } + static scheduleSave() { if (!Setting.autoSaveEnabled) return; diff --git a/packages/lib/services/plugins/api/JoplinSettings.ts b/packages/lib/services/plugins/api/JoplinSettings.ts index 2982a119e..1e4ed5686 100644 --- a/packages/lib/services/plugins/api/JoplinSettings.ts +++ b/packages/lib/services/plugins/api/JoplinSettings.ts @@ -1,7 +1,17 @@ +import eventManager from '../../../eventManager'; import Setting, { SettingItem as InternalSettingItem } from '../../../models/Setting'; import Plugin from '../Plugin'; import { SettingItem, SettingSection } from './types'; +export interface ChangeEvent { + /** + * Setting keys that have been changed + */ + keys: string[]; +} + +export type ChangeHandler = (event: ChangeEvent)=> void; + /** * This API allows registering new settings and setting sections, as well as getting and setting settings. Once a setting has been registered it will appear in the config screen and be editable by the user. * @@ -14,14 +24,18 @@ import { SettingItem, SettingSection } from './types'; export default class JoplinSettings { private plugin_: Plugin = null; - constructor(plugin: Plugin) { + public constructor(plugin: Plugin) { this.plugin_ = plugin; } + private get keyPrefix(): string { + return `plugin-${this.plugin_.id}.`; + } + // Ensures that the plugin settings and sections are within their own namespace, to prevent them from // overwriting other plugin settings or the default settings. private namespacedKey(key: string): string { - return `plugin-${this.plugin_.id}.${key}`; + return `${this.keyPrefix}${key}`; } /** @@ -30,7 +44,7 @@ export default class JoplinSettings { * The setting value however will be preserved from one launch to the next so there is no risk that it will be lost even if for some * reason the plugin fails to start at some point. */ - async registerSetting(key: string, settingItem: SettingItem) { + public async registerSetting(key: string, settingItem: SettingItem) { const internalSettingItem: InternalSettingItem = { key: key, value: settingItem.value, @@ -56,21 +70,21 @@ export default class JoplinSettings { /** * Registers a new setting section. Like for registerSetting, it is dynamic and needs to be done every time the plugin starts. */ - async registerSection(name: string, section: SettingSection) { + public async registerSection(name: string, section: SettingSection) { return Setting.registerSection(this.namespacedKey(name), section); } /** * Gets a setting value (only applies to setting you registered from your plugin) */ - async value(key: string): Promise { + public async value(key: string): Promise { return Setting.value(this.namespacedKey(key)); } /** * Sets a setting value (only applies to setting you registered from your plugin) */ - async setValue(key: string, value: any) { + public async setValue(key: string, value: any) { return Setting.setValue(this.namespacedKey(key), value); } @@ -81,7 +95,23 @@ export default class JoplinSettings { * * https://github.com/laurent22/joplin/blob/dev/packages/lib/models/Setting.ts#L142 */ - async globalValue(key: string): Promise { + public async globalValue(key: string): Promise { return Setting.value(key); } + + /** + * Called when one or multiple settings of your plugin have been changed. + * - For performance reasons, this event is triggered with a delay. + * - You will only get events for your own plugin settings. + */ + public async onChange(handler: ChangeHandler): Promise { + // Filter out keys that are not related to this plugin + eventManager.on('settingsChange', (event: ChangeEvent) => { + const keys = event.keys + .filter(k => k.indexOf(this.keyPrefix) === 0) + .map(k => k.substr(this.keyPrefix.length)); + if (!keys.length) return; + handler({ keys }); + }); + } }