From f0b9c621ccd8a1dbca28b782774bfee39887fd13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:40:12 +0000 Subject: [PATCH 1/3] Initial plan From 083bf4f93c01d15babbedb1c5f58fdb06e6e4a7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:46:43 +0000 Subject: [PATCH 2/3] feat(i18n): enhance i18n protocol with ICU support, placeholder, sourceHash, _meta, namespace, notifications/errors, breakdown, AI hooks - Add `placeholder` to FieldTranslationSchema for form input hints - Add `MessageFormatSchema` (icu/simple) and `messageFormat` to TranslationConfigSchema - Add `_meta` (locale, direction/bidi) to AppTranslationBundleSchema for RTL support - Add `namespace` to AppTranslationBundleSchema for plugin isolation - Add `_notifications` and `_errors` to ObjectTranslationNodeSchema and AppTranslationBundleSchema - Add `sourceHash` to TranslationDiffItemSchema for precise stale detection - Add `aiSuggested` and `aiConfidence` to TranslationDiffItemSchema - Add `CoverageBreakdownEntrySchema` and `breakdown` to TranslationCoverageResultSchema - Add `suggestTranslations` optional method to II18nService contract - Add 21 new tests covering all new fields and schemas - Update ROADMAP.md with enhanced i18n capabilities Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 4 +- .../spec/src/contracts/i18n-service.test.ts | 27 +- packages/spec/src/contracts/i18n-service.ts | 15 +- packages/spec/src/system/translation.test.ts | 234 ++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 107 +++++++- 5 files changed, 379 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 46463e60e5..8f1ddeeb67 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -341,7 +341,7 @@ business/custom objects, aligning with industry best practices (e.g., ServiceNow - [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) - [x] **Driver Specifications** — Memory, PostgreSQL, MongoDB driver schemas + SQL/NoSQL abstractions - [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`) -- [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 +- [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 - [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) - [x] **AI Protocol** — Agent, Agent Action, Conversation, Cost, MCP, Model Registry, NLQ, Orchestration, Predictive, RAG Pipeline, Runtime Ops, Feedback Loop, DevOps Agent, Plugin Development - [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 | Contract | Priority | Package | Notes | |:---|:---:|:---|:---| -| `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution; object-first bundle & diff detection | +| `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution; object-first bundle & diff detection; AI suggestion hook (`suggestTranslations`) | | `IRealtimeService` | **P1** | `@objectstack/service-realtime` | WebSocket/SSE push (replaces Studio setTimeout hack) | | `IFeedService` | **P1** | `@objectstack/service-feed` | ✅ Feed/Chatter with comments, reactions, subscriptions | | `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver | diff --git a/packages/spec/src/contracts/i18n-service.test.ts b/packages/spec/src/contracts/i18n-service.test.ts index f8e3eddad4..5baf9f694a 100644 --- a/packages/spec/src/contracts/i18n-service.test.ts +++ b/packages/spec/src/contracts/i18n-service.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { II18nService } from './i18n-service'; -import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod'; +import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; describe('I18n Service Contract', () => { it('should allow a minimal II18nService implementation with required methods', () => { @@ -162,5 +162,30 @@ describe('I18n Service Contract', () => { expect(minimalService.getAppBundle).toBeUndefined(); expect(minimalService.loadAppBundle).toBeUndefined(); expect(minimalService.getCoverage).toBeUndefined(); + expect(minimalService.suggestTranslations).toBeUndefined(); + }); + + it('should allow implementation with suggestTranslations', async () => { + const service: II18nService = { + t: () => '', + getTranslations: () => ({}), + loadTranslations: () => {}, + getLocales: () => ['en', 'zh-CN'], + suggestTranslations: async (_locale, items) => { + return items.map(item => ({ + ...item, + aiSuggested: `AI翻译: ${item.key}`, + aiConfidence: 0.85, + })); + }, + }; + + const items: TranslationDiffItem[] = [ + { key: 'o.account.fields.website.label', status: 'missing', locale: 'zh-CN' }, + ]; + const suggestions = await service.suggestTranslations!('zh-CN', items); + expect(suggestions).toHaveLength(1); + expect(suggestions[0].aiSuggested).toBe('AI翻译: o.account.fields.website.label'); + expect(suggestions[0].aiConfidence).toBe(0.85); }); }); diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index 556933f42f..4ed79b7c90 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -1,6 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod'; +import type { AppTranslationBundle, TranslationCoverageResult, TranslationDiffItem } from '../system/translation.zod'; /** * II18nService - Internationalization Service Contract @@ -89,4 +89,17 @@ export interface II18nService { * @returns Coverage result with per-key diff items */ getCoverage?(locale: string, objectName?: string): TranslationCoverageResult; + + /** + * Request AI-powered translation suggestions for missing or stale keys. + * + * Implementations may call an internal AI agent, external TMS, or + * third-party translation API. Each returned diff item should have + * `aiSuggested` and `aiConfidence` populated. + * + * @param locale - Target BCP-47 locale code + * @param items - Diff items to generate suggestions for + * @returns Diff items enriched with `aiSuggested` and `aiConfidence` + */ + suggestTranslations?(locale: string, items: TranslationDiffItem[]): Promise; } diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index e4777765f9..357ef88efa 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -7,11 +7,13 @@ import { ObjectTranslationDataSchema, TranslationFileOrganizationSchema, TranslationConfigSchema, + MessageFormatSchema, ObjectTranslationNodeSchema, AppTranslationBundleSchema, TranslationDiffStatusSchema, TranslationDiffItemSchema, TranslationCoverageResultSchema, + CoverageBreakdownEntrySchema, type TranslationBundle, type ObjectTranslationData, type TranslationConfig, @@ -19,6 +21,7 @@ import { type AppTranslationBundle, type TranslationDiffItem, type TranslationCoverageResult, + type CoverageBreakdownEntry, } from './translation.zod'; describe('LocaleSchema', () => { @@ -474,6 +477,25 @@ describe('FieldTranslationSchema', () => { const result = FieldTranslationSchema.parse({}); expect(result).toBeDefined(); }); + + it('should accept field with placeholder', () => { + const result = FieldTranslationSchema.parse({ + label: 'Email', + placeholder: 'Enter your email address', + }); + expect(result.placeholder).toBe('Enter your email address'); + }); + + it('should accept field with all properties including placeholder', () => { + const result = FieldTranslationSchema.parse({ + label: '邮箱', + help: '输入您的电子邮箱地址', + placeholder: '例如:user@example.com', + options: { work: '工作邮箱', personal: '个人邮箱' }, + }); + expect(result.label).toBe('邮箱'); + expect(result.placeholder).toBe('例如:user@example.com'); + }); }); // ============================================================================ @@ -622,6 +644,38 @@ describe('TranslationConfigSchema', () => { }), ).toThrow(); }); + + it('should default messageFormat to simple', () => { + const config = TranslationConfigSchema.parse({ + defaultLocale: 'en', + supportedLocales: ['en'], + }); + expect(config.messageFormat).toBe('simple'); + }); + + it('should accept ICU message format config', () => { + const config = TranslationConfigSchema.parse({ + defaultLocale: 'en', + supportedLocales: ['en', 'ar-SA'], + messageFormat: 'icu', + }); + expect(config.messageFormat).toBe('icu'); + }); +}); + +// ============================================================================ +// MessageFormatSchema +// ============================================================================ + +describe('MessageFormatSchema', () => { + it('should accept icu and simple', () => { + expect(MessageFormatSchema.parse('icu')).toBe('icu'); + expect(MessageFormatSchema.parse('simple')).toBe('simple'); + }); + + it('should reject invalid format', () => { + expect(() => MessageFormatSchema.parse('mf2')).toThrow(); + }); }); // ============================================================================ @@ -703,6 +757,25 @@ describe('ObjectTranslationNodeSchema', () => { expect(node.fields?.stage.label).toBe('Stage'); expect(node._views?.pipeline.label).toBe('Pipeline View'); }); + + it('should accept node with _notifications and _errors', () => { + const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ + label: 'Order', + _notifications: { + order_shipped: { title: 'Order Shipped', body: 'Your order has been shipped.' }, + order_cancelled: { title: 'Order Cancelled' }, + }, + _errors: { + insufficient_stock: 'Not enough stock for this order.', + payment_failed: 'Payment could not be processed.', + }, + }); + expect(node._notifications?.order_shipped.title).toBe('Order Shipped'); + expect(node._notifications?.order_shipped.body).toBe('Your order has been shipped.'); + expect(node._notifications?.order_cancelled.title).toBe('Order Cancelled'); + expect(node._errors?.insufficient_stock).toBe('Not enough stock for this order.'); + expect(node._errors?.payment_failed).toBe('Payment could not be processed.'); + }); }); // ============================================================================ @@ -822,6 +895,45 @@ describe('AppTranslationBundleSchema', () => { expect(zh.nav?.home).toBe('首页'); expect(zh.messages?.['common.save']).toBe('保存'); }); + + it('should accept bundle with _meta for RTL locale', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ + _meta: { locale: 'ar-SA', direction: 'rtl' }, + messages: { 'common.save': 'حفظ' }, + }); + expect(bundle._meta?.locale).toBe('ar-SA'); + expect(bundle._meta?.direction).toBe('rtl'); + }); + + it('should accept bundle with namespace for plugin isolation', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ + namespace: 'plugin-helpdesk', + o: { ticket: { label: 'Ticket' } }, + }); + expect(bundle.namespace).toBe('plugin-helpdesk'); + }); + + it('should accept bundle with global notifications and errors', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ + notifications: { + system_update: { title: 'System Update', body: 'A new version is available.' }, + }, + errors: { + unauthorized: 'You are not authorized to perform this action.', + not_found: 'The requested resource was not found.', + }, + }); + expect(bundle.notifications?.system_update.title).toBe('System Update'); + expect(bundle.errors?.unauthorized).toBe('You are not authorized to perform this action.'); + }); + + it('should accept bundle with _meta direction ltr', () => { + const bundle = AppTranslationBundleSchema.parse({ + _meta: { direction: 'ltr' }, + }); + expect(bundle._meta?.direction).toBe('ltr'); + expect(bundle._meta?.locale).toBeUndefined(); + }); }); // ============================================================================ @@ -883,6 +995,50 @@ describe('TranslationDiffItemSchema', () => { TranslationDiffItemSchema.parse({ status: 'missing', locale: 'en' }), ).toThrow(); }); + + it('should accept diff item with sourceHash', () => { + const item = TranslationDiffItemSchema.parse({ + key: 'o.account.label', + status: 'stale', + locale: 'zh-CN', + sourceHash: 'sha256:abc123', + }); + expect(item.sourceHash).toBe('sha256:abc123'); + }); + + it('should accept diff item with AI suggestion fields', () => { + const item: TranslationDiffItem = TranslationDiffItemSchema.parse({ + key: 'o.account.fields.website.label', + status: 'missing', + locale: 'zh-CN', + aiSuggested: '网站', + aiConfidence: 0.92, + }); + expect(item.aiSuggested).toBe('网站'); + expect(item.aiConfidence).toBe(0.92); + }); + + it('should reject AI confidence above 1', () => { + expect(() => + TranslationDiffItemSchema.parse({ + key: 'o.account.label', + status: 'missing', + locale: 'en', + aiConfidence: 1.5, + }), + ).toThrow(); + }); + + it('should reject AI confidence below 0', () => { + expect(() => + TranslationDiffItemSchema.parse({ + key: 'o.account.label', + status: 'missing', + locale: 'en', + aiConfidence: -0.1, + }), + ).toThrow(); + }); }); // ============================================================================ @@ -963,4 +1119,82 @@ describe('TranslationCoverageResultSchema', () => { }), ).toThrow(); }); + + it('should accept result with breakdown', () => { + const result: TranslationCoverageResult = TranslationCoverageResultSchema.parse({ + locale: 'zh-CN', + totalKeys: 100, + translatedKeys: 80, + missingKeys: 20, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 80, + items: [], + breakdown: [ + { group: 'fields', totalKeys: 60, translatedKeys: 50, coveragePercent: 83.3 }, + { group: 'views', totalKeys: 20, translatedKeys: 15, coveragePercent: 75 }, + { group: 'actions', totalKeys: 10, translatedKeys: 10, coveragePercent: 100 }, + { group: 'messages', totalKeys: 10, translatedKeys: 5, coveragePercent: 50 }, + ], + }); + expect(result.breakdown).toHaveLength(4); + expect(result.breakdown![0].group).toBe('fields'); + expect(result.breakdown![0].coveragePercent).toBe(83.3); + expect(result.breakdown![2].coveragePercent).toBe(100); + }); + + it('should accept result without breakdown (optional)', () => { + const result = TranslationCoverageResultSchema.parse({ + locale: 'en', + totalKeys: 10, + translatedKeys: 10, + missingKeys: 0, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 100, + items: [], + }); + expect(result.breakdown).toBeUndefined(); + }); +}); + +// ============================================================================ +// CoverageBreakdownEntrySchema +// ============================================================================ + +describe('CoverageBreakdownEntrySchema', () => { + it('should accept a valid breakdown entry', () => { + const entry: CoverageBreakdownEntry = CoverageBreakdownEntrySchema.parse({ + group: 'fields', + totalKeys: 50, + translatedKeys: 45, + coveragePercent: 90, + }); + expect(entry.group).toBe('fields'); + expect(entry.totalKeys).toBe(50); + expect(entry.translatedKeys).toBe(45); + expect(entry.coveragePercent).toBe(90); + }); + + it('should reject breakdown entry with negative totalKeys', () => { + expect(() => + CoverageBreakdownEntrySchema.parse({ + group: 'fields', + totalKeys: -1, + translatedKeys: 0, + coveragePercent: 0, + }), + ).toThrow(); + }); + + it('should reject breakdown entry with coverage above 100', () => { + expect(() => + CoverageBreakdownEntrySchema.parse({ + group: 'fields', + totalKeys: 10, + translatedKeys: 10, + coveragePercent: 101, + }), + ).toThrow(); + }); }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 88edd53706..83009ee8fe 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -19,6 +19,7 @@ export const LocaleSchema = z.string().describe('BCP-47 Language Tag (e.g. en-US export const FieldTranslationSchema = z.object({ label: z.string().optional().describe('Translated field label'), help: z.string().optional().describe('Translated help text'), + placeholder: z.string().optional().describe('Translated placeholder text for form inputs'), options: z.record(z.string(), z.string()).optional().describe('Option value to translated label map'), }).describe('Translation data for a single field'); @@ -168,6 +169,21 @@ export type TranslationFileOrganization = z.infer; + export const TranslationConfigSchema = z.object({ /** Default locale for the application */ defaultLocale: LocaleSchema.describe('Default locale (e.g., "en")'), @@ -178,6 +194,14 @@ export const TranslationConfigSchema = z.object({ /** How translation files are organized on disk */ fileOrganization: TranslationFileOrganizationSchema.default('per_locale') .describe('File organization strategy'), + /** + * Message interpolation format. + * When set to `'icu'`, messages and validationMessages are expected to use + * ICU MessageFormat syntax (plurals, select, number/date skeletons). + * @default 'simple' + */ + messageFormat: MessageFormatSchema.default('simple') + .describe('Message interpolation format (ICU MessageFormat or simple)'), /** Load translations on demand instead of eagerly */ lazyLoad: z.boolean().default(false).describe('Load translations on demand'), /** Cache loaded translations in memory */ @@ -259,6 +283,16 @@ export const ObjectTranslationNodeSchema = z.object({ label: z.string().optional().describe('Translated action label'), confirmMessage: z.string().optional().describe('Translated confirmation message'), })).optional().describe('Action translations keyed by action name'), + + /** Notification message translations keyed by notification name */ + _notifications: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated notification title'), + body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), + })).optional().describe('Notification translations keyed by notification name'), + + /** Error message translations keyed by error code */ + _errors: z.record(z.string(), z.string()).optional() + .describe('Error message translations keyed by error code'), }).describe('Object-first aggregated translation node'); export type ObjectTranslationNode = z.infer; @@ -305,6 +339,26 @@ export type ObjectTranslationNode = z.infer; * ``` */ export const AppTranslationBundleSchema = z.object({ + /** + * Bundle-level metadata. + * Provides locale-aware rendering hints such as text direction (bidi) + * and the canonical locale code this bundle represents. + */ + _meta: z.object({ + /** BCP-47 locale code this bundle represents */ + locale: z.string().optional().describe('BCP-47 locale code for this bundle'), + /** Text direction for the locale */ + direction: z.enum(['ltr', 'rtl']).optional().describe('Text direction: left-to-right or right-to-left'), + }).optional().describe('Bundle-level metadata (locale, bidi direction)'), + + /** + * Namespace for plugin/extension isolation. + * When multiple plugins contribute translations, each should use a unique + * namespace to avoid key collisions (e.g. "crm", "helpdesk", "plugin-xyz"). + */ + namespace: z.string().optional() + .describe('Namespace for plugin isolation to avoid translation key collisions'), + /** Object-first translations keyed by object name (snake_case) */ o: z.record(z.string(), ObjectTranslationNodeSchema).optional() .describe('Object-first translations keyed by object name'), @@ -341,13 +395,23 @@ export const AppTranslationBundleSchema = z.object({ description: z.string().optional().describe('Translated page description'), })).optional().describe('Page translations keyed by page name'), - /** UI message translations */ + /** UI message translations (supports ICU MessageFormat when enabled) */ messages: z.record(z.string(), z.string()).optional() - .describe('UI message translations keyed by message ID'), + .describe('UI message translations keyed by message ID (supports ICU MessageFormat)'), - /** Validation error message translations */ + /** Validation error message translations (supports ICU MessageFormat when enabled) */ validationMessages: z.record(z.string(), z.string()).optional() - .describe('Validation error message translations keyed by rule name'), + .describe('Validation error message translations keyed by rule name (supports ICU MessageFormat)'), + + /** Global notification translations not bound to a specific object */ + notifications: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated notification title'), + body: z.string().optional().describe('Translated notification body'), + })).optional().describe('Global notification translations keyed by notification name'), + + /** Global error message translations not bound to a specific object */ + errors: z.record(z.string(), z.string()).optional() + .describe('Global error message translations keyed by error code'), }).describe('Object-first application translation bundle for a single locale'); export type AppTranslationBundle = z.infer; @@ -394,6 +458,18 @@ export const TranslationDiffItemSchema = z.object({ objectName: z.string().optional().describe('Associated object name (snake_case)'), /** Locale code */ locale: z.string().describe('BCP-47 locale code'), + /** + * Hash of the source metadata value at the time the translation was made. + * Used by CLI/Workbench to detect stale translations without a full diff. + */ + sourceHash: z.string().optional().describe('Hash of source metadata for precise stale detection'), + /** + * AI-suggested translation text for missing or stale entries. + * Populated by AI translation hooks or TMS integrations. + */ + aiSuggested: z.string().optional().describe('AI-suggested translation for this key'), + /** Confidence score (0-1) for the AI suggestion */ + aiConfidence: z.number().min(0).max(1).optional().describe('AI suggestion confidence score (0–1)'), }).describe('A single translation diff item'); export type TranslationDiffItem = z.infer; @@ -418,6 +494,22 @@ export type TranslationDiffItem = z.infer; * }; * ``` */ +/** + * Per-group coverage breakdown entry. + */ +export const CoverageBreakdownEntrySchema = z.object({ + /** Group category (e.g. "fields", "views", "actions", "messages") */ + group: z.string().describe('Translation group category'), + /** Total translatable keys in this group */ + totalKeys: z.number().int().nonnegative().describe('Total keys in this group'), + /** Number of translated keys in this group */ + translatedKeys: z.number().int().nonnegative().describe('Translated keys in this group'), + /** Coverage percentage for this group */ + coveragePercent: z.number().min(0).max(100).describe('Coverage percentage for this group'), +}).describe('Coverage breakdown for a single translation group'); + +export type CoverageBreakdownEntry = z.infer; + export const TranslationCoverageResultSchema = z.object({ /** BCP-47 locale code */ locale: z.string().describe('BCP-47 locale code'), @@ -437,6 +529,13 @@ export const TranslationCoverageResultSchema = z.object({ coveragePercent: z.number().min(0).max(100).describe('Translation coverage percentage'), /** Individual diff items */ items: z.array(TranslationDiffItemSchema).describe('Detailed diff items'), + /** + * Per-group coverage breakdown for translation project management. + * Each entry represents a logical group (e.g. "fields", "views", "actions", + * "messages") with its own coverage statistics. + */ + breakdown: z.array(CoverageBreakdownEntrySchema).optional() + .describe('Per-group coverage breakdown'), }).describe('Aggregated translation coverage result'); export type TranslationCoverageResult = z.infer; From b73a88ac201a48c85f620144cc92da317c197b23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:47:33 +0000 Subject: [PATCH 3/3] fix: add ICU MessageFormat note to bundle-level notification body description Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- packages/spec/src/system/translation.zod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 83009ee8fe..7b23b16aaa 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -406,7 +406,7 @@ export const AppTranslationBundleSchema = z.object({ /** Global notification translations not bound to a specific object */ notifications: z.record(z.string(), z.object({ title: z.string().optional().describe('Translated notification title'), - body: z.string().optional().describe('Translated notification body'), + body: z.string().optional().describe('Translated notification body (supports ICU MessageFormat when enabled)'), })).optional().describe('Global notification translations keyed by notification name'), /** Global error message translations not bound to a specific object */