From 3c9de82915736f43ce0bf0d792648c3baf975414 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:56:25 +0000 Subject: [PATCH 1/2] Initial plan From e7aa1b45f3bcc4d8f5adfd6dc9d2e1b5c757e662 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:04:54 +0000 Subject: [PATCH 2/2] feat: add object-first i18n convention with ObjectTranslationNodeSchema, AppTranslationBundleSchema, diff/coverage schemas, and extended II18nService contract Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com> --- ROADMAP.md | 4 +- .../docs/protocol/objectos/i18n-standard.mdx | 46 +++ .../spec/src/contracts/i18n-service.test.ts | 78 ++++ packages/spec/src/contracts/i18n-service.ts | 37 +- packages/spec/src/system/translation.test.ts | 350 ++++++++++++++++++ packages/spec/src/system/translation.zod.ts | 255 +++++++++++++ 6 files changed, 767 insertions(+), 3 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2e4a20f6ef..46463e60e5 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, 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), 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 | +| `II18nService` | **P1** | `@objectstack/service-i18n` | Map-backed translation with locale resolution; object-first bundle & diff detection | | `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/content/docs/protocol/objectos/i18n-standard.mdx b/content/docs/protocol/objectos/i18n-standard.mdx index 3e3a48a0e6..755adba3b7 100644 --- a/content/docs/protocol/objectos/i18n-standard.mdx +++ b/content/docs/protocol/objectos/i18n-standard.mdx @@ -130,6 +130,52 @@ Fallback to: en (system default) ✓ Translations are stored in **JSON files** organized by locale and namespace. +### Object-First Convention (Recommended) + +ObjectOS uses an **object-first** convention where all translatable metadata +for an object is aggregated under `o.{object_name}`. Global (non-object-bound) +translations remain in dedicated top-level groups. This aligns with Salesforce DX +and Dynamics conventions, enabling efficient translation workbench editing +and automated coverage detection. + +```typescript +// AppTranslationBundle for a single locale (e.g. zh-CN) +const zh: AppTranslationBundle = { + // ── Object-first translations ───────────────────────────────── + o: { + account: { + label: '客户', + pluralLabel: '客户', + description: '客户管理对象', + fields: { + name: { label: '客户名称', help: '公司法定名称' }, + industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, + }, + _options: { status: { active: '活跃', inactive: '停用' } }, + _views: { all_accounts: { label: '全部客户' } }, + _sections: { basic_info: { label: '基本信息' } }, + _actions: { convert: { label: '转换', confirmMessage: '确认转换?' } }, + }, + }, + + // ── Global translations ─────────────────────────────────────── + _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, + app: { crm: { label: '客户关系管理' } }, + nav: { home: '首页', settings: '设置' }, + dashboard: { sales_overview: { label: '销售概览' } }, + reports: { pipeline_report: { label: '管道报表' } }, + pages: { landing: { title: '欢迎' } }, + messages: { 'common.save': '保存' }, + validationMessages: { 'discount_limit': '折扣不能超过40%' }, +}; +``` + +**Key benefits:** +- ✅ All translatable content for one object in one place +- ✅ CLI can generate translation skeletons per object +- ✅ Workbench can show per-object coverage and diffs +- ✅ No redundant category/fieldOptions/reports nodes + ### Directory Structure ``` diff --git a/packages/spec/src/contracts/i18n-service.test.ts b/packages/spec/src/contracts/i18n-service.test.ts index 0d6e633a25..f8e3eddad4 100644 --- a/packages/spec/src/contracts/i18n-service.test.ts +++ b/packages/spec/src/contracts/i18n-service.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import type { II18nService } from './i18n-service'; +import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod'; describe('I18n Service Contract', () => { it('should allow a minimal II18nService implementation with required methods', () => { @@ -85,4 +86,81 @@ describe('I18n Service Contract', () => { service.setDefaultLocale!('zh-CN'); expect(service.getDefaultLocale!()).toBe('zh-CN'); }); + + it('should allow implementation with getAppBundle and loadAppBundle', () => { + const bundles = new Map(); + + const service: II18nService = { + t: () => '', + getTranslations: () => ({}), + loadTranslations: () => {}, + getLocales: () => Array.from(bundles.keys()), + getAppBundle: (locale) => bundles.get(locale), + loadAppBundle: (locale, bundle) => { bundles.set(locale, bundle); }, + }; + + const zhBundle: AppTranslationBundle = { + o: { + account: { + label: '客户', + fields: { name: { label: '客户名称' } }, + _views: { all_accounts: { label: '全部客户' } }, + }, + }, + messages: { 'common.save': '保存' }, + }; + + service.loadAppBundle!('zh-CN', zhBundle); + const loaded = service.getAppBundle!('zh-CN'); + expect(loaded).toBeDefined(); + expect(loaded?.o?.account.label).toBe('客户'); + expect(loaded?.o?.account._views?.all_accounts.label).toBe('全部客户'); + expect(loaded?.messages?.['common.save']).toBe('保存'); + }); + + it('should allow implementation with getCoverage', () => { + const service: II18nService = { + t: () => '', + getTranslations: () => ({}), + loadTranslations: () => {}, + getLocales: () => ['en', 'zh-CN'], + getCoverage: (locale, objectName?) => { + const result: TranslationCoverageResult = { + locale, + objectName, + totalKeys: 50, + translatedKeys: 45, + missingKeys: 5, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 90, + items: [ + { key: 'o.account.fields.website.label', status: 'missing', objectName: 'account', locale }, + ], + }; + return result; + }, + }; + + const coverage = service.getCoverage!('zh-CN', 'account'); + expect(coverage.locale).toBe('zh-CN'); + expect(coverage.objectName).toBe('account'); + expect(coverage.coveragePercent).toBe(90); + expect(coverage.missingKeys).toBe(5); + expect(coverage.items).toHaveLength(1); + expect(coverage.items[0].status).toBe('missing'); + }); + + it('should keep backward compatibility — new methods are optional', () => { + const minimalService: II18nService = { + t: (_key, _locale) => '', + getTranslations: (_locale) => ({}), + loadTranslations: (_locale, _translations) => {}, + getLocales: () => [], + }; + + expect(minimalService.getAppBundle).toBeUndefined(); + expect(minimalService.loadAppBundle).toBeUndefined(); + expect(minimalService.getCoverage).toBeUndefined(); + }); }); diff --git a/packages/spec/src/contracts/i18n-service.ts b/packages/spec/src/contracts/i18n-service.ts index b73524a03d..556933f42f 100644 --- a/packages/spec/src/contracts/i18n-service.ts +++ b/packages/spec/src/contracts/i18n-service.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import type { AppTranslationBundle, TranslationCoverageResult } from '../system/translation.zod'; + /** * II18nService - Internationalization Service Contract * @@ -16,7 +18,7 @@ export interface II18nService { /** * Translate a message key for a given locale - * @param key - Translation key (e.g. 'objects.account.label') + * @param key - Translation key (e.g. 'o.account.label') * @param locale - BCP-47 locale code (e.g. 'en-US', 'zh-CN') * @param params - Optional interpolation parameters * @returns Translated string, or the key itself if not found @@ -54,4 +56,37 @@ export interface II18nService { * @param locale - BCP-47 locale code */ setDefaultLocale?(locale: string): void; + + // ── Object-first aggregation & diff detection ────────────────────── + + /** + * Get object-first translation bundle for a locale. + * + * Returns all translations aggregated under `o.{objectName}` with + * global groups (app, nav, dashboard, etc.) at the top level. + * + * @param locale - BCP-47 locale code + * @returns Object-first AppTranslationBundle, or undefined if no data + */ + getAppBundle?(locale: string): AppTranslationBundle | undefined; + + /** + * Load an object-first translation bundle for a locale. + * + * @param locale - BCP-47 locale code + * @param bundle - Object-first AppTranslationBundle + */ + loadAppBundle?(locale: string, bundle: AppTranslationBundle): void; + + /** + * Get translation coverage for a locale, optionally scoped to a single object. + * + * Compares the supplied (or currently loaded) translation bundle against + * the source metadata to detect missing, redundant, and stale entries. + * + * @param locale - BCP-47 locale code + * @param objectName - Optional object name to scope the check + * @returns Coverage result with per-key diff items + */ + getCoverage?(locale: string, objectName?: string): TranslationCoverageResult; } diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 9a9423be1f..e4777765f9 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -7,9 +7,18 @@ import { ObjectTranslationDataSchema, TranslationFileOrganizationSchema, TranslationConfigSchema, + ObjectTranslationNodeSchema, + AppTranslationBundleSchema, + TranslationDiffStatusSchema, + TranslationDiffItemSchema, + TranslationCoverageResultSchema, type TranslationBundle, type ObjectTranslationData, type TranslationConfig, + type ObjectTranslationNode, + type AppTranslationBundle, + type TranslationDiffItem, + type TranslationCoverageResult, } from './translation.zod'; describe('LocaleSchema', () => { @@ -614,3 +623,344 @@ describe('TranslationConfigSchema', () => { ).toThrow(); }); }); + +// ============================================================================ +// ObjectTranslationNodeSchema — object-first aggregated translation node +// ============================================================================ + +describe('ObjectTranslationNodeSchema', () => { + it('should accept minimal node with label only', () => { + const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ + label: 'Account', + }); + expect(node.label).toBe('Account'); + expect(node.pluralLabel).toBeUndefined(); + expect(node.description).toBeUndefined(); + expect(node.helpText).toBeUndefined(); + expect(node.fields).toBeUndefined(); + expect(node._options).toBeUndefined(); + expect(node._views).toBeUndefined(); + expect(node._sections).toBeUndefined(); + expect(node._actions).toBeUndefined(); + }); + + it('should accept full object-first node with all sub-groups', () => { + const node: ObjectTranslationNode = ObjectTranslationNodeSchema.parse({ + label: '客户', + pluralLabel: '客户', + description: '客户管理对象', + helpText: '用于管理公司的所有客户', + fields: { + name: { label: '客户名称', help: '公司或组织的法定名称' }, + industry: { + label: '行业', + options: { tech: '科技', finance: '金融' }, + }, + }, + _options: { + status: { active: '活跃', inactive: '停用' }, + }, + _views: { + all_accounts: { label: '全部客户', description: '查看所有客户' }, + }, + _sections: { + basic_info: { label: '基本信息' }, + }, + _actions: { + convert_lead: { label: '转换线索', confirmMessage: '确认转换?' }, + }, + }); + + expect(node.label).toBe('客户'); + expect(node.pluralLabel).toBe('客户'); + expect(node.description).toBe('客户管理对象'); + expect(node.helpText).toBe('用于管理公司的所有客户'); + expect(node.fields?.name.label).toBe('客户名称'); + expect(node.fields?.industry.options?.tech).toBe('科技'); + expect(node._options?.status.active).toBe('活跃'); + expect(node._views?.all_accounts.label).toBe('全部客户'); + expect(node._sections?.basic_info.label).toBe('基本信息'); + expect(node._actions?.convert_lead.label).toBe('转换线索'); + expect(node._actions?.convert_lead.confirmMessage).toBe('确认转换?'); + }); + + it('should reject node without label', () => { + expect(() => + ObjectTranslationNodeSchema.parse({ pluralLabel: 'Accounts' }), + ).toThrow(); + }); + + it('should accept node with only fields and views', () => { + const node = ObjectTranslationNodeSchema.parse({ + label: 'Opportunity', + fields: { + stage: { label: 'Stage', options: { open: 'Open', closed: 'Closed' } }, + }, + _views: { + pipeline: { label: 'Pipeline View' }, + }, + }); + expect(node.fields?.stage.label).toBe('Stage'); + expect(node._views?.pipeline.label).toBe('Pipeline View'); + }); +}); + +// ============================================================================ +// AppTranslationBundleSchema — object-first full app bundle +// ============================================================================ + +describe('AppTranslationBundleSchema', () => { + it('should accept empty bundle', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({}); + expect(bundle).toBeDefined(); + }); + + it('should accept bundle with object-first translations', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ + o: { + account: { + label: '客户', + fields: { name: { label: '客户名称' } }, + _views: { all_accounts: { label: '全部客户' } }, + }, + contact: { + label: '联系人', + fields: { email: { label: '邮箱' } }, + }, + }, + }); + + expect(bundle.o?.account.label).toBe('客户'); + expect(bundle.o?.account.fields?.name.label).toBe('客户名称'); + expect(bundle.o?.account._views?.all_accounts.label).toBe('全部客户'); + expect(bundle.o?.contact.label).toBe('联系人'); + }); + + it('should accept bundle with global options', () => { + const bundle = AppTranslationBundleSchema.parse({ + _globalOptions: { + currency: { usd: '美元', eur: '欧元', gbp: '英镑' }, + country: { us: '美国', cn: '中国' }, + }, + }); + + expect(bundle._globalOptions?.currency.usd).toBe('美元'); + expect(bundle._globalOptions?.country.cn).toBe('中国'); + }); + + it('should accept bundle with all global groups', () => { + const bundle: AppTranslationBundle = AppTranslationBundleSchema.parse({ + app: { + crm: { label: 'CRM', description: 'Customer Relationship Management' }, + }, + nav: { home: 'Home', settings: 'Settings' }, + dashboard: { + sales_overview: { label: 'Sales Overview', description: 'Key sales metrics' }, + }, + reports: { + pipeline_report: { label: 'Pipeline Report' }, + }, + pages: { + landing: { title: 'Welcome', description: 'Landing page' }, + }, + messages: { + 'common.save': 'Save', + 'common.cancel': 'Cancel', + }, + validationMessages: { + 'discount_limit': 'Discount cannot exceed 40%', + }, + }); + + expect(bundle.app?.crm.label).toBe('CRM'); + expect(bundle.nav?.home).toBe('Home'); + expect(bundle.dashboard?.sales_overview.label).toBe('Sales Overview'); + expect(bundle.reports?.pipeline_report.label).toBe('Pipeline Report'); + expect(bundle.pages?.landing.title).toBe('Welcome'); + expect(bundle.messages?.['common.save']).toBe('Save'); + expect(bundle.validationMessages?.['discount_limit']).toBe('Discount cannot exceed 40%'); + }); + + it('should accept a complete Chinese translation bundle', () => { + const zh: AppTranslationBundle = AppTranslationBundleSchema.parse({ + o: { + account: { + label: '客户', + pluralLabel: '客户', + description: '客户管理对象', + fields: { + name: { label: '客户名称', help: '公司或组织的法定名称' }, + industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, + }, + _options: { status: { active: '活跃', inactive: '停用' } }, + _views: { all_accounts: { label: '全部客户' } }, + _sections: { basic_info: { label: '基本信息' } }, + _actions: { convert: { label: '转换', confirmMessage: '确认转换?' } }, + }, + opportunity: { + label: '商机', + fields: { + stage: { label: '阶段', options: { open: '打开', closed: '关闭' } }, + }, + }, + }, + _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, + app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, + nav: { home: '首页', settings: '设置' }, + dashboard: { sales_overview: { label: '销售概览' } }, + reports: { pipeline_report: { label: '管道报表' } }, + pages: { landing: { title: '欢迎' } }, + messages: { 'common.save': '保存', 'common.cancel': '取消' }, + validationMessages: { 'discount_limit': '折扣不能超过40%' }, + }); + + expect(zh.o?.account.label).toBe('客户'); + expect(zh.o?.account._options?.status.active).toBe('活跃'); + expect(zh.o?.opportunity.fields?.stage.options?.open).toBe('打开'); + expect(zh._globalOptions?.currency.usd).toBe('美元'); + expect(zh.app?.crm.label).toBe('客户关系管理'); + expect(zh.nav?.home).toBe('首页'); + expect(zh.messages?.['common.save']).toBe('保存'); + }); +}); + +// ============================================================================ +// TranslationDiffStatusSchema +// ============================================================================ + +describe('TranslationDiffStatusSchema', () => { + it('should accept valid statuses', () => { + expect(TranslationDiffStatusSchema.parse('missing')).toBe('missing'); + expect(TranslationDiffStatusSchema.parse('redundant')).toBe('redundant'); + expect(TranslationDiffStatusSchema.parse('stale')).toBe('stale'); + }); + + it('should reject invalid status', () => { + expect(() => TranslationDiffStatusSchema.parse('outdated')).toThrow(); + }); +}); + +// ============================================================================ +// TranslationDiffItemSchema +// ============================================================================ + +describe('TranslationDiffItemSchema', () => { + it('should accept a missing translation diff item', () => { + const item: TranslationDiffItem = TranslationDiffItemSchema.parse({ + key: 'o.account.fields.website.label', + status: 'missing', + objectName: 'account', + locale: 'zh-CN', + }); + expect(item.key).toBe('o.account.fields.website.label'); + expect(item.status).toBe('missing'); + expect(item.objectName).toBe('account'); + expect(item.locale).toBe('zh-CN'); + }); + + it('should accept a redundant diff item without objectName', () => { + const item = TranslationDiffItemSchema.parse({ + key: 'messages.old_key', + status: 'redundant', + locale: 'en', + }); + expect(item.objectName).toBeUndefined(); + expect(item.status).toBe('redundant'); + }); + + it('should accept a stale diff item', () => { + const item = TranslationDiffItemSchema.parse({ + key: 'o.contact.label', + status: 'stale', + objectName: 'contact', + locale: 'ja', + }); + expect(item.status).toBe('stale'); + }); + + it('should reject diff item without key', () => { + expect(() => + TranslationDiffItemSchema.parse({ status: 'missing', locale: 'en' }), + ).toThrow(); + }); +}); + +// ============================================================================ +// TranslationCoverageResultSchema +// ============================================================================ + +describe('TranslationCoverageResultSchema', () => { + it('should accept a full coverage result', () => { + const result: TranslationCoverageResult = TranslationCoverageResultSchema.parse({ + locale: 'zh-CN', + totalKeys: 120, + translatedKeys: 105, + missingKeys: 12, + redundantKeys: 3, + staleKeys: 0, + coveragePercent: 87.5, + items: [ + { key: 'o.account.fields.website.label', status: 'missing', objectName: 'account', locale: 'zh-CN' }, + { key: 'messages.old_key', status: 'redundant', locale: 'zh-CN' }, + ], + }); + + expect(result.locale).toBe('zh-CN'); + expect(result.totalKeys).toBe(120); + expect(result.translatedKeys).toBe(105); + expect(result.missingKeys).toBe(12); + expect(result.redundantKeys).toBe(3); + expect(result.staleKeys).toBe(0); + expect(result.coveragePercent).toBe(87.5); + expect(result.items).toHaveLength(2); + expect(result.items[0].status).toBe('missing'); + }); + + it('should accept a scoped coverage result for a single object', () => { + const result = TranslationCoverageResultSchema.parse({ + locale: 'de', + objectName: 'account', + totalKeys: 15, + translatedKeys: 15, + missingKeys: 0, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 100, + items: [], + }); + + expect(result.objectName).toBe('account'); + expect(result.coveragePercent).toBe(100); + expect(result.items).toHaveLength(0); + }); + + it('should reject result with negative counts', () => { + expect(() => + TranslationCoverageResultSchema.parse({ + locale: 'en', + totalKeys: -1, + translatedKeys: 0, + missingKeys: 0, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 0, + items: [], + }), + ).toThrow(); + }); + + it('should reject result with coverage percent above 100', () => { + expect(() => + TranslationCoverageResultSchema.parse({ + locale: 'en', + totalKeys: 10, + translatedKeys: 10, + missingKeys: 0, + redundantKeys: 0, + staleKeys: 0, + coveragePercent: 101, + items: [], + }), + ).toThrow(); + }); +}); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 983eba0a5a..88edd53706 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -185,3 +185,258 @@ export const TranslationConfigSchema = z.object({ }).describe('Internationalization configuration'); export type TranslationConfig = z.infer; + +// ──────────────────────────────────────────────────────────────────────────── +// Object-First Translation Node (object-first aggregated structure) +// ──────────────────────────────────────────────────────────────────────────── + +/** Translatable option map: option value → translated label */ +const OptionTranslationMapSchema = z.record(z.string(), z.string()) + .describe('Option value to translated label map'); + +/** + * ObjectTranslationNodeSchema + * + * Object-first aggregated translation node that groups **all** translatable + * content for a single object under one key. Aligns with Salesforce / Dynamics + * conventions where translations are organized per-object rather than per-category. + * + * Located at `o.{object_name}` inside an {@link AppTranslationBundle}. + * + * @example + * ```typescript + * const accountNode: ObjectTranslationNode = { + * label: '客户', + * pluralLabel: '客户', + * description: '客户管理对象', + * fields: { + * name: { label: '客户名称', help: '公司或组织的法定名称' }, + * industry: { label: '行业', options: { tech: '科技', finance: '金融' } }, + * }, + * _options: { status: { active: '活跃', inactive: '停用' } }, + * _views: { all_accounts: { label: '全部客户' } }, + * _sections: { basic_info: { label: '基本信息' } }, + * _actions: { + * convert_lead: { label: '转换线索', confirmMessage: '确认转换?' }, + * }, + * }; + * ``` + */ +export const ObjectTranslationNodeSchema = z.object({ + /** Translated singular label */ + label: z.string().describe('Translated singular label'), + /** Translated plural label */ + pluralLabel: z.string().optional().describe('Translated plural label'), + /** Translated object description */ + description: z.string().optional().describe('Translated object description'), + /** Translated help text shown in tooltips or guidance panels */ + helpText: z.string().optional().describe('Translated help text for the object'), + + /** Field-level translations keyed by field name (snake_case) */ + fields: z.record(z.string(), FieldTranslationSchema).optional() + .describe('Field translations keyed by field name'), + + /** + * Global picklist / select option overrides scoped to this object. + * Keyed by field name → { optionValue: translatedLabel }. + */ + _options: z.record(z.string(), OptionTranslationMapSchema).optional() + .describe('Object-scoped picklist option translations keyed by field name'), + + /** View translations keyed by view name */ + _views: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated view label'), + description: z.string().optional().describe('Translated view description'), + })).optional().describe('View translations keyed by view name'), + + /** Section (form section / tab) translations keyed by section name */ + _sections: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated section label'), + })).optional().describe('Section translations keyed by section name'), + + /** Action translations keyed by action name */ + _actions: z.record(z.string(), 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'), +}).describe('Object-first aggregated translation node'); + +export type ObjectTranslationNode = z.infer; + +// ──────────────────────────────────────────────────────────────────────────── +// App Translation Bundle (object-first, full application) +// ──────────────────────────────────────────────────────────────────────────── + +/** + * AppTranslationBundleSchema + * + * Complete application translation bundle for a **single locale** using + * the **object-first** convention. All per-object translatable content + * is aggregated under `o.{object_name}`, while global (non-object-bound) + * translations are kept in dedicated top-level groups. + * + * This schema is designed for: + * - Translation workbench UIs (object-level editing & coverage) + * - CLI skeleton generation (`objectstack i18n extract`) + * - Automated diff/coverage detection + * + * @example + * ```typescript + * const zh: AppTranslationBundle = { + * o: { + * account: { + * label: '客户', + * fields: { name: { label: '客户名称' } }, + * _options: { industry: { tech: '科技' } }, + * _views: { all_accounts: { label: '全部客户' } }, + * _sections: { basic_info: { label: '基本信息' } }, + * _actions: { convert: { label: '转换' } }, + * }, + * }, + * _globalOptions: { currency: { usd: '美元', eur: '欧元' } }, + * app: { crm: { label: '客户关系管理', description: '管理销售流程' } }, + * nav: { home: '首页', settings: '设置' }, + * dashboard: { sales_overview: { label: '销售概览' } }, + * reports: { pipeline_report: { label: '管道报表' } }, + * pages: { landing: { title: '欢迎' } }, + * messages: { 'common.save': '保存' }, + * validationMessages: { 'discount_limit': '折扣不能超过40%' }, + * }; + * ``` + */ +export const AppTranslationBundleSchema = z.object({ + /** Object-first translations keyed by object name (snake_case) */ + o: z.record(z.string(), ObjectTranslationNodeSchema).optional() + .describe('Object-first translations keyed by object name'), + + /** Global picklist options not bound to any specific object */ + _globalOptions: z.record(z.string(), OptionTranslationMapSchema).optional() + .describe('Global picklist option translations keyed by option set name'), + + /** App-level translations */ + app: z.record(z.string(), z.object({ + label: z.string().describe('Translated app label'), + description: z.string().optional().describe('Translated app description'), + })).optional().describe('App translations keyed by app name'), + + /** Navigation menu translations */ + nav: z.record(z.string(), z.string()).optional() + .describe('Navigation item translations keyed by nav item name'), + + /** Dashboard translations keyed by dashboard name */ + dashboard: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated dashboard label'), + description: z.string().optional().describe('Translated dashboard description'), + })).optional().describe('Dashboard translations keyed by dashboard name'), + + /** Report translations keyed by report name */ + reports: z.record(z.string(), z.object({ + label: z.string().optional().describe('Translated report label'), + description: z.string().optional().describe('Translated report description'), + })).optional().describe('Report translations keyed by report name'), + + /** Page translations keyed by page name */ + pages: z.record(z.string(), z.object({ + title: z.string().optional().describe('Translated page title'), + description: z.string().optional().describe('Translated page description'), + })).optional().describe('Page translations keyed by page name'), + + /** UI message translations */ + messages: z.record(z.string(), z.string()).optional() + .describe('UI message translations keyed by message ID'), + + /** Validation error message translations */ + validationMessages: z.record(z.string(), z.string()).optional() + .describe('Validation error message translations keyed by rule name'), +}).describe('Object-first application translation bundle for a single locale'); + +export type AppTranslationBundle = z.infer; + +// ──────────────────────────────────────────────────────────────────────────── +// Translation Diff & Coverage +// ──────────────────────────────────────────────────────────────────────────── + +/** + * Translation Diff Status + * + * Status of a single translation entry compared to the source metadata. + */ +export const TranslationDiffStatusSchema = z.enum([ + 'missing', + 'redundant', + 'stale', +]).describe('Translation diff status: missing from bundle, redundant (no matching metadata), or stale (metadata changed)'); + +export type TranslationDiffStatus = z.infer; + +/** + * TranslationDiffItemSchema + * + * Describes a single translation key that is missing, redundant, or stale + * relative to the source metadata. Used by CLI/API diff detection. + * + * @example + * ```typescript + * const item: TranslationDiffItem = { + * key: 'o.account.fields.website.label', + * status: 'missing', + * objectName: 'account', + * locale: 'zh-CN', + * }; + * ``` + */ +export const TranslationDiffItemSchema = z.object({ + /** Dot-path translation key (e.g. "o.account.fields.website.label") */ + key: z.string().describe('Dot-path translation key'), + /** Diff status */ + status: TranslationDiffStatusSchema.describe('Diff status of this translation key'), + /** Object name if the key belongs to an object translation node */ + objectName: z.string().optional().describe('Associated object name (snake_case)'), + /** Locale code */ + locale: z.string().describe('BCP-47 locale code'), +}).describe('A single translation diff item'); + +export type TranslationDiffItem = z.infer; + +/** + * TranslationCoverageResultSchema + * + * Aggregated coverage result for a locale, optionally scoped to a single object. + * Returned by the i18n diff detection API. + * + * @example + * ```typescript + * const result: TranslationCoverageResult = { + * locale: 'zh-CN', + * totalKeys: 120, + * translatedKeys: 105, + * missingKeys: 12, + * redundantKeys: 3, + * staleKeys: 0, + * coveragePercent: 87.5, + * items: [ ... ], + * }; + * ``` + */ +export const TranslationCoverageResultSchema = z.object({ + /** BCP-47 locale code */ + locale: z.string().describe('BCP-47 locale code'), + /** Optional object name scope */ + objectName: z.string().optional().describe('Object name scope (omit for full bundle)'), + /** Total translatable keys derived from metadata */ + totalKeys: z.number().int().nonnegative().describe('Total translatable keys from metadata'), + /** Number of keys that have a translation */ + translatedKeys: z.number().int().nonnegative().describe('Number of translated keys'), + /** Number of missing translations */ + missingKeys: z.number().int().nonnegative().describe('Number of missing translations'), + /** Number of redundant (orphaned) translations */ + redundantKeys: z.number().int().nonnegative().describe('Number of redundant translations'), + /** Number of stale translations */ + staleKeys: z.number().int().nonnegative().describe('Number of stale translations'), + /** Coverage percentage (0-100) */ + coveragePercent: z.number().min(0).max(100).describe('Translation coverage percentage'), + /** Individual diff items */ + items: z.array(TranslationDiffItemSchema).describe('Detailed diff items'), +}).describe('Aggregated translation coverage result'); + +export type TranslationCoverageResult = z.infer;