@startsimpli/ui 0.4.135 → 0.4.136
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 +4 -3
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/history/RecordHistoryPanel.tsx +303 -0
- package/src/components/history/RevisionFields.tsx +224 -0
- package/src/components/history/__tests__/record-history-panel.test.tsx +312 -0
- package/src/components/history/__tests__/revision-history.test.ts +355 -0
- package/src/components/history/__tests__/text-diff.test.ts +59 -0
- package/src/components/history/index.ts +62 -0
- package/src/components/history/record-history-panel-default-class-names.ts +29 -0
- package/src/components/history/revision-history.ts +484 -0
- package/src/components/history/text-diff.ts +139 -0
- package/src/components/history/types.ts +167 -0
- package/src/components/index.ts +11 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The render-time fold, and what one field change actually SAYS
|
|
3
|
+
* (bd startsim-g4rwo; trail bd startsim-o1qib; extraction bd startsim-m7fdm.1).
|
|
4
|
+
*
|
|
5
|
+
* Pure — no React, no fetching — so every rule below unit-tests without a
|
|
6
|
+
* backend and without a render.
|
|
7
|
+
*
|
|
8
|
+
* ── WHY THE FOLD IS HERE AND NOT ON THE SERVER ──────────────────────────────
|
|
9
|
+
*
|
|
10
|
+
* There is NO server-side coalescing, deliberately: the trail is one bare
|
|
11
|
+
* INSERT per write, because growing an open row inside a window is a
|
|
12
|
+
* read-modify-write on a path where the autosave debounce is 1200ms and two
|
|
13
|
+
* saves from one reviewer are routinely in flight — which is precisely the
|
|
14
|
+
* mechanism that drops `data._edit_history` entries today, one table over. A
|
|
15
|
+
* revision that claims text the record does not hold is worse than a missing
|
|
16
|
+
* one. So the server stays dumb and correct, and the folding happens here.
|
|
17
|
+
*
|
|
18
|
+
* The consequence this module exists to absorb: a reviewer working through a
|
|
19
|
+
* 500-word blog issues DOZENS of PATCHes, so a sitting is dozens of rows. One
|
|
20
|
+
* row per PATCH is not accountability, it is a wall of near-identical
|
|
21
|
+
* timestamps that nobody reads — and the wall is worse than nothing, because it
|
|
22
|
+
* hides the one thing the customer asked for.
|
|
23
|
+
*
|
|
24
|
+
* ── WHY FIVE MINUTES ────────────────────────────────────────────────────────
|
|
25
|
+
*
|
|
26
|
+
* Not because five is special — because of where the gaps actually fall. Inside
|
|
27
|
+
* one sitting at the keyboard the gaps are SECONDS (the debounce is 1.2s and
|
|
28
|
+
* typing is continuous), so almost any window kills the autosave noise. What a
|
|
29
|
+
* window has to get right is the other side: the smallest gap that means "she
|
|
30
|
+
* stopped, and came back". Five minutes is comfortably longer than reading a
|
|
31
|
+
* rendered preview or fixing an issue in the rail, and comfortably shorter than
|
|
32
|
+
* leaving the desk. Longer windows only merge sittings that really were
|
|
33
|
+
* separate, which is a loss of truth for no gain in quiet.
|
|
34
|
+
*
|
|
35
|
+
* AND IT IS ONE NUMBER: 0 gives a row per save, higher gives a row per visit.
|
|
36
|
+
* Nothing else in this module knows which a tenant wants.
|
|
37
|
+
*/
|
|
38
|
+
import type {
|
|
39
|
+
ActorKind,
|
|
40
|
+
ActorKindSource,
|
|
41
|
+
EntityRevision,
|
|
42
|
+
RevisionFieldChange,
|
|
43
|
+
} from './types';
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* How close two writes by the same actor have to be to count as one sitting.
|
|
47
|
+
* See this module's header for why five minutes. Carried over verbatim from
|
|
48
|
+
* `marketing-agents-web/src/lib/edit-history.ts`, whose `COLLAPSE_WINDOW_MS`
|
|
49
|
+
* this replaces.
|
|
50
|
+
*/
|
|
51
|
+
export const COLLAPSE_WINDOW_MS = 5 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/** A side of a field change, with ABSENCE made explicit.
|
|
54
|
+
*
|
|
55
|
+
* `hasBefore`/`hasAfter` exist because `before`/`after` can legitimately BE
|
|
56
|
+
* `null` — a stored null and a key that was never there are different facts,
|
|
57
|
+
* and `entry.after === undefined` collapses them. */
|
|
58
|
+
export interface FoldedFieldChange {
|
|
59
|
+
hasBefore: boolean;
|
|
60
|
+
before?: unknown;
|
|
61
|
+
hasAfter: boolean;
|
|
62
|
+
after?: unknown;
|
|
63
|
+
/** Either side was too large to store, anywhere in the fold. */
|
|
64
|
+
truncated: boolean;
|
|
65
|
+
/** The per-field read gate withheld this field from this caller. */
|
|
66
|
+
withheld: boolean;
|
|
67
|
+
/** The ROW was over the byte cap: the field moved, the values are not here. */
|
|
68
|
+
omitted: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a field entry actually says, as one word.
|
|
73
|
+
*
|
|
74
|
+
* THE ORDER IS LOAD-BEARING. `withheld` and `omitted` are checked FIRST because
|
|
75
|
+
* both arrive as `{}` — indistinguishable from each other and from a created
|
|
76
|
+
* key by shape alone, and distinguishable only by the row's `redacted_fields`
|
|
77
|
+
* and `values_omitted`. `truncated` is checked BEFORE the created/deleted
|
|
78
|
+
* inference because a truncated side's absence says nothing, so calling it a
|
|
79
|
+
* deletion would invent one.
|
|
80
|
+
*/
|
|
81
|
+
export type FieldChangeKind =
|
|
82
|
+
| 'withheld'
|
|
83
|
+
| 'omitted'
|
|
84
|
+
| 'truncated'
|
|
85
|
+
| 'created'
|
|
86
|
+
| 'deleted'
|
|
87
|
+
| 'updated';
|
|
88
|
+
|
|
89
|
+
export function classifyFieldChange(change: FoldedFieldChange): FieldChangeKind {
|
|
90
|
+
if (change.withheld) return 'withheld';
|
|
91
|
+
if (change.omitted) return 'omitted';
|
|
92
|
+
if (change.truncated) return 'truncated';
|
|
93
|
+
if (!change.hasBefore && change.hasAfter) return 'created';
|
|
94
|
+
if (change.hasBefore && !change.hasAfter) return 'deleted';
|
|
95
|
+
return 'updated';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Whether a within-field diff can honestly be rendered for this change.
|
|
99
|
+
*
|
|
100
|
+
* REFUSES A TRUNCATED SIDE, which is the whole point: a diff over a silently
|
|
101
|
+
* truncated value shows a deletion that never happened. Also refuses anything
|
|
102
|
+
* withheld or omitted (there are no values), and anything that is not text on
|
|
103
|
+
* both sides (a line diff over a dict is noise). */
|
|
104
|
+
export function canDiffFieldChange(change: FoldedFieldChange): boolean {
|
|
105
|
+
if (change.truncated || change.withheld || change.omitted) return false;
|
|
106
|
+
return typeof change.before === 'string' && typeof change.after === 'string';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* -------------------------------------------------------------------------- */
|
|
110
|
+
/* READING THE WIRE */
|
|
111
|
+
/* -------------------------------------------------------------------------- */
|
|
112
|
+
|
|
113
|
+
/** First non-empty of several candidate spellings — the snake/camel tolerance
|
|
114
|
+
* described in `types.ts`. */
|
|
115
|
+
function pick<T>(...values: Array<T | null | undefined>): T | undefined {
|
|
116
|
+
for (const value of values) {
|
|
117
|
+
if (value !== null && value !== undefined && value !== '') return value;
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function strings(value: unknown): string[] {
|
|
123
|
+
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The three-state kind, FAILING CLOSED.
|
|
127
|
+
*
|
|
128
|
+
* Anything outside the vocabulary — absent, empty, a value from a future
|
|
129
|
+
* build — reads `unknown`, and NEVER `person`. A trail that labels a machine
|
|
130
|
+
* as your colleague is worse than no trail at all, so the floor is the state
|
|
131
|
+
* that claims nothing. */
|
|
132
|
+
export function actorKindOf(revision: EntityRevision): ActorKind {
|
|
133
|
+
const raw = pick(revision.actor_kind, revision.actorKind);
|
|
134
|
+
return raw === 'person' || raw === 'machine' ? raw : 'unknown';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether the kind was CLAIMED or GUESSED. `undefined` when the row does not
|
|
138
|
+
* say, which a row from a pre-rollout build will not. */
|
|
139
|
+
export function actorKindSourceOf(revision: EntityRevision): ActorKindSource | undefined {
|
|
140
|
+
const raw = pick(revision.actor_kind_source, revision.actorKindSource);
|
|
141
|
+
return raw === 'claim' || raw === 'inferred' ? raw : undefined;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* One `changed` entry read into {@link FoldedFieldChange}.
|
|
146
|
+
*
|
|
147
|
+
* `'before' in entry` rather than `entry.before !== undefined`, because `null`
|
|
148
|
+
* is a legal stored value and the server never writes `after: null` to mean
|
|
149
|
+
* "deleted" — it omits the key. Collapsing the two would turn every
|
|
150
|
+
* null-valued field into a deletion.
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeFieldChange(
|
|
153
|
+
entry: RevisionFieldChange | undefined,
|
|
154
|
+
options: { withheld?: boolean; omitted?: boolean } = {},
|
|
155
|
+
): FoldedFieldChange {
|
|
156
|
+
const raw = entry && typeof entry === 'object' ? entry : {};
|
|
157
|
+
const hasBefore = Object.prototype.hasOwnProperty.call(raw, 'before');
|
|
158
|
+
const hasAfter = Object.prototype.hasOwnProperty.call(raw, 'after');
|
|
159
|
+
return {
|
|
160
|
+
hasBefore,
|
|
161
|
+
...(hasBefore ? { before: raw.before } : {}),
|
|
162
|
+
hasAfter,
|
|
163
|
+
...(hasAfter ? { after: raw.after } : {}),
|
|
164
|
+
truncated: raw.truncated === true,
|
|
165
|
+
withheld: options.withheld === true,
|
|
166
|
+
omitted: options.omitted === true,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** An ISO instant we can actually order by, or undefined. */
|
|
171
|
+
function instant(value: unknown): string | undefined {
|
|
172
|
+
if (typeof value !== 'string') return undefined;
|
|
173
|
+
return Number.isNaN(Date.parse(value)) ? undefined : value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** One revision row, read defensively into a single spelling. */
|
|
177
|
+
export interface NormalizedRevision {
|
|
178
|
+
id: string;
|
|
179
|
+
version: number;
|
|
180
|
+
action: string;
|
|
181
|
+
source: string;
|
|
182
|
+
precondition: string;
|
|
183
|
+
/** ISO-8601, or `''` when the row carried no readable timestamp. */
|
|
184
|
+
createdAt: string;
|
|
185
|
+
actorSub: string;
|
|
186
|
+
actorLabel: string;
|
|
187
|
+
actorKind: ActorKind;
|
|
188
|
+
actorKindSource?: ActorKindSource;
|
|
189
|
+
changed: Record<string, FoldedFieldChange>;
|
|
190
|
+
name?: FoldedFieldChange;
|
|
191
|
+
preserved: string[];
|
|
192
|
+
regenerate: string[];
|
|
193
|
+
redacted: string[];
|
|
194
|
+
valuesOmitted: boolean;
|
|
195
|
+
/** A machine ATTEMPT: something was held back and nothing moved. */
|
|
196
|
+
isAttempt: boolean;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function normalizeRevision(revision: EntityRevision): NormalizedRevision {
|
|
200
|
+
const metadata = revision.metadata && typeof revision.metadata === 'object' ? revision.metadata : {};
|
|
201
|
+
const redacted = strings(pick(revision.redacted_fields, revision.redactedFields));
|
|
202
|
+
const valuesOmitted =
|
|
203
|
+
metadata.values_omitted === true || metadata.valuesOmitted === true;
|
|
204
|
+
const rawChanged =
|
|
205
|
+
metadata.changed && typeof metadata.changed === 'object' ? metadata.changed : {};
|
|
206
|
+
|
|
207
|
+
const changed: Record<string, FoldedFieldChange> = {};
|
|
208
|
+
for (const [field, entry] of Object.entries(rawChanged)) {
|
|
209
|
+
changed[field] = normalizeFieldChange(entry, {
|
|
210
|
+
withheld: redacted.includes(field),
|
|
211
|
+
omitted: valuesOmitted,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const preserved = strings(metadata.preserved);
|
|
216
|
+
return {
|
|
217
|
+
id: String(revision.id ?? ''),
|
|
218
|
+
version: typeof revision.version === 'number' ? revision.version : 0,
|
|
219
|
+
action: String(pick(revision.action) ?? 'updated'),
|
|
220
|
+
source: String(pick(revision.source) ?? ''),
|
|
221
|
+
precondition: String(pick(revision.precondition) ?? ''),
|
|
222
|
+
createdAt: instant(pick(revision.created_at, revision.createdAt)) ?? '',
|
|
223
|
+
actorSub: String(pick(revision.actor_sub, revision.actorSub) ?? ''),
|
|
224
|
+
actorLabel: String(pick(revision.actor_label, revision.actorLabel) ?? ''),
|
|
225
|
+
actorKind: actorKindOf(revision),
|
|
226
|
+
...(actorKindSourceOf(revision) ? { actorKindSource: actorKindSourceOf(revision)! } : {}),
|
|
227
|
+
changed,
|
|
228
|
+
...(metadata.name
|
|
229
|
+
? { name: normalizeFieldChange(metadata.name, { omitted: valuesOmitted }) }
|
|
230
|
+
: {}),
|
|
231
|
+
preserved,
|
|
232
|
+
regenerate: strings(metadata.regenerate),
|
|
233
|
+
redacted,
|
|
234
|
+
valuesOmitted,
|
|
235
|
+
isAttempt: preserved.length > 0 && Object.keys(changed).length === 0,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* -------------------------------------------------------------------------- */
|
|
240
|
+
/* THE FOLD */
|
|
241
|
+
/* -------------------------------------------------------------------------- */
|
|
242
|
+
|
|
243
|
+
/** One sitting: who, over what span, and what moved across the whole of it. */
|
|
244
|
+
export interface RevisionSitting {
|
|
245
|
+
/** Stable React key — the newest folded row's id. */
|
|
246
|
+
id: string;
|
|
247
|
+
/** The rows folded in, NEWEST FIRST, so "show each save" needs no re-sort. */
|
|
248
|
+
revisions: NormalizedRevision[];
|
|
249
|
+
actorSub: string;
|
|
250
|
+
actorLabel: string;
|
|
251
|
+
actorKind: ActorKind;
|
|
252
|
+
actorKindSource?: ActorKindSource;
|
|
253
|
+
/** Version of the OLDEST row folded in. */
|
|
254
|
+
fromVersion: number;
|
|
255
|
+
/** Version of the NEWEST row folded in. */
|
|
256
|
+
toVersion: number;
|
|
257
|
+
/** When the sitting started (oldest row). */
|
|
258
|
+
from: string;
|
|
259
|
+
/** When it last saved (newest row). */
|
|
260
|
+
at: string;
|
|
261
|
+
/** How many rows folded in. At least 1. */
|
|
262
|
+
saves: number;
|
|
263
|
+
/** `created` if the sitting contains the record's creation, else the newest
|
|
264
|
+
* row's action. */
|
|
265
|
+
action: string;
|
|
266
|
+
/** Per field: BEFORE from the oldest row that touched it, AFTER from the
|
|
267
|
+
* newest. */
|
|
268
|
+
changed: Record<string, FoldedFieldChange>;
|
|
269
|
+
name?: FoldedFieldChange;
|
|
270
|
+
preserved: string[];
|
|
271
|
+
regenerate: string[];
|
|
272
|
+
redacted: string[];
|
|
273
|
+
valuesOmitted: boolean;
|
|
274
|
+
isAttempt: boolean;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Whether two adjacent rows are the same sitting.
|
|
279
|
+
*
|
|
280
|
+
* FOUR REFUSALS, each of which would otherwise produce a confident lie:
|
|
281
|
+
*
|
|
282
|
+
* 1. A DIFFERENT ACTOR is a different sitting, obviously — and that includes a
|
|
283
|
+
* different ABSENCE of one, treated as its own actor rather than matching
|
|
284
|
+
* everybody.
|
|
285
|
+
* 2. A DIFFERENT `actorKind` OR `actorKindSource` NEVER FOLDS, even when the
|
|
286
|
+
* sub matches. During the healing window one person's rows legitimately
|
|
287
|
+
* carry `unknown` then `person`, and folding them would stamp the whole
|
|
288
|
+
* sitting with whichever end won — manufacturing a claim the data does not
|
|
289
|
+
* make. bd startsim-jkkn7.2 says fail closed on absence; refusing the fold
|
|
290
|
+
* is the honest version of that.
|
|
291
|
+
* 3. AN ATTEMPT NEVER FOLDS INTO A CHANGE. A `preserved` row records that a
|
|
292
|
+
* machine tried to write and was held back; merging it into a row that DID
|
|
293
|
+
* move a field would read as the attempt having succeeded, which inverts the
|
|
294
|
+
* one fact the actor axis exists to record.
|
|
295
|
+
* 4. A CREATE NEVER ABSORBS A LATER EDIT, so "created" keeps meaning the
|
|
296
|
+
* record's version 1 rather than "created, and then some".
|
|
297
|
+
*
|
|
298
|
+
* And the window itself is checked on the gap between the two rows, `>= 0`
|
|
299
|
+
* guarding a clock that went backwards: an out-of-order row extends the sitting
|
|
300
|
+
* rather than opening one that appears to precede it.
|
|
301
|
+
*/
|
|
302
|
+
function sameSitting(
|
|
303
|
+
newer: NormalizedRevision,
|
|
304
|
+
older: NormalizedRevision,
|
|
305
|
+
windowMs: number,
|
|
306
|
+
): boolean {
|
|
307
|
+
if (newer.actorSub !== older.actorSub) return false;
|
|
308
|
+
if (newer.actorKind !== older.actorKind) return false;
|
|
309
|
+
if (newer.actorKindSource !== older.actorKindSource) return false;
|
|
310
|
+
if (newer.isAttempt !== older.isAttempt) return false;
|
|
311
|
+
if (older.action === 'created' || newer.action === 'created') return false;
|
|
312
|
+
if (!newer.createdAt || !older.createdAt) return false;
|
|
313
|
+
const gap = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
|
|
314
|
+
return gap >= 0 && gap <= windowMs;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Merge an OLDER row's field change into a sitting that already holds the
|
|
319
|
+
* newer one.
|
|
320
|
+
*
|
|
321
|
+
* THE DIRECTION IS THE WHOLE POINT. `before` must come from the OLDEST row that
|
|
322
|
+
* touched the field and `after` from the NEWEST, so a sitting reports what the
|
|
323
|
+
* paragraph was when she sat down and what it was when she left. A naive union
|
|
324
|
+
* takes whichever side it saw last and reports an intermediate autosave as the
|
|
325
|
+
* final text — which is exactly the kind of confidently-wrong value this trail
|
|
326
|
+
* exists to replace.
|
|
327
|
+
*
|
|
328
|
+
* Flags UNION rather than overwrite: a sitting that folded one truncated,
|
|
329
|
+
* withheld or omitted row carries that forward, because a reader who is shown
|
|
330
|
+
* a clean diff built partly from a withheld row has been told something false.
|
|
331
|
+
*/
|
|
332
|
+
function mergeOlder(into: FoldedFieldChange, older: FoldedFieldChange): FoldedFieldChange {
|
|
333
|
+
return {
|
|
334
|
+
// BEFORE moves back to the older row's.
|
|
335
|
+
hasBefore: older.hasBefore,
|
|
336
|
+
...(older.hasBefore ? { before: older.before } : {}),
|
|
337
|
+
// AFTER stays with the newer row's.
|
|
338
|
+
hasAfter: into.hasAfter,
|
|
339
|
+
...(into.hasAfter ? { after: into.after } : {}),
|
|
340
|
+
truncated: into.truncated || older.truncated,
|
|
341
|
+
withheld: into.withheld || older.withheld,
|
|
342
|
+
omitted: into.omitted || older.omitted,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function startSitting(revision: NormalizedRevision): RevisionSitting {
|
|
347
|
+
return {
|
|
348
|
+
id: revision.id,
|
|
349
|
+
revisions: [revision],
|
|
350
|
+
actorSub: revision.actorSub,
|
|
351
|
+
actorLabel: revision.actorLabel,
|
|
352
|
+
actorKind: revision.actorKind,
|
|
353
|
+
...(revision.actorKindSource ? { actorKindSource: revision.actorKindSource } : {}),
|
|
354
|
+
fromVersion: revision.version,
|
|
355
|
+
toVersion: revision.version,
|
|
356
|
+
from: revision.createdAt,
|
|
357
|
+
at: revision.createdAt,
|
|
358
|
+
saves: 1,
|
|
359
|
+
action: revision.action,
|
|
360
|
+
changed: { ...revision.changed },
|
|
361
|
+
...(revision.name ? { name: revision.name } : {}),
|
|
362
|
+
preserved: [...revision.preserved],
|
|
363
|
+
regenerate: [...revision.regenerate],
|
|
364
|
+
redacted: [...revision.redacted],
|
|
365
|
+
valuesOmitted: revision.valuesOmitted,
|
|
366
|
+
isAttempt: revision.isAttempt,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function absorb(sitting: RevisionSitting, older: NormalizedRevision): void {
|
|
371
|
+
sitting.revisions.push(older);
|
|
372
|
+
sitting.saves += 1;
|
|
373
|
+
sitting.fromVersion = older.version;
|
|
374
|
+
sitting.from = older.createdAt || sitting.from;
|
|
375
|
+
if (older.action === 'created') sitting.action = 'created';
|
|
376
|
+
|
|
377
|
+
for (const [field, change] of Object.entries(older.changed)) {
|
|
378
|
+
sitting.changed[field] = sitting.changed[field]
|
|
379
|
+
? mergeOlder(sitting.changed[field]!, change)
|
|
380
|
+
: change;
|
|
381
|
+
}
|
|
382
|
+
if (older.name) {
|
|
383
|
+
sitting.name = sitting.name ? mergeOlder(sitting.name, older.name) : older.name;
|
|
384
|
+
}
|
|
385
|
+
const union = (a: string[], b: string[]) => [...new Set([...a, ...b])].sort();
|
|
386
|
+
sitting.preserved = union(sitting.preserved, older.preserved);
|
|
387
|
+
sitting.regenerate = union(sitting.regenerate, older.regenerate);
|
|
388
|
+
sitting.redacted = union(sitting.redacted, older.redacted);
|
|
389
|
+
sitting.valuesOmitted = sitting.valuesOmitted || older.valuesOmitted;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Fold a NEWEST-FIRST page of revisions into sittings, newest first.
|
|
394
|
+
*
|
|
395
|
+
* Takes the rows exactly as the endpoint ordered them (`-version`, then
|
|
396
|
+
* `-created_at`) and folds only ADJACENT rows, so the result stays in the
|
|
397
|
+
* server's order and no row is reordered on the strength of a timestamp.
|
|
398
|
+
*
|
|
399
|
+
* FOLD OVER THE ACCUMULATED PAGES, NOT PER PAGE. The panel loads pages with
|
|
400
|
+
* `useInfiniteQuery` and folds the flattened array, which is why a sitting
|
|
401
|
+
* straddling a page boundary is folded once rather than appearing as "3 saves"
|
|
402
|
+
* followed by "2 saves". Calling this per page would be wrong and silently so.
|
|
403
|
+
*/
|
|
404
|
+
export function foldRevisions(
|
|
405
|
+
revisions: EntityRevision[],
|
|
406
|
+
windowMs: number = COLLAPSE_WINDOW_MS,
|
|
407
|
+
): RevisionSitting[] {
|
|
408
|
+
const rows = (revisions ?? []).map(normalizeRevision);
|
|
409
|
+
const out: RevisionSitting[] = [];
|
|
410
|
+
for (const row of rows) {
|
|
411
|
+
const open = out[out.length - 1];
|
|
412
|
+
// `open.revisions[open.revisions.length - 1]` is the OLDEST row folded so
|
|
413
|
+
// far, which is the one this (older still) row is adjacent to.
|
|
414
|
+
const tail = open?.revisions[open.revisions.length - 1];
|
|
415
|
+
if (open && tail && sameSitting(tail, row, windowMs)) {
|
|
416
|
+
absorb(open, row);
|
|
417
|
+
} else {
|
|
418
|
+
out.push(startSitting(row));
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* What the sitting collapsed, in words.
|
|
426
|
+
*
|
|
427
|
+
* A COLLAPSED SITTING MUST SAY IT IS A COLLAPSE. A bare timestamp over twelve
|
|
428
|
+
* folded saves quietly claims a precision it does not have — the reader should
|
|
429
|
+
* be able to see that this was a sitting, not a keystroke. Locale-free on
|
|
430
|
+
* purpose: the absolute time is rendered by the row, this is only the shape of
|
|
431
|
+
* the sitting.
|
|
432
|
+
*/
|
|
433
|
+
export function sittingSummary(sitting: RevisionSitting): string {
|
|
434
|
+
if (sitting.saves <= 1) return '1 save';
|
|
435
|
+
const minutes = Math.floor((Date.parse(sitting.at) - Date.parse(sitting.from)) / 60_000);
|
|
436
|
+
return minutes >= 1 ? `${sitting.saves} saves over ${minutes} min` : `${sitting.saves} saves`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The fields a sitting touched, in a stable order, so two renders of the same
|
|
440
|
+
* sitting never disagree about which field comes first. */
|
|
441
|
+
export function sittingFields(sitting: RevisionSitting): string[] {
|
|
442
|
+
return Object.keys(sitting.changed).sort();
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** "a", "a and b", "a, b and c" — an Oxford-free list, because these are field
|
|
446
|
+
* names in a sentence and not prose. */
|
|
447
|
+
export function joinNames(names: string[]): string {
|
|
448
|
+
if (names.length === 0) return '';
|
|
449
|
+
if (names.length === 1) return names[0]!;
|
|
450
|
+
return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The sitting's headline — one line that says what happened.
|
|
455
|
+
*
|
|
456
|
+
* AN ATTEMPT GETS ITS OWN SENTENCE, and it is the sentence nobody could produce
|
|
457
|
+
* when the 41 machine-written notes could not be cleaned up: "tried to write
|
|
458
|
+
* team_verdict — held back". It must never read as a change, because the record
|
|
459
|
+
* did not move; what happened is that a machine was stopped.
|
|
460
|
+
*
|
|
461
|
+
* Beyond about three fields the names stop helping and the count starts: a
|
|
462
|
+
* headline listing nine attribute names is not scannable, and the full list is
|
|
463
|
+
* one row down anyway.
|
|
464
|
+
*/
|
|
465
|
+
export function sittingHeadline(
|
|
466
|
+
sitting: RevisionSitting,
|
|
467
|
+
fieldLabel: (field: string) => string = (field) => field,
|
|
468
|
+
): string {
|
|
469
|
+
if (sitting.isAttempt) {
|
|
470
|
+
const names = joinNames(sitting.preserved.map(fieldLabel));
|
|
471
|
+
return names ? `Tried to write ${names} — held back` : 'A write was held back';
|
|
472
|
+
}
|
|
473
|
+
const fields = sittingFields(sitting);
|
|
474
|
+
const renamed = sitting.name !== undefined;
|
|
475
|
+
if (sitting.action === 'created') {
|
|
476
|
+
return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
|
|
477
|
+
}
|
|
478
|
+
if (!fields.length) return renamed ? 'Renamed the record' : 'Saved the record';
|
|
479
|
+
const named =
|
|
480
|
+
fields.length <= 3
|
|
481
|
+
? `Edited ${joinNames(fields.map(fieldLabel))}`
|
|
482
|
+
: `Edited ${fields.length} fields`;
|
|
483
|
+
return renamed ? `${named}, and renamed it` : named;
|
|
484
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn two revisions of one text field into a git-style unified diff string
|
|
3
|
+
* (bd startsim-g4rwo; originally marketing-agents-web `src/lib/blog-diff.ts`,
|
|
4
|
+
* bd 768w.16.10.5).
|
|
5
|
+
*
|
|
6
|
+
* This is what makes the epic's answer to "which paragraph moved" nearly free
|
|
7
|
+
* rather than a second build: {@link DiffViewer} already renders a RAW unified
|
|
8
|
+
* diff (it splits on `diff --git` headers, colourises `@@` hunks and `+`/`-`
|
|
9
|
+
* lines) and does no diffing itself, so showing a field against its previous
|
|
10
|
+
* value only needs that string synthesised from the two values. Pure and
|
|
11
|
+
* line-based (LCS), so it unit-tests without a backend and adds no dependency.
|
|
12
|
+
*
|
|
13
|
+
* RENAMED FROM `blog-diff` / `unifiedBlogDiff` ON THE WAY IN, deliberately.
|
|
14
|
+
* The algorithm never knew what a blog was — it takes two strings and a path
|
|
15
|
+
* label — and `packages/ui` is consumed by every foundry fork, most of which
|
|
16
|
+
* have no blogs. A shared module carrying one tenant's vocabulary is how
|
|
17
|
+
* surfaces end up unable to reuse it. `path` is what names the field in the
|
|
18
|
+
* rendered header, so a caller passes the field's own name.
|
|
19
|
+
*
|
|
20
|
+
* DO NOT CALL THIS ON A TRUNCATED SIDE. A diff over a silently truncated value
|
|
21
|
+
* shows a deletion that never happened; `canDiffFieldChange` in
|
|
22
|
+
* `revision-history.ts` is the gate, and the panel says why it refused rather
|
|
23
|
+
* than rendering nothing.
|
|
24
|
+
*
|
|
25
|
+
* The algorithm: an LCS edit script over lines, grouped into hunks with a few
|
|
26
|
+
* lines of surrounding context (like `git diff -U<n>`). Identical inputs yield
|
|
27
|
+
* '' so the viewer shows its own empty state rather than an all-context block.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
type Op = { type: 'eq' | 'del' | 'ins'; line: string };
|
|
31
|
+
|
|
32
|
+
/** Split into lines, tolerating CRLF and a trailing newline (no phantom last line). */
|
|
33
|
+
function toLines(text: string): string[] {
|
|
34
|
+
if (text === '') return [];
|
|
35
|
+
return text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A minimal LCS edit script between two line arrays. O(n*m) DP — a record field is a
|
|
40
|
+
* few hundred lines at most. Returns ops in output order: equal/deleted/inserted.
|
|
41
|
+
*/
|
|
42
|
+
export function diffLines(oldLines: string[], newLines: string[]): Op[] {
|
|
43
|
+
const n = oldLines.length;
|
|
44
|
+
const m = newLines.length;
|
|
45
|
+
// lcs[i][j] = LCS length of oldLines[i:] and newLines[j:].
|
|
46
|
+
const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
|
|
47
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
48
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
49
|
+
lcs[i][j] =
|
|
50
|
+
oldLines[i] === newLines[j]
|
|
51
|
+
? lcs[i + 1][j + 1] + 1
|
|
52
|
+
: Math.max(lcs[i + 1][j], lcs[i][j + 1]);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ops: Op[] = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
let j = 0;
|
|
59
|
+
while (i < n && j < m) {
|
|
60
|
+
if (oldLines[i] === newLines[j]) {
|
|
61
|
+
ops.push({ type: 'eq', line: oldLines[i] });
|
|
62
|
+
i++;
|
|
63
|
+
j++;
|
|
64
|
+
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
|
|
65
|
+
ops.push({ type: 'del', line: oldLines[i] });
|
|
66
|
+
i++;
|
|
67
|
+
} else {
|
|
68
|
+
ops.push({ type: 'ins', line: newLines[j] });
|
|
69
|
+
j++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
while (i < n) ops.push({ type: 'del', line: oldLines[i++] });
|
|
73
|
+
while (j < m) ops.push({ type: 'ins', line: newLines[j++] });
|
|
74
|
+
return ops;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A prefixed diff body line: ' ' context, '-' deletion, '+' insertion. */
|
|
78
|
+
function prefixed(op: Op): string {
|
|
79
|
+
const mark = op.type === 'eq' ? ' ' : op.type === 'del' ? '-' : '+';
|
|
80
|
+
return `${mark}${op.line}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the unified diff of two text values as a single-file `diff --git` block the
|
|
85
|
+
* DiffViewer can render. `context` is the number of unchanged lines kept around
|
|
86
|
+
* each change (git's default 3). Returns '' when the bodies are identical.
|
|
87
|
+
*/
|
|
88
|
+
export function unifiedTextDiff(
|
|
89
|
+
oldText: string,
|
|
90
|
+
newText: string,
|
|
91
|
+
path = 'value.txt',
|
|
92
|
+
context = 3,
|
|
93
|
+
): string {
|
|
94
|
+
if (oldText === newText) return '';
|
|
95
|
+
const ops = diffLines(toLines(oldText), toLines(newText));
|
|
96
|
+
if (!ops.some((op) => op.type !== 'eq')) return '';
|
|
97
|
+
|
|
98
|
+
// Indices of ops that are actual changes, expanded by `context` on each side and
|
|
99
|
+
// merged into contiguous hunk ranges.
|
|
100
|
+
const changed: number[] = [];
|
|
101
|
+
ops.forEach((op, idx) => {
|
|
102
|
+
if (op.type !== 'eq') changed.push(idx);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const ranges: Array<[number, number]> = [];
|
|
106
|
+
for (const idx of changed) {
|
|
107
|
+
const start = Math.max(0, idx - context);
|
|
108
|
+
const end = Math.min(ops.length - 1, idx + context);
|
|
109
|
+
const last = ranges[ranges.length - 1];
|
|
110
|
+
if (last && start <= last[1] + 1) {
|
|
111
|
+
last[1] = Math.max(last[1], end);
|
|
112
|
+
} else {
|
|
113
|
+
ranges.push([start, end]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const lines: string[] = [`diff --git a/${path} b/${path}`];
|
|
118
|
+
for (const [start, end] of ranges) {
|
|
119
|
+
// 1-based line numbers of the hunk's first old/new line.
|
|
120
|
+
let oldStart = 1;
|
|
121
|
+
let newStart = 1;
|
|
122
|
+
for (let k = 0; k < start; k++) {
|
|
123
|
+
if (ops[k].type !== 'ins') oldStart++;
|
|
124
|
+
if (ops[k].type !== 'del') newStart++;
|
|
125
|
+
}
|
|
126
|
+
let oldCount = 0;
|
|
127
|
+
let newCount = 0;
|
|
128
|
+
const body: string[] = [];
|
|
129
|
+
for (let k = start; k <= end; k++) {
|
|
130
|
+
const op = ops[k];
|
|
131
|
+
if (op.type !== 'ins') oldCount++;
|
|
132
|
+
if (op.type !== 'del') newCount++;
|
|
133
|
+
body.push(prefixed(op));
|
|
134
|
+
}
|
|
135
|
+
lines.push(`@@ -${oldStart},${oldCount} +${newStart},${newCount} @@`);
|
|
136
|
+
lines.push(...body);
|
|
137
|
+
}
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|