@_linked/translation 0.9.3 → 0.9.5

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