Skip to content

Commit 54b71be

Browse files
baozhoutaoclaude
andcommitted
feat(i18n): localize collab notification titles; add service-storage translation bundle
Fixes three symptoms observed on a zh-CN workspace: - the bell title 'sys_file "repro.png" assigned to you' was a hardcoded English literal built from the raw object API name — assignment and @mention titles now resolve through i18n (messages.assignedToYou / mentionedYou / mentionedYouAnonymous) in the RECIPIENT's locale, with the object named by its translated label (objects.{name}.label → def label → API name); every step degrades best-effort to English. - sys_file / sys_upload_session had no translation bundle, so the file detail page (labels + Pending Upload / Committed / Deleted status pipeline) rendered English on every locale — service-storage now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES) loaded on kernel:ready, mirroring service-messaging. (The third symptom — unread state never clearing — is an objectui fix: the console wrote sys_notification_receipt via the generic data API, which ADR-0103 made read-only; it must use POST /api/v1/notifications/read.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 45a3769 commit 54b71be

11 files changed

Lines changed: 884 additions & 20 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
"@objectstack/service-storage": patch
4+
---
5+
6+
feat(i18n): localize collaboration notification titles and the storage objects
7+
8+
Two gaps left the notification surface English-only on localized workspaces
9+
(observed as `sys_file "repro.png" assigned to you` over an all-Chinese UI):
10+
11+
- **plugin-audit** — the assignment (`collab.assignment`) and @mention
12+
(`collab.mention`) bell titles were hardcoded English literals built from the
13+
raw object API name. They now resolve through the i18n service with the same
14+
key shapes as the activity summaries (framework#3039): new
15+
`messages.assignedToYou` / `messages.mentionedYou` /
16+
`messages.mentionedYouAnonymous` templates (en / zh-CN / ja-JP / es-ES), the
17+
object named by its translated label (`objects.{name}.label` → authored def
18+
label → API name), and the locale resolved for the **recipient** (they read
19+
the bell), not the acting user. Every step stays best-effort: no locale / no
20+
i18n / key miss degrades to the English literal — which now also prefers the
21+
authored object label over the API name.
22+
23+
- **service-storage**`sys_file` / `sys_upload_session` had no translation
24+
bundle at all, so the file detail page (labels, and the Pending Upload /
25+
Committed / Deleted status pipeline) rendered English on every locale. The
26+
service now ships its own ADR-0029 D8 bundle (en / zh-CN / ja-JP / es-ES,
27+
`src/translations` + `scripts/i18n-extract.config.ts`) and contributes it via
28+
`i18n.loadTranslations` on `kernel:ready`, matching service-messaging.
29+
(`sys_attachment` stays in platform-objects' bundles pending the
30+
storage-domain decomposition.)

packages/plugins/plugin-audit/src/audit-writers.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,4 +636,78 @@ describe('audit writers — localized activity summaries (framework#3039)', { ti
636636
await fire('afterInsert', { ...insertCtx(), result: { id: 'q-2', name: 'OC-00002' } });
637637
expect(localeCalls()).toBe(1);
638638
});
639+
640+
// Collaboration notification titles (assignment / @mention) are localized to
641+
// the RECIPIENT's locale with the same key shapes, and fall back to the
642+
// authored object label (not the API name) in English.
643+
function setupWithMessaging(
644+
locale: string | undefined,
645+
i18n: { t: Function } | undefined,
646+
objectDefs: Record<string, any> = {},
647+
schemas: Record<string, string[] | Record<string, any>> = SINGLE_TENANT,
648+
) {
649+
const { engine, fire } = makeEngine(schemas, objectDefs);
650+
const emits: any[] = [];
651+
installAuditWriters(engine as any, 'test.audit', {
652+
getI18n: () => i18n as any,
653+
getLocale: async () => locale,
654+
getMessaging: () => ({
655+
emit: async (e: any) => {
656+
emits.push(e);
657+
return {};
658+
},
659+
}),
660+
});
661+
return { fire, emits };
662+
}
663+
664+
it('localizes the assignment notification title to the recipient locale', async () => {
665+
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
666+
await fire('afterInsert', {
667+
...insertCtx(),
668+
result: { id: 'q-1', name: 'OC-00001', owner_id: 'user-2' },
669+
});
670+
const assignment = emits.find((e) => e.topic === 'collab.assignment');
671+
expect(assignment).toBeDefined();
672+
expect(assignment.audience).toEqual(['user-2']);
673+
expect(assignment.payload.title).toBe('人员资质 "OC-00001" 已分配给你');
674+
});
675+
676+
it('localizes the @mention notification title to the recipient locale', async () => {
677+
const { fire, emits } = setupWithMessaging('zh-CN', await makeI18n());
678+
await fire('afterInsert', {
679+
object: 'sys_comment',
680+
input: {},
681+
result: {
682+
id: 'c-1',
683+
thread_id: 'crm_lead:l-1',
684+
author_id: 'user-1',
685+
author_name: 'Alice',
686+
body: 'hello',
687+
mentions: '["user-2"]',
688+
},
689+
session: { tenantId: 'org-1', userId: 'user-1' },
690+
});
691+
const mention = emits.find((e) => e.topic === 'collab.mention');
692+
expect(mention).toBeDefined();
693+
expect(mention.audience).toEqual(['user-2']);
694+
expect(mention.payload.title).toBe('Alice 提到了你');
695+
});
696+
697+
it('falls back to an English title with the authored object label when i18n misses', async () => {
698+
const { fire, emits } = setupWithMessaging(
699+
undefined,
700+
undefined,
701+
{ crm_lead: { label: 'Lead' } },
702+
{ ...SINGLE_TENANT, crm_lead: ['id', 'name'] },
703+
);
704+
await fire('afterInsert', {
705+
object: 'crm_lead',
706+
input: { id: 'l-1' },
707+
result: { id: 'l-1', name: 'Acme', owner_id: 'user-2' },
708+
session: { tenantId: 'org-1', userId: 'user-1' },
709+
});
710+
const assignment = emits.find((e) => e.topic === 'collab.assignment');
711+
expect(assignment.payload.title).toBe('Lead "Acme" assigned to you');
712+
});
639713
});

packages/plugins/plugin-audit/src/audit-writers.ts

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,24 @@ export function installAuditWriters(
312312
return value;
313313
};
314314

315+
// Translate function bound to a locale. Returns undefined on any miss
316+
// (no locale / no i18n / key miss) so callers keep their English fallback
317+
// (framework#3039).
318+
const translateWith =
319+
(locale: string | undefined) =>
320+
(key: string, params?: Record<string, unknown>): string | undefined => {
321+
if (!locale) return undefined;
322+
const i18n = getI18n();
323+
if (!i18n || typeof i18n.t !== 'function') return undefined;
324+
try {
325+
const value = i18n.t(key, locale, params);
326+
// A miss returns the key verbatim (II18nService contract).
327+
return typeof value === 'string' && value !== key ? value : undefined;
328+
} catch {
329+
return undefined;
330+
}
331+
};
332+
315333
// Remove any prior installation so we can safely re-install on hot reload.
316334
if (typeof engine.unregisterHooksByPackage === 'function') {
317335
engine.unregisterHooksByPackage(packageId);
@@ -381,6 +399,20 @@ export function installAuditWriters(
381399
return def;
382400
};
383401

402+
// Display label for an object under a given translate fn: translated label
403+
// → authored def label → API name. Shared by activity summaries and the
404+
// collaboration notification titles below.
405+
const displayLabelFor = (
406+
objectName: string,
407+
translate: (key: string, params?: Record<string, unknown>) => string | undefined,
408+
): string => {
409+
const def = getObjectDef(objectName);
410+
return (
411+
translate(`objects.${objectName}.label`) ??
412+
(typeof def?.label === 'string' && def.label.length > 0 ? def.label : objectName)
413+
);
414+
};
415+
384416
/**
385417
* beforeUpdate / beforeDelete: capture "previous" snapshot via api.sudo()
386418
* so we can compute the diff in the afterXxx hook. We attach the snapshot
@@ -521,24 +553,8 @@ export function installAuditWriters(
521553
// default locale (ADR-0053, framework#3039). Every step is best-effort:
522554
// no locale / no i18n / key miss all degrade to the English literal.
523555
const locale = await resolveWriteLocale(tenantId, userId);
524-
const translate = (key: string, params?: Record<string, unknown>): string | undefined => {
525-
if (!locale) return undefined;
526-
const i18n = getI18n();
527-
if (!i18n || typeof i18n.t !== 'function') return undefined;
528-
try {
529-
const value = i18n.t(key, locale, params);
530-
// A miss returns the key verbatim (II18nService contract).
531-
return typeof value === 'string' && value !== key ? value : undefined;
532-
} catch {
533-
return undefined;
534-
}
535-
};
536-
const objectDef = getObjectDef(ctx.object);
537-
const objectDisplay =
538-
translate(`objects.${ctx.object}.label`) ??
539-
(typeof objectDef?.label === 'string' && objectDef.label.length > 0
540-
? objectDef.label
541-
: ctx.object);
556+
const translate = translateWith(locale);
557+
const objectDisplay = displayLabelFor(ctx.object, translate);
542558
let summary: string;
543559
let activityType: string = activityTypeFor(action);
544560
if (action === 'create') {
@@ -614,6 +630,16 @@ export function installAuditWriters(
614630
after,
615631
actorId: userId ?? null,
616632
tenantId: tenantId ?? null,
633+
// Localize the bell title to the RECIPIENT's locale (they read it),
634+
// not the acting user's — same key shapes as the activity summaries.
635+
makeTitle: async (recipientId) => {
636+
const rTranslate = translateWith(await resolveWriteLocale(tenantId, recipientId));
637+
const objectLabel = displayLabelFor(ctx.object, rTranslate);
638+
return (
639+
rTranslate('messages.assignedToYou', { object: objectLabel, label }) ??
640+
`${objectLabel} "${label}" assigned to you`
641+
);
642+
},
617643
});
618644
} catch (err) {
619645
// Log via engine logger if available, but never throw.
@@ -729,6 +755,12 @@ export function installAuditWriters(
729755
for (const uid of userIds) {
730756
if (uid === actorId) continue; // don't notify the mention author
731757
try {
758+
// Localized to the mentioned user's locale (same rationale as the
759+
// assignment titles); miss → English literal.
760+
const tr = translateWith(await resolveWriteLocale(tenantId ?? undefined, uid));
761+
const title = actorName
762+
? (tr('messages.mentionedYou', { actor: actorName }) ?? `${actorName} mentioned you`)
763+
: (tr('messages.mentionedYouAnonymous') ?? 'You were mentioned');
732764
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
733765
// channel materializes the bell row + a delivered receipt.
734766
await messaging.emit({
@@ -740,7 +772,7 @@ export function installAuditWriters(
740772
organizationId: tenantId ?? undefined,
741773
dedupKey: commentId ? `collab.mention:${commentId}:${uid}` : undefined,
742774
payload: {
743-
title: actorName ? `${actorName} mentioned you` : 'You were mentioned',
775+
title,
744776
body: bodyPreview,
745777
actorName,
746778
},
@@ -780,6 +812,12 @@ async function writeAssignmentNotifications(
780812
after: any;
781813
actorId: string | null;
782814
tenantId: string | null;
815+
/**
816+
* Build the (recipient-locale-localized) notification title. Optional —
817+
* absent or throwing, the English literal below is used, with the raw
818+
* object API name (pre-#3039 behavior).
819+
*/
820+
makeTitle?: (recipientId: string) => Promise<string>;
783821
},
784822
): Promise<void> {
785823
if (!messaging) return; // no pipeline installed → no assignment notifications
@@ -792,6 +830,15 @@ async function writeAssignmentNotifications(
792830
if (params.action === 'update' && newOwner === oldOwner) return;
793831
if (newOwner === params.actorId) return; // self-assignment is silent
794832

833+
let title = `${params.object} "${params.label}" assigned to you`;
834+
if (params.makeTitle) {
835+
try {
836+
title = await params.makeTitle(newOwner);
837+
} catch {
838+
/* keep the English fallback */
839+
}
840+
}
841+
795842
try {
796843
// ADR-0030 single ingress — emit() writes the L2 event and the inbox
797844
// channel materializes the bell row + a delivered receipt. organizationId
@@ -817,7 +864,7 @@ async function writeAssignmentNotifications(
817864
? `collab.assignment:${params.object}:${params.recordId}:${newOwner}:${writeVersion}`
818865
: undefined,
819866
payload: {
820-
title: `${params.object} "${params.label}" assigned to you`,
867+
title,
821868
},
822869
});
823870
} catch {

packages/plugins/plugin-audit/src/translations/messages.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,34 @@ export const enMessages: Messages = {
1818
activityCreated: 'Created {{object}} "{{label}}"',
1919
activityUpdated: 'Updated {{object}} "{{label}}"',
2020
activityDeleted: 'Deleted {{object}} "{{label}}"',
21+
assignedToYou: '{{object}} "{{label}}" assigned to you',
22+
mentionedYou: '{{actor}} mentioned you',
23+
mentionedYouAnonymous: 'You were mentioned',
2124
};
2225

2326
export const zhCNMessages: Messages = {
2427
activityCreated: '创建了 {{object}} "{{label}}"',
2528
activityUpdated: '更新了 {{object}} "{{label}}"',
2629
activityDeleted: '删除了 {{object}} "{{label}}"',
30+
assignedToYou: '{{object}} "{{label}}" 已分配给你',
31+
mentionedYou: '{{actor}} 提到了你',
32+
mentionedYouAnonymous: '有人提到了你',
2733
};
2834

2935
export const jaJPMessages: Messages = {
3036
activityCreated: '{{object}}「{{label}}」を作成しました',
3137
activityUpdated: '{{object}}「{{label}}」を更新しました',
3238
activityDeleted: '{{object}}「{{label}}」を削除しました',
39+
assignedToYou: '{{object}}「{{label}}」があなたに割り当てられました',
40+
mentionedYou: '{{actor}}さんがあなたをメンションしました',
41+
mentionedYouAnonymous: 'あなたがメンションされました',
3342
};
3443

3544
export const esESMessages: Messages = {
3645
activityCreated: 'Creó {{object}} "{{label}}"',
3746
activityUpdated: 'Actualizó {{object}} "{{label}}"',
3847
activityDeleted: 'Eliminó {{object}} "{{label}}"',
48+
assignedToYou: 'Se te asignó {{object}} "{{label}}"',
49+
mentionedYou: '{{actor}} te mencionó',
50+
mentionedYouAnonymous: 'Te han mencionado',
3951
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Build-time only config for `os i18n extract` (ADR-0029 D8). Not deployed.
5+
* The service owns the i18n extraction for the objects it owns; the
6+
* `translations` baseline is this service's OWN generated bundles so re-running
7+
* `--merge` preserves every hand-translated string.
8+
*
9+
* `sys_attachment` is contributed by this plugin but still DEFINED in
10+
* platform-objects — its translations stay there pending the storage-domain
11+
* decomposition (see that package's extract config).
12+
*
13+
* os i18n extract packages/services/service-storage/scripts/i18n-extract.config.ts \
14+
* --locales=zh-CN,ja-JP,es-ES --fill=default --objects-only \
15+
* --out=packages/services/service-storage/src/translations
16+
*/
17+
18+
import { defineStack } from '@objectstack/spec';
19+
import { SystemFile, SystemUploadSession } from '../src/objects/index.js';
20+
import { enObjects } from '../src/translations/en.objects.generated.js';
21+
import { zhCNObjects } from '../src/translations/zh-CN.objects.generated.js';
22+
import { jaJPObjects } from '../src/translations/ja-JP.objects.generated.js';
23+
import { esESObjects } from '../src/translations/es-ES.objects.generated.js';
24+
25+
export default defineStack({
26+
name: 'service-storage-i18n-extract',
27+
objects: [SystemFile, SystemUploadSession] as any,
28+
translations: [
29+
{ en: { objects: enObjects } },
30+
{ 'zh-CN': { objects: zhCNObjects } },
31+
{ 'ja-JP': { objects: jaJPObjects } },
32+
{ 'es-ES': { objects: esESObjects } },
33+
],
34+
});

packages/services/service-storage/src/storage-service-plugin.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,23 @@ export class StorageServicePlugin implements Plugin {
184184
} catch {
185185
// manifest service may not be available in all environments
186186
}
187+
188+
// ADR-0029 D8 — contribute this service's object translations (sys_file /
189+
// sys_upload_session) to the i18n service on kernel:ready (the i18n plugin
190+
// may register after this one).
191+
if (typeof (ctx as any).hook === 'function') {
192+
(ctx as any).hook('kernel:ready', async () => {
193+
try {
194+
const i18n = ctx.getService<any>('i18n');
195+
if (i18n && typeof i18n.loadTranslations === 'function') {
196+
const { StorageTranslations } = await import('./translations/index.js');
197+
for (const [locale, data] of Object.entries(StorageTranslations)) {
198+
i18n.loadTranslations(locale, data as Record<string, unknown>);
199+
}
200+
}
201+
} catch { /* i18n optional */ }
202+
});
203+
}
187204
}
188205

189206
async start(ctx: PluginContext): Promise<void> {

0 commit comments

Comments
 (0)