mirror of
https://github.com/laurent22/joplin.git
synced 2024-12-24 10:27:10 +02:00
Desktop: Add support for multi-language spell check (#6617)
This commit is contained in:
parent
8b06cbf04e
commit
0356cbbfab
@ -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');
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user