@nyaruka/temba-components 0.156.14 → 0.156.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyaruka/temba-components",
3
- "version": "0.156.14",
3
+ "version": "0.156.16",
4
4
  "description": "Web components to support rapidpro and related projects",
5
5
  "author": "Nyaruka <code@nyaruka.coim>",
6
6
  "main": "dist/index.js",
@@ -3,7 +3,7 @@ import { css, PropertyValues } from 'lit';
3
3
  import { property, state } from 'lit/decorators.js';
4
4
  import { RapidElement } from '../RapidElement';
5
5
  import { getStore } from '../store/Store';
6
- import { zustand } from '../store/AppState';
6
+ import { AppState, fromStore, zustand } from '../store/AppState';
7
7
  import { FlowDefinition } from '../store/flow-definition';
8
8
  import { TranslationEntry, buildTranslationBundles } from './flow-translations';
9
9
  import { getLanguageDisplayName } from './utils';
@@ -66,10 +66,6 @@ export class AutoTranslate extends RapidElement {
66
66
  text-decoration: underline;
67
67
  }
68
68
 
69
- .auto-translate-single-model {
70
- font-size: 13px;
71
- }
72
-
73
69
  .auto-translate-status {
74
70
  display: flex;
75
71
  align-items: center;
@@ -141,6 +137,9 @@ export class AutoTranslate extends RapidElement {
141
137
  @property({ type: Boolean })
142
138
  disabled = false;
143
139
 
140
+ @fromStore(zustand, (state: AppState) => state.brand)
141
+ private brand!: string;
142
+
144
143
  // Reactive flag the host can read to show "translating" state in UI
145
144
  // adjacent to this component (e.g. the toolbar button).
146
145
  @property({ type: Boolean, reflect: true })
@@ -170,6 +169,9 @@ export class AutoTranslate extends RapidElement {
170
169
  @state()
171
170
  interrupt = false;
172
171
 
172
+ @state()
173
+ updateExisting = false;
174
+
173
175
  // Tracks whether the dialog has ever opened so we can keep it mounted
174
176
  // afterwards (so it sees its own close transition) without paying
175
177
  // for an empty hidden dialog before that.
@@ -192,6 +194,7 @@ export class AutoTranslate extends RapidElement {
192
194
  this.error = null;
193
195
  this.errorExpanded = false;
194
196
  this.selectedModel = null;
197
+ this.updateExisting = false;
195
198
  this.dialogOpen = true;
196
199
  await this.loadModels();
197
200
 
@@ -228,6 +231,11 @@ export class AutoTranslate extends RapidElement {
228
231
  this.selectedModel = next ? { uuid: next.uuid, name: next.name } : null;
229
232
  }
230
233
 
234
+ private handleUpdateExistingChange(event: Event): void {
235
+ const checkbox = event.target as any;
236
+ this.updateExisting = !!checkbox?.checked;
237
+ }
238
+
231
239
  private confirmTranslate(): void {
232
240
  if (!this.selectedModel) {
233
241
  return;
@@ -258,7 +266,10 @@ export class AutoTranslate extends RapidElement {
258
266
  * preTranslated for direct application), and dedupes pending entries
259
267
  * sharing identical source arrays so each unique array is sent at most
260
268
  * once (duplicates are returned in duplicateKeyToCanonical for
261
- * propagation when the canonical key's translation lands).
269
+ * propagation when the canonical key's translation lands). When
270
+ * updateExisting is true, already-translated entries are also included
271
+ * and the pre-translation reuse path is skipped so all localizable
272
+ * entries are sent to the LLM.
262
273
  */
263
274
  private buildTranslationBatches(): {
264
275
  keyToEntries: Map<string, TranslationEntry[]>;
@@ -276,53 +287,71 @@ export class AutoTranslate extends RapidElement {
276
287
  }
277
288
 
278
289
  const bundles = buildTranslationBundles(this.definition, this.languageCode);
290
+ const updateExisting = this.updateExisting;
279
291
 
280
292
  // Map source-content -> already-stored translation array, built from
281
293
  // entries that have a translation in the live localization map. Reading
282
- // the stored array (not entry.to) preserves the original shape.
294
+ // the stored array (not entry.to) preserves the original shape. Skipped
295
+ // in update mode so we always re-translate via the LLM.
283
296
  const existingByContent = new Map<string, string[]>();
284
297
  const localization =
285
298
  this.definition.localization?.[this.languageCode] || {};
286
299
 
287
- for (const bundle of bundles) {
288
- for (const entry of bundle.translations) {
289
- if (!entry.to || entry.to.trim().length === 0) {
290
- continue;
291
- }
292
- if (!entry.from || entry.from.trim().length === 0) {
293
- continue;
294
- }
295
- const stored = localization[entry.uuid]?.[entry.attribute];
296
- if (!Array.isArray(stored) || stored.length === 0) {
297
- continue;
298
- }
299
- const contentKey = JSON.stringify([entry.from]);
300
- if (!existingByContent.has(contentKey)) {
301
- existingByContent.set(contentKey, stored);
300
+ if (!updateExisting) {
301
+ for (const bundle of bundles) {
302
+ for (const entry of bundle.translations) {
303
+ // Skip entries with no real translation: an empty array OR an
304
+ // array of only empty/whitespace strings shouldn't seed reuse,
305
+ // or we'd silently propagate a blank to other matching sources.
306
+ if (
307
+ !entry.toValues ||
308
+ !entry.toValues.some((v) => v && v.trim().length > 0)
309
+ ) {
310
+ continue;
311
+ }
312
+ if (!entry.fromValues || entry.fromValues.length === 0) {
313
+ continue;
314
+ }
315
+ const stored = localization[entry.uuid]?.[entry.attribute];
316
+ if (!Array.isArray(stored) || stored.length === 0) {
317
+ continue;
318
+ }
319
+ const contentKey = JSON.stringify(entry.fromValues);
320
+ if (!existingByContent.has(contentKey)) {
321
+ existingByContent.set(contentKey, stored);
322
+ }
302
323
  }
303
324
  }
304
325
  }
305
326
 
306
- // Collect pending entries (no translation yet) preserving order, and
307
- // group source values per key in case the same key yields multiple
308
- // entries.
327
+ // Collect pending entries preserving order. Each unique key (uuid +
328
+ // attribute) maps to its source array (entry.fromValues), preserving
329
+ // multi-item structure for attributes like router-case `arguments`.
330
+ // In update mode we include entries that already have a translation.
331
+ // Invariant: findTranslations produces at most one entry per
332
+ // (uuid, attribute) pair, so the first fromValues is authoritative
333
+ // and any later entry with the same key is treated as a duplicate.
309
334
  const valuesByKey = new Map<string, string[]>();
310
335
 
311
336
  for (const bundle of bundles) {
312
337
  for (const entry of bundle.translations) {
313
- if (entry.to && entry.to.trim().length > 0) {
338
+ if (
339
+ !updateExisting &&
340
+ entry.toValues &&
341
+ entry.toValues.some((v) => v && v.trim().length > 0)
342
+ ) {
314
343
  continue;
315
344
  }
316
- if (!entry.from || entry.from.trim().length === 0) {
345
+ if (!entry.fromValues || entry.fromValues.length === 0) {
317
346
  continue;
318
347
  }
319
348
  const key = `${entry.uuid}:${entry.attribute}`;
320
349
  const list = keyToEntries.get(key) || [];
321
350
  list.push(entry);
322
351
  keyToEntries.set(key, list);
323
- const values = valuesByKey.get(key) || [];
324
- values.push(entry.from);
325
- valuesByKey.set(key, values);
352
+ if (!valuesByKey.has(key)) {
353
+ valuesByKey.set(key, entry.fromValues);
354
+ }
326
355
  }
327
356
  }
328
357
 
@@ -507,8 +536,39 @@ export class AutoTranslate extends RapidElement {
507
536
  const liveDefinition = zustand.getState().flowDefinition;
508
537
  const existing =
509
538
  liveDefinition?.localization?.[this.languageCode]?.[uuid] || {};
539
+ const existingValues = Array.isArray(existing[attribute])
540
+ ? (existing[attribute] as string[])
541
+ : null;
542
+
543
+ // Never replace an existing non-empty translation with an empty
544
+ // value: when re-translating already-localized content the LLM may
545
+ // return blanks for some entries; keep the prior translation in
546
+ // those slots. Only safe when the arrays align by index — if the
547
+ // source array length has changed since the last translation, the
548
+ // old values aren't tied to the same items anymore, so skip the
549
+ // per-item preserve and let the LLM result through unchanged.
550
+ const canPreservePerItem =
551
+ !!existingValues && existingValues.length === values.length;
552
+ const mergedValues = values.map((v, i) => {
553
+ const isEmpty =
554
+ v === null ||
555
+ v === undefined ||
556
+ (typeof v === 'string' && v.trim().length === 0);
557
+ if (isEmpty && canPreservePerItem && existingValues![i]) {
558
+ return existingValues![i];
559
+ }
560
+ return v;
561
+ });
562
+
563
+ const hasContent = mergedValues.some(
564
+ (v) => v && (typeof v !== 'string' || v.trim().length > 0)
565
+ );
566
+ if (!hasContent) {
567
+ continue;
568
+ }
569
+
510
570
  const merged = updatesByUuid.get(uuid) || { ...existing };
511
- merged[attribute] = values;
571
+ merged[attribute] = mergedValues;
512
572
  updatesByUuid.set(uuid, merged);
513
573
  }
514
574
 
@@ -642,11 +702,15 @@ export class AutoTranslate extends RapidElement {
642
702
 
643
703
  const selected = this.selectedModel ? [this.selectedModel] : [];
644
704
  const languageName = getLanguageDisplayName(this.languageCode);
705
+ const aiClause = this.brand
706
+ ? html`${this.brand} uses AI for automatic translation, which can make
707
+ mistakes,`
708
+ : html`Automatic translation uses AI, which can make mistakes,`;
645
709
  return html`
646
710
  <p>
647
711
  All remaining text for <strong>${languageName}</strong> will be
648
- translated automatically. Remember, AI models can make mistakes so it is
649
- important to review all of your translations to verify they are correct.
712
+ translated automatically. ${aiClause} so it is important to review all
713
+ of your translations to verify they are correct.
650
714
  </p>
651
715
  ${this.models.length > 1
652
716
  ? html`<temba-select
@@ -660,9 +724,13 @@ export class AutoTranslate extends RapidElement {
660
724
  placeholder="Select an AI model"
661
725
  @change=${this.handleModelChange}
662
726
  ></temba-select>`
663
- : html`<div class="auto-translate-single-model">
664
- Using <strong>${this.models[0]?.name}</strong>
665
- </div>`}
727
+ : ''}
728
+ <temba-checkbox
729
+ class="auto-translate-update-existing"
730
+ label="Update existing translations"
731
+ ?checked=${this.updateExisting}
732
+ @change=${this.handleUpdateExistingChange}
733
+ ></temba-checkbox>
666
734
  `;
667
735
  }
668
736
 
@@ -40,7 +40,6 @@ import type { RevisionsWindow } from './RevisionsWindow';
40
40
  import {
41
41
  ACTION_GROUP_METADATA,
42
42
  CONTEXT_MENU_SHORTCUTS,
43
- Features,
44
43
  FlowType,
45
44
  FlowTypes
46
45
  } from './types';
@@ -200,10 +199,6 @@ export class Editor extends RapidElement {
200
199
  @property({ type: Array })
201
200
  public features: string[] = [];
202
201
 
203
- private get autoTranslateEnabled(): boolean {
204
- return this.features?.includes(Features.AUTO_TRANSLATE) ?? false;
205
- }
206
-
207
202
  private activityTimer: number | null = null;
208
203
  private activityInterval = 100; // Start with 100ms interval for fast initial load
209
204
 
@@ -1601,6 +1596,9 @@ export class Editor extends RapidElement {
1601
1596
  }
1602
1597
 
1603
1598
  getStore().getState().setDirtyDate(null);
1599
+
1600
+ // Refresh the revisions list if it's currently open.
1601
+ this.getRevisionsWindow()?.refresh();
1604
1602
  })
1605
1603
  .catch((error) => {
1606
1604
  console.error('Failed to save flow:', error);
@@ -1760,7 +1758,7 @@ export class Editor extends RapidElement {
1760
1758
  }
1761
1759
 
1762
1760
  private handleAutoTranslateClick(): void {
1763
- if (!this.autoTranslateEnabled || this.viewingRevision) {
1761
+ if (this.viewingRevision) {
1764
1762
  return;
1765
1763
  }
1766
1764
  const at = this.querySelector('temba-auto-translate') as any;
@@ -3690,11 +3688,11 @@ export class Editor extends RapidElement {
3690
3688
  })
3691
3689
  ];
3692
3690
 
3691
+ // shown whenever the active language has any localizable content, even
3692
+ // at 100% — the dialog's "update existing" option lets users re-run
3693
+ // translation on already-translated entries.
3693
3694
  const hasPendingTranslations =
3694
- this.autoTranslateEnabled &&
3695
- Boolean(activeLanguage) &&
3696
- progress.total > 0 &&
3697
- progress.localized < progress.total;
3695
+ Boolean(activeLanguage) && progress.total > 0;
3698
3696
 
3699
3697
  return html`
3700
3698
  <temba-editor-toolbar
@@ -3928,14 +3926,12 @@ export class Editor extends RapidElement {
3928
3926
  @temba-revision-reverted=${this.handleRevisionReverted}
3929
3927
  @temba-revisions-closed=${this.handleRevisionsClosed}
3930
3928
  ></temba-revisions-window>
3931
- ${this.autoTranslateEnabled
3932
- ? html`<temba-auto-translate
3933
- .definition=${this.definition}
3934
- language-code=${this.languageCode}
3935
- ?disabled=${this.viewingRevision}
3936
- @temba-auto-translate-changed=${this.handleAutoTranslateChanged}
3937
- ></temba-auto-translate>`
3938
- : ''}
3929
+ <temba-auto-translate
3930
+ .definition=${this.definition}
3931
+ language-code=${this.languageCode}
3932
+ ?disabled=${this.viewingRevision}
3933
+ @temba-auto-translate-changed=${this.handleAutoTranslateChanged}
3934
+ ></temba-auto-translate>
3939
3935
  <div id="editor-container">
3940
3936
  ${this.renderToolbarElement()}
3941
3937
  <div id="editor">
@@ -7,18 +7,28 @@ import { getStore } from '../store/Store';
7
7
  import { FlowDefinition } from '../store/flow-definition';
8
8
  import { fetchResults } from '../utils';
9
9
  import { FLOW_SPEC_VERSION } from '../store/AppState';
10
+ import {
11
+ labelsFor,
12
+ RevisionChanges,
13
+ summarizeChanges
14
+ } from './revision-summary';
15
+
16
+ const GROUP_WINDOW_MS = 15 * 60 * 1000;
17
+ const MAX_GROUP_LABELS = 3;
10
18
 
11
19
  export interface Revision {
12
20
  id: number;
13
21
  user: {
14
- id: number;
15
- username: string;
16
- first_name: string;
17
- last_name: string;
22
+ id?: number;
23
+ email?: string;
24
+ username?: string;
25
+ first_name?: string;
26
+ last_name?: string;
18
27
  name?: string;
19
28
  };
20
29
  created_on: string;
21
30
  comment?: string;
31
+ changes?: RevisionChanges | null;
22
32
  }
23
33
 
24
34
  export class RevisionsWindow extends RapidElement {
@@ -53,11 +63,18 @@ export class RevisionsWindow extends RapidElement {
53
63
  dirtyDate: Date | null;
54
64
  } | null = null;
55
65
  private browseLanguageCode: string | null = null;
66
+ private fetchRequestId = 0;
56
67
 
57
68
  public get isViewingRevision(): boolean {
58
69
  return this.viewingRevision !== null;
59
70
  }
60
71
 
72
+ public refresh(): void {
73
+ if (!this.hidden) {
74
+ this.fetchRevisions();
75
+ }
76
+ }
77
+
61
78
  protected updated(changes: PropertyValues): void {
62
79
  super.updated(changes);
63
80
  if (
@@ -84,8 +101,8 @@ export class RevisionsWindow extends RapidElement {
84
101
  name="revisions"
85
102
  header="Revisions"
86
103
  icon="revisions"
87
- .width=${240}
88
- .maxHeight=${400}
104
+ .width=${340}
105
+ .maxHeight=${500}
89
106
  .top=${120}
90
107
  color="rgb(142, 94, 167)"
91
108
  .saving=${this.saving}
@@ -99,11 +116,15 @@ export class RevisionsWindow extends RapidElement {
99
116
  >
100
117
  ${this.isLoading && !this.revisions.length
101
118
  ? html`<temba-loading></temba-loading>`
102
- : this.revisions.map((rev) => {
119
+ : this.revisions.map((rev, index) => {
120
+ const isCurrent = index === 0;
103
121
  const isSelected = this.viewingRevision?.id === rev.id;
122
+ const summary = summarizeChanges(rev.changes);
104
123
  return html`
105
124
  <div
106
- class="revision-item ${isSelected ? 'selected' : ''}"
125
+ class="revision-item ${isSelected
126
+ ? 'selected'
127
+ : ''} ${isCurrent ? 'current' : ''}"
107
128
  style="padding:8px; border-radius:4px; cursor:pointer; background:${isSelected
108
129
  ? '#f0f6ff'
109
130
  : '#f9fafb'}; border:1px solid ${isSelected
@@ -111,36 +132,46 @@ export class RevisionsWindow extends RapidElement {
111
132
  : '#e5e7eb'}; transition: all 0.2s ease;"
112
133
  @click=${() => this.handleRevisionClick(rev)}
113
134
  >
135
+ ${summary
136
+ ? html`<div
137
+ class="revision-summary"
138
+ style="font-size:13px; color:#111827; line-height:1.3;"
139
+ >
140
+ ${summary}
141
+ </div>`
142
+ : ''}
114
143
  <div
115
- style="display:flex; justify-content:space-between; align-items:center;"
144
+ class="revision-meta"
145
+ style="display:flex; justify-content:space-between; align-items:center; gap:8px; min-height:20px; font-size:11px; color:#6b7280; margin-top:${summary
146
+ ? '2px'
147
+ : '0'};"
116
148
  >
117
- <div
118
- class="revision-header"
119
- style="margin-bottom: 2px;"
120
- >
121
- <div
122
- style="font-weight:600; font-size:13px; color:#111827;"
123
- >
124
- <temba-date
125
- value=${rev.created_on}
126
- display="duration"
127
- ></temba-date>
128
- </div>
129
- <div style="font-size:11px; color:#6b7280;">
130
- ${rev.user.name || rev.user.username}
131
- </div>
149
+ <div style="flex:1; min-width:0;">
150
+ <temba-date
151
+ value=${rev.created_on}
152
+ display="duration"
153
+ ></temba-date>
154
+ · ${rev.user.name || rev.user.username}
132
155
  </div>
133
- ${isSelected
134
- ? html`<button
135
- class="revert-button"
136
- @click=${(e: Event) => {
137
- e.stopPropagation();
138
- this.handleRevertClick();
139
- }}
156
+ ${isCurrent
157
+ ? html`<div
158
+ class="current-label"
159
+ style="font-size:10px; font-weight:600; text-transform:uppercase; color:#6b7280; background:#e5e7eb; padding:2px 6px; border-radius:10px; letter-spacing:0.5px; flex-shrink:0;"
140
160
  >
141
- Revert
142
- </button>`
143
- : html``}
161
+ Current
162
+ </div>`
163
+ : isSelected
164
+ ? html`<button
165
+ class="revert-button"
166
+ style="font-size:10px; font-weight:600; text-transform:uppercase; color:#1e3a8a; background:#a4cafe; padding:2px 6px; border-radius:10px; letter-spacing:0.5px; border:none; cursor:pointer; flex-shrink:0;"
167
+ @click=${(e: Event) => {
168
+ e.stopPropagation();
169
+ this.handleRevertClick();
170
+ }}
171
+ >
172
+ Revert
173
+ </button>`
174
+ : html``}
144
175
  </div>
145
176
 
146
177
  ${rev.comment
@@ -162,17 +193,92 @@ export class RevisionsWindow extends RapidElement {
162
193
  // --- Private ---
163
194
 
164
195
  private async fetchRevisions() {
196
+ const requestId = ++this.fetchRequestId;
165
197
  this.isLoading = true;
166
198
  try {
167
199
  const results = await fetchResults(
168
200
  `/flow/revisions/${this.flow}/?version=${FLOW_SPEC_VERSION}`
169
201
  );
170
- this.revisions = results.slice(1);
202
+ if (requestId !== this.fetchRequestId) return;
203
+ this.revisions = this.collapseRevisions(results);
171
204
  } catch (e) {
205
+ if (requestId !== this.fetchRequestId) return;
172
206
  console.error('Error fetching revisions', e);
173
207
  } finally {
174
- this.isLoading = false;
208
+ if (requestId === this.fetchRequestId) {
209
+ this.isLoading = false;
210
+ }
211
+ }
212
+ }
213
+
214
+ // Lump revisions made in a continuous editing session (within 15 minutes
215
+ // of each other, by the same author) onto their most recent member,
216
+ // merging the tag sets so the summary covers everything that happened in
217
+ // the window. The merged revision is capped at three distinct displayed
218
+ // labels — once a fourth would be introduced we break out into a new row.
219
+ private collapseRevisions(revisions: Revision[]): Revision[] {
220
+ // The API returns newest-first today; sort defensively so the head/window
221
+ // logic stays correct if that ever changes.
222
+ const sorted = [...revisions].sort(
223
+ (a, b) =>
224
+ new Date(b.created_on).getTime() - new Date(a.created_on).getTime()
225
+ );
226
+ const result: Revision[] = [];
227
+ let group: Revision[] = [];
228
+ let groupLabels = new Set<string>();
229
+
230
+ const flush = () => {
231
+ if (group.length === 0) return;
232
+ const head = group[0];
233
+ const tagSet = new Set<string>();
234
+ let anyKnown = false;
235
+ for (const r of group) {
236
+ if (r.changes) {
237
+ anyKnown = true;
238
+ for (const tag of r.changes.tags || []) tagSet.add(tag);
239
+ }
240
+ }
241
+ result.push({
242
+ ...head,
243
+ changes: anyKnown ? { tags: Array.from(tagSet) } : null
244
+ });
245
+ group = [];
246
+ groupLabels = new Set();
247
+ };
248
+
249
+ for (const rev of sorted) {
250
+ if (group.length === 0) {
251
+ group.push(rev);
252
+ groupLabels = labelsFor(rev.changes);
253
+ continue;
254
+ }
255
+ const head = group[0];
256
+ const headTime = new Date(head.created_on).getTime();
257
+ const revTime = new Date(rev.created_on).getTime();
258
+ const withinWindow = headTime - revTime < GROUP_WINDOW_MS;
259
+ // Compare on whichever identifier the server provides — real data
260
+ // arrives with `email`, while test fixtures use `username`. Falling
261
+ // back through the chain keeps both shapes working.
262
+ const headId = head.user?.email ?? head.user?.username;
263
+ const revId = rev.user?.email ?? rev.user?.username;
264
+ const sameAuthor = headId === revId;
265
+ const prospective = new Set([
266
+ ...groupLabels,
267
+ ...labelsFor(rev.changes)
268
+ ]);
269
+ const fitsLabelCap = prospective.size <= MAX_GROUP_LABELS;
270
+
271
+ if (withinWindow && sameAuthor && fitsLabelCap) {
272
+ group.push(rev);
273
+ groupLabels = prospective;
274
+ } else {
275
+ flush();
276
+ group.push(rev);
277
+ groupLabels = labelsFor(rev.changes);
278
+ }
175
279
  }
280
+ flush();
281
+ return result;
176
282
  }
177
283
 
178
284
  private async handleRevisionClick(revision: Revision) {
@@ -10,6 +10,12 @@ export interface TranslationEntry {
10
10
  attribute: string;
11
11
  from: string;
12
12
  to: string | null;
13
+ // Original array form of the source/target. For attributes whose value
14
+ // is an array (e.g. router case `arguments`), these preserve the per-item
15
+ // structure that `from`/`to` would otherwise lose to comma-joining. For
16
+ // scalar attributes the arrays contain a single item.
17
+ fromValues: string[];
18
+ toValues: string[] | null;
13
19
  }
14
20
 
15
21
  export interface TranslationBundle {
@@ -97,12 +103,29 @@ export function findTranslations(
97
103
 
98
104
  const toValue = to ? formatTranslationValue(to) : null;
99
105
 
106
+ const toArray = (value: any): string[] => {
107
+ if (Array.isArray(value)) {
108
+ return value.map((v) =>
109
+ v === null || v === undefined ? '' : String(v)
110
+ );
111
+ }
112
+ if (value === null || value === undefined) {
113
+ return [];
114
+ }
115
+ return [String(value)];
116
+ };
117
+
118
+ const fromValues = toArray(from);
119
+ const toValues = to !== null && to !== undefined ? toArray(to) : null;
120
+
100
121
  translations.push({
101
122
  uuid,
102
123
  type,
103
124
  attribute,
104
125
  from: fromValue,
105
- to: toValue
126
+ to: toValue,
127
+ fromValues,
128
+ toValues
106
129
  });
107
130
  });
108
131
 
@@ -0,0 +1,62 @@
1
+ export interface RevisionChanges {
2
+ tags: string[];
3
+ }
4
+
5
+ const TAG_LABELS: Record<string, { label: string; order: number }> = {
6
+ metadata: { label: 'metadata', order: 0 },
7
+ nodes: { label: 'nodes', order: 1 },
8
+ routing: { label: 'routing', order: 2 },
9
+ actions: { label: 'actions', order: 3 },
10
+ stickies: { label: 'stickies', order: 5 },
11
+ layout: { label: 'layout', order: 6 }
12
+ };
13
+
14
+ function tagToLabel(tag: string): { label: string; order: number } | null {
15
+ if (Object.prototype.hasOwnProperty.call(TAG_LABELS, tag)) {
16
+ return TAG_LABELS[tag];
17
+ }
18
+ if (tag.startsWith('localization:')) {
19
+ return { label: 'translations', order: 4 };
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export function labelsFor(
25
+ changes: RevisionChanges | null | undefined
26
+ ): Set<string> {
27
+ const result = new Set<string>();
28
+ for (const tag of changes?.tags || []) {
29
+ const entry = tagToLabel(tag);
30
+ if (entry) result.add(entry.label);
31
+ }
32
+ return result;
33
+ }
34
+
35
+ export function summarizeChanges(
36
+ changes: RevisionChanges | null | undefined
37
+ ): string {
38
+ if (!changes) return '';
39
+ const tags = changes.tags || [];
40
+ if (tags.length === 0) return '';
41
+
42
+ const orders = new Map<string, number>();
43
+ for (const tag of tags) {
44
+ const entry = tagToLabel(tag);
45
+ if (entry && !orders.has(entry.label)) {
46
+ orders.set(entry.label, entry.order);
47
+ }
48
+ }
49
+ if (orders.size === 0) return '';
50
+
51
+ const labels = Array.from(orders.entries())
52
+ .sort((a, b) => a[1] - b[1])
53
+ .map(([label]) => label);
54
+
55
+ return `Changed ${joinNaturally(labels)}`;
56
+ }
57
+
58
+ function joinNaturally(parts: string[]): string {
59
+ if (parts.length <= 1) return parts.join('');
60
+ if (parts.length === 2) return `${parts[0]} and ${parts[1]}`;
61
+ return `${parts.slice(0, -1).join(', ')}, and ${parts[parts.length - 1]}`;
62
+ }