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
8 changes: 8 additions & 0 deletions packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const en: TranslationData = {
group_access_control: { label: 'Access Control' },
group_approvals: { label: 'Approvals' },
group_configuration: { label: 'Configuration' },
group_integrations: { label: 'Integrations' },
group_diagnostics: { label: 'Diagnostics' },
group_advanced: { label: 'Advanced' },

Expand Down Expand Up @@ -84,7 +85,14 @@ export const en: TranslationData = {
nav_settings_mail: { label: 'Email' },
nav_settings_branding: { label: 'Branding' },
nav_settings_auth: { label: 'Authentication' },
nav_settings_storage: { label: 'File Storage' },
nav_settings_ai: { label: 'AI & Embedder' },
nav_settings_knowledge: { label: 'Knowledge' },
nav_settings_feature_flags: { label: 'Feature Flags' },
// Notifications (contributed by @objectstack/service-messaging)
nav_notification_preferences: { label: 'Notification Preferences' },
nav_notification_subscriptions: { label: 'Notification Subscriptions' },
nav_notification_templates: { label: 'Notification Templates' },

// Diagnostics
nav_sessions: { label: 'Sessions' },
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const esES: TranslationData = {
group_access_control: { label: 'Control de Acceso' },
group_approvals: { label: 'Aprobaciones' },
group_configuration: { label: 'Configuración' },
group_integrations: { label: 'Integraciones' },
group_diagnostics: { label: 'Diagnóstico' },
group_advanced: { label: 'Avanzado' },

Expand All @@ -62,7 +63,14 @@ export const esES: TranslationData = {
nav_settings_hub: { label: 'Todos los Ajustes' },
nav_settings_mail: { label: 'Correo' },
nav_settings_branding: { label: 'Marca' },
nav_settings_auth: { label: 'Autenticación' },
nav_settings_storage: { label: 'Almacenamiento de Archivos' },
nav_settings_ai: { label: 'IA y Embedder' },
nav_settings_knowledge: { label: 'Conocimiento' },
nav_settings_feature_flags: { label: 'Indicadores de Funcionalidad' },
nav_notification_preferences: { label: 'Preferencias de Notificación' },
nav_notification_subscriptions: { label: 'Suscripciones de Notificación' },
nav_notification_templates: { label: 'Plantillas de Notificación' },

nav_sessions: { label: 'Sesiones' },
nav_audit_logs: { label: 'Registros de Auditoría' },
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/apps/translations/ja-JP.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const jaJP: TranslationData = {
group_access_control: { label: 'アクセス制御' },
group_approvals: { label: '承認' },
group_configuration: { label: '構成' },
group_integrations: { label: '統合' },
group_diagnostics: { label: '診断' },
group_advanced: { label: '詳細' },

Expand All @@ -62,7 +63,14 @@ export const jaJP: TranslationData = {
nav_settings_hub: { label: 'すべての設定' },
nav_settings_mail: { label: 'メール' },
nav_settings_branding: { label: 'ブランディング' },
nav_settings_auth: { label: '認証' },
nav_settings_storage: { label: 'ファイルストレージ' },
nav_settings_ai: { label: 'AI と Embedder' },
nav_settings_knowledge: { label: 'ナレッジ' },
nav_settings_feature_flags: { label: '機能フラグ' },
nav_notification_preferences: { label: '通知設定' },
nav_notification_subscriptions: { label: '通知購読' },
nav_notification_templates: { label: '通知テンプレート' },

nav_sessions: { label: 'セッション' },
nav_audit_logs: { label: '監査ログ' },
Expand Down
8 changes: 8 additions & 0 deletions packages/platform-objects/src/apps/translations/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export const zhCN: TranslationData = {
group_access_control: { label: '访问控制' },
group_approvals: { label: '审批' },
group_configuration: { label: '配置' },
group_integrations: { label: '集成' },
group_diagnostics: { label: '诊断' },
group_advanced: { label: '高级' },

Expand All @@ -62,7 +63,14 @@ export const zhCN: TranslationData = {
nav_settings_hub: { label: '全部设置' },
nav_settings_mail: { label: '邮件' },
nav_settings_branding: { label: '品牌' },
nav_settings_auth: { label: '认证' },
nav_settings_storage: { label: '文件存储' },
nav_settings_ai: { label: 'AI 与 Embedder' },
nav_settings_knowledge: { label: '知识库' },
nav_settings_feature_flags: { label: '功能开关' },
nav_notification_preferences: { label: '通知偏好' },
nav_notification_subscriptions: { label: '通知订阅' },
nav_notification_templates: { label: '通知模板' },

nav_sessions: { label: '会话' },
nav_audit_logs: { label: '审计日志' },
Expand Down
43 changes: 41 additions & 2 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,24 @@ const logError = (...args: unknown[]) => (globalThis as any).console?.error(...a
*/
const TRANSLATABLE_META_TYPES = new Set(['view', 'action', 'object', 'app', 'dashboard']);

/**
* Detect the `getMetaItem` response envelope (`{ type, name, item, lock, … }`)
* whose translatable metadata document is nested at `.item`. The cached read
* path and `getMetaItems` element shape hand back the already-unwrapped
* document instead, so translation helpers must distinguish the two: an
* envelope carries a nested `item` object alongside its own `type`/`name`,
* which a bare metadata document never does.
*/
function isMetaEnvelope(value: any): boolean {
return !!value
&& typeof value === 'object'
&& typeof value.type === 'string'
&& typeof value.name === 'string'
&& value.item != null
&& typeof value.item === 'object'
&& !Array.isArray(value.item);
}

/**
* Map a data-layer error to a clean HTTP response. Unknown-object errors
* (SQLite "no such table", PG "relation does not exist", protocol
Expand Down Expand Up @@ -1037,22 +1055,43 @@ export class RestServer {
const locale = this.extractLocale(req, i18n);
if (!locale) return item;
const { translateMetadataDocument } = await import('@objectstack/spec/system');
// `getMetaItem` returns an envelope `{ type, name, item, lock, ... }`
// whose translatable document is nested at `.item`; the cached read
// path hands us the already-unwrapped document. Translate whichever
// shape we received — nav/field labels live on the inner doc, so
// translating the envelope's top level (which has no `navigation`)
// would leave the menu untranslated.
if (isMetaEnvelope(item)) {
return { ...item, item: translateMetadataDocument(type, item.item, bundle, { locale }) };
}
return translateMetadataDocument(type, item, bundle, { locale });
}

/**
* Translate a list of metadata documents using `translateMetaItem`.
*/
private async translateMetaItems(req: any, type: string, environmentId: string | undefined, items: any): Promise<any> {
if (!Array.isArray(items)) return items;
if (!TRANSLATABLE_META_TYPES.has(type)) return items;
// `getMetaItems` may hand back a bare array or an `{ items: [...] }`
// envelope. Unwrap so list responses are localized the same way the
// single-item route is; a non-array, non-envelope value is returned
// untouched.
const arr: any[] | null = Array.isArray(items)
? items
: (items && typeof items === 'object' && Array.isArray(items.items) ? items.items : null);
if (!arr) return items;
const i18n = await this.resolveI18nService(environmentId, req);
const bundle = this.buildTranslationBundle(i18n);
if (!bundle) return items;
const locale = this.extractLocale(req, i18n);
if (!locale) return items;
const { translateMetadataDocument } = await import('@objectstack/spec/system');
return items.map((item) => translateMetadataDocument(type, item, bundle, { locale }));
const translated = arr.map((item) =>
isMetaEnvelope(item)
? { ...item, item: translateMetadataDocument(type, item.item, bundle, { locale }) }
: translateMetadataDocument(type, item, bundle, { locale }),
);
return Array.isArray(items) ? translated : { ...items, items: translated };
}

/**
Expand Down
85 changes: 85 additions & 0 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1595,3 +1595,88 @@ describe('discovery — routes.mcp (ADR-0036, #152)', () => {
expect(body.routes.mcp).toBeUndefined();
});
});

// ──────────────────────────────────────────────────────────────────────────
// Metadata translation — envelope unwrapping
//
// Regression guard for the Setup-app i18n gap: `getMetaItem` returns an
// envelope `{ type, name, item, ... }` whose translatable document (and its
// `navigation` tree) is nested at `.item`. Translating the envelope's top
// level instead of the inner doc left every nav label in English. The
// single-item app route bypasses the HTTP cache (for per-user RBAC
// filtering), so it only ever sees the envelope shape.
// ──────────────────────────────────────────────────────────────────────────
describe('RestServer metadata translation — envelope unwrap', () => {
// Minimal i18n service exposing one zh-CN bundle for the `setup` app.
const fakeI18n = {
getLocales: () => ['zh-CN'],
getDefaultLocale: () => 'zh-CN',
getTranslations: (locale: string) =>
locale === 'zh-CN'
? {
apps: {
setup: {
label: '系统设置',
navigation: {
group_configuration: { label: '配置' },
nav_settings_storage: { label: '文件存储' },
},
},
},
}
: undefined,
};
const zhReq = { headers: { 'accept-language': 'zh-CN' } };
// A fresh app document each call (helpers must not mutate the input).
const makeDoc = () => ({
name: 'setup',
label: 'Setup',
navigation: [
{
id: 'group_configuration',
type: 'group',
label: 'Configuration',
children: [{ id: 'nav_settings_storage', type: 'url', label: 'File Storage' }],
},
],
});

it('translates the inner document of a getMetaItem envelope', async () => {
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any);
const envelope = { type: 'app', name: 'setup', item: makeDoc(), lock: null };
const out = await (rest as any).translateMetaItem(zhReq, 'app', undefined, envelope, fakeI18n);
// Envelope shape preserved …
expect(out.type).toBe('app');
expect(out.name).toBe('setup');
expect(out.lock).toBeNull();
// … and the nested doc — not the envelope top level — is translated.
expect(out.item.label).toBe('系统设置');
expect(out.item.navigation[0].label).toBe('配置');
expect(out.item.navigation[0].children[0].label).toBe('文件存储');
});

it('still translates a bare (already-unwrapped) document', async () => {
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any);
const out = await (rest as any).translateMetaItem(zhReq, 'app', undefined, makeDoc(), fakeI18n);
expect(out.label).toBe('系统设置');
expect(out.navigation[0].children[0].label).toBe('文件存储');
});

it('translates list responses in the `{ items: [...] }` envelope shape', async () => {
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any);
// translateMetaItems resolves the i18n service itself; stub the lookup.
(rest as any).resolveI18nService = async () => fakeI18n;
const listEnvelope = { items: [{ type: 'app', name: 'setup', item: makeDoc() }] };
const out = await (rest as any).translateMetaItems(zhReq, 'app', undefined, listEnvelope);
expect(Array.isArray(out.items)).toBe(true);
expect(out.items[0].item.navigation[0].children[0].label).toBe('文件存储');
});

it('translates a bare array of unwrapped documents', async () => {
const rest = new RestServer(createMockServer() as any, createMockProtocol() as any);
(rest as any).resolveI18nService = async () => fakeI18n;
const out = await (rest as any).translateMetaItems(zhReq, 'app', undefined, [makeDoc()]);
expect(Array.isArray(out)).toBe(true);
expect(out[0].navigation[0].children[0].label).toBe('文件存储');
});
});
33 changes: 33 additions & 0 deletions packages/services/service-settings/src/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ export const esES: TranslationData = {
},
},

auth: {
title: 'Autenticación',
description: 'Inicio de sesión, registro y controles de las funciones de autenticación integradas.',
groups: {
email_password: {
title: 'Correo y contraseña',
description: 'Controla el inicio de sesión local con correo/contraseña y el registro de autoservicio.',
},
social: {
title: 'Inicio de sesión social',
description:
'Configura el proveedor de inicio de sesión de Google integrado. Las variables de entorno del despliegue siguen teniendo prioridad.',
},
},
keys: {
email_password_enabled: { label: 'Habilitar inicio de sesión con correo/contraseña' },
signup_enabled: { label: 'Permitir registro de autoservicio' },
require_email_verification: { label: 'Requerir verificación de correo' },
google_enabled: {
label: 'Habilitar inicio de sesión con Google',
help: 'Requiere un ID de cliente y un secreto de OAuth de Google desde Google Cloud Console.',
},
google_client_id: {
label: 'ID de cliente de Google',
help: 'ID de cliente de OAuth desde Google Cloud Console. También se puede definir GOOGLE_CLIENT_ID en el servidor.',
},
google_client_secret: {
label: 'Secreto de cliente de Google',
help: 'Se almacena cifrado en reposo. También se puede definir GOOGLE_CLIENT_SECRET en el servidor.',
},
},
},

feature_flags: {
title: 'Indicadores de función',
description: 'Activa funciones experimentales y en beta para este espacio de trabajo.',
Expand Down
Loading