@_linked/translation 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +102 -0
  2. package/lib/cjs/data/translation.json +8 -0
  3. package/lib/esm/archive.d.ts +133 -0
  4. package/lib/esm/archive.js +567 -0
  5. package/lib/esm/archive.js.map +1 -0
  6. package/lib/esm/authorization.d.ts +12 -0
  7. package/lib/esm/authorization.js +10 -0
  8. package/lib/esm/authorization.js.map +1 -0
  9. package/lib/esm/backend.d.ts +1 -0
  10. package/lib/esm/backend.js +3 -0
  11. package/lib/esm/backend.js.map +1 -0
  12. package/lib/esm/cdn-loader.d.ts +40 -0
  13. package/lib/esm/cdn-loader.js +200 -0
  14. package/lib/esm/cdn-loader.js.map +1 -0
  15. package/lib/esm/checks.d.ts +56 -0
  16. package/lib/esm/checks.js +288 -0
  17. package/lib/esm/checks.js.map +1 -0
  18. package/lib/esm/compile.d.ts +64 -0
  19. package/lib/esm/compile.js +99 -0
  20. package/lib/esm/compile.js.map +1 -0
  21. package/lib/esm/core/messages.d.ts +25 -0
  22. package/lib/esm/core/messages.js +76 -0
  23. package/lib/esm/core/messages.js.map +1 -0
  24. package/lib/esm/data/translation.json +8 -0
  25. package/lib/esm/discovery.d.ts +97 -0
  26. package/lib/esm/discovery.js +365 -0
  27. package/lib/esm/discovery.js.map +1 -0
  28. package/lib/esm/exchange.d.ts +100 -0
  29. package/lib/esm/exchange.js +223 -0
  30. package/lib/esm/exchange.js.map +1 -0
  31. package/lib/esm/export.d.ts +28 -0
  32. package/lib/esm/export.js +92 -0
  33. package/lib/esm/export.js.map +1 -0
  34. package/lib/esm/formats/csv.d.ts +8 -0
  35. package/lib/esm/formats/csv.js +397 -0
  36. package/lib/esm/formats/csv.js.map +1 -0
  37. package/lib/esm/formats/json.d.ts +30 -0
  38. package/lib/esm/formats/json.js +459 -0
  39. package/lib/esm/formats/json.js.map +1 -0
  40. package/lib/esm/formats/xliff.d.ts +12 -0
  41. package/lib/esm/formats/xliff.js +577 -0
  42. package/lib/esm/formats/xliff.js.map +1 -0
  43. package/lib/esm/import.d.ts +197 -0
  44. package/lib/esm/import.js +391 -0
  45. package/lib/esm/import.js.map +1 -0
  46. package/lib/esm/index.d.ts +42 -0
  47. package/lib/esm/index.js +34 -0
  48. package/lib/esm/index.js.map +1 -0
  49. package/lib/esm/key-sync-node.d.ts +29 -0
  50. package/lib/esm/key-sync-node.js +174 -0
  51. package/lib/esm/key-sync-node.js.map +1 -0
  52. package/lib/esm/key-sync.d.ts +46 -0
  53. package/lib/esm/key-sync.js +114 -0
  54. package/lib/esm/key-sync.js.map +1 -0
  55. package/lib/esm/key-version.d.ts +50 -0
  56. package/lib/esm/key-version.js +134 -0
  57. package/lib/esm/key-version.js.map +1 -0
  58. package/lib/esm/languages.d.ts +18 -0
  59. package/lib/esm/languages.js +74 -0
  60. package/lib/esm/languages.js.map +1 -0
  61. package/lib/esm/linked-discovery.d.ts +44 -0
  62. package/lib/esm/linked-discovery.js +267 -0
  63. package/lib/esm/linked-discovery.js.map +1 -0
  64. package/lib/esm/message-format.d.ts +41 -0
  65. package/lib/esm/message-format.js +133 -0
  66. package/lib/esm/message-format.js.map +1 -0
  67. package/lib/esm/mt.d.ts +111 -0
  68. package/lib/esm/mt.js +142 -0
  69. package/lib/esm/mt.js.map +1 -0
  70. package/lib/esm/ontologies/translation.d.ts +203 -0
  71. package/lib/esm/ontologies/translation.js +219 -0
  72. package/lib/esm/ontologies/translation.js.map +1 -0
  73. package/lib/esm/package.d.ts +4 -0
  74. package/lib/esm/package.js +11 -0
  75. package/lib/esm/package.js.map +1 -0
  76. package/lib/esm/react.d.ts +55 -0
  77. package/lib/esm/react.js +127 -0
  78. package/lib/esm/react.js.map +1 -0
  79. package/lib/esm/records.d.ts +151 -0
  80. package/lib/esm/records.js +2 -0
  81. package/lib/esm/records.js.map +1 -0
  82. package/lib/esm/release.d.ts +198 -0
  83. package/lib/esm/release.js +435 -0
  84. package/lib/esm/release.js.map +1 -0
  85. package/lib/esm/shape-catalog.d.ts +79 -0
  86. package/lib/esm/shape-catalog.js +118 -0
  87. package/lib/esm/shape-catalog.js.map +1 -0
  88. package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
  89. package/lib/esm/shapes/GlossaryTerm.js +94 -0
  90. package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
  91. package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
  92. package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
  93. package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
  94. package/lib/esm/shapes/TranslationKey.d.ts +92 -0
  95. package/lib/esm/shapes/TranslationKey.js +194 -0
  96. package/lib/esm/shapes/TranslationKey.js.map +1 -0
  97. package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
  98. package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
  99. package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
  100. package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
  101. package/lib/esm/shapes/TranslationLanguage.js +102 -0
  102. package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
  103. package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
  104. package/lib/esm/shapes/TranslationProvider.js +1376 -0
  105. package/lib/esm/shapes/TranslationProvider.js.map +1 -0
  106. package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
  107. package/lib/esm/shapes/TranslationRelease.js +149 -0
  108. package/lib/esm/shapes/TranslationRelease.js.map +1 -0
  109. package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
  110. package/lib/esm/shapes/TranslationRevision.js +159 -0
  111. package/lib/esm/shapes/TranslationRevision.js.map +1 -0
  112. package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
  113. package/lib/esm/shapes/TranslationUnit.js +111 -0
  114. package/lib/esm/shapes/TranslationUnit.js.map +1 -0
  115. package/package.json +134 -0
  116. package/src/archive.ts +894 -0
  117. package/src/authorization.ts +33 -0
  118. package/src/backend.ts +2 -0
  119. package/src/cdn-loader.ts +264 -0
  120. package/src/checks.ts +413 -0
  121. package/src/compile.ts +168 -0
  122. package/src/core/messages.ts +106 -0
  123. package/src/data/translation.json +8 -0
  124. package/src/discovery.ts +594 -0
  125. package/src/exchange.ts +428 -0
  126. package/src/export.ts +135 -0
  127. package/src/formats/csv.ts +459 -0
  128. package/src/formats/json.ts +587 -0
  129. package/src/formats/xliff.ts +708 -0
  130. package/src/import.ts +648 -0
  131. package/src/index.ts +207 -0
  132. package/src/key-sync-node.ts +269 -0
  133. package/src/key-sync.ts +168 -0
  134. package/src/key-version.ts +206 -0
  135. package/src/languages.ts +102 -0
  136. package/src/linked-discovery.ts +385 -0
  137. package/src/message-format.ts +214 -0
  138. package/src/mt.ts +248 -0
  139. package/src/ontologies/translation.ts +234 -0
  140. package/src/package.ts +19 -0
  141. package/src/react.tsx +250 -0
  142. package/src/records.ts +180 -0
  143. package/src/release.ts +726 -0
  144. package/src/shape-catalog.ts +198 -0
  145. package/src/shapes/GlossaryTerm.ts +61 -0
  146. package/src/shapes/TranslationInventoryOrigin.ts +131 -0
  147. package/src/shapes/TranslationKey.ts +174 -0
  148. package/src/shapes/TranslationKeyVersion.ts +77 -0
  149. package/src/shapes/TranslationLanguage.ts +51 -0
  150. package/src/shapes/TranslationProvider.ts +1755 -0
  151. package/src/shapes/TranslationRelease.ts +114 -0
  152. package/src/shapes/TranslationRevision.ts +105 -0
  153. package/src/shapes/TranslationUnit.ts +72 -0
@@ -0,0 +1,1755 @@
1
+ import { ShapeProvider } from '@_linked/server-utils/utils/ShapeProvider';
2
+ import type {
3
+ GlossaryTermRecord,
4
+ TranslationEntryRecord,
5
+ TranslationMemoryMatchRecord,
6
+ TranslationMemoryPretranslateCandidate,
7
+ TranslationMemoryPretranslateReport,
8
+ TranslationMemoryRecord,
9
+ TranslationProposalDecision,
10
+ TranslationRevisionRecord,
11
+ TranslationRevisionStatus,
12
+ TranslationState,
13
+ TranslationKeyVersionRecord,
14
+ TranslationReleaseRecord,
15
+ TranslationUnitRecord,
16
+ } from '../records.js';
17
+ import { TranslationKey } from './TranslationKey.js';
18
+ import { TranslationKeyVersion } from './TranslationKeyVersion.js';
19
+ import { TranslationRelease } from './TranslationRelease.js';
20
+ import { TranslationUnit } from './TranslationUnit.js';
21
+ import { TranslationRevision } from './TranslationRevision.js';
22
+ import { GlossaryTerm } from './GlossaryTerm.js';
23
+ import type { TranslationMessages } from '../core/messages.js';
24
+ import {
25
+ classifyKeyVersion,
26
+ createMonotonicVersionId,
27
+ deriveBackfillKeyVersion,
28
+ deriveTranslationKeyVersionContract,
29
+ } from '../key-version.js';
30
+ import {
31
+ canAuthorTranslation,
32
+ type TranslationAuthoringAction,
33
+ } from '../authorization.js';
34
+
35
+ export interface TranslationKeyInput {
36
+ appId: string;
37
+ key: string;
38
+ sourceText: string;
39
+ namespace?: string;
40
+ description?: string;
41
+ kind?: 'ui' | 'content';
42
+ ofNode?: string;
43
+ ofField?: string;
44
+ ofShape?: string;
45
+ ofProperty?: string;
46
+ fromPackage?: string;
47
+ /** Force the override flag; when omitted it is derived (see upsertTranslationKey). */
48
+ overridden?: boolean;
49
+ format?: 'simple' | 'icu';
50
+ sourceLanguage?: string;
51
+ }
52
+
53
+ export interface TranslationUnitInput {
54
+ appId: string;
55
+ key: string;
56
+ language: string;
57
+ text: string;
58
+ state?: TranslationState;
59
+ updatedBy?: string;
60
+ }
61
+
62
+ /** Who/what authored a unit write — recorded on its `applied` revision (AD-P). */
63
+ export interface TranslationAuthorship {
64
+ /** WebID of the human author, or the MT provider IRI. */
65
+ author?: string;
66
+ authorKind?: 'human' | 'machine';
67
+ /** MT provider key when authorKind is 'machine'. */
68
+ mtProvider?: string;
69
+ note?: string;
70
+ }
71
+
72
+ type TranslationKeyRow = Omit<TranslationEntryRecord, 'units'>;
73
+ type TranslationUnitRow = TranslationUnitRecord & {
74
+ ofKey?: { key?: string };
75
+ ofKeyVersion?: string | { id?: string };
76
+ };
77
+ type TranslationKeyVersionRow = TranslationKeyVersionRecord & {
78
+ ofKey?: { key?: string };
79
+ };
80
+ type TranslationMemoryUnitRow = {
81
+ id?: string | { id?: string };
82
+ language?: string;
83
+ text?: string;
84
+ state?: string;
85
+ updatedAt?: string;
86
+ ofKey?: { key?: string };
87
+ ofKeyVersion?:
88
+ | string
89
+ | {
90
+ id?: string;
91
+ sourceText?: string;
92
+ sourceHash?: string;
93
+ contractHash?: string;
94
+ };
95
+ };
96
+
97
+ const iri = (value: unknown): string | undefined =>
98
+ typeof value === 'string'
99
+ ? value
100
+ : (value as { id?: string } | undefined)?.id;
101
+
102
+ const isoDate = (value: string | Date | undefined): string | undefined =>
103
+ value instanceof Date ? value.toISOString() : value || undefined;
104
+
105
+ function toKeyVersionRecord(
106
+ row: TranslationKeyVersionRow,
107
+ ): TranslationKeyVersionRecord | null {
108
+ const id = iri((row as any).id) ?? iri(row);
109
+ if (!id || !row.versionId) return null;
110
+ return {
111
+ id,
112
+ versionId: row.versionId,
113
+ sourceLanguage: row.sourceLanguage || 'en',
114
+ sourceText: row.sourceText ?? '',
115
+ format: row.format === 'icu' ? 'icu' : 'simple',
116
+ argumentSignature: row.argumentSignature || '[]',
117
+ contractHash: row.contractHash || '',
118
+ sourceHash: row.sourceHash || '',
119
+ supersedes: iri(row.supersedes),
120
+ createdAt: isoDate(row.createdAt) || undefined,
121
+ createdBy: iri(row.createdBy),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Shared TM/carry-forward classifier (Plan 018 §5.5). Exact source matches may
127
+ * cross logical keys; changed-source carry-forward candidates never do.
128
+ */
129
+ export function classifyTranslationMemoryMatches(input: {
130
+ key: string;
131
+ language: string;
132
+ currentVersion: TranslationKeyVersionRecord;
133
+ rows?: TranslationMemoryUnitRow[];
134
+ }): TranslationMemoryMatchRecord[] {
135
+ const matches: TranslationMemoryMatchRecord[] = [];
136
+ for (const row of input.rows ?? []) {
137
+ const version =
138
+ typeof row.ofKeyVersion === 'object' ? row.ofKeyVersion : undefined;
139
+ const unitId = iri(row.id);
140
+ const keyVersionId = iri(row.ofKeyVersion);
141
+ const candidateKey = row.ofKey?.key;
142
+ if (
143
+ !unitId ||
144
+ !keyVersionId ||
145
+ keyVersionId === input.currentVersion.id ||
146
+ !candidateKey ||
147
+ row.language !== input.language ||
148
+ row.state !== 'reviewed' ||
149
+ !row.text?.trim() ||
150
+ !version?.contractHash ||
151
+ version.contractHash !== input.currentVersion.contractHash
152
+ ) {
153
+ continue;
154
+ }
155
+ const exact = version.sourceHash === input.currentVersion.sourceHash;
156
+ if (!exact && candidateKey !== input.key) continue;
157
+ matches.push({
158
+ unitId,
159
+ key: candidateKey,
160
+ language: input.language,
161
+ text: row.text,
162
+ keyVersionId,
163
+ sourceText: version.sourceText ?? '',
164
+ sourceHash: version.sourceHash ?? '',
165
+ contractHash: version.contractHash,
166
+ kind: exact ? 'exact' : 'carry-forward',
167
+ updatedAt: isoDate(row.updatedAt) || undefined,
168
+ });
169
+ }
170
+ return matches.sort(
171
+ (left, right) =>
172
+ (left.kind === right.kind ? 0 : left.kind === 'exact' ? -1 : 1) ||
173
+ (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '') ||
174
+ left.key.localeCompare(right.key),
175
+ );
176
+ }
177
+
178
+ /** Build the reviewed-unit index shown in the explicit TM browser. */
179
+ export function toTranslationMemoryRecords(
180
+ rows: TranslationMemoryUnitRow[] = [],
181
+ ): TranslationMemoryRecord[] {
182
+ return rows
183
+ .flatMap((row): TranslationMemoryRecord[] => {
184
+ const version =
185
+ typeof row.ofKeyVersion === 'object' ? row.ofKeyVersion : undefined;
186
+ const unitId = iri(row.id);
187
+ const keyVersionId = iri(row.ofKeyVersion);
188
+ const key = row.ofKey?.key;
189
+ if (
190
+ !unitId ||
191
+ !keyVersionId ||
192
+ !key ||
193
+ !row.language ||
194
+ row.state !== 'reviewed' ||
195
+ !row.text?.trim() ||
196
+ !version?.sourceHash ||
197
+ !version.contractHash
198
+ ) {
199
+ return [];
200
+ }
201
+ return [
202
+ {
203
+ unitId,
204
+ key,
205
+ language: row.language,
206
+ text: row.text,
207
+ keyVersionId,
208
+ sourceText: version.sourceText ?? '',
209
+ sourceHash: version.sourceHash,
210
+ contractHash: version.contractHash,
211
+ updatedAt: isoDate(row.updatedAt) || undefined,
212
+ },
213
+ ];
214
+ })
215
+ .sort(
216
+ (left, right) =>
217
+ (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '') ||
218
+ left.language.localeCompare(right.language) ||
219
+ left.key.localeCompare(right.key),
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Find current untranslated cells that can be filled without MT cost.
225
+ * Source AND argument-contract hashes must match; the current version can
226
+ * never source itself.
227
+ */
228
+ export function findExactMemoryPretranslations(input: {
229
+ entries: TranslationEntryRecord[];
230
+ memory: TranslationMemoryRecord[];
231
+ language: string;
232
+ }): TranslationMemoryPretranslateCandidate[] {
233
+ const byContract = new Map<string, TranslationMemoryRecord[]>();
234
+ for (const record of input.memory) {
235
+ if (record.language !== input.language) continue;
236
+ const signature = `${record.sourceHash}\u0000${record.contractHash}`;
237
+ const candidates = byContract.get(signature) ?? [];
238
+ candidates.push(record);
239
+ byContract.set(signature, candidates);
240
+ }
241
+ for (const candidates of byContract.values()) {
242
+ candidates.sort(
243
+ (left, right) =>
244
+ (right.updatedAt ?? '').localeCompare(left.updatedAt ?? '') ||
245
+ left.key.localeCompare(right.key),
246
+ );
247
+ }
248
+
249
+ return input.entries.flatMap((entry) => {
250
+ const current = entry.currentVersion;
251
+ const unit = entry.units[input.language];
252
+ if (
253
+ !current?.sourceHash ||
254
+ !current.contractHash ||
255
+ unit?.text.trim()
256
+ ) {
257
+ return [];
258
+ }
259
+ const signature = `${current.sourceHash}\u0000${current.contractHash}`;
260
+ const match = byContract
261
+ .get(signature)
262
+ ?.find((candidate) => candidate.keyVersionId !== current.id);
263
+ return match
264
+ ? [
265
+ {
266
+ key: entry.key,
267
+ language: input.language,
268
+ unitId: match.unitId,
269
+ sourceKey: match.key,
270
+ text: match.text,
271
+ },
272
+ ]
273
+ : [];
274
+ });
275
+ }
276
+
277
+ const TRANSLATION_STATES = new Set<TranslationState>([
278
+ 'untranslated',
279
+ 'machine',
280
+ 'reviewed',
281
+ 'stale',
282
+ ]);
283
+
284
+ function normalizeState(value: unknown): TranslationState {
285
+ return TRANSLATION_STATES.has(value as TranslationState)
286
+ ? (value as TranslationState)
287
+ : 'untranslated';
288
+ }
289
+
290
+ /**
291
+ * Convert LINKED query rows into a deterministic, serialization-safe editor
292
+ * contract. Exported for package-unit testing; it performs no I/O.
293
+ */
294
+ export function groupTranslationEntries(
295
+ keyRows: TranslationKeyRow[] = [],
296
+ unitRows: TranslationUnitRow[] = [],
297
+ versionRows: TranslationKeyVersionRow[] = [],
298
+ ): TranslationEntryRecord[] {
299
+ const entries = new Map<string, TranslationEntryRecord>();
300
+ const currentVersionByKey = new Map<string, string>();
301
+
302
+ for (const row of keyRows ?? []) {
303
+ if (!row?.key) continue;
304
+ const currentVersionId = iri((row as any).currentVersion);
305
+ if (currentVersionId) currentVersionByKey.set(row.key, currentVersionId);
306
+ entries.set(row.key, {
307
+ key: row.key,
308
+ namespace: row.namespace ?? '',
309
+ sourceText: row.sourceText ?? '',
310
+ description: row.description || undefined,
311
+ kind: row.kind === 'content' ? 'content' : 'ui',
312
+ format: row.format === 'icu' ? 'icu' : 'simple',
313
+ ofShape: iri(row.ofShape),
314
+ ofProperty: iri(row.ofProperty),
315
+ fromPackage: row.fromPackage || undefined,
316
+ overridden:
317
+ (row as any).overridden === true || (row as any).overridden === 'true'
318
+ ? true
319
+ : undefined,
320
+ versions: [],
321
+ units: {},
322
+ });
323
+ }
324
+
325
+ for (const row of versionRows ?? []) {
326
+ const key = row?.ofKey?.key;
327
+ const entry = key ? entries.get(key) : undefined;
328
+ const version = toKeyVersionRecord(row);
329
+ if (!entry || !version) continue;
330
+ entry.versions!.push(version);
331
+ if (currentVersionByKey.get(key!) === version.id) {
332
+ entry.currentVersion = version;
333
+ }
334
+ }
335
+ for (const entry of entries.values()) {
336
+ entry.versions?.sort(
337
+ (left, right) =>
338
+ (right.createdAt ?? '').localeCompare(left.createdAt ?? '') ||
339
+ right.versionId.localeCompare(left.versionId),
340
+ );
341
+ if (!entry.versions?.length) delete entry.versions;
342
+ }
343
+
344
+ const unitCandidates = new Map<
345
+ string,
346
+ { score: number; record: TranslationUnitRecord }
347
+ >();
348
+ for (const row of unitRows ?? []) {
349
+ const key = row?.ofKey?.key;
350
+ const language = row?.language;
351
+ const entry = key ? entries.get(key) : undefined;
352
+ if (!entry || !language) continue;
353
+ const keyVersionId = iri(row.ofKeyVersion);
354
+ const score =
355
+ keyVersionId && keyVersionId === entry.currentVersion?.id
356
+ ? 2
357
+ : keyVersionId
358
+ ? 1
359
+ : 0;
360
+ const candidateKey = `${key}\u0000${language}`;
361
+ if ((unitCandidates.get(candidateKey)?.score ?? -1) >= score) continue;
362
+ unitCandidates.set(candidateKey, {
363
+ score,
364
+ record: {
365
+ language,
366
+ text: row.text ?? '',
367
+ state:
368
+ entry.currentVersion && score < 2
369
+ ? 'stale'
370
+ : normalizeState(row.state),
371
+ origin: row.origin || undefined,
372
+ updatedAt: isoDate(row.updatedAt) || undefined,
373
+ keyVersionId,
374
+ },
375
+ });
376
+ }
377
+ for (const [candidateKey, { record }] of unitCandidates) {
378
+ const [key, language] = candidateKey.split('\u0000');
379
+ const entry = entries.get(key);
380
+ if (entry) entry.units[language] = record;
381
+ }
382
+
383
+ return [...entries.values()].sort((a, b) => a.key.localeCompare(b.key));
384
+ }
385
+
386
+ /** App-scoped key read shared by the RPC provider and managed key-sync flows. */
387
+ export async function listTranslationKeys(data: { appId: string }) {
388
+ const appId = requireText(data.appId, 'appId');
389
+ return TranslationKey.select((k) => [
390
+ k.namespace,
391
+ k.key,
392
+ k.sourceText,
393
+ k.description,
394
+ k.kind,
395
+ k.ofNode,
396
+ k.ofField,
397
+ k.ofShape,
398
+ k.ofProperty,
399
+ k.fromPackage,
400
+ k.overridden,
401
+ k.format,
402
+ k.currentVersion,
403
+ ]).where((k) => k.ofApplication.equals({ id: appId } as any));
404
+ }
405
+
406
+ /**
407
+ * App-scoped keys + grouped language units as plain records — the read behind
408
+ * the editor RPC AND the publish/compile flow (Plan 016 P2.3). No auth of its
409
+ * own (the RPC wrapper gates); callers on the server run it in request context.
410
+ */
411
+ export async function listTranslationEntries(data: {
412
+ appId: string;
413
+ }): Promise<TranslationEntryRecord[]> {
414
+ const appId = requireText(data.appId, 'appId');
415
+ const [keys, units, versions] = await Promise.all([
416
+ TranslationKey.select((k) => [
417
+ k.namespace,
418
+ k.key,
419
+ k.sourceText,
420
+ k.description,
421
+ k.kind,
422
+ k.format,
423
+ k.ofShape,
424
+ k.ofProperty,
425
+ k.fromPackage,
426
+ k.overridden,
427
+ k.currentVersion,
428
+ ]).where((k) => k.ofApplication.equals({ id: appId } as any)),
429
+ TranslationUnit.select((u) => [
430
+ u.language,
431
+ u.text,
432
+ u.state,
433
+ u.origin,
434
+ u.updatedAt,
435
+ u.ofKeyVersion,
436
+ u.ofKey.select((k) => [k.key]),
437
+ ]).where((u) => u.ofKey.ofApplication.equals({ id: appId } as any)),
438
+ TranslationKeyVersion.select((v) => [
439
+ v.versionId,
440
+ v.sourceLanguage,
441
+ v.sourceText,
442
+ v.format,
443
+ v.argumentSignature,
444
+ v.contractHash,
445
+ v.sourceHash,
446
+ v.supersedes,
447
+ v.createdAt,
448
+ v.createdBy,
449
+ v.ofKey.select((k) => [k.key]),
450
+ ]).where((v) => v.ofApplication.equals({ id: appId } as any)),
451
+ ]);
452
+ return groupTranslationEntries(keys as any, units as any, versions as any);
453
+ }
454
+
455
+ export async function listTranslationKeyVersions(data: {
456
+ appId: string;
457
+ key?: string;
458
+ }): Promise<TranslationKeyVersionRecord[]> {
459
+ const appId = requireText(data.appId, 'appId');
460
+ const keyId = data.key
461
+ ? keyIri(appId, requireText(data.key, 'key'))
462
+ : undefined;
463
+ const query = TranslationKeyVersion.select((v) => [
464
+ v.versionId,
465
+ v.sourceLanguage,
466
+ v.sourceText,
467
+ v.format,
468
+ v.argumentSignature,
469
+ v.contractHash,
470
+ v.sourceHash,
471
+ v.supersedes,
472
+ v.createdAt,
473
+ v.createdBy,
474
+ ]).where((v) =>
475
+ keyId
476
+ ? v.ofApplication
477
+ .equals({ id: appId } as any)
478
+ .and(v.ofKey.equals({ id: keyId } as any))
479
+ : v.ofApplication.equals({ id: appId } as any),
480
+ );
481
+ const rows = await query;
482
+ return (rows ?? [])
483
+ .map((row: any) => toKeyVersionRecord(row))
484
+ .filter(
485
+ (record): record is TranslationKeyVersionRecord => record !== null,
486
+ )
487
+ .sort(
488
+ (left, right) =>
489
+ (right.createdAt ?? '').localeCompare(left.createdAt ?? '') ||
490
+ right.versionId.localeCompare(left.versionId),
491
+ );
492
+ }
493
+
494
+ function toReleaseRecord(row: any): TranslationReleaseRecord | null {
495
+ const id = iri(row?.id) ?? iri(row);
496
+ const appId = iri(row?.ofApplication);
497
+ const branchId = iri(row?.branch);
498
+ if (!id || !appId || !branchId || !row?.releaseId) return null;
499
+ return {
500
+ id,
501
+ releaseId: row.releaseId,
502
+ appId,
503
+ branchId,
504
+ buildId: row.buildId || undefined,
505
+ channel: row.channel === 'production' ? 'production' : 'preview',
506
+ status: ['published', 'superseded', 'retired'].includes(row.status)
507
+ ? row.status
508
+ : 'draft',
509
+ contractSetHash: row.contractSetHash || '',
510
+ manifestHash: row.manifestHash || '',
511
+ hotfixSequence: Number(row.hotfixSequence ?? 0),
512
+ qualitySummary: row.qualitySummary || undefined,
513
+ createdAt: isoDate(row.createdAt) || '',
514
+ publishedAt: isoDate(row.publishedAt) || undefined,
515
+ supportedUntil: isoDate(row.supportedUntil) || undefined,
516
+ supersedes: iri(row.supersedes),
517
+ };
518
+ }
519
+
520
+ export async function listTranslationReleases(data: {
521
+ appId: string;
522
+ }): Promise<TranslationReleaseRecord[]> {
523
+ const appId = requireText(data.appId, 'appId');
524
+ const rows = await TranslationRelease.select((release) => [
525
+ release.releaseId,
526
+ release.ofApplication,
527
+ release.branch,
528
+ release.buildId,
529
+ release.channel,
530
+ release.status,
531
+ release.contractSetHash,
532
+ release.manifestHash,
533
+ release.hotfixSequence,
534
+ release.qualitySummary,
535
+ release.createdAt,
536
+ release.publishedAt,
537
+ release.supportedUntil,
538
+ release.supersedes,
539
+ ]).where((release) =>
540
+ release.ofApplication.equals({ id: appId } as any),
541
+ );
542
+ return (rows ?? [])
543
+ .map(toReleaseRecord)
544
+ .filter((record): record is TranslationReleaseRecord => record !== null)
545
+ .sort((left, right) => right.createdAt.localeCompare(left.createdAt));
546
+ }
547
+
548
+ export async function createTranslationRelease(
549
+ data: Omit<TranslationReleaseRecord, 'id' | 'createdAt'> & {
550
+ createdAt?: string;
551
+ },
552
+ ): Promise<TranslationReleaseRecord> {
553
+ const appId = requireText(data.appId, 'appId');
554
+ const releaseId = requireText(data.releaseId, 'releaseId');
555
+ const branchId = requireText(data.branchId, 'branchId');
556
+ if (!['preview', 'production'].includes(data.channel)) {
557
+ throw new Error('channel must be preview or production.');
558
+ }
559
+ if (!['draft', 'published', 'superseded', 'retired'].includes(data.status)) {
560
+ throw new Error('Invalid translation release status.');
561
+ }
562
+ const id = `${appId.replace(/\/$/, '')}/translation/release/${encodeURIComponent(releaseId)}`;
563
+ const existing = await TranslationRelease.select((release) => [
564
+ release.releaseId,
565
+ ])
566
+ .where((release) => release.equals({ id } as any))
567
+ .one()
568
+ .catch(() => null);
569
+ if (existing) {
570
+ throw new Error(`Translation release "${releaseId}" already exists.`);
571
+ }
572
+ const createdAt = data.createdAt || new Date().toISOString();
573
+ await TranslationRelease.create({
574
+ __id: id,
575
+ ofApplication: { id: appId },
576
+ branch: { id: branchId },
577
+ releaseId,
578
+ buildId: data.buildId,
579
+ channel: data.channel,
580
+ status: data.status,
581
+ contractSetHash: requireText(data.contractSetHash, 'contractSetHash'),
582
+ manifestHash: requireText(data.manifestHash, 'manifestHash'),
583
+ hotfixSequence: data.hotfixSequence,
584
+ qualitySummary: data.qualitySummary,
585
+ createdAt: new Date(createdAt),
586
+ publishedAt: data.publishedAt ? new Date(data.publishedAt) : undefined,
587
+ supportedUntil: data.supportedUntil ? new Date(data.supportedUntil) : undefined,
588
+ supersedes: data.supersedes ? { id: data.supersedes } : undefined,
589
+ } as any);
590
+ return { ...data, id, createdAt };
591
+ }
592
+
593
+ export async function advanceTranslationReleaseHotfix(data: {
594
+ id: string;
595
+ expectedSequence: number;
596
+ hotfixSequence: number;
597
+ manifestHash: string;
598
+ }): Promise<TranslationReleaseRecord> {
599
+ const id = requireText(data.id, 'id');
600
+ const current = await TranslationRelease.select((release) => [
601
+ release.releaseId,
602
+ release.ofApplication,
603
+ release.branch,
604
+ release.buildId,
605
+ release.channel,
606
+ release.status,
607
+ release.contractSetHash,
608
+ release.manifestHash,
609
+ release.hotfixSequence,
610
+ release.qualitySummary,
611
+ release.createdAt,
612
+ release.publishedAt,
613
+ release.supportedUntil,
614
+ release.supersedes,
615
+ ])
616
+ .where((release) => release.equals({ id } as any))
617
+ .one()
618
+ .catch(() => null);
619
+ const record = current ? toReleaseRecord(current) : null;
620
+ if (!record) throw new Error(`Translation release "${id}" was not found.`);
621
+ if (record.hotfixSequence !== data.expectedSequence) {
622
+ throw new Error(
623
+ `Translation release advanced from sequence ${data.expectedSequence} to ${record.hotfixSequence}.`,
624
+ );
625
+ }
626
+ await TranslationRelease.update({
627
+ hotfixSequence: data.hotfixSequence,
628
+ manifestHash: requireText(data.manifestHash, 'manifestHash'),
629
+ } as any).for({ id } as any);
630
+ return {
631
+ ...record,
632
+ hotfixSequence: data.hotfixSequence,
633
+ manifestHash: data.manifestHash,
634
+ };
635
+ }
636
+
637
+ function requireText(value: string, label: string): string {
638
+ const clean = value?.trim();
639
+ if (!clean) throw new Error(`${label} is required.`);
640
+ return clean;
641
+ }
642
+
643
+ function keyIri(appId: string, key: string): string {
644
+ return `${appId.replace(/\/$/, '')}/translation/key/${encodeURIComponent(key)}`;
645
+ }
646
+
647
+ function versionIri(keyId: string, versionId: string): string {
648
+ return `${keyId.replace(/\/$/, '')}/version/${versionId}`;
649
+ }
650
+
651
+ function unitIri(keyVersionId: string, language: string): string {
652
+ return `${keyVersionId.replace(/\/$/, '')}/unit/${encodeURIComponent(language)}`;
653
+ }
654
+
655
+ /** Unique append-only revision IRI under the version-scoped cell it revises. */
656
+ function revisionIri(keyVersionId: string, language: string): string {
657
+ const suffix = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
658
+ return `${unitIri(keyVersionId, language)}/rev/${suffix}`;
659
+ }
660
+
661
+ async function readKeyVersion(
662
+ id: string,
663
+ ): Promise<TranslationKeyVersionRecord | null> {
664
+ const row = await TranslationKeyVersion.select((v) => [
665
+ v.versionId,
666
+ v.sourceLanguage,
667
+ v.sourceText,
668
+ v.format,
669
+ v.argumentSignature,
670
+ v.contractHash,
671
+ v.sourceHash,
672
+ v.supersedes,
673
+ v.createdAt,
674
+ v.createdBy,
675
+ ])
676
+ .where((v) => v.equals({ id } as any))
677
+ .one()
678
+ .catch(() => null);
679
+ return row ? toKeyVersionRecord(row as any) : null;
680
+ }
681
+
682
+ async function createTranslationKeyVersion(input: {
683
+ appId: string;
684
+ keyId: string;
685
+ sourceLanguage: string;
686
+ sourceText: string;
687
+ format: 'simple' | 'icu';
688
+ supersedes?: string;
689
+ createdBy?: string;
690
+ backfill?: boolean;
691
+ }): Promise<TranslationKeyVersionRecord> {
692
+ const derived = input.backfill
693
+ ? await deriveBackfillKeyVersion(input)
694
+ : {
695
+ ...(await deriveTranslationKeyVersionContract(input)),
696
+ versionId: createMonotonicVersionId(),
697
+ };
698
+ const id = versionIri(input.keyId, derived.versionId);
699
+ const createdAt = new Date().toISOString();
700
+ await TranslationKeyVersion.create({
701
+ __id: id,
702
+ ofApplication: { id: input.appId },
703
+ ofKey: { id: input.keyId },
704
+ versionId: derived.versionId,
705
+ sourceLanguage: derived.sourceLanguage,
706
+ sourceText: derived.sourceText,
707
+ format: derived.format,
708
+ argumentSignature: derived.argumentSignature,
709
+ contractHash: derived.contractHash,
710
+ sourceHash: derived.sourceHash,
711
+ supersedes: input.supersedes ? { id: input.supersedes } : undefined,
712
+ createdAt: new Date(createdAt),
713
+ createdBy: input.createdBy ? { id: input.createdBy } : undefined,
714
+ } as any);
715
+ return {
716
+ id,
717
+ ...derived,
718
+ supersedes: input.supersedes,
719
+ createdAt,
720
+ createdBy: input.createdBy,
721
+ };
722
+ }
723
+
724
+ async function ensureCurrentKeyVersion(input: {
725
+ appId: string;
726
+ keyId: string;
727
+ currentVersion?: unknown;
728
+ sourceLanguage: string;
729
+ sourceText: string;
730
+ format: 'simple' | 'icu';
731
+ createdBy?: string;
732
+ }): Promise<TranslationKeyVersionRecord> {
733
+ const currentId = iri(input.currentVersion);
734
+ if (currentId) {
735
+ const current = await readKeyVersion(currentId);
736
+ if (current) return current;
737
+ }
738
+ const initial = await createTranslationKeyVersion({
739
+ ...input,
740
+ backfill: true,
741
+ });
742
+ await TranslationKey.update({
743
+ currentVersion: { id: initial.id },
744
+ } as any).for({ id: input.keyId } as any);
745
+ return initial;
746
+ }
747
+
748
+ const REVISION_STATUSES = new Set<TranslationRevisionStatus>([
749
+ 'applied',
750
+ 'proposed',
751
+ 'suggested',
752
+ 'accepted',
753
+ 'rejected',
754
+ 'superseded',
755
+ ]);
756
+
757
+ async function requireKeyWriteContext(input: {
758
+ appId: string;
759
+ key: string;
760
+ createdBy?: string;
761
+ }): Promise<{ keyId: string; version: TranslationKeyVersionRecord }> {
762
+ const keyId = keyIri(input.appId, input.key);
763
+ const keyRow = await TranslationKey.select((k) => [
764
+ k.sourceText,
765
+ k.format,
766
+ k.currentVersion,
767
+ ])
768
+ .where((k) => k.equals({ id: keyId } as any))
769
+ .one()
770
+ .catch(() => null);
771
+ if (!keyRow) {
772
+ throw new Error(
773
+ `Translation key "${input.key}" does not exist for this app.`,
774
+ );
775
+ }
776
+ const version = await ensureCurrentKeyVersion({
777
+ appId: input.appId,
778
+ keyId,
779
+ currentVersion: (keyRow as any).currentVersion,
780
+ sourceLanguage: 'en',
781
+ sourceText: (keyRow as any).sourceText ?? '',
782
+ format: (keyRow as any).format === 'icu' ? 'icu' : 'simple',
783
+ createdBy: input.createdBy,
784
+ });
785
+ return { keyId, version };
786
+ }
787
+
788
+ /**
789
+ * Convert LINKED revision rows into plain RPC-safe records, newest first.
790
+ * Exported for package-unit testing; performs no I/O.
791
+ */
792
+ export function toRevisionRecords(
793
+ rows: Array<Record<string, unknown>> = [],
794
+ ): TranslationRevisionRecord[] {
795
+ const iri = (value: unknown): string | undefined =>
796
+ typeof value === 'string'
797
+ ? value
798
+ : (value as { id?: string } | undefined)?.id;
799
+ return rows
800
+ .filter((row) => typeof (row as any)?.text === 'string')
801
+ .map((row: any): TranslationRevisionRecord => ({
802
+ id: iri(row.id) ?? iri(row) ?? '',
803
+ key: row.ofKey?.key ?? '',
804
+ language: row.language ?? '',
805
+ text: row.text ?? '',
806
+ status: REVISION_STATUSES.has(row.status)
807
+ ? (row.status as TranslationRevisionStatus)
808
+ : 'applied',
809
+ author: iri(row.author),
810
+ authorKind: row.authorKind === 'machine' ? 'machine' : 'human',
811
+ mtProvider: row.mtProvider || undefined,
812
+ basedOnText: typeof row.basedOnText === 'string' ? row.basedOnText : undefined,
813
+ note: row.note || undefined,
814
+ createdAt: isoDate(row.createdAt) || undefined,
815
+ decidedAt: isoDate(row.decidedAt) || undefined,
816
+ decidedBy: iri(row.decidedBy),
817
+ keyVersionId: iri(row.ofKeyVersion),
818
+ }))
819
+ .sort(
820
+ (a, b) =>
821
+ (b.createdAt ?? '').localeCompare(a.createdAt ?? '') ||
822
+ b.id.localeCompare(a.id),
823
+ );
824
+ }
825
+
826
+ export async function getTranslationRevision(data: {
827
+ appId: string;
828
+ revisionId: string;
829
+ }): Promise<TranslationRevisionRecord | null> {
830
+ const appId = requireText(data.appId, 'appId');
831
+ const revisionId = requireText(data.revisionId, 'revisionId');
832
+ const row = await TranslationRevision.select((revision) => [
833
+ revision.language,
834
+ revision.text,
835
+ revision.status,
836
+ revision.author,
837
+ revision.authorKind,
838
+ revision.mtProvider,
839
+ revision.basedOnText,
840
+ revision.note,
841
+ revision.createdAt,
842
+ revision.decidedAt,
843
+ revision.decidedBy,
844
+ revision.ofKeyVersion,
845
+ revision.ofKey.select((key) => [key.key]),
846
+ ])
847
+ .where((revision) =>
848
+ revision
849
+ .equals({ id: revisionId } as any)
850
+ .and(revision.ofApplication.equals({ id: appId } as any)),
851
+ )
852
+ .one()
853
+ .catch(() => null);
854
+ return row ? toRevisionRecords([row as any])[0] ?? null : null;
855
+ }
856
+
857
+ /** Pending human proposals for the app, optionally narrowed to one language. */
858
+ export async function listTranslationProposals(data: {
859
+ appId: string;
860
+ language?: string;
861
+ }): Promise<TranslationRevisionRecord[]> {
862
+ const appId = requireText(data.appId, 'appId');
863
+ const language = data.language?.trim();
864
+ const rows = await TranslationRevision.select((revision) => [
865
+ revision.language,
866
+ revision.text,
867
+ revision.status,
868
+ revision.author,
869
+ revision.authorKind,
870
+ revision.basedOnText,
871
+ revision.note,
872
+ revision.createdAt,
873
+ revision.ofKeyVersion,
874
+ revision.ofKey.select((key) => [key.key]),
875
+ ]).where((revision) => {
876
+ const base = revision.ofApplication
877
+ .equals({ id: appId } as any)
878
+ .and(revision.status.equals('proposed'));
879
+ return language ? base.and(revision.language.equals(language)) : base;
880
+ });
881
+ const proposals = toRevisionRecords(rows as any);
882
+ const entries = await listTranslationEntries({ appId });
883
+ const current = new Map(
884
+ entries.flatMap((entry) =>
885
+ Object.entries(entry.units).map(([unitLanguage, unit]) => [
886
+ `${entry.key}\u0000${unitLanguage}`,
887
+ unit.text,
888
+ ]),
889
+ ),
890
+ );
891
+ return proposals.map((proposal) => {
892
+ const currentText = current.get(`${proposal.key}\u0000${proposal.language}`) ?? '';
893
+ return {
894
+ ...proposal,
895
+ currentText,
896
+ conflicted: (proposal.basedOnText ?? '') !== currentText,
897
+ };
898
+ });
899
+ }
900
+
901
+ export async function decideTranslationProposal(
902
+ data: {
903
+ appId: string;
904
+ revisionId: string;
905
+ decision: 'accept' | 'reject';
906
+ note?: string;
907
+ },
908
+ reviewer?: string,
909
+ ): Promise<TranslationProposalDecision> {
910
+ const proposal = await getTranslationRevision({
911
+ appId: data.appId,
912
+ revisionId: data.revisionId,
913
+ });
914
+ if (!proposal || proposal.status !== 'proposed') {
915
+ throw new Error('This translation proposal is no longer pending.');
916
+ }
917
+ const decisionNote = data.note?.trim();
918
+ if (data.decision === 'reject' && !decisionNote) {
919
+ throw new Error('A reviewer note is required when rejecting a proposal.');
920
+ }
921
+ const now = new Date().toISOString();
922
+ let unit: TranslationUnitRecord | undefined;
923
+ if (data.decision === 'accept') {
924
+ const currentEntries = await listTranslationEntries({ appId: data.appId });
925
+ const currentText =
926
+ currentEntries.find(({ key }) => key === proposal.key)?.units[
927
+ proposal.language
928
+ ]?.text ?? '';
929
+ if ((proposal.basedOnText ?? '') !== currentText) {
930
+ throw new Error(
931
+ 'This proposal conflicts with a newer translation. Review it again before accepting.',
932
+ );
933
+ }
934
+ await upsertTranslationUnit(
935
+ {
936
+ appId: data.appId,
937
+ key: proposal.key,
938
+ language: proposal.language,
939
+ text: proposal.text,
940
+ state: 'reviewed',
941
+ },
942
+ {
943
+ author: reviewer,
944
+ authorKind: 'human',
945
+ note: data.note || `Accepted proposal ${proposal.id}.`,
946
+ },
947
+ );
948
+ unit = {
949
+ language: proposal.language,
950
+ text: proposal.text,
951
+ state: 'reviewed',
952
+ updatedAt: now,
953
+ };
954
+ const competing = await listTranslationProposals({
955
+ appId: data.appId,
956
+ language: proposal.language,
957
+ });
958
+ await Promise.all(
959
+ competing
960
+ .filter(
961
+ ({ id, key }) => id !== proposal.id && key === proposal.key,
962
+ )
963
+ .map((revision) =>
964
+ TranslationRevision.update({
965
+ status: 'superseded',
966
+ note: `Superseded by accepted proposal ${proposal.id}.`,
967
+ decidedAt: new Date(now),
968
+ decidedBy: reviewer ? { id: reviewer } : undefined,
969
+ } as any).for({ id: revision.id } as any),
970
+ ),
971
+ );
972
+ }
973
+ await TranslationRevision.update({
974
+ status: data.decision === 'accept' ? 'accepted' : 'rejected',
975
+ note: decisionNote || proposal.note,
976
+ decidedAt: new Date(now),
977
+ decidedBy: reviewer ? { id: reviewer } : undefined,
978
+ } as any).for({ id: proposal.id } as any);
979
+ return {
980
+ revision: {
981
+ ...proposal,
982
+ status: data.decision === 'accept' ? 'accepted' : 'rejected',
983
+ note: decisionNote || proposal.note,
984
+ decidedAt: now,
985
+ decidedBy: reviewer,
986
+ conflicted: false,
987
+ currentText: unit?.text ?? proposal.currentText,
988
+ },
989
+ unit,
990
+ };
991
+ }
992
+
993
+ /**
994
+ * Idempotent key write shared by the RPC provider and managed key-sync flows.
995
+ * Source drift marks every existing language unit stale before returning.
996
+ */
997
+ export async function upsertTranslationKey(
998
+ data: TranslationKeyInput,
999
+ authorship: { createdBy?: string } = {},
1000
+ ): Promise<{ id: string; versionId: string; versionCreated: boolean }> {
1001
+ const appId = requireText(data.appId, 'appId');
1002
+ const key = requireText(data.key, 'key');
1003
+ requireText(data.sourceText, 'sourceText');
1004
+ const id = keyIri(appId, key);
1005
+ const values = {
1006
+ namespace: data.namespace ?? key.split('.').slice(0, -1).join('.'),
1007
+ key,
1008
+ sourceText: data.sourceText,
1009
+ description: data.description,
1010
+ kind: data.kind ?? 'ui',
1011
+ ofApplication: { id: appId },
1012
+ ofNode: data.ofNode ? { id: data.ofNode } : undefined,
1013
+ ofField: data.ofField,
1014
+ ofShape: data.ofShape ? { id: data.ofShape } : undefined,
1015
+ ofProperty: data.ofProperty ? { id: data.ofProperty } : undefined,
1016
+ fromPackage: data.fromPackage,
1017
+ overridden: data.overridden === true ? true : undefined,
1018
+ format: data.format ?? 'simple',
1019
+ } as any;
1020
+ const existing = await TranslationKey.select((k) => [
1021
+ k.sourceText,
1022
+ k.format,
1023
+ k.ofShape,
1024
+ k.overridden,
1025
+ k.currentVersion,
1026
+ ])
1027
+ .where((k) => k.equals({ id } as any))
1028
+ .one()
1029
+ .catch(() => null);
1030
+ if (existing) {
1031
+ const sourceChanged = (existing as any).sourceText !== data.sourceText;
1032
+ const wasOverridden = (existing as any).overridden === true;
1033
+ // AD-N override rule: editing the source of a SHAPE-owned key through the
1034
+ // per-app path (this write) marks the app copy `overridden`, so shape-route
1035
+ // propagation won't clobber the app's local wording. An explicit
1036
+ // `data.overridden` (e.g. the installer clearing it) always wins.
1037
+ const isShapeKey = !!(existing as any).ofShape || !!data.ofShape;
1038
+ if (data.overridden === undefined && isShapeKey && sourceChanged) {
1039
+ values.overridden = true;
1040
+ // Snapshot the canonical at FIRST divergence so "clear override" has a
1041
+ // value to revert to even for a single-app project. Later edits keep the
1042
+ // original snapshot (don't re-capture the already-overridden wording).
1043
+ if (!wasOverridden) values.shapeSource = (existing as any).sourceText;
1044
+ }
1045
+ const currentVersion = await ensureCurrentKeyVersion({
1046
+ appId,
1047
+ keyId: id,
1048
+ currentVersion: (existing as any).currentVersion,
1049
+ sourceLanguage: data.sourceLanguage ?? 'en',
1050
+ sourceText: (existing as any).sourceText ?? '',
1051
+ format: (existing as any).format === 'icu' ? 'icu' : 'simple',
1052
+ createdBy: authorship.createdBy,
1053
+ });
1054
+ const decision = classifyKeyVersion(currentVersion, {
1055
+ sourceText: data.sourceText,
1056
+ format: data.format ?? 'simple',
1057
+ });
1058
+ const nextVersion =
1059
+ decision.action === 'create-version'
1060
+ ? await createTranslationKeyVersion({
1061
+ appId,
1062
+ keyId: id,
1063
+ sourceLanguage:
1064
+ data.sourceLanguage ?? currentVersion.sourceLanguage ?? 'en',
1065
+ sourceText: data.sourceText,
1066
+ format: data.format ?? 'simple',
1067
+ supersedes: currentVersion.id,
1068
+ createdBy: authorship.createdBy,
1069
+ })
1070
+ : currentVersion;
1071
+ values.currentVersion = { id: nextVersion.id };
1072
+ await TranslationKey.update(values).for({ id } as any);
1073
+ if (decision.action === 'create-version') {
1074
+ await TranslationUnit.update({ state: 'stale' } as any).where((u) =>
1075
+ u.ofKey.equals({ id } as any),
1076
+ );
1077
+ }
1078
+ return {
1079
+ id,
1080
+ versionId: nextVersion.id,
1081
+ versionCreated: decision.action === 'create-version',
1082
+ };
1083
+ }
1084
+ await TranslationKey.create({ __id: id, ...values });
1085
+ const firstVersion = await createTranslationKeyVersion({
1086
+ appId,
1087
+ keyId: id,
1088
+ sourceLanguage: data.sourceLanguage ?? 'en',
1089
+ sourceText: data.sourceText,
1090
+ format: data.format ?? 'simple',
1091
+ createdBy: authorship.createdBy,
1092
+ });
1093
+ await TranslationKey.update({
1094
+ currentVersion: { id: firstVersion.id },
1095
+ } as any).for({ id } as any);
1096
+ return { id, versionId: firstVersion.id, versionCreated: true };
1097
+ }
1098
+
1099
+ /**
1100
+ * Append one revision WITHOUT touching the unit (Plan 017 AD-P) — the write
1101
+ * path for MT SUGGESTIONS (Slice B) and translator PROPOSALS (Slice C): a
1102
+ * pending value a reviewer/editor applies later. `basedOnText` should carry
1103
+ * the unit text visible to the author, for conflict detection at decision time.
1104
+ */
1105
+ export async function createTranslationRevision(data: {
1106
+ appId: string;
1107
+ key: string;
1108
+ language: string;
1109
+ text: string;
1110
+ status: TranslationRevisionStatus;
1111
+ author?: string;
1112
+ authorKind?: 'human' | 'machine';
1113
+ mtProvider?: string;
1114
+ basedOnText?: string;
1115
+ note?: string;
1116
+ }): Promise<{ id: string }> {
1117
+ const appId = requireText(data.appId, 'appId');
1118
+ const key = requireText(data.key, 'key');
1119
+ const language = requireText(data.language, 'language');
1120
+ const { keyId, version } = await requireKeyWriteContext({
1121
+ appId,
1122
+ key,
1123
+ createdBy: data.author,
1124
+ });
1125
+ const id = revisionIri(version.id, language);
1126
+ await TranslationRevision.create({
1127
+ __id: id,
1128
+ ofApplication: { id: appId },
1129
+ ofKey: { id: keyId },
1130
+ ofKeyVersion: { id: version.id },
1131
+ language,
1132
+ text: data.text,
1133
+ status: data.status,
1134
+ author: data.author ? { id: data.author } : undefined,
1135
+ authorKind: data.authorKind ?? 'human',
1136
+ mtProvider: data.mtProvider,
1137
+ basedOnText: data.basedOnText,
1138
+ note: data.note,
1139
+ createdAt: new Date(),
1140
+ } as any);
1141
+ return { id };
1142
+ }
1143
+
1144
+ /**
1145
+ * Idempotent unit write shared by the RPC provider, import, and (P4) MT flows.
1146
+ * Every write that CHANGES the text also records an append-only
1147
+ * `status:'applied'` `TranslationRevision` (Plan 017 AD-P) carrying the
1148
+ * previous text as `basedOnText` — the audit trail behind the history UI.
1149
+ * Re-saving identical text updates state/provenance without a revision.
1150
+ */
1151
+ export async function upsertTranslationUnit(
1152
+ data: TranslationUnitInput,
1153
+ authorship: TranslationAuthorship = {},
1154
+ ): Promise<{ id: string; revisionId?: string }> {
1155
+ const appId = requireText(data.appId, 'appId');
1156
+ const key = requireText(data.key, 'key');
1157
+ const language = requireText(data.language, 'language');
1158
+ const author = authorship.author ?? data.updatedBy;
1159
+ const { keyId, version } = await requireKeyWriteContext({
1160
+ appId,
1161
+ key,
1162
+ createdBy: author,
1163
+ });
1164
+ const now = new Date().toISOString();
1165
+ const values = {
1166
+ ofApplication: { id: appId },
1167
+ ofKey: { id: keyId },
1168
+ ofKeyVersion: { id: version.id },
1169
+ language,
1170
+ text: data.text,
1171
+ state: data.state ?? 'reviewed',
1172
+ updatedAt: new Date(now),
1173
+ updatedBy: author ? { id: author } : undefined,
1174
+ origin: authorship.mtProvider === 'tm' ? 'tm' : '',
1175
+ } as any;
1176
+ const existing = await TranslationUnit.select((u) => [u.text])
1177
+ .where((u) =>
1178
+ u.language
1179
+ .equals(language)
1180
+ .and(u.ofKeyVersion.equals({ id: version.id } as any)),
1181
+ )
1182
+ .one()
1183
+ .catch(() => null);
1184
+ const id =
1185
+ iri((existing as any)?.id) ?? unitIri(version.id, language);
1186
+ if (existing) await TranslationUnit.update(values).for({ id } as any);
1187
+ else await TranslationUnit.create({ __id: id, ...values });
1188
+
1189
+ const previousText = (existing as any)?.text as string | undefined;
1190
+ if (existing && previousText === data.text) return { id };
1191
+ const revisionId = revisionIri(version.id, language);
1192
+ await TranslationRevision.create({
1193
+ __id: revisionId,
1194
+ ofApplication: { id: appId },
1195
+ ofKey: { id: keyId },
1196
+ ofKeyVersion: { id: version.id },
1197
+ language,
1198
+ text: data.text,
1199
+ status: 'applied',
1200
+ author: author ? { id: author } : undefined,
1201
+ authorKind: authorship.authorKind ?? 'human',
1202
+ mtProvider: authorship.mtProvider,
1203
+ basedOnText: previousText,
1204
+ note: authorship.note,
1205
+ createdAt: new Date(now),
1206
+ } as any);
1207
+ return { id, revisionId };
1208
+ }
1209
+
1210
+ /** One app-scoped lookup powers both exact TM and same-key carry-forward. */
1211
+ export async function listTranslationMemoryMatches(data: {
1212
+ appId: string;
1213
+ key: string;
1214
+ language: string;
1215
+ }): Promise<TranslationMemoryMatchRecord[]> {
1216
+ const appId = requireText(data.appId, 'appId');
1217
+ const key = requireText(data.key, 'key');
1218
+ const language = requireText(data.language, 'language');
1219
+ const keyId = keyIri(appId, key);
1220
+ const keyRow = await TranslationKey.select((candidate) => [
1221
+ candidate.currentVersion,
1222
+ ])
1223
+ .where((candidate) => candidate.equals({ id: keyId } as any))
1224
+ .one()
1225
+ .catch(() => null);
1226
+ const currentVersionId = iri((keyRow as any)?.currentVersion);
1227
+ const currentVersion = currentVersionId
1228
+ ? await readKeyVersion(currentVersionId)
1229
+ : null;
1230
+ if (!currentVersion) return [];
1231
+
1232
+ const rows = await TranslationUnit.select((unit) => [
1233
+ unit.language,
1234
+ unit.text,
1235
+ unit.state,
1236
+ unit.updatedAt,
1237
+ unit.ofKey.select((candidate) => [candidate.key]),
1238
+ unit.ofKeyVersion.select((version) => [
1239
+ version.sourceText,
1240
+ version.sourceHash,
1241
+ version.contractHash,
1242
+ ]),
1243
+ ]).where((unit) =>
1244
+ unit.ofApplication
1245
+ .equals({ id: appId } as any)
1246
+ .and(unit.language.equals(language))
1247
+ .and(unit.state.equals('reviewed')),
1248
+ );
1249
+ return classifyTranslationMemoryMatches({
1250
+ key,
1251
+ language,
1252
+ currentVersion,
1253
+ rows: rows as any,
1254
+ });
1255
+ }
1256
+
1257
+ /** App-scoped reviewed-unit index for search, audit, and exact reuse. */
1258
+ export async function listTranslationMemory(data: {
1259
+ appId: string;
1260
+ }): Promise<TranslationMemoryRecord[]> {
1261
+ const appId = requireText(data.appId, 'appId');
1262
+ const rows = await TranslationUnit.select((unit) => [
1263
+ unit.language,
1264
+ unit.text,
1265
+ unit.state,
1266
+ unit.updatedAt,
1267
+ unit.ofKey.select((candidate) => [candidate.key]),
1268
+ unit.ofKeyVersion.select((version) => [
1269
+ version.sourceText,
1270
+ version.sourceHash,
1271
+ version.contractHash,
1272
+ ]),
1273
+ ]).where((unit) =>
1274
+ unit.ofApplication
1275
+ .equals({ id: appId } as any)
1276
+ .and(unit.state.equals('reviewed')),
1277
+ );
1278
+ return toTranslationMemoryRecords(rows as any);
1279
+ }
1280
+
1281
+ /** Preview or apply every safe exact-match TM candidate for one language. */
1282
+ export async function pretranslateFromMemory(
1283
+ data: {
1284
+ appId: string;
1285
+ language: string;
1286
+ dryRun?: boolean;
1287
+ },
1288
+ authorship: { author?: string } = {},
1289
+ ): Promise<TranslationMemoryPretranslateReport> {
1290
+ const appId = requireText(data.appId, 'appId');
1291
+ const language = requireText(data.language, 'language');
1292
+ const [entries, memory] = await Promise.all([
1293
+ listTranslationEntries({ appId }),
1294
+ listTranslationMemory({ appId }),
1295
+ ]);
1296
+ const candidates = findExactMemoryPretranslations({
1297
+ entries,
1298
+ memory,
1299
+ language,
1300
+ });
1301
+ if (!data.dryRun) {
1302
+ for (const candidate of candidates) {
1303
+ await upsertTranslationUnit(
1304
+ {
1305
+ appId,
1306
+ key: candidate.key,
1307
+ language,
1308
+ text: candidate.text,
1309
+ state: 'machine',
1310
+ },
1311
+ {
1312
+ author: authorship.author,
1313
+ authorKind: 'machine',
1314
+ mtProvider: 'tm',
1315
+ note: `Exact translation-memory match from ${candidate.sourceKey}.`,
1316
+ },
1317
+ );
1318
+ }
1319
+ }
1320
+ return {
1321
+ language,
1322
+ eligible: candidates.length,
1323
+ translated: data.dryRun ? 0 : candidates.length,
1324
+ candidates,
1325
+ };
1326
+ }
1327
+
1328
+ /** App-data termbase read shared by Studio, MT, and publish quality gates. */
1329
+ export async function listGlossaryTerms(data: {
1330
+ appId: string;
1331
+ }): Promise<GlossaryTermRecord[]> {
1332
+ requireText(data.appId, 'appId');
1333
+ const rows = await GlossaryTerm.select((glossary) => [
1334
+ glossary.term,
1335
+ glossary.translation,
1336
+ glossary.language,
1337
+ glossary.description,
1338
+ glossary.termType,
1339
+ glossary.useInstead,
1340
+ glossary.caseSensitive,
1341
+ ]).catch(() => []);
1342
+ return (rows ?? [])
1343
+ .filter((row: any) => row?.term)
1344
+ .map((row: any) => ({
1345
+ id: typeof row.id === 'string' ? row.id : (row.id?.id ?? ''),
1346
+ term: row.term,
1347
+ termType:
1348
+ row.termType === 'keep' || row.termType === 'forbid' || row.termType === 'require'
1349
+ ? row.termType
1350
+ : 'prefer',
1351
+ translation: row.translation || undefined,
1352
+ useInstead: row.useInstead || undefined,
1353
+ language: row.language || undefined,
1354
+ caseSensitive:
1355
+ row.caseSensitive === true || row.caseSensitive === 'true'
1356
+ ? true
1357
+ : undefined,
1358
+ description: row.description || undefined,
1359
+ }))
1360
+ .sort(
1361
+ (left: GlossaryTermRecord, right: GlossaryTermRecord) =>
1362
+ left.term.localeCompare(right.term) ||
1363
+ (left.language ?? '').localeCompare(right.language ?? ''),
1364
+ );
1365
+ }
1366
+
1367
+ /**
1368
+ * Apply a server-validated memory candidate. Exact matches retain `machine`
1369
+ * state and `tm` provenance; changed-source carry-forward is an explicit
1370
+ * reviewer action and becomes reviewed.
1371
+ */
1372
+ export async function applyTranslationMemoryMatch(
1373
+ data: {
1374
+ appId: string;
1375
+ key: string;
1376
+ language: string;
1377
+ unitId: string;
1378
+ },
1379
+ authorship: { author?: string } = {},
1380
+ ): Promise<TranslationUnitRecord> {
1381
+ const unitId = requireText(data.unitId, 'unitId');
1382
+ const match = (
1383
+ await listTranslationMemoryMatches({
1384
+ appId: data.appId,
1385
+ key: data.key,
1386
+ language: data.language,
1387
+ })
1388
+ ).find((candidate) => candidate.unitId === unitId);
1389
+ if (!match) {
1390
+ throw new Error(
1391
+ 'Translation-memory candidate is no longer compatible with this key version.',
1392
+ );
1393
+ }
1394
+ const state: TranslationState =
1395
+ match.kind === 'exact' ? 'machine' : 'reviewed';
1396
+ await upsertTranslationUnit(
1397
+ {
1398
+ appId: data.appId,
1399
+ key: data.key,
1400
+ language: data.language,
1401
+ text: match.text,
1402
+ state,
1403
+ },
1404
+ {
1405
+ author: authorship.author,
1406
+ authorKind: match.kind === 'exact' ? 'machine' : 'human',
1407
+ mtProvider: match.kind === 'exact' ? 'tm' : undefined,
1408
+ note:
1409
+ match.kind === 'exact'
1410
+ ? `Exact translation-memory match from ${match.key}.`
1411
+ : `Reviewed carry-forward from ${match.keyVersionId}.`,
1412
+ },
1413
+ );
1414
+ return {
1415
+ language: data.language,
1416
+ text: match.text,
1417
+ state,
1418
+ origin: match.kind === 'exact' ? 'tm' : undefined,
1419
+ updatedAt: new Date().toISOString(),
1420
+ };
1421
+ }
1422
+
1423
+ /** Server boundary for app-scoped translation keys and language units. */
1424
+ export class TranslationProvider extends ShapeProvider {
1425
+ public shape = TranslationKey;
1426
+
1427
+ /**
1428
+ * Authoring gate. Reads `linkedAuth` off the request the LINKED server already
1429
+ * populates — framework-level, so this stays portable (no CN/create-now-js
1430
+ * import, AD-G). Anonymous callers can no longer read or write an app's
1431
+ * translation content via `/call`. Fine-grained "is translation enabled for
1432
+ * this app + is the caller a project member" gating is a Create Now concern and
1433
+ * is enforced additionally at the CN route layer (`isTranslationEnabledForApp`).
1434
+ * `getMessages` intentionally stays public — it is the app's runtime string
1435
+ * fetch, not an authoring surface.
1436
+ */
1437
+ private requireAuthor(): void {
1438
+ const account = (this as any).request?.linkedAuth?.userAccount;
1439
+ if (!account) {
1440
+ throw new Error('Authentication required to read or edit translations.');
1441
+ }
1442
+ }
1443
+
1444
+ private async requireAccess(
1445
+ data: { appId: string; language?: string },
1446
+ action: TranslationAuthoringAction,
1447
+ ): Promise<void> {
1448
+ this.requireAuthor();
1449
+ const actorWebId = this.authorIri();
1450
+ if (
1451
+ !actorWebId ||
1452
+ !(await canAuthorTranslation({
1453
+ appId: data.appId,
1454
+ actorWebId,
1455
+ action,
1456
+ language: data.language,
1457
+ }))
1458
+ ) {
1459
+ throw new Error(`Translation ${action} permission required.`);
1460
+ }
1461
+ }
1462
+
1463
+ async listKeys(data: { appId: string }) {
1464
+ this.requireAuthor();
1465
+ return listTranslationKeys(data);
1466
+ }
1467
+
1468
+ async listEntries(data: { appId: string }): Promise<TranslationEntryRecord[]> {
1469
+ this.requireAuthor();
1470
+ return listTranslationEntries(data);
1471
+ }
1472
+
1473
+ async listKeyVersions(data: {
1474
+ appId: string;
1475
+ key?: string;
1476
+ }): Promise<TranslationKeyVersionRecord[]> {
1477
+ this.requireAuthor();
1478
+ return listTranslationKeyVersions(data);
1479
+ }
1480
+
1481
+ async listMemoryMatches(data: {
1482
+ appId: string;
1483
+ key: string;
1484
+ language: string;
1485
+ }): Promise<TranslationMemoryMatchRecord[]> {
1486
+ this.requireAuthor();
1487
+ return listTranslationMemoryMatches(data);
1488
+ }
1489
+
1490
+ async proposeRevision(data: {
1491
+ appId: string;
1492
+ key: string;
1493
+ language: string;
1494
+ text: string;
1495
+ basedOnText?: string;
1496
+ note?: string;
1497
+ }): Promise<{ id: string }> {
1498
+ await this.requireAccess(data, 'propose');
1499
+ const currentEntries = await listTranslationEntries({ appId: data.appId });
1500
+ const basedOnText =
1501
+ currentEntries.find(({ key }) => key === data.key)?.units[data.language]
1502
+ ?.text ?? '';
1503
+ return createTranslationRevision({
1504
+ ...data,
1505
+ basedOnText,
1506
+ status: 'proposed',
1507
+ author: this.authorIri(),
1508
+ authorKind: 'human',
1509
+ });
1510
+ }
1511
+
1512
+ async listProposals(data: {
1513
+ appId: string;
1514
+ language?: string;
1515
+ }): Promise<TranslationRevisionRecord[]> {
1516
+ this.requireAuthor();
1517
+ const actorWebId = this.authorIri();
1518
+ if (!actorWebId) throw new Error('Translation read permission required.');
1519
+ const proposals = await listTranslationProposals(data);
1520
+ const allowed = await Promise.all(
1521
+ proposals.map((proposal) =>
1522
+ canAuthorTranslation({
1523
+ appId: data.appId,
1524
+ actorWebId,
1525
+ action: 'read',
1526
+ language: proposal.language,
1527
+ }),
1528
+ ),
1529
+ );
1530
+ return proposals.filter((_, index) => allowed[index]);
1531
+ }
1532
+
1533
+ async decideProposal(data: {
1534
+ appId: string;
1535
+ revisionId: string;
1536
+ decision: 'accept' | 'reject';
1537
+ note?: string;
1538
+ }): Promise<TranslationProposalDecision> {
1539
+ const proposal = await getTranslationRevision({
1540
+ appId: data.appId,
1541
+ revisionId: data.revisionId,
1542
+ });
1543
+ if (!proposal) {
1544
+ throw new Error('This translation proposal is no longer pending.');
1545
+ }
1546
+ await this.requireAccess(
1547
+ { appId: data.appId, language: proposal.language },
1548
+ 'review',
1549
+ );
1550
+ return decideTranslationProposal(data, this.authorIri());
1551
+ }
1552
+
1553
+ async listMemory(data: {
1554
+ appId: string;
1555
+ }): Promise<TranslationMemoryRecord[]> {
1556
+ this.requireAuthor();
1557
+ return listTranslationMemory(data);
1558
+ }
1559
+
1560
+ async pretranslateMemory(data: {
1561
+ appId: string;
1562
+ language: string;
1563
+ dryRun?: boolean;
1564
+ }): Promise<TranslationMemoryPretranslateReport> {
1565
+ await this.requireAccess(data, 'run-mt');
1566
+ return pretranslateFromMemory(data, { author: this.authorIri() });
1567
+ }
1568
+
1569
+ async applyMemoryMatch(data: {
1570
+ appId: string;
1571
+ key: string;
1572
+ language: string;
1573
+ unitId: string;
1574
+ }): Promise<TranslationUnitRecord> {
1575
+ await this.requireAccess(data, 'review');
1576
+ return applyTranslationMemoryMatch(data, { author: this.authorIri() });
1577
+ }
1578
+
1579
+ async listReleases(data: {
1580
+ appId: string;
1581
+ }): Promise<TranslationReleaseRecord[]> {
1582
+ this.requireAuthor();
1583
+ return listTranslationReleases(data);
1584
+ }
1585
+
1586
+ async createRelease(
1587
+ data: Omit<TranslationReleaseRecord, 'id' | 'createdAt'> & {
1588
+ createdAt?: string;
1589
+ },
1590
+ ): Promise<TranslationReleaseRecord> {
1591
+ this.requireAuthor();
1592
+ return createTranslationRelease(data);
1593
+ }
1594
+
1595
+ async advanceReleaseHotfix(data: {
1596
+ id: string;
1597
+ expectedSequence: number;
1598
+ hotfixSequence: number;
1599
+ manifestHash: string;
1600
+ }): Promise<TranslationReleaseRecord> {
1601
+ this.requireAuthor();
1602
+ return advanceTranslationReleaseHotfix(data);
1603
+ }
1604
+
1605
+ async upsertKey(data: TranslationKeyInput): Promise<{ id: string }> {
1606
+ this.requireAuthor();
1607
+ return upsertTranslationKey(data, { createdBy: this.authorIri() });
1608
+ }
1609
+
1610
+ /** WebID of the signed-in caller — never trust a client-supplied author. */
1611
+ private authorIri(): string | undefined {
1612
+ const auth = (this as any).request?.linkedAuth;
1613
+ const webId =
1614
+ auth?.userAccount?.accountOf?.id ??
1615
+ auth?.webId ??
1616
+ auth?.userAccount?.id ??
1617
+ auth?.userAccount;
1618
+ return typeof webId === 'string' && webId ? webId : undefined;
1619
+ }
1620
+
1621
+ async upsertUnit(data: TranslationUnitInput): Promise<{ id: string }> {
1622
+ await this.requireAccess(data, 'review');
1623
+ return upsertTranslationUnit(
1624
+ { ...data, updatedBy: undefined },
1625
+ { author: this.authorIri(), authorKind: 'human' },
1626
+ );
1627
+ }
1628
+
1629
+ /**
1630
+ * Glossary CRUD (Plan 017 Slice B). App-data is branch-routed per app, so
1631
+ * every GlossaryTerm in context belongs to the calling app; the appId only
1632
+ * anchors the IRIs. Uniqueness = (term, language) — upsert is
1633
+ * delete-then-create on that identity.
1634
+ */
1635
+ async listGlossary(data: { appId: string }): Promise<GlossaryTermRecord[]> {
1636
+ this.requireAuthor();
1637
+ return listGlossaryTerms(data);
1638
+ }
1639
+
1640
+ async upsertGlossaryTerm(data: {
1641
+ appId: string;
1642
+ term: string;
1643
+ termType?: 'prefer' | 'require' | 'keep' | 'forbid';
1644
+ translation?: string;
1645
+ useInstead?: string;
1646
+ language?: string;
1647
+ caseSensitive?: boolean;
1648
+ description?: string;
1649
+ }): Promise<{ id: string }> {
1650
+ await this.requireAccess(data, 'manage');
1651
+ const appId = requireText(data.appId, 'appId');
1652
+ const term = requireText(data.term, 'term');
1653
+ const language = data.language?.trim() || '';
1654
+ const termType =
1655
+ data.termType === 'keep' || data.termType === 'forbid' || data.termType === 'require'
1656
+ ? data.termType
1657
+ : 'prefer';
1658
+ if (termType === 'require' && !data.translation?.trim()) {
1659
+ throw new Error('A required glossary term needs a translation.');
1660
+ }
1661
+ const id = `${appId.replace(/\/$/, '')}/translation/glossary/${encodeURIComponent(
1662
+ term,
1663
+ )}--${encodeURIComponent(language || 'all')}`;
1664
+ // Delete-then-create: INSERT DATA is add-only, re-saving would duplicate.
1665
+ await (GlossaryTerm as any).delete({ id }).catch(() => {});
1666
+ await GlossaryTerm.create({
1667
+ __id: id,
1668
+ term,
1669
+ termType,
1670
+ translation:
1671
+ termType === 'prefer' || termType === 'require' ? data.translation?.trim() || undefined : undefined,
1672
+ useInstead:
1673
+ termType === 'forbid' ? data.useInstead?.trim() || undefined : undefined,
1674
+ language: language || undefined,
1675
+ caseSensitive: data.caseSensitive === true ? true : undefined,
1676
+ description: data.description?.trim() || undefined,
1677
+ } as any);
1678
+ return { id };
1679
+ }
1680
+
1681
+ async deleteGlossaryTerm(data: { appId: string; id: string }): Promise<{ deleted: boolean }> {
1682
+ await this.requireAccess(data, 'manage');
1683
+ const appId = requireText(data.appId, 'appId');
1684
+ const id = requireText(data.id, 'id');
1685
+ // Only glossary nodes under this app's namespace may be deleted here.
1686
+ if (!id.startsWith(`${appId.replace(/\/$/, '')}/translation/glossary/`)) {
1687
+ throw new Error('Not a glossary entry of this app.');
1688
+ }
1689
+ await (GlossaryTerm as any).delete({ id });
1690
+ return { deleted: true };
1691
+ }
1692
+
1693
+ /** History for one (key, language) cell — newest first (Plan 017 AD-P). */
1694
+ async listRevisions(data: {
1695
+ appId: string;
1696
+ key: string;
1697
+ language: string;
1698
+ }): Promise<TranslationRevisionRecord[]> {
1699
+ this.requireAuthor();
1700
+ const appId = requireText(data.appId, 'appId');
1701
+ const key = requireText(data.key, 'key');
1702
+ const language = requireText(data.language, 'language');
1703
+ const keyId = keyIri(appId, key);
1704
+ const rows = await TranslationRevision.select((r) => [
1705
+ r.language,
1706
+ r.text,
1707
+ r.status,
1708
+ r.author,
1709
+ r.authorKind,
1710
+ r.mtProvider,
1711
+ r.basedOnText,
1712
+ r.note,
1713
+ r.createdAt,
1714
+ r.decidedAt,
1715
+ r.decidedBy,
1716
+ r.ofKeyVersion,
1717
+ r.ofKey.select((k) => [k.key]),
1718
+ ]).where((r) =>
1719
+ r.language.equals(language).and(r.ofKey.equals({ id: keyId } as any)),
1720
+ );
1721
+ return toRevisionRecords(rows as any);
1722
+ }
1723
+
1724
+ async getMessages(data: {
1725
+ appId: string;
1726
+ language: string;
1727
+ }): Promise<TranslationMessages> {
1728
+ const appId = requireText(data.appId, 'appId');
1729
+ const language = requireText(data.language, 'language');
1730
+ const rows = await TranslationUnit.select((u) => [
1731
+ u.text,
1732
+ u.ofKeyVersion,
1733
+ u.ofKey.select((k) => [k.key, k.format, k.currentVersion]),
1734
+ ]).where((u) =>
1735
+ u.language
1736
+ .equals(language)
1737
+ .and(u.ofKey.ofApplication.equals({ id: appId } as any)),
1738
+ );
1739
+ return Object.fromEntries(
1740
+ (rows ?? [])
1741
+ .filter((row: any) => {
1742
+ const currentVersion = iri(row.ofKey?.currentVersion);
1743
+ const unitVersion = iri(row.ofKeyVersion);
1744
+ return !currentVersion || currentVersion === unitVersion;
1745
+ })
1746
+ .map((row: any) => [
1747
+ row.ofKey?.key,
1748
+ row.ofKey?.format === 'icu'
1749
+ ? { message: row.text, format: 'icu' as const }
1750
+ : row.text,
1751
+ ] as const)
1752
+ .filter(([key]) => typeof key === 'string' && key.length > 0),
1753
+ );
1754
+ }
1755
+ }