From ec577ecbfe324bea183cf130c689b9a5a0d6eaec Mon Sep 17 00:00:00 2001 From: Eugene Lesnov Date: Mon, 4 May 2026 12:18:24 +0300 Subject: [PATCH] feat: added localization support --- src/contentScripts/runtime.js | 15 ++- src/contentScripts/secureView.ts | 25 +++- src/index.ts | 70 ++++++----- src/localization.ts | 199 +++++++++++++++++++++++++++++++ src/utils.ts | 32 ++--- 5 files changed, 290 insertions(+), 51 deletions(-) create mode 100644 src/localization.ts diff --git a/src/contentScripts/runtime.js b/src/contentScripts/runtime.js index 545e1c7..238c2dc 100644 --- a/src/contentScripts/runtime.js +++ b/src/contentScripts/runtime.js @@ -19,6 +19,11 @@ async function shakeInput(input, placeholderMsg) { input.focus(); } +function getLocalizedString(key, fallback) { + const source = document.getElementById("sn-md"); + return source?.dataset?.[key] || fallback; +} + // Password handle function async function handleSubmit() { const csID = document.getElementById("data-contentscript-id").innerText; @@ -26,7 +31,10 @@ async function handleSubmit() { const password = input?.value?.trim() ?? ""; if (!password) { - await shakeInput(input, "Password cannot be empty"); + await shakeInput( + input, + getLocalizedString("passwordCannotBeEmpty", "Password cannot be empty"), + ); logger("Empty password"); return; } @@ -69,7 +77,10 @@ async function init() { if (snMd) snMd.style.display = "flex"; if (input) { input.value = ""; - input.placeholder = "Enter Password to View Note"; + input.placeholder = getLocalizedString( + "enterPasswordToViewNote", + "Enter Password to View Note", + ); input.focus(); } } diff --git a/src/contentScripts/secureView.ts b/src/contentScripts/secureView.ts index 482c619..69809a3 100644 --- a/src/contentScripts/secureView.ts +++ b/src/contentScripts/secureView.ts @@ -3,6 +3,15 @@ * @description : SecureNotes MarkdownIt renderer (RTE-safe). */ +import strings from "../localization"; + +const escapeHtmlAttribute = (value: string): string => + value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + export default function (context: any) { const contentScriptId = context.contentScriptId; return { @@ -34,7 +43,12 @@ export default function (context: any) { const escaped = markdownIt.utils.escapeHtml(content); return ` -
+
${escaped}

🔒 Secure Notes

-

This is an encrypted note

+

${strings.secureViewEncryptedNoteInfo}

- +
- 🔒 This note is read-only. To edit it, decrypt the note, make changes, - then re-encrypt. + ${strings.secureViewReadOnlyInfo}
diff --git a/src/index.ts b/src/index.ts index a3c0c3f..bc77948 100644 --- a/src/index.ts +++ b/src/index.ts @@ -36,10 +36,12 @@ import { decryptData, } from "./encryption"; import { createLogger } from "./pluginLogger"; +import strings, { setLocale } from "./localization"; /** Global constants */ export const PLUGIN_ID = "SecureNotes"; export const LOG_LEVEL = "DEBUG"; +const JOPLIN_LOCALE_SETTING = "locale"; export const SETTINGS_SECTION = { MAIN: `${PLUGIN_ID}.settings`, @@ -83,9 +85,11 @@ const logger = createLogger(`[${PLUGIN_ID}]`, LOG_LEVEL); */ joplin.plugins.register({ onStart: async () => { + await initializeLocale(); + // Register settings section await joplin.settings.registerSection(SETTINGS_SECTION.MAIN, { - label: "Secure Notes", + label: strings.settingsSectionLabel, iconName: "fas fa-user-shield", }); @@ -96,11 +100,11 @@ joplin.plugins.register({ type: SettingItemType.Int, section: SETTINGS_SECTION.MAIN, public: true, - label: "AES Key Size", + label: strings.aesKeySizeLabel, isEnum: true, options: { 128: "128-bit", - 256: "256-bit (Recommended)", + 256: `256-bit (${strings.recommendedOptionSuffix})`, }, }, [SETTINGS_MAIN.AES_MODE]: { @@ -108,12 +112,12 @@ joplin.plugins.register({ type: SettingItemType.String, section: SETTINGS_SECTION.MAIN, public: true, - label: "AES Cipher Mode", + label: strings.aesCipherModeLabel, isEnum: true, options: { "AES-CBC": "CBC", "AES-CTR": "CTR", - "AES-GCM": "GCM (Recommended)", + "AES-GCM": `GCM (${strings.recommendedOptionSuffix})`, }, }, }); @@ -121,14 +125,14 @@ joplin.plugins.register({ // Register commands await joplin.commands.register({ name: COMMANDS.ENCRYPT, - label: "Encrypt Note", + label: strings.encryptNoteCommand, enabledCondition: "oneNoteSelected", execute: encryptNote, iconName: "fas fa-lock", }); await joplin.commands.register({ name: COMMANDS.DECRYPT, - label: "Decrypt Note", + label: strings.decryptNoteCommand, enabledCondition: "oneNoteSelected", execute: decryptNote, iconName: "fas fa-unlock", @@ -136,7 +140,7 @@ joplin.plugins.register({ await joplin.commands.register({ name: COMMANDS.TOGGLELOCK, enabledCondition: "oneNoteSelected", - label: "Toggle Note Lock", + label: strings.toggleNoteLockCommand, execute: toggleLock, iconName: "fas fa-user-lock", }); @@ -211,6 +215,18 @@ joplin.plugins.register({ /** * Update global vars based on settings change. */ +async function initializeLocale() { + try { + const locale = await joplin.settings.globalValue(JOPLIN_LOCALE_SETTING); + + if (typeof locale === "string" && locale.trim()) { + setLocale(locale); + } + } catch (error) { + logger.warn("Failed to initialize locale:", error); + } +} + async function updateSettings() { const pluginSettings = await joplin.settings.values([ SETTINGS_MAIN.KEY_SIZE, @@ -267,8 +283,8 @@ export async function handlePasswdSubmit(passwd: string) { if (!parsed) { logger.error("Invalid format"); - await showToast("Invalid format", ToastType.Error); - return { type: "error", msg: "Invalid format" }; + await showToast(strings.invalidFormat, ToastType.Error); + return { type: "error", msg: strings.invalidFormat }; } try { @@ -287,11 +303,11 @@ export async function handlePasswdSubmit(passwd: string) { } catch (error) { if (error instanceof WrongPasswordError) { logger.info("Incorrect password"); - return { type: "error", msg: "Incorrect password, try again" }; + return { type: "error", msg: strings.incorrectPasswordTryAgain }; } logger.error("Decryption error:", error); - showToast("Decryption failed", ToastType.Error); - return { type: "error", msg: "Decryption failed" }; + showToast(strings.decryptionFailed, ToastType.Error); + return { type: "error", msg: strings.decryptionFailed }; } } @@ -306,13 +322,13 @@ export async function encryptNote(note: any) { if (isLocked) { logger.debug("Note is already encrypted"); - await showToast("Note is already encrypted", ToastType.Info); + await showToast(strings.noteIsAlreadyEncrypted, ToastType.Info); return; } const passwd = await showEncryptionDialog( encryptionDialogId, - "Enter password to Encrypt", + strings.enterPasswordToEncrypt, ); if (!passwd) { logger.debug("Password dialog cancelled"); @@ -324,7 +340,7 @@ export async function encryptNote(note: any) { body: await generateEncryptedNote(aesOptions, encryptedData), }); - await showToast("Note encrypted successfully", ToastType.Success); + await showToast(strings.noteEncryptedSuccessfully, ToastType.Success); logger.info("Encryption complete"); await refreshNoteView(note.id); } @@ -339,18 +355,18 @@ export async function decryptNote(note: any) { if (!isLocked) { logger.debug("Note is not encrypted"); - await showToast("Note is not encrypted", ToastType.Info); + await showToast(strings.noteIsNotEncrypted, ToastType.Info); return; } const parsed = await validateFormat(note.body); if (!parsed) { logger.error("Invalid format"); - await showToast("Invalid format", ToastType.Error); + await showToast(strings.invalidFormat, ToastType.Error); return; } - let msg = "Enter password to Decrypt"; + let msg = strings.enterPasswordToDecrypt; // TODO: This is dangerous, limit it to 3 counts. while (true) { const passwd = await showDecryptionDialog(decryptionDialogId, msg); @@ -368,17 +384,17 @@ export async function decryptNote(note: any) { await joplin.data.put(["notes", note.id], null, { body: decryptedContent, }); - await showToast("Note decrypted successfully", ToastType.Success); + await showToast(strings.noteDecryptedSuccessfully, ToastType.Success); logger.info("Decryption complete"); await refreshNoteView(note.id); return; } catch (error) { if (error instanceof WrongPasswordError) { logger.info("Incorrect password"); - msg = "Incorrect password, try again"; + msg = strings.incorrectPasswordTryAgain; } else { logger.info("Decryption failed: ", error); - showToast("Decryption faild", ToastType.Error); + showToast(strings.decryptionFailed, ToastType.Error); return; } } @@ -395,11 +411,11 @@ export async function decryptOldNote(note: any) { const parsed = validateOldFormat(note.body || "{}"); if (!parsed) { logger.error("Invalid old format"); - await showToast("Invalid old format", ToastType.Error); + await showToast(strings.invalidOldFormat, ToastType.Error); return; } - let msg = "Enter password to Decrypt"; + let msg = strings.enterPasswordToDecrypt; while (true) { const passwd = await showDecryptionDialog(decryptionDialogId, msg); @@ -415,17 +431,17 @@ export async function decryptOldNote(note: any) { ); await joplin.data.put(["notes", note.id], null, { body: decrypted }); await removeTag(note.id, lockedTagId!); - await showToast("Note decrypted successfully", ToastType.Success); + await showToast(strings.noteDecryptedSuccessfully, ToastType.Success); logger.info("Decryption complete:", note.id); await refreshNoteView(note.id); return; } catch (error) { if (error instanceof WrongPasswordError) { logger.info("Incorrect password"); - msg = "Incorrect password, try again"; + msg = strings.incorrectPasswordTryAgain; } else { logger.info("Decryption failed: ", error); - showToast("Decryption faild", ToastType.Error); + showToast(strings.decryptionFailed, ToastType.Error); return; } } diff --git a/src/localization.ts b/src/localization.ts new file mode 100644 index 0000000..75c3984 --- /dev/null +++ b/src/localization.ts @@ -0,0 +1,199 @@ +const PLACEHOLDER_PATTERN = /\{(\w+)\}/g; + +let supportedLanguages: string[] = []; + +export interface AppLocalization { + settingsSectionLabel: string; + aesKeySizeLabel: string; + aesCipherModeLabel: string; + recommendedOptionSuffix: string; + encryptNoteCommand: string; + decryptNoteCommand: string; + toggleNoteLockCommand: string; + passwordPlaceholder: string; + confirmPasswordPlaceholder: string; + okButton: string; + cancelButton: string; + closeButton: string; + enterPasswordToEncrypt: string; + enterPasswordToDecrypt: string; + enterPasswordToViewNote: string; + passwordCannotBeEmpty: string; + passwordsDoNotMatch: string; + invalidFormat: string; + invalidOldFormat: string; + incorrectPasswordTryAgain: string; + decryptionFailed: string; + noteIsAlreadyEncrypted: string; + noteIsNotEncrypted: string; + noteEncryptedSuccessfully: string; + noteDecryptedSuccessfully: string; + encryptedNoteInfo: string; + secureViewEncryptedNoteInfo: string; + secureViewUnlockButton: string; + secureViewReadOnlyInfo: string; + legacyFormatTitle: string; + legacyFormatInfo: string; +} + +const defaultStrings: AppLocalization = { + settingsSectionLabel: "Secure Notes", + aesKeySizeLabel: "AES Key Size", + aesCipherModeLabel: "AES Cipher Mode", + recommendedOptionSuffix: "Recommended", + encryptNoteCommand: "Encrypt Note", + decryptNoteCommand: "Decrypt Note", + toggleNoteLockCommand: "Toggle Note Lock", + passwordPlaceholder: "password", + confirmPasswordPlaceholder: "confirm password", + okButton: "Ok", + cancelButton: "Cancel", + closeButton: "Close", + enterPasswordToEncrypt: "Enter password to Encrypt", + enterPasswordToDecrypt: "Enter password to Decrypt", + enterPasswordToViewNote: "Enter Password to View Note", + passwordCannotBeEmpty: "Password cannot be empty", + passwordsDoNotMatch: "Passwords do not match", + invalidFormat: "Invalid format", + invalidOldFormat: "Invalid old format", + incorrectPasswordTryAgain: "Incorrect password, try again", + decryptionFailed: "Decryption failed", + noteIsAlreadyEncrypted: "Note is already encrypted", + noteIsNotEncrypted: "Note is not encrypted", + noteEncryptedSuccessfully: "Note encrypted successfully", + noteDecryptedSuccessfully: "Note decrypted successfully", + encryptedNoteInfo: + "This is an encrypted note, use Secure Notes plugin and switch to Markdown editor's viewer layout.", + secureViewEncryptedNoteInfo: "This is an encrypted note", + secureViewUnlockButton: "Unlock", + secureViewReadOnlyInfo: + "🔒 This note is read-only. To edit it, decrypt the note, make changes, then re-encrypt.", + legacyFormatTitle: "Legacy format", + legacyFormatInfo: + "This note was encrypted with an older version of Secure Notes.
Decrypt it and re-encrypt to upgrade to the new format.", +}; + +const strings: AppLocalization = { ...defaultStrings }; + +const localizations: Record> = { + ru: { + settingsSectionLabel: "Secure Notes", + aesKeySizeLabel: "Размер ключа AES", + aesCipherModeLabel: "Режим шифрования AES", + recommendedOptionSuffix: "рекомендуется", + encryptNoteCommand: "Зашифровать заметку", + decryptNoteCommand: "Расшифровать заметку", + toggleNoteLockCommand: "Переключить защиту заметки", + passwordPlaceholder: "пароль", + confirmPasswordPlaceholder: "подтвердите пароль", + okButton: "ОК", + cancelButton: "Отмена", + closeButton: "Закрыть", + enterPasswordToEncrypt: "Введите пароль для шифрования", + enterPasswordToDecrypt: "Введите пароль для расшифровки", + enterPasswordToViewNote: "Введите пароль для просмотра заметки", + passwordCannotBeEmpty: "Пароль не может быть пустым", + passwordsDoNotMatch: "Пароли не совпадают", + invalidFormat: "Некорректный формат", + invalidOldFormat: "Некорректный старый формат", + incorrectPasswordTryAgain: "Неверный пароль, попробуйте еще раз", + decryptionFailed: "Не удалось расшифровать заметку", + noteIsAlreadyEncrypted: "Заметка уже зашифрована", + noteIsNotEncrypted: "Заметка не зашифрована", + noteEncryptedSuccessfully: "Заметка успешно зашифрована", + noteDecryptedSuccessfully: "Заметка успешно расшифрована", + encryptedNoteInfo: + "Это зашифрованная заметка. Используйте плагин Secure Notes и переключитесь в режим просмотра Markdown-редактора.", + secureViewEncryptedNoteInfo: "Это зашифрованная заметка", + secureViewUnlockButton: "Открыть", + secureViewReadOnlyInfo: + "🔒 Эта заметка доступна только для чтения. Чтобы изменить ее, расшифруйте заметку, внесите изменения и зашифруйте снова.", + legacyFormatTitle: "Старый формат", + legacyFormatInfo: + "Эта заметка была зашифрована старой версией Secure Notes.
Расшифруйте ее и зашифруйте снова, чтобы перейти на новый формат.", + }, +}; + +const getNavigatorLanguages = (): readonly string[] => { + if (typeof navigator === "undefined") { + return []; + } + + if (navigator.languages?.length > 0) { + return navigator.languages; + } + + return navigator.language ? [navigator.language] : []; +}; + +const normalizeLocale = (locale: string): string => locale.replace("_", "-"); + +const getLanguageCode = (locale: string): string | undefined => { + const localeSeparatorIndex = locale.indexOf("-"); + + return localeSeparatorIndex === -1 + ? undefined + : locale.substring(0, localeSeparatorIndex); +}; + +const getSupportedLanguages = (locales: readonly string[]): string[] => { + const languages: string[] = []; + + for (const locale of locales) { + const normalizedLocale = normalizeLocale(locale); + languages.push(normalizedLocale); + + const languageCode = getLanguageCode(normalizedLocale); + + if (languageCode) { + languages.push(languageCode); + } + } + + return languages; +}; + +const findLocalization = ( + languages: readonly string[], +): Partial => { + for (const language of languages) { + const localization = localizations[language]; + + if (localization) { + return localization; + } + } + + return {}; +}; + +const applyLocalization = (localization: Partial) => { + Object.assign(strings, defaultStrings, localization); +}; + +export const setLocale = (supportedLocales: readonly string[] | string) => { + const locales = + typeof supportedLocales === "string" ? [supportedLocales] : supportedLocales; + const languages = getSupportedLanguages(locales); + + supportedLanguages = languages; + applyLocalization(findLocalization(languages)); +}; + +setLocale(getNavigatorLanguages()); + +export const getLocales = () => { + return [...supportedLanguages]; +}; + +export const formatLocalizedString = ( + template: string, + values: Record, +): string => { + return template.replace(PLACEHOLDER_PATTERN, (match, key: string) => { + const value = values[key]; + return value === undefined ? match : String(value); + }); +}; + +export default strings; diff --git a/src/utils.ts b/src/utils.ts index b337b22..888c5e0 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,6 +9,7 @@ import joplin from "api"; import { ToastType } from "api/types"; import { AesOptions } from "./encryption"; import { PLUGIN_ID } from "./index"; +import strings from "./localization"; import MarkdownIt = require("markdown-it"); /** @@ -49,13 +50,13 @@ export async function showEncryptionDialog( name="password" class="passwd-input" type="password" - placeholder="password" + placeholder="${strings.passwordPlaceholder}" /> @@ -71,8 +72,8 @@ export async function showEncryptionDialog( "./dialogScripts/encryptionDialog.js", ); await dialogs.setButtons(passwdDialogID, [ - { id: "ok", title: "Ok" }, - { id: "cancel", title: "Cancel" }, + { id: "ok", title: strings.okButton }, + { id: "cancel", title: strings.cancelButton }, ]); await dialogs.setFitToContent(passwdDialogID, true); const result = await dialogs.open(passwdDialogID); @@ -80,11 +81,11 @@ export async function showEncryptionDialog( const password = result.formData?.passwordForm?.password || ""; const confirm = result.formData?.passwordForm?.confirmPassword || ""; if (!password) { - currentMsg = "Password cannot be empty"; + currentMsg = strings.passwordCannotBeEmpty; continue; } if (password !== confirm) { - currentMsg = "Passwords do not match"; + currentMsg = strings.passwordsDoNotMatch; continue; } return password; @@ -116,7 +117,7 @@ export async function showDecryptionDialog( name="password" class="passwd-input" type="password" - placeholder="password" + placeholder="${strings.passwordPlaceholder}" /> @@ -132,15 +133,15 @@ export async function showDecryptionDialog( "./dialogScripts/decryptionDialog.js", ); await dialogs.setButtons(passwdDialogID, [ - { id: "ok", title: "Ok" }, - { id: "cancel", title: "Cancel" }, + { id: "ok", title: strings.okButton }, + { id: "cancel", title: strings.cancelButton }, ]); await dialogs.setFitToContent(passwdDialogID, true); const result = await dialogs.open(passwdDialogID); if (result.id !== "ok") return null; const password = result.formData?.passwordForm?.password || ""; if (!password) { - currentMsg = "Password cannot be empty"; + currentMsg = strings.passwordCannotBeEmpty; continue; } return password; @@ -170,7 +171,7 @@ export async function generateEncryptedNote( ) { const secureNotesBlock = `\`\`\`${PLUGIN_ID} ## Info -This is an encrypted note, use Secure Notes plugin and switch to Markdown editor's viewer layout. +${strings.encryptedNoteInfo} ## Encryption mode: ${aesOptions.AesMode} @@ -236,18 +237,17 @@ export async function showLegacyDialog(legacyDialogId: any): Promise { `

Secure Notes

-

Legacy format

+

${strings.legacyFormatTitle}

- This note was encrypted with an older version of Secure Notes.
- Decrypt it and re-encrypt to upgrade to the new format. + ${strings.legacyFormatInfo}

`, ); await dialogs.addScript(legacyDialogId, "./dialogScripts/legacyDialog.css"); await dialogs.setButtons(legacyDialogId, [ - { id: "decrypt", title: "Decrypt Note" }, - { id: "close", title: "Close" }, + { id: "decrypt", title: strings.decryptNoteCommand }, + { id: "close", title: strings.closeButton }, ]); await dialogs.setFitToContent(legacyDialogId, true); const result = await dialogs.open(legacyDialogId);