@_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
package/src/import.ts
ADDED
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Translation import framework (Plan 014 AD-M / Plan 016 P2.1) — the minimal,
|
|
3
|
+
* pluggable core that migrates existing translations INTO the graph. A
|
|
4
|
+
* `TranslationImportSource` yields `(language, key, text)` triples; the shared
|
|
5
|
+
* `runImport` pipeline maps them onto existing `TranslationKey`s and writes
|
|
6
|
+
* `TranslationUnit`s via an injected target — no I/O of its own, so it is pure
|
|
7
|
+
* and unit-testable, and P3 can add XLIFF/CSV/JSON sources behind the same
|
|
8
|
+
* `TranslationImportSource` interface. The Tolgee CDN adapter is v1 (Plan 016
|
|
9
|
+
* D3, Option A: pull the public per-language files, no auth).
|
|
10
|
+
*
|
|
11
|
+
* Design mirrors `key-sync.ts`: transport is injected, the sync is a pure diff.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
normalizeParsedTranslationExchangeDocument,
|
|
15
|
+
type ParsedTranslationExchangeDocument,
|
|
16
|
+
type ParsedTranslationExchangeEntry,
|
|
17
|
+
type TranslationImportDisposition,
|
|
18
|
+
} from './exchange.js';
|
|
19
|
+
import { canonicalLanguageTag, sha256Hex } from './key-version.js';
|
|
20
|
+
import { canonicalJson } from './release.js';
|
|
21
|
+
import type {
|
|
22
|
+
TranslationEntryRecord,
|
|
23
|
+
TranslationState,
|
|
24
|
+
TranslationUnitRecord,
|
|
25
|
+
} from './records.js';
|
|
26
|
+
|
|
27
|
+
/** How an incoming unit resolves against an existing app unit. */
|
|
28
|
+
export type ImportMergePolicy = 'skip-existing' | 'overwrite' | 'import-as-stale';
|
|
29
|
+
|
|
30
|
+
/** One incoming translation from a source, pre-mapping. */
|
|
31
|
+
export interface ParsedUnit {
|
|
32
|
+
language: string;
|
|
33
|
+
key: string;
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A migration source (Tolgee CDN now; XLIFF/CSV/JSON later — AD-M). */
|
|
38
|
+
export interface TranslationImportSource {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
/** Fetch + parse every `(language, key, text)` the source carries. */
|
|
41
|
+
load(): Promise<ParsedUnit[]>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ImportOptions {
|
|
45
|
+
/** Default `skip-existing` — never clobber an app-authored unit (Plan 016 D4). */
|
|
46
|
+
mergePolicy?: ImportMergePolicy;
|
|
47
|
+
/** The source/canonical language, NOT imported as units (it is `sourceText`). Default `en`. */
|
|
48
|
+
sourceLanguage?: string;
|
|
49
|
+
/** State written for imported units. Default `reviewed` (Plan 016 D3, Option A). */
|
|
50
|
+
importState?: TranslationState;
|
|
51
|
+
/** Report only; write nothing. */
|
|
52
|
+
dryRun?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Injected persistence — the pipeline stays free of any store/RPC (AD-G). */
|
|
56
|
+
export interface ImportTarget {
|
|
57
|
+
/** Existing keys (with source text + format) this app owns. */
|
|
58
|
+
listKeys(): Promise<Array<{ key: string; sourceText: string; format?: string }>>;
|
|
59
|
+
/** Existing `(key, language)` units, for the merge policy. */
|
|
60
|
+
listUnits(): Promise<Array<{ key: string; language: string }>>;
|
|
61
|
+
/** Create/replace one unit. Not called on a dry run. */
|
|
62
|
+
upsertUnit(data: {
|
|
63
|
+
key: string;
|
|
64
|
+
language: string;
|
|
65
|
+
text: string;
|
|
66
|
+
state: TranslationState;
|
|
67
|
+
}): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ImportReport {
|
|
71
|
+
source: string;
|
|
72
|
+
dryRun: boolean;
|
|
73
|
+
languages: string[];
|
|
74
|
+
/** Units created (new). */
|
|
75
|
+
created: number;
|
|
76
|
+
/** Units overwritten (merge policy `overwrite`/`import-as-stale`). */
|
|
77
|
+
updated: number;
|
|
78
|
+
/** Units left alone under `skip-existing`. */
|
|
79
|
+
skippedExisting: number;
|
|
80
|
+
/** Source keys with no matching `TranslationKey` (reported, never created). */
|
|
81
|
+
orphanKeys: string[];
|
|
82
|
+
/** App keys the source has no translation for in any language (→ English fallback). */
|
|
83
|
+
uncoveredKeys: string[];
|
|
84
|
+
/** Source-language values that differ from the app's `sourceText` (drift). */
|
|
85
|
+
sourceMismatches: Array<{ key: string; appSource: string; importSource: string }>;
|
|
86
|
+
/** Simple-format keys whose imported text contains ICU plural/select syntax (AD-J/R8). */
|
|
87
|
+
icuFlags: string[];
|
|
88
|
+
perLanguage: Record<
|
|
89
|
+
string,
|
|
90
|
+
{ created: number; updated: number; skippedExisting: number }
|
|
91
|
+
>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Detect ICU plural/select syntax that a `format:'simple'` key would render literally. */
|
|
95
|
+
export function hasIcuSyntax(text: string): boolean {
|
|
96
|
+
return /\{[^{}]*,\s*(plural|select|selectordinal)\s*,/.test(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Run a source through the shared pipeline: map → merge policy → state → lint →
|
|
101
|
+
* report (and commit unless `dryRun`). Source-language values are compared to
|
|
102
|
+
* `sourceText` (drift report) but never written as units — English is the
|
|
103
|
+
* canonical inline default (AD-J/R7).
|
|
104
|
+
*/
|
|
105
|
+
export async function runImport(
|
|
106
|
+
target: ImportTarget,
|
|
107
|
+
source: TranslationImportSource,
|
|
108
|
+
options: ImportOptions = {},
|
|
109
|
+
): Promise<ImportReport> {
|
|
110
|
+
const mergePolicy = options.mergePolicy ?? 'skip-existing';
|
|
111
|
+
const sourceLanguage = options.sourceLanguage ?? 'en';
|
|
112
|
+
const importState = options.importState ?? 'reviewed';
|
|
113
|
+
const dryRun = options.dryRun ?? false;
|
|
114
|
+
|
|
115
|
+
const [parsed, keyRows, unitRows] = await Promise.all([
|
|
116
|
+
source.load(),
|
|
117
|
+
target.listKeys(),
|
|
118
|
+
target.listUnits(),
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
const sourceText = new Map(keyRows.map((k) => [k.key, k.sourceText]));
|
|
122
|
+
const formatOf = new Map(keyRows.map((k) => [k.key, k.format ?? 'simple']));
|
|
123
|
+
const existingUnit = new Set(unitRows.map((u) => `${u.key}\u0000${u.language}`));
|
|
124
|
+
|
|
125
|
+
const report: ImportReport = {
|
|
126
|
+
source: source.name,
|
|
127
|
+
dryRun,
|
|
128
|
+
languages: [],
|
|
129
|
+
created: 0,
|
|
130
|
+
updated: 0,
|
|
131
|
+
skippedExisting: 0,
|
|
132
|
+
orphanKeys: [],
|
|
133
|
+
uncoveredKeys: [],
|
|
134
|
+
sourceMismatches: [],
|
|
135
|
+
icuFlags: [],
|
|
136
|
+
perLanguage: {},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const languages = new Set<string>();
|
|
140
|
+
const orphans = new Set<string>();
|
|
141
|
+
const icuFlags = new Set<string>();
|
|
142
|
+
const coveredAppKeys = new Set<string>();
|
|
143
|
+
const per = (lang: string) =>
|
|
144
|
+
(report.perLanguage[lang] ??= { created: 0, updated: 0, skippedExisting: 0 });
|
|
145
|
+
|
|
146
|
+
for (const unit of parsed) {
|
|
147
|
+
if (!unit?.key || !unit.language) continue;
|
|
148
|
+
languages.add(unit.language);
|
|
149
|
+
|
|
150
|
+
// Source language: drift-check against sourceText, but never import as units.
|
|
151
|
+
if (unit.language === sourceLanguage) {
|
|
152
|
+
if (sourceText.has(unit.key)) {
|
|
153
|
+
coveredAppKeys.add(unit.key);
|
|
154
|
+
const app = sourceText.get(unit.key)!;
|
|
155
|
+
if (app !== unit.text) {
|
|
156
|
+
report.sourceMismatches.push({
|
|
157
|
+
key: unit.key,
|
|
158
|
+
appSource: app,
|
|
159
|
+
importSource: unit.text,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!sourceText.has(unit.key)) {
|
|
167
|
+
orphans.add(unit.key);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
coveredAppKeys.add(unit.key);
|
|
171
|
+
if (formatOf.get(unit.key) === 'simple' && hasIcuSyntax(unit.text)) {
|
|
172
|
+
icuFlags.add(unit.key);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const exists = existingUnit.has(`${unit.key}\u0000${unit.language}`);
|
|
176
|
+
if (exists && mergePolicy === 'skip-existing') {
|
|
177
|
+
report.skippedExisting++;
|
|
178
|
+
per(unit.language).skippedExisting++;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const state: TranslationState =
|
|
182
|
+
mergePolicy === 'import-as-stale' ? 'stale' : importState;
|
|
183
|
+
if (!dryRun) {
|
|
184
|
+
await target.upsertUnit({
|
|
185
|
+
key: unit.key,
|
|
186
|
+
language: unit.language,
|
|
187
|
+
text: unit.text,
|
|
188
|
+
state,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
if (exists) {
|
|
192
|
+
report.updated++;
|
|
193
|
+
per(unit.language).updated++;
|
|
194
|
+
} else {
|
|
195
|
+
report.created++;
|
|
196
|
+
per(unit.language).created++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
report.languages = [...languages].sort();
|
|
201
|
+
report.orphanKeys = [...orphans].sort();
|
|
202
|
+
report.icuFlags = [...icuFlags].sort();
|
|
203
|
+
report.uncoveredKeys = keyRows
|
|
204
|
+
.map((k) => k.key)
|
|
205
|
+
.filter((k) => !coveredAppKeys.has(k))
|
|
206
|
+
.sort();
|
|
207
|
+
return report;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Flatten nested JSON (Tolgee's CDN shape) into dot-joined leaf keys. */
|
|
211
|
+
export function flattenMessages(
|
|
212
|
+
obj: unknown,
|
|
213
|
+
prefix = '',
|
|
214
|
+
out: Record<string, string> = {},
|
|
215
|
+
): Record<string, string> {
|
|
216
|
+
if (!obj || typeof obj !== 'object') return out;
|
|
217
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
218
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
219
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
220
|
+
flattenMessages(v, key, out);
|
|
221
|
+
} else if (typeof v === 'string') {
|
|
222
|
+
out[key] = v;
|
|
223
|
+
} else if (v != null) {
|
|
224
|
+
out[key] = String(v);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return out;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Tolgee CDN import source (Plan 016 D3, Option A). Pulls the public per-language
|
|
232
|
+
* JSON files (no auth) and flattens Tolgee's nested structure to dot-keys that
|
|
233
|
+
* match serve's `t('a.b.c')` keys. A language whose file is missing/unfetchable
|
|
234
|
+
* is skipped (it simply contributes no units). `fetchImpl` is injectable for tests.
|
|
235
|
+
*/
|
|
236
|
+
export function tolgeeCdnSource(config: {
|
|
237
|
+
baseUrl: string;
|
|
238
|
+
languages: string[];
|
|
239
|
+
fetchImpl?: typeof fetch;
|
|
240
|
+
}): TranslationImportSource {
|
|
241
|
+
const base = config.baseUrl.replace(/\/+$/, '');
|
|
242
|
+
const doFetch = config.fetchImpl ?? fetch;
|
|
243
|
+
return {
|
|
244
|
+
name: `tolgee-cdn(${base})`,
|
|
245
|
+
async load() {
|
|
246
|
+
const units: ParsedUnit[] = [];
|
|
247
|
+
for (const language of config.languages) {
|
|
248
|
+
let json: unknown;
|
|
249
|
+
try {
|
|
250
|
+
const res = await doFetch(`${base}/${language}.json`);
|
|
251
|
+
if (!res.ok) continue;
|
|
252
|
+
json = await res.json();
|
|
253
|
+
} catch {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
for (const [key, text] of Object.entries(flattenMessages(json))) {
|
|
257
|
+
units.push({ language, key, text });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return units;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Immutable, version-aware decision produced before any persistence is
|
|
267
|
+
* attempted. Hosts may render these directly in an import dry-run.
|
|
268
|
+
*/
|
|
269
|
+
export interface VersionedImportDecision {
|
|
270
|
+
readonly key: string;
|
|
271
|
+
readonly importedKey: string;
|
|
272
|
+
readonly language: string;
|
|
273
|
+
readonly text: string;
|
|
274
|
+
readonly state: TranslationState;
|
|
275
|
+
readonly disposition: TranslationImportDisposition;
|
|
276
|
+
readonly reason:
|
|
277
|
+
| 'exact-current-version'
|
|
278
|
+
| 'compatible-source-change'
|
|
279
|
+
| 'incompatible-contract'
|
|
280
|
+
| 'missing-version-evidence'
|
|
281
|
+
| 'manager-bound-unverified'
|
|
282
|
+
| 'orphan-key'
|
|
283
|
+
| 'source-language'
|
|
284
|
+
| 'existing-unit';
|
|
285
|
+
readonly expectedKeyVersionId?: string;
|
|
286
|
+
readonly expectedUnitContentHash?: string;
|
|
287
|
+
readonly basedOnText?: string;
|
|
288
|
+
readonly note?: string;
|
|
289
|
+
readonly importedContentHash: string;
|
|
290
|
+
readonly idempotencyKey: string;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface VersionedImportPlan {
|
|
294
|
+
readonly sessionId: string;
|
|
295
|
+
readonly documentHash: string;
|
|
296
|
+
readonly revisionWatermark: string;
|
|
297
|
+
readonly decisions: readonly VersionedImportDecision[];
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface VersionedImportPlanOptions {
|
|
301
|
+
sessionId: string;
|
|
302
|
+
revisionWatermark: string;
|
|
303
|
+
mergePolicy?: ImportMergePolicy;
|
|
304
|
+
importState?: TranslationState;
|
|
305
|
+
/**
|
|
306
|
+
* Explicit manager-approved foreign-key mapping. A mapped value remains a
|
|
307
|
+
* suggestion because the foreign file carries no trustworthy version proof.
|
|
308
|
+
*/
|
|
309
|
+
managerBindings?: Readonly<Record<string, string>>;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export interface VersionedImportCurrentState {
|
|
313
|
+
key: string;
|
|
314
|
+
language: string;
|
|
315
|
+
keyVersionId?: string;
|
|
316
|
+
unitContentHash?: string;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface VersionedImportTarget {
|
|
320
|
+
readCurrentState(
|
|
321
|
+
requested: ReadonlyArray<{ key: string; language: string }>,
|
|
322
|
+
): Promise<VersionedImportCurrentState[]>;
|
|
323
|
+
upsertUnit(data: {
|
|
324
|
+
key: string;
|
|
325
|
+
language: string;
|
|
326
|
+
text: string;
|
|
327
|
+
state: TranslationState;
|
|
328
|
+
keyVersionId: string;
|
|
329
|
+
source: 'import';
|
|
330
|
+
idempotencyKey: string;
|
|
331
|
+
}): Promise<void>;
|
|
332
|
+
createSuggestion(data: {
|
|
333
|
+
key: string;
|
|
334
|
+
language: string;
|
|
335
|
+
text: string;
|
|
336
|
+
keyVersionId: string;
|
|
337
|
+
basedOnText: string;
|
|
338
|
+
source: 'import' | 'carry-forward';
|
|
339
|
+
note?: string;
|
|
340
|
+
idempotencyKey: string;
|
|
341
|
+
}): Promise<void>;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface VersionedImportCommitReport {
|
|
345
|
+
status: 'committed' | 'requires-redry-run';
|
|
346
|
+
imported: number;
|
|
347
|
+
suggested: number;
|
|
348
|
+
skipped: number;
|
|
349
|
+
completedIdempotencyKeys: string[];
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Stable compare-and-set value for a current translation unit. */
|
|
353
|
+
export async function translationUnitContentHash(
|
|
354
|
+
unit: Pick<TranslationUnitRecord, 'keyVersionId' | 'language' | 'text' | 'state'>,
|
|
355
|
+
): Promise<string> {
|
|
356
|
+
return sha256Hex(
|
|
357
|
+
canonicalJson([
|
|
358
|
+
unit.keyVersionId ?? null,
|
|
359
|
+
canonicalLanguageTag(unit.language),
|
|
360
|
+
unit.text,
|
|
361
|
+
unit.state,
|
|
362
|
+
]),
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function entryIdentity(entry: Pick<TranslationEntryRecord, 'namespace' | 'key'>): string {
|
|
367
|
+
return `${entry.namespace ?? ''}\u0000${entry.key}`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function parsedIdentity(entry: Pick<ParsedTranslationExchangeEntry, 'namespace' | 'key'>): string {
|
|
371
|
+
return `${entry.namespace ?? ''}\u0000${entry.key}`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
async function makeVersionedDecision(input: {
|
|
375
|
+
importedEntry: ParsedTranslationExchangeEntry;
|
|
376
|
+
importedKey: string;
|
|
377
|
+
language: string;
|
|
378
|
+
text: string;
|
|
379
|
+
state: TranslationState;
|
|
380
|
+
note?: string;
|
|
381
|
+
current?: TranslationEntryRecord;
|
|
382
|
+
sessionId: string;
|
|
383
|
+
mergePolicy: ImportMergePolicy;
|
|
384
|
+
managerBound: boolean;
|
|
385
|
+
}): Promise<VersionedImportDecision> {
|
|
386
|
+
const {
|
|
387
|
+
importedEntry,
|
|
388
|
+
importedKey,
|
|
389
|
+
language,
|
|
390
|
+
text,
|
|
391
|
+
state,
|
|
392
|
+
note,
|
|
393
|
+
current,
|
|
394
|
+
sessionId,
|
|
395
|
+
mergePolicy,
|
|
396
|
+
managerBound,
|
|
397
|
+
} = input;
|
|
398
|
+
const version = current?.currentVersion;
|
|
399
|
+
const existing = current?.units[language];
|
|
400
|
+
const expectedUnitContentHash = existing
|
|
401
|
+
? await translationUnitContentHash(existing)
|
|
402
|
+
: undefined;
|
|
403
|
+
const importedContentHash = await sha256Hex(
|
|
404
|
+
canonicalJson([importedEntry.keyVersionId ?? null, language, text, state]),
|
|
405
|
+
);
|
|
406
|
+
const key = current?.key ?? importedKey;
|
|
407
|
+
const idempotencyKey = await sha256Hex(
|
|
408
|
+
canonicalJson([
|
|
409
|
+
sessionId,
|
|
410
|
+
version?.id ?? 'unverified',
|
|
411
|
+
language,
|
|
412
|
+
importedContentHash,
|
|
413
|
+
]),
|
|
414
|
+
);
|
|
415
|
+
const base = {
|
|
416
|
+
key,
|
|
417
|
+
importedKey,
|
|
418
|
+
language,
|
|
419
|
+
text,
|
|
420
|
+
state,
|
|
421
|
+
expectedKeyVersionId: version?.id,
|
|
422
|
+
expectedUnitContentHash,
|
|
423
|
+
note,
|
|
424
|
+
importedContentHash,
|
|
425
|
+
idempotencyKey,
|
|
426
|
+
} as const;
|
|
427
|
+
|
|
428
|
+
if (!current || !version) {
|
|
429
|
+
return { ...base, disposition: 'conflict', reason: 'orphan-key' };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const hasEvidence = Boolean(
|
|
433
|
+
importedEntry.keyVersionId &&
|
|
434
|
+
importedEntry.sourceHash &&
|
|
435
|
+
importedEntry.contractHash &&
|
|
436
|
+
importedEntry.argumentSignature !== undefined &&
|
|
437
|
+
importedEntry.sourceText !== undefined,
|
|
438
|
+
);
|
|
439
|
+
if (!hasEvidence) {
|
|
440
|
+
if (managerBound) {
|
|
441
|
+
return {
|
|
442
|
+
...base,
|
|
443
|
+
disposition: 'unverified-source',
|
|
444
|
+
reason: 'manager-bound-unverified',
|
|
445
|
+
basedOnText: version.sourceText,
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
return {
|
|
449
|
+
...base,
|
|
450
|
+
disposition: 'unverified-source',
|
|
451
|
+
reason: 'missing-version-evidence',
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (
|
|
456
|
+
importedEntry.contractHash !== version.contractHash ||
|
|
457
|
+
importedEntry.argumentSignature !== version.argumentSignature ||
|
|
458
|
+
importedEntry.format !== version.format
|
|
459
|
+
) {
|
|
460
|
+
return {
|
|
461
|
+
...base,
|
|
462
|
+
disposition: 'conflict',
|
|
463
|
+
reason: 'incompatible-contract',
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const exactVersion =
|
|
468
|
+
importedEntry.keyVersionId === version.id &&
|
|
469
|
+
importedEntry.sourceHash === version.sourceHash &&
|
|
470
|
+
importedEntry.sourceText === version.sourceText;
|
|
471
|
+
if (!exactVersion) {
|
|
472
|
+
return {
|
|
473
|
+
...base,
|
|
474
|
+
disposition: 'carry-forward',
|
|
475
|
+
reason: 'compatible-source-change',
|
|
476
|
+
basedOnText: version.sourceText,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (existing && mergePolicy === 'skip-existing') {
|
|
481
|
+
return { ...base, disposition: 'skip', reason: 'existing-unit' };
|
|
482
|
+
}
|
|
483
|
+
return { ...base, disposition: 'import', reason: 'exact-current-version' };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Normalize and classify the complete document before any import write. Merge
|
|
488
|
+
* policy is intentionally applied only after version compatibility succeeds.
|
|
489
|
+
*/
|
|
490
|
+
export async function planVersionedImport(
|
|
491
|
+
value: unknown,
|
|
492
|
+
currentEntries: readonly TranslationEntryRecord[],
|
|
493
|
+
options: VersionedImportPlanOptions,
|
|
494
|
+
): Promise<VersionedImportPlan> {
|
|
495
|
+
const document = normalizeParsedTranslationExchangeDocument(value);
|
|
496
|
+
const mergePolicy = options.mergePolicy ?? 'skip-existing';
|
|
497
|
+
const defaultState = options.importState ?? 'reviewed';
|
|
498
|
+
const byIdentity = new Map(
|
|
499
|
+
currentEntries.map((entry) => [entryIdentity(entry), entry]),
|
|
500
|
+
);
|
|
501
|
+
const byKey = new Map(currentEntries.map((entry) => [entry.key, entry]));
|
|
502
|
+
const decisions: VersionedImportDecision[] = [];
|
|
503
|
+
|
|
504
|
+
for (const importedEntry of [...document.entries].sort((left, right) =>
|
|
505
|
+
parsedIdentity(left).localeCompare(parsedIdentity(right)),
|
|
506
|
+
)) {
|
|
507
|
+
const boundKey = options.managerBindings?.[importedEntry.key];
|
|
508
|
+
const current =
|
|
509
|
+
(boundKey ? byKey.get(boundKey) : undefined) ??
|
|
510
|
+
byIdentity.get(parsedIdentity(importedEntry));
|
|
511
|
+
for (const [rawLanguage, value] of Object.entries(
|
|
512
|
+
importedEntry.translations,
|
|
513
|
+
).sort(([left], [right]) => left.localeCompare(right))) {
|
|
514
|
+
const language = canonicalLanguageTag(rawLanguage);
|
|
515
|
+
if (language === document.sourceLanguage) {
|
|
516
|
+
const importedContentHash = await sha256Hex(
|
|
517
|
+
canonicalJson([importedEntry.keyVersionId ?? null, language, value.text]),
|
|
518
|
+
);
|
|
519
|
+
decisions.push({
|
|
520
|
+
key: current?.key ?? importedEntry.key,
|
|
521
|
+
importedKey: importedEntry.key,
|
|
522
|
+
language,
|
|
523
|
+
text: value.text,
|
|
524
|
+
state: value.state ?? defaultState,
|
|
525
|
+
disposition: 'skip',
|
|
526
|
+
reason: 'source-language',
|
|
527
|
+
expectedKeyVersionId: current?.currentVersion?.id,
|
|
528
|
+
importedContentHash,
|
|
529
|
+
idempotencyKey: await sha256Hex(
|
|
530
|
+
canonicalJson([options.sessionId, 'source-language', language, importedContentHash]),
|
|
531
|
+
),
|
|
532
|
+
});
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
const state =
|
|
536
|
+
mergePolicy === 'import-as-stale'
|
|
537
|
+
? 'stale'
|
|
538
|
+
: (value.state ?? defaultState);
|
|
539
|
+
decisions.push(
|
|
540
|
+
await makeVersionedDecision({
|
|
541
|
+
importedEntry,
|
|
542
|
+
importedKey: importedEntry.key,
|
|
543
|
+
language,
|
|
544
|
+
text: value.text,
|
|
545
|
+
state,
|
|
546
|
+
note: value.note,
|
|
547
|
+
current,
|
|
548
|
+
sessionId: options.sessionId,
|
|
549
|
+
mergePolicy,
|
|
550
|
+
managerBound: Boolean(boundKey),
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return Object.freeze({
|
|
557
|
+
sessionId: options.sessionId,
|
|
558
|
+
documentHash: await sha256Hex(canonicalJson(document)),
|
|
559
|
+
revisionWatermark: options.revisionWatermark,
|
|
560
|
+
decisions: Object.freeze(decisions.map((decision) => Object.freeze(decision))),
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Compare-and-set commit. The entire requested batch is re-read and validated
|
|
566
|
+
* before its first write, so stale dry-runs cannot partially mutate the batch.
|
|
567
|
+
*/
|
|
568
|
+
export async function commitVersionedImport(
|
|
569
|
+
plan: VersionedImportPlan,
|
|
570
|
+
target: VersionedImportTarget,
|
|
571
|
+
options: { completedIdempotencyKeys?: ReadonlySet<string> } = {},
|
|
572
|
+
): Promise<VersionedImportCommitReport> {
|
|
573
|
+
const completed = options.completedIdempotencyKeys ?? new Set<string>();
|
|
574
|
+
const actionable = plan.decisions.filter(
|
|
575
|
+
(decision) =>
|
|
576
|
+
!completed.has(decision.idempotencyKey) &&
|
|
577
|
+
(decision.disposition === 'import' ||
|
|
578
|
+
decision.disposition === 'carry-forward' ||
|
|
579
|
+
(decision.disposition === 'unverified-source' &&
|
|
580
|
+
decision.reason === 'manager-bound-unverified')),
|
|
581
|
+
);
|
|
582
|
+
const current = await target.readCurrentState(
|
|
583
|
+
actionable.map(({ key, language }) => ({ key, language })),
|
|
584
|
+
);
|
|
585
|
+
const currentByIdentity = new Map(
|
|
586
|
+
current.map((item) => [`${item.key}\u0000${item.language}`, item]),
|
|
587
|
+
);
|
|
588
|
+
const drifted = actionable.some((decision) => {
|
|
589
|
+
const actual = currentByIdentity.get(
|
|
590
|
+
`${decision.key}\u0000${decision.language}`,
|
|
591
|
+
);
|
|
592
|
+
return (
|
|
593
|
+
actual?.keyVersionId !== decision.expectedKeyVersionId ||
|
|
594
|
+
actual?.unitContentHash !== decision.expectedUnitContentHash
|
|
595
|
+
);
|
|
596
|
+
});
|
|
597
|
+
if (drifted) {
|
|
598
|
+
return {
|
|
599
|
+
status: 'requires-redry-run',
|
|
600
|
+
imported: 0,
|
|
601
|
+
suggested: 0,
|
|
602
|
+
skipped: plan.decisions.length,
|
|
603
|
+
completedIdempotencyKeys: [...completed],
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
let imported = 0;
|
|
608
|
+
let suggested = 0;
|
|
609
|
+
const completedKeys = [...completed];
|
|
610
|
+
for (const decision of actionable) {
|
|
611
|
+
if (!decision.expectedKeyVersionId) continue;
|
|
612
|
+
if (decision.disposition === 'import') {
|
|
613
|
+
await target.upsertUnit({
|
|
614
|
+
key: decision.key,
|
|
615
|
+
language: decision.language,
|
|
616
|
+
text: decision.text,
|
|
617
|
+
state: decision.state,
|
|
618
|
+
keyVersionId: decision.expectedKeyVersionId,
|
|
619
|
+
source: 'import',
|
|
620
|
+
idempotencyKey: decision.idempotencyKey,
|
|
621
|
+
});
|
|
622
|
+
imported++;
|
|
623
|
+
} else {
|
|
624
|
+
await target.createSuggestion({
|
|
625
|
+
key: decision.key,
|
|
626
|
+
language: decision.language,
|
|
627
|
+
text: decision.text,
|
|
628
|
+
keyVersionId: decision.expectedKeyVersionId,
|
|
629
|
+
basedOnText: decision.basedOnText ?? '',
|
|
630
|
+
source:
|
|
631
|
+
decision.disposition === 'carry-forward'
|
|
632
|
+
? 'carry-forward'
|
|
633
|
+
: 'import',
|
|
634
|
+
note: decision.note,
|
|
635
|
+
idempotencyKey: decision.idempotencyKey,
|
|
636
|
+
});
|
|
637
|
+
suggested++;
|
|
638
|
+
}
|
|
639
|
+
completedKeys.push(decision.idempotencyKey);
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
status: 'committed',
|
|
643
|
+
imported,
|
|
644
|
+
suggested,
|
|
645
|
+
skipped: plan.decisions.length - actionable.length,
|
|
646
|
+
completedIdempotencyKeys: completedKeys,
|
|
647
|
+
};
|
|
648
|
+
}
|