feat(i18n): add Russian locale (#668)

Add Russian beside Polish for the web app, the mobile editor, memo templates, and AI prompt seeds. Untranslated strings stay in English. iOS, the browser extension, and the marketing site are unchanged.
This commit is contained in:
Sergey authored and GitHub committed 2026-10-25 09:55:26 +08:00
1 parent fae1c227d0
commit 7aff06445e
35 files changed
+3473 -42

No files matched your search

@@ -150,7 +150,7 @@ type LocalTiptapEditorSharedProps = {
onReaderScroll?: (collapsed: boolean) => Promise<void>;
onImageExportEvent?: (payloadJson: string) => Promise<void>;
ref: Ref<LocalTiptapEditorRef>;
locale: "zh-CN" | "en-US" | "ja" | "pl";
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
theme: "light" | "dark";
/** Live-switchable. The same DomWebView stays mounted across viewer → editor. */
mode?: "editor" | "viewer";
@@ -453,7 +453,7 @@ const ReadOnlyX6Diagram = ({
theme,
}: {
diagram: DiagramDocument;
locale: "zh-CN" | "en-US" | "ja" | "pl";
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
theme: "light" | "dark";
}) => {
const containerRef = useRef<HTMLDivElement>(null);
@@ -1828,7 +1828,7 @@ const MobileSelectionAiPanel = ({
panel,
prompts,
}: {
locale: "zh-CN" | "en-US" | "ja" | "pl";
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
onApply: (mode: "append" | "replace") => void;
onChange: Dispatch<SetStateAction<MobileAiPanelState | null>>;
onClose: () => void;
@@ -2341,7 +2341,7 @@ const loadMermaid = () => {
};
const createMobileCodeBlockExtension = (
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
theme: "light" | "dark",
hideCopyForVisualDiagram = false,
) => CodeBlock.extend({
@@ -2517,7 +2517,7 @@ const createMobileCodeBlockExtension = (
});
const createMobileImageSizeControls = (
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
updateWidth: (width: number) => void
) => {
const controls = document.createElement("div");
@@ -2569,7 +2569,7 @@ const createMobileImageSizeControls = (
const createProtectedImageExtension = (
baseUrl: string,
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
loadResource: (source: string) => Promise<string | null>,
options?: {
readOnly?: boolean | (() => boolean);
+1 -1
View File
@@ -84,7 +84,7 @@ export const MobileMermaidDiagram = ({
source,
theme,
}: {
locale: "zh-CN" | "en-US" | "ja" | "pl";
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
source: string;
theme: "light" | "dark";
}) => {
+1 -1
View File
@@ -59,7 +59,7 @@ const createLocalImagePreviewDataUrl = async (asset: { mimeType?: string | null;
};
const alertProtectedImageLoadFailure = (
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
failure: ProtectedResourceLoadFailure
) => {
const statusLabel = failure.status != null
@@ -16,7 +16,7 @@ export const useMobileSelectionAi = ({
}: {
client: MobileAiClient | null | undefined;
editorRef: RefObject<LocalTiptapEditorRef | null>;
resolvedLocale: "zh-CN" | "en-US" | "ja" | "pl";
resolvedLocale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
titleRef: RefObject<string>;
}) => {
const activeRequestRef = useRef<{ requestId: string; controller: AbortController } | null>(null);
+1 -1
View File
@@ -1,7 +1,7 @@
import { ApiRequestError } from "@edgeever/client";
import { translateMobileText } from "./mobile-locale";
export type MobileLoginLocale = "zh-CN" | "en-US" | "ja" | "pl";
export type MobileLoginLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
export const MISSING_MOBILE_SESSION_MESSAGE = "登录成功但服务端没有返回移动端会话。请确认服务端已更新到支持 App 登录的版本。";
+2 -1
View File
@@ -29,7 +29,7 @@ describe("mobile locale translation", () => {
];
// Japanese writes "unsaved" with the same characters as Chinese.
const sameInJapanese = new Set(["未保存"]);
for (const locale of ["en-US", "ja", "pl"]) {
for (const locale of ["en-US", "ja", "pl", "ru"]) {
const untranslated = copy.filter((value) => translateMobileText(value, locale) === value
&& !(locale === "ja" && sameInJapanese.has(value)));
expect({ locale, untranslated }).toEqual({ locale, untranslated: [] });
@@ -100,6 +100,7 @@ describe("mobile locale translation", () => {
expect(localizeMissingNotebookName("en-US")).toBe("Uncategorized");
expect(localizeMissingNotebookName("ja")).toBe("未分類");
expect(localizeMissingNotebookName("pl")).toBe("Nieprzypisane");
expect(localizeMissingNotebookName("ru")).toBe("Без блокнота");
expect(localizeMissingNotebookName("zh-CN")).toBe("未分类");
});
+20 -2
View File
@@ -1,5 +1,5 @@
import { DEFAULT_MEMO_TITLE, getMemoSaveErrorReasonKey } from "@edgeever/shared";
import { enUS, ja, pl, zhCN } from "@edgeever/shared/i18n";
import { enUS, ja, pl, ru, zhCN } from "@edgeever/shared/i18n";
import { resolveSupportedLocale } from "@edgeever/shared/i18n/locales";
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import {
@@ -8,7 +8,7 @@ import {
type MobileLocalePreference,
} from "./preferences";
type SupportedMobileLocale = "zh-CN" | "en-US" | "ja" | "pl";
type SupportedMobileLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
type MobileLocaleContextValue = {
preference: MobileLocalePreference;
resolvedLocale: SupportedMobileLocale;
@@ -753,6 +753,7 @@ const zhStrings = flattenStrings(zhCN);
const enStrings = flattenStrings(enUS);
const jaStrings = flattenStrings(ja);
const plStrings = flattenStrings(pl);
const ruStrings = flattenStrings(ru);
const buildTranslationPairs = (targets: Map<string, string>) =>
Array.from(zhStrings.entries()).flatMap(([key, source]) => {
@@ -766,6 +767,7 @@ const buildTranslationPairs = (targets: Map<string, string>) =>
const enTranslationPairs = buildTranslationPairs(enStrings);
const jaTranslationPairs = buildTranslationPairs(jaStrings);
const plTranslationPairs = buildTranslationPairs(plStrings);
const ruTranslationPairs = buildTranslationPairs(ruStrings);
const exactEnglishTranslations = new Map(
enTranslationPairs.filter((pair) => !pair.pattern).map((pair) => [pair.source, pair.target]),
);
@@ -775,6 +777,9 @@ const exactJapaneseTranslations = new Map(
const exactPolishTranslations = new Map(
plTranslationPairs.filter((pair) => !pair.pattern).map((pair) => [pair.source, pair.target]),
);
const exactRussianTranslations = new Map(
ruTranslationPairs.filter((pair) => !pair.pattern).map((pair) => [pair.source, pair.target]),
);
const mobileTemplateTranslations: TranslationPair[] = Array.from(mobileOnlyTranslations.entries())
.filter(([source]) => source.includes("{{"))
.map(([source, target]) => createTranslationPair(source, target));
@@ -796,6 +801,9 @@ const polishTemplateTranslations = [
...mobilePolishTemplateTranslations,
...plTranslationPairs.filter((pair) => pair.pattern),
].sort((left, right) => right.source.length - left.source.length);
const russianTemplateTranslations = [
...ruTranslationPairs.filter((pair) => pair.pattern),
].sort((left, right) => right.source.length - left.source.length);
const resolveSystemLocale = (): SupportedMobileLocale =>
resolveSupportedLocale(Intl.DateTimeFormat().resolvedOptions().locale);
@@ -828,6 +836,15 @@ export const translateMobileText = (value: string, locale: SupportedMobileLocale
if (exact) return exact;
return applyTemplateTranslations(value, polishTemplateTranslations) ?? value;
}
if (locale === "ru") {
const exact = exactRussianTranslations.get(value);
if (exact) return exact;
const fromCatalog = applyTemplateTranslations(value, russianTemplateTranslations);
if (fromCatalog) return fromCatalog;
const english = mobileOnlyTranslations.get(value) ?? exactEnglishTranslations.get(value);
if (english) return english;
return applyTemplateTranslations(value, englishTemplateTranslations) ?? value;
}
const exact = mobileOnlyTranslations.get(value) ?? exactEnglishTranslations.get(value);
if (exact) {
return exact;
@@ -859,6 +876,7 @@ export const localizeMissingNotebookName = (locale: SupportedMobileLocale) => {
if (locale === "en-US") return "Uncategorized";
if (locale === "ja") return "未分類";
if (locale === "pl") return "Nieprzypisane";
if (locale === "ru") return "Без блокнота";
return "未分类";
};
+9 -1
View File
@@ -27,7 +27,7 @@ export type MobileRenderedWebPage = {
title?: string;
};
export type MobileWebClipLocale = "zh-CN" | "en-US" | "ja" | "pl";
export type MobileWebClipLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
const webClipCopy: Record<MobileWebClipLocale, {
capturedAtLabel: string;
@@ -69,6 +69,14 @@ const webClipCopy: Record<MobileWebClipLocale, {
imageAlt: "Obraz",
separator: ": ",
},
ru: {
sourceLabel: "Источник",
capturedAtLabel: "Сохранено",
unavailableBody: "Не удалось извлечь текст страницы. Ссылка на источник сохранена, позже можно повторить.",
fallbackTitle: "Вырезка страницы",
imageAlt: "Изображение",
separator: ": ",
},
};
const URL_PATTERN = /https?:\/\/[^\s<>"'))]+/i;
+2 -2
View File
@@ -14,7 +14,7 @@ const LOCALE_PREFERENCE_KEY = "edgeever.mobile.localePreference";
const THEME_PREFERENCE_KEY = "edgeever.mobile.themePreference";
export type MobileMemoListDensity = "preview" | "compact";
export type MobileLocalePreference = "system" | "zh-CN" | "en-US" | "ja" | "pl";
export type MobileLocalePreference = "system" | "zh-CN" | "en-US" | "ja" | "pl" | "ru";
export type MobileThemePreference = "system" | "light" | "dark";
export const readMobileMemoListDensity = async (): Promise<MobileMemoListDensity> => {
@@ -70,7 +70,7 @@ export const readMobileThemePreference = async (): Promise<MobileThemePreference
export const writeMobileThemePreference = (theme: MobileThemePreference) => AsyncStorage.setItem(THEME_PREFERENCE_KEY, theme);
const isMobileLocalePreference = (value: unknown): value is MobileLocalePreference =>
value === "system" || value === "zh-CN" || value === "en-US" || value === "ja" || value === "pl";
value === "system" || value === "zh-CN" || value === "en-US" || value === "ja" || value === "pl" || value === "ru";
export const readMobileAiAssistantLastAction = async (
scope: AiAssistantLastActionScope,
+1 -1
View File
@@ -123,7 +123,7 @@ const resolveEditableMemoTitle = (title?: string | null) => {
};
const alertProtectedImageLoadFailure = (
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
failure: ProtectedResourceLoadFailure
) => {
const statusLabel = failure.status != null
@@ -165,7 +165,7 @@ const loadAuthenticatedImageDataUrl = (
};
const alertProtectedImageLoadFailure = (
locale: "zh-CN" | "en-US" | "ja" | "pl",
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
failure: ProtectedResourceLoadFailure
) => {
const statusLabel = failure.status != null ? String(failure.status) : locale !== "zh-CN" ? "network error" : "网络错误";
@@ -53,6 +53,7 @@ const MOBILE_LOCALE_OPTIONS: Array<{ label: string; value: MobileLocalePreferenc
{ label: "English", value: "en-US" },
{ label: "日本語", value: "ja" },
{ label: "Polski", value: "pl" },
{ label: "Русский", value: "ru" },
];
type SettingsTab = "general" | "account" | "system";
export type MobileLocaleMode = MobileLocalePreference;
@@ -65,6 +65,7 @@ describe("mobile workspace utilities", () => {
expect(getResolvedMobileLocale("zh-CN")).toBe("zh-CN");
expect(getResolvedMobileLocale("en-US")).toBe("en-US");
expect(getResolvedMobileLocale("pl")).toBe("pl");
expect(getResolvedMobileLocale("ru")).toBe("ru");
expect(isEnglishMobileLocale("zh-CN")).toBe(false);
expect(isEnglishMobileLocale("en-US")).toBe(true);
});
@@ -85,6 +86,9 @@ describe("mobile workspace utilities", () => {
expect(isEnglishMobileLocale("system")).toBe(true);
withLocale("pl");
expect(getResolvedMobileLocale("system")).toBe("pl");
withLocale("ru-RU");
expect(getResolvedMobileLocale("system")).toBe("ru");
expect(isEnglishMobileLocale("system")).toBe(true);
withLocale("fr-FR");
expect(getResolvedMobileLocale("system")).toBe("en-US");
expect(isEnglishMobileLocale("system")).toBe(true);
@@ -144,6 +144,7 @@ export const formatMemoPreviewDate = (value: string, localePreference: MobileLoc
if (memoDay === today - 24 * 60 * 60 * 1000) {
if (locale === "ja") return "昨日";
if (locale === "pl") return "Wczoraj";
if (locale === "ru") return "Вчера";
if (locale === "en-US") return "Yesterday";
return "昨天";
}
@@ -15,13 +15,16 @@ import {
Graph.registerConnector(MIND_MAP_CONNECTOR_NAME, mindMapConnector, true);
const diagramTitle = (diagram: DiagramDocument, locale: "zh-CN" | "en-US" | "ja" | "pl") => {
const diagramTitle = (diagram: DiagramDocument, locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru") => {
if (locale === "en-US") {
return diagram.kind === "mind-map" ? "Mind map" : diagram.kind === "architecture" ? "Architecture diagram" : "Flowchart";
}
if (locale === "pl") {
return diagram.kind === "mind-map" ? "Mapa myśli" : diagram.kind === "architecture" ? "Diagram architektury" : "Schemat blokowy";
}
if (locale === "ru") {
return diagram.kind === "mind-map" ? "Ментальная карта" : diagram.kind === "architecture" ? "Схема архитектуры" : "Блок-схема";
}
return diagram.kind === "mind-map" ? "思维导图" : diagram.kind === "architecture" ? "架构图" : "流程图";
};
@@ -32,7 +35,7 @@ export const ReadOnlyX6Diagram = ({
controls = false,
}: {
diagram: DiagramDocument;
locale: "zh-CN" | "en-US" | "ja" | "pl";
locale: "zh-CN" | "en-US" | "ja" | "pl" | "ru";
theme: "light" | "dark";
controls?: boolean;
}) => {
+7 -1
View File
@@ -23,7 +23,7 @@ describe("lazy Japanese catalog", () => {
expect(viteConfig).toContain("globPatterns: []");
expect(viteConfig).toContain("name: \"i18n-ja\"");
expect(viteConfig).toContain("edgeever-optional-locales");
expect(viteConfig).toContain("i18n-(?:ja|pl)-");
expect(viteConfig).toContain("i18n-(?:ja|pl|ru)-");
});
test("loads Polish on demand like Japanese", () => {
@@ -31,4 +31,10 @@ describe("lazy Japanese catalog", () => {
expect(source).toContain('await import("./resources/pl")');
expect(viteConfig).toContain("name: \"i18n-pl\"");
});
test("loads Russian on demand like Polish", () => {
expect(source).not.toContain('import { ru } from "./resources/ru"');
expect(source).toContain('await import("./resources/ru")');
expect(viteConfig).toContain("name: \"i18n-ru\"");
});
});
+6 -2
View File
@@ -30,13 +30,16 @@ export const resources = {
type OptionalLocale = Exclude<SupportedLocale, keyof typeof resources>;
const isOptionalLocale = (locale: SupportedLocale): locale is OptionalLocale =>
locale === "ja" || locale === "pl";
locale === "ja" || locale === "pl" || locale === "ru";
const loadOptionalCatalog = async (locale: OptionalLocale) => {
if (locale === "ja") {
return (await import("./resources/ja")).ja;
}
return (await import("./resources/pl")).pl;
if (locale === "pl") {
return (await import("./resources/pl")).pl;
}
return (await import("./resources/ru")).ru;
};
const ensureLocaleCatalog = async (locale: SupportedLocale) => {
@@ -53,6 +56,7 @@ void i18n.use(initReactI18next).init({
fallbackLng: {
ja: ["en-US"],
pl: ["en-US"],
ru: ["en-US"],
default: [defaultLocale],
},
supportedLngs: supportedLocales,
+5
View File
@@ -46,6 +46,11 @@ describe("web locale resolution", () => {
expect(getBrowserLocale()).toBe("pl");
});
test("keeps Russian browser languages on the shipped ru locale", () => {
setNavigatorLanguages(["ru-RU", "en-US"]);
expect(getBrowserLocale()).toBe("ru");
});
test("falls unmatched browser languages back to English instead of Chinese", () => {
setNavigatorLanguages(["fr-FR", "de-DE"]);
expect(getBrowserLocale()).toBe("en-US");
+1
View File
@@ -21,6 +21,7 @@ export const localeLabels: Record<SupportedLocale, string> = {
"en-US": "English",
ja: "日本語",
pl: "Polski",
ru: "Русский",
};
export const normalizeLocale = (locale: string | null | undefined): SupportedLocale | null =>
+1
View File
@@ -0,0 +1 @@
export { ru } from "@edgeever/shared/i18n/ru";
+7 -2
View File
@@ -223,7 +223,7 @@ export default defineConfig({
},
},
{
urlPattern: ({ url }) => /\/assets\/i18n-(?:ja|pl)-/.test(url.pathname),
urlPattern: ({ url }) => /\/assets\/i18n-(?:ja|pl|ru)-/.test(url.pathname),
handler: "CacheFirst",
options: {
cacheName: "edgeever-optional-locales",
@@ -292,7 +292,7 @@ export default defineConfig({
? false
: {
resolveDependencies: (_filename, dependencies) => dependencies.filter((dependency) =>
!/(?:vendor-code-highlight|vendor-(?:mermaid|D3|tiptap|prosemirror|floating|codemirror|x6|infographic|zod|streamdown)|vendor~(?:wasm|emacs-lisp)-|vendor-radix(?!-slot)|ui-primitives|ui-button-tooltip|i18n-(?:ja|pl)-)/.test(dependency),
!/(?:vendor-code-highlight|vendor-(?:mermaid|D3|tiptap|prosemirror|floating|codemirror|x6|infographic|zod|streamdown)|vendor~(?:wasm|emacs-lisp)-|vendor-radix(?!-slot)|ui-primitives|ui-button-tooltip|i18n-(?:ja|pl|ru)-)/.test(dependency),
),
},
rolldownOptions: {
@@ -347,6 +347,11 @@ export default defineConfig({
test: /[\\/]i18n[\\/](?:resources[\\/])?pl\.ts$/,
priority: 41,
},
{
name: "i18n-ru",
test: /[\\/]i18n[\\/](?:resources[\\/])?ru\.ts$/,
priority: 41,
},
{
name: "vendor-react",
test: /node_modules[\\/](react|react-dom|scheduler|react-router)[\\/]/,
+1
View File
@@ -20,6 +20,7 @@
"./i18n/zh-CN": "./src/i18n/zh-CN.ts",
"./i18n/ja": "./src/i18n/ja.ts",
"./i18n/pl": "./src/i18n/pl.ts",
"./i18n/ru": "./src/i18n/ru.ts",
"./i18n/locales": "./src/i18n/locales.ts",
"./memo-template-seeds": "./src/memo-template-seeds.ts",
"./default-workspace-seeds": "./src/default-workspace-seeds.ts",
+1
View File
@@ -111,6 +111,7 @@ export const getDefaultAiTargetLanguage = (locale: string | undefined): AiTarget
if (normalized.startsWith("zh")) return "en";
if (normalized.startsWith("ja")) return "ja";
if (normalized.startsWith("pl")) return "en";
if (normalized.startsWith("ru")) return "en";
return "zh-CN";
};
+40 -2
View File
@@ -6,7 +6,7 @@ import type {
import { resolveSupportedLocale } from "./i18n/locales";
export type AiPromptSeedKey = Exclude<AiAction, "custom">;
export type AiPromptSeedLocale = "zh-CN" | "en-US" | "ja" | "pl";
export type AiPromptSeedLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
export type AiPromptSeedTranslation = {
name: string;
@@ -29,10 +29,11 @@ const seed = (
enUS: AiPromptSeedTranslation,
ja: AiPromptSeedTranslation,
pl: AiPromptSeedTranslation,
ru: AiPromptSeedTranslation,
): AiPromptSeed => ({
...metadata,
...zhCN,
translations: { "zh-CN": zhCN, "en-US": enUS, ja, pl },
translations: { "zh-CN": zhCN, "en-US": enUS, ja, pl, ru },
});
export const normalizeAiPromptSeedLocale = (locale: string | null | undefined): AiPromptSeedLocale =>
@@ -110,6 +111,18 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
"Zachowaj język notatki i zwróć wyłącznie podsumowanie w formacie Markdown.",
].join(""),
},
{
name: "Кратко",
description: "Сжать заметку до темы, выводов и действий",
instruction: [
"Сделайте настоящее краткое изложение заметки, а не пересказ предложения за предложением и не повтор текста. ",
"Выделите главную тему, основные мысли, ключевые выводы и действия. ",
"Опустите повторы, риторику, примеры, цитаты и второстепенные детали, если без них вывод всё равно понятен. ",
"Для длинной заметки ориентируйтесь примерно на 20–30% исходного объёма и 3–7 коротких пунктов Markdown; для короткой хватит 1–3 предложений. ",
"Не копируйте длинные фрагменты дословно и не добавляйте факты, которых нет в тексте. ",
"Сохраните язык заметки и верните только краткое изложение в Markdown.",
].join(""),
},
),
seed(
{ key: "translate", action: "translate", parameterKind: "target-language", resultMode: "both" },
@@ -133,6 +146,11 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
description: "Przetłumacz na wybrany język, zachowując formatowanie",
instruction: "Przetłumacz całą notatkę na język docelowy wskazany przez użytkownika. Zachowaj jej znaczenie, strukturę Markdown, linki i bloki kodu. Zwróć wyłącznie przetłumaczoną notatkę, bez komentarzy.",
},
{
name: "Перевести",
description: "Перевести на выбранный язык, сохранив оформление",
instruction: "Переведите заметку целиком на язык, который указал пользователь. Сохраните смысл, структуру Markdown, ссылки и блоки кода. Верните только перевод, без комментариев.",
},
),
seed(
{ key: "improve-writing", action: "improve-writing", parameterKind: "none", resultMode: "both" },
@@ -156,6 +174,11 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
description: "Popraw język oraz zwiększ przejrzystość i płynność tekstu",
instruction: "Dopracuj treść: popraw pisownię, gramatykę i interpunkcję oraz ulepsz dobór słów, budowę zdań, przejrzystość i płynność. Nie zmieniaj jej znaczenia ani celowo jej nie skracaj. Zachowaj jej język i przydatne formatowanie Markdown. Zwróć wyłącznie dopracowaną treść.",
},
{
name: "Улучшить текст",
description: "Исправить язык и сделать текст яснее и плавнее",
instruction: "Улучшите текст: исправьте орфографию, грамматику и пунктуацию, подберите слова, структуру фраз, ясность и плавность. Не меняйте смысл и не сокращайте текст нарочно. Сохраните язык и полезное оформление Markdown. Верните только улучшенный текст.",
},
),
seed(
{ key: "make-shorter", action: "make-shorter", parameterKind: "none", resultMode: "both" },
@@ -179,6 +202,11 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
description: "Usuń powtórzenia i napisz tekst zwięźle i konkretnie",
instruction: "Dopracuj treść, usuwając powtórzenia, puste frazy i zbędne określenia oraz łącząc zdania tam, gdzie to pomaga. Spraw, by tekst był zwięzły, jasny i konkretny, zachowując każdy kluczowy fakt, tezę i pierwotne znaczenie. Nie dodawaj nowych informacji. Zachowaj jej język i przydatne formatowanie Markdown. Zwróć wyłącznie dopracowaną treść.",
},
{
name: "Короче и яснее",
description: "Убрать повторы и написать короче и прямее",
instruction: "Сократите текст: уберите повторы, пустые фразы и лишние определения, объедините предложения, где это уместно. Сделайте его коротким, ясным и прямым, сохранив каждый важный факт, мысль и исходный смысл. Не добавляйте новой информации. Сохраните язык и полезное оформление Markdown. Верните только сокращённый текст.",
},
),
seed(
{ key: "extract-todos", action: "extract-todos", parameterKind: "none", resultMode: "append" },
@@ -202,6 +230,11 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
description: "Znajdź działania do wykonania i utwórz listę zadań",
instruction: "Wyodrębnij z notatki jawne lub domniemane zadania do wykonania jako listę zadań Markdown z użyciem '- [ ]'. Zachowaj jej język i nie wymyślaj zadań. Jeśli nie ma zadań do wykonania, napisz to krótko w języku notatki.",
},
{
name: "Выделить задачи",
description: "Найти дела и составить список задач",
instruction: "Выделите из заметки явные и подразумеваемые дела и оформите их списком задач Markdown через '- [ ]'. Сохраните язык заметки и не придумывайте задачи. Если дел нет, коротко напишите об этом на языке заметки.",
},
),
seed(
{ key: "continue-writing", action: "continue-writing", parameterKind: "none", resultMode: "append" },
@@ -225,6 +258,11 @@ export const DEFAULT_AI_PROMPT_SEEDS: readonly AiPromptSeed[] = [
description: "Kontynuuj naturalnie od końca notatki",
instruction: "Kontynuuj pisanie naturalnie od miejsca, w którym kończy się notatka. Zwróć wyłącznie nową kontynuację, bez oryginalnej treści. Zachowaj jej język i styl Markdown.",
},
{
name: "Продолжить текст",
description: "Продолжить естественно с конца заметки",
instruction: "Продолжите текст естественно с того места, где заметка заканчивается. Верните только продолжение, без исходного текста. Сохраните язык и стиль Markdown.",
},
),
];
+12 -5
View File
@@ -16,35 +16,35 @@ export const DEFAULT_NOTEBOOK_SEEDS: readonly DefaultNotebookSeed[] = [
slug: "inbox",
color: "#0f766e",
sortOrder: 10,
names: { "zh-CN": "等待分类", "en-US": "Inbox", ja: "受信トレイ", pl: "Skrzynka" },
names: { "zh-CN": "等待分类", "en-US": "Inbox", ja: "受信トレイ", pl: "Skrzynka", ru: "Входящие" },
},
{
key: "projects",
slug: "work-projects",
color: "#2563eb",
sortOrder: 20,
names: { "zh-CN": "工作项目", "en-US": "Work Projects", ja: "仕事のプロジェクト", pl: "Projekty zawodowe" },
names: { "zh-CN": "工作项目", "en-US": "Work Projects", ja: "仕事のプロジェクト", pl: "Projekty zawodowe", ru: "Рабочие проекты" },
},
{
key: "learning",
slug: "learning-resources",
color: "#7c3aed",
sortOrder: 30,
names: { "zh-CN": "学习资料", "en-US": "Learning Resources", ja: "学習資料", pl: "Materiały do nauki" },
names: { "zh-CN": "学习资料", "en-US": "Learning Resources", ja: "学習資料", pl: "Materiały do nauki", ru: "Учебные материалы" },
},
{
key: "creative",
slug: "creative-ideas",
color: "#db2777",
sortOrder: 40,
names: { "zh-CN": "灵感创作", "en-US": "Creative Ideas", ja: "アイデアと創作", pl: "Pomysły i twórczość" },
names: { "zh-CN": "灵感创作", "en-US": "Creative Ideas", ja: "アイデアと創作", pl: "Pomysły i twórczość", ru: "Творческие идеи" },
},
{
key: "personal",
slug: "personal-life",
color: "#ea580c",
sortOrder: 50,
names: { "zh-CN": "生活个人", "en-US": "Personal Life", ja: "暮らしと個人", pl: "Życie prywatne" },
names: { "zh-CN": "生活个人", "en-US": "Personal Life", ja: "暮らしと個人", pl: "Życie prywatne", ru: "Личная жизнь" },
},
];
@@ -101,6 +101,13 @@ export const WELCOME_MEMO_TRANSLATIONS: Record<SupportedLocale, WelcomeMemoTrans
"Teraz możesz tworzyć notatniki, pisać notatki, przeszukiwać treści i scalać kilka notatek w jedną nową, trwałą notatkę.",
],
},
ru: {
title: "Добро пожаловать в EdgeEver",
paragraphs: [
"Это первая заметка в EdgeEver: три панели, работа на краю сети и готовность к агентам ИИ.",
"Дальше можно создавать блокноты, писать заметки, искать по содержимому и объединять несколько заметок в одну новую долгосрочную.",
],
},
};
export type WelcomeMemoContent = {
+4 -1
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
import { enUS } from "./en-US.ts";
import { ja } from "./ja.ts";
import { pl } from "./pl.ts";
import { ru } from "./ru.ts";
import { zhCN } from "./zh-CN.ts";
import { supportedLocales } from "./locales.ts";
@@ -20,12 +21,14 @@ const collectKeys = (value, prefix = "") => {
describe("shared i18n catalogs", () => {
test("ships Japanese alongside Chinese and English with matching keys", () => {
expect(supportedLocales).toEqual(["zh-CN", "en-US", "ja", "pl"]);
expect(supportedLocales).toEqual(["zh-CN", "en-US", "ja", "pl", "ru"]);
expect(collectKeys(ja)).toEqual(collectKeys(enUS));
expect(collectKeys(ja)).toEqual(collectKeys(zhCN));
expect(ja.login.title).toContain("サインイン");
expect(ja.editor.imageScale).toContain("画像");
expect(collectKeys(pl)).toEqual(collectKeys(enUS));
expect(pl.login.title).toContain("Zaloguj");
expect(collectKeys(ru)).toEqual(collectKeys(enUS));
expect(ru.login.title).toContain("Вход");
});
});
+1
View File
@@ -2,6 +2,7 @@ export { enUS } from "./en-US";
export { zhCN } from "./zh-CN";
export { ja } from "./ja";
export { pl } from "./pl";
export { ru } from "./ru";
export {
defaultLocale,
matchSupportedLocale,
@@ -23,6 +23,9 @@ describe("supported locale matching", () => {
expect(matchSupportedLocale("pl")).toBe("pl");
expect(matchSupportedLocale("pl-PL")).toBe("pl");
expect(matchSupportedLocale("pl_PL")).toBe("pl");
expect(matchSupportedLocale("ru")).toBe("ru");
expect(matchSupportedLocale("ru-RU")).toBe("ru");
expect(matchSupportedLocale("ru_RU")).toBe("ru");
});
test("does not treat unrelated tags as a shipped locale", () => {
+5 -1
View File
@@ -1,4 +1,4 @@
export const supportedLocales = ["zh-CN", "en-US", "ja", "pl"] as const;
export const supportedLocales = ["zh-CN", "en-US", "ja", "pl", "ru"] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
@@ -31,6 +31,10 @@ export const matchSupportedLocale = (locale: string | null | undefined): Support
return "pl";
}
if (normalized === "ru" || normalized.startsWith("ru-")) {
return "ru";
}
return null;
};
File diff suppressed because it is too large. Load diff
+28 -2
View File
@@ -1,6 +1,6 @@
import { resolveSupportedLocale } from "./i18n/locales";
export type MemoTemplateSeedLocale = "zh-CN" | "en-US" | "ja" | "pl";
export type MemoTemplateSeedLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
export type MemoTemplateSeedTranslation = {
title: string;
@@ -21,9 +21,10 @@ const seed = (
enUS: MemoTemplateSeedTranslation,
ja: MemoTemplateSeedTranslation,
pl: MemoTemplateSeedTranslation,
ru: MemoTemplateSeedTranslation,
): MemoTemplateSeed => ({
...metadata,
translations: { "zh-CN": zhCN, "en-US": enUS, ja, pl },
translations: { "zh-CN": zhCN, "en-US": enUS, ja, pl, ru },
});
export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
@@ -49,6 +50,11 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
description: "Zapisuj ulotne myśli, pomysły, linki i działania do natychmiastowego podjęcia.",
contentMarkdown: "## 💡 Ulotne myśli\n\n- \n\n## 📌 Kontekst i uwagi\n\n\n\n## 🚀 Kolejne kroki\n\n- [ ] ",
},
{
title: "Быстрая заметка",
description: "Записывайте мимолётные мысли, идеи, ссылки и ближайшие действия.",
contentMarkdown: "## 💡 Мимолётные мысли\n\n- \n\n## 📌 Контекст и заметки\n\n\n\n## 🚀 Дальше\n\n- [ ] ",
},
),
seed(
{ key: "meeting", translationKey: "meeting", tag: "meeting" },
@@ -72,6 +78,11 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
description: "Uporządkowany zapis agendy, kluczowych decyzji i zadań z osobami odpowiedzialnymi.",
contentMarkdown: "# 📝 Notatka ze spotkania\n\n- **Termin**:\n- **Prowadzący/protokolant**:\n- **Uczestnicy**:\n\n---\n\n## 🎯 Cel\n\n- \n\n## 💬 Dyskusja i decyzje\n\n1. **[Temat 1]**\n - Najważniejsze punkty:\n - ✅ **Decyzja**:\n\n2. **[Temat 2]**\n - Najważniejsze punkty:\n - ✅ **Decyzja**:\n\n## 📋 Zadania do wykonania\n\n- [ ] **[Osoba odpowiedzialna]** Opis zadania (termin: MM-DD)\n- [ ] **[Osoba odpowiedzialna]** Opis zadania (termin: MM-DD)\n",
},
{
title: "Протокол встречи",
description: "Структурированная запись повестки, решений и задач с ответственными.",
contentMarkdown: "# 📝 Протокол встречи\n\n- **Время**:\n- **Ведущий/запись**:\n- **Участники**:\n\n---\n\n## 🎯 Цель\n\n- \n\n## 💬 Обсуждение и решения\n\n1. **[Тема 1]**\n - Главное:\n - ✅ **Решение**:\n\n2. **[Тема 2]**\n - Главное:\n - ✅ **Решение**:\n\n## 📋 Задачи\n\n- [ ] **[Ответственный]** Описание задачи (срок: ММ-ДД)\n- [ ] **[Ответственный]** Описание задачи (срок: ММ-ДД)\n",
},
),
seed(
{ key: "weekly-review", translationKey: "weeklyReview", tag: "weekly-review" },
@@ -95,6 +106,11 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
description: "Podsumuj najważniejsze osiągnięcia tygodnia, blokery i kluczowe priorytety na kolejny tydzień.",
contentMarkdown: "# 🗓️ Tygodniowy raport postępów\n\n## 🌟 Najważniejsze osiągnięcia\n\n- [x] **[Projekt/funkcja]** Szczegóły osiągnięcia\n- [x] **[Projekt/funkcja]** Szczegóły osiągnięcia\n\n## 🚧 Blokery i ryzyka\n\n- ⚠️ **Bloker**: Przyczyna i potrzebne wsparcie\n\n## 🎯 Priorytety na kolejny tydzień\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Refleksje i wnioski\n\n- \n",
},
{
title: "Итоги недели",
description: "Главное за неделю, препятствия и приоритеты на следующую.",
contentMarkdown: "# 🗓️ Отчёт за неделю\n\n## 🌟 Главное\n\n- [x] **[Проект/функция]** Что сделано\n- [x] **[Проект/функция]** Что сделано\n\n## 🚧 Препятствия и риски\n\n- ⚠️ **Препятствие**: причина и какая помощь нужна\n\n## 🎯 Приоритеты на следующую неделю\n\n- [ ] \n- [ ] \n- [ ] \n\n## 💡 Выводы\n\n- \n",
},
),
seed(
{ key: "reading", translationKey: "reading", tag: "reading" },
@@ -118,6 +134,11 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
description: "Wyodrębnij kluczowe wnioski, cytaty, własne refleksje i powiązane zagadnienia.",
contentMarkdown: "# 📖 Karta lektury\n\n- **Książka/artykuł**:\n- **Autor/źródło**:\n- **Ocena**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Najważniejszy wniosek\n\n> \n\n## ✍️ Najciekawsze fragmenty i cytaty\n\n> [Treść cytatu]\n> —— *Źródło*\n\n## 🧠 Własne refleksje\n\n- \n\n## 🔗 Działanie i praktyka\n\n- [ ] **Plan działania**:\n",
},
{
title: "Карточка чтения",
description: "Главные выводы, цитаты, собственные мысли и связанные идеи.",
contentMarkdown: "# 📖 Карточка чтения\n\n- **Книга/статья**:\n- **Автор/источник**:\n- **Оценка**: ⭐⭐⭐⭐⭐\n\n---\n\n## 💡 Главный вывод\n\n> \n\n## ✍️ Цитаты\n\n> [Текст цитаты]\n> —— *Источник*\n\n## 🧠 Свои мысли\n\n- \n\n## 🔗 Что сделать\n\n- [ ] **План**:\n",
},
),
seed(
{ key: "okr", translationKey: "okr", tag: "okr" },
@@ -141,6 +162,11 @@ export const DEFAULT_MEMO_TEMPLATE_SEEDS: readonly MemoTemplateSeed[] = [
description: "Określ cele OKR, kluczowe rezultaty, kamienie milowe i listy zadań.",
contentMarkdown: "# 🎯 Podział celu\n\n- **Okres**:\n- **Osoba odpowiedzialna**:\n\n---\n\n## 📌 Cel (Objective)\n\n> \n\n## 📈 Kluczowe rezultaty (Key Results)\n\n- **KR 1**: Docelowy wskaźnik -> Obecny postęp\n- **KR 2**: Docelowy wskaźnik -> Obecny postęp\n\n## 🗓️ Kamienie milowe\n\n- [ ] **Etap 1 (data)**: Cel etapu\n- [ ] **Etap 2 (data)**: Cel etapu\n\n## 📋 Lista zadań do wykonania\n\n- [ ] \n- [ ] \n",
},
{
title: "Цели и разбиение задач",
description: "Цели OKR, ключевые результаты, вехи и список дел.",
contentMarkdown: "# 🎯 Разбор цели\n\n- **Период**:\n- **Ответственный**:\n\n---\n\n## 📌 Цель (Objective)\n\n> \n\n## 📈 Ключевые результаты (Key Results)\n\n- **KR 1**: целевой показатель -> текущий прогресс\n- **KR 2**: целевой показатель -> текущий прогресс\n\n## 🗓️ Вехи\n\n- [ ] **Этап 1 (дата)**: результат этапа\n- [ ] **Этап 2 (дата)**: результат этапа\n\n## 📋 Список дел\n\n- [ ] \n- [ ] \n",
},
),
];
@@ -71,16 +71,17 @@ const isTextNode = (node: TiptapNode | TiptapTextNode): node is TiptapTextNode =
const getOriginalType = (attrs: Record<string, unknown> | undefined) =>
typeof attrs?.originalType === "string" ? attrs.originalType : "unknown";
const getFallbackLabel = (type: string, locale?: "zh-CN" | "en-US" | "ja" | "pl") => {
const getFallbackLabel = (type: string, locale?: "zh-CN" | "en-US" | "ja" | "pl" | "ru") => {
if (locale === "zh-CN") return `暂不支持的内容:${type}`;
if (locale === "ja") return `未対応のコンテンツ: ${type}`;
if (locale === "pl") return `Nieobsługiwana zawartość: ${type}`;
if (locale === "ru") return `Неподдерживаемое содержимое: ${type}`;
return `Unsupported content: ${type}`;
};
const fallbackAttrs = (
node: TiptapNode | TiptapTextNode,
locale?: "zh-CN" | "en-US" | "ja" | "pl",
locale?: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
) => ({
originalType: node.type,
originalJson: JSON.stringify(node),
@@ -228,7 +229,7 @@ const prepareTextNode = (node: TiptapTextNode): TiptapTextNode => {
*/
export const prepareNativeEditorContent = (
doc: TiptapDoc,
locale?: "zh-CN" | "en-US" | "ja" | "pl",
locale?: "zh-CN" | "en-US" | "ja" | "pl" | "ru",
): TiptapDoc => {
const visit = (
node: TiptapNode | TiptapTextNode,
+24 -1
View File
@@ -2,7 +2,7 @@ import type { Editor } from "@tiptap/core";
import { EditorState } from "@tiptap/pm/state";
import type { ImageWidthPresetId } from "./image-display";
export type MobileEditorLocale = "zh-CN" | "en-US" | "ja" | "pl";
export type MobileEditorLocale = "zh-CN" | "en-US" | "ja" | "pl" | "ru";
export type MobileEditorToolbarActionId =
| "undo"
@@ -132,6 +132,29 @@ const MOBILE_EDITOR_COPY = {
full: "Pełna szerokość",
},
},
ru: {
placeholder: "Начните запись...",
toolbar: "Панель редактора",
actions: {
undo: "Отменить",
redo: "Повторить",
image: "Загрузить изображение",
bold: "Полужирный",
bulletList: "Маркированный список",
taskList: "Список задач",
increaseListIndent: "Увеличить уровень списка (Tab)",
decreaseListIndent: "Уменьшить уровень списка (Shift + Tab)",
blockquote: "Цитата",
horizontalRule: "Горизонтальная линия",
},
imageScale: "Размер изображения",
imageSizes: {
small: "Маленький",
medium: "Средний",
large: "Большой",
full: "На всю ширину",
},
},
} as const;
export const getMobileEditorPlaceholder = (locale: MobileEditorLocale): string =>
+2 -1
View File
@@ -9,6 +9,7 @@ import { zhCN } from "./i18n/zh-CN";
import { enUS } from "./i18n/en-US";
import { ja } from "./i18n/ja";
import { pl } from "./i18n/pl";
import { ru } from "./i18n/ru";
import { NOTE_IMAGE_FONT_SUBSETS } from "./note-image-font-manifest";
describe("note image templates", () => {
@@ -44,7 +45,7 @@ describe("note image templates", () => {
for (const theme of NOTE_IMAGE_THEME_OPTIONS) {
expect(NOTE_IMAGE_THEMES[theme]).toBeDefined();
expect(NOTE_IMAGE_BACKGROUND_COLORS[theme]).toMatch(/^#[\da-f]{6}$/i);
for (const locale of [zhCN, enUS, ja, pl]) expect(locale.editor.imageShare.themes[theme]).toBeTruthy();
for (const locale of [zhCN, enUS, ja, pl, ru]) expect(locale.editor.imageShare.themes[theme]).toBeTruthy();
}
for (const retired of ["paper", "press", "highlight", "slate", "mint", "aurora", "sunset", "terminal"]) {
expect(NOTE_IMAGE_THEME_OPTIONS).not.toContain(retired);
+1 -1
View File
@@ -43,7 +43,7 @@ assert.ok(initialModulePreloadBytes <= INITIAL_MODULE_PRELOAD_BUDGET, `Initial m
const startupGraph = readWebStartupGraph(distDirectory);
assert.ok(startupGraph.initialJavaScriptBytes <= 1024 * 1024, `Eager JavaScript exceeds 1 MiB: ${startupGraph.initialJavaScriptBytes}`);
const eagerOptionalPattern = /(?:^|\/)(?:vendor-(?:code-highlight|beautiful-mermaid|mermaid|tiptap|prosemirror|codemirror|x6|infographic|streamdown)|i18n-(?:ja|pl)-|(?:WorkspaceApp|EditorPane|DiagramEditorPane|PosterEditorPane|TableEditorPane|InfographicEditorPane)-)/;
const eagerOptionalPattern = /(?:^|\/)(?:vendor-(?:code-highlight|beautiful-mermaid|mermaid|tiptap|prosemirror|codemirror|x6|infographic|streamdown)|i18n-(?:ja|pl|ru)-|(?:WorkspaceApp|EditorPane|DiagramEditorPane|PosterEditorPane|TableEditorPane|InfographicEditorPane)-)/;
assert.ok(!startupGraph.initialJavaScriptFiles.some(file => eagerOptionalPattern.test(file)), "Optional editors and locales must remain outside the actual startup module graph");
// Optional fonts must remain downloads, even when the desktop installer packages all Web files.