@startsimpli/ui 0.4.134 → 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 +5 -4
- package/src/components/ActivityTimeline.tsx +22 -4
- package/src/components/ActorIdentity.tsx +144 -4
- package/src/components/collection/InlineReviewActions.tsx +31 -16
- package/src/components/collection/ReviewDrawer.tsx +15 -2
- package/src/components/collection/__tests__/review.test.ts +149 -0
- package/src/components/collection/index.ts +2 -0
- package/src/components/collection/review.ts +88 -0
- 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,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ported from marketing-agents-web `src/lib/__tests__/blog-diff.test.ts` with
|
|
3
|
+
* the module (bd startsim-g4rwo). Same assertions, jest instead of vitest, and
|
|
4
|
+
* the generic `unifiedTextDiff` name — see `text-diff.ts` on the rename.
|
|
5
|
+
*/
|
|
6
|
+
import { parseDiffFiles } from '../../diff'
|
|
7
|
+
import { diffLines, unifiedTextDiff } from '../text-diff'
|
|
8
|
+
|
|
9
|
+
describe('diffLines', () => {
|
|
10
|
+
it('marks equal / deleted / inserted lines in output order', () => {
|
|
11
|
+
expect(diffLines(['a', 'b', 'c'], ['a', 'x', 'c'])).toEqual([
|
|
12
|
+
{ type: 'eq', line: 'a' },
|
|
13
|
+
{ type: 'del', line: 'b' },
|
|
14
|
+
{ type: 'ins', line: 'x' },
|
|
15
|
+
{ type: 'eq', line: 'c' },
|
|
16
|
+
])
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('unifiedTextDiff', () => {
|
|
21
|
+
it('returns "" for identical values', () => {
|
|
22
|
+
expect(unifiedTextDiff('same\ntext', 'same\ntext')).toBe('')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('emits a git-style block the shared DiffViewer parser can read', () => {
|
|
26
|
+
const diff = unifiedTextDiff(
|
|
27
|
+
'Line one\nLine two\nLine three',
|
|
28
|
+
'Line one\nLine 2\nLine three',
|
|
29
|
+
'blog',
|
|
30
|
+
)
|
|
31
|
+
expect(diff).toContain('diff --git a/blog b/blog')
|
|
32
|
+
expect(diff).toMatch(/^@@ -\d+,\d+ \+\d+,\d+ @@$/m)
|
|
33
|
+
expect(diff).toContain('-Line two')
|
|
34
|
+
expect(diff).toContain('+Line 2')
|
|
35
|
+
const files = parseDiffFiles(diff)
|
|
36
|
+
expect(files).toHaveLength(1)
|
|
37
|
+
expect(files[0]!.filePath).toBe('blog')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps context lines but not the whole document', () => {
|
|
41
|
+
const oldText = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n')
|
|
42
|
+
const newText = oldText.replace('line 10', 'LINE TEN')
|
|
43
|
+
const body = unifiedTextDiff(oldText, newText)
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((l) => /^[ +-]/.test(l) && !l.startsWith('+++') && !l.startsWith('---'))
|
|
46
|
+
expect(body).toContain('-line 10')
|
|
47
|
+
expect(body).toContain('+LINE TEN')
|
|
48
|
+
expect(body).toContain(' line 7')
|
|
49
|
+
expect(body).not.toContain(' line 0')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('names the field in the header, so the rendered diff says what moved', () => {
|
|
53
|
+
expect(unifiedTextDiff('a', 'b', 'linkedin')).toContain('diff --git a/linkedin b/linkedin')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('tolerates CRLF and a trailing newline without inventing a last line', () => {
|
|
57
|
+
expect(unifiedTextDiff('a\r\nb\r\n', 'a\nb\n')).toBe('')
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/ui/history — the shared record history panel (bd startsim-g4rwo).
|
|
3
|
+
*
|
|
4
|
+
* "Who edited what piece of the text and when", over the append-only revision
|
|
5
|
+
* trail (bd startsim-o1qib). A page-level composer plus the pure helpers it
|
|
6
|
+
* folds with, so a foundry fork holds an injected client and its theme
|
|
7
|
+
* classNames rather than its own timeline, its own fold and its own idea of
|
|
8
|
+
* what `{}` means (rule #9).
|
|
9
|
+
*
|
|
10
|
+
* THE ONE THING A CONSUMER MUST NOT GET WRONG: `actorKind` is THREE-state, and
|
|
11
|
+
* `unknown` is the common case for genuine human edits for up to 14 days after
|
|
12
|
+
* rollout. Render it through this panel — or through `ActorIdentity` with
|
|
13
|
+
* `kind`/`kindSource` — rather than deciding locally, because "absent" read as
|
|
14
|
+
* "a machine did this" is the single worst failure available here.
|
|
15
|
+
*/
|
|
16
|
+
export { RecordHistoryPanel, REVISION_ENTRY_TYPES } from './RecordHistoryPanel';
|
|
17
|
+
export type { RecordHistoryPanelProps } from './RecordHistoryPanel';
|
|
18
|
+
|
|
19
|
+
export { RevisionFields } from './RevisionFields';
|
|
20
|
+
export type { RevisionFieldsProps } from './RevisionFields';
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
RECORD_HISTORY_PANEL_DEFAULTS,
|
|
24
|
+
type RecordHistoryPanelClassNames,
|
|
25
|
+
} from './record-history-panel-default-class-names';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
COLLAPSE_WINDOW_MS,
|
|
29
|
+
actorKindOf,
|
|
30
|
+
actorKindSourceOf,
|
|
31
|
+
canDiffFieldChange,
|
|
32
|
+
classifyFieldChange,
|
|
33
|
+
foldRevisions,
|
|
34
|
+
joinNames,
|
|
35
|
+
normalizeFieldChange,
|
|
36
|
+
normalizeRevision,
|
|
37
|
+
sittingFields,
|
|
38
|
+
sittingHeadline,
|
|
39
|
+
sittingSummary,
|
|
40
|
+
} from './revision-history';
|
|
41
|
+
export type {
|
|
42
|
+
FieldChangeKind,
|
|
43
|
+
FoldedFieldChange,
|
|
44
|
+
NormalizedRevision,
|
|
45
|
+
RevisionSitting,
|
|
46
|
+
} from './revision-history';
|
|
47
|
+
|
|
48
|
+
export { diffLines, unifiedTextDiff } from './text-diff';
|
|
49
|
+
|
|
50
|
+
export type {
|
|
51
|
+
ActorKind,
|
|
52
|
+
ActorKindSource,
|
|
53
|
+
EntityRevision,
|
|
54
|
+
RevisionAction,
|
|
55
|
+
RevisionClient,
|
|
56
|
+
RevisionFieldChange,
|
|
57
|
+
RevisionMetadata,
|
|
58
|
+
RevisionPage,
|
|
59
|
+
RevisionPrecondition,
|
|
60
|
+
RevisionQuery,
|
|
61
|
+
RevisionSource,
|
|
62
|
+
} from './types';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link RecordHistoryPanel}. bd startsim-g4rwo.
|
|
2
|
+
* Semantic tokens only (startsim-1f3n) — theme-driven, dark-mode safe, and no
|
|
3
|
+
* literal colour, so a dark tenant theme recolours this without a fork. */
|
|
4
|
+
|
|
5
|
+
export interface RecordHistoryPanelClassNames {
|
|
6
|
+
root?: string;
|
|
7
|
+
header?: string;
|
|
8
|
+
heading?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
body?: string;
|
|
12
|
+
/** The panel-level sentences: the unrecorded-kind explanation, the redaction
|
|
13
|
+
* notice and the "some write paths are not recorded" caveat. */
|
|
14
|
+
notice?: string;
|
|
15
|
+
footer?: string;
|
|
16
|
+
countLine?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const RECORD_HISTORY_PANEL_DEFAULTS: Required<RecordHistoryPanelClassNames> = {
|
|
20
|
+
root: 'rounded-lg border border-border bg-card',
|
|
21
|
+
header: 'flex flex-wrap items-start justify-between gap-2 border-b border-border px-5 py-3',
|
|
22
|
+
heading: 'space-y-1',
|
|
23
|
+
title: 'font-medium text-foreground',
|
|
24
|
+
description: 'text-xs text-muted-foreground',
|
|
25
|
+
body: 'max-h-[32rem] overflow-y-auto px-5 py-4',
|
|
26
|
+
notice: 'border-b border-border px-5 py-2 text-xs text-muted-foreground',
|
|
27
|
+
footer: 'border-t border-border px-5 py-3 text-sm',
|
|
28
|
+
countLine: 'border-t border-border px-5 py-2 text-xs text-muted-foreground',
|
|
29
|
+
};
|
|
@@ -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
|
+
}
|