Skip to content

Commit 87669b4

Browse files
authored
Merge pull request #848 from objectstack-ai/copilot/optimize-i18n-protocol
2 parents 47e03a7 + b73a88a commit 87669b4

5 files changed

Lines changed: 379 additions & 8 deletions

File tree

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
341341
- [x] **Data Protocol** — Object, Field (35+ types), Query, Filter, Validation, Hook, Datasource, Dataset, Analytics, Document, Storage Name Mapping (`tableName`/`columnName`), Feed & Activity Timeline (FeedItem, Comment, Mention, Reaction, FieldChange), Record Subscription (notification channels)
342342
- [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions
343343
- [x] **UI Protocol** — View (List/Form/Kanban/Calendar/Gantt), App, Dashboard, Report, Action, Page (16 types), Chart, Widget, Theme, Animation, DnD, Touch, Keyboard, Responsive, Offline, Notification, i18n, Content Elements, Enhanced Activity Timeline (`RecordActivityProps` unified timeline, `RecordChatterProps` sidebar/drawer), Unified Navigation Protocol (`NavigationItem` as single source of truth with 7 types: object/dashboard/page/url/report/action/group; `NavigationArea` for business domain partitioning; `order`/`badge`/`requiredPermissions` on all nav items), Airtable Interface Parity (`UserActionsConfig`, `AppearanceConfig`, `ViewTab`, `AddRecordConfig`, `InterfacePageConfig`, `showRecordCount`, `allowPrinting`)
344-
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation (object-first `AppTranslationBundle` + diff/coverage detection), Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities
344+
- [x] **System Protocol** — Manifest, Auth Config, Cache, Logging, Metrics, Tracing, Audit, Encryption, Masking, Migration, Tenant, Translation (object-first `AppTranslationBundle` + diff/coverage detection + ICU MessageFormat support + bundle `_meta`/bidi + namespace isolation + `_notifications`/`_errors` grouping + AI translation hooks + coverage breakdown), Search Engine, HTTP Server, Worker, Job, Object Storage, Notification, Message Queue, Registry Config, Collaboration, Compliance, Change Management, Disaster Recovery, License, Security Context, Core Services, SystemObjectName/SystemFieldName Constants, StorageNameMapping Utilities
345345
- [x] **Automation Protocol** — Flow (autolaunched/screen/schedule), Workflow, State Machine, Trigger Registry, Approval, ETL, Sync, Webhook, BPMN Semantics (parallel/join gateways, boundary events, wait events, default sequence flows), Node Executor Plugin Protocol (wait pause/resume, executor descriptors), BPMN XML Interop (import/export options, element mappings, diagnostics)
346346
- [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development
347347
- [x] **API Protocol** — Protocol (104 schemas), Endpoint, Contract, Router, Dispatcher, REST Server, GraphQL, OData, WebSocket, Realtime, Batch, Versioning, HTTP Cache, Documentation, Discovery, Registry, Errors, Auth, Auth Endpoints, Metadata, Analytics, Query Adapter, Storage, Plugin REST API, Feed API (Feed CRUD, Reactions, Subscription), Automation API (CRUD + Toggle + Runs)
@@ -477,7 +477,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow
477477

478478
| Contract | Priority | Package | Notes |
479479
|:---|:---:|:---|:---|
480-
| `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution; object-first bundle & diff detection |
480+
| `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution; object-first bundle & diff detection; AI suggestion hook (`suggestTranslations`) |
481481
| `IRealtimeService` | **P1** | `@objectstack/service-realtime` | WebSocket/SSE push (replaces Studio setTimeout hack) |
482482
| `IFeedService` | **P1** | `@objectstack/service-feed` | ✅ Feed/Chatter with comments, reactions, subscriptions |
483483
| `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver |

packages/spec/src/contracts/i18n-service.test.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import type { II18nService } from './i18n-service';
3-
import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod';
3+
import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod';
44

55
describe('I18n Service Contract', () => {
66
it('should allow a minimal II18nService implementation with required methods', () => {
@@ -162,5 +162,30 @@ describe('I18n Service Contract', () => {
162162
expect(minimalService.getAppBundle).toBeUndefined();
163163
expect(minimalService.loadAppBundle).toBeUndefined();
164164
expect(minimalService.getCoverage).toBeUndefined();
165+
expect(minimalService.suggestTranslations).toBeUndefined();
166+
});
167+
168+
it('should allow implementation with suggestTranslations', async () => {
169+
const service: II18nService = {
170+
t: () => '',
171+
getTranslations: () => ({}),
172+
loadTranslations: () => {},
173+
getLocales: () => ['en', 'zh-CN'],
174+
suggestTranslations: async (_locale, items) => {
175+
return items.map(item => ({
176+
...item,
177+
aiSuggested: `AI翻译: ${item.key}`,
178+
aiConfidence: 0.85,
179+
}));
180+
},
181+
};
182+
183+
const items: TranslationDiffItem[] = [
184+
{ key: 'o.account.fields.website.label', status: 'missing', locale: 'zh-CN' },
185+
];
186+
const suggestions = await service.suggestTranslations!('zh-CN', items);
187+
expect(suggestions).toHaveLength(1);
188+
expect(suggestions[0].aiSuggested).toBe('AI翻译: o.account.fields.website.label');
189+
expect(suggestions[0].aiConfidence).toBe(0.85);
165190
});
166191
});

packages/spec/src/contracts/i18n-service.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod';
3+
import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod';
44

55
/**
66
* II18nService - Internationalization Service Contract
@@ -89,4 +89,17 @@ export interface II18nService {
8989
* @returns Coverage result with per-key diff items
9090
*/
9191
getCoverage?(locale: string, objectName?: string): TranslationCoverageResult;
92+
93+
/**
94+
* Request AI-powered translation suggestions for missing or stale keys.
95+
*
96+
* Implementations may call an internal AI agent, external TMS, or
97+
* third-party translation API. Each returned diff item should have
98+
* `aiSuggested` and `aiConfidence` populated.
99+
*
100+
* @param locale - Target BCP-47 locale code
101+
* @param items - Diff items to generate suggestions for
102+
* @returns Diff items enriched with `aiSuggested` and `aiConfidence`
103+
*/
104+
suggestTranslations?(locale: string, items: TranslationDiffItem[]): Promise<TranslationDiffItem[]>;
92105
}

packages/spec/src/system/translation.test.ts

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,21 @@ import {
77
ObjectTranslationDataSchema,
88
TranslationFileOrganizationSchema,
99
TranslationConfigSchema,
10+
MessageFormatSchema,
1011
ObjectTranslationNodeSchema,
1112
AppTranslationBundleSchema,
1213
TranslationDiffStatusSchema,
1314
TranslationDiffItemSchema,
1415
TranslationCoverageResultSchema,
16+
CoverageBreakdownEntrySchema,
1517
type TranslationBundle,
1618
type ObjectTranslationData,
1719
type TranslationConfig,
1820
type ObjectTranslationNode,
1921
type AppTranslationBundle,
2022
type TranslationDiffItem,
2123
type TranslationCoverageResult,
24+
type CoverageBreakdownEntry,
2225
} from './translation.zod';
2326

2427
describe('LocaleSchema', () => {
@@ -474,6 +477,25 @@ describe('FieldTranslationSchema', () => {
474477
const result = FieldTranslationSchema.parse({});
475478
expect(result).toBeDefined();
476479
});
480+
481+
it('should accept field with placeholder', () => {
482+
const result = FieldTranslationSchema.parse({
483+
label: 'Email',
484+
placeholder: 'Enter your email address',
485+
});
486+
expect(result.placeholder).toBe('Enter your email address');
487+
});
488+
489+
it('should accept field with all properties including placeholder', () => {
490+
const result = FieldTranslationSchema.parse({
491+
label: '邮箱',
492+
help: '输入您的电子邮箱地址',
493+
placeholder: '例如:user@example.com',
494+
options: { work: '工作邮箱', personal: '个人邮箱' },
495+
});
496+
expect(result.label).toBe('邮箱');
497+
expect(result.placeholder).toBe('例如:user@example.com');
498+
});
477499
});
478500

479501
// ============================================================================
@@ -622,6 +644,38 @@ describe('TranslationConfigSchema', () => {
622644
}),
623645
).toThrow();
624646
});
647+
648+
it('should default messageFormat to simple', () => {
649+
const config = TranslationConfigSchema.parse({
650+
defaultLocale: 'en',
651+
supportedLocales: ['en'],
652+
});
653+
expect(config.messageFormat).toBe('simple');
654+
});
655+
656+
it('should accept ICU message format config', () => {
657+
const config = TranslationConfigSchema.parse({
658+
defaultLocale: 'en',
659+
supportedLocales: ['en', 'ar-SA'],
660+
messageFormat: 'icu',
661+
});
662+
expect(config.messageFormat).toBe('icu');
663+
});
664+
});
665+
666+
// ============================================================================
667+
// MessageFormatSchema
668+
// ============================================================================
669+
670+
describe('MessageFormatSchema', () => {
671+
it('should accept icu and simple', () => {
672+
expect(MessageFormatSchema.parse('icu')).toBe('icu');
673+
expect(MessageFormatSchema.parse('simple')).toBe('simple');
674+
});
675+
676+
it('should reject invalid format', () => {
677+
expect(() => MessageFormatSchema.parse('mf2')).toThrow();
678+
});
625679
});
626680

627681
// ============================================================================
@@ -703,6 +757,25 @@ describe('ObjectTranslationNodeSchema', () => {
703757
expect(node.fields?.stage.label).toBe('Stage');
704758
expect(node._views?.pipeline.label).toBe('Pipeline View');
705759
});
760+
761+
it('should accept node with _notifications and _errors', () => {
762+
const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({
763+
label: 'Order',
764+
_notifications: {
765+
order_shipped: { title: 'Order Shipped', body: 'Your order has been shipped.' },
766+
order_cancelled: { title: 'Order Cancelled' },
767+
},
768+
_errors: {
769+
insufficient_stock: 'Not enough stock for this order.',
770+
payment_failed: 'Payment could not be processed.',
771+
},
772+
});
773+
expect(node._notifications?.order_shipped.title).toBe('Order Shipped');
774+
expect(node._notifications?.order_shipped.body).toBe('Your order has been shipped.');
775+
expect(node._notifications?.order_cancelled.title).toBe('Order Cancelled');
776+
expect(node._errors?.insufficient_stock).toBe('Not enough stock for this order.');
777+
expect(node._errors?.payment_failed).toBe('Payment could not be processed.');
778+
});
706779
});
707780

708781
// ============================================================================
@@ -822,6 +895,45 @@ describe('AppTranslationBundleSchema', () => {
822895
expect(zh.nav?.home).toBe('首页');
823896
expect(zh.messages?.['common.save']).toBe('保存');
824897
});
898+
899+
it('should accept bundle with _meta for RTL locale', () => {
900+
const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({
901+
_meta: { locale: 'ar-SA', direction: 'rtl' },
902+
messages: { 'common.save': 'حفظ' },
903+
});
904+
expect(bundle._meta?.locale).toBe('ar-SA');
905+
expect(bundle._meta?.direction).toBe('rtl');
906+
});
907+
908+
it('should accept bundle with namespace for plugin isolation', () => {
909+
const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({
910+
namespace: 'plugin-helpdesk',
911+
o: { ticket: { label: 'Ticket' } },
912+
});
913+
expect(bundle.namespace).toBe('plugin-helpdesk');
914+
});
915+
916+
it('should accept bundle with global notifications and errors', () => {
917+
const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({
918+
notifications: {
919+
system_update: { title: 'System Update', body: 'A new version is available.' },
920+
},
921+
errors: {
922+
unauthorized: 'You are not authorized to perform this action.',
923+
not_found: 'The requested resource was not found.',
924+
},
925+
});
926+
expect(bundle.notifications?.system_update.title).toBe('System Update');
927+
expect(bundle.errors?.unauthorized).toBe('You are not authorized to perform this action.');
928+
});
929+
930+
it('should accept bundle with _meta direction ltr', () => {
931+
const bundle = AppTranslationBundleSchema.parse({
932+
_meta: { direction: 'ltr' },
933+
});
934+
expect(bundle._meta?.direction).toBe('ltr');
935+
expect(bundle._meta?.locale).toBeUndefined();
936+
});
825937
});
826938

827939
// ============================================================================
@@ -883,6 +995,50 @@ describe('TranslationDiffItemSchema', () => {
883995
TranslationDiffItemSchema.parse({ status: 'missing', locale: 'en' }),
884996
).toThrow();
885997
});
998+
999+
it('should accept diff item with sourceHash', () => {
1000+
const item = TranslationDiffItemSchema.parse({
1001+
key: 'o.account.label',
1002+
status: 'stale',
1003+
locale: 'zh-CN',
1004+
sourceHash: 'sha256:abc123',
1005+
});
1006+
expect(item.sourceHash).toBe('sha256:abc123');
1007+
});
1008+
1009+
it('should accept diff item with AI suggestion fields', () => {
1010+
const item: TranslationDiffItem = TranslationDiffItemSchema.parse({
1011+
key: 'o.account.fields.website.label',
1012+
status: 'missing',
1013+
locale: 'zh-CN',
1014+
aiSuggested: '网站',
1015+
aiConfidence: 0.92,
1016+
});
1017+
expect(item.aiSuggested).toBe('网站');
1018+
expect(item.aiConfidence).toBe(0.92);
1019+
});
1020+
1021+
it('should reject AI confidence above 1', () => {
1022+
expect(() =>
1023+
TranslationDiffItemSchema.parse({
1024+
key: 'o.account.label',
1025+
status: 'missing',
1026+
locale: 'en',
1027+
aiConfidence: 1.5,
1028+
}),
1029+
).toThrow();
1030+
});
1031+
1032+
it('should reject AI confidence below 0', () => {
1033+
expect(() =>
1034+
TranslationDiffItemSchema.parse({
1035+
key: 'o.account.label',
1036+
status: 'missing',
1037+
locale: 'en',
1038+
aiConfidence: -0.1,
1039+
}),
1040+
).toThrow();
1041+
});
8861042
});
8871043

8881044
// ============================================================================
@@ -963,4 +1119,82 @@ describe('TranslationCoverageResultSchema', () => {
9631119
}),
9641120
).toThrow();
9651121
});
1122+
1123+
it('should accept result with breakdown', () => {
1124+
const result: TranslationCoverageResult = TranslationCoverageResultSchema.parse({
1125+
locale: 'zh-CN',
1126+
totalKeys: 100,
1127+
translatedKeys: 80,
1128+
missingKeys: 20,
1129+
redundantKeys: 0,
1130+
staleKeys: 0,
1131+
coveragePercent: 80,
1132+
items: [],
1133+
breakdown: [
1134+
{ group: 'fields', totalKeys: 60, translatedKeys: 50, coveragePercent: 83.3 },
1135+
{ group: 'views', totalKeys: 20, translatedKeys: 15, coveragePercent: 75 },
1136+
{ group: 'actions', totalKeys: 10, translatedKeys: 10, coveragePercent: 100 },
1137+
{ group: 'messages', totalKeys: 10, translatedKeys: 5, coveragePercent: 50 },
1138+
],
1139+
});
1140+
expect(result.breakdown).toHaveLength(4);
1141+
expect(result.breakdown![0].group).toBe('fields');
1142+
expect(result.breakdown![0].coveragePercent).toBe(83.3);
1143+
expect(result.breakdown![2].coveragePercent).toBe(100);
1144+
});
1145+
1146+
it('should accept result without breakdown (optional)', () => {
1147+
const result = TranslationCoverageResultSchema.parse({
1148+
locale: 'en',
1149+
totalKeys: 10,
1150+
translatedKeys: 10,
1151+
missingKeys: 0,
1152+
redundantKeys: 0,
1153+
staleKeys: 0,
1154+
coveragePercent: 100,
1155+
items: [],
1156+
});
1157+
expect(result.breakdown).toBeUndefined();
1158+
});
1159+
});
1160+
1161+
// ============================================================================
1162+
// CoverageBreakdownEntrySchema
1163+
// ============================================================================
1164+
1165+
describe('CoverageBreakdownEntrySchema', () => {
1166+
it('should accept a valid breakdown entry', () => {
1167+
const entry: CoverageBreakdownEntry = CoverageBreakdownEntrySchema.parse({
1168+
group: 'fields',
1169+
totalKeys: 50,
1170+
translatedKeys: 45,
1171+
coveragePercent: 90,
1172+
});
1173+
expect(entry.group).toBe('fields');
1174+
expect(entry.totalKeys).toBe(50);
1175+
expect(entry.translatedKeys).toBe(45);
1176+
expect(entry.coveragePercent).toBe(90);
1177+
});
1178+
1179+
it('should reject breakdown entry with negative totalKeys', () => {
1180+
expect(() =>
1181+
CoverageBreakdownEntrySchema.parse({
1182+
group: 'fields',
1183+
totalKeys: -1,
1184+
translatedKeys: 0,
1185+
coveragePercent: 0,
1186+
}),
1187+
).toThrow();
1188+
});
1189+
1190+
it('should reject breakdown entry with coverage above 100', () => {
1191+
expect(() =>
1192+
CoverageBreakdownEntrySchema.parse({
1193+
group: 'fields',
1194+
totalKeys: 10,
1195+
translatedKeys: 10,
1196+
coveragePercent: 101,
1197+
}),
1198+
).toThrow();
1199+
});
9661200
});

0 commit comments

Comments
 (0)