@eifi1/ui-kit 0.17.0 → 0.19.0
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 +83 -4
- package/dist/chart.d.ts +13 -1
- package/dist/components/account-chips.d.ts +153 -0
- package/dist/components/account-chips.js +135 -0
- package/dist/components/account-chips.js.map +1 -0
- package/dist/components/account-settings-labels.d.ts +10 -2
- package/dist/components/account-settings-labels.js +3 -0
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/amount-input.d.ts +13 -1
- package/dist/components/button-group.d.ts +13 -1
- package/dist/components/calculator.d.ts +13 -1
- package/dist/components/chip.d.ts +25 -1
- package/dist/components/chip.js +143 -6
- package/dist/components/chip.js.map +1 -1
- package/dist/components/confirm-dialog.d.ts +42 -1
- package/dist/components/confirm-dialog.js +36 -5
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +13 -1
- package/dist/components/danger-confirm.d.ts +55 -4
- package/dist/components/danger-confirm.js +67 -30
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.d.ts +1 -1
- package/dist/components/data-table-filter-popover.d.ts +1 -1
- package/dist/components/data-table-filters.d.ts +1 -1
- package/dist/components/data-table.d.ts +1 -1
- package/dist/components/data-table.js +2 -0
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/facing-pair.d.ts +13 -1
- package/dist/components/file-button.d.ts +13 -1
- package/dist/components/file-dropzone.d.ts +13 -1
- package/dist/components/floating-panel.d.ts +4 -1
- package/dist/components/floating-panel.js +15 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/form-actions.d.ts +13 -1
- package/dist/components/form-actions.js +3 -0
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/legal.d.ts +84 -0
- package/dist/components/legal.js +55 -0
- package/dist/components/legal.js.map +1 -0
- package/dist/components/money-field.d.ts +13 -1
- package/dist/components/number-field.d.ts +14 -2
- package/dist/components/number-field.js +23 -22
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +13 -1
- package/dist/components/numpad-sheet.d.ts +13 -1
- package/dist/components/passkeys-setting.d.ts +27 -1
- package/dist/components/passkeys-setting.js +31 -5
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/progress-bar.d.ts +15 -1
- package/dist/components/progress-bar.js +8 -1
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/reauth-dialog.d.ts +90 -0
- package/dist/components/reauth-dialog.js +158 -0
- package/dist/components/reauth-dialog.js.map +1 -0
- package/dist/components/series-chart.d.ts +13 -1
- package/dist/components/server-wake.d.ts +60 -0
- package/dist/components/server-wake.js +79 -0
- package/dist/components/server-wake.js.map +1 -0
- package/dist/components/settings-fields.d.ts +13 -1
- package/dist/components/share-card.d.ts +64 -0
- package/dist/components/share-card.js +350 -0
- package/dist/components/share-card.js.map +1 -0
- package/dist/components/status-dot.d.ts +2 -0
- package/dist/components/text-link.d.ts +13 -1
- package/dist/components/time-input.d.ts +13 -1
- package/dist/components/toast.d.ts +10 -1
- package/dist/components/toast.js +42 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +5 -2
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/translation-review-editor.d.ts +56 -0
- package/dist/components/translation-review-editor.js +159 -0
- package/dist/components/translation-review-editor.js.map +1 -0
- package/dist/components/translation-review-labels.d.ts +92 -0
- package/dist/components/translation-review-labels.js +67 -0
- package/dist/components/translation-review-labels.js.map +1 -0
- package/dist/components/translation-review-parts.d.ts +97 -0
- package/dist/components/translation-review-parts.js +163 -0
- package/dist/components/translation-review-parts.js.map +1 -0
- package/dist/components/translation-review.d.ts +94 -0
- package/dist/components/translation-review.js +344 -0
- package/dist/components/translation-review.js.map +1 -0
- package/dist/components/ui.d.ts +13 -1
- package/dist/components/ui.js +43 -28
- package/dist/components/ui.js.map +1 -1
- package/dist/components/use-table-state.d.ts +1 -1
- package/dist/components/user-avatar.d.ts +2 -0
- package/dist/components/write-lock.d.ts +68 -0
- package/dist/components/write-lock.js +33 -0
- package/dist/components/write-lock.js.map +1 -0
- package/dist/{data-table-CpkWUJBY.d.ts → data-table-3mi2R3so.d.ts} +19 -1
- package/dist/data-table.d.ts +1 -1
- package/dist/hooks/use-file-drop.d.ts +13 -1
- package/dist/hooks/use-overlay-history.js +16 -0
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/i18n/defaults.d.ts +13 -1
- package/dist/i18n/defaults.js +15 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +81 -0
- package/dist/i18n/{locales/de.js → german.js} +110 -9
- package/dist/i18n/german.js.map +1 -0
- package/dist/i18n/kit-labels.d.ts +13 -1
- package/dist/i18n/kit-labels.js +5 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +243 -0
- package/dist/i18n/languages.js +70 -0
- package/dist/i18n/languages.js.map +1 -0
- package/dist/i18n/locales/de-CH.d.ts +26 -8
- package/dist/i18n/locales/de-CH.js +7 -3
- package/dist/i18n/locales/de-CH.js.map +1 -1
- package/dist/i18n/locales/en.d.ts +13 -1
- package/dist/i18n/locales/en.js +8 -0
- package/dist/i18n/locales/en.js.map +1 -1
- package/dist/i18n/locales/es.d.ts +13 -1
- package/dist/i18n/locales/es.js +127 -27
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +13 -1
- package/dist/i18n/locales/fr.js +101 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +13 -1
- package/dist/i18n/locales/hu.js +105 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +13 -1
- package/dist/i18n/locales/it.js +134 -34
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +13 -1
- package/dist/i18n/locales/zh.js +101 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +102 -0
- package/dist/i18n/review.js +158 -0
- package/dist/i18n/review.js.map +1 -0
- package/dist/i18n/swiss.d.ts +3 -3
- package/dist/i18n/swiss.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +97 -1
- package/dist/index.js.map +1 -1
- package/dist/{kit-labels-C4uB_zmg.d.ts → kit-labels-D1J2_jyD.d.ts} +236 -9
- package/dist/lib/floating-stack.d.ts +31 -0
- package/dist/lib/floating-stack.js +42 -0
- package/dist/lib/floating-stack.js.map +1 -0
- package/dist/lib/server-wake.d.ts +146 -0
- package/dist/lib/server-wake.js +122 -0
- package/dist/lib/server-wake.js.map +1 -0
- package/dist/lib/translation-review.d.ts +232 -0
- package/dist/lib/translation-review.js +194 -0
- package/dist/lib/translation-review.js.map +1 -0
- package/dist/rhf/fields.d.ts +13 -1
- package/dist/rhf/form.d.ts +13 -1
- package/dist/rhf.d.ts +13 -1
- package/dist/search/global-search.d.ts +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/shell/app-shell.d.ts +13 -1
- package/dist/shell/top-bar-brand.d.ts +13 -1
- package/dist/shell/topbar-action-menu.d.ts +2 -0
- package/dist/shell.d.ts +13 -1
- package/dist/wizard/stepper-nav.d.ts +13 -1
- package/dist/wizard.d.ts +13 -1
- package/package.json +1 -1
- package/src/components/account-chips.tsx +302 -0
- package/src/components/account-settings-labels.ts +16 -2
- package/src/components/chip.tsx +239 -6
- package/src/components/confirm-dialog.tsx +82 -7
- package/src/components/danger-confirm.tsx +118 -33
- package/src/components/data-table.tsx +20 -0
- package/src/components/floating-panel.tsx +19 -1
- package/src/components/form-actions.tsx +22 -3
- package/src/components/legal.tsx +159 -0
- package/src/components/number-field.tsx +32 -26
- package/src/components/passkeys-setting.tsx +68 -7
- package/src/components/progress-bar.tsx +21 -1
- package/src/components/reauth-dialog.tsx +273 -0
- package/src/components/server-wake.tsx +156 -0
- package/src/components/share-card.tsx +615 -0
- package/src/components/toast.tsx +72 -2
- package/src/components/toggle-group.tsx +5 -2
- package/src/components/translation-review-editor.tsx +261 -0
- package/src/components/translation-review-labels.ts +152 -0
- package/src/components/translation-review-parts.tsx +269 -0
- package/src/components/translation-review.tsx +502 -0
- package/src/components/ui.tsx +89 -32
- package/src/components/write-lock.tsx +96 -0
- package/src/hooks/use-overlay-history.ts +51 -0
- package/src/i18n/defaults.ts +14 -0
- package/src/i18n/{locales/de.ts → german.ts} +120 -13
- package/src/i18n/kit-labels.tsx +29 -2
- package/src/i18n/languages.ts +285 -0
- package/src/i18n/locales/de-CH.ts +17 -8
- package/src/i18n/locales/en.ts +8 -0
- package/src/i18n/locales/es.ts +128 -26
- package/src/i18n/locales/fr.ts +103 -0
- package/src/i18n/locales/hu.ts +107 -0
- package/src/i18n/locales/it.ts +135 -33
- package/src/i18n/locales/zh.ts +102 -0
- package/src/i18n/review.ts +193 -0
- package/src/i18n/swiss.ts +3 -3
- package/src/index.ts +86 -1
- package/src/lib/floating-stack.ts +66 -0
- package/src/lib/server-wake.ts +291 -0
- package/src/lib/translation-review.ts +465 -0
- package/src/search/global-search.tsx +3 -1
- package/dist/i18n/locales/de-CH-informal.d.ts +0 -61
- package/dist/i18n/locales/de-CH-informal.js +0 -8
- package/dist/i18n/locales/de-CH-informal.js.map +0 -1
- package/dist/i18n/locales/de-informal.d.ts +0 -82
- package/dist/i18n/locales/de-informal.js +0 -37
- package/dist/i18n/locales/de-informal.js.map +0 -1
- package/dist/i18n/locales/de.d.ts +0 -66
- package/dist/i18n/locales/de.js.map +0 -1
- package/src/i18n/locales/de-CH-informal.ts +0 -10
- package/src/i18n/locales/de-informal.ts +0 -61
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
const DOWNLOAD_RESPONSE_TYPES = /* @__PURE__ */ new Set(["blob", "arraybuffer", "stream"]);
|
|
3
|
+
function watchReadsAnd(writes) {
|
|
4
|
+
return (method, url, request) => !DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? "") && (method === "get" || writes !== void 0 && writes.test(url));
|
|
5
|
+
}
|
|
6
|
+
function isOffline() {
|
|
7
|
+
return typeof navigator !== "undefined" && navigator.onLine === false;
|
|
8
|
+
}
|
|
9
|
+
function createServerWake({
|
|
10
|
+
slowMs = 2e3,
|
|
11
|
+
wakingMs = 7e3,
|
|
12
|
+
shouldWatch = watchReadsAnd()
|
|
13
|
+
} = {}) {
|
|
14
|
+
let inFlight = 0;
|
|
15
|
+
let stage = "idle";
|
|
16
|
+
let slowTimer = null;
|
|
17
|
+
let wakingTimer = null;
|
|
18
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
19
|
+
const setStage = (next) => {
|
|
20
|
+
if (next === stage) return;
|
|
21
|
+
stage = next;
|
|
22
|
+
for (const listener of listeners) listener();
|
|
23
|
+
};
|
|
24
|
+
const clearTimers = () => {
|
|
25
|
+
if (slowTimer !== null) clearTimeout(slowTimer);
|
|
26
|
+
if (wakingTimer !== null) clearTimeout(wakingTimer);
|
|
27
|
+
slowTimer = null;
|
|
28
|
+
wakingTimer = null;
|
|
29
|
+
};
|
|
30
|
+
const start = (method, url, request = { method, url }) => {
|
|
31
|
+
if (isOffline()) return false;
|
|
32
|
+
if (!shouldWatch((method ?? "get").toLowerCase(), url ?? "", request)) return false;
|
|
33
|
+
inFlight += 1;
|
|
34
|
+
if (inFlight === 1) {
|
|
35
|
+
clearTimers();
|
|
36
|
+
slowTimer = setTimeout(() => setStage("slow"), slowMs);
|
|
37
|
+
wakingTimer = setTimeout(() => setStage("waking"), wakingMs);
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
};
|
|
41
|
+
const end = () => {
|
|
42
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
43
|
+
if (inFlight > 0) return;
|
|
44
|
+
clearTimers();
|
|
45
|
+
setStage("idle");
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
start,
|
|
49
|
+
end,
|
|
50
|
+
track(work, request = {}) {
|
|
51
|
+
const counted = start(request.method, request.url, request);
|
|
52
|
+
let promise;
|
|
53
|
+
try {
|
|
54
|
+
promise = Promise.resolve(typeof work === "function" ? work() : work);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
promise = Promise.reject(error);
|
|
57
|
+
}
|
|
58
|
+
return counted ? promise.finally(end) : promise;
|
|
59
|
+
},
|
|
60
|
+
reset() {
|
|
61
|
+
inFlight = 0;
|
|
62
|
+
clearTimers();
|
|
63
|
+
setStage("idle");
|
|
64
|
+
},
|
|
65
|
+
subscribe(listener) {
|
|
66
|
+
listeners.add(listener);
|
|
67
|
+
return () => {
|
|
68
|
+
listeners.delete(listener);
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
getStage: () => stage,
|
|
72
|
+
getInFlight: () => inFlight
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const serverWake = createServerWake();
|
|
76
|
+
const COUNTED = "_serverWake";
|
|
77
|
+
function attachServerWake(instance, watcher = serverWake) {
|
|
78
|
+
const settle = (config) => {
|
|
79
|
+
const marked = config;
|
|
80
|
+
if (marked?.[COUNTED]) {
|
|
81
|
+
marked[COUNTED] = false;
|
|
82
|
+
watcher.end();
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const request = instance.interceptors.request.use(
|
|
86
|
+
(config) => {
|
|
87
|
+
config[COUNTED] = watcher.start(config.method, config.url, config);
|
|
88
|
+
return config;
|
|
89
|
+
},
|
|
90
|
+
(error) => Promise.reject(error)
|
|
91
|
+
);
|
|
92
|
+
const response = instance.interceptors.response.use(
|
|
93
|
+
(res) => {
|
|
94
|
+
settle(res.config);
|
|
95
|
+
return res;
|
|
96
|
+
},
|
|
97
|
+
(error) => {
|
|
98
|
+
settle(error?.config);
|
|
99
|
+
return Promise.reject(error);
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
return () => {
|
|
103
|
+
instance.interceptors.request.eject(request);
|
|
104
|
+
instance.interceptors.response.eject(response);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function wrapFetch(fetchImpl = globalThis.fetch, watcher = serverWake) {
|
|
108
|
+
return (input, init) => {
|
|
109
|
+
const request = typeof Request !== "undefined" && input instanceof Request ? input : void 0;
|
|
110
|
+
const method = init?.method ?? request?.method;
|
|
111
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : request?.url ?? "";
|
|
112
|
+
return watcher.track(() => fetchImpl(input, init), { method, url });
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export {
|
|
116
|
+
attachServerWake,
|
|
117
|
+
createServerWake,
|
|
118
|
+
serverWake,
|
|
119
|
+
watchReadsAnd,
|
|
120
|
+
wrapFetch
|
|
121
|
+
};
|
|
122
|
+
//# sourceMappingURL=server-wake.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/server-wake.ts"],"sourcesContent":["/**\n * \"Is the server asleep, or is the app broken?\" — the cold-start watchdog, lifted\n * from keksdose (feedback #199) so kastlan and Kurvenschmiede get it too.\n *\n * All three backends run on Cloud Run with `--min-instances=0`: after an idle\n * period the container is gone and the first request has to start it, which takes\n * several seconds and occasionally more. Nothing in the UI said so, and none of the\n * axios instances has a timeout, so the honest experience was a spinner that looked\n * stuck. This counts the in-flight requests a cold start would actually delay and\n * escalates in two steps, so a merely slow response says \"still loading\" and only a\n * genuinely long wait blames the sleeping server.\n *\n * Framework-free on purpose (no zustand, no axios, no React): a counter, two timers\n * and a listener set, read by `useServerWakeStage` through `useSyncExternalStore`.\n * The adapters below take the app's axios instance / fetch as an ARGUMENT, so the\n * kit never imports either.\n *\n * WHAT ARMS IT, and why the default is narrow:\n * - reads (GET) — what every page does on open — always;\n * - writes only when the app says so (`watchReadsAnd(/\\/auth\\/login\\b/)`): the\n * auth POSTs that gate the app are the only writes a user waits on before the\n * app exists at all, so a cold start there looks exactly like a broken login.\n * An UPLOAD must never be watched — a statement PDF or a photo takes seconds on a\n * perfectly warm server, and \"the server is waking up\" during a 4 s upload would\n * simply be false. With the default that holds by itself (an upload is a POST);\n * an app widening `shouldWatch` keeps its upload routes out of it.\n *\n * Nor a DOWNLOAD, for the same reason (kastlan 0.18): axios answers only once the\n * whole body is in, so a generated PDF or a large photo — a GET — ran past both\n * thresholds on a warm server, and held the count for every read beside it. The\n * default filter skips a request whose `responseType` is `\"blob\"`, `\"arraybuffer\"` or\n * `\"stream\"`; a custom filter gets the request as its third argument to do the same.\n * (`wrapFetch` ends the wait at the headers, so a download through it is harmless.)\n *\n * It never arms while the browser is offline, whatever `shouldWatch` says: that state\n * has its own, truthful indicator, and a request that never left the device is not\n * evidence of anything about the server.\n */\n\n/** `slow` = longer than a warm server ever takes; `waking` = long enough that a cold\n * start is the only sensible explanation. */\nexport type ServerWakeStage = \"idle\" | \"slow\" | \"waking\";\n\n/** Decides which requests count. `method` arrives lower-cased (`\"get\"` when the\n * request named none — axios's and fetch's default); `url` as the client had it;\n * `request` is what the adapter saw — for axios the request config itself, so\n * `responseType` or the app's own fields can decide. */\nexport type ServerWakeFilter = (method: string, url: string, request: ServerWakeRequest) => boolean;\n\nexport interface ServerWakeOptions {\n /** Past this, a warm round trip is out of the question (a cold database query on a\n * free tier can still take a second, so it is not tighter). Default 2000 ms. */\n slowMs?: number;\n /** Past this, the container was almost certainly scaled to zero. Default 7000 ms. */\n wakingMs?: number;\n /** Which requests count. Default {@link watchReadsAnd}`()` — GETs only, downloads\n * excluded. The offline guard runs before it and cannot be overridden. */\n shouldWatch?: ServerWakeFilter;\n}\n\n/** What a request is, for {@link ServerWakeWatcher.track} and a {@link ServerWakeFilter}. */\nexport interface ServerWakeRequest {\n method?: string;\n url?: string;\n /** axios's `responseType`. `\"blob\"`, `\"arraybuffer\"` and `\"stream\"` mark a download,\n * which the default filter does not count. */\n responseType?: string;\n}\n\nexport interface ServerWakeWatcher {\n /**\n * Count a request that is going out, and arm the escalation if it is the first.\n * Returns whether it was counted — the caller MUST call {@link end} exactly once\n * for every `true`, or the counter never returns to zero and the notice sticks.\n * (The axios adapter stashes it on the request config for that reason.)\n * `request` reaches the filter as its third argument; default `{ method, url }`.\n */\n start(method?: string, url?: string, request?: ServerWakeRequest): boolean;\n /** A counted request settled — either way: an error still proves the server\n * answered or gave up. When the last one does, the notice goes away. Never takes\n * the counter below zero. */\n end(): void;\n /** `start` + `end` around a promise (or a function returning one), for a request\n * that does not go through an adapter. Resolves and rejects as the work does. */\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request?: ServerWakeRequest): Promise<T>;\n /** Back to a pristine watchdog — for tests, which share an instance across cases. */\n reset(): void;\n /** `useSyncExternalStore`'s pair. Listeners hear stage changes only. */\n subscribe(listener: () => void): () => void;\n getStage(): ServerWakeStage;\n /** Counted requests currently outstanding. */\n getInFlight(): number;\n}\n\n/** Response types that make a request a download — see the module comment. */\nconst DOWNLOAD_RESPONSE_TYPES = new Set([\"blob\", \"arraybuffer\", \"stream\"]);\n\n/**\n * The usual filter: every GET, plus any other request whose URL matches `writes` —\n * keksdose's `/\\/auth\\/(login|register|demo-session)\\b/` — but never a download\n * (`responseType` `\"blob\"`, `\"arraybuffer\"` or `\"stream\"`). Keep upload routes out of\n * the pattern (see the module comment).\n */\nexport function watchReadsAnd(writes?: RegExp): ServerWakeFilter {\n return (method, url, request) =>\n !DOWNLOAD_RESPONSE_TYPES.has(request?.responseType ?? \"\") &&\n (method === \"get\" || (writes !== undefined && writes.test(url)));\n}\n\nfunction isOffline(): boolean {\n return typeof navigator !== \"undefined\" && navigator.onLine === false;\n}\n\nexport function createServerWake({\n slowMs = 2000,\n wakingMs = 7000,\n shouldWatch = watchReadsAnd(),\n}: ServerWakeOptions = {}): ServerWakeWatcher {\n let inFlight = 0;\n let stage: ServerWakeStage = \"idle\";\n let slowTimer: ReturnType<typeof setTimeout> | null = null;\n let wakingTimer: ReturnType<typeof setTimeout> | null = null;\n const listeners = new Set<() => void>();\n\n const setStage = (next: ServerWakeStage) => {\n if (next === stage) return;\n stage = next;\n for (const listener of listeners) listener();\n };\n\n const clearTimers = () => {\n if (slowTimer !== null) clearTimeout(slowTimer);\n if (wakingTimer !== null) clearTimeout(wakingTimer);\n slowTimer = null;\n wakingTimer = null;\n };\n\n const start = (method?: string, url?: string, request: ServerWakeRequest = { method, url }): boolean => {\n if (isOffline()) return false;\n if (!shouldWatch((method ?? \"get\").toLowerCase(), url ?? \"\", request)) return false;\n inFlight += 1;\n // The clock runs from the FIRST outstanding request: a page firing a second read\n // at 1.9 s is still waiting on the same sleeping server, not starting afresh.\n if (inFlight === 1) {\n clearTimers();\n slowTimer = setTimeout(() => setStage(\"slow\"), slowMs);\n wakingTimer = setTimeout(() => setStage(\"waking\"), wakingMs);\n }\n return true;\n };\n\n const end = () => {\n inFlight = Math.max(0, inFlight - 1);\n if (inFlight > 0) return;\n clearTimers();\n setStage(\"idle\");\n };\n\n return {\n start,\n end,\n track<T>(work: PromiseLike<T> | (() => PromiseLike<T>), request: ServerWakeRequest = {}) {\n const counted = start(request.method, request.url, request);\n let promise: Promise<T>;\n try {\n promise = Promise.resolve(typeof work === \"function\" ? work() : work);\n } catch (error) {\n // A thunk that throws before it returns a promise settled too.\n promise = Promise.reject(error);\n }\n return counted ? promise.finally(end) : promise;\n },\n reset() {\n inFlight = 0;\n clearTimers();\n setStage(\"idle\");\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n getStage: () => stage,\n getInFlight: () => inFlight,\n };\n}\n\n/**\n * The shared instance: what `<ServerWakeNotice />`, `useServerWakeStage()`,\n * {@link attachServerWake} and {@link wrapFetch} use when no watcher is passed. GETs\n * only, default timings — an app that also watches its login POST creates its own\n * with {@link createServerWake} and passes it to both ends.\n */\nexport const serverWake: ServerWakeWatcher = createServerWake();\n\n// ── axios ─────────────────────────────────────────────────────────────────────\n\n/** The config fields the watchdog reads — what axios's request config has. The whole\n * config reaches the filter, so an app's filter can read the rest of it. */\nexport interface ServerWakeAxiosConfig {\n method?: string;\n url?: string;\n responseType?: string;\n}\n\n/** The slice of an interceptor manager the adapter uses. A method (not a function\n * property) so axios's wider `use(onFulfilled?, onRejected?, options?)` fits it. */\ninterface InterceptorManagerLike<V> {\n use(onFulfilled: (value: V) => V | Promise<V>, onRejected: (error: unknown) => unknown): number;\n eject(id: number): void;\n}\n\n/** An axios instance, structurally — so the kit types the adapter without importing\n * axios. `axios.create()`'s result fits it as is. */\nexport interface AxiosLikeInstance<C extends ServerWakeAxiosConfig, R extends { config: C }> {\n interceptors: {\n request: InterceptorManagerLike<C>;\n response: InterceptorManagerLike<R>;\n };\n}\n\n/** Whether the watchdog counted this request — the response side owes a matching\n * `end()` only then. On the config, as keksdose's `_wake` was: it travels with the\n * request onto the response and onto an error. Cleared once settled, so a 401 replay\n * of the same config re-enters the request interceptor and is counted afresh — the\n * pairing stays balanced. */\nconst COUNTED = \"_serverWake\";\ntype Marked = { [COUNTED]?: boolean };\n\n/**\n * Drive a watcher from an axios instance's interceptors: `attachServerWake(api)`.\n * Returns a function that ejects both interceptors again.\n *\n * Attach it BEFORE the app's own response interceptors: axios runs those in the order\n * they were added, so the wake one ends the wait before a 401 handler awaits a token\n * refresh — otherwise the notice would keep counting a request that has already been\n * answered. (Request-side order does not matter.)\n */\nexport function attachServerWake<C extends ServerWakeAxiosConfig, R extends { config: C }>(\n instance: AxiosLikeInstance<C, R>,\n watcher: ServerWakeWatcher = serverWake,\n): () => void {\n const settle = (config: unknown) => {\n const marked = config as Marked | undefined;\n if (marked?.[COUNTED]) {\n marked[COUNTED] = false;\n watcher.end();\n }\n };\n const request = instance.interceptors.request.use(\n (config) => {\n (config as C & Marked)[COUNTED] = watcher.start(config.method, config.url, config);\n return config;\n },\n (error) => Promise.reject(error),\n );\n const response = instance.interceptors.response.use(\n (res) => {\n settle(res.config);\n return res;\n },\n (error) => {\n settle((error as { config?: unknown } | null)?.config);\n return Promise.reject(error);\n },\n );\n return () => {\n instance.interceptors.request.eject(request);\n instance.interceptors.response.eject(response);\n };\n}\n\n// ── fetch ─────────────────────────────────────────────────────────────────────\n\n/**\n * A `fetch` that drives a watcher: `const apiFetch = wrapFetch(fetch)`. The wait ends\n * when the response's HEADERS arrive (or the call fails) — the server is awake by then,\n * however long the body takes.\n */\nexport function wrapFetch(\n fetchImpl: typeof fetch = globalThis.fetch,\n watcher: ServerWakeWatcher = serverWake,\n): typeof fetch {\n return (input, init) => {\n const request = typeof Request !== \"undefined\" && input instanceof Request ? input : undefined;\n const method = init?.method ?? request?.method;\n const url = typeof input === \"string\" ? input : input instanceof URL ? input.href : (request?.url ?? \"\");\n return watcher.track(() => fetchImpl(input, init), { method, url });\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of an app's translation review (H1 in docs/i18n-harmonization.md): from
|
|
3
|
+
* the strings the app ships and the verdicts its server stores to one row per (locale,
|
|
4
|
+
* key) with a status — keksdose's `features/translations/translation-catalogue.ts` and
|
|
5
|
+
* kastlan's copy of it, which had grown apart by a fifth status and a second kind of key.
|
|
6
|
+
*
|
|
7
|
+
* WHY THE STRINGS ARE THE CATALOGUE. Nothing on the server lists which strings exist: a
|
|
8
|
+
* verdict only says what somebody thought of one. So the rows are built from what the app
|
|
9
|
+
* ships — its locale bundles, kastlan's document texts, the kit's own words
|
|
10
|
+
* (`kitLabelStrings`) — and the review can never offer a string users do not see. A
|
|
11
|
+
* verdict is stored WITH the wording it was given on, and a string that changed since is
|
|
12
|
+
* noticed by comparing the two: `changed`, read it again, whatever the verdict was.
|
|
13
|
+
*
|
|
14
|
+
* WHAT STAYS IN THE APP. Which locale is read against which (keksdose: everything against
|
|
15
|
+
* English, English against de-CH; kastlan: against German, German against English), where
|
|
16
|
+
* the strings come from, who may review what (the locales and areas its server answers
|
|
17
|
+
* with), and every request. The kit takes those as input and never derives them.
|
|
18
|
+
*
|
|
19
|
+
* camelCase, like the rest of the kit. The contract's wire shape is snake_case
|
|
20
|
+
* (`reference_text`, `reviewer_name`, `reviewed_at`); {@link fromApiReview} and
|
|
21
|
+
* {@link toApiWrite} are the two lines each app would otherwise write — both apps speak
|
|
22
|
+
* the same contract, so the mapping is written once, here.
|
|
23
|
+
*/
|
|
24
|
+
type TranslationVerdict = "APPROVED" | "NEEDS_CHANGE";
|
|
25
|
+
/** One stored verdict: what a reviewer said about one string in one locale. */
|
|
26
|
+
interface TranslationReview {
|
|
27
|
+
locale: string;
|
|
28
|
+
key: string;
|
|
29
|
+
/** The wording that was judged — compared with today's to notice a change. Empty for
|
|
30
|
+
* a string the locale lacked (a suggested translation for a `missing` row). */
|
|
31
|
+
text: string;
|
|
32
|
+
/** The reference wording at the time. `null` from a server that did not store it. */
|
|
33
|
+
referenceText: string | null;
|
|
34
|
+
verdict: TranslationVerdict;
|
|
35
|
+
note: string | null;
|
|
36
|
+
/** The reviewer's wording — or, for a missing string, the translation to add. */
|
|
37
|
+
suggestion: string | null;
|
|
38
|
+
/** `null` once the reviewer's account has been erased. */
|
|
39
|
+
reviewerName: string | null;
|
|
40
|
+
/** ISO timestamp. */
|
|
41
|
+
reviewedAt: string;
|
|
42
|
+
}
|
|
43
|
+
/** What a save sends: the verdict plus the wording it is given on. */
|
|
44
|
+
interface TranslationReviewWrite {
|
|
45
|
+
locale: string;
|
|
46
|
+
key: string;
|
|
47
|
+
text: string;
|
|
48
|
+
referenceText: string | null;
|
|
49
|
+
verdict: TranslationVerdict;
|
|
50
|
+
note: string | null;
|
|
51
|
+
suggestion: string | null;
|
|
52
|
+
}
|
|
53
|
+
/** What a reset sends: back to "unreviewed". */
|
|
54
|
+
interface TranslationReviewKey {
|
|
55
|
+
locale: string;
|
|
56
|
+
key: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Where a string stands, in the order a reviewer works through them: what is not there
|
|
60
|
+
* at all, what nobody has read, what changed after it was read, what was sent back, and
|
|
61
|
+
* what is done. `missing` never occurs for an app whose locales have every key
|
|
62
|
+
* (keksdose); kastlan's French lacks 172 screen texts.
|
|
63
|
+
*/
|
|
64
|
+
type ReviewStatus = "missing" | "unreviewed" | "changed" | "needs_change" | "approved";
|
|
65
|
+
declare const REVIEW_STATUSES: readonly ReviewStatus[];
|
|
66
|
+
interface TranslationRow {
|
|
67
|
+
/** `${locale}|${key}` — unique across every locale on a page. A bar, not a colon:
|
|
68
|
+
* kastlan's keys contain colons (`common:actions.save`). */
|
|
69
|
+
id: string;
|
|
70
|
+
key: string;
|
|
71
|
+
/** The key's group: `budget` for `budget.rta`, `common` for `common:actions.save`,
|
|
72
|
+
* `kit` for the kit's own words. See {@link keyNamespace}. */
|
|
73
|
+
namespace: string;
|
|
74
|
+
/** Where the string comes from, in the app's own words — kastlan's `screen` and
|
|
75
|
+
* `documents`, keksdose's one source. `""` when the app does not say. */
|
|
76
|
+
source: string;
|
|
77
|
+
locale: string;
|
|
78
|
+
/** The wording users see today; empty when the locale lacks the string. */
|
|
79
|
+
text: string;
|
|
80
|
+
/** The same key in the reference locale. */
|
|
81
|
+
reference: string;
|
|
82
|
+
status: ReviewStatus;
|
|
83
|
+
review: TranslationReview | null;
|
|
84
|
+
/** The interpolations and tags differ from the reference's — `{{count}}` dropped, a
|
|
85
|
+
* `<1>` link lost, a `{date}` renamed. A machine can check that much, so it does,
|
|
86
|
+
* before a person reads. Never set on a missing string: it has nothing to lose. */
|
|
87
|
+
placeholderMismatch: boolean;
|
|
88
|
+
}
|
|
89
|
+
/** A verdict as `GET /translations/reviews` returns it (H1's shared contract). Typed
|
|
90
|
+
* structurally, so an app's generated OpenAPI type fits without a cast. */
|
|
91
|
+
interface ApiTranslationReview {
|
|
92
|
+
locale: string;
|
|
93
|
+
key: string;
|
|
94
|
+
text: string;
|
|
95
|
+
reference_text: string | null;
|
|
96
|
+
verdict: TranslationVerdict;
|
|
97
|
+
note: string | null;
|
|
98
|
+
suggestion: string | null;
|
|
99
|
+
reviewer_name: string | null;
|
|
100
|
+
reviewed_at: string;
|
|
101
|
+
}
|
|
102
|
+
/** One item of `PUT /translations/reviews`. */
|
|
103
|
+
interface ApiTranslationReviewWrite {
|
|
104
|
+
locale: string;
|
|
105
|
+
key: string;
|
|
106
|
+
text: string;
|
|
107
|
+
reference_text: string | null;
|
|
108
|
+
verdict: TranslationVerdict;
|
|
109
|
+
note: string | null;
|
|
110
|
+
suggestion: string | null;
|
|
111
|
+
}
|
|
112
|
+
/** A verdict from the server, in the kit's spelling. */
|
|
113
|
+
declare function fromApiReview(review: ApiTranslationReview): TranslationReview;
|
|
114
|
+
/** A save, in the server's spelling. `TranslationReviewKey` needs no mapping — it is
|
|
115
|
+
* `{ locale, key }` on both sides. */
|
|
116
|
+
declare function toApiWrite(write: TranslationReviewWrite): ApiTranslationReviewWrite;
|
|
117
|
+
/**
|
|
118
|
+
* A nested bundle as `key → string`, in the bundle's own order. Non-string leaves are
|
|
119
|
+
* skipped rather than stringified into something nobody wrote.
|
|
120
|
+
*
|
|
121
|
+
* `prefix` is put in front of every key AS IS, so it names the separator too:
|
|
122
|
+
* `flattenStrings(bundle)` is keksdose's `budget.rta`, `flattenStrings(tree, "common:")`
|
|
123
|
+
* kastlan's `common:actions.save`, and `flattenStrings(kitLabelStrings(labels), "kit.")`
|
|
124
|
+
* the kit's own words as `kit.combobox.resultCount(3)` — already flat, so only the prefix
|
|
125
|
+
* is added.
|
|
126
|
+
*/
|
|
127
|
+
declare function flattenStrings(tree: Readonly<Record<string, unknown>>, prefix?: string): Record<string, string>;
|
|
128
|
+
/**
|
|
129
|
+
* The key's group, for the namespace filter: the part before a colon when there is one
|
|
130
|
+
* (kastlan's `common:actions.save` → `common`, `doc_text.invoice:due` →
|
|
131
|
+
* `doc_text.invoice`), else the first dotted segment (keksdose's `budget.rta` →
|
|
132
|
+
* `budget`, the kit's `kit.combobox.resultCount(3)` → `kit`). Only the part before a
|
|
133
|
+
* sample's `(` is looked at, so a colon inside one cannot move the split.
|
|
134
|
+
*/
|
|
135
|
+
declare function keyNamespace(key: string): string;
|
|
136
|
+
/** The placeholders in a string, as a sorted multiset: `{{count}}`, `{date}`, `<1>`,
|
|
137
|
+
* `$t(common.save)`. */
|
|
138
|
+
declare function placeholderTokens(text: string): string[];
|
|
139
|
+
/** Whether `text` has other placeholders than `reference` — dropped, renamed or added.
|
|
140
|
+
* Moving one is fine: a translation reorders a sentence. */
|
|
141
|
+
declare function placeholderMismatch(text: string, reference: string): boolean;
|
|
142
|
+
/**
|
|
143
|
+
* A verdict counts only for the wording it was given on. If the string or its reference
|
|
144
|
+
* changed since, it is `changed` — read it again — whatever the verdict was. A string the
|
|
145
|
+
* locale lacks is `missing` until somebody suggests its translation (stored as
|
|
146
|
+
* NEEDS_CHANGE on the empty text, so it reads `needs_change`, and `changed` once a
|
|
147
|
+
* developer adds the string).
|
|
148
|
+
*/
|
|
149
|
+
declare function reviewStatus(review: TranslationReview | null | undefined, text: string, reference: string): ReviewStatus;
|
|
150
|
+
/** Whether `key` lies in `area`: the key IS the area, or sits under it — `legal.terms`
|
|
151
|
+
* and kastlan's `legal:terms` are in `legal`, `legalese.title` is not. */
|
|
152
|
+
declare function keyInArea(key: string, area: string): boolean;
|
|
153
|
+
/**
|
|
154
|
+
* Whether `key` lies in one of `areas` — the scope a reviewer may be limited to
|
|
155
|
+
* (keksdose: `["legal"]`, a lawyer who judges the Imprint, the Privacy Policy and the
|
|
156
|
+
* Terms and sees nothing else). `null`/`undefined` is every area. The server applies the
|
|
157
|
+
* same rule and refuses verdicts outside; this decides only what is listed.
|
|
158
|
+
*/
|
|
159
|
+
declare function keyInAreas(key: string, areas: readonly string[] | null | undefined, inArea?: (key: string, area: string) => boolean): boolean;
|
|
160
|
+
interface TranslationRowsInput {
|
|
161
|
+
locale: string;
|
|
162
|
+
/** The locale's strings, `key → text` ({@link flattenStrings}). */
|
|
163
|
+
strings: Readonly<Record<string, string>>;
|
|
164
|
+
/** The reference locale's strings — whichever locale THIS app reads `locale` against. */
|
|
165
|
+
reference: Readonly<Record<string, string>>;
|
|
166
|
+
/** The verdicts: this locale's by key, or the whole list as the server sent it (other
|
|
167
|
+
* locales' verdicts are skipped). */
|
|
168
|
+
reviews?: ReadonlyMap<string, TranslationReview> | readonly TranslationReview[];
|
|
169
|
+
/** Default {@link keyNamespace}. */
|
|
170
|
+
namespaceOf?: (key: string) => string;
|
|
171
|
+
/** The row's source, an app-defined word (kastlan: `doc_text.` keys are `documents`,
|
|
172
|
+
* the rest `screen`). Default: none (`""`). */
|
|
173
|
+
sourceOf?: (key: string) => string;
|
|
174
|
+
/** Keep only keys inside these areas ({@link keyInAreas}); `null` keeps every key. */
|
|
175
|
+
areas?: readonly string[] | null;
|
|
176
|
+
/** Default {@link keyInArea}. */
|
|
177
|
+
inArea?: (key: string, area: string) => boolean;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Every key of one locale, with its status: the reference's keys first, in its order, so
|
|
181
|
+
* rows read the way the app's authors wrote them; a key only this locale has is appended
|
|
182
|
+
* rather than dropped (a parity test should forbid it, but the page should not lie if one
|
|
183
|
+
* slips through).
|
|
184
|
+
*/
|
|
185
|
+
declare function translationRows({ locale, strings, reference, reviews, namespaceOf: groupOf, sourceOf, areas, inArea, }: TranslationRowsInput): TranslationRow[];
|
|
186
|
+
/** How many rows stand where, and how many there are. */
|
|
187
|
+
type TranslationSummary = Record<ReviewStatus, number> & {
|
|
188
|
+
total: number;
|
|
189
|
+
};
|
|
190
|
+
declare function summariseRows(rows: readonly TranslationRow[]): TranslationSummary;
|
|
191
|
+
/** The panel's filters. `"all"` switches one off. */
|
|
192
|
+
interface TranslationReviewFilter {
|
|
193
|
+
status: ReviewStatus | "all";
|
|
194
|
+
source: string;
|
|
195
|
+
namespace: string;
|
|
196
|
+
/** Searched in the key, the text and the reference, case-insensitively. */
|
|
197
|
+
query: string;
|
|
198
|
+
placeholdersOnly: boolean;
|
|
199
|
+
}
|
|
200
|
+
declare const DEFAULT_TRANSLATION_REVIEW_FILTER: TranslationReviewFilter;
|
|
201
|
+
declare function filterTranslationRows(rows: readonly TranslationRow[], filter: Partial<TranslationReviewFilter>): TranslationRow[];
|
|
202
|
+
/** What gets stored for a verdict: the wording it was given on, so a later change of the
|
|
203
|
+
* string (or of its reference) shows up as `changed`. */
|
|
204
|
+
declare function reviewWrite(row: TranslationRow, verdict: TranslationVerdict, note?: string | null, suggestion?: string | null): TranslationReviewWrite;
|
|
205
|
+
/** The app's cached verdict list with `written` in place of what was said before — a
|
|
206
|
+
* save answers with the rows it wrote, so a page patches its copy instead of
|
|
207
|
+
* downloading every verdict again after each click. Any spelling with `locale` and
|
|
208
|
+
* `key` (the API's or the kit's). */
|
|
209
|
+
declare function mergeReviews<T extends TranslationReviewKey>(prev: readonly T[], written: readonly T[]): T[];
|
|
210
|
+
/** The cached list without the verdicts on `cleared`. */
|
|
211
|
+
declare function dropReviews<T extends TranslationReviewKey>(prev: readonly T[], cleared: readonly TranslationReviewKey[]): T[];
|
|
212
|
+
/**
|
|
213
|
+
* One entry of the "Export corrections" file — enough for a developer to apply the change
|
|
214
|
+
* to the bundle without opening the page. snake_case `reviewed_at` on purpose: this is a
|
|
215
|
+
* FILE, the one keksdose and kastlan already write, beside an API spelled that way.
|
|
216
|
+
*/
|
|
217
|
+
interface TranslationCorrection {
|
|
218
|
+
locale: string;
|
|
219
|
+
key: string;
|
|
220
|
+
status: ReviewStatus;
|
|
221
|
+
current: string;
|
|
222
|
+
reference: string;
|
|
223
|
+
suggestion: string | null;
|
|
224
|
+
note: string | null;
|
|
225
|
+
reviewer: string | null;
|
|
226
|
+
reviewed_at: string | null;
|
|
227
|
+
}
|
|
228
|
+
/** The rows sent back (verdict NEEDS_CHANGE — a suggested translation for a missing
|
|
229
|
+
* string included), as the export file lists them. */
|
|
230
|
+
declare function translationCorrections(rows: readonly TranslationRow[]): TranslationCorrection[];
|
|
231
|
+
|
|
232
|
+
export { type ApiTranslationReview, type ApiTranslationReviewWrite, DEFAULT_TRANSLATION_REVIEW_FILTER, REVIEW_STATUSES, type ReviewStatus, type TranslationCorrection, type TranslationReview, type TranslationReviewFilter, type TranslationReviewKey, type TranslationReviewWrite, type TranslationRow, type TranslationRowsInput, type TranslationSummary, type TranslationVerdict, dropReviews, filterTranslationRows, flattenStrings, fromApiReview, keyInArea, keyInAreas, keyNamespace, mergeReviews, placeholderMismatch, placeholderTokens, reviewStatus, reviewWrite, summariseRows, toApiWrite, translationCorrections, translationRows };
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
const REVIEW_STATUSES = [
|
|
3
|
+
"missing",
|
|
4
|
+
"unreviewed",
|
|
5
|
+
"changed",
|
|
6
|
+
"needs_change",
|
|
7
|
+
"approved"
|
|
8
|
+
];
|
|
9
|
+
function fromApiReview(review) {
|
|
10
|
+
return {
|
|
11
|
+
locale: review.locale,
|
|
12
|
+
key: review.key,
|
|
13
|
+
text: review.text,
|
|
14
|
+
referenceText: review.reference_text,
|
|
15
|
+
verdict: review.verdict,
|
|
16
|
+
note: review.note,
|
|
17
|
+
suggestion: review.suggestion,
|
|
18
|
+
reviewerName: review.reviewer_name,
|
|
19
|
+
reviewedAt: review.reviewed_at
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function toApiWrite(write) {
|
|
23
|
+
return {
|
|
24
|
+
locale: write.locale,
|
|
25
|
+
key: write.key,
|
|
26
|
+
text: write.text,
|
|
27
|
+
reference_text: write.referenceText,
|
|
28
|
+
verdict: write.verdict,
|
|
29
|
+
note: write.note,
|
|
30
|
+
suggestion: write.suggestion
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function flattenStrings(tree, prefix = "") {
|
|
34
|
+
const out = {};
|
|
35
|
+
for (const [key, value] of Object.entries(tree)) {
|
|
36
|
+
const path = `${prefix}${key}`;
|
|
37
|
+
if (typeof value === "string") out[path] = value;
|
|
38
|
+
else if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
39
|
+
Object.assign(out, flattenStrings(value, `${path}.`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
function keyNamespace(key) {
|
|
45
|
+
const head = key.split("(", 1)[0];
|
|
46
|
+
const colon = head.indexOf(":");
|
|
47
|
+
return colon >= 0 ? head.slice(0, colon) : head.split(".", 1)[0];
|
|
48
|
+
}
|
|
49
|
+
const PLACEHOLDER_RE = /\{\{\s*([^}\s,]+)[^}]*\}\}|(?<![\\A-Za-z])\{([a-z_][a-z0-9_]*|\d+)(?:[!:][^{}]*)?\}|<\/?(\d+|[a-z][a-z0-9]*)\s*\/?>|\$t\(([^)]+)\)/g;
|
|
50
|
+
function placeholderTokens(text) {
|
|
51
|
+
const found = [];
|
|
52
|
+
for (const m of text.matchAll(PLACEHOLDER_RE)) {
|
|
53
|
+
if (m[1]) found.push(`{{${m[1]}}}`);
|
|
54
|
+
else if (m[2]) found.push(`{${m[2]}}`);
|
|
55
|
+
else if (m[3]) found.push(`<${m[3]}>`);
|
|
56
|
+
else if (m[4]) found.push(`$t(${m[4]})`);
|
|
57
|
+
}
|
|
58
|
+
return found.sort();
|
|
59
|
+
}
|
|
60
|
+
function placeholderMismatch(text, reference) {
|
|
61
|
+
const a = placeholderTokens(text);
|
|
62
|
+
const b = placeholderTokens(reference);
|
|
63
|
+
return a.length !== b.length || a.some((p, i) => p !== b[i]);
|
|
64
|
+
}
|
|
65
|
+
function reviewStatus(review, text, reference) {
|
|
66
|
+
if (!review) return text === "" && reference !== "" ? "missing" : "unreviewed";
|
|
67
|
+
if (review.text !== text) return "changed";
|
|
68
|
+
if (review.referenceText != null && review.referenceText !== reference) return "changed";
|
|
69
|
+
return review.verdict === "APPROVED" ? "approved" : "needs_change";
|
|
70
|
+
}
|
|
71
|
+
function keyInArea(key, area) {
|
|
72
|
+
return key === area || key.startsWith(`${area}.`) || key.startsWith(`${area}:`);
|
|
73
|
+
}
|
|
74
|
+
function keyInAreas(key, areas, inArea = keyInArea) {
|
|
75
|
+
return !areas || areas.some((area) => inArea(key, area));
|
|
76
|
+
}
|
|
77
|
+
function translationRows({
|
|
78
|
+
locale,
|
|
79
|
+
strings,
|
|
80
|
+
reference,
|
|
81
|
+
reviews,
|
|
82
|
+
namespaceOf: groupOf = keyNamespace,
|
|
83
|
+
sourceOf,
|
|
84
|
+
areas,
|
|
85
|
+
inArea
|
|
86
|
+
}) {
|
|
87
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
88
|
+
if (reviews instanceof Map) {
|
|
89
|
+
for (const [key, review] of reviews) byKey.set(key, review);
|
|
90
|
+
} else if (reviews) {
|
|
91
|
+
for (const review of reviews) {
|
|
92
|
+
if (review.locale === locale) byKey.set(review.key, review);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(reference);
|
|
96
|
+
for (const key of Object.keys(strings)) if (!(key in reference)) keys.push(key);
|
|
97
|
+
return keys.filter((key) => keyInAreas(key, areas, inArea)).map((key) => {
|
|
98
|
+
const text = strings[key] ?? "";
|
|
99
|
+
const ref = reference[key] ?? "";
|
|
100
|
+
const review = byKey.get(key) ?? null;
|
|
101
|
+
return {
|
|
102
|
+
id: `${locale}|${key}`,
|
|
103
|
+
key,
|
|
104
|
+
namespace: groupOf(key),
|
|
105
|
+
source: sourceOf?.(key) ?? "",
|
|
106
|
+
locale,
|
|
107
|
+
text,
|
|
108
|
+
reference: ref,
|
|
109
|
+
status: reviewStatus(review, text, ref),
|
|
110
|
+
review,
|
|
111
|
+
placeholderMismatch: text !== "" && placeholderMismatch(text, ref)
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
function summariseRows(rows) {
|
|
116
|
+
const summary = {
|
|
117
|
+
missing: 0,
|
|
118
|
+
unreviewed: 0,
|
|
119
|
+
changed: 0,
|
|
120
|
+
needs_change: 0,
|
|
121
|
+
approved: 0,
|
|
122
|
+
total: rows.length
|
|
123
|
+
};
|
|
124
|
+
for (const row of rows) summary[row.status] += 1;
|
|
125
|
+
return summary;
|
|
126
|
+
}
|
|
127
|
+
const DEFAULT_TRANSLATION_REVIEW_FILTER = {
|
|
128
|
+
status: "all",
|
|
129
|
+
source: "all",
|
|
130
|
+
namespace: "all",
|
|
131
|
+
query: "",
|
|
132
|
+
placeholdersOnly: false
|
|
133
|
+
};
|
|
134
|
+
function filterTranslationRows(rows, filter) {
|
|
135
|
+
const f = { ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...filter };
|
|
136
|
+
const q = f.query.trim().toLowerCase();
|
|
137
|
+
return rows.filter(
|
|
138
|
+
(row) => (f.status === "all" || row.status === f.status) && (f.source === "all" || row.source === f.source) && (f.namespace === "all" || row.namespace === f.namespace) && (!f.placeholdersOnly || row.placeholderMismatch) && (!q || row.key.toLowerCase().includes(q) || row.text.toLowerCase().includes(q) || row.reference.toLowerCase().includes(q))
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
function reviewWrite(row, verdict, note = null, suggestion = null) {
|
|
142
|
+
return {
|
|
143
|
+
locale: row.locale,
|
|
144
|
+
key: row.key,
|
|
145
|
+
text: row.text,
|
|
146
|
+
referenceText: row.reference,
|
|
147
|
+
verdict,
|
|
148
|
+
note,
|
|
149
|
+
suggestion
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function mergeReviews(prev, written) {
|
|
153
|
+
const id = (r) => `${r.locale}|${r.key}`;
|
|
154
|
+
const replaced = new Set(written.map(id));
|
|
155
|
+
return [...prev.filter((r) => !replaced.has(id(r))), ...written];
|
|
156
|
+
}
|
|
157
|
+
function dropReviews(prev, cleared) {
|
|
158
|
+
const gone = new Set(cleared.map((r) => `${r.locale}|${r.key}`));
|
|
159
|
+
return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));
|
|
160
|
+
}
|
|
161
|
+
function translationCorrections(rows) {
|
|
162
|
+
return rows.filter((row) => row.review?.verdict === "NEEDS_CHANGE").map((row) => ({
|
|
163
|
+
locale: row.locale,
|
|
164
|
+
key: row.key,
|
|
165
|
+
status: row.status,
|
|
166
|
+
current: row.text,
|
|
167
|
+
reference: row.reference,
|
|
168
|
+
suggestion: row.review?.suggestion ?? null,
|
|
169
|
+
note: row.review?.note ?? null,
|
|
170
|
+
reviewer: row.review?.reviewerName ?? null,
|
|
171
|
+
reviewed_at: row.review?.reviewedAt ?? null
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
export {
|
|
175
|
+
DEFAULT_TRANSLATION_REVIEW_FILTER,
|
|
176
|
+
REVIEW_STATUSES,
|
|
177
|
+
dropReviews,
|
|
178
|
+
filterTranslationRows,
|
|
179
|
+
flattenStrings,
|
|
180
|
+
fromApiReview,
|
|
181
|
+
keyInArea,
|
|
182
|
+
keyInAreas,
|
|
183
|
+
keyNamespace,
|
|
184
|
+
mergeReviews,
|
|
185
|
+
placeholderMismatch,
|
|
186
|
+
placeholderTokens,
|
|
187
|
+
reviewStatus,
|
|
188
|
+
reviewWrite,
|
|
189
|
+
summariseRows,
|
|
190
|
+
toApiWrite,
|
|
191
|
+
translationCorrections,
|
|
192
|
+
translationRows
|
|
193
|
+
};
|
|
194
|
+
//# sourceMappingURL=translation-review.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/lib/translation-review.ts"],"sourcesContent":["/**\n * The pure half of an app's translation review (H1 in docs/i18n-harmonization.md): from\n * the strings the app ships and the verdicts its server stores to one row per (locale,\n * key) with a status — keksdose's `features/translations/translation-catalogue.ts` and\n * kastlan's copy of it, which had grown apart by a fifth status and a second kind of key.\n *\n * WHY THE STRINGS ARE THE CATALOGUE. Nothing on the server lists which strings exist: a\n * verdict only says what somebody thought of one. So the rows are built from what the app\n * ships — its locale bundles, kastlan's document texts, the kit's own words\n * (`kitLabelStrings`) — and the review can never offer a string users do not see. A\n * verdict is stored WITH the wording it was given on, and a string that changed since is\n * noticed by comparing the two: `changed`, read it again, whatever the verdict was.\n *\n * WHAT STAYS IN THE APP. Which locale is read against which (keksdose: everything against\n * English, English against de-CH; kastlan: against German, German against English), where\n * the strings come from, who may review what (the locales and areas its server answers\n * with), and every request. The kit takes those as input and never derives them.\n *\n * camelCase, like the rest of the kit. The contract's wire shape is snake_case\n * (`reference_text`, `reviewer_name`, `reviewed_at`); {@link fromApiReview} and\n * {@link toApiWrite} are the two lines each app would otherwise write — both apps speak\n * the same contract, so the mapping is written once, here.\n */\n\n// ── Shapes ──────────────────────────────────────────────────────────────────────\n\nexport type TranslationVerdict = \"APPROVED\" | \"NEEDS_CHANGE\";\n\n/** One stored verdict: what a reviewer said about one string in one locale. */\nexport interface TranslationReview {\n locale: string;\n key: string;\n /** The wording that was judged — compared with today's to notice a change. Empty for\n * a string the locale lacked (a suggested translation for a `missing` row). */\n text: string;\n /** The reference wording at the time. `null` from a server that did not store it. */\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n /** The reviewer's wording — or, for a missing string, the translation to add. */\n suggestion: string | null;\n /** `null` once the reviewer's account has been erased. */\n reviewerName: string | null;\n /** ISO timestamp. */\n reviewedAt: string;\n}\n\n/** What a save sends: the verdict plus the wording it is given on. */\nexport interface TranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n referenceText: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** What a reset sends: back to \"unreviewed\". */\nexport interface TranslationReviewKey {\n locale: string;\n key: string;\n}\n\n/**\n * Where a string stands, in the order a reviewer works through them: what is not there\n * at all, what nobody has read, what changed after it was read, what was sent back, and\n * what is done. `missing` never occurs for an app whose locales have every key\n * (keksdose); kastlan's French lacks 172 screen texts.\n */\nexport type ReviewStatus = \"missing\" | \"unreviewed\" | \"changed\" | \"needs_change\" | \"approved\";\n\nexport const REVIEW_STATUSES: readonly ReviewStatus[] = [\n \"missing\",\n \"unreviewed\",\n \"changed\",\n \"needs_change\",\n \"approved\",\n];\n\nexport interface TranslationRow {\n /** `${locale}|${key}` — unique across every locale on a page. A bar, not a colon:\n * kastlan's keys contain colons (`common:actions.save`). */\n id: string;\n key: string;\n /** The key's group: `budget` for `budget.rta`, `common` for `common:actions.save`,\n * `kit` for the kit's own words. See {@link keyNamespace}. */\n namespace: string;\n /** Where the string comes from, in the app's own words — kastlan's `screen` and\n * `documents`, keksdose's one source. `\"\"` when the app does not say. */\n source: string;\n locale: string;\n /** The wording users see today; empty when the locale lacks the string. */\n text: string;\n /** The same key in the reference locale. */\n reference: string;\n status: ReviewStatus;\n review: TranslationReview | null;\n /** The interpolations and tags differ from the reference's — `{{count}}` dropped, a\n * `<1>` link lost, a `{date}` renamed. A machine can check that much, so it does,\n * before a person reads. Never set on a missing string: it has nothing to lose. */\n placeholderMismatch: boolean;\n}\n\n// ── The wire contract ───────────────────────────────────────────────────────────\n\n/** A verdict as `GET /translations/reviews` returns it (H1's shared contract). Typed\n * structurally, so an app's generated OpenAPI type fits without a cast. */\nexport interface ApiTranslationReview {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n reviewer_name: string | null;\n reviewed_at: string;\n}\n\n/** One item of `PUT /translations/reviews`. */\nexport interface ApiTranslationReviewWrite {\n locale: string;\n key: string;\n text: string;\n reference_text: string | null;\n verdict: TranslationVerdict;\n note: string | null;\n suggestion: string | null;\n}\n\n/** A verdict from the server, in the kit's spelling. */\nexport function fromApiReview(review: ApiTranslationReview): TranslationReview {\n return {\n locale: review.locale,\n key: review.key,\n text: review.text,\n referenceText: review.reference_text,\n verdict: review.verdict,\n note: review.note,\n suggestion: review.suggestion,\n reviewerName: review.reviewer_name,\n reviewedAt: review.reviewed_at,\n };\n}\n\n/** A save, in the server's spelling. `TranslationReviewKey` needs no mapping — it is\n * `{ locale, key }` on both sides. */\nexport function toApiWrite(write: TranslationReviewWrite): ApiTranslationReviewWrite {\n return {\n locale: write.locale,\n key: write.key,\n text: write.text,\n reference_text: write.referenceText,\n verdict: write.verdict,\n note: write.note,\n suggestion: write.suggestion,\n };\n}\n\n// ── Strings ─────────────────────────────────────────────────────────────────────\n\n/**\n * A nested bundle as `key → string`, in the bundle's own order. Non-string leaves are\n * skipped rather than stringified into something nobody wrote.\n *\n * `prefix` is put in front of every key AS IS, so it names the separator too:\n * `flattenStrings(bundle)` is keksdose's `budget.rta`, `flattenStrings(tree, \"common:\")`\n * kastlan's `common:actions.save`, and `flattenStrings(kitLabelStrings(labels), \"kit.\")`\n * the kit's own words as `kit.combobox.resultCount(3)` — already flat, so only the prefix\n * is added.\n */\nexport function flattenStrings(tree: Readonly<Record<string, unknown>>, prefix = \"\"): Record<string, string> {\n const out: Record<string, string> = {};\n for (const [key, value] of Object.entries(tree)) {\n const path = `${prefix}${key}`;\n if (typeof value === \"string\") out[path] = value;\n else if (value && typeof value === \"object\" && !Array.isArray(value)) {\n Object.assign(out, flattenStrings(value as Record<string, unknown>, `${path}.`));\n }\n }\n return out;\n}\n\n/**\n * The key's group, for the namespace filter: the part before a colon when there is one\n * (kastlan's `common:actions.save` → `common`, `doc_text.invoice:due` →\n * `doc_text.invoice`), else the first dotted segment (keksdose's `budget.rta` →\n * `budget`, the kit's `kit.combobox.resultCount(3)` → `kit`). Only the part before a\n * sample's `(` is looked at, so a colon inside one cannot move the split.\n */\nexport function keyNamespace(key: string): string {\n const head = key.split(\"(\", 1)[0];\n const colon = head.indexOf(\":\");\n return colon >= 0 ? head.slice(0, colon) : head.split(\".\", 1)[0];\n}\n\n// i18next `{{x}}` (formatting options ignored: `{{x, number}}` is `{{x}}`); a Python\n// format field `{x}` / `{0}` / `{amount:.2f}` (kastlan's document texts), but not a\n// LaTeX argument (`\\textbf{x}` — a letter or backslash right before the brace); a\n// numbered or named tag (`<1>`, `<strong>`), not prose in angle brackets (\"<deleted\n// user>\"); and a nested `$t(key)`. `{{x}}` is tried first at its position and consumed\n// whole, so it is never counted a second time as `{x}`.\nconst PLACEHOLDER_RE =\n /\\{\\{\\s*([^}\\s,]+)[^}]*\\}\\}|(?<![\\\\A-Za-z])\\{([a-z_][a-z0-9_]*|\\d+)(?:[!:][^{}]*)?\\}|<\\/?(\\d+|[a-z][a-z0-9]*)\\s*\\/?>|\\$t\\(([^)]+)\\)/g;\n\n/** The placeholders in a string, as a sorted multiset: `{{count}}`, `{date}`, `<1>`,\n * `$t(common.save)`. */\nexport function placeholderTokens(text: string): string[] {\n const found: string[] = [];\n for (const m of text.matchAll(PLACEHOLDER_RE)) {\n if (m[1]) found.push(`{{${m[1]}}}`);\n else if (m[2]) found.push(`{${m[2]}}`);\n else if (m[3]) found.push(`<${m[3]}>`);\n else if (m[4]) found.push(`$t(${m[4]})`);\n }\n return found.sort();\n}\n\n/** Whether `text` has other placeholders than `reference` — dropped, renamed or added.\n * Moving one is fine: a translation reorders a sentence. */\nexport function placeholderMismatch(text: string, reference: string): boolean {\n const a = placeholderTokens(text);\n const b = placeholderTokens(reference);\n return a.length !== b.length || a.some((p, i) => p !== b[i]);\n}\n\n// ── Status ──────────────────────────────────────────────────────────────────────\n\n/**\n * A verdict counts only for the wording it was given on. If the string or its reference\n * changed since, it is `changed` — read it again — whatever the verdict was. A string the\n * locale lacks is `missing` until somebody suggests its translation (stored as\n * NEEDS_CHANGE on the empty text, so it reads `needs_change`, and `changed` once a\n * developer adds the string).\n */\nexport function reviewStatus(\n review: TranslationReview | null | undefined,\n text: string,\n reference: string,\n): ReviewStatus {\n if (!review) return text === \"\" && reference !== \"\" ? \"missing\" : \"unreviewed\";\n if (review.text !== text) return \"changed\";\n if (review.referenceText != null && review.referenceText !== reference) return \"changed\";\n return review.verdict === \"APPROVED\" ? \"approved\" : \"needs_change\";\n}\n\n/** Whether `key` lies in `area`: the key IS the area, or sits under it — `legal.terms`\n * and kastlan's `legal:terms` are in `legal`, `legalese.title` is not. */\nexport function keyInArea(key: string, area: string): boolean {\n return key === area || key.startsWith(`${area}.`) || key.startsWith(`${area}:`);\n}\n\n/**\n * Whether `key` lies in one of `areas` — the scope a reviewer may be limited to\n * (keksdose: `[\"legal\"]`, a lawyer who judges the Imprint, the Privacy Policy and the\n * Terms and sees nothing else). `null`/`undefined` is every area. The server applies the\n * same rule and refuses verdicts outside; this decides only what is listed.\n */\nexport function keyInAreas(\n key: string,\n areas: readonly string[] | null | undefined,\n inArea: (key: string, area: string) => boolean = keyInArea,\n): boolean {\n return !areas || areas.some((area) => inArea(key, area));\n}\n\nexport interface TranslationRowsInput {\n locale: string;\n /** The locale's strings, `key → text` ({@link flattenStrings}). */\n strings: Readonly<Record<string, string>>;\n /** The reference locale's strings — whichever locale THIS app reads `locale` against. */\n reference: Readonly<Record<string, string>>;\n /** The verdicts: this locale's by key, or the whole list as the server sent it (other\n * locales' verdicts are skipped). */\n reviews?: ReadonlyMap<string, TranslationReview> | readonly TranslationReview[];\n /** Default {@link keyNamespace}. */\n namespaceOf?: (key: string) => string;\n /** The row's source, an app-defined word (kastlan: `doc_text.` keys are `documents`,\n * the rest `screen`). Default: none (`\"\"`). */\n sourceOf?: (key: string) => string;\n /** Keep only keys inside these areas ({@link keyInAreas}); `null` keeps every key. */\n areas?: readonly string[] | null;\n /** Default {@link keyInArea}. */\n inArea?: (key: string, area: string) => boolean;\n}\n\n/**\n * Every key of one locale, with its status: the reference's keys first, in its order, so\n * rows read the way the app's authors wrote them; a key only this locale has is appended\n * rather than dropped (a parity test should forbid it, but the page should not lie if one\n * slips through).\n */\nexport function translationRows({\n locale,\n strings,\n reference,\n reviews,\n namespaceOf: groupOf = keyNamespace,\n sourceOf,\n areas,\n inArea,\n}: TranslationRowsInput): TranslationRow[] {\n const byKey = new Map<string, TranslationReview>();\n if (reviews instanceof Map) {\n for (const [key, review] of reviews) byKey.set(key, review);\n } else if (reviews) {\n for (const review of reviews as readonly TranslationReview[]) {\n if (review.locale === locale) byKey.set(review.key, review);\n }\n }\n const keys = Object.keys(reference);\n for (const key of Object.keys(strings)) if (!(key in reference)) keys.push(key);\n return keys\n .filter((key) => keyInAreas(key, areas, inArea))\n .map((key) => {\n const text = strings[key] ?? \"\";\n const ref = reference[key] ?? \"\";\n const review = byKey.get(key) ?? null;\n return {\n id: `${locale}|${key}`,\n key,\n namespace: groupOf(key),\n source: sourceOf?.(key) ?? \"\",\n locale,\n text,\n reference: ref,\n status: reviewStatus(review, text, ref),\n review,\n placeholderMismatch: text !== \"\" && placeholderMismatch(text, ref),\n };\n });\n}\n\n/** How many rows stand where, and how many there are. */\nexport type TranslationSummary = Record<ReviewStatus, number> & { total: number };\n\nexport function summariseRows(rows: readonly TranslationRow[]): TranslationSummary {\n const summary: TranslationSummary = {\n missing: 0,\n unreviewed: 0,\n changed: 0,\n needs_change: 0,\n approved: 0,\n total: rows.length,\n };\n for (const row of rows) summary[row.status] += 1;\n return summary;\n}\n\n// ── Filtering ───────────────────────────────────────────────────────────────────\n\n/** The panel's filters. `\"all\"` switches one off. */\nexport interface TranslationReviewFilter {\n status: ReviewStatus | \"all\";\n source: string;\n namespace: string;\n /** Searched in the key, the text and the reference, case-insensitively. */\n query: string;\n placeholdersOnly: boolean;\n}\n\nexport const DEFAULT_TRANSLATION_REVIEW_FILTER: TranslationReviewFilter = {\n status: \"all\",\n source: \"all\",\n namespace: \"all\",\n query: \"\",\n placeholdersOnly: false,\n};\n\nexport function filterTranslationRows(\n rows: readonly TranslationRow[],\n filter: Partial<TranslationReviewFilter>,\n): TranslationRow[] {\n const f = { ...DEFAULT_TRANSLATION_REVIEW_FILTER, ...filter };\n const q = f.query.trim().toLowerCase();\n return rows.filter(\n (row) =>\n (f.status === \"all\" || row.status === f.status) &&\n (f.source === \"all\" || row.source === f.source) &&\n (f.namespace === \"all\" || row.namespace === f.namespace) &&\n (!f.placeholdersOnly || row.placeholderMismatch) &&\n (!q ||\n row.key.toLowerCase().includes(q) ||\n row.text.toLowerCase().includes(q) ||\n row.reference.toLowerCase().includes(q)),\n );\n}\n\n// ── Writing ─────────────────────────────────────────────────────────────────────\n\n/** What gets stored for a verdict: the wording it was given on, so a later change of the\n * string (or of its reference) shows up as `changed`. */\nexport function reviewWrite(\n row: TranslationRow,\n verdict: TranslationVerdict,\n note: string | null = null,\n suggestion: string | null = null,\n): TranslationReviewWrite {\n return {\n locale: row.locale,\n key: row.key,\n text: row.text,\n referenceText: row.reference,\n verdict,\n note,\n suggestion,\n };\n}\n\n/** The app's cached verdict list with `written` in place of what was said before — a\n * save answers with the rows it wrote, so a page patches its copy instead of\n * downloading every verdict again after each click. Any spelling with `locale` and\n * `key` (the API's or the kit's). */\nexport function mergeReviews<T extends TranslationReviewKey>(prev: readonly T[], written: readonly T[]): T[] {\n const id = (r: TranslationReviewKey) => `${r.locale}|${r.key}`;\n const replaced = new Set(written.map(id));\n return [...prev.filter((r) => !replaced.has(id(r))), ...written];\n}\n\n/** The cached list without the verdicts on `cleared`. */\nexport function dropReviews<T extends TranslationReviewKey>(\n prev: readonly T[],\n cleared: readonly TranslationReviewKey[],\n): T[] {\n const gone = new Set(cleared.map((r) => `${r.locale}|${r.key}`));\n return prev.filter((r) => !gone.has(`${r.locale}|${r.key}`));\n}\n\n// ── Export ──────────────────────────────────────────────────────────────────────\n\n/**\n * One entry of the \"Export corrections\" file — enough for a developer to apply the change\n * to the bundle without opening the page. snake_case `reviewed_at` on purpose: this is a\n * FILE, the one keksdose and kastlan already write, beside an API spelled that way.\n */\nexport interface TranslationCorrection {\n locale: string;\n key: string;\n status: ReviewStatus;\n current: string;\n reference: string;\n suggestion: string | null;\n note: string | null;\n reviewer: string | null;\n reviewed_at: string | null;\n}\n\n/** The rows sent back (verdict NEEDS_CHANGE — a suggested translation for a missing\n * string included), as the export file lists them. */\nexport function translationCorrections(rows: readonly TranslationRow[]): TranslationCorrection[] {\n return rows\n .filter((row) => row.review?.verdict === \"NEEDS_CHANGE\")\n .map((row) => ({\n locale: row.locale,\n key: row.key,\n status: row.status,\n current: row.text,\n reference: row.reference,\n suggestion: row.review?.suggestion ?? null,\n note: row.review?.note ?? null,\n reviewer: row.review?.reviewerName ?? null,\n reviewed_at: row.review?.reviewedAt ?? null,\n }));\n}\n"],"mappings":"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","names":[]}
|