diff --git a/apps/client/package.json b/apps/client/package.json index f3341ba5c..cd1317e2b 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -55,7 +55,7 @@ "mark.js": "8.11.1", "marked": "16.4.2", "mermaid": "11.12.1", - "mind-elixir": "5.3.5", + "mind-elixir": "5.3.6", "normalize.css": "8.0.1", "panzoom": "9.4.3", "preact": "10.27.2", diff --git a/apps/client/src/translations/ro/translation.json b/apps/client/src/translations/ro/translation.json index 0f443bc53..b0e412b35 100644 --- a/apps/client/src/translations/ro/translation.json +++ b/apps/client/src/translations/ro/translation.json @@ -302,7 +302,10 @@ "edit_branch_prefix": "Editează prefixul ramurii", "help_on_tree_prefix": "Informații despre prefixe de ierarhie", "prefix": "Prefix: ", - "save": "Salvează" + "save": "Salvează", + "edit_branch_prefix_multiple": "Editează prefixul pentru {{count}} ramuri", + "branch_prefix_saved_multiple": "Prefixul a fost modificat pentru {{count}} ramuri.", + "affected_branches": "Ramuri afectate ({{count}}):" }, "bulk_actions": { "affected_notes": "Notițe afectate", @@ -537,7 +540,8 @@ "opml_version_1": "OPML v1.0 - text simplu", "opml_version_2": "OPML v2.0 - permite și HTML", "format_html": "HTML - recomandat deoarece păstrează toata formatarea", - "format_pdf": "PDF - cu scopul de printare sau partajare." + "format_pdf": "PDF - cu scopul de printare sau partajare.", + "share-format": "HTML pentru publicare web - folosește aceeași temă pentru notițele partajate, dar se pot publica într-un website static." }, "fast_search": { "description": "Căutarea rapidă dezactivează căutarea la nivel de conținut al notițelor cu scopul de a îmbunătăți performanța de căutare pentru baze de date mari.", @@ -753,7 +757,8 @@ "placeholder": "Introduceți etichetele HTML, câte unul pe linie", "reset_button": "Resetează la lista implicită", "title": "Etichete HTML la importare" - } + }, + "importZipRecommendation": "Când importați un fișier ZIP, ierarhia notițelor va reflecta structura subdirectoarelor din arhivă." }, "include_archived_notes": { "include_archived_notes": "Include notițele arhivate" @@ -799,7 +804,8 @@ "default_description": "În mod implicit Trilium limitează lățimea conținutului pentru a îmbunătăți lizibilitatea pentru ferestrele maximizate pe ecrane late.", "max_width_label": "Lungimea maximă a conținutului", "max_width_unit": "pixeli", - "title": "Lățime conținut" + "title": "Lățime conținut", + "centerContent": "Centrează conținutul" }, "mobile_detail_menu": { "delete_this_note": "Șterge această notiță", @@ -856,7 +862,8 @@ "convert_into_attachment_failed": "Nu s-a putut converti notița „{{title}}”.", "convert_into_attachment_successful": "Notița „{{title}}” a fost convertită în atașament.", "convert_into_attachment_prompt": "Doriți convertirea notiței „{{title}}” într-un atașament al notiței părinte?", - "print_pdf": "Exportare ca PDF..." + "print_pdf": "Exportare ca PDF...", + "open_note_on_server": "Deschide notița pe server" }, "note_erasure_timeout": { "deleted_notes_erased": "Notițele șterse au fost eliminate permanent.", @@ -1246,11 +1253,11 @@ "timeout_unit": "milisecunde" }, "table_of_contents": { - "description": "Tabela de conținut va apărea în notițele de tip text atunci când notița are un număr de titluri mai mare decât cel definit. Acest număr se poate personaliza:", + "description": "Cuprinsul va apărea în notițele de tip text atunci când notița are un număr de titluri mai mare decât cel definit. Acest număr se poate personaliza:", "unit": "titluri", - "disable_info": "De asemenea se poate dezactiva tabela de conținut setând o valoare foarte mare.", - "shortcut_info": "Se poate configura și o scurtatură pentru a comuta rapid vizibilitatea panoului din dreapta (inclusiv tabela de conținut) în Opțiuni -> Scurtături (denumirea „toggleRightPane”).", - "title": "Tabelă de conținut" + "disable_info": "De asemenea se poate dezactiva cuprinsul setând o valoare foarte mare.", + "shortcut_info": "Se poate configura și o scurtatură pentru a comuta rapid vizibilitatea panoului din dreapta (inclusiv cuprinsul) în Opțiuni -> Scurtături (denumirea „toggleRightPane”).", + "title": "Cuprins" }, "text_auto_read_only_size": { "description": "Marchează pragul în care o notiță de o anumită dimensiune va fi afișată în mod de citire (pentru motive de performanță).", @@ -1503,7 +1510,9 @@ "window-on-top": "Menține fereastra mereu vizibilă" }, "note_detail": { - "could_not_find_typewidget": "Nu s-a putut găsi widget-ul corespunzător tipului „{{type}}”" + "could_not_find_typewidget": "Nu s-a putut găsi widget-ul corespunzător tipului „{{type}}”", + "printing": "Imprimare în curs...", + "printing_pdf": "Exportare ca PDF în curs..." }, "note_title": { "placeholder": "introduceți titlul notiței aici..." @@ -2014,7 +2023,8 @@ "new-item-placeholder": "Introduceți titlul notiței...", "add-column-placeholder": "Introduceți denumirea coloanei...", "edit-note-title": "Clic pentru a edita titlul notiței", - "edit-column-title": "Clic pentru a edita titlul coloanei" + "edit-column-title": "Clic pentru a edita titlul coloanei", + "column-already-exists": "Această coloană deja există." }, "command_palette": { "tree-action-name": "Listă de notițe: {{name}}", @@ -2076,5 +2086,14 @@ "edit-slide": "Editați acest slide", "start-presentation": "Începeți prezentarea", "slide-overview": "Afișați o imagine de ansamblu a slide-urilor" + }, + "read-only-info": { + "read-only-note": "Vizualizați o notiță în modul doar în citire.", + "auto-read-only-note": "Această notiță este afișată în modul doar în citire din motive de performanță.", + "auto-read-only-learn-more": "Mai multe detalii", + "edit-note": "Editează notița" + }, + "calendar_view": { + "delete_note": "Șterge notița..." } } diff --git a/apps/client/src/widgets/collections/calendar/index.tsx b/apps/client/src/widgets/collections/calendar/index.tsx index 6cae60f26..bac6862b2 100644 --- a/apps/client/src/widgets/collections/calendar/index.tsx +++ b/apps/client/src/widgets/collections/calendar/index.tsx @@ -91,6 +91,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps setCalendarView(initialView.current)); useResizeObserver(containerRef, () => calendarRef.current?.updateSize()); @@ -134,6 +135,7 @@ export default function CalendarView({ note, noteIds }: ViewModeProps void; } +const LOCALE_MAPPINGS: Record = { + ar: null, + cn: "zh_CN", + de: null, + en: "en", + en_rtl: "en", + es: "es", + fr: "fr", + it: "it", + ja: "ja", + pt: "pt", + pt_br: "pt", + ro: null, + ru: "ru", + tw: "zh_TW", + uk: null +}; + export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { const apiRef = useRef(null); const containerRef = useRef(null); @@ -110,12 +129,14 @@ export default function MindMap({ note, ntxId, noteContext }: TypeWidgetProps) { function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef: externalApiRef, onChange, editable }: MindElixirProps) { const containerRef = useSyncedRef(externalContainerRef, null); const apiRef = useRef(null); + const [ locale ] = useTriliumOption("locale"); function reinitialize() { if (!containerRef.current) return; const mind = new VanillaMindElixir({ el: containerRef.current, + locale: LOCALE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined, editable }); @@ -143,7 +164,7 @@ function MindElixir({ containerRef: externalContainerRef, containerProps, apiRef if (data) { apiRef.current?.init(data); } - }, [ editable ]); + }, [ editable, locale ]); // On change listener. useEffect(() => { diff --git a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx index 79f7f3795..6a5ea9377 100644 --- a/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx +++ b/apps/client/src/widgets/type_widgets/canvas/Canvas.tsx @@ -1,7 +1,7 @@ import { Excalidraw } from "@excalidraw/excalidraw"; import { TypeWidgetProps } from "../type_widget"; import "@excalidraw/excalidraw/index.css"; -import { useNoteLabelBoolean } from "../../react/hooks"; +import { useNoteLabelBoolean, useTriliumOption } from "../../react/hooks"; import { useCallback, useMemo, useRef } from "preact/hooks"; import { type ExcalidrawImperativeAPI, type AppState } from "@excalidraw/excalidraw/types"; import options from "../../../services/options"; @@ -9,6 +9,8 @@ import "./Canvas.css"; import { NonDeletedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import { goToLinkExt } from "../../../services/link"; import useCanvasPersistence from "./persistence"; +import { LANGUAGE_MAPPINGS } from "./i18n"; +import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; // currently required by excalidraw, in order to allows self-hosting fonts locally. // this avoids making excalidraw load the fonts from an external CDN. @@ -21,6 +23,7 @@ export default function Canvas({ note, noteContext }: TypeWidgetProps) { const documentStyle = window.getComputedStyle(document.documentElement); return documentStyle.getPropertyValue("--theme-style")?.trim() as AppState["theme"]; }, []); + const [ locale ] = useTriliumOption("locale"); const persistence = useCanvasPersistence(note, noteContext, apiRef, themeStyle, isReadOnly); /** Use excalidraw's native zoom instead of the global zoom. */ @@ -58,6 +61,7 @@ export default function Canvas({ note, noteContext }: TypeWidgetProps) { detectScroll={false} handleKeyboardGlobally={false} autoFocus={false} + langCode={LANGUAGE_MAPPINGS[locale as DISPLAYABLE_LOCALE_IDS] ?? undefined} UIOptions={{ canvasActions: { saveToActiveFile: false, diff --git a/apps/client/src/widgets/type_widgets/canvas/i18n.spec.ts b/apps/client/src/widgets/type_widgets/canvas/i18n.spec.ts new file mode 100644 index 000000000..71eb3d18c --- /dev/null +++ b/apps/client/src/widgets/type_widgets/canvas/i18n.spec.ts @@ -0,0 +1,29 @@ +import { LOCALES } from "@triliumnext/commons"; +import { readdirSync } from "fs"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import { LANGUAGE_MAPPINGS } from "./i18n.js"; + +const localeDir = join(__dirname, "../../../../../../node_modules/@excalidraw/excalidraw/dist/prod/locales"); + +describe("Canvas i18n", () => { + it("all languages are mapped correctly", () => { + // Read the node_modules dir to obtain all the supported locales. + const supportedLanguageCodes = new Set(); + for (const file of readdirSync(localeDir)) { + if (file.startsWith("percentages")) continue; + const match = file.match("^[a-z]{2,3}(?:-[A-Z]{2,3})?"); + if (!match) continue; + supportedLanguageCodes.add(match[0]); + } + + // Cross-check the locales. + for (const locale of LOCALES) { + if (locale.contentOnly || locale.devOnly) continue; + const languageCode = LANGUAGE_MAPPINGS[locale.id]; + if (!supportedLanguageCodes.has(languageCode)) { + expect.fail(`Unable to find locale for ${locale.id} -> ${languageCode}.`) + } + } + }); +}); diff --git a/apps/client/src/widgets/type_widgets/canvas/i18n.ts b/apps/client/src/widgets/type_widgets/canvas/i18n.ts new file mode 100644 index 000000000..43ee724cf --- /dev/null +++ b/apps/client/src/widgets/type_widgets/canvas/i18n.ts @@ -0,0 +1,19 @@ +import type { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; + +export const LANGUAGE_MAPPINGS: Record = { + ar: "ar-SA", + cn: "zh-CN", + de: "de-DE", + en: "en", + en_rtl: "en", + es: "es-ES", + fr: "fr-FR", + it: "it-IT", + ja: "ja-JP", + pt: "pt-PT", + pt_br: "pt-BR", + ro: "ro-RO", + ru: "ru-RU", + tw: "zh-TW", + uk: "uk-UA" +}; diff --git a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx index b7346dd9a..fd6814528 100644 --- a/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx +++ b/apps/client/src/widgets/type_widgets/text/CKEditorWithWatchdog.tsx @@ -1,9 +1,10 @@ import { HTMLProps, RefObject, useEffect, useImperativeHandle, useRef, useState } from "preact/compat"; import { PopupEditor, ClassicEditor, EditorWatchdog, type WatchdogConfig, CKTextEditor, TemplateDefinition } from "@triliumnext/ckeditor5"; import { buildConfig, BuildEditorOptions } from "./config"; -import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef } from "../../react/hooks"; +import { useKeyboardShortcuts, useLegacyImperativeHandlers, useNoteContext, useSyncedRef, useTriliumOption } from "../../react/hooks"; import link from "../../../services/link"; import froca from "../../../services/froca"; +import { DISPLAYABLE_LOCALE_IDS } from "@triliumnext/commons"; export type BoxSize = "small" | "medium" | "full"; @@ -37,6 +38,7 @@ interface CKEditorWithWatchdogProps extends Pick, "cla export default function CKEditorWithWatchdog({ containerRef: externalContainerRef, content, contentLanguage, className, tabIndex, isClassicEditor, watchdogRef: externalWatchdogRef, watchdogConfig, onNotificationWarning, onWatchdogStateChange, onChange, onEditorInitialized, editorApi, templates }: CKEditorWithWatchdogProps) { const containerRef = useSyncedRef(externalContainerRef, null); const watchdogRef = useRef(null); + const [ uiLanguage ] = useTriliumOption("locale"); const [ editor, setEditor ] = useState(); const { parentComponent } = useNoteContext(); @@ -156,6 +158,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe const editor = await buildEditor(container, !!isClassicEditor, { forceGplLicense: false, isClassicEditor: !!isClassicEditor, + uiLanguage: uiLanguage as DISPLAYABLE_LOCALE_IDS, contentLanguage: contentLanguage ?? null, templates }); @@ -180,7 +183,7 @@ export default function CKEditorWithWatchdog({ containerRef: externalContainerRe watchdog.create(container); return () => watchdog.destroy(); - }, [ contentLanguage, templates ]); + }, [ contentLanguage, templates, uiLanguage ]); // React to content changes. useEffect(() => editor?.setData(content ?? ""), [ editor, content ]); diff --git a/apps/client/src/widgets/type_widgets/text/config.spec.ts b/apps/client/src/widgets/type_widgets/text/config.spec.ts new file mode 100644 index 000000000..5e85bab3b --- /dev/null +++ b/apps/client/src/widgets/type_widgets/text/config.spec.ts @@ -0,0 +1,39 @@ +import { DISPLAYABLE_LOCALE_IDS, LOCALES } from "@triliumnext/commons"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock('../../../services/options.js', () => ({ + default: { + get(name: string) { + if (name === "allowedHtmlTags") return "[]"; + return undefined; + }, + getJson: () => [] + } +})); + +describe("CK config", () => { + it("maps all languages correctly", async () => { + const { buildConfig } = await import("./config.js"); + for (const locale of LOCALES) { + if (locale.contentOnly || locale.devOnly) continue; + + const config = await buildConfig({ + uiLanguage: locale.id as DISPLAYABLE_LOCALE_IDS, + contentLanguage: locale.id, + forceGplLicense: false, + isClassicEditor: false, + templates: [] + }); + + let expectedLocale = locale.id.substring(0, 2); + if (expectedLocale === "cn") expectedLocale = "zh"; + if (expectedLocale === "tw") expectedLocale = "zh-tw"; + + if (locale.id !== "en") { + expect((config.language as any).ui).toMatch(new RegExp(`^${expectedLocale}`)); + expect(config.translations, locale.id).toBeDefined(); + expect(config.translations, locale.id).toHaveLength(2); + } + } + }); +}); diff --git a/apps/client/src/widgets/type_widgets/text/config.ts b/apps/client/src/widgets/type_widgets/text/config.ts index 7f39c4ea2..a12d384ef 100644 --- a/apps/client/src/widgets/type_widgets/text/config.ts +++ b/apps/client/src/widgets/type_widgets/text/config.ts @@ -1,5 +1,5 @@ -import { ALLOWED_PROTOCOLS, MIME_TYPE_AUTO } from "@triliumnext/commons"; -import { buildExtraCommands, type EditorConfig, PREMIUM_PLUGINS, TemplateDefinition } from "@triliumnext/ckeditor5"; +import { ALLOWED_PROTOCOLS, DISPLAYABLE_LOCALE_IDS, MIME_TYPE_AUTO } from "@triliumnext/commons"; +import { buildExtraCommands, type EditorConfig, getCkLocale, PREMIUM_PLUGINS, TemplateDefinition } from "@triliumnext/ckeditor5"; import { getHighlightJsNameForMime } from "../../../services/mime_types.js"; import options from "../../../services/options.js"; import { ensureMimeTypesForHighlighting, isSyntaxHighlightEnabled } from "../../../services/syntax_highlight.js"; @@ -17,6 +17,7 @@ export const OPEN_SOURCE_LICENSE_KEY = "GPL"; export interface BuildEditorOptions { forceGplLicense: boolean; isClassicEditor: boolean; + uiLanguage: DISPLAYABLE_LOCALE_IDS; contentLanguage: string | null; templates: TemplateDefinition[]; } @@ -161,9 +162,8 @@ export async function buildConfig(opts: BuildEditorOptions): Promise