diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a82aa7b..ea5de9d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Counted messages now use each language's own plural rules. French and Persian put a count of zero in the plural form. +- Persian now uses the import, export, widget and file terms that shipped + software uses, one word per concept, and no longer leaves "Import" in English + in one message. + - Opening and saving an app no longer rewrites a colour that was not changed. - Admin form toggles now carry accessible names. diff --git a/docs/i18n.md b/docs/i18n.md index dce59925..75a9e80d 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -115,6 +115,24 @@ Terms with a fixed meaning here: | stale | Last reading is old; the service was not reached this time. | | unavailable | The service answered, and it is not working. | +### Persian + +One word per concept, taken from Persian that shipped software actually uses +rather than from the dictionary. Immich, Jellyfin and Uptime Kuma were read for +these; Immich is the closest to this project and ships +`وارد کردن پیکربندی` for "Import configuration" and `خروجی` for "Export". + +| English | Use | Not | +| --- | --- | --- | +| Import | وارد کردن | درون‌ریزی | +| Export | خروجی | برون‌بری | +| Widget | ویجت | ابزارک | +| File | فایل | پرونده | + +`ui/test/i18n-coverage.test.mjs` fails on any term in the "Not" column, in the +dashboard catalogue and in every widget catalogue. Add a row there and here +together when a term is decided. + ## Development locales Two locales exist for testing and are never offered in the selector. Reach them diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 45f1d701..a67cf22c 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -13,8 +13,8 @@ }, "common": { "save": "ذخیره", - "import": "درون‌ریزی", - "export": "برون‌بری", + "import": "وارد کردن", + "export": "خروجی", "add": "+افزودن", "back": "بازگشت", "cancel": "لغو", @@ -55,7 +55,7 @@ "backup": "پشتیبان‌گیری", "importExport": "وارد کردن / خروجی گرفتن", "signOut": "خروج از حساب", - "importForeign": "درون‌ریزی از داشبورد دیگر", + "importForeign": "وارد کردن از داشبورد دیگر", "importForeignTip": "فایل‌های YAML مربوط به gethomepage و Dashy را می‌خواند. پیوندها و پوشه‌ها به داشبورد شما افزوده می‌شوند؛ ویجت‌های داشبورد دیگر به پیوند ساده تبدیل می‌شوند.", "logDebug": "اشکال‌زدایی", "logInfo": "اطلاعات", @@ -108,13 +108,13 @@ "searchPh": "جستجو", "filterAll": "همه", "filterApps": "برنامه‌ها", - "filterWidgets": "ابزارک‌ها", + "filterWidgets": "ویجت‌ها", "filterFolders": "پوشه‌ها" }, "type": { "addNew": "افزودن جدید", "app": "برنامه", - "widget": "ابزارک", + "widget": "ویجت", "folder": "پوشه" }, "folder": { @@ -223,12 +223,12 @@ "imported": "وارد شد", "importFailed": "Import ناموفق بود: {err}", "exportFailed": "Export ناموفق بود: {err}", - "importNoChange": "چیزی برای Import نیست — فایل با تنظیمات فعلی شما مطابقت دارد.", + "importNoChange": "چیزی برای وارد کردن نیست — فایل با تنظیمات فعلی شما مطابقت دارد.", "pwWeak": "رمز عبور ضعیف است: {label}", "sessionsRevoked": "همه دستگاه‌ها خارج شدند.", "authNeedsPassword": "پیش از فعال‌سازی احراز هویت، یک گذرواژه تعیین کنید.", - "importForeignDone": "درون‌ریزی پایان یافت. برنامه‌ها: {apps}. پوشه‌ها: {folders}.", - "importForeignNothing": "در این فایل‌ها چیزی برای درون‌ریزی نیست.", + "importForeignDone": "وارد کردن پایان یافت. برنامه‌ها: {apps}. پوشه‌ها: {folders}.", + "importForeignNothing": "در این فایل‌ها چیزی برای وارد کردن نیست.", "importYamlUnsupported": "{file}: {reason} (خط {line})", "importUnknownFormat": "{file} پیکربندی gethomepage یا Dashy نیست.", "secretsWithheld": "اعتبارنامه را دوباره وارد کنید برای: {items}. نگه داشته نشد، چون درخواستِ مربوط به آن تغییر کرده است.", @@ -252,8 +252,8 @@ "noMatches": "نتیجه‌ای یافت نشد." }, "import": { - "confirm": "{n} مورد درون‌ریزی شود؟ {added} افزوده، {updated} به‌روزرسانی، {deleted} حذف.", - "confirmTitle": "درون‌ریزی پیکربندی" + "confirm": "{n} مورد وارد شود؟ {added} افزوده، {updated} به‌روزرسانی، {deleted} حذف.", + "confirmTitle": "وارد کردن پیکربندی" }, "status": { "needsAttention": "وضعیت: نیاز به رسیدگی", @@ -292,12 +292,12 @@ }, "widgetCfg": { "unavailable": "تنظیمات در دسترس نیست", - "unavailableTip": "تعریف این ابزارک ({type}) بارگذاری نشد، بنابراین تنظیمات آن قابل نمایش یا ویرایش نیست. این تنظیمات روی سرور نگهداری می‌شوند و ذخیره‌کردن آن‌ها را تغییر نمی‌دهد.", - "refused_one": "‏{count} ابزارک بارگذاری نشد، بنابراین در فهرست زیر نیست.", - "refused_other": "‏{count} ابزارک بارگذاری نشدند، بنابراین در فهرست زیر نیستند.", + "unavailableTip": "تعریف این ویجت ({type}) بارگذاری نشد، بنابراین تنظیمات آن قابل نمایش یا ویرایش نیست. این تنظیمات روی سرور نگهداری می‌شوند و ذخیره‌کردن آن‌ها را تغییر نمی‌دهد.", + "refused_one": "‏{count} ویجت بارگذاری نشد، بنابراین در فهرست زیر نیست.", + "refused_other": "‏{count} ویجت بارگذاری نشدند، بنابراین در فهرست زیر نیستند.", "name": "نام", - "type": "نوع ابزارک", - "namePh": "ابزارک من", + "type": "نوع ویجت", + "namePh": "ویجت من", "editName": "ویرایش نام", "size": "اندازه", "iframeTip": "این نشانی به‌صورت آی‌فریم در داشبورد جاسازی می‌شود.", @@ -314,12 +314,12 @@ "loaded_other": "{count} گزینه بارگیری شد" }, "importForeign": { - "title": "درون‌ریزی از داشبورد دیگر", + "title": "وارد کردن از داشبورد دیگر", "lead": "آنچه از پیش در داشبورد دارید تغییر نمی‌کند.", - "willAdd": "برنامه‌های افزودنی", - "willCreate": "پوشه‌های ساختنی", - "changed": "با تغییر درون‌ریزی شد", - "notImported": "درون‌ریزی نشد", + "willAdd": "برنامه‌هایی که افزوده می‌شوند", + "willCreate": "پوشه‌هایی که ساخته می‌شوند", + "changed": "با تغییر وارد شد", + "notImported": "وارد نشد", "appCount": "({n})", "skipNoLabel": "بدون نام", "skipNoHref": "بدون پیوند", @@ -330,15 +330,15 @@ "noteRemoteContainer": "کانتینر نادیده گرفته شد، روی {detail} اجرا می‌شود", "noteFlattened": "گروه تودرتو به پوشه‌ای جداگانه تبدیل شد", "noteSubItems": "زیرمورد به پوشه منتقل شد", - "noteWidgetLink": "ویجت به‌صورت پیوند درون‌ریزی شد", + "noteWidgetLink": "ویجت به‌صورت پیوند وارد شد", "noteWidgetDropped": "{detail} ویجت بخش کنار گذاشته شد", "noteFields": "فیلدهای کنارگذاشته: {detail}", "notePages": "{detail} صفحه دیگر دنبال نشد", "noteLocalUrl": "نشانی محلی دوم کنار گذاشته شد: {detail}", - "wholeFile": "همین پرونده", - "untitledFolder": "درون‌ریزی‌شده", + "wholeFile": "همین فایل", + "untitledFolder": "واردشده", "skipRelativeHref": "پیوند نشانی ندارد، فقط یک مسیر", - "skipUnreadable": "این ورودی ساختاری ندارد که قالب پرونده بپذیرد", + "skipUnreadable": "این ورودی ساختاری ندارد که قالب فایل بپذیرد", "skipUnparsable": "خط {detail} خوانده نشد", "monitors": "· {url} را می‌پاید" }, diff --git a/ui/test/i18n-coverage.test.mjs b/ui/test/i18n-coverage.test.mjs index e340a6be..c9972a34 100644 --- a/ui/test/i18n-coverage.test.mjs +++ b/ui/test/i18n-coverage.test.mjs @@ -103,3 +103,53 @@ for (const [file, allowed] of Object.entries(SAME_ON_PURPOSE)) { assert.deepEqual(untranslated, [], `${file}: still English. Translate, or add to SAME_ON_PURPOSE with a reason.`); }); } + +/* ── one word per concept ─────────────────────────────────────────────────── */ + +/* A machine translates each string on its own, so the same concept comes back + under a different word in each. The Persian catalogue named a widget both + ابزارک and ویجت, and a file both فایل and پرونده, in one interface. + + It also reached for فرهنگستان coinages that correct Persian dictionaries + carry but shipped software does not use. درون‌ریزی and برون‌بری for import and + export appear in none of Immich, Jellyfin or Uptime Kuma, all of which build + import on وارد کردن; Immich, the closest of the three to this project, ships + وارد کردن پیکربندی for "Import configuration" and خروجی for "Export". + + Each entry is the rejected term and what to write instead. Adding one is how + a terminology decision is recorded, so it survives the next bulk retranslation. + The glossary in docs/i18n.md carries the same list in prose. */ +const REJECTED = { + 'fa.json': [ + ['درون‌ریزی', 'وارد کردن'], + ['برون‌بری', 'خروجی'], + ['ابزارک', 'ویجت'], + ['پرونده', 'فایل'], + ], +}; + +const widgetCatalogues = locale => { + const dir = path.join(DIR, '..', 'widgets'); + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter(e => e.isDirectory() && fs.existsSync(path.join(dir, e.name, 'i18n', locale))) + .map(e => [ + `widgets/${e.name}/i18n/${locale}`, + JSON.parse(fs.readFileSync(path.join(dir, e.name, 'i18n', locale), 'utf8')), + ]); +}; + +for (const [file, pairs] of Object.entries(REJECTED)) { + test(`${file} uses one agreed word per concept`, () => { + const catalogues = [[file, read(file)], ...widgetCatalogues(file)]; + const found = []; + for (const [name, catalogue] of catalogues) { + for (const [key, value] of flat(catalogue)) { + for (const [rejected, instead] of pairs) { + if (String(value).includes(rejected)) found.push(`${name} ${key}: ${rejected} -> ${instead}`); + } + } + } + assert.deepEqual(found, [], `A term the glossary rules out. See docs/i18n.md:\n ${found.join('\n ')}`); + }); +} diff --git a/ui/widgets/dns/i18n/fa.json b/ui/widgets/dns/i18n/fa.json index 28c31a30..b8651f3b 100644 --- a/ui/widgets/dns/i18n/fa.json +++ b/ui/widgets/dns/i18n/fa.json @@ -5,7 +5,7 @@ "dnsUrl.placeholder": "dns-host:80", "dnsUrl.hint": "نام کانتینر و پورت، یا یک نشانی کامل.", "dnsHref.label": "نشانی کلیک", - "dnsHref.hint": "جایی که با کلیک روی ابزارک باز می‌شود. برای غیرفعال کردن خالی بگذارید.", + "dnsHref.hint": "جایی که با کلیک روی ویجت باز می‌شود. برای غیرفعال کردن خالی بگذارید.", "dnsUser.label": "نام کاربری", "dnsPass.label": "گذرواژه", "dnsPiholePassword.label": "گذرواژه Pi-hole", diff --git a/ui/widgets/github/i18n/fa.json b/ui/widgets/github/i18n/fa.json index 9d7a0b78..cde42c60 100644 --- a/ui/widgets/github/i18n/fa.json +++ b/ui/widgets/github/i18n/fa.json @@ -8,9 +8,9 @@ "githubUser.label": "نام کاربری GitHub", "githubUser.placeholder": "octocat", "githubToken.label": "توکن دسترسی شخصی", - "githubToken.hint": "توکن کلاسیک با دسترسی repo و read:user (یا توکن دقیق با همان سطح خواندن). فقط همین ابزارک از آن استفاده می‌کند.", + "githubToken.hint": "توکن کلاسیک با دسترسی repo و read:user (یا توکن دقیق با همان سطح خواندن). فقط همین ویجت از آن استفاده می‌کند.", "githubHref.label": "نشانی کلیک", - "githubHref.hint": "جایی که با کلیک روی ابزارک باز می‌شود. برای غیرفعال کردن خالی بگذارید.", + "githubHref.hint": "جایی که با کلیک روی ویجت باز می‌شود. برای غیرفعال کردن خالی بگذارید.", "githubPrFilters.label": "پالایه‌های درخواست ادغام", "githubPrFilters.hint": "کدام درخواست‌های باز نمایش داده شوند. چند پالایه با هم ترکیب می‌شوند.", "githubPrFilters.opt.created": "ساخته‌شده", diff --git a/ui/widgets/system-summary/i18n/fa.json b/ui/widgets/system-summary/i18n/fa.json index 8c53f6d3..f73cc120 100644 --- a/ui/widgets/system-summary/i18n/fa.json +++ b/ui/widgets/system-summary/i18n/fa.json @@ -31,9 +31,9 @@ "slots.primary.placeholder": "/", "slots.secondary.label": "دومین مسیر اتصال", "slots.secondary.placeholder": "/mnt/data", - "slots.fsPrimary.label": "نخستین سامانهٔ پرونده", + "slots.fsPrimary.label": "نخستین سیستم فایل", "slots.fsPrimary.placeholder": "root", - "slots.fsSecondary.label": "دومین سامانهٔ پرونده", + "slots.fsSecondary.label": "دومین سیستم فایل", "slots.fsSecondary.placeholder": "media", "slots.sharePrimary.label": "نخستین اشتراک", "slots.sharePrimary.placeholder": "media",