Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 |
Expand Down
27 changes: 26 additions & 1 deletion packages/spec/src/contracts/i18n-service.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
15 changes: 14 additions & 1 deletion packages/spec/src/contracts/i18n-service.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<TranslationDiffItem[]>;
}
234 changes: 234 additions & 0 deletions packages/spec/src/system/translation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ import {
ObjectTranslationDataSchema,
TranslationFileOrganizationSchema,
TranslationConfigSchema,
MessageFormatSchema,
ObjectTranslationNodeSchema,
AppTranslationBundleSchema,
TranslationDiffStatusSchema,
TranslationDiffItemSchema,
TranslationCoverageResultSchema,
CoverageBreakdownEntrySchema,
type TranslationBundle,
type ObjectTranslationData,
type TranslationConfig,
type ObjectTranslationNode,
type AppTranslationBundle,
type TranslationDiffItem,
type TranslationCoverageResult,
type CoverageBreakdownEntry,
} from './translation.zod';

describe('LocaleSchema', () => {
Expand Down Expand Up @@ -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');
});
});

// ============================================================================
Expand Down Expand Up @@ -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();
});
});

// ============================================================================
Expand Down Expand Up @@ -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.');
});
});

// ============================================================================
Expand Down Expand Up @@ -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();
});
});

// ============================================================================
Expand Down Expand Up @@ -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();
});
});

// ============================================================================
Expand Down Expand Up @@ -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();
});
});
Loading