- 🔒 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);