@walkinissue/angy 0.2.17 → 0.2.18

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.
@@ -1,24 +1,32 @@
1
- <!-- @wc-ignore-file -->
1
+ <!-- @wc-ignore-file -->
2
2
  <script lang="ts">
3
3
  import { browser, dev } from "$app/environment";
4
4
  import { page } from "$app/state";
5
5
  import { loadLocale } from "wuchale/load-utils";
6
- import { onMount } from "svelte";
7
- import { draggable } from "./dragItem";
8
- import PendingChangesDialog from "./PendingChangesDialog.svelte";
9
- import TranslationHelperForm from "./TranslationHelperForm.svelte";
10
- import {
11
- readSuggestionCache,
12
- requestTranslationSuggestions
13
- } from "./translationSuggestions";
6
+ import { onMount } from "svelte";
7
+ import { draggable } from "./dragItem";
8
+ import PendingChangesDialog from "./PendingChangesDialog.svelte";
9
+ import RotationWarningDialog from "./RotationWarningDialog.svelte";
10
+ import TranslationHelperForm from "./TranslationHelperForm.svelte";
11
+ import {
12
+ clearDraftValue,
13
+ getDraftValue,
14
+ readDraftCache,
15
+ setDraftValue,
16
+ type DraftCacheItem
17
+ } from "./translationDrafts";
18
+ import {
19
+ readSuggestionCache,
20
+ requestTranslationSuggestions
21
+ } from "./translationSuggestions";
14
22
  import {
15
23
  getCandidateList,
16
- getEffectiveResolvedKey,
17
- getEntryOrigin,
18
- getEntryOriginalValue,
19
- sameResolvedKey,
20
- translationKey,
21
- type DraftTranslation,
24
+ getEffectiveResolvedKey,
25
+ getEntryOrigin,
26
+ getEntryOriginalValue,
27
+ sameResolvedKey,
28
+ translationKey,
29
+ type DraftTranslation,
22
30
  type ResolvedKey,
23
31
  type TranslationContextResult
24
32
  } from "./toggleQA.shared";
@@ -27,6 +35,8 @@
27
35
  __ANGY_ROUTE_PATH__?: string;
28
36
  __ANGY_LOCALES__?: string[];
29
37
  };
38
+ const LOCALE_STORAGE_KEY = "angy:locale";
39
+ const LOCALE_COOKIE_KEY = "locale";
30
40
 
31
41
  const defaultEndpoint =
32
42
  typeof runtimeConfig.__ANGY_ROUTE_PATH__ !== "undefined"
@@ -40,109 +50,175 @@
40
50
  let { endpoint = defaultEndpoint } = $props<{
41
51
  endpoint?: string;
42
52
  }>();
43
-
44
- let dragQua = $state(true);
45
- let selectedResolvedKey = $state<ResolvedKey | null>(null);
46
- let focusedAltKey = $state<string | null>(null);
47
-
48
- let contextPending = $state(false);
49
- let contextError = $state<string | null>(null);
50
- let contextResult = $state<TranslationContextResult | null>(null);
51
-
52
- let stagedTranslations = $state<Record<string, DraftTranslation>>({});
53
-
53
+
54
+ let dragQua = $state(true);
55
+ let selectedResolvedKey = $state<ResolvedKey | null>(null);
56
+ let focusedAltKey = $state<string | null>(null);
57
+
58
+ let contextPending = $state(false);
59
+ let contextError = $state<string | null>(null);
60
+ let contextResult = $state<TranslationContextResult | null>(null);
61
+
62
+ let stagedTranslations = $state<Record<string, DraftTranslation>>({});
63
+ let draftLookup = $state<Record<string, DraftCacheItem>>(readDraftCache());
64
+
54
65
  let currentLocale = $state(defaultLocales);
55
66
  let locale = 0;
56
-
57
- let activeSwitchLocale = $state(true);
58
- let selectionStarted = false;
59
- let capturedSelection: string | undefined = $state(undefined);
60
- let spawnTranslation = $state(false);
61
- let translatedValue = $state("");
62
-
67
+
68
+ let activeSwitchLocale = $state(false);
69
+ let selectionStarted = false;
70
+ let capturedSelection: string | undefined = $state(undefined);
71
+ let spawnTranslation = $state(false);
72
+ let translatedValue = $state("");
73
+
63
74
  let pending = $state(false);
64
75
  let rotatePending = $state(false);
65
76
  let error = $state<string | null>(null);
66
77
  let success = $state<string | null>(null);
67
78
  let renderedLocale = $state<string>(currentLocale[locale] ?? "en");
68
- let showPendingChangesDialog = $state(false);
69
- let suggestionLookup = $state<Record<string, string>>(readSuggestionCache());
70
- let suggestionPending = $state(false);
71
-
72
- let panelEl: HTMLDivElement | null = null;
73
- let translationInputEl: HTMLTextAreaElement | null = null;
74
-
75
- function setTranslationInputEl(element: HTMLTextAreaElement | null) {
76
- translationInputEl = element;
77
- }
78
-
79
- function getLocale(increase = true) {
80
- if (increase) {
81
- locale = locale >= currentLocale.length - 1 ? 0 : locale + 1;
82
- }
83
-
84
- renderedLocale = currentLocale[locale];
85
- return currentLocale[locale];
86
- }
87
-
79
+ let showPendingChangesDialog = $state(false);
80
+ let showRotationWarningDialog = $state(false);
81
+ let rotationImpact = $state<
82
+ Array<{ msgid: string; msgctxt: string | null; baseValue: string; workingValue: string }>
83
+ >([]);
84
+ let suggestionLookup = $state<Record<string, string>>(readSuggestionCache());
85
+ let suggestionPending = $state(false);
86
+
87
+ let panelEl: HTMLDivElement | null = null;
88
+ let translationInputEl: HTMLTextAreaElement | null = null;
89
+
90
+ function setTranslationInputEl(element: HTMLTextAreaElement | null) {
91
+ translationInputEl = element;
92
+ }
93
+
94
+ function getLocale(increase = true) {
95
+ if (increase) {
96
+ locale = locale >= currentLocale.length - 1 ? 0 : locale + 1;
97
+ }
98
+
99
+ renderedLocale = currentLocale[locale];
100
+ return currentLocale[locale];
101
+ }
102
+
103
+ function peekNextLocale() {
104
+ const nextIndex = locale >= currentLocale.length - 1 ? 0 : locale + 1;
105
+ return currentLocale[nextIndex];
106
+ }
107
+
108
+ function persistLocaleSelection(nextLocale: string) {
109
+ if (!browser) return;
110
+ localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
111
+ document.cookie = `${LOCALE_COOKIE_KEY}=${nextLocale}; path=/; SameSite=Lax`;
112
+ }
113
+
114
+ function isWorkingLocaleName(value: string | null | undefined) {
115
+ return Boolean(value?.endsWith("-working"));
116
+ }
117
+
118
+ const previewOnly = $derived(isWorkingLocaleName(renderedLocale));
119
+
88
120
  function focusTranslationInput() {
89
- queueMicrotask(() => {
90
- translationInputEl?.focus();
91
- translationInputEl?.select();
92
- });
121
+ queueMicrotask(() => {
122
+ translationInputEl?.focus();
123
+ translationInputEl?.select();
124
+ });
93
125
  }
94
126
 
95
- function handleLocaleToggle(event: MouseEvent) {
96
- if (activeSwitchLocale) return;
127
+ async function handleLocaleToggle(event: MouseEvent) {
128
+ if (activeSwitchLocale || pending) return;
97
129
  event.preventDefault();
98
- loadLocale(getLocale());
99
- }
100
-
101
- function getStaged(msgid: string, msgctxt: string | null) {
102
- return stagedTranslations[translationKey(msgid, msgctxt)] ?? null;
103
- }
104
-
105
- function getSuggestion(msgid: string, msgctxt: string | null) {
106
- return suggestionLookup[translationKey(msgid, msgctxt)] ?? "";
107
- }
108
-
109
- function isStaged(msgid: string, msgctxt: string | null) {
110
- return Boolean(getStaged(msgid, msgctxt));
111
- }
112
-
113
- function isSelectedAlt(msgid: string, msgctxt: string | null) {
114
- return sameResolvedKey(selectedResolvedKey, { msgid, msgctxt });
115
- }
116
-
117
- function setFocusedAltKey(key: string | null) {
118
- focusedAltKey = key;
119
- }
120
-
130
+ const nextLocale = peekNextLocale();
131
+ persistLocaleSelection(nextLocale);
132
+
133
+ if (isWorkingLocaleName(nextLocale)) {
134
+ resetFeedback();
135
+ pending = true;
136
+
137
+ try {
138
+ const response = await fetch(`${endpoint}?intent=promote-working-preview`, {
139
+ method: "POST"
140
+ });
141
+ const payload = await response.json().catch(() => null);
142
+ if (!response.ok) {
143
+ error = payload?.error ?? "Failed to prepare working preview";
144
+ return;
145
+ }
146
+ } catch {
147
+ error = "Failed to prepare working preview";
148
+ return;
149
+ } finally {
150
+ pending = false;
151
+ }
152
+ }
153
+
154
+ await loadLocale(getLocale());
155
+ }
156
+
157
+ function getStaged(msgid: string, msgctxt: string | null) {
158
+ return stagedTranslations[translationKey(msgid, msgctxt)] ?? null;
159
+ }
160
+
161
+ function getSuggestion(msgid: string, msgctxt: string | null) {
162
+ return suggestionLookup[translationKey(msgid, msgctxt)] ?? "";
163
+ }
164
+
165
+ function getDraft(msgid: string, msgctxt: string | null) {
166
+ return getDraftValue(draftLookup, msgid, msgctxt);
167
+ }
168
+
169
+ function isStaged(msgid: string, msgctxt: string | null) {
170
+ return Boolean(getStaged(msgid, msgctxt));
171
+ }
172
+
173
+ function isSelectedAlt(msgid: string, msgctxt: string | null) {
174
+ return sameResolvedKey(selectedResolvedKey, { msgid, msgctxt });
175
+ }
176
+
177
+ function setFocusedAltKey(key: string | null) {
178
+ focusedAltKey = key;
179
+ }
180
+
181
+ function persistDraft(msgid: string, msgctxt: string | null, item: DraftCacheItem) {
182
+ draftLookup = setDraftValue(draftLookup, msgid, msgctxt, item);
183
+ }
184
+
185
+ function clearDraft(msgid: string, msgctxt: string | null) {
186
+ draftLookup = clearDraftValue(draftLookup, msgid, msgctxt);
187
+ }
188
+
121
189
  function resetFeedback() {
122
190
  error = null;
123
191
  success = null;
124
192
  }
125
193
 
126
- async function rotateCatalogs() {
127
- if (rotatePending || pending) return;
128
- if (!confirm("Rotate catalogs? This will back up both catalogs and promote the working catalog into the base catalog.")) {
129
- return;
130
- }
131
-
194
+ async function runRotation(confirmDestructive = false) {
132
195
  rotatePending = true;
133
196
  resetFeedback();
134
197
 
135
198
  try {
136
199
  const response = await fetch(`${endpoint}?intent=rotate-catalogs`, {
137
- method: "POST"
200
+ method: "POST",
201
+ headers: {
202
+ "content-type": "application/json"
203
+ },
204
+ body: JSON.stringify({ confirmDestructive })
138
205
  });
139
206
  const payload = await response.json().catch(() => null);
140
207
 
141
208
  if (!response.ok) {
209
+ if (payload?.code === "rotation_confirmation_required") {
210
+ rotationImpact = Array.isArray(payload?.affected) ? payload.affected : [];
211
+ showRotationWarningDialog = true;
212
+ error = payload?.error ?? "Catalog rotation requires confirmation";
213
+ return;
214
+ }
215
+
142
216
  error = payload?.error ?? "Catalog rotation failed";
143
217
  return;
144
218
  }
145
219
 
220
+ showRotationWarningDialog = false;
221
+ rotationImpact = [];
146
222
  success = payload?.message ?? "Catalogs rotated";
147
223
  } catch {
148
224
  error = "Catalog rotation failed";
@@ -150,423 +226,534 @@
150
226
  rotatePending = false;
151
227
  }
152
228
  }
153
-
154
- function resetContextState() {
155
- contextError = null;
156
- contextResult = null;
157
- }
158
-
159
- function hasDirtyStagedTranslations() {
160
- return Object.values(stagedTranslations).some((item) => item.isDirty);
161
- }
162
-
163
- function applyCommittedTranslations(
164
- currentContext: TranslationContextResult | null,
165
- items: DraftTranslation[]
166
- ): TranslationContextResult | null {
167
- if (!currentContext || !items.length) return currentContext;
168
-
169
- const updates = new Map(
170
- items.map((item) => [translationKey(item.msgid, item.msgctxt), item.value])
171
- );
172
-
173
- const updateEntry = <T extends { msgid: string; msgctxt: string | null; msgstr: string[]; hasTranslation?: boolean; isCommittedToWorking?: boolean; isFuzzy?: boolean | null; translationOrigin?: "base" | "working" | null }>(
174
- entry: T
175
- ): T => {
176
- const nextValue = updates.get(translationKey(entry.msgid, entry.msgctxt));
177
- if (!nextValue) return entry;
178
-
179
- return {
180
- ...entry,
181
- msgstr: [nextValue],
182
- hasTranslation: true,
183
- isCommittedToWorking: true,
184
- matchesTargetTranslation: false,
185
- isFuzzy: false,
186
- translationOrigin: "working"
187
- };
229
+
230
+ async function rotateCatalogs() {
231
+ if (rotatePending || pending) return;
232
+ try {
233
+ const response = await fetch(`${endpoint}?intent=rotate-preflight`, {
234
+ method: "POST"
235
+ });
236
+ const payload = await response.json().catch(() => null);
237
+
238
+ if (!response.ok) {
239
+ error = payload?.error ?? "Catalog rotation failed";
240
+ return;
241
+ }
242
+
243
+ if (Array.isArray(payload?.affected) && payload.affected.length) {
244
+ rotationImpact = Array.isArray(payload?.affected) ? payload.affected : [];
245
+ showRotationWarningDialog = true;
246
+ resetFeedback();
247
+ return;
248
+ }
249
+
250
+ if (
251
+ !confirm(
252
+ "Rotate catalogs? This will back up both catalogs and promote the working catalog into the base catalog."
253
+ )
254
+ ) {
255
+ return;
256
+ }
257
+
258
+ await runRotation(false);
259
+ } catch {
260
+ error = "Catalog rotation failed";
261
+ }
262
+ }
263
+
264
+ function resetContextState() {
265
+ contextError = null;
266
+ contextResult = null;
267
+ }
268
+
269
+ function hasDirtyStagedTranslations() {
270
+ return Object.values(stagedTranslations).some((item) => item.isDirty);
271
+ }
272
+
273
+ function getResolvedInputValue(msgid: string, msgctxt: string | null) {
274
+ return (
275
+ getStaged(msgid, msgctxt)?.value ||
276
+ getDraft(msgid, msgctxt)?.value ||
277
+ getSuggestion(msgid, msgctxt) ||
278
+ getEntryOriginalValue(contextResult, msgid, msgctxt)
279
+ );
280
+ }
281
+
282
+ function syncDraftForResolved(msgid: string, msgctxt: string | null, value: string) {
283
+ const originalValue = getEntryOriginalValue(contextResult, msgid, msgctxt);
284
+ const trimmedValue = value.trim();
285
+ if (!trimmedValue || trimmedValue === originalValue) {
286
+ clearDraft(msgid, msgctxt);
287
+ return;
288
+ }
289
+
290
+ persistDraft(msgid, msgctxt, {
291
+ value,
292
+ isDirty: trimmedValue !== originalValue
293
+ });
294
+ }
295
+
296
+ function handleTranslationInput() {
297
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
298
+ if (!resolved) return;
299
+ syncDraftForResolved(resolved.msgid, resolved.msgctxt, translatedValue);
300
+ }
301
+
302
+ async function refreshContextAndReconcile(payload: DraftTranslation[]) {
303
+ if (!capturedSelection) return true;
304
+
305
+ await fetchContext();
306
+
307
+ let allReconciled = true;
308
+ for (const item of payload) {
309
+ const latest = getEntryOriginalValue(contextResult, item.msgid, item.msgctxt);
310
+ if (latest === item.value) {
311
+ clearDraft(item.msgid, item.msgctxt);
312
+ } else {
313
+ allReconciled = false;
314
+ persistDraft(item.msgid, item.msgctxt, { value: item.value, isDirty: true });
315
+ }
316
+ }
317
+
318
+ return allReconciled;
319
+ }
320
+
321
+ function selectResolvedKey(msgid: string, msgctxt: string | null, focusInput = false) {
322
+ selectedResolvedKey = { msgid, msgctxt };
323
+ translatedValue = getResolvedInputValue(msgid, msgctxt);
324
+
325
+ resetFeedback();
326
+
327
+ if (focusInput) {
328
+ focusTranslationInput();
329
+ }
330
+ }
331
+
332
+ function hasActiveSuggestion() {
333
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
334
+ if (!resolved) return false;
335
+
336
+ if (getStaged(resolved.msgid, resolved.msgctxt)) {
337
+ return false;
338
+ }
339
+
340
+ const suggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
341
+ if (!suggestion) return false;
342
+
343
+ const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
344
+ return translatedValue === suggestion && suggestion !== originalValue;
345
+ }
346
+
347
+ function handleSelectAlt(event: Event, msgid: string, msgctxt: string | null) {
348
+ event.preventDefault();
349
+ event.stopPropagation();
350
+ selectResolvedKey(msgid, msgctxt, true);
351
+ }
352
+
353
+ function moveSelection(direction: 1 | -1) {
354
+ const items = getCandidateList(contextResult);
355
+ if (!items.length) return false;
356
+
357
+ const current = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
358
+ const currentIndex = current
359
+ ? items.findIndex(
360
+ (item) =>
361
+ item.msgid === current.msgid &&
362
+ (item.msgctxt ?? null) === (current.msgctxt ?? null)
363
+ )
364
+ : -1;
365
+
366
+ for (let step = 1; step <= items.length; step++) {
367
+ const nextIndex =
368
+ ((Math.max(currentIndex, 0) + direction * step) % items.length + items.length) %
369
+ items.length;
370
+ const next = items[nextIndex];
371
+ const skipForKeyboard = next.hasTranslation && !next.isFuzzy;
372
+
373
+ if (skipForKeyboard) continue;
374
+
375
+ selectResolvedKey(next.msgid, next.msgctxt, true);
376
+ return true;
377
+ }
378
+
379
+ const nextIndex =
380
+ ((Math.max(currentIndex, 0) + direction) % items.length + items.length) % items.length;
381
+ const next = items[nextIndex];
382
+ selectResolvedKey(next.msgid, next.msgctxt, true);
383
+ return true;
384
+ }
385
+
386
+ function onAltKeydown(event: KeyboardEvent, msgid: string, msgctxt: string | null) {
387
+ if (event.key === "Enter" || event.key === " ") {
388
+ event.preventDefault();
389
+ selectResolvedKey(msgid, msgctxt, true);
390
+ return;
391
+ }
392
+
393
+ if (event.key === "Tab") {
394
+ event.preventDefault();
395
+ moveSelection(event.shiftKey ? -1 : 1);
396
+ }
397
+ }
398
+
399
+ function stageCurrentTranslation(event?: Event) {
400
+ event?.preventDefault();
401
+ if (previewOnly) {
402
+ error = "Working locale is preview-only. Rotate back to a non-working locale before staging.";
403
+ return;
404
+ }
405
+
406
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
407
+ if (!resolved?.msgid) {
408
+ error = "No resolved translation key selected";
409
+ return;
410
+ }
411
+
412
+ const value = translatedValue.trim();
413
+ if (!value) {
414
+ error = "No translation";
415
+ return;
416
+ }
417
+
418
+ const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
419
+ stagedTranslations = {
420
+ ...stagedTranslations,
421
+ [translationKey(resolved.msgid, resolved.msgctxt)]: {
422
+ msgid: resolved.msgid,
423
+ msgctxt: resolved.msgctxt,
424
+ value,
425
+ origin: getEntryOrigin(contextResult, resolved.msgid, resolved.msgctxt),
426
+ originalValue,
427
+ isDirty: value !== originalValue
428
+ }
188
429
  };
189
-
190
- return {
191
- ...currentContext,
192
- entry: updateEntry(currentContext.entry),
193
- alternatives: currentContext.alternatives.map((alternative) => updateEntry(alternative))
194
- };
195
- }
196
-
197
- function selectResolvedKey(msgid: string, msgctxt: string | null, focusInput = false) {
198
- selectedResolvedKey = { msgid, msgctxt };
199
- translatedValue =
200
- getStaged(msgid, msgctxt)?.value ||
201
- getSuggestion(msgid, msgctxt) ||
202
- getEntryOriginalValue(contextResult, msgid, msgctxt);
203
-
204
- resetFeedback();
205
-
206
- if (focusInput) {
207
- focusTranslationInput();
208
- }
209
- }
210
-
211
- function hasActiveSuggestion() {
212
- const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
213
- if (!resolved) return false;
214
-
215
- if (getStaged(resolved.msgid, resolved.msgctxt)) {
216
- return false;
217
- }
218
-
219
- const suggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
220
- if (!suggestion) return false;
221
-
222
- const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
223
- return translatedValue === suggestion && suggestion !== originalValue;
224
- }
225
-
226
- function handleSelectAlt(event: Event, msgid: string, msgctxt: string | null) {
227
- event.preventDefault();
228
- event.stopPropagation();
229
- selectResolvedKey(msgid, msgctxt, true);
230
- }
231
-
232
- function moveSelection(direction: 1 | -1) {
233
- const items = getCandidateList(contextResult);
234
- if (!items.length) return false;
235
-
236
- const current = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
237
- const currentIndex = current
238
- ? items.findIndex(
239
- (item) =>
240
- item.msgid === current.msgid &&
241
- (item.msgctxt ?? null) === (current.msgctxt ?? null)
242
- )
243
- : -1;
244
-
245
- for (let step = 1; step <= items.length; step++) {
246
- const nextIndex =
247
- ((Math.max(currentIndex, 0) + direction * step) % items.length + items.length) %
248
- items.length;
249
- const next = items[nextIndex];
250
- const skipForKeyboard = next.hasTranslation && !next.isFuzzy;
251
-
252
- if (skipForKeyboard) continue;
253
-
254
- selectResolvedKey(next.msgid, next.msgctxt, true);
255
- return true;
256
- }
257
-
258
- const nextIndex =
259
- ((Math.max(currentIndex, 0) + direction) % items.length + items.length) % items.length;
260
- const next = items[nextIndex];
261
- selectResolvedKey(next.msgid, next.msgctxt, true);
262
- return true;
263
- }
264
-
265
- function onAltKeydown(event: KeyboardEvent, msgid: string, msgctxt: string | null) {
266
- if (event.key === "Enter" || event.key === " ") {
267
- event.preventDefault();
268
- selectResolvedKey(msgid, msgctxt, true);
269
- return;
270
- }
271
-
272
- if (event.key === "Tab") {
273
- event.preventDefault();
274
- moveSelection(event.shiftKey ? -1 : 1);
275
- }
276
- }
277
-
278
- function stageCurrentTranslation(event?: Event) {
279
- event?.preventDefault();
280
-
281
- const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
282
- if (!resolved?.msgid) {
283
- error = "No resolved translation key selected";
284
- return;
285
- }
286
-
287
- const value = translatedValue.trim();
288
- if (!value) {
289
- error = "No translation";
290
- return;
291
- }
292
-
293
- const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
294
- stagedTranslations = {
295
- ...stagedTranslations,
296
- [translationKey(resolved.msgid, resolved.msgctxt)]: {
297
- msgid: resolved.msgid,
298
- msgctxt: resolved.msgctxt,
299
- value,
300
- origin: getEntryOrigin(contextResult, resolved.msgid, resolved.msgctxt),
301
- originalValue,
302
- isDirty: value !== originalValue
303
- }
304
- };
305
-
306
- error = null;
307
- success = "Staged";
308
-
309
- if (!moveSelection(1)) {
310
- focusTranslationInput();
311
- }
312
- }
313
-
314
- async function commitStagedTranslations() {
315
- const payload = Object.values(stagedTranslations).filter((item) => item.isDirty);
316
- if (!payload.length) {
317
- error = "No changed translations to submit";
318
- return false;
319
- }
320
-
321
- pending = true;
322
- resetFeedback();
323
-
324
- const res = await fetch(`${endpoint}?intent=commit-batch`, {
325
- method: "POST",
326
- headers: {
327
- "content-type": "application/json"
328
- },
329
- body: JSON.stringify({
330
- items: payload.map((item) => ({
331
- resolvedMsgid: item.msgid,
332
- resolvedMsgctxt: item.msgctxt,
333
- translationValue: item.value
334
- }))
335
- })
336
- });
337
-
338
- const json = await res.json();
339
- pending = false;
340
-
341
- if (!res.ok) {
342
- error = json.error ?? "Batch commit failed";
343
- return false;
344
- }
345
-
346
- success = json.message ?? "Translations committed";
347
-
348
- const next = { ...stagedTranslations };
349
- for (const item of payload) {
350
- delete next[translationKey(item.msgid, item.msgctxt)];
351
- }
352
- stagedTranslations = next;
353
- contextResult = applyCommittedTranslations(contextResult, payload);
354
- return true;
355
- }
356
-
357
- async function submitStagedTranslations(event: SubmitEvent) {
358
- if (!event.defaultPrevented) {
359
- event.preventDefault();
360
- }
361
-
362
- await commitStagedTranslations();
363
- }
364
-
365
- function openTranslation(text: string) {
366
- capturedSelection = text;
367
- translatedValue = "";
368
- resetFeedback();
369
- resetContextState();
370
- spawnTranslation = true;
371
- showPendingChangesDialog = false;
372
- }
373
-
374
- async function fetchContext() {
375
- if (!capturedSelection) return;
376
-
377
- contextPending = true;
378
- resetContextState();
379
- resetFeedback();
380
-
381
- const body = new FormData();
382
- body.set("translationKey", capturedSelection);
383
- body.set("currentPath", page.url.pathname);
384
-
430
+ persistDraft(resolved.msgid, resolved.msgctxt, {
431
+ value,
432
+ isDirty: value !== originalValue
433
+ });
434
+
435
+ error = null;
436
+ success = "Staged";
437
+
438
+ if (!moveSelection(1)) {
439
+ focusTranslationInput();
440
+ }
441
+ }
442
+
443
+ async function commitStagedTranslations() {
444
+ if (previewOnly) {
445
+ error = "Working locale is preview-only. Commit from the base or target locale instead.";
446
+ return false;
447
+ }
448
+
449
+ const payload = Object.values(stagedTranslations).filter((item) => item.isDirty);
450
+ if (!payload.length) {
451
+ error = "No changed translations to submit";
452
+ return false;
453
+ }
454
+
455
+ pending = true;
456
+ resetFeedback();
457
+
458
+ try {
459
+ const res = await fetch(`${endpoint}?intent=commit-batch`, {
460
+ method: "POST",
461
+ headers: {
462
+ "content-type": "application/json"
463
+ },
464
+ body: JSON.stringify({
465
+ items: payload.map((item) => ({
466
+ resolvedMsgid: item.msgid,
467
+ resolvedMsgctxt: item.msgctxt,
468
+ translationValue: item.value
469
+ }))
470
+ })
471
+ });
472
+
473
+ const json = await res.json().catch(() => null);
474
+
475
+ if (!res.ok) {
476
+ error = json?.error ?? "Batch commit failed";
477
+ return false;
478
+ }
479
+
480
+ const reconciled = await refreshContextAndReconcile(payload);
481
+ const next = { ...stagedTranslations };
482
+ for (const item of payload) {
483
+ if (reconciled || getEntryOriginalValue(contextResult, item.msgid, item.msgctxt) === item.value) {
484
+ delete next[translationKey(item.msgid, item.msgctxt)];
485
+ }
486
+ }
487
+ stagedTranslations = next;
488
+ success = reconciled
489
+ ? (json?.message ?? "Translations committed")
490
+ : "Commit may have landed; verifying catalog state. Your edits were kept locally.";
491
+ return true;
492
+ } finally {
493
+ pending = false;
494
+ }
495
+ }
496
+
497
+ async function submitStagedTranslations(event: SubmitEvent) {
498
+ if (!event.defaultPrevented) {
499
+ event.preventDefault();
500
+ }
501
+
502
+ await commitStagedTranslations();
503
+ }
504
+
505
+ function openTranslation(text: string) {
506
+ capturedSelection = text;
507
+ translatedValue = "";
508
+ resetFeedback();
509
+ resetContextState();
510
+ spawnTranslation = true;
511
+ showPendingChangesDialog = false;
512
+ }
513
+
514
+ async function fetchContext() {
515
+ if (!capturedSelection) return;
516
+
517
+ contextPending = true;
518
+ resetContextState();
519
+ resetFeedback();
520
+
521
+ const body = new FormData();
522
+ body.set("translationKey", capturedSelection);
523
+ body.set("currentPath", page.url.pathname);
524
+
385
525
  const res = await fetch(`${endpoint}?intent=context`, {
386
- method: "POST",
387
- body
388
- });
389
-
390
- const json = await res.json();
391
- contextPending = false;
392
-
393
- if (!res.ok) {
394
- contextError = json.error ?? "Context lookup failed";
395
- return;
396
- }
397
-
398
- contextResult = json;
399
- selectedResolvedKey = {
400
- msgid: json.entry.msgid,
401
- msgctxt: json.entry.msgctxt
402
- };
403
- translatedValue =
404
- getStaged(json.entry.msgid, json.entry.msgctxt)?.value ||
405
- getSuggestion(json.entry.msgid, json.entry.msgctxt) ||
406
- json.entry.msgstr?.[0] ||
407
- "";
408
-
409
- if (!suggestionPending) {
410
- suggestionPending = true;
526
+ method: "POST",
527
+ body
528
+ });
529
+
530
+ const json = await res.json();
531
+ contextPending = false;
532
+
533
+ if (!res.ok) {
534
+ contextError = json.error ?? "Context lookup failed";
535
+ return;
536
+ }
537
+
538
+ contextResult = json;
539
+ selectedResolvedKey = {
540
+ msgid: json.entry.msgid,
541
+ msgctxt: json.entry.msgctxt
542
+ };
543
+ const currentDraft = getDraft(json.entry.msgid, json.entry.msgctxt);
544
+ translatedValue =
545
+ getStaged(json.entry.msgid, json.entry.msgctxt)?.value ||
546
+ currentDraft?.value ||
547
+ getSuggestion(json.entry.msgid, json.entry.msgctxt) ||
548
+ json.entry.msgstr?.[0] ||
549
+ "";
550
+
551
+ if (!suggestionPending) {
552
+ suggestionPending = true;
411
553
  void requestTranslationSuggestions(json, endpoint)
412
- .then((nextSuggestions) => {
413
- suggestionLookup = nextSuggestions;
414
-
415
- const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
416
- if (!resolved) return;
417
-
418
- const staged = getStaged(resolved.msgid, resolved.msgctxt);
419
- const original = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
420
- const currentSuggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
421
-
422
- if (!staged && translatedValue === original && currentSuggestion) {
423
- translatedValue = currentSuggestion;
424
- }
425
- })
426
- .catch(() => {
427
- // Suggestion lookup is best-effort in this dev tool.
428
- })
429
- .finally(() => {
430
- suggestionPending = false;
431
- });
432
- }
433
-
434
- focusTranslationInput();
435
- }
436
-
437
- function closeTranslation() {
438
- spawnTranslation = false;
439
- capturedSelection = undefined;
440
- selectionStarted = false;
441
- translatedValue = "";
442
- selectedResolvedKey = null;
443
- focusedAltKey = null;
444
- showPendingChangesDialog = false;
445
- resetFeedback();
446
- resetContextState();
447
- }
448
-
449
- function requestCloseTranslation() {
450
- if (pending) return;
451
-
452
- if (hasDirtyStagedTranslations()) {
453
- showPendingChangesDialog = true;
454
- return;
455
- }
456
-
457
- closeTranslation();
458
- }
459
-
460
- function discardAndCloseTranslation() {
461
- stagedTranslations = {};
462
- closeTranslation();
463
- }
464
-
465
- async function submitAndCloseTranslation() {
466
- const committed = await commitStagedTranslations();
467
- if (committed) {
468
- closeTranslation();
469
- }
470
- }
471
-
472
- function getElementTranslationCandidate(target: EventTarget | null): string | null {
473
- const el =
474
- target instanceof Element
475
- ? target.closest(
476
- 'button, a, [role="button"], input[type="button"], input[type="submit"]'
477
- )
478
- : null;
479
-
480
- if (!el) return null;
481
-
482
- if (el instanceof HTMLInputElement) {
483
- return (
484
- el.value?.trim() ||
485
- el.getAttribute("aria-label")?.trim() ||
486
- el.title?.trim() ||
487
- null
488
- );
489
- }
490
-
491
- return (
492
- el.getAttribute("aria-label")?.trim() ||
493
- el.textContent?.trim() ||
494
- el.getAttribute("title")?.trim() ||
495
- null
496
- );
497
- }
498
-
499
- function translationForm() {
500
- if (!browser || !dev) return;
501
-
502
- const onSelectStart = () => {
503
- selectionStarted = dragQua;
504
- };
505
-
506
- function onCaptureInteractivePointerDown(event: PointerEvent) {
507
- if (!event.altKey) return;
508
-
509
- const text = getElementTranslationCandidate(event.target);
510
- if (!text) return;
511
-
512
- event.preventDefault();
513
- event.stopPropagation();
514
- event.stopImmediatePropagation?.();
515
-
516
- openTranslation(text);
517
- void fetchContext();
518
- }
519
-
520
- const onMouseUp = () => {
521
- if (!selectionStarted) return;
522
-
523
- const text = document.getSelection()?.toString().trim();
524
- if (text) {
525
- openTranslation(text);
526
- void fetchContext();
527
- }
528
-
529
- selectionStarted = false;
530
- };
531
-
532
- const onDocumentMouseDown = (event: MouseEvent) => {
533
- if (!spawnTranslation) return;
534
- if (showPendingChangesDialog) return;
535
-
536
- const target = event.target as Node | null;
537
- if (panelEl && target && panelEl.contains(target)) return;
538
-
539
- requestCloseTranslation();
540
- };
541
-
542
- function onCtrlDown(event: KeyboardEvent) {
543
- dragQua = !event.ctrlKey;
544
- }
545
-
546
- function onCtrlUp() {
547
- dragQua = true;
548
- }
549
-
550
- document.addEventListener("selectstart", onSelectStart);
551
- document.addEventListener("mouseup", onMouseUp);
552
- document.addEventListener("mousedown", onDocumentMouseDown);
553
- document.addEventListener("keydown", onCtrlDown);
554
- document.addEventListener("keyup", onCtrlUp);
555
- document.addEventListener("pointerdown", onCaptureInteractivePointerDown, true);
556
-
557
- return () => {
558
- document.removeEventListener("selectstart", onSelectStart);
559
- document.removeEventListener("mouseup", onMouseUp);
560
- document.removeEventListener("mousedown", onDocumentMouseDown);
561
- document.removeEventListener("keydown", onCtrlDown);
562
- document.removeEventListener("keyup", onCtrlUp);
563
- document.removeEventListener("pointerdown", onCaptureInteractivePointerDown, true);
564
- };
565
- }
566
-
567
- onMount(translationForm);
568
- </script>
569
-
554
+ .then((nextSuggestions) => {
555
+ suggestionLookup = nextSuggestions;
556
+
557
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
558
+ if (!resolved) return;
559
+
560
+ const staged = getStaged(resolved.msgid, resolved.msgctxt);
561
+ const original = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
562
+ const currentSuggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
563
+
564
+ const draft = getDraft(resolved.msgid, resolved.msgctxt);
565
+ if (!staged && !draft && translatedValue === original && currentSuggestion) {
566
+ translatedValue = currentSuggestion;
567
+ }
568
+ })
569
+ .catch(() => {
570
+ // Suggestion lookup is best-effort in this dev tool.
571
+ })
572
+ .finally(() => {
573
+ suggestionPending = false;
574
+ });
575
+ }
576
+
577
+ if (!previewOnly) {
578
+ focusTranslationInput();
579
+ }
580
+ }
581
+
582
+ function closeTranslation() {
583
+ spawnTranslation = false;
584
+ capturedSelection = undefined;
585
+ selectionStarted = false;
586
+ translatedValue = "";
587
+ selectedResolvedKey = null;
588
+ focusedAltKey = null;
589
+ showPendingChangesDialog = false;
590
+ showRotationWarningDialog = false;
591
+ resetFeedback();
592
+ resetContextState();
593
+ }
594
+
595
+ function requestCloseTranslation() {
596
+ if (pending) return;
597
+
598
+ if (hasDirtyStagedTranslations()) {
599
+ showPendingChangesDialog = true;
600
+ return;
601
+ }
602
+
603
+ closeTranslation();
604
+ }
605
+
606
+ function discardAndCloseTranslation() {
607
+ for (const item of Object.values(stagedTranslations)) {
608
+ clearDraft(item.msgid, item.msgctxt);
609
+ }
610
+ stagedTranslations = {};
611
+ closeTranslation();
612
+ }
613
+
614
+ async function submitAndCloseTranslation() {
615
+ const committed = await commitStagedTranslations();
616
+ if (committed) {
617
+ closeTranslation();
618
+ }
619
+ }
620
+
621
+ function getElementTranslationCandidate(target: EventTarget | null): string | null {
622
+ const el =
623
+ target instanceof Element
624
+ ? target.closest(
625
+ 'button, a, [role="button"], input[type="button"], input[type="submit"]'
626
+ )
627
+ : null;
628
+
629
+ if (!el) return null;
630
+
631
+ if (el instanceof HTMLInputElement) {
632
+ return (
633
+ el.getAttribute("aria-label")?.trim() ||
634
+ el.value?.trim() ||
635
+ el.title?.trim() ||
636
+ null
637
+ );
638
+ }
639
+
640
+ function getFirstDescendantTextCandidate(root: Element) {
641
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
642
+ let current = walker.nextNode();
643
+
644
+ while (current) {
645
+ if (current instanceof HTMLElement) {
646
+ const text = current.innerText?.trim() || current.textContent?.trim() || "";
647
+ if (text) {
648
+ return text;
649
+ }
650
+ }
651
+
652
+ current = walker.nextNode();
653
+ }
654
+
655
+ return "";
656
+ }
657
+
658
+ const directText = [...el.childNodes]
659
+ .filter((node) => node.nodeType === Node.TEXT_NODE)
660
+ .map((node) => node.textContent?.trim() ?? "")
661
+ .filter(Boolean)
662
+ .slice(0,1)[0]
663
+ .trim();
664
+ const descendantText = getFirstDescendantTextCandidate(el);
665
+
666
+ return (
667
+ el.getAttribute("aria-label")?.trim() ||
668
+ el.getAttribute("title")?.trim() ||
669
+ directText ||
670
+ descendantText ||
671
+ null
672
+ );
673
+ }
674
+
675
+ function translationForm() {
676
+ if (!browser || !dev) return;
677
+
678
+ const onSelectStart = () => {
679
+ selectionStarted = dragQua;
680
+ };
681
+
682
+ function onCaptureInteractivePointerDown(event: PointerEvent) {
683
+ if (!event.altKey) return;
684
+
685
+ const text = getElementTranslationCandidate(event.target);
686
+ if (!text) return;
687
+
688
+ event.preventDefault();
689
+ event.stopPropagation();
690
+ event.stopImmediatePropagation?.();
691
+
692
+ openTranslation(text);
693
+ void fetchContext();
694
+ }
695
+
696
+ const onMouseUp = () => {
697
+ if (!selectionStarted) return;
698
+
699
+ const text = document.getSelection()?.toString().trim();
700
+ if (text) {
701
+ openTranslation(text);
702
+ void fetchContext();
703
+ }
704
+
705
+ selectionStarted = false;
706
+ };
707
+
708
+ const onDocumentMouseDown = (event: MouseEvent) => {
709
+ if (!spawnTranslation) return;
710
+ if (showPendingChangesDialog) return;
711
+
712
+ const target = event.target as Node | null;
713
+ if (panelEl && target && panelEl.contains(target)) return;
714
+
715
+ requestCloseTranslation();
716
+ };
717
+
718
+ function onCtrlDown(event: KeyboardEvent) {
719
+ dragQua = !event.ctrlKey;
720
+ }
721
+
722
+ function onCtrlUp() {
723
+ dragQua = true;
724
+ }
725
+
726
+ document.addEventListener("selectstart", onSelectStart);
727
+ document.addEventListener("mouseup", onMouseUp);
728
+ document.addEventListener("mousedown", onDocumentMouseDown);
729
+ document.addEventListener("keydown", onCtrlDown);
730
+ document.addEventListener("keyup", onCtrlUp);
731
+ document.addEventListener("pointerdown", onCaptureInteractivePointerDown, true);
732
+
733
+ return () => {
734
+ document.removeEventListener("selectstart", onSelectStart);
735
+ document.removeEventListener("mouseup", onMouseUp);
736
+ document.removeEventListener("mousedown", onDocumentMouseDown);
737
+ document.removeEventListener("keydown", onCtrlDown);
738
+ document.removeEventListener("keyup", onCtrlUp);
739
+ document.removeEventListener("pointerdown", onCaptureInteractivePointerDown, true);
740
+ };
741
+ }
742
+
743
+ onMount(translationForm);
744
+
745
+ onMount(() => {
746
+ if (!browser) return;
747
+ const storedLocale = localStorage.getItem(LOCALE_STORAGE_KEY);
748
+ if (!storedLocale) return;
749
+ const nextIndex = currentLocale.findIndex((item) => item === storedLocale);
750
+ if (nextIndex >= 0) {
751
+ locale = nextIndex;
752
+ renderedLocale = currentLocale[nextIndex];
753
+ }
754
+ });
755
+ </script>
756
+
570
757
  <div class="translator-actions sticky-rotate">
571
758
  <button
572
759
  type="button"
@@ -602,51 +789,69 @@
602
789
  class="translator-toggle-button"
603
790
  onclick={handleLocaleToggle}
604
791
  title="qa-button"
605
- disabled={activeSwitchLocale}
606
- aria-disabled={activeSwitchLocale}
792
+ disabled={activeSwitchLocale || pending}
793
+ aria-disabled={activeSwitchLocale || pending}
607
794
  >
608
795
  [-QA-] {renderedLocale}
609
796
  </button>
610
797
  </div>
611
-
612
- {#if spawnTranslation}
613
- <TranslationHelperForm
614
- {capturedSelection}
615
- {contextPending}
616
- {contextError}
617
- {contextResult}
618
- {selectedResolvedKey}
619
- {focusedAltKey}
620
- bind:translatedValue
621
- {stagedTranslations}
622
- hasActiveSuggestion={hasActiveSuggestion()}
623
- {pending}
624
- {error}
625
- {success}
626
- onClose={requestCloseTranslation}
627
- onSubmit={submitStagedTranslations}
628
- onStage={stageCurrentTranslation}
629
- onAltFocusChange={setFocusedAltKey}
630
- {onAltKeydown}
631
- onSelectAlt={handleSelectAlt}
632
- {isStaged}
633
- {isSelectedAlt}
634
- {setTranslationInputEl}
635
- {moveSelection}
636
- />
637
- {/if}
638
-
639
- {#if showPendingChangesDialog}
640
- <PendingChangesDialog
641
- {pending}
642
- onCancel={() => (showPendingChangesDialog = false)}
643
- onDiscard={discardAndCloseTranslation}
644
- onSubmit={() => void submitAndCloseTranslation()}
645
- />
646
- {/if}
647
- </div>
648
-
649
- <style>
798
+
799
+ {#if spawnTranslation}
800
+ <TranslationHelperForm
801
+ {capturedSelection}
802
+ {contextPending}
803
+ {contextError}
804
+ {contextResult}
805
+ {selectedResolvedKey}
806
+ {focusedAltKey}
807
+ bind:translatedValue
808
+ {stagedTranslations}
809
+ hasActiveSuggestion={hasActiveSuggestion()}
810
+ {pending}
811
+ {error}
812
+ {success}
813
+ {previewOnly}
814
+ hasDraft={Boolean(
815
+ selectedResolvedKey &&
816
+ getDraft(selectedResolvedKey.msgid, selectedResolvedKey.msgctxt)?.isDirty
817
+ )}
818
+ onClose={requestCloseTranslation}
819
+ onSubmit={submitStagedTranslations}
820
+ onStage={stageCurrentTranslation}
821
+ onInputValue={handleTranslationInput}
822
+ onAltFocusChange={setFocusedAltKey}
823
+ {onAltKeydown}
824
+ onSelectAlt={handleSelectAlt}
825
+ {isStaged}
826
+ {isSelectedAlt}
827
+ {setTranslationInputEl}
828
+ {moveSelection}
829
+ />
830
+ {/if}
831
+
832
+ {#if showPendingChangesDialog}
833
+ <PendingChangesDialog
834
+ {pending}
835
+ onCancel={() => (showPendingChangesDialog = false)}
836
+ onDiscard={discardAndCloseTranslation}
837
+ onSubmit={() => void submitAndCloseTranslation()}
838
+ />
839
+ {/if}
840
+
841
+ {#if showRotationWarningDialog}
842
+ <RotationWarningDialog
843
+ pending={rotatePending}
844
+ affected={rotationImpact}
845
+ onCancel={() => {
846
+ showRotationWarningDialog = false;
847
+ rotationImpact = [];
848
+ }}
849
+ onConfirm={() => void runRotation(true)}
850
+ />
851
+ {/if}
852
+ </div>
853
+
854
+ <style>
650
855
  .translator-shell {
651
856
  display: flex;
652
857
  flex-direction: column;
@@ -713,7 +918,7 @@
713
918
  border: 1px solid rgba(255, 130, 130, 0.28);
714
919
  color: rgba(255, 224, 224, 0.98);
715
920
  }
716
-
921
+
717
922
  .translator-toggle {
718
923
  display: inline-flex;
719
924
  opacity: 0.72;
@@ -756,7 +961,7 @@
756
961
  .translator-toggle:hover {
757
962
  opacity: 1;
758
963
  }
759
-
964
+
760
965
  .sticky {
761
966
  position: fixed;
762
967
  top: 10%;