diff --git a/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts b/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts index 491f37345..f52edd375 100644 --- a/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts +++ b/packages/app-desktop/gui/MainScreen/commands/showSpellCheckerMenu.ts @@ -14,21 +14,24 @@ export const declaration: CommandDeclaration = { export const runtime = (): CommandRuntime => { return { - execute: async (context: CommandContext, selectedLanguage: string = null, useSpellChecker: boolean = null) => { - selectedLanguage = selectedLanguage === null ? context.state.settings['spellChecker.language'] : selectedLanguage; + execute: async (context: CommandContext, selectedLanguages: string[] = null, useSpellChecker: boolean = null) => { + selectedLanguages = selectedLanguages === null ? context.state.settings['spellChecker.languages'] : selectedLanguages; useSpellChecker = useSpellChecker === null ? context.state.settings['spellChecker.enabled'] : useSpellChecker; - const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguage, useSpellChecker); + const menuItems = SpellCheckerService.instance().spellCheckerConfigMenuItems(selectedLanguages, useSpellChecker); const menu = Menu.buildFromTemplate(menuItems as any); menu.popup(bridge().window()); }, mapStateToTitle(state: AppState): string { if (!state.settings['spellChecker.enabled']) return null; - const language = state.settings['spellChecker.language']; - if (!language) return null; - const s = language.split('-'); - return s[0]; + const languages = state.settings['spellChecker.languages']; + if (languages.length === 0) return null; + const s: string[] = []; + languages.forEach((language: string) => { + s.push(language.split('-')[0]); + }); + return s.join(', '); }, }; }; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 20143e66e..ea8171e4e 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -122,7 +122,7 @@ interface Props { pluginMenuItems: any[]; pluginMenus: any[]; ['spellChecker.enabled']: boolean; - ['spellChecker.language']: string; + ['spellChecker.languages']: string[]; plugins: PluginStates; customCss: string; locale: string; @@ -478,7 +478,7 @@ function useMenu(props: Props) { } toolsItems = toolsItems.concat(toolsItemsAll); - toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.language'], props['spellChecker.enabled'])); + toolsItems.push(SpellCheckerService.instance().spellCheckerConfigMenuItem(props['spellChecker.languages'], props['spellChecker.enabled'])); function _checkForUpdates() { void checkForUpdates(false, bridge().window(), { includePreReleases: Setting.value('autoUpdate.includePreReleases') }); @@ -920,7 +920,7 @@ function useMenu(props: Props) { keymapLastChangeTime, modulesLastChangeTime, // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied - props['spellChecker.language'], + props['spellChecker.languages'], // eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied props['spellChecker.enabled'], props.customCss, @@ -983,7 +983,7 @@ const mapStateToProps = (state: AppState) => { showCompletedTodos: state.settings.showCompletedTodos, pluginMenuItems: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menuItem') }, 'menuBar.pluginMenuItems'), pluginMenus: stateUtils.selectArrayShallow({ array: pluginUtils.viewsByType(state.pluginService.plugins, 'menu') }, 'menuBar.pluginMenus'), - ['spellChecker.language']: state.settings['spellChecker.language'], + ['spellChecker.languages']: state.settings['spellChecker.languages'], ['spellChecker.enabled']: state.settings['spellChecker.enabled'], plugins: state.pluginService.plugins, customCss: state.customCss, diff --git a/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts b/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts index 73b52b928..daf1541fc 100644 --- a/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts +++ b/packages/app-desktop/services/spellChecker/SpellCheckerServiceDriverNative.ts @@ -2,7 +2,6 @@ import SpellCheckerServiceDriverBase from '@joplin/lib/services/spellChecker/SpellCheckerServiceDriverBase'; import bridge from '../bridge'; -import { languageCodeOnly, localesFromLanguageCode } from '@joplin/lib/locale'; import Logger from '@joplin/lib/Logger'; const logger = Logger.create('SpellCheckerServiceDriverNative'); @@ -17,35 +16,17 @@ export default class SpellCheckerServiceDriverNative extends SpellCheckerService return this.session().availableSpellCheckerLanguages; } - // Language can be set to '' to disable spell-checking - public setLanguage(v: string) { + // Language can be set to [] to disable spell-checking + public setLanguages(v: string[]) { // If we pass an empty array, it disables spell checking // https://github.com/electron/electron/issues/25228 - if (!v) { + if (v.length === 0) { this.session().setSpellCheckerLanguages([]); return; } - // The below function will throw an error if the provided language is - // not supported, so we provide fallbacks. - // https://github.com/laurent22/joplin/issues/4146 - const languagesToTry = [ - v, - languageCodeOnly(v), - ].concat(localesFromLanguageCode(languageCodeOnly(v), this.availableLanguages)); - - for (const toTry of languagesToTry) { - try { - this.session().setSpellCheckerLanguages([toTry]); - logger.info(`Set effective language from "${v}" to "${toTry}"`); - return; - } catch (error) { - logger.warn(`Failed to set language to "${toTry}". Will try the next one in this list: ${JSON.stringify(languagesToTry)}`); - logger.warn('Error was:', error); - } - } - - logger.error(`Could not set language to: ${v}`); + this.session().setSpellCheckerLanguages(v); + logger.info(`Set effective languages to "${v}"`); } public get language(): string { diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts index 3e0082911..d05209ab5 100644 --- a/packages/lib/BaseApplication.ts +++ b/packages/lib/BaseApplication.ts @@ -848,6 +848,7 @@ export default class BaseApplication { Setting.setValue('firstStart', 0); } else { Setting.applyDefaultMigrations(); + Setting.applyUserSettingMigration(); } setLocale(Setting.value('locale')); diff --git a/packages/lib/models/Setting.test.ts b/packages/lib/models/Setting.test.ts index 80d3e17ab..5e1508412 100644 --- a/packages/lib/models/Setting.test.ts +++ b/packages/lib/models/Setting.test.ts @@ -273,6 +273,31 @@ describe('models/Setting', function() { expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed })); + it('should migrate to new setting', (async () => { + await Setting.reset(); + + Setting.setValue('spellChecker.language', 'fr-FR'); + Setting.applyUserSettingMigration(); + expect(Setting.value('spellChecker.languages')).toStrictEqual(['fr-FR']); + })); + + it('should not override new setting, if it already set', (async () => { + await Setting.reset(); + + Setting.setValue('spellChecker.languages', ['fr-FR', 'en-US']); + Setting.setValue('spellChecker.language', 'fr-FR'); + Setting.applyUserSettingMigration(); + expect(Setting.value('spellChecker.languages')).toStrictEqual(['fr-FR', 'en-US']); + })); + + it('should not set new setting, if old setting is not set', (async () => { + await Setting.reset(); + + expect(Setting.isSet('spellChecker.language')).toBe(false); + Setting.applyUserSettingMigration(); + expect(Setting.isSet('spellChecker.languages')).toBe(false); + })); + it('should load sub-profile settings - 1', async () => { await Setting.reset(); diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts index 469f4aa1f..8d0423154 100644 --- a/packages/lib/models/Setting.ts +++ b/packages/lib/models/Setting.ts @@ -201,6 +201,22 @@ const defaultMigrations: DefaultMigration[] = [ }, ]; +// "UserSettingMigration" are used to migrate existing user setting to a new setting. With a way +// to transform existing value of the old setting to value and type of the new setting. +interface UserSettingMigration { + oldName: string; + newName: string; + transformValue: Function; +} + +const userSettingMigration: UserSettingMigration[] = [ + { + oldName: 'spellChecker.language', + newName: 'spellChecker.languages', + transformValue: (value: string) => { return [value]; }, + }, +]; + class Setting extends BaseModel { public static schemaUrl = 'https://joplinapp.org/schema/settings.json'; @@ -1460,7 +1476,8 @@ class Setting extends BaseModel { 'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] }, 'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false }, - 'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false }, + 'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false }, // Depreciated in favour of spellChecker.languages. + 'spellChecker.languages': { value: [], type: SettingItemType.Array, isGlobal: true, storage: SettingStorage.File, public: false }, windowContentZoomFactor: { value: 100, @@ -1608,6 +1625,16 @@ class Setting extends BaseModel { this.setValue('lastSettingDefaultMigration', defaultMigrations.length - 1); } + public static applyUserSettingMigration() { + // Function to translate existing user settings to new setting. + userSettingMigration.forEach(userMigration => { + if (!this.isSet(userMigration.newName) && this.isSet(userMigration.oldName)) { + this.setValue(userMigration.newName, userMigration.transformValue(this.value(userMigration.oldName))); + logger.info(`Migrating ${userMigration.oldName} to ${userMigration.newName}`); + } + }); + } + public static featureFlagKeys(appType: AppType): string[] { const keys = this.keys(false, appType); return keys.filter(k => k.indexOf('featureFlag.') === 0); @@ -1669,7 +1696,7 @@ class Setting extends BaseModel { } public static isSet(key: string) { - return this.cache_.find(d => d.key === key); + return !!this.cache_.find(d => d.key === key); } static keyDescription(key: string, appType: AppType = null) { diff --git a/packages/lib/services/spellChecker/SpellCheckerService.ts b/packages/lib/services/spellChecker/SpellCheckerService.ts index ca3159d7f..ffe2411dd 100644 --- a/packages/lib/services/spellChecker/SpellCheckerService.ts +++ b/packages/lib/services/spellChecker/SpellCheckerService.ts @@ -35,23 +35,33 @@ export default class SpellCheckerService { } private async addLatestSelectedLanguage(language: string) { + // This function will add selected languages to the history. History size will be capped at languagesHistorySizeMax, + // but it can be bigger. Enabled languages will always be in the history, even if it count greater then + // languagesHistorySizeMax, in such case if one of the languages will be disabled it will disappear from history. + const languagesHistorySizeMax = 5; const languages = this.latestSelectedLanguages_.slice(); - if (languages.length > 5) languages.splice(0, 1); - - if (languages.includes(language)) { - languages.splice(languages.indexOf(language), 1); + if (!languages.includes(language)) { + languages.push(language); } - languages.splice(0, 0, language); + if (languages.length > languagesHistorySizeMax) { + this.latestSelectedLanguages_.forEach(l => { + if (!this.languages.includes(l) && languages.length > languagesHistorySizeMax) languages.splice(languages.indexOf(l), 1); + }); + } this.latestSelectedLanguages_ = languages; await KvStore.instance().setValue('spellCheckerService.latestSelectedLanguages', JSON.stringify(this.latestSelectedLanguages_)); } public setupDefaultLanguage() { - if (!Setting.value('spellChecker.language')) { + if (Setting.value('spellChecker.languages').length === 0) { const l = this.driver_.language; - this.setLanguage(l ? l : this.defaultLanguage); + if (this.availableLanguages.includes(l)) { + this.setLanguage(l); + } else { + this.setLanguage(this.defaultLanguage); + } } } @@ -60,17 +70,23 @@ export default class SpellCheckerService { } private applyStateToDriver() { - this.driver_.setLanguage(this.enabled ? this.language : ''); + this.driver_.setLanguages(this.enabled ? this.languages : []); } public setLanguage(language: string) { - Setting.setValue('spellChecker.language', language); + let enabledLanguages: string[] = [...this.languages]; + if (enabledLanguages.includes(language)) { + enabledLanguages = enabledLanguages.filter(obj => obj !== language); + } else { + enabledLanguages.push(language); + } + Setting.setValue('spellChecker.languages', enabledLanguages); this.applyStateToDriver(); void this.addLatestSelectedLanguage(language); } - public get language(): string { - return Setting.value('spellChecker.language'); + public get languages(): string[] { + return Setting.value('spellChecker.languages'); } public get enabled(): boolean { @@ -115,7 +131,7 @@ export default class SpellCheckerService { output.push({ label: _('Add to dictionary'), click: () => { - void this.addToDictionary(this.language, misspelledWord); + void this.addToDictionary(this.languages[0], misspelledWord); }, }); @@ -125,7 +141,7 @@ export default class SpellCheckerService { private changeLanguageMenuItem(language: string, enabled: boolean, checked: boolean) { return { label: countryDisplayName(language), - type: 'radio', + type: 'checkbox', checked: checked, enabled: enabled, click: () => { @@ -134,11 +150,11 @@ export default class SpellCheckerService { }; } - private changeLanguageMenuItems(selectedLanguage: string, enabled: boolean) { + private changeLanguageMenuItems(selectedLanguages: string[], enabled: boolean) { const languageMenuItems = []; for (const locale of this.driver_.availableLanguages) { - languageMenuItems.push(this.changeLanguageMenuItem(locale, enabled, locale === selectedLanguage)); + languageMenuItems.push(this.changeLanguageMenuItem(locale, enabled, selectedLanguages.includes(locale))); } languageMenuItems.sort((a: any, b: any) => { @@ -148,9 +164,9 @@ export default class SpellCheckerService { return languageMenuItems; } - public spellCheckerConfigMenuItems(selectedLanguage: string, useSpellChecker: boolean) { + public spellCheckerConfigMenuItems(selectedLanguages: string[], useSpellChecker: boolean) { const latestLanguageItems = this.latestSelectedLanguages_.map((language: string) => { - return this.changeLanguageMenuItem(language, true, language === selectedLanguage); + return this.changeLanguageMenuItem(language, true, selectedLanguages.includes(language)); }); if (latestLanguageItems.length) latestLanguageItems.splice(0, 0, { type: 'separator' } as any); @@ -187,15 +203,15 @@ export default class SpellCheckerService { { label: _('Change language'), - submenu: this.changeLanguageMenuItems(selectedLanguage, useSpellChecker), + submenu: this.changeLanguageMenuItems(selectedLanguages, useSpellChecker), }, ]; } - public spellCheckerConfigMenuItem(selectedLanguage: string, useSpellChecker: boolean) { + public spellCheckerConfigMenuItem(selectedLanguages: string[], useSpellChecker: boolean) { return { label: _('Spell checker'), - submenu: this.spellCheckerConfigMenuItems(selectedLanguage, useSpellChecker), + submenu: this.spellCheckerConfigMenuItems(selectedLanguages, useSpellChecker), }; } diff --git a/packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.ts b/packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.ts index 648757135..6f885d5c2 100644 --- a/packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.ts +++ b/packages/lib/services/spellChecker/SpellCheckerServiceDriverBase.ts @@ -4,7 +4,7 @@ export default class SpellCheckerServiceDriverBase { throw new Error('Not implemented'); } - public setLanguage(_v: string) { + public setLanguages(_v: string[]) { throw new Error('Not implemented'); }