From 6ec4ccd34a0d3fbd4256d21a079cb2c24dca0624 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 17:28:58 +0000 Subject: [PATCH 1/2] i18n(service-messaging): ship the notification objects and digest heading in zh-Hant, ar, fr and pt-BR Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6Cai5CRdAs4u9gTipWU --- .../30512-service-messaging-locales-a.md | 10 + .../scripts/i18n-extract.config.ts | 10 +- ...eyed-notification-text.integration.test.ts | 26 +- .../src/objects/admin-copy-vocabulary.test.ts | 15 +- .../objects/setup-list-empty-states.test.ts | 15 +- .../src/translations/ar.objects.generated.ts | 485 ++++++++++++++++++ .../ar.source-hashes.generated.ts | 26 + .../src/translations/fr.objects.generated.ts | 485 ++++++++++++++++++ .../fr.source-hashes.generated.ts | 31 ++ .../src/translations/index.ts | 24 + .../src/translations/messages.ts | 43 +- .../translations/pt-BR.objects.generated.ts | 485 ++++++++++++++++++ .../pt-BR.source-hashes.generated.ts | 29 ++ .../translations/zh-Hant.objects.generated.ts | 485 ++++++++++++++++++ .../zh-Hant.source-hashes.generated.ts | 26 + 15 files changed, 2178 insertions(+), 17 deletions(-) create mode 100644 .changeset/30512-service-messaging-locales-a.md create mode 100644 packages/services/service-messaging/src/translations/ar.objects.generated.ts create mode 100644 packages/services/service-messaging/src/translations/ar.source-hashes.generated.ts create mode 100644 packages/services/service-messaging/src/translations/fr.objects.generated.ts create mode 100644 packages/services/service-messaging/src/translations/fr.source-hashes.generated.ts create mode 100644 packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts create mode 100644 packages/services/service-messaging/src/translations/pt-BR.source-hashes.generated.ts create mode 100644 packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts create mode 100644 packages/services/service-messaging/src/translations/zh-Hant.source-hashes.generated.ts diff --git a/.changeset/30512-service-messaging-locales-a.md b/.changeset/30512-service-messaging-locales-a.md new file mode 100644 index 00000000000..95ec8b4d23e --- /dev/null +++ b/.changeset/30512-service-messaging-locales-a.md @@ -0,0 +1,10 @@ +--- +'@objectstack/service-messaging': patch +--- + +i18n(service-messaging): the notification objects and the digest heading ship in Traditional Chinese, Arabic, French and Brazilian Portuguese + +Clause-②: no +Metadata-protocol: none + +The messaging objects (`sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_template`, `sys_http_delivery`, `sys_user_device`) and the digest heading "You have {{count}} notifications" now carry `zh-Hant`, `ar`, `fr` and `pt-BR` beside the existing `en`, `zh-CN`, `ja-JP` and `es-ES`, so a recipient in one of those languages reads the notification screens and the digest heading in their own language instead of English. `t()` selects no plural form, so the Arabic heading is worded as a tally (`عدد الإشعارات لديك: {{count}}`), correct for every count. A recipient whose language is `fr-FR` now reads the `fr` pack. Each new locale is registered under its platform-table tag with its source-hash companion and its messages, and the package's extract command lists the tags, so the existing `check:i18n` bundle check covers them. First half of package 9 of #30512; `ru`, `de` and `ko` follow in the second half. diff --git a/packages/services/service-messaging/scripts/i18n-extract.config.ts b/packages/services/service-messaging/scripts/i18n-extract.config.ts index 6d74097c7dc..a6d2f07b15e 100644 --- a/packages/services/service-messaging/scripts/i18n-extract.config.ts +++ b/packages/services/service-messaging/scripts/i18n-extract.config.ts @@ -7,7 +7,7 @@ * `--merge` preserves every hand-translated string. * * os i18n extract packages/services/service-messaging/scripts/i18n-extract.config.ts \ - * --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only --no-metadata-forms \ + * --locales=zh-CN,ja-JP,es-ES,zh-Hant,ar,fr,pt-BR --fill=default --objects-only --no-metadata-forms \ * --source-hashes \ * --out=packages/services/service-messaging/src/translations * @@ -49,6 +49,10 @@ import { enObjects } from '../src/translations/en.objects.generated.js'; import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js'; import { esESObjects } from '../src/translations/es-ES.objects.generated.js'; +import { zhHantObjects } from '../src/translations/zh-Hant.objects.generated.js'; +import { arObjects } from '../src/translations/ar.objects.generated.js'; +import { frObjects } from '../src/translations/fr.objects.generated.js'; +import { ptBRObjects } from '../src/translations/pt-BR.objects.generated.js'; /** * The annotation is load-bearing, not decoration (#10868). `defineStack` @@ -79,6 +83,10 @@ const config: ObjectStackDefinition = defineStack({ { 'zh-CN': { objects: zhCNObjects } }, { 'ja-JP': { objects: jaJPObjects } }, { 'es-ES': { objects: esESObjects } }, + { 'zh-Hant': { objects: zhHantObjects } }, + { 'ar': { objects: arObjects } }, + { 'fr': { objects: frObjects } }, + { 'pt-BR': { objects: ptBRObjects } }, ], }); diff --git a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts index 7c9d77cd69e..60f6764e7c1 100644 --- a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts +++ b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts @@ -86,7 +86,9 @@ const USERS = [ { id: 'u_en', name: 'Ada', email: 'ada@example.com', phone_number: '+15550000001', locale: 'en' }, { id: 'u_ja', name: 'Sato', email: 'sato@example.com', phone_number: '+81300000001', locale: 'ja-JP' }, { id: 'u_none', name: 'Nobody', email: 'nobody@example.com', phone_number: '+15550000002' }, - // A language no pack in this deployment holds. + // A language no pack in this deployment holds (Italian is not a platform-table language). + { id: 'u_it', name: 'Rossi', email: 'rossi@example.com', phone_number: '+390600000001', locale: 'it-IT' }, + // A regional tag of a platform-table language: the messaging bundle ships `fr` (#30512). { id: 'u_fr', name: 'Dupont', email: 'dupont@example.com', phone_number: '+33100000001', locale: 'fr-FR' }, ]; @@ -487,22 +489,34 @@ describe("#25015 — a digest's heading is rendered in its recipient's language" i18n: catalog({ messagingBundle: true, defaultLocale: 'zh-CN' }), }); - const [digest] = await digestOf(ql, c, 'u_fr', 2); + const [digest] = await digestOf(ql, c, 'u_it', 2); expect(digest.title).toBe('您有 2 条通知'); }); - it('when neither the recipient\'s language nor the deployment default has a pack, the English literal — said once', async () => { + it('a regional recipient language reads its platform-table pack (`fr-FR` reads `fr`, #30512)', async () => { const ql = await boot(); const c = compose(ql, { - outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'fr-FR', - i18n: catalog({ messagingBundle: true, defaultLocale: 'fr-FR' }), + outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'zh-CN', + i18n: catalog({ messagingBundle: true, defaultLocale: 'zh-CN' }), }); const [digest] = await digestOf(ql, c, 'u_fr', 2); + expect(digest.title).toBe('Vous avez 2 notifications'); + }); + + it('when neither the recipient\'s language nor the deployment default has a pack, the English literal — said once', async () => { + const ql = await boot(); + const c = compose(ql, { + outbox: 'memory', preferenceResolver: digestAll, deploymentDefault: 'it-IT', + i18n: catalog({ messagingBundle: true, defaultLocale: 'it-IT' }), + }); + + const [digest] = await digestOf(ql, c, 'u_it', 2); + expect(digest.title).toBe('You have 2 notifications'); - expect(c.warnings.filter((w) => w.includes(`'${DIGEST_HEADING_KEY}'`) && w.includes('fr-FR'))).toHaveLength(1); + expect(c.warnings.filter((w) => w.includes(`'${DIGEST_HEADING_KEY}'`) && w.includes('it-IT'))).toHaveLength(1); }); it("a one-item digest keeps its item's own title, rendered in the recipient's language", async () => { diff --git a/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts b/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts index a339a703032..2f1cb4c4210 100644 --- a/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts +++ b/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts @@ -26,6 +26,10 @@ import { enObjects } from '../translations/en.objects.generated.js'; import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; import { esESObjects } from '../translations/es-ES.objects.generated.js'; +import { zhHantObjects } from '../translations/zh-Hant.objects.generated.js'; +import { arObjects } from '../translations/ar.objects.generated.js'; +import { frObjects } from '../translations/fr.objects.generated.js'; +import { ptBRObjects } from '../translations/pt-BR.objects.generated.js'; /** The design-record vocabulary an admin-facing string must not carry. */ const DESIGN_RECORD_VOCABULARY: ReadonlyArray = [ @@ -90,7 +94,16 @@ function isObjectSchema(v: unknown): v is ObjectLike { && !!(v as ObjectLike).fields && typeof (v as ObjectLike).fields === 'object'; } -const OBJECT_BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const; +const OBJECT_BUNDLES = { + en: enObjects, + 'zh-CN': zhCNObjects, + 'ja-JP': jaJPObjects, + 'es-ES': esESObjects, + 'zh-Hant': zhHantObjects, + 'ar': arObjects, + 'fr': frObjects, + 'pt-BR': ptBRObjects, +} as const; describe('messaging objects: admin-facing copy carries no design-record vocabulary', () => { it('the matcher recognises each kind of vocabulary it exists to keep out', () => { diff --git a/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts b/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts index df68fe8c244..5d4a2c417c9 100644 --- a/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts +++ b/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts @@ -28,6 +28,10 @@ import { enObjects } from '../translations/en.objects.generated.js'; import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; import { esESObjects } from '../translations/es-ES.objects.generated.js'; +import { zhHantObjects } from '../translations/zh-Hant.objects.generated.js'; +import { arObjects } from '../translations/ar.objects.generated.js'; +import { frObjects } from '../translations/fr.objects.generated.js'; +import { ptBRObjects } from '../translations/pt-BR.objects.generated.js'; interface ViewLike { columns?: unknown; @@ -45,7 +49,16 @@ interface ObjectLike { const OBJECTS = [NotificationPreference, NotificationTemplate] as unknown as ObjectLike[]; -const BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const; +const BUNDLES = { + en: enObjects, + 'zh-CN': zhCNObjects, + 'ja-JP': jaJPObjects, + 'es-ES': esESObjects, + 'zh-Hant': zhHantObjects, + 'ar': arObjects, + 'fr': frObjects, + 'pt-BR': ptBRObjects, +} as const; function views(def: ObjectLike): Array<[string, ViewLike]> { return Object.entries(def.listViews ?? {}); diff --git a/packages/services/service-messaging/src/translations/ar.objects.generated.ts b/packages/services/service-messaging/src/translations/ar.objects.generated.ts new file mode 100644 index 00000000000..174616e0136 --- /dev/null +++ b/packages/services/service-messaging/src/translations/ar.objects.generated.ts @@ -0,0 +1,485 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'ar'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const arObjects: NonNullable = { + sys_inbox_message: { + label: "رسالة الوارد", + pluralLabel: "رسائل الوارد", + description: "صفوف إشعارات داخل التطبيق موجّهة للمستخدم تُجسّدها قناة رسائل الوارد.", + fields: { + id: { + label: "معرّف رسالة الوارد" + }, + user_id: { + label: "المستخدم المستلِم" + }, + notification_id: { + label: "حدث الإشعار", + help: "مفتاح خارجي → sys_notification (حدث L2 الذي يجسّده هذا الصف)" + }, + delivery_id: { + label: "التسليم", + help: "مفتاح خارجي → sys_notification_delivery (صف صندوق الصادر)؛ null حتى P1" + }, + actor_id: { + label: "المنفِّذ", + help: "المستخدم الذي تسبّب في الحدث (صاحب الإشارة، أو من أسند المهمة) — بالدلالة نفسها لـ sys_notification.actor_id. يتيح للعميل إخفاء إيصالاته الخاصة بمقارنة محلية بحتة. قيمته null في صف الملخّص بطبيعة بنائه: فالمجموعة المدمجة ليس لها منفِّذ واحد." + }, + topic: { + label: "الموضوع" + }, + title: { + label: "العنوان" + }, + body_md: { + label: "النص" + }, + severity: { + label: "درجة الخطورة", + options: { + info: "معلومات", + warning: "تحذير", + critical: "حرج" + } + }, + action_url: { + label: "رابط الإجراء" + }, + created_at: { + label: "تاريخ الإنشاء" + } + }, + _views: { + mine: { + label: "الإشعارات", + emptyState: { + title: "صندوق الوارد فارغ", + message: "لا توجد إشعارات." + } + } + } + }, + sys_notification_receipt: { + label: "إيصال الإشعار", + pluralLabel: "إيصالات الإشعارات", + description: "إيصال لكل مستلِم × قناة؛ وهو المصدر المعتمد لحالة قراءة الإشعار.", + fields: { + id: { + label: "معرّف الإيصال" + }, + notification_id: { + label: "حدث الإشعار", + help: "مفتاح خارجي → sys_notification (حدث L2)" + }, + delivery_id: { + label: "التسليم", + help: "مفتاح خارجي → sys_notification_delivery (صف صندوق الصادر)؛ null حتى P1" + }, + user_id: { + label: "المستخدم المستلِم" + }, + channel: { + label: "القناة", + help: "معرّف القناة التي يخصّها هذا الإيصال (inbox / email / push / …)" + }, + state: { + label: "الحالة", + options: { + delivered: "تم التسليم", + read: "مقروء", + clicked: "تم النقر", + dismissed: "تم التجاهل" + } + }, + at: { + label: "الوقت", + help: "وقت وصول الإيصال إلى حالته الحالية" + }, + created_at: { + label: "تاريخ الإنشاء" + } + } + }, + sys_notification_delivery: { + label: "تسليم الإشعار", + pluralLabel: "عمليات تسليم الإشعارات", + description: "صندوق صادر دائم للتسليم لكل مستلِم × قناة.", + fields: { + id: { + label: "معرّف التسليم" + }, + display_title: { + label: "العنوان", + help: "عنوان السجل: قناة التسليم والشخص المقصود بها، أو العنوان حين لا يكون المستلِم مستخدمًا (يُحسب عند القراءة)" + }, + notification_id: { + label: "حدث الإشعار", + help: "مفتاح خارجي → sys_notification (حدث L2)" + }, + recipient_id: { + label: "المستلِم", + help: "العنوان الذي يُرسَل إليه التسليم: معرّف مستخدم، أو عنوان بريد إلكتروني أو رقم هاتف محفوظ كما أُدخل." + }, + recipient_user_id: { + label: "المستخدم المستلِم", + help: "المستخدم المقصود بالتسليم. يكون فارغًا حين يكون المستلِم عنوان بريد إلكتروني أو رقم هاتف لا يخصّ أي مستخدم." + }, + channel: { + label: "القناة" + }, + topic: { + label: "الموضوع" + }, + digest_key: { + label: "مفتاح الملخّص", + help: "مفتاح التجميع recipient|channel|window لعمليات التسليم المجمّعة (الملخّص)؛ null للإرسال العادي." + }, + payload: { + label: "الحمولة", + help: "لقطة من محتوى الإشعار المعروض لأجل الإرسال." + }, + status: { + label: "الحالة", + options: { + pending: "قيد الانتظار", + in_flight: "قيد الإرسال", + success: "نجاح", + failed: "فشل", + dead: "ميت", + suppressed: "محجوب" + } + }, + attempts: { + label: "المحاولات" + }, + partition_key: { + label: "مفتاح التقسيم" + }, + claimed_by: { + label: "تمت المطالبة بواسطة", + help: "معرّف العقدة أثناء in_flight" + }, + claimed_at: { + label: "وقت المطالبة (مللي ثانية)" + }, + next_attempt_at: { + label: "وقت المحاولة التالية (مللي ثانية)" + }, + last_attempted_at: { + label: "وقت آخر محاولة (مللي ثانية)" + }, + error: { + label: "الخطأ" + }, + created_at: { + label: "تاريخ الإنشاء" + }, + updated_at: { + label: "تاريخ التحديث" + } + } + }, + sys_notification_preference: { + label: "تفضيل الإشعارات", + pluralLabel: "تفضيلات الإشعارات", + description: "الموضوعات والقنوات التي يتلقّاها كل مستخدم؛ ويضبط المسؤولون القيم الافتراضية.", + fields: { + id: { + label: "معرّف التفضيل" + }, + display_title: { + label: "العنوان", + help: "عنوان السجل: الشخص (أو All users) والموضوع والقناة التي يشملها هذا المفتاح (يُحسب عند القراءة)" + }, + user_id: { + label: "المستخدم", + help: "المستخدم الذي ينطبق عليه هذا التفضيل. اتركه فارغًا ليكون الافتراضي المطبَّق على جميع المستخدمين." + }, + topic: { + label: "الموضوع", + help: "الموضوع الذي تُرسَل الإشعارات تحته: الموضوع الذي تسمّيه خطوة الإشعار في تدفّق، أو موضوع ترسله المنصّة، مثل approval.requested أو collab.mention. أدخل * لكل الموضوعات. الموضوع الذي لا يُرسل به شيء لا يطابق شيئًا." + }, + channel: { + label: "القناة", + help: "القناة: inbox (إشعارات تظهر داخل التطبيق، متاحة دائمًا)، أو email (بعد إعداد خدمة بريد)، أو sms (بعد إعداد خدمة رسائل نصية)، أو معرّف قناة تضيفها إضافة مثبّتة. أدخل * لكل القنوات. المعرّف الذي لا تستخدمه أي قناة لا يطابق شيئًا." + }, + enabled: { + label: "مُفعَّل", + help: "عندما تكون false، يُكتم هذا الثلاثي (المستخدم، الموضوع، القناة)." + }, + digest: { + label: "الملخّص", + help: "اجمع هذه الإشعارات في ملخّص واحد بدل إرسال كل منها عند وصوله: daily يرسله عند منتصف الليل، وweekly يوم الاثنين عند منتصف الليل، وnone يرسل كلًّا منها فورًا. تُرسل الإشعارات الحرجة دائمًا فورًا. يسري على التفضيل الذي قناته * (كل القنوات).", + options: { + none: "بلا ملخّص", + daily: "يومي", + weekly: "أسبوعي" + } + }, + quiet_hours: { + label: "ساعات الهدوء", + help: "نافذة يومية تُحتجز فيها الإشعارات وتُسلَّم عند انتهائها، مثل { \"tz\": \"Europe/Berlin\", \"start\": \"22:00\", \"end\": \"07:00\" }. الأوقات بصيغة HH:MM في تلك المنطقة الزمنية (UTC عند إغفال tz)، ويمكن أن تمتد النافذة بعد منتصف الليل. لا تُحتجز الإشعارات الحرجة. يسري على التفضيل الذي قناته * (كل القنوات)." + }, + created_at: { + label: "تاريخ الإنشاء" + }, + updated_at: { + label: "تاريخ التحديث" + } + }, + _views: { + all: { + label: "الكل", + emptyState: { + title: "لا توجد تفضيلات بعد", + message: "لا يُكتم أي شيء حتى يُضاف تفضيل؛ أضف تفضيلًا لكتم موضوع على قناة أو السماح به، لمستخدم واحد أو كقيمة افتراضية للجميع." + } + } + } + }, + sys_notification_template: { + label: "قالب الإشعار", + pluralLabel: "قوالب الإشعارات", + description: "نص البريد الإلكتروني والرسائل النصية الذي يرسله كل موضوع إشعار، لكل لغة. لا يستخدم صندوق الوارد داخل التطبيق هذه القوالب أبدًا.", + fields: { + id: { + label: "معرّف القالب" + }, + topic: { + label: "الموضوع" + }, + channel: { + label: "القناة", + help: "القناة التي تستخدم هذا القالب: email أو sms؛ وتُرفض أي قيمة أخرى. لا يستخدمه صندوق الوارد داخل التطبيق أبدًا؛ لمحتوى داخل التطبيق اكتب قالب بريد إلكتروني وسمِّه في template لخطوة الإشعار." + }, + locale: { + label: "اللغة", + help: "رمز لغة BCP-47، مثل 'en' / 'en-US' / 'zh-CN'." + }, + version: { + label: "الإصدار" + }, + subject: { + label: "الموضوع / العنوان", + help: "موضوع البريد الإلكتروني؛ ولا ترسله الرسالة النصية إلا حين يكون النص فارغًا. يدعم {{ payload.x }}." + }, + body: { + label: "النص", + help: "نص القالب. يدعم {{ payload.x }}. يُفسَّر وفق `format`." + }, + format: { + label: "تنسيق النص", + options: { + markdown: "Markdown", + html: "HTML", + text: "نص عادي", + mjml: "MJML" + } + }, + is_active: { + label: "نشط", + help: "لا تُختار عند العرض إلا القوالب النشطة." + }, + created_at: { + label: "تاريخ الإنشاء" + }, + updated_at: { + label: "تاريخ التحديث" + } + }, + _views: { + all: { + label: "الكل", + emptyState: { + title: "لا توجد قوالب بعد", + message: "أضف قالبًا لكل موضوع وقناة (بريد إلكتروني أو رسالة نصية) ولغة؛ وحتى ذلك الحين ترسل هذه القنوات العنوان والنص اللذين يحملهما الإشعار. يأتي المحتوى داخل التطبيق من قالب بريد إلكتروني: الإشعار الذي يسمّي قالبًا يرسله بالبريد الإلكتروني وفي صندوق الوارد، وتظل الرسائل النصية تستخدم القالب هنا." + } + } + }, + _validations: { + channel_renders_template: { + message: "لا تستخدم قالب الإشعار إلا قناتا email وsms، لذا يُرفض القالب لأي قناة أخرى: اضبط channel على email أو sms، أو احذف القالب. لمحتوى داخل التطبيق (صندوق الوارد)، اكتب قالب بريد إلكتروني (email_template) وسمِّه في template لخطوة الإشعار؛ فالبريد الإلكتروني وصندوق الوارد يعرضانه كلاهما." + } + } + }, + sys_http_delivery: { + label: "تسليم HTTP", + pluralLabel: "عمليات تسليم HTTP", + description: "استدعاءات HTTP صادرة تجريها خطافات الويب والتدفقات، صف لكل استدعاء، مع حالة تسليمه.", + fields: { + id: { + label: "معرّف التسليم", + help: "UUID — يعمل أيضًا كمفتاح عدم التكرار لدى الجهة المستقبِلة" + }, + display_title: { + label: "العنوان", + help: "عنوان السجل: تسمية التسليم إن وُجدت، وعنوان URL الهدف (يُحسب عند القراءة)" + }, + source: { + label: "المصدر", + help: "نطاق المصدر، مثل 'webhook' | 'flow'. UNIQUE(source, dedup_key)." + }, + ref_id: { + label: "معرّف المرجع", + help: "مرساة التقسيم/الترتيب داخل المصدر (معرّف webhook، معرّف flow، …)" + }, + dedup_key: { + label: "مفتاح إزالة التكرار", + help: "UNIQUE(source, dedup_key) لضمان الإدراج في قائمة الانتظار مرة واحدة على الأكثر" + }, + label: { + label: "التسمية", + help: "تسمية تشخيصية / نوع الحدث — تظهر في X-Objectstack-Event" + }, + url: { + label: "عنوان URL الهدف", + help: "تُلتقط لقطة منه عند الإدراج في قائمة الانتظار حتى لا تعيد تعديلات الإعداد كتابة الصفوف الحيّة" + }, + method: { + label: "الأسلوب" + }, + headers_json: { + label: "ترويسات JSON", + help: "ترويسات الطلب المُعَدّة لهذا التسليم — وهي الموضع المعتاد لبيانات الاعتماد، لذا لا تُعاد أبدًا عبر مسار البيانات العام. يستعيدها المُرسِل عبر الموصِّل المميَّز للمحرك في مسار المطالبة." + }, + signature: { + label: "توقيع HMAC", + help: "X-Objectstack-Signature المُرسَل مع هذا التسليم (sha256=). يُحسب عند الإدراج في قائمة الانتظار؛ ولا يُحفظ سر التوقيع أبدًا." + }, + timeout_ms: { + label: "المهلة (مللي ثانية)" + }, + payload_json: { + label: "حمولة JSON" + }, + partition_key: { + label: "القسم", + help: "hash(ref_id) mod partitionCount — محسوب مسبقًا لعبارة WHERE منخفضة الكلفة" + }, + status: { + label: "الحالة", + help: "pending | in_flight | success | failed | dead" + }, + attempts: { + label: "المحاولات", + help: "عدد المحاولات حتى الآن. القيمة 0 في صف نهائي تعني أن التسليم PARKED — لم يُرسَل قط ولا يمكن إعادة تسليمه لأنه لا يحمل توقيعًا؛ راجع `error` لمعرفة السبب." + }, + claimed_by: { + label: "تمت المطالبة بواسطة" + }, + claimed_at: { + label: "وقت المطالبة (مللي ثانية)" + }, + next_retry_at: { + label: "وقت إعادة المحاولة التالية (مللي ثانية)" + }, + last_attempted_at: { + label: "وقت آخر محاولة (مللي ثانية)" + }, + response_code: { + label: "رمز حالة HTTP" + }, + response_body: { + label: "نص الاستجابة (مقتطع)" + }, + error: { + label: "الخطأ", + help: "سبب عدم كون هذا الصف استدعاءً مُسلَّمًا: آخر خطأ في النقل، أو — في صف بلا محاولات — سبب تعذّر تجهيز التسليم أصلًا." + }, + created_at: { + label: "تاريخ الإنشاء" + }, + updated_at: { + label: "تاريخ التحديث" + } + }, + _views: { + recent: { + label: "الأحدث" + }, + failures: { + label: "الإخفاقات" + }, + pending: { + label: "قيد الانتظار" + } + } + }, + sys_user_device: { + label: "جهاز المستخدم", + pluralLabel: "أجهزة المستخدمين", + description: "جهاز الإشعارات الفورية للمستخدم: صف لكل تثبيت للتطبيق، يحمل رمز الإشعارات الخاص بالمورّد.", + fields: { + id: { + label: "معرّف الصف" + }, + device_id: { + label: "معرّف الجهاز", + help: "معرّف جهاز يحتفظ به العميل، ثابت لكل تثبيت للتطبيق. فريد لكل مستخدم." + }, + user_id: { + label: "المستخدم", + help: "المستخدم الذي ينتمي إليه الجهاز: المستخدم المسجَّل دخوله الذي سجّله." + }, + vendor: { + label: "المورّد", + help: "مورّد الإشعارات الفورية الذي يتبع له الرمز.", + options: { + apns: "APNs", + fcm: "FCM" + } + }, + token: { + label: "رمز الإشعارات الفورية", + help: "رمز الإشعارات الفورية من المورّد. يحمله جهاز واحد في كل مرة." + }, + platform: { + label: "المنصّة", + options: { + ios: "iOS", + android: "Android", + web: "الويب" + } + }, + app_id: { + label: "معرّف التطبيق", + help: "معرّف حزمة iOS أو معرّف تطبيق Android الذي صدر له الرمز." + }, + locale: { + label: "اللغة المحلية", + help: "لغة الجهاز كما ذكرها التسجيل. تُخزَّن للجهاز، ولا يُعرض أي إشعار فوري بها: يصل الإشعار الفوري بلغة المستلم (لغة المستخدم لديه، ثم اللغة الافتراضية للنشر)." + }, + name: { + label: "اسم الجهاز" + }, + enabled: { + label: "مُفعَّل", + help: "تكون false بعد أن يُبلغ المورّد بانتهاء صلاحية الرمز؛ لا يُسلَّم شيء إلى جهاز معطَّل." + }, + invalidated_at: { + label: "وقت الإبطال", + help: "وقت إبلاغ المورّد بانتهاء صلاحية الرمز. يمحوه التسجيل الذي يعيد تفعيله." + }, + last_seen_at: { + label: "آخر ظهور", + help: "آخر مرة سجّل فيها الجهاز أو حدّث بياناته." + }, + created_at: { + label: "تاريخ الإنشاء" + } + } + } +}; diff --git a/packages/services/service-messaging/src/translations/ar.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ar.source-hashes.generated.ts new file mode 100644 index 00000000000..9277c981b86 --- /dev/null +++ b/packages/services/service-messaging/src/translations/ar.source-hashes.generated.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'ar'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const arGeneratedSourceHashes: Readonly> = { + "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", + "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812", + "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91", + "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698", + "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4", +}; diff --git a/packages/services/service-messaging/src/translations/fr.objects.generated.ts b/packages/services/service-messaging/src/translations/fr.objects.generated.ts new file mode 100644 index 00000000000..13a07b901c9 --- /dev/null +++ b/packages/services/service-messaging/src/translations/fr.objects.generated.ts @@ -0,0 +1,485 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'fr'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const frObjects: NonNullable = { + sys_inbox_message: { + label: "Message de la boîte de réception", + pluralLabel: "Messages de la boîte de réception", + description: "Lignes de notifications intégrées à l’application, destinées aux utilisateurs, matérialisées par le canal de messagerie de la boîte de réception.", + fields: { + id: { + label: "ID du message" + }, + user_id: { + label: "Utilisateur destinataire" + }, + notification_id: { + label: "Événement de notification", + help: "Clé étrangère → sys_notification (l’événement L2 que cette ligne matérialise)" + }, + delivery_id: { + label: "Livraison", + help: "Clé étrangère → sys_notification_delivery (ligne de la boîte d’envoi) ; null jusqu’à P1" + }, + actor_id: { + label: "Auteur de l’action", + help: "Utilisateur à l’origine de l’événement (auteur de la mention, de l’attribution) — même sémantique que sys_notification.actor_id. Permet à un client de masquer ses propres accusés par une simple comparaison locale. Null sur une ligne de résumé par construction : un groupe regroupé n’a pas d’auteur unique." + }, + topic: { + label: "Sujet" + }, + title: { + label: "Titre" + }, + body_md: { + label: "Contenu" + }, + severity: { + label: "Gravité", + options: { + info: "Information", + warning: "Avertissement", + critical: "Critique" + } + }, + action_url: { + label: "URL d’action" + }, + created_at: { + label: "Créé le" + } + }, + _views: { + mine: { + label: "Notifications", + emptyState: { + title: "Boîte de réception vide", + message: "Aucune notification." + } + } + } + }, + sys_notification_receipt: { + label: "Accusé de notification", + pluralLabel: "Accusés de notification", + description: "Accusé par destinataire × canal ; la source de vérité de l’état de lecture des notifications.", + fields: { + id: { + label: "ID de l’accusé" + }, + notification_id: { + label: "Événement de notification", + help: "Clé étrangère → sys_notification (événement L2)" + }, + delivery_id: { + label: "Livraison", + help: "Clé étrangère → sys_notification_delivery (ligne de la boîte d’envoi) ; null jusqu’à P1" + }, + user_id: { + label: "Utilisateur destinataire" + }, + channel: { + label: "Canal", + help: "Identifiant du canal concerné par cet accusé (inbox / email / push / …)" + }, + state: { + label: "État", + options: { + delivered: "livré", + read: "lu", + clicked: "cliqué", + dismissed: "ignoré" + } + }, + at: { + label: "Le", + help: "Moment où l’accusé a atteint son état actuel" + }, + created_at: { + label: "Créé le" + } + } + }, + sys_notification_delivery: { + label: "Livraison de notification", + pluralLabel: "Livraisons de notifications", + description: "Boîte d’envoi durable des livraisons par destinataire × canal.", + fields: { + id: { + label: "ID de livraison" + }, + display_title: { + label: "Titre", + help: "Titre de l’enregistrement : le canal de livraison et la personne concernée, ou l’adresse quand le destinataire n’est pas un utilisateur (calculé à la lecture)" + }, + notification_id: { + label: "Événement de notification", + help: "Clé étrangère → sys_notification (événement L2)" + }, + recipient_id: { + label: "Destinataire", + help: "L’adresse à laquelle la livraison est envoyée : un identifiant utilisateur, ou une adresse e-mail ou un numéro de téléphone conservé tel quel." + }, + recipient_user_id: { + label: "Utilisateur destinataire", + help: "L’utilisateur destinataire de la livraison. Vide quand le destinataire est une adresse e-mail ou un numéro de téléphone qu’aucun utilisateur ne possède." + }, + channel: { + label: "Canal" + }, + topic: { + label: "Sujet" + }, + digest_key: { + label: "Clé de résumé", + help: "Clé de regroupement recipient|channel|window pour les livraisons groupées (résumé) ; null pour les envois normaux." + }, + payload: { + label: "Charge utile", + help: "Instantané du contenu de notification rendu, pour l’envoi." + }, + status: { + label: "Statut", + options: { + pending: "en attente", + in_flight: "en cours d’envoi", + success: "réussi", + failed: "échec", + dead: "abandonné", + suppressed: "bloqué" + } + }, + attempts: { + label: "Tentatives" + }, + partition_key: { + label: "Clé de partition" + }, + claimed_by: { + label: "Réclamé par", + help: "Identifiant du nœud pendant in_flight" + }, + claimed_at: { + label: "Réclamé le (ms)" + }, + next_attempt_at: { + label: "Prochaine tentative le (ms)" + }, + last_attempted_at: { + label: "Dernière tentative le (ms)" + }, + error: { + label: "Erreur" + }, + created_at: { + label: "Créé le" + }, + updated_at: { + label: "Mis à jour le" + } + } + }, + sys_notification_preference: { + label: "Préférence de notification", + pluralLabel: "Préférences de notification", + description: "Les sujets et canaux que reçoit chaque utilisateur ; les administrateurs définissent les valeurs par défaut.", + fields: { + id: { + label: "ID de la préférence" + }, + display_title: { + label: "Titre", + help: "Titre de l’enregistrement : la personne (ou All users), le sujet et le canal couverts par ce réglage (calculé à la lecture)" + }, + user_id: { + label: "Utilisateur", + help: "L’utilisateur auquel s’applique cette préférence. Laissez vide pour la valeur par défaut qui s’applique à tous les utilisateurs." + }, + topic: { + label: "Sujet", + help: "Le sujet sous lequel les notifications sont envoyées : celui que nomme l’étape de notification d’un flux, ou un sujet envoyé par la plateforme, comme approval.requested ou collab.mention. Saisissez * pour tous les sujets. Un sujet que rien n’envoie ne correspond à rien." + }, + channel: { + label: "Canal", + help: "Le canal : inbox (notifications affichées dans l’application, toujours disponible), email (dès qu’un service d’e-mail est configuré), sms (dès qu’un service SMS est configuré), ou l’identifiant d’un canal ajouté par un plugin installé. Saisissez * pour tous les canaux. Un identifiant qu’aucun canal n’utilise ne correspond à rien." + }, + enabled: { + label: "Activé", + help: "Si false, ce trio (utilisateur, sujet, canal) est mis en sourdine." + }, + digest: { + label: "Résumé", + help: "Regroupe ces notifications en un seul résumé au lieu de les envoyer une à une : daily l’envoie à minuit, weekly le lundi à minuit, none envoie chacune immédiatement. Les notifications critiques partent toujours immédiatement. S’applique à une préférence dont le canal est * (tous les canaux).", + options: { + none: "aucun", + daily: "quotidien", + weekly: "hebdomadaire" + } + }, + quiet_hours: { + label: "Heures calmes", + help: "Une plage quotidienne pendant laquelle les notifications sont retenues puis livrées à sa fin, par exemple { \"tz\": \"Europe/Berlin\", \"start\": \"22:00\", \"end\": \"07:00\" }. Les heures sont au format HH:MM dans ce fuseau (UTC si tz est omis), et la plage peut dépasser minuit. Les notifications critiques ne sont pas retenues. S’applique à une préférence dont le canal est * (tous les canaux)." + }, + created_at: { + label: "Créé le" + }, + updated_at: { + label: "Mis à jour le" + } + }, + _views: { + all: { + label: "Tous", + emptyState: { + title: "Aucune préférence pour l’instant", + message: "Rien n’est mis en sourdine tant qu’aucune préférence n’est ajoutée ; ajoutez-en une pour couper ou autoriser un sujet sur un canal, pour un utilisateur ou par défaut pour tous." + } + } + } + }, + sys_notification_template: { + label: "Modèle de notification", + pluralLabel: "Modèles de notification", + description: "Le texte d’e-mail et de SMS envoyé par chaque sujet de notification, par langue. La boîte de réception de l’application n’utilise jamais ces modèles.", + fields: { + id: { + label: "ID du modèle" + }, + topic: { + label: "Sujet" + }, + channel: { + label: "Canal", + help: "Le canal qui utilise ce modèle : email ou sms ; toute autre valeur est refusée. La boîte de réception de l’application ne l’utilise jamais ; pour un contenu dans l’application, rédigez un modèle d’e-mail et nommez-le dans le template d’une étape de notification." + }, + locale: { + label: "Langue", + help: "Code de langue BCP-47, par ex. 'en' / 'en-US' / 'zh-CN'." + }, + version: { + label: "Version" + }, + subject: { + label: "Objet / Titre", + help: "L’objet de l’e-mail ; un SMS ne l’envoie que si le corps est vide. Prend en charge {{ payload.x }}." + }, + body: { + label: "Contenu", + help: "Corps du modèle. Prend en charge {{ payload.x }}. Interprété selon `format`." + }, + format: { + label: "Format du corps", + options: { + markdown: "Markdown", + html: "HTML", + text: "texte brut", + mjml: "MJML" + } + }, + is_active: { + label: "Actif", + help: "Seuls les modèles actifs sont retenus au rendu." + }, + created_at: { + label: "Créé le" + }, + updated_at: { + label: "Mis à jour le" + } + }, + _views: { + all: { + label: "Tous", + emptyState: { + title: "Aucun modèle pour l’instant", + message: "Ajoutez un modèle par sujet, canal (e-mail ou SMS) et langue ; d’ici là, ces canaux envoient le titre et le corps portés par la notification. Le contenu dans l’application provient d’un modèle d’e-mail : une notification qui en nomme un l’envoie par e-mail et dans la boîte de réception, et le SMS utilise toujours le modèle d’ici." + } + } + }, + _validations: { + channel_renders_template: { + message: "Seuls les canaux email et sms utilisent un modèle de notification ; un modèle pour tout autre canal est donc refusé : définissez channel sur email ou sms, ou supprimez le modèle. Pour un contenu dans l’application (boîte de réception), rédigez un modèle d’e-mail (email_template) et nommez-le dans le template de l’étape de notification ; l’e-mail et la boîte de réception l’affichent tous deux." + } + } + }, + sys_http_delivery: { + label: "Livraison HTTP", + pluralLabel: "Livraisons HTTP", + description: "Appels HTTP sortants effectués par les webhooks et les flux, une ligne par appel, avec son statut de livraison.", + fields: { + id: { + label: "ID de livraison", + help: "UUID — sert aussi de clé d’idempotence côté récepteur" + }, + display_title: { + label: "Titre", + help: "Titre de l’enregistrement : le libellé de la livraison, s’il est défini, et son URL cible (calculé à la lecture)" + }, + source: { + label: "Source", + help: "Domaine de provenance, par ex. 'webhook' | 'flow'. UNIQUE(source, dedup_key)." + }, + ref_id: { + label: "ID de référence", + help: "Ancre de partition/ordre au sein de la source (id de webhook, id de flux, …)" + }, + dedup_key: { + label: "Clé de dédoublonnage", + help: "UNIQUE(source, dedup_key) pour une mise en file au plus une fois" + }, + label: { + label: "Libellé", + help: "Libellé de diagnostic / type d’événement — exposé dans X-Objectstack-Event" + }, + url: { + label: "URL cible", + help: "Figée à la mise en file pour que les modifications de configuration ne réécrivent pas les lignes actives" + }, + method: { + label: "Méthode" + }, + headers_json: { + label: "En-têtes JSON", + help: "En-têtes de requête rédigés pour cette livraison — l’endroit habituel d’un identifiant, donc jamais renvoyés par le chemin de données générique. Le répartiteur les récupère via l’accesseur privilégié du moteur sur le chemin de réclamation." + }, + signature: { + label: "Signature HMAC", + help: "X-Objectstack-Signature envoyée avec cette livraison (sha256=). Calculée à la mise en file ; le secret de signature n’est jamais conservé." + }, + timeout_ms: { + label: "Délai d’expiration (ms)" + }, + payload_json: { + label: "Charge utile JSON" + }, + partition_key: { + label: "Partition", + help: "hash(ref_id) mod partitionCount — précalculé pour un WHERE peu coûteux" + }, + status: { + label: "Statut", + help: "pending | in_flight | success | failed | dead" + }, + attempts: { + label: "Tentatives", + help: "Nombre de tentatives effectuées jusqu’ici. 0 sur une ligne terminale signifie que la livraison a été PARKED — jamais envoyée et non relivrable, faute de signature ; voir `error` pour la cause." + }, + claimed_by: { + label: "Réclamé par" + }, + claimed_at: { + label: "Réclamé le (ms)" + }, + next_retry_at: { + label: "Prochaine relance le (ms)" + }, + last_attempted_at: { + label: "Dernière tentative le (ms)" + }, + response_code: { + label: "Statut HTTP" + }, + response_body: { + label: "Corps de la réponse (tronqué)" + }, + error: { + label: "Erreur", + help: "Pourquoi cette ligne n’est pas un appel livré : la dernière erreur de transport, ou — sur une ligne à 0 tentative — pourquoi la livraison n’a jamais pu être préparée." + }, + created_at: { + label: "Créé le" + }, + updated_at: { + label: "Mis à jour le" + } + }, + _views: { + recent: { + label: "Récents" + }, + failures: { + label: "Échecs" + }, + pending: { + label: "En attente" + } + } + }, + sys_user_device: { + label: "Appareil de l’utilisateur", + pluralLabel: "Appareils des utilisateurs", + description: "L’appareil de notifications push d’un utilisateur : une ligne par installation de l’application, avec son jeton push du fournisseur.", + fields: { + id: { + label: "ID de ligne" + }, + device_id: { + label: "ID de l’appareil", + help: "Identifiant d’appareil détenu par le client, stable par installation de l’application. Unique par utilisateur." + }, + user_id: { + label: "Utilisateur", + help: "L’utilisateur auquel appartient l’appareil : l’utilisateur connecté qui l’a enregistré." + }, + vendor: { + label: "Fournisseur", + help: "Fournisseur push auquel appartient le jeton.", + options: { + apns: "APNs", + fcm: "FCM" + } + }, + token: { + label: "Jeton push", + help: "Jeton push du fournisseur. Détenu par un seul appareil à la fois." + }, + platform: { + label: "Plateforme", + options: { + ios: "iOS", + android: "Android", + web: "Web" + } + }, + app_id: { + label: "ID de l’application", + help: "Le bundle ID iOS ou l’ID d’application Android pour lequel le jeton a été émis." + }, + locale: { + label: "Paramètres régionaux", + help: "Paramètres régionaux de l’appareil, tels que l’enregistrement les a indiqués. Conservés pour l’appareil ; aucune notification push n’est rendue selon eux : une notification push arrive dans la langue du destinataire (sa langue d’utilisateur, sinon la langue par défaut du déploiement)." + }, + name: { + label: "Nom de l’appareil" + }, + enabled: { + label: "Activé", + help: "False dès que le fournisseur a signalé le jeton comme mort ; rien n’est livré à un appareil désactivé." + }, + invalidated_at: { + label: "Invalidé le", + help: "Moment où le fournisseur a signalé le jeton comme mort. Effacé par l’enregistrement qui le réactive." + }, + last_seen_at: { + label: "Vu pour la dernière fois", + help: "Moment du dernier enregistrement ou de la dernière actualisation de l’appareil." + }, + created_at: { + label: "Créé le" + } + } + } +}; diff --git a/packages/services/service-messaging/src/translations/fr.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/fr.source-hashes.generated.ts new file mode 100644 index 00000000000..ec92da8bcf1 --- /dev/null +++ b/packages/services/service-messaging/src/translations/fr.source-hashes.generated.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'fr'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const frGeneratedSourceHashes: Readonly> = { + "objects.sys_http_delivery.fields.partition_key.label": "ff6e8336a279020a", + "objects.sys_http_delivery.fields.source.label": "8fe786e8e29c8cec", + "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", + "objects.sys_inbox_message._views.mine.label": "e0fdb90b1d31078d", + "objects.sys_notification_template.fields.version.label": "b74a37c0879ec50e", + "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812", + "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91", + "objects.sys_user_device.fields.platform.options.web": "2a7ead3da8c035c5", + "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698", + "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4", +}; diff --git a/packages/services/service-messaging/src/translations/index.ts b/packages/services/service-messaging/src/translations/index.ts index 1f85b8399e2..fe6f0a9e648 100644 --- a/packages/services/service-messaging/src/translations/index.ts +++ b/packages/services/service-messaging/src/translations/index.ts @@ -19,9 +19,17 @@ import { enObjects } from './en.objects.generated.js'; import { zhCNObjects } from './zh-CN.objects.generated.js'; import { jaJPObjects } from './ja-JP.objects.generated.js'; import { esESObjects } from './es-ES.objects.generated.js'; +import { zhHantObjects } from './zh-Hant.objects.generated.js'; +import { arObjects } from './ar.objects.generated.js'; +import { frObjects } from './fr.objects.generated.js'; +import { ptBRObjects } from './pt-BR.objects.generated.js'; import { zhCNGeneratedSourceHashes } from './zh-CN.source-hashes.generated.js'; import { jaJPGeneratedSourceHashes } from './ja-JP.source-hashes.generated.js'; import { esESGeneratedSourceHashes } from './es-ES.source-hashes.generated.js'; +import { zhHantGeneratedSourceHashes } from './zh-Hant.source-hashes.generated.js'; +import { arGeneratedSourceHashes } from './ar.source-hashes.generated.js'; +import { frGeneratedSourceHashes } from './fr.source-hashes.generated.js'; +import { ptBRGeneratedSourceHashes } from './pt-BR.source-hashes.generated.js'; import { messagingMessages } from './messages.js'; /** @@ -75,4 +83,20 @@ export const MessagingTranslations: TranslationBundle = { { objects: esESObjects, messages: messagingMessages['es-ES'] }, enSource, undefined, esESGeneratedSourceHashes, ), + 'zh-Hant': withSourceFallback( + { objects: zhHantObjects, messages: messagingMessages['zh-Hant'] }, + enSource, undefined, zhHantGeneratedSourceHashes, + ), + 'ar': withSourceFallback( + { objects: arObjects, messages: messagingMessages['ar'] }, + enSource, undefined, arGeneratedSourceHashes, + ), + 'fr': withSourceFallback( + { objects: frObjects, messages: messagingMessages['fr'] }, + enSource, undefined, frGeneratedSourceHashes, + ), + 'pt-BR': withSourceFallback( + { objects: ptBRObjects, messages: messagingMessages['pt-BR'] }, + enSource, undefined, ptBRGeneratedSourceHashes, + ), }; diff --git a/packages/services/service-messaging/src/translations/messages.ts b/packages/services/service-messaging/src/translations/messages.ts index 5715018d2e6..ee8bdc0a845 100644 --- a/packages/services/service-messaging/src/translations/messages.ts +++ b/packages/services/service-messaging/src/translations/messages.ts @@ -36,12 +36,15 @@ * more. * * Neither `II18nService` implementation selects a plural form: `t()` - * interpolates `{{name}}` holes and nothing else. None is needed for the - * locales shipped here, because the count is always two or more, and in each - * of them every count from two up takes ONE form (en and es-ES: the plural; - * zh-CN and ja-JP: no plural inflection). A locale whose form varies above two - * (Polish, Russian, Arabic) would need plural selection in the catalog, which - * is the `i18n` service's to add, not this bundle's. + * interpolates `{{name}}` holes and nothing else. The count is always two or + * more, and in most shipped locales every count from two up takes ONE form + * (en, es-ES, fr, pt-BR: the plural; zh-CN, zh-Hant, ja-JP: no plural + * inflection). Arabic does vary above two (3–10 vs 11+), so its sentence is + * worded count-neutrally — the number stands after a colon as a tally + * (`عدد الإشعارات لديك: {{count}}`), which reads correctly for every count + * without choosing a noun form (#30512). A sentence that cannot be phrased + * that way needs plural selection in the catalog, which is the `i18n` + * service's to add, not this bundle's. */ import type { TranslationData } from '@objectstack/spec/system'; @@ -70,10 +73,34 @@ export const esESMessages: MessagingMessages = { notificationDigestTitle: 'Tiene {{count}} notificaciones', }; -/** The four shipped locales, keyed as the bundle beside this file keys them. */ -export const messagingMessages: Readonly> = { +export const zhHantMessages: MessagingMessages = { + notificationDigestTitle: '您有 {{count}} 則通知', +}; + +export const arMessages: MessagingMessages = { + notificationDigestTitle: 'عدد الإشعارات لديك: {{count}}', +}; + +export const frMessages: MessagingMessages = { + notificationDigestTitle: 'Vous avez {{count}} notifications', +}; + +export const ptBRMessages: MessagingMessages = { + notificationDigestTitle: 'Você tem {{count}} notificações', +}; + +/** The shipped locales, keyed as the bundle beside this file keys them. */ +export type MessagingLocale = + | 'en' | 'zh-CN' | 'ja-JP' | 'es-ES' + | 'zh-Hant' | 'ar' | 'fr' | 'pt-BR'; + +export const messagingMessages: Readonly> = { en: enMessages, 'zh-CN': zhCNMessages, 'ja-JP': jaJPMessages, 'es-ES': esESMessages, + 'zh-Hant': zhHantMessages, + 'ar': arMessages, + 'fr': frMessages, + 'pt-BR': ptBRMessages, }; diff --git a/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts b/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts new file mode 100644 index 00000000000..499966afd74 --- /dev/null +++ b/packages/services/service-messaging/src/translations/pt-BR.objects.generated.ts @@ -0,0 +1,485 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'pt-BR'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const ptBRObjects: NonNullable = { + sys_inbox_message: { + label: "Mensagem da caixa de entrada", + pluralLabel: "Mensagens da caixa de entrada", + description: "Linhas de notificação no app, voltadas ao usuário, materializadas pelo canal de mensagens da caixa de entrada.", + fields: { + id: { + label: "ID da mensagem" + }, + user_id: { + label: "Usuário destinatário" + }, + notification_id: { + label: "Evento de notificação", + help: "Chave estrangeira → sys_notification (o evento L2 que esta linha materializa)" + }, + delivery_id: { + label: "Entrega", + help: "Chave estrangeira → sys_notification_delivery (linha da caixa de saída); null até a P1" + }, + actor_id: { + label: "Autor da ação", + help: "Usuário que causou o evento (quem mencionou, quem atribuiu) — mesma semântica de sys_notification.actor_id. Permite que um cliente oculte os próprios recibos com uma comparação puramente local. Null em uma linha de resumo por construção: um grupo agrupado não tem um único autor." + }, + topic: { + label: "Tópico" + }, + title: { + label: "Título" + }, + body_md: { + label: "Corpo" + }, + severity: { + label: "Gravidade", + options: { + info: "Informação", + warning: "Aviso", + critical: "Crítico" + } + }, + action_url: { + label: "URL da ação" + }, + created_at: { + label: "Criado em" + } + }, + _views: { + mine: { + label: "Notificações", + emptyState: { + title: "Caixa de entrada vazia", + message: "Nenhuma notificação." + } + } + } + }, + sys_notification_receipt: { + label: "Recibo de notificação", + pluralLabel: "Recibos de notificação", + description: "Recibo por destinatário × canal; a fonte de verdade do estado de leitura das notificações.", + fields: { + id: { + label: "ID do recibo" + }, + notification_id: { + label: "Evento de notificação", + help: "Chave estrangeira → sys_notification (evento L2)" + }, + delivery_id: { + label: "Entrega", + help: "Chave estrangeira → sys_notification_delivery (linha da caixa de saída); null até a P1" + }, + user_id: { + label: "Usuário destinatário" + }, + channel: { + label: "Canal", + help: "ID do canal a que este recibo se refere (inbox / email / push / …)" + }, + state: { + label: "Estado", + options: { + delivered: "entregue", + read: "lido", + clicked: "clicado", + dismissed: "dispensado" + } + }, + at: { + label: "Em", + help: "Quando o recibo chegou ao estado atual" + }, + created_at: { + label: "Criado em" + } + } + }, + sys_notification_delivery: { + label: "Entrega de notificação", + pluralLabel: "Entregas de notificações", + description: "Caixa de saída durável de entregas por destinatário × canal.", + fields: { + id: { + label: "ID da entrega" + }, + display_title: { + label: "Título", + help: "Título do registro: o canal de entrega e a pessoa a quem se destina, ou o endereço quando o destinatário não é um usuário (calculado na leitura)" + }, + notification_id: { + label: "Evento de notificação", + help: "Chave estrangeira → sys_notification (evento L2)" + }, + recipient_id: { + label: "Destinatário", + help: "O endereço para onde a entrega é enviada: um ID de usuário, ou um e-mail ou telefone mantido como informado." + }, + recipient_user_id: { + label: "Usuário destinatário", + help: "O usuário a quem a entrega se destina. Vazio quando o destinatário é um e-mail ou telefone que nenhum usuário possui." + }, + channel: { + label: "Canal" + }, + topic: { + label: "Tópico" + }, + digest_key: { + label: "Chave de resumo", + help: "Chave de agrupamento recipient|channel|window para entregas em lote (resumo); null para envios normais." + }, + payload: { + label: "Carga útil", + help: "Instantâneo do conteúdo renderizado da notificação para o envio." + }, + status: { + label: "Status", + options: { + pending: "pendente", + in_flight: "em trânsito", + success: "sucesso", + failed: "falhou", + dead: "morta", + suppressed: "suprimida" + } + }, + attempts: { + label: "Tentativas" + }, + partition_key: { + label: "Chave de partição" + }, + claimed_by: { + label: "Reivindicado por", + help: "ID do nó durante in_flight" + }, + claimed_at: { + label: "Reivindicado em (ms)" + }, + next_attempt_at: { + label: "Próxima tentativa em (ms)" + }, + last_attempted_at: { + label: "Última tentativa em (ms)" + }, + error: { + label: "Erro" + }, + created_at: { + label: "Criado em" + }, + updated_at: { + label: "Atualizado em" + } + } + }, + sys_notification_preference: { + label: "Preferência de notificação", + pluralLabel: "Preferências de notificação", + description: "Quais tópicos e canais cada usuário recebe; os administradores definem os padrões.", + fields: { + id: { + label: "ID da preferência" + }, + display_title: { + label: "Título", + help: "Título do registro: a pessoa (ou All users), o tópico e o canal que esta opção cobre (calculado na leitura)" + }, + user_id: { + label: "Usuário", + help: "O usuário a quem esta preferência se aplica. Deixe em branco para o padrão que vale para todos os usuários." + }, + topic: { + label: "Tópico", + help: "O tópico em que as notificações são enviadas: o tópico que a etapa de notificação de um fluxo indica, ou um que a plataforma envia, como approval.requested ou collab.mention. Digite * para todos os tópicos. Um tópico que nada envia não corresponde a nada." + }, + channel: { + label: "Canal", + help: "O canal: inbox (notificações exibidas no app, sempre disponível), email (depois que um serviço de e-mail é configurado), sms (depois que um serviço de SMS é configurado) ou o ID de um canal adicionado por um plugin instalado. Digite * para todos os canais. Um ID que nenhum canal usa não corresponde a nada." + }, + enabled: { + label: "Ativado", + help: "Quando false, este (usuário, tópico, canal) fica silenciado." + }, + digest: { + label: "Resumo", + help: "Reúne estas notificações em um único resumo em vez de enviar cada uma ao chegar: daily envia à meia-noite, weekly na segunda-feira à meia-noite e none envia cada uma na hora. Notificações críticas são sempre enviadas na hora. Vale para uma preferência cujo canal é * (todos os canais).", + options: { + none: "nenhum", + daily: "diário", + weekly: "semanal" + } + }, + quiet_hours: { + label: "Horário silencioso", + help: "Uma janela diária em que as notificações ficam retidas e são entregues quando ela termina, por exemplo { \"tz\": \"Europe/Berlin\", \"start\": \"22:00\", \"end\": \"07:00\" }. Os horários são HH:MM nesse fuso (UTC quando tz é omitido), e a janela pode passar da meia-noite. Notificações críticas não ficam retidas. Vale para uma preferência cujo canal é * (todos os canais)." + }, + created_at: { + label: "Criado em" + }, + updated_at: { + label: "Atualizado em" + } + }, + _views: { + all: { + label: "Todos", + emptyState: { + title: "Nenhuma preferência ainda", + message: "Nada é silenciado até que uma preferência seja adicionada; adicione uma para silenciar ou permitir um tópico em um canal, para um usuário ou como padrão para todos." + } + } + } + }, + sys_notification_template: { + label: "Modelo de notificação", + pluralLabel: "Modelos de notificação", + description: "O texto de e-mail e SMS que cada tópico de notificação envia, por idioma. A caixa de entrada do app nunca usa estes modelos.", + fields: { + id: { + label: "ID do modelo" + }, + topic: { + label: "Tópico" + }, + channel: { + label: "Canal", + help: "Qual canal usa este modelo: email ou sms; qualquer outro valor é recusado. A caixa de entrada do app nunca usa; para conteúdo no app, escreva um modelo de e-mail e indique-o no template de uma etapa de notificação." + }, + locale: { + label: "Idioma", + help: "Código de idioma BCP-47, ex.: 'en' / 'en-US' / 'zh-CN'." + }, + version: { + label: "Versão" + }, + subject: { + label: "Assunto / Título", + help: "O assunto do e-mail; um SMS só o envia quando o corpo está vazio. Aceita {{ payload.x }}." + }, + body: { + label: "Corpo", + help: "Corpo do modelo. Aceita {{ payload.x }}. Interpretado conforme `format`." + }, + format: { + label: "Formato do corpo", + options: { + markdown: "Markdown", + html: "HTML", + text: "texto simples", + mjml: "MJML" + } + }, + is_active: { + label: "Ativo", + help: "Somente modelos ativos são escolhidos na renderização." + }, + created_at: { + label: "Criado em" + }, + updated_at: { + label: "Atualizado em" + } + }, + _views: { + all: { + label: "Todos", + emptyState: { + title: "Nenhum modelo ainda", + message: "Adicione um modelo por tópico, canal (e-mail ou SMS) e idioma; até lá, esses canais enviam o título e o corpo que a notificação traz. O conteúdo no app vem de um modelo de e-mail: uma notificação que indica um modelo o envia por e-mail e na caixa de entrada, e o SMS continua usando o modelo daqui." + } + } + }, + _validations: { + channel_renders_template: { + message: "Somente os canais email e sms usam um modelo de notificação, então um modelo para qualquer outro canal é recusado: defina channel como email ou sms, ou exclua o modelo. Para conteúdo no app (caixa de entrada), escreva um modelo de e-mail (email_template) e indique-o no template da etapa de notificação; o e-mail e a caixa de entrada o renderizam." + } + } + }, + sys_http_delivery: { + label: "Entrega HTTP", + pluralLabel: "Entregas HTTP", + description: "Chamadas HTTP de saída feitas por webhooks e fluxos, uma linha por chamada, com o status de entrega.", + fields: { + id: { + label: "ID da entrega", + help: "UUID — também serve como chave de idempotência do lado do receptor" + }, + display_title: { + label: "Título", + help: "Título do registro: o rótulo da entrega, se definido, e a URL de destino (calculado na leitura)" + }, + source: { + label: "Origem", + help: "Domínio de origem, ex.: 'webhook' | 'flow'. UNIQUE(source, dedup_key)." + }, + ref_id: { + label: "ID de referência", + help: "Âncora de partição/ordenação dentro da origem (ID do webhook, ID do fluxo, …)" + }, + dedup_key: { + label: "Chave de desduplicação", + help: "UNIQUE(source, dedup_key) para enfileiramento no máximo uma vez" + }, + label: { + label: "Rótulo", + help: "Rótulo de diagnóstico / tipo de evento — exposto em X-Objectstack-Event" + }, + url: { + label: "URL de destino", + help: "Capturada ao enfileirar para que edições de configuração não reescrevam linhas ativas" + }, + method: { + label: "Método" + }, + headers_json: { + label: "Cabeçalhos JSON", + help: "Cabeçalhos de requisição definidos para esta entrega — o lugar comum de uma credencial, por isso nunca retornados pelo caminho de dados genérico. O despachante os recupera pelo acessor privilegiado do motor no caminho de reivindicação." + }, + signature: { + label: "Assinatura HMAC", + help: "X-Objectstack-Signature enviada com esta entrega (sha256=). Calculada ao enfileirar; o segredo de assinatura nunca é armazenado." + }, + timeout_ms: { + label: "Tempo limite (ms)" + }, + payload_json: { + label: "Carga útil JSON" + }, + partition_key: { + label: "Partição", + help: "hash(ref_id) mod partitionCount — pré-calculado para um WHERE barato" + }, + status: { + label: "Status", + help: "pending | in_flight | success | failed | dead" + }, + attempts: { + label: "Tentativas", + help: "Número de tentativas feitas até agora. 0 em uma linha terminal significa que a entrega ficou PARKED — nunca enviada e não reenviável, pois não tem assinatura; veja `error` para a causa." + }, + claimed_by: { + label: "Reivindicado por" + }, + claimed_at: { + label: "Reivindicado em (ms)" + }, + next_retry_at: { + label: "Próxima nova tentativa em (ms)" + }, + last_attempted_at: { + label: "Última tentativa em (ms)" + }, + response_code: { + label: "Status HTTP" + }, + response_body: { + label: "Corpo da resposta (truncado)" + }, + error: { + label: "Erro", + help: "Por que esta linha não é uma chamada entregue: o último erro de transporte ou — em uma linha com 0 tentativas — por que a entrega nunca pôde ser preparada." + }, + created_at: { + label: "Criado em" + }, + updated_at: { + label: "Atualizado em" + } + }, + _views: { + recent: { + label: "Recentes" + }, + failures: { + label: "Falhas" + }, + pending: { + label: "Pendentes" + } + } + }, + sys_user_device: { + label: "Dispositivo do usuário", + pluralLabel: "Dispositivos dos usuários", + description: "O dispositivo de push de um usuário: uma linha por instalação do app, com o token de push do fornecedor.", + fields: { + id: { + label: "ID da linha" + }, + device_id: { + label: "ID do dispositivo", + help: "ID de dispositivo mantido pelo cliente, estável por instalação do app. Único por usuário." + }, + user_id: { + label: "Usuário", + help: "O usuário dono do dispositivo: o usuário conectado que o registrou." + }, + vendor: { + label: "Fornecedor", + help: "Fornecedor de push ao qual o token pertence.", + options: { + apns: "APNs", + fcm: "FCM" + } + }, + token: { + label: "Token de push", + help: "Token de push do fornecedor. Fica com um dispositivo por vez." + }, + platform: { + label: "Plataforma", + options: { + ios: "iOS", + android: "Android", + web: "Web" + } + }, + app_id: { + label: "ID do app", + help: "O bundle ID do iOS ou o ID de aplicativo Android para o qual o token foi emitido." + }, + locale: { + label: "Localidade", + help: "Localidade do dispositivo, conforme informada no registro. Armazenada para o dispositivo; nenhum push é renderizado por ela: um push chega no idioma do destinatário (o idioma do usuário dele e, em seguida, o padrão da implantação)." + }, + name: { + label: "Nome do dispositivo" + }, + enabled: { + label: "Ativado", + help: "False quando o fornecedor informa que o token está inválido; nada é entregue a um dispositivo desativado." + }, + invalidated_at: { + label: "Invalidado em", + help: "Quando o fornecedor informou que o token está inválido. Limpo pelo registro que o reativa." + }, + last_seen_at: { + label: "Visto por último em", + help: "Quando o dispositivo se registrou ou atualizou pela última vez." + }, + created_at: { + label: "Criado em" + } + } + } +}; diff --git a/packages/services/service-messaging/src/translations/pt-BR.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/pt-BR.source-hashes.generated.ts new file mode 100644 index 00000000000..056fb4c019d --- /dev/null +++ b/packages/services/service-messaging/src/translations/pt-BR.source-hashes.generated.ts @@ -0,0 +1,29 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'pt-BR'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const ptBRGeneratedSourceHashes: Readonly> = { + "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", + "objects.sys_http_delivery.fields.status.label": "64a420165247654b", + "objects.sys_notification_delivery.fields.status.label": "64a420165247654b", + "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812", + "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91", + "objects.sys_user_device.fields.platform.options.web": "2a7ead3da8c035c5", + "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698", + "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4", +}; diff --git a/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts new file mode 100644 index 00000000000..9699ed24988 --- /dev/null +++ b/packages/services/service-messaging/src/translations/zh-Hant.objects.generated.ts @@ -0,0 +1,485 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'zh-Hant'. + * Edit translations in place; re-run extract (with --merge) to fill new gaps. + * Do not hand-edit the structure — only the leaf string values. + * Merge only fills gaps: correcting a source label/description does not + * push the correction into a leaf here that already holds a translation — + * a present-but-stale string is not a gap, so it is left as-is. Re-translate + * it by hand when its source changes; nothing here or in `os i18n check` + * tells a leaf a translator updated on purpose from one nobody has looked + * at since the source moved. + */ + +import type { TranslationData } from '@objectstack/spec/system'; + +export const zhHantObjects: NonNullable = { + sys_inbox_message: { + label: "站內訊息", + pluralLabel: "站內訊息", + description: "由站內信通道具體化、面向使用者的應用程式內通知列。", + fields: { + id: { + label: "站內訊息 ID" + }, + user_id: { + label: "收件使用者" + }, + notification_id: { + label: "通知事件", + help: "外鍵 → sys_notification(本列所具體化的 L2 事件)" + }, + delivery_id: { + label: "傳送紀錄", + help: "外鍵 → sys_notification_delivery(寄件匣列);P1 之前為 null" + }, + actor_id: { + label: "執行者", + help: "觸發此事件的使用者(提及者、指派者)——與 sys_notification.actor_id 語意相同。用戶端可據此以純本機比較隱藏自己觸發的回執。摘要列依構造為 null:合併後的群組沒有單一執行者。" + }, + topic: { + label: "主題" + }, + title: { + label: "標題" + }, + body_md: { + label: "內文" + }, + severity: { + label: "嚴重程度", + options: { + info: "資訊", + warning: "警告", + critical: "嚴重" + } + }, + action_url: { + label: "動作連結" + }, + created_at: { + label: "建立時間" + } + }, + _views: { + mine: { + label: "通知", + emptyState: { + title: "收件匣已清空", + message: "目前沒有任何通知。" + } + } + } + }, + sys_notification_receipt: { + label: "通知回執", + pluralLabel: "通知回執", + description: "依收件者 × 通道的回執;通知已讀狀態的唯一權威來源。", + fields: { + id: { + label: "回執 ID" + }, + notification_id: { + label: "通知事件", + help: "外鍵 → sys_notification(L2 事件)" + }, + delivery_id: { + label: "傳送紀錄", + help: "外鍵 → sys_notification_delivery(寄件匣列);P1 之前為 null" + }, + user_id: { + label: "收件使用者" + }, + channel: { + label: "通道", + help: "此回執對應的通道 ID(inbox / email / push / …)" + }, + state: { + label: "狀態", + options: { + delivered: "已送達", + read: "已讀", + clicked: "已點擊", + dismissed: "已忽略" + } + }, + at: { + label: "狀態時間", + help: "回執到達目前狀態的時間" + }, + created_at: { + label: "建立時間" + } + } + }, + sys_notification_delivery: { + label: "通知傳送", + pluralLabel: "通知傳送", + description: "持久化、依收件者 × 通道的傳送寄件匣。", + fields: { + id: { + label: "傳送 ID" + }, + display_title: { + label: "標題", + help: "記錄標題:傳送通道及其對象;收件者不是使用者時則為該地址(讀取時計算)" + }, + notification_id: { + label: "通知事件", + help: "外鍵 → sys_notification(L2 事件)" + }, + recipient_id: { + label: "收件者", + help: "傳送的目標地址:使用者 ID,或依原樣保留的電子郵件地址或電話號碼。" + }, + recipient_user_id: { + label: "收件使用者", + help: "此傳送的對象使用者。收件者是沒有對應使用者的電子郵件地址或電話號碼時為空。" + }, + channel: { + label: "通道" + }, + topic: { + label: "主題" + }, + digest_key: { + label: "摘要鍵", + help: "批次(摘要)傳送的 recipient|channel|window 分組鍵;一般傳送為 null。" + }, + payload: { + label: "承載", + help: "用於分派的通知內容呈現快照。" + }, + status: { + label: "狀態", + options: { + pending: "待處理", + in_flight: "傳送中", + success: "成功", + failed: "失敗", + dead: "無效信件", + suppressed: "已抑制" + } + }, + attempts: { + label: "嘗試次數" + }, + partition_key: { + label: "分割區鍵" + }, + claimed_by: { + label: "認領節點", + help: "in_flight 期間的節點 ID" + }, + claimed_at: { + label: "認領時間(毫秒)" + }, + next_attempt_at: { + label: "下次嘗試時間(毫秒)" + }, + last_attempted_at: { + label: "上次嘗試時間(毫秒)" + }, + error: { + label: "錯誤" + }, + created_at: { + label: "建立時間" + }, + updated_at: { + label: "更新時間" + } + } + }, + sys_notification_preference: { + label: "通知偏好設定", + pluralLabel: "通知偏好設定", + description: "每位使用者接收哪些主題與通道;預設值由管理員設定。", + fields: { + id: { + label: "偏好設定 ID" + }, + display_title: { + label: "標題", + help: "記錄標題:此開關適用的人員(或 All users)、主題與通道(讀取時計算)" + }, + user_id: { + label: "使用者", + help: "此偏好設定適用的使用者。留空即為適用於所有使用者的預設值。" + }, + topic: { + label: "主題", + help: "通知所使用的主題:流程中通知步驟指定的主題,或平台送出的主題,例如 approval.requested 或 collab.mention。輸入 * 代表所有主題。沒有任何發送方使用的主題不會符合任何通知。" + }, + channel: { + label: "通道", + help: "通道:inbox(應用程式內顯示的通知,一律可用)、email(設定郵件服務後可用)、sms(設定簡訊服務後可用),或已安裝外掛程式新增的通道 ID。輸入 * 代表所有通道。沒有任何通道使用的 ID 不會符合任何通知。" + }, + enabled: { + label: "已啟用", + help: "為 false 時,此(使用者、主題、通道)組合會被靜音。" + }, + digest: { + label: "摘要", + help: "將這些通知彙整成一份摘要,而不是逐則送出:daily 於午夜送出,weekly 於週一午夜送出,none 則每則立即送出。緊急通知一律立即送出。僅在通道為 *(所有通道)的偏好設定上生效。", + options: { + none: "不彙整", + daily: "每日", + weekly: "每週" + } + }, + quiet_hours: { + label: "勿擾時段", + help: "每天暫緩通知、並於時段結束時送達的時段,例如 { \"tz\": \"Europe/Berlin\", \"start\": \"22:00\", \"end\": \"07:00\" }。時間為該時區的 HH:MM(未填 tz 時為 UTC),時段可以跨越午夜。緊急通知不會暫緩。僅在通道為 *(所有通道)的偏好設定上生效。" + }, + created_at: { + label: "建立時間" + }, + updated_at: { + label: "更新時間" + } + }, + _views: { + all: { + label: "全部", + emptyState: { + title: "尚無偏好設定", + message: "新增偏好設定之前,任何通知都不會被靜音;新增一筆,即可為某位使用者或作為所有人的預設值,將某個主題在某個通道上靜音或允許。" + } + } + } + }, + sys_notification_template: { + label: "通知範本", + pluralLabel: "通知範本", + description: "每個通知主題依語言送出的電子郵件與簡訊文字。應用程式內收件匣從不使用這些範本。", + fields: { + id: { + label: "範本 ID" + }, + topic: { + label: "主題" + }, + channel: { + label: "通道", + help: "使用此範本的通道:email 或 sms,其他值會被拒絕。應用程式內收件匣從不使用;應用程式內內容請撰寫電子郵件範本,並在通知步驟的 template 中指定它。" + }, + locale: { + label: "語言", + help: "BCP-47 語言代碼,例如 'en' / 'en-US' / 'zh-CN'。" + }, + version: { + label: "版本" + }, + subject: { + label: "主旨/標題", + help: "電子郵件主旨;簡訊僅在內文為空時送出它。支援 {{ payload.x }}。" + }, + body: { + label: "內文", + help: "範本內文。支援 {{ payload.x }},依 `format` 解讀。" + }, + format: { + label: "內文格式", + options: { + markdown: "Markdown", + html: "HTML", + text: "純文字", + mjml: "MJML" + } + }, + is_active: { + label: "啟用", + help: "呈現時只會選用啟用中的範本。" + }, + created_at: { + label: "建立時間" + }, + updated_at: { + label: "更新時間" + } + }, + _views: { + all: { + label: "全部", + emptyState: { + title: "尚無範本", + message: "依主題、通道(電子郵件或簡訊)與語言新增範本;在此之前,這些通道會送出通知本身所帶的標題與內文。應用程式內內容來自電子郵件範本:指定了範本的通知會以電子郵件並在收件匣中送出它,簡訊仍使用此處的範本。" + } + } + }, + _validations: { + channel_renders_template: { + message: "只有 email 與 sms 通道使用通知範本,因此其他通道的範本會被拒絕:請將 channel 設為 email 或 sms,或刪除此範本。應用程式內(收件匣)內容請撰寫電子郵件範本(email_template),並在通知步驟的 template 中指定它;電子郵件與收件匣都會呈現它。" + } + } + }, + sys_http_delivery: { + label: "HTTP 傳送", + pluralLabel: "HTTP 傳送", + description: "由 Webhook 與流程發出的外送 HTTP 呼叫,每次呼叫一列,並記錄其傳送狀態。", + fields: { + id: { + label: "傳送 ID", + help: "UUID——同時作為接收端的冪等鍵" + }, + display_title: { + label: "標題", + help: "記錄標題:傳送標籤(若已設定)及其目標 URL(讀取時計算)" + }, + source: { + label: "來源", + help: "來源網域,例如 'webhook' | 'flow'。UNIQUE(source, dedup_key)。" + }, + ref_id: { + label: "參照 ID", + help: "來源內的分割/排序錨點(webhook ID、flow ID 等)" + }, + dedup_key: { + label: "去重鍵", + help: "UNIQUE(source, dedup_key),確保至多入佇列一次" + }, + label: { + label: "標籤", + help: "診斷標籤/事件類型——透過 X-Objectstack-Event 標頭公開" + }, + url: { + label: "目標 URL", + help: "入佇列時建立快照,因此修改設定不會改寫現有列" + }, + method: { + label: "方法" + }, + headers_json: { + label: "標頭 JSON", + help: "此次傳送所撰寫的請求標頭——憑證通常就放在這裡,因此一般資料路徑從不傳回。排程器在認領路徑上透過引擎的特權存取器取回它。" + }, + signature: { + label: "HMAC 簽章", + help: "此次傳送所送出的 X-Objectstack-Signature(sha256=)。入佇列時計算,簽章密鑰從不儲存。" + }, + timeout_ms: { + label: "逾時(毫秒)" + }, + payload_json: { + label: "承載 JSON" + }, + partition_key: { + label: "分割區", + help: "hash(ref_id) mod partitionCount——預先計算以利低成本 WHERE" + }, + status: { + label: "狀態", + help: "pending | in_flight | success | failed | dead" + }, + attempts: { + label: "嘗試次數", + help: "目前為止的嘗試次數。終態列上為 0 表示此傳送已被 PARKED——從未送出且無法重新傳送,因為它不帶簽章;原因見 `error`。" + }, + claimed_by: { + label: "認領節點" + }, + claimed_at: { + label: "認領時間(毫秒)" + }, + next_retry_at: { + label: "下次重試時間(毫秒)" + }, + last_attempted_at: { + label: "上次嘗試時間(毫秒)" + }, + response_code: { + label: "HTTP 狀態碼" + }, + response_body: { + label: "回應內文(截斷)" + }, + error: { + label: "錯誤", + help: "此列為何不是已送達的呼叫:最後一次傳輸錯誤,或——在嘗試次數為 0 的列上——此傳送為何始終無法備妥。" + }, + created_at: { + label: "建立時間" + }, + updated_at: { + label: "更新時間" + } + }, + _views: { + recent: { + label: "最近" + }, + failures: { + label: "失敗" + }, + pending: { + label: "待處理" + } + } + }, + sys_user_device: { + label: "使用者裝置", + pluralLabel: "使用者裝置", + description: "使用者的推播裝置:每次安裝應用程式一列,保存其廠商推播權杖。", + fields: { + id: { + label: "列 ID" + }, + device_id: { + label: "裝置 ID", + help: "由用戶端保存的裝置 ID,每次安裝應用程式皆保持不變。同一使用者內唯一。" + }, + user_id: { + label: "使用者", + help: "裝置所屬的使用者:註冊該裝置的已登入使用者。" + }, + vendor: { + label: "廠商", + help: "權杖所屬的推播廠商。", + options: { + apns: "APNs", + fcm: "FCM" + } + }, + token: { + label: "推播權杖", + help: "廠商推播權杖。同一時間只由一台裝置持有。" + }, + platform: { + label: "平台", + options: { + ios: "iOS", + android: "Android", + web: "網頁" + } + }, + app_id: { + label: "應用程式 ID", + help: "權杖所核發給的 iOS Bundle ID 或 Android 應用程式 ID。" + }, + locale: { + label: "地區語言", + help: "註冊時提交的裝置地區語言,隨裝置儲存;推播不依它呈現:推播以收件人的語言送達(其使用者語言,其次為部署預設語言)。" + }, + name: { + label: "裝置名稱" + }, + enabled: { + label: "已啟用", + help: "廠商回報權杖失效後為 false;不會傳送任何內容到已停用的裝置。" + }, + invalidated_at: { + label: "失效時間", + help: "廠商回報權杖失效的時間。重新啟用該裝置的註冊會將其清除。" + }, + last_seen_at: { + label: "最近活動時間", + help: "裝置最近一次註冊或重新整理的時間。" + }, + created_at: { + label: "建立時間" + } + } + } +}; diff --git a/packages/services/service-messaging/src/translations/zh-Hant.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/zh-Hant.source-hashes.generated.ts new file mode 100644 index 00000000000..30064f91295 --- /dev/null +++ b/packages/services/service-messaging/src/translations/zh-Hant.source-hashes.generated.ts @@ -0,0 +1,26 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Auto-generated by 'os i18n extract' for locale 'zh-Hant'. Do not hand-edit. + * + * Each entry is the digest of the SOURCE REVISION that this locale's leaf at + * that path is still a byte copy of — provenance for the generated half of the + * bundles (commit 09b4f4e4e): a leaf whose digest no longer matches its source is stale and serves the source text instead. + * + * An entry exists only while the leaf IS such a copy. Re-translate the leaf in + * `.objects.generated.ts` and the next extract drops its entry by + * itself — the table makes no claim about text a translator wrote. A path with + * no entry is LEGACY-TRUSTED and never reported stale. + * + * ⚠️ Do not "fix" a staleness report by editing this file. Refreshing a digest + * records that the current text was copied from the current source, which is + * the false claim the mechanism exists to detect. Fix the TRANSLATION. + */ + +export const zhHantGeneratedSourceHashes: Readonly> = { + "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", + "objects.sys_user_device.fields.platform.options.android": "44fa27bdaae7f812", + "objects.sys_user_device.fields.platform.options.ios": "461f32943613be91", + "objects.sys_user_device.fields.vendor.options.apns": "b6edcf327f308698", + "objects.sys_user_device.fields.vendor.options.fcm": "fc3b030ffd243df4", +}; From 91247933f40d2cef2a8ec0118eed28ba2c19dd25 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 19:41:09 +0000 Subject: [PATCH 2/2] i18n(service-messaging): name the package's place in the series in its changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6Cai5CRdAs4u9gTipWU --- .changeset/30512-service-messaging-locales-a.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/30512-service-messaging-locales-a.md b/.changeset/30512-service-messaging-locales-a.md index 95ec8b4d23e..b10d80cac8a 100644 --- a/.changeset/30512-service-messaging-locales-a.md +++ b/.changeset/30512-service-messaging-locales-a.md @@ -7,4 +7,4 @@ i18n(service-messaging): the notification objects and the digest heading ship in Clause-②: no Metadata-protocol: none -The messaging objects (`sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_template`, `sys_http_delivery`, `sys_user_device`) and the digest heading "You have {{count}} notifications" now carry `zh-Hant`, `ar`, `fr` and `pt-BR` beside the existing `en`, `zh-CN`, `ja-JP` and `es-ES`, so a recipient in one of those languages reads the notification screens and the digest heading in their own language instead of English. `t()` selects no plural form, so the Arabic heading is worded as a tally (`عدد الإشعارات لديك: {{count}}`), correct for every count. A recipient whose language is `fr-FR` now reads the `fr` pack. Each new locale is registered under its platform-table tag with its source-hash companion and its messages, and the package's extract command lists the tags, so the existing `check:i18n` bundle check covers them. First half of package 9 of #30512; `ru`, `de` and `ko` follow in the second half. +The messaging objects (`sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_template`, `sys_http_delivery`, `sys_user_device`) and the digest heading "You have {{count}} notifications" now carry `zh-Hant`, `ar`, `fr` and `pt-BR` beside the existing `en`, `zh-CN`, `ja-JP` and `es-ES`, so a recipient in one of those languages reads the notification screens and the digest heading in their own language instead of English. `t()` selects no plural form, so the Arabic heading is worded as a tally (`عدد الإشعارات لديك: {{count}}`), correct for every count. A recipient whose language is `fr-FR` now reads the `fr` pack. Each new locale is registered under its platform-table tag with its source-hash companion and its messages, and the package's extract command lists the tags, so the existing `check:i18n` bundle check covers them. First half of package 5 of #30512; `ru`, `de` and `ko` follow in the second half.