|
| 1 | +/** |
| 2 | + * Load application-specific translations for a given language from the API. |
| 3 | + * |
| 4 | + * The @objectstack/spec REST API (`/api/v1/i18n/translations/:locale`) wraps |
| 5 | + * its response in the standard envelope: `{ data: { locale, translations } }`. |
| 6 | + * We extract `data.translations` when present, and fall back to the raw JSON |
| 7 | + * for mock / local-dev environments that may return flat translation objects. |
| 8 | + * |
| 9 | + * When the response uses the @objectstack/spec `TranslationData` format |
| 10 | + * (objects with nested fields), we automatically transform it into the flat |
| 11 | + * format expected by @object-ui/i18n's `useObjectLabel` hook: |
| 12 | + * - `objects.{name}.fields.{field}.label` → `fields.{name}.{field}` (string) |
| 13 | + * - `objects.{name}.fields.{field}.options` → `fieldOptions.{name}.{field}` |
| 14 | + * - Wrapped under an `app` namespace key for hook discovery |
| 15 | + */ |
| 16 | +export async function loadLanguage(lang: string): Promise<Record<string, unknown>> { |
| 17 | + try { |
| 18 | + const serverUrl = import.meta.env.VITE_SERVER_URL || ''; |
| 19 | + const res = await fetch(`${serverUrl}/api/v1/i18n/translations/${lang}`); |
| 20 | + if (!res.ok) { |
| 21 | + console.warn(`[i18n] Failed to load translations for '${lang}': HTTP ${res.status}`); |
| 22 | + return {}; |
| 23 | + } |
| 24 | + const json = await res.json(); |
| 25 | + // Unwrap the spec REST API envelope when present |
| 26 | + let translations: Record<string, unknown>; |
| 27 | + if (json?.data?.translations && typeof json.data.translations === 'object') { |
| 28 | + translations = json.data.translations as Record<string, unknown>; |
| 29 | + } else { |
| 30 | + // Fallback: mock server / local dev returns flat translation objects |
| 31 | + translations = json; |
| 32 | + } |
| 33 | + // Auto-transform @objectstack/spec TranslationData format when detected |
| 34 | + if (isSpecTranslationData(translations)) { |
| 35 | + return transformSpecTranslations(translations); |
| 36 | + } |
| 37 | + return translations; |
| 38 | + } catch (err) { |
| 39 | + console.warn(`[i18n] Failed to load translations for '${lang}':`, err); |
| 40 | + return {}; |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Detect whether the data uses the @objectstack/spec `TranslationData` format. |
| 46 | + * |
| 47 | + * TranslationData has: `objects.{name}.fields.{field}.label` (nested objects). |
| 48 | + * The flat format has: `{namespace}.objects.{name}.label` + `{namespace}.fields.{name}.{field}` (string). |
| 49 | + * |
| 50 | + * We check if `data.objects` exists AND at least one object has a nested `fields` |
| 51 | + * key whose values are objects (not strings). |
| 52 | + */ |
| 53 | +function isSpecTranslationData(data: Record<string, unknown>): boolean { |
| 54 | + const objects = data.objects; |
| 55 | + if (!objects || typeof objects !== 'object' || Array.isArray(objects)) return false; |
| 56 | + for (const obj of Object.values(objects as Record<string, unknown>)) { |
| 57 | + if (obj && typeof obj === 'object' && !Array.isArray(obj) && 'fields' in obj) { |
| 58 | + return true; |
| 59 | + } |
| 60 | + } |
| 61 | + return false; |
| 62 | +} |
| 63 | + |
| 64 | +/** |
| 65 | + * Transform `TranslationData` (objectstack/spec) into the flat format |
| 66 | + * expected by `useObjectLabel` (object-ui/i18n). |
| 67 | + * |
| 68 | + * The result is wrapped under an `app` namespace key so that |
| 69 | + * `useObjectLabel.getAppNamespaces()` discovers it via the presence |
| 70 | + * of `objects` and `fields` sub-keys. |
| 71 | + */ |
| 72 | +function transformSpecTranslations(data: Record<string, unknown>): Record<string, unknown> { |
| 73 | + const objects: Record<string, unknown> = {}; |
| 74 | + const fields: Record<string, Record<string, string>> = {}; |
| 75 | + const fieldOptions: Record<string, Record<string, Record<string, string>>> = {}; |
| 76 | + |
| 77 | + const srcObjects = data.objects as Record<string, any> | undefined; |
| 78 | + if (srcObjects) { |
| 79 | + for (const [objName, objData] of Object.entries(srcObjects)) { |
| 80 | + if (!objData || typeof objData !== 'object') continue; |
| 81 | + |
| 82 | + // Object-level metadata |
| 83 | + const obj: Record<string, unknown> = { label: objData.label }; |
| 84 | + if (objData.pluralLabel) obj.pluralLabel = objData.pluralLabel; |
| 85 | + if (objData.description) obj.description = objData.description; |
| 86 | + objects[objName] = obj; |
| 87 | + |
| 88 | + // Flatten fields: objects.X.fields.Y.label → fields.X.Y = string |
| 89 | + if (objData.fields && typeof objData.fields === 'object') { |
| 90 | + fields[objName] = {}; |
| 91 | + for (const [fieldName, fieldData] of Object.entries(objData.fields as Record<string, any>)) { |
| 92 | + if (fieldData?.label) fields[objName][fieldName] = fieldData.label; |
| 93 | + if (fieldData?.options && typeof fieldData.options === 'object' && Object.keys(fieldData.options).length > 0) { |
| 94 | + if (!fieldOptions[objName]) fieldOptions[objName] = {}; |
| 95 | + fieldOptions[objName][fieldName] = fieldData.options; |
| 96 | + } |
| 97 | + } |
| 98 | + } |
| 99 | + } |
| 100 | + } |
| 101 | + |
| 102 | + const appNs: Record<string, unknown> = {}; |
| 103 | + if (Object.keys(objects).length > 0) appNs.objects = objects; |
| 104 | + if (Object.keys(fields).length > 0) appNs.fields = fields; |
| 105 | + if (Object.keys(fieldOptions).length > 0) appNs.fieldOptions = fieldOptions; |
| 106 | + if (data.apps) appNs.apps = data.apps; |
| 107 | + if (data.messages) appNs.messages = data.messages; |
| 108 | + if (data.validationMessages) appNs.validationMessages = data.validationMessages; |
| 109 | + |
| 110 | + return { app: appNs }; |
| 111 | +} |
0 commit comments