1
0
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:
Anton Tuchkov 2022-08-27 16:05:44 +05:00 committed by GitHub
parent 8b06cbf04e
commit 0356cbbfab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 111 additions and 58 deletions

View File

@ -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(', ');
},
};
};

View File

@ -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,

View File

@ -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 {

View File

@ -848,6 +848,7 @@ export default class BaseApplication {
Setting.setValue('firstStart', 0);
} else {
Setting.applyDefaultMigrations();
Setting.applyUserSettingMigration();
}
setLocale(Setting.value('locale'));

View File

@ -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();

View File

@ -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) {

View File

@ -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),
};
}

View File

@ -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');
}