@_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.
- package/README.md +102 -0
- package/lib/cjs/data/translation.json +8 -0
- package/lib/esm/archive.d.ts +133 -0
- package/lib/esm/archive.js +567 -0
- package/lib/esm/archive.js.map +1 -0
- package/lib/esm/authorization.d.ts +12 -0
- package/lib/esm/authorization.js +10 -0
- package/lib/esm/authorization.js.map +1 -0
- package/lib/esm/backend.d.ts +1 -0
- package/lib/esm/backend.js +3 -0
- package/lib/esm/backend.js.map +1 -0
- package/lib/esm/cdn-loader.d.ts +40 -0
- package/lib/esm/cdn-loader.js +200 -0
- package/lib/esm/cdn-loader.js.map +1 -0
- package/lib/esm/checks.d.ts +56 -0
- package/lib/esm/checks.js +288 -0
- package/lib/esm/checks.js.map +1 -0
- package/lib/esm/compile.d.ts +64 -0
- package/lib/esm/compile.js +99 -0
- package/lib/esm/compile.js.map +1 -0
- package/lib/esm/core/messages.d.ts +25 -0
- package/lib/esm/core/messages.js +76 -0
- package/lib/esm/core/messages.js.map +1 -0
- package/lib/esm/data/translation.json +8 -0
- package/lib/esm/discovery.d.ts +97 -0
- package/lib/esm/discovery.js +365 -0
- package/lib/esm/discovery.js.map +1 -0
- package/lib/esm/exchange.d.ts +100 -0
- package/lib/esm/exchange.js +223 -0
- package/lib/esm/exchange.js.map +1 -0
- package/lib/esm/export.d.ts +28 -0
- package/lib/esm/export.js +92 -0
- package/lib/esm/export.js.map +1 -0
- package/lib/esm/formats/csv.d.ts +8 -0
- package/lib/esm/formats/csv.js +397 -0
- package/lib/esm/formats/csv.js.map +1 -0
- package/lib/esm/formats/json.d.ts +30 -0
- package/lib/esm/formats/json.js +459 -0
- package/lib/esm/formats/json.js.map +1 -0
- package/lib/esm/formats/xliff.d.ts +12 -0
- package/lib/esm/formats/xliff.js +577 -0
- package/lib/esm/formats/xliff.js.map +1 -0
- package/lib/esm/import.d.ts +197 -0
- package/lib/esm/import.js +391 -0
- package/lib/esm/import.js.map +1 -0
- package/lib/esm/index.d.ts +42 -0
- package/lib/esm/index.js +34 -0
- package/lib/esm/index.js.map +1 -0
- package/lib/esm/key-sync-node.d.ts +29 -0
- package/lib/esm/key-sync-node.js +174 -0
- package/lib/esm/key-sync-node.js.map +1 -0
- package/lib/esm/key-sync.d.ts +46 -0
- package/lib/esm/key-sync.js +114 -0
- package/lib/esm/key-sync.js.map +1 -0
- package/lib/esm/key-version.d.ts +50 -0
- package/lib/esm/key-version.js +134 -0
- package/lib/esm/key-version.js.map +1 -0
- package/lib/esm/languages.d.ts +18 -0
- package/lib/esm/languages.js +74 -0
- package/lib/esm/languages.js.map +1 -0
- package/lib/esm/linked-discovery.d.ts +44 -0
- package/lib/esm/linked-discovery.js +267 -0
- package/lib/esm/linked-discovery.js.map +1 -0
- package/lib/esm/message-format.d.ts +41 -0
- package/lib/esm/message-format.js +133 -0
- package/lib/esm/message-format.js.map +1 -0
- package/lib/esm/mt.d.ts +111 -0
- package/lib/esm/mt.js +142 -0
- package/lib/esm/mt.js.map +1 -0
- package/lib/esm/ontologies/translation.d.ts +203 -0
- package/lib/esm/ontologies/translation.js +219 -0
- package/lib/esm/ontologies/translation.js.map +1 -0
- package/lib/esm/package.d.ts +4 -0
- package/lib/esm/package.js +11 -0
- package/lib/esm/package.js.map +1 -0
- package/lib/esm/react.d.ts +55 -0
- package/lib/esm/react.js +127 -0
- package/lib/esm/react.js.map +1 -0
- package/lib/esm/records.d.ts +151 -0
- package/lib/esm/records.js +2 -0
- package/lib/esm/records.js.map +1 -0
- package/lib/esm/release.d.ts +198 -0
- package/lib/esm/release.js +435 -0
- package/lib/esm/release.js.map +1 -0
- package/lib/esm/shape-catalog.d.ts +79 -0
- package/lib/esm/shape-catalog.js +118 -0
- package/lib/esm/shape-catalog.js.map +1 -0
- package/lib/esm/shapes/GlossaryTerm.d.ts +28 -0
- package/lib/esm/shapes/GlossaryTerm.js +94 -0
- package/lib/esm/shapes/GlossaryTerm.js.map +1 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.d.ts +33 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js +209 -0
- package/lib/esm/shapes/TranslationInventoryOrigin.js.map +1 -0
- package/lib/esm/shapes/TranslationKey.d.ts +92 -0
- package/lib/esm/shapes/TranslationKey.js +194 -0
- package/lib/esm/shapes/TranslationKey.js.map +1 -0
- package/lib/esm/shapes/TranslationKeyVersion.d.ts +19 -0
- package/lib/esm/shapes/TranslationKeyVersion.js +125 -0
- package/lib/esm/shapes/TranslationKeyVersion.js.map +1 -0
- package/lib/esm/shapes/TranslationLanguage.d.ts +15 -0
- package/lib/esm/shapes/TranslationLanguage.js +102 -0
- package/lib/esm/shapes/TranslationLanguage.js.map +1 -0
- package/lib/esm/shapes/TranslationProvider.d.ts +409 -0
- package/lib/esm/shapes/TranslationProvider.js +1376 -0
- package/lib/esm/shapes/TranslationProvider.js.map +1 -0
- package/lib/esm/shapes/TranslationRelease.d.ts +36 -0
- package/lib/esm/shapes/TranslationRelease.js +149 -0
- package/lib/esm/shapes/TranslationRelease.js.map +1 -0
- package/lib/esm/shapes/TranslationRevision.d.ts +39 -0
- package/lib/esm/shapes/TranslationRevision.js +159 -0
- package/lib/esm/shapes/TranslationRevision.js.map +1 -0
- package/lib/esm/shapes/TranslationUnit.d.ts +26 -0
- package/lib/esm/shapes/TranslationUnit.js +111 -0
- package/lib/esm/shapes/TranslationUnit.js.map +1 -0
- package/package.json +134 -0
- package/src/archive.ts +894 -0
- package/src/authorization.ts +33 -0
- package/src/backend.ts +2 -0
- package/src/cdn-loader.ts +264 -0
- package/src/checks.ts +413 -0
- package/src/compile.ts +168 -0
- package/src/core/messages.ts +106 -0
- package/src/data/translation.json +8 -0
- package/src/discovery.ts +594 -0
- package/src/exchange.ts +428 -0
- package/src/export.ts +135 -0
- package/src/formats/csv.ts +459 -0
- package/src/formats/json.ts +587 -0
- package/src/formats/xliff.ts +708 -0
- package/src/import.ts +648 -0
- package/src/index.ts +207 -0
- package/src/key-sync-node.ts +269 -0
- package/src/key-sync.ts +168 -0
- package/src/key-version.ts +206 -0
- package/src/languages.ts +102 -0
- package/src/linked-discovery.ts +385 -0
- package/src/message-format.ts +214 -0
- package/src/mt.ts +248 -0
- package/src/ontologies/translation.ts +234 -0
- package/src/package.ts +19 -0
- package/src/react.tsx +250 -0
- package/src/records.ts +180 -0
- package/src/release.ts +726 -0
- package/src/shape-catalog.ts +198 -0
- package/src/shapes/GlossaryTerm.ts +61 -0
- package/src/shapes/TranslationInventoryOrigin.ts +131 -0
- package/src/shapes/TranslationKey.ts +174 -0
- package/src/shapes/TranslationKeyVersion.ts +77 -0
- package/src/shapes/TranslationLanguage.ts +51 -0
- package/src/shapes/TranslationProvider.ts +1755 -0
- package/src/shapes/TranslationRelease.ts +114 -0
- package/src/shapes/TranslationRevision.ts +105 -0
- 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
|
+
}
|