You've already forked joplin
							
							
				mirror of
				https://github.com/laurent22/joplin.git
				synced 2025-10-31 00:07:48 +02:00 
			
		
		
		
	Desktop: Add support for multi-language spell check (#6617)
This commit is contained in:
		| @@ -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(', '); | ||||
| 		}, | ||||
| 	}; | ||||
| }; | ||||
|   | ||||
| @@ -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, | ||||
|   | ||||
| @@ -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 { | ||||
|   | ||||
| @@ -848,6 +848,7 @@ export default class BaseApplication { | ||||
| 			Setting.setValue('firstStart', 0); | ||||
| 		} else { | ||||
| 			Setting.applyDefaultMigrations(); | ||||
| 			Setting.applyUserSettingMigration(); | ||||
| 		} | ||||
|  | ||||
| 		setLocale(Setting.value('locale')); | ||||
|   | ||||
| @@ -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(); | ||||
|  | ||||
|   | ||||
| @@ -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) { | ||||
|   | ||||
| @@ -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), | ||||
| 		}; | ||||
| 	} | ||||
|  | ||||
|   | ||||
| @@ -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'); | ||||
| 	} | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user