@startsimpli/ui 0.4.141 → 0.4.143
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 +38 -12
- package/src/components/activity/ActivityFeedPanel.tsx +96 -29
- package/src/components/activity/__tests__/activity-feed-cursor.test.tsx +107 -0
- package/src/components/activity/types.ts +10 -5
- package/src/components/collection/DraftReviewWorkspace.tsx +2 -2
- package/src/components/collection/InlineReviewActions.tsx +3 -2
- package/src/components/collection/ReviewDrawer.tsx +35 -7
- package/src/components/collection/TopicReviewWorkspace.tsx +2 -2
- package/src/components/collection/__tests__/drawer-precondition.test.tsx +175 -0
- package/src/components/collection/data.ts +16 -1
- package/src/components/collection/index.ts +2 -1
- package/src/components/collection/scoped-client.ts +4 -1
- package/src/components/collection/types.ts +19 -1
- package/src/components/history/FieldAttribution.tsx +308 -0
- package/src/components/history/RecordActivityPanel.tsx +344 -0
- package/src/components/history/RecordHistoryPanel.tsx +331 -15
- package/src/components/history/RestoreConfirmDialog.tsx +244 -0
- package/src/components/history/RevisionFields.tsx +69 -3
- package/src/components/history/StaleSaveDialog.tsx +28 -7
- package/src/components/history/__tests__/field-attribution.test.tsx +213 -0
- package/src/components/history/__tests__/record-activity-panel.test.tsx +179 -0
- package/src/components/history/__tests__/record-history-restore.test.tsx +373 -0
- package/src/components/history/__tests__/restore.test.ts +209 -0
- package/src/components/history/index.ts +72 -1
- package/src/components/history/restore.ts +295 -0
- package/src/components/history/revision-feed.ts +105 -0
- package/src/components/history/revision-history.ts +22 -0
- package/src/components/history/types.ts +153 -1
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/index.ts +74 -0
- package/src/components/track-changes/normalize.ts +364 -0
- package/src/components/track-changes/types.ts +373 -0
- package/src/components/track-changes/useTrackChanges.ts +214 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Restore, the pure half (bd startsim-zkyk4; server bd startsim-ti1yv).
|
|
3
|
+
*
|
|
4
|
+
* `POST /entities/<id>/revisions/<N>/restore/` puts one field — or the whole
|
|
5
|
+
* record — back to how it stood AFTER revision N, as an ordinary new write. This
|
|
6
|
+
* module answers the two questions the panel has to answer before and after that
|
|
7
|
+
* call, without React and without a backend:
|
|
8
|
+
*
|
|
9
|
+
* 1. WHAT WOULD CHANGE ({@link restorePreview}). The confirm dialog shows this,
|
|
10
|
+
* and it must agree with the server EXACTLY or the dialog promises one change
|
|
11
|
+
* and the server makes another. The server's rule (`_state_at` in
|
|
12
|
+
* tenant-starter/apps/api/revision_history.py): start from the record as it is
|
|
13
|
+
* now and undo, newest first, every edit after N — a key whose `before` is
|
|
14
|
+
* absent was created after N and is removed. That is a fold over exactly the
|
|
15
|
+
* rows NEWER than N, and the panel already holds them: the trail is read
|
|
16
|
+
* newest first, so any row the reader can see has every newer row loaded
|
|
17
|
+
* above it. Values come from the trail alone — `after` of the newest row that
|
|
18
|
+
* touched a field is its current value, because the chain is gapless.
|
|
19
|
+
*
|
|
20
|
+
* 2. WHAT THE SERVER SAID ({@link parseRestoreOutcome}) — resolved or thrown,
|
|
21
|
+
* snake or camel, with a sentence for every refusal the route can give.
|
|
22
|
+
*/
|
|
23
|
+
import { parseStaleSaveRefusal, toVersion } from './conditional-write';
|
|
24
|
+
import { normalizeRevision, type FoldedFieldChange, type NormalizedRevision } from './revision-history';
|
|
25
|
+
import type { EntityRevision } from './types';
|
|
26
|
+
|
|
27
|
+
/** One side of a previewed value. `has: false` means the key is ABSENT — a
|
|
28
|
+
* different fact from a stored `null`. */
|
|
29
|
+
export interface RestoreSide {
|
|
30
|
+
has: boolean;
|
|
31
|
+
value?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RestoreFieldChange {
|
|
35
|
+
field: string;
|
|
36
|
+
/** What the field holds now. */
|
|
37
|
+
current: RestoreSide;
|
|
38
|
+
/** What the restore would put back. `has: false` = the key is removed. */
|
|
39
|
+
restored: RestoreSide;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RestorePreview {
|
|
43
|
+
/** The version being restored to. */
|
|
44
|
+
target: number;
|
|
45
|
+
/** The newest version in the trail the preview was computed over. */
|
|
46
|
+
newest: number;
|
|
47
|
+
/** Fields that would move, in a stable order. Withheld and unknowable fields
|
|
48
|
+
* are NOT here — see {@link withheld} and {@link unknowable}. */
|
|
49
|
+
changes: RestoreFieldChange[];
|
|
50
|
+
/** The name column, on a whole-record restore that would move it. */
|
|
51
|
+
name?: Omit<RestoreFieldChange, 'field'>;
|
|
52
|
+
/** Nothing would change. The server answers a no-op with `revision: null`. */
|
|
53
|
+
noop: boolean;
|
|
54
|
+
/** Every row between N and the newest is loaded. When false, the preview is
|
|
55
|
+
* NOT shown as a confident answer — a hole would hide a change. */
|
|
56
|
+
complete: boolean;
|
|
57
|
+
/** A whole restore leaves these as they are: the reader may not see them. */
|
|
58
|
+
withheld: string[];
|
|
59
|
+
/** A truncated or omitted side after N: the value at N is not known exactly,
|
|
60
|
+
* and the server refuses with a 409. */
|
|
61
|
+
unknowable: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function same(a: RestoreSide, b: RestoreSide): boolean {
|
|
65
|
+
if (a.has !== b.has) return false;
|
|
66
|
+
if (!a.has) return true;
|
|
67
|
+
if (a.value === b.value) return true;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.stringify(a.value) === JSON.stringify(b.value);
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function sideOf(change: FoldedFieldChange, which: 'before' | 'after'): RestoreSide {
|
|
76
|
+
return which === 'before'
|
|
77
|
+
? change.hasBefore
|
|
78
|
+
? { has: true, value: change.before }
|
|
79
|
+
: { has: false }
|
|
80
|
+
: change.hasAfter
|
|
81
|
+
? { has: true, value: change.after }
|
|
82
|
+
: { has: false };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What a restore to version `target` would change, computed exactly as the
|
|
87
|
+
* server computes it. `fields` narrows to a field restore; omit it for the whole
|
|
88
|
+
* record (which also covers the name column).
|
|
89
|
+
*
|
|
90
|
+
* `revisions` is the trail as the route returns it — newest first — and may
|
|
91
|
+
* contain rows at or below `target`; only rows NEWER than it are folded.
|
|
92
|
+
*/
|
|
93
|
+
export function restorePreview(
|
|
94
|
+
revisions: EntityRevision[],
|
|
95
|
+
target: number,
|
|
96
|
+
options: {
|
|
97
|
+
fields?: string[];
|
|
98
|
+
/** The rows came from a `?field=` read: they are that field's own trail, so
|
|
99
|
+
* version gaps between them are other fields' edits, not holes. Only
|
|
100
|
+
* meaningful with `fields`. */
|
|
101
|
+
narrowed?: boolean;
|
|
102
|
+
} = {},
|
|
103
|
+
): RestorePreview {
|
|
104
|
+
const only = options.fields ? new Set(options.fields) : null;
|
|
105
|
+
const rows: NormalizedRevision[] = (revisions ?? [])
|
|
106
|
+
.map(normalizeRevision)
|
|
107
|
+
.sort((a, b) => b.version - a.version);
|
|
108
|
+
const newest = rows.length ? rows[0]!.version : target;
|
|
109
|
+
const later = rows.filter((r) => r.version > target);
|
|
110
|
+
|
|
111
|
+
// GAPLESS by construction server-side, so a count mismatch is a hole in what
|
|
112
|
+
// was LOADED (or a lost revision) — either way, not a preview to trust.
|
|
113
|
+
const versions = new Set(later.map((r) => r.version));
|
|
114
|
+
const complete =
|
|
115
|
+
(options.narrowed === true && !!only) || versions.size === Math.max(0, newest - target);
|
|
116
|
+
|
|
117
|
+
const current = new Map<string, RestoreSide>();
|
|
118
|
+
const restored = new Map<string, RestoreSide>();
|
|
119
|
+
const withheld = new Set<string>();
|
|
120
|
+
const unknowable = new Set<string>();
|
|
121
|
+
let nameCurrent: RestoreSide | undefined;
|
|
122
|
+
let nameRestored: RestoreSide | undefined;
|
|
123
|
+
|
|
124
|
+
// Newest first: the first row seen for a field gives its CURRENT value, and
|
|
125
|
+
// each older row overwrites what the restore would put back, so the last one
|
|
126
|
+
// standing is the oldest row after N — exactly the server's undo order.
|
|
127
|
+
for (const row of later) {
|
|
128
|
+
for (const [field, change] of Object.entries(row.changed)) {
|
|
129
|
+
if (only && !only.has(field)) continue;
|
|
130
|
+
if (change.withheld) {
|
|
131
|
+
withheld.add(field);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (change.omitted || change.truncated) {
|
|
135
|
+
unknowable.add(field);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!current.has(field)) current.set(field, sideOf(change, 'after'));
|
|
139
|
+
restored.set(field, sideOf(change, 'before'));
|
|
140
|
+
}
|
|
141
|
+
if (!only) {
|
|
142
|
+
if (row.valuesOmitted) {
|
|
143
|
+
unknowable.add('name');
|
|
144
|
+
} else if (row.name) {
|
|
145
|
+
if (row.name.truncated) unknowable.add('name');
|
|
146
|
+
else {
|
|
147
|
+
if (!nameCurrent) nameCurrent = sideOf(row.name, 'after');
|
|
148
|
+
nameRestored = sideOf(row.name, 'before');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const changes: RestoreFieldChange[] = [];
|
|
155
|
+
for (const field of [...restored.keys()].sort()) {
|
|
156
|
+
if (unknowable.has(field) || withheld.has(field)) continue;
|
|
157
|
+
const c = current.get(field)!;
|
|
158
|
+
const r = restored.get(field)!;
|
|
159
|
+
if (!same(c, r)) changes.push({ field, current: c, restored: r });
|
|
160
|
+
}
|
|
161
|
+
const name =
|
|
162
|
+
nameCurrent && nameRestored && !same(nameCurrent, nameRestored)
|
|
163
|
+
? { current: nameCurrent, restored: nameRestored }
|
|
164
|
+
: undefined;
|
|
165
|
+
|
|
166
|
+
const unknown = [...unknowable].filter((f) => !withheld.has(f)).sort();
|
|
167
|
+
return {
|
|
168
|
+
target,
|
|
169
|
+
newest,
|
|
170
|
+
changes,
|
|
171
|
+
...(name ? { name } : {}),
|
|
172
|
+
noop: changes.length === 0 && !name && unknown.length === 0,
|
|
173
|
+
complete,
|
|
174
|
+
withheld: [...withheld].sort(),
|
|
175
|
+
unknowable: unknown,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Per field, the newest version that changed it, over the rows loaded. A field
|
|
181
|
+
* restore at version N is offered only where this is NEWER than N — on the row
|
|
182
|
+
* whose value IS the current one, a restore would change nothing.
|
|
183
|
+
*/
|
|
184
|
+
export function latestFieldVersions(revisions: EntityRevision[]): Record<string, number> {
|
|
185
|
+
const out: Record<string, number> = {};
|
|
186
|
+
for (const row of (revisions ?? []).map(normalizeRevision)) {
|
|
187
|
+
for (const field of Object.keys(row.changed)) {
|
|
188
|
+
if ((out[field] ?? -1) < row.version) out[field] = row.version;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/* -------------------------------------------------------------------------- */
|
|
195
|
+
/* READING THE ANSWER */
|
|
196
|
+
/* -------------------------------------------------------------------------- */
|
|
197
|
+
|
|
198
|
+
export interface RestoreSummary {
|
|
199
|
+
restoredFrom?: number;
|
|
200
|
+
/** The keys that actually moved. */
|
|
201
|
+
fields: string[];
|
|
202
|
+
/** The new version, or `null` when nothing changed (no revision written). */
|
|
203
|
+
revision: number | null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type ParsedRestoreOutcome =
|
|
207
|
+
| {
|
|
208
|
+
kind: 'restored';
|
|
209
|
+
/** The record as the server saved it — prime the host cache with this. */
|
|
210
|
+
record: Record<string, unknown>;
|
|
211
|
+
/** The record's version after the call (unchanged on a no-op). */
|
|
212
|
+
version?: number;
|
|
213
|
+
summary: RestoreSummary;
|
|
214
|
+
}
|
|
215
|
+
| { kind: 'stale'; currentVersion?: number; detail?: string }
|
|
216
|
+
| { kind: 'refused'; status?: number; message: string };
|
|
217
|
+
|
|
218
|
+
function statusOf(source: unknown): number | undefined {
|
|
219
|
+
if (!source || typeof source !== 'object') return undefined;
|
|
220
|
+
const c = source as { status?: unknown; statusCode?: unknown; response?: { status?: unknown } };
|
|
221
|
+
for (const raw of [c.status, c.statusCode, c.response?.status]) {
|
|
222
|
+
const v = toVersion(raw);
|
|
223
|
+
if (v !== undefined) return v;
|
|
224
|
+
}
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function bodyOf(source: unknown): Record<string, unknown> {
|
|
229
|
+
if (!source || typeof source !== 'object') return {};
|
|
230
|
+
const c = source as { body?: unknown; data?: unknown; response?: { data?: unknown; body?: unknown } };
|
|
231
|
+
const raw = c.body ?? c.data ?? c.response?.data ?? c.response?.body;
|
|
232
|
+
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** DRF's error bodies: `{detail}`, or `{field: [msg]}` for a 400. */
|
|
236
|
+
function serverSentence(body: Record<string, unknown>): string | undefined {
|
|
237
|
+
if (typeof body.detail === 'string' && body.detail) return body.detail;
|
|
238
|
+
for (const value of Object.values(body)) {
|
|
239
|
+
if (typeof value === 'string' && value) return value;
|
|
240
|
+
if (Array.isArray(value)) {
|
|
241
|
+
const first = value.find((v) => typeof v === 'string' && v);
|
|
242
|
+
if (first) return first as string;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const FALLBACK: Record<number, string> = {
|
|
249
|
+
400: 'This restore was refused — the field may no longer be declared on this type, or it would move the record out of its scope.',
|
|
250
|
+
403: 'You do not have access to one of these fields, so you cannot restore it.',
|
|
251
|
+
404: 'That version is no longer available — the record, or your access to it, has changed.',
|
|
252
|
+
409: 'The history cannot say exactly what the value was at that version, so it cannot be restored.',
|
|
253
|
+
428: 'A restore must say which version of the record it was chosen from. Reload and try again.',
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* What the route answered, from a resolved outcome OR a thrown error — a plain
|
|
258
|
+
* `fetch` resolves non-2xx and a wrapper throws, and a restore must not fall
|
|
259
|
+
* through to a generic failure on either.
|
|
260
|
+
*/
|
|
261
|
+
export function parseRestoreOutcome(source: unknown): ParsedRestoreOutcome {
|
|
262
|
+
const refusal = parseStaleSaveRefusal(source);
|
|
263
|
+
if (refusal) {
|
|
264
|
+
return {
|
|
265
|
+
kind: 'stale',
|
|
266
|
+
...(refusal.currentVersion !== undefined ? { currentVersion: refusal.currentVersion } : {}),
|
|
267
|
+
...(refusal.detail ? { detail: refusal.detail } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
const status = statusOf(source);
|
|
271
|
+
const body = bodyOf(source);
|
|
272
|
+
const ok = status === undefined ? source instanceof Error === false && 'restore' in body : status >= 200 && status < 300;
|
|
273
|
+
if (!ok) {
|
|
274
|
+
const message =
|
|
275
|
+
serverSentence(body) ??
|
|
276
|
+
(status !== undefined ? FALLBACK[status] : undefined) ??
|
|
277
|
+
(source instanceof Error && source.message ? source.message : undefined) ??
|
|
278
|
+
'The restore did not go through. Please try again.';
|
|
279
|
+
return { kind: 'refused', ...(status !== undefined ? { status } : {}), message };
|
|
280
|
+
}
|
|
281
|
+
const raw = body.restore && typeof body.restore === 'object' ? (body.restore as Record<string, unknown>) : {};
|
|
282
|
+
const from = toVersion(raw.restored_from ?? raw.restoredFrom);
|
|
283
|
+
const revision = toVersion(raw.revision);
|
|
284
|
+
const version = toVersion(body.version);
|
|
285
|
+
return {
|
|
286
|
+
kind: 'restored',
|
|
287
|
+
record: body,
|
|
288
|
+
...(version !== undefined ? { version } : {}),
|
|
289
|
+
summary: {
|
|
290
|
+
...(from !== undefined ? { restoredFrom: from } : {}),
|
|
291
|
+
fields: Array.isArray(raw.fields) ? raw.fields.filter((f): f is string => typeof f === 'string') : [],
|
|
292
|
+
revision: revision ?? null,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The cross-record edit feed, the pure half (bd startsim-ucg5u; route bd
|
|
3
|
+
* startsim-ivr3n `GET /api/v1/revisions/`).
|
|
4
|
+
*
|
|
5
|
+
* Two rules, each with a sharp edge on the wire:
|
|
6
|
+
*
|
|
7
|
+
* 1. ONLY THE PARAMS THE ROUTE ACCEPTS, NEVER BLANK. The route 400s a parameter
|
|
8
|
+
* it does not recognise AND a blank `actor` or `scope` — a filter that could
|
|
9
|
+
* have narrowed and did not is how a feed silently shows everything. So
|
|
10
|
+
* {@link revisionFeedParams} is an allowlist that drops empty values, and the
|
|
11
|
+
* panel sends nothing else (no `q`, no `action`, never `page`).
|
|
12
|
+
* 2. SITTINGS NEVER CROSS RECORDS. `foldRevisions` folds adjacent rows by the
|
|
13
|
+
* same actor and assumes ONE entity. In a feed, two records saved a second
|
|
14
|
+
* apart by the same person would fold into one sitting with a meaningless
|
|
15
|
+
* version span. {@link foldFeed} folds only runs on the same record.
|
|
16
|
+
*/
|
|
17
|
+
import { COLLAPSE_WINDOW_MS, foldRevisions, type RevisionSitting } from './revision-history';
|
|
18
|
+
import type { RevisionFeedEntity, RevisionFeedFilters, RevisionFeedRow } from './types';
|
|
19
|
+
|
|
20
|
+
/** The filter keys the route accepts, in the order a URL shows them. */
|
|
21
|
+
export const REVISION_FEED_FILTER_KEYS = ['actor', 'actor_kind', 'type', 'scope', 'since', 'until'] as const;
|
|
22
|
+
|
|
23
|
+
/** The actor kinds the route accepts for `actor_kind`. */
|
|
24
|
+
export const REVISION_FEED_ACTOR_KINDS = ['person', 'machine', 'unknown'] as const;
|
|
25
|
+
|
|
26
|
+
/** Filters → query params: the allowlist, with blanks OMITTED. */
|
|
27
|
+
export function revisionFeedParams(filters: RevisionFeedFilters | Record<string, unknown>): RevisionFeedFilters {
|
|
28
|
+
const out: Record<string, string> = {};
|
|
29
|
+
const source = (filters ?? {}) as Record<string, unknown>;
|
|
30
|
+
for (const key of REVISION_FEED_FILTER_KEYS) {
|
|
31
|
+
const value = source[key];
|
|
32
|
+
if (typeof value === 'string' && value.trim()) out[key] = value.trim();
|
|
33
|
+
}
|
|
34
|
+
return out as RevisionFeedFilters;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the filters out of URL search params (or anything with `get`). The
|
|
38
|
+
* host's page owns the URL; this only knows the spelling. */
|
|
39
|
+
export function revisionFeedFiltersFrom(params: { get(name: string): string | null } | null | undefined): RevisionFeedFilters {
|
|
40
|
+
const out: Record<string, string> = {};
|
|
41
|
+
if (!params) return out;
|
|
42
|
+
for (const key of REVISION_FEED_FILTER_KEYS) {
|
|
43
|
+
const v = params.get(key);
|
|
44
|
+
if (v && v.trim()) out[key] = v.trim();
|
|
45
|
+
}
|
|
46
|
+
return out as RevisionFeedFilters;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface FeedSitting {
|
|
50
|
+
/** Stable key: the entity plus the sitting's newest row. */
|
|
51
|
+
id: string;
|
|
52
|
+
entity: RevisionFeedEntity | null;
|
|
53
|
+
sitting: RevisionSitting;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function entityId(row: RevisionFeedRow): string {
|
|
57
|
+
return row.entity?.id ? String(row.entity.id) : '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Fold a newest-first feed page into sittings, never across records. */
|
|
61
|
+
export function foldFeed(rows: RevisionFeedRow[], windowMs: number = COLLAPSE_WINDOW_MS): FeedSitting[] {
|
|
62
|
+
const out: FeedSitting[] = [];
|
|
63
|
+
let run: RevisionFeedRow[] = [];
|
|
64
|
+
const flush = () => {
|
|
65
|
+
if (!run.length) return;
|
|
66
|
+
const entity = run[0]!.entity ?? null;
|
|
67
|
+
for (const sitting of foldRevisions(run, windowMs)) {
|
|
68
|
+
out.push({ id: `${entityId(run[0]!)}:${sitting.id}`, entity, sitting });
|
|
69
|
+
}
|
|
70
|
+
run = [];
|
|
71
|
+
};
|
|
72
|
+
for (const row of rows ?? []) {
|
|
73
|
+
if (run.length && entityId(run[run.length - 1]!) !== entityId(row)) flush();
|
|
74
|
+
run.push(row);
|
|
75
|
+
}
|
|
76
|
+
flush();
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Monday 00:00 in the READER's timezone, as an ISO instant. "This week" means
|
|
81
|
+
* the reader's week; sending the UTC day boundary instead would, east of
|
|
82
|
+
* Greenwich, ask the server for a `since` in the future early on a Monday. */
|
|
83
|
+
export function startOfThisWeek(now: Date = new Date()): string {
|
|
84
|
+
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
85
|
+
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // back to Monday
|
|
86
|
+
return d.toISOString();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A `YYYY-MM-DD` date input → the instant that local day starts / ends. */
|
|
90
|
+
export function localDayStart(date: string): string {
|
|
91
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
92
|
+
return new Date(y!, (m ?? 1) - 1, d ?? 1, 0, 0, 0, 0).toISOString();
|
|
93
|
+
}
|
|
94
|
+
export function localDayEnd(date: string): string {
|
|
95
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
96
|
+
return new Date(y!, (m ?? 1) - 1, d ?? 1, 23, 59, 59, 999).toISOString();
|
|
97
|
+
}
|
|
98
|
+
/** An instant → the local `YYYY-MM-DD` a date input shows. */
|
|
99
|
+
export function localDateInput(iso?: string): string {
|
|
100
|
+
if (!iso) return '';
|
|
101
|
+
const d = new Date(iso);
|
|
102
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
103
|
+
const pad = (n: number) => String(n).padStart(2, '0');
|
|
104
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
105
|
+
}
|
|
@@ -194,6 +194,9 @@ export interface NormalizedRevision {
|
|
|
194
194
|
valuesOmitted: boolean;
|
|
195
195
|
/** A machine ATTEMPT: something was held back and nothing moved. */
|
|
196
196
|
isAttempt: boolean;
|
|
197
|
+
/** Set on a `source: "restore"` row: the version it put back
|
|
198
|
+
* (bd startsim-ti1yv). */
|
|
199
|
+
restoredFrom?: number;
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
export function normalizeRevision(revision: EntityRevision): NormalizedRevision {
|
|
@@ -213,6 +216,9 @@ export function normalizeRevision(revision: EntityRevision): NormalizedRevision
|
|
|
213
216
|
}
|
|
214
217
|
|
|
215
218
|
const preserved = strings(metadata.preserved);
|
|
219
|
+
const restoredRaw = metadata.restored_from ?? metadata.restoredFrom;
|
|
220
|
+
const restoredFrom =
|
|
221
|
+
typeof restoredRaw === 'number' && Number.isInteger(restoredRaw) ? restoredRaw : undefined;
|
|
216
222
|
return {
|
|
217
223
|
id: String(revision.id ?? ''),
|
|
218
224
|
version: typeof revision.version === 'number' ? revision.version : 0,
|
|
@@ -233,6 +239,7 @@ export function normalizeRevision(revision: EntityRevision): NormalizedRevision
|
|
|
233
239
|
redacted,
|
|
234
240
|
valuesOmitted,
|
|
235
241
|
isAttempt: preserved.length > 0 && Object.keys(changed).length === 0,
|
|
242
|
+
...(restoredFrom !== undefined ? { restoredFrom } : {}),
|
|
236
243
|
};
|
|
237
244
|
}
|
|
238
245
|
|
|
@@ -272,6 +279,9 @@ export interface RevisionSitting {
|
|
|
272
279
|
redacted: string[];
|
|
273
280
|
valuesOmitted: boolean;
|
|
274
281
|
isAttempt: boolean;
|
|
282
|
+
/** A restore is always its own sitting, so this is the restore's source
|
|
283
|
+
* version when the sitting IS one. */
|
|
284
|
+
restoredFrom?: number;
|
|
275
285
|
}
|
|
276
286
|
|
|
277
287
|
/**
|
|
@@ -309,6 +319,10 @@ function sameSitting(
|
|
|
309
319
|
if (newer.actorKindSource !== older.actorKindSource) return false;
|
|
310
320
|
if (newer.isAttempt !== older.isAttempt) return false;
|
|
311
321
|
if (older.action === 'created' || newer.action === 'created') return false;
|
|
322
|
+
// 5. A RESTORE IS ITS OWN ROW. Folded into the edit before it, "restored
|
|
323
|
+
// from v2" would vanish into "Edited blog" and the one fact a restore row
|
|
324
|
+
// carries — which version came back, and who chose it — would be lost.
|
|
325
|
+
if (newer.restoredFrom !== undefined || older.restoredFrom !== undefined) return false;
|
|
312
326
|
if (!newer.createdAt || !older.createdAt) return false;
|
|
313
327
|
const gap = Date.parse(newer.createdAt) - Date.parse(older.createdAt);
|
|
314
328
|
return gap >= 0 && gap <= windowMs;
|
|
@@ -364,6 +378,7 @@ function startSitting(revision: NormalizedRevision): RevisionSitting {
|
|
|
364
378
|
redacted: [...revision.redacted],
|
|
365
379
|
valuesOmitted: revision.valuesOmitted,
|
|
366
380
|
isAttempt: revision.isAttempt,
|
|
381
|
+
...(revision.restoredFrom !== undefined ? { restoredFrom: revision.restoredFrom } : {}),
|
|
367
382
|
};
|
|
368
383
|
}
|
|
369
384
|
|
|
@@ -472,6 +487,13 @@ export function sittingHeadline(
|
|
|
472
487
|
}
|
|
473
488
|
const fields = sittingFields(sitting);
|
|
474
489
|
const renamed = sitting.name !== undefined;
|
|
490
|
+
if (sitting.restoredFrom !== undefined) {
|
|
491
|
+
const from = `v${sitting.restoredFrom}`;
|
|
492
|
+
if (!fields.length) return renamed ? `Restored the name from ${from}` : `Restored from ${from} — nothing changed`;
|
|
493
|
+
return fields.length <= 3
|
|
494
|
+
? `Restored ${joinNames(fields.map(fieldLabel))} from ${from}`
|
|
495
|
+
: `Restored ${fields.length} fields from ${from}`;
|
|
496
|
+
}
|
|
475
497
|
if (sitting.action === 'created') {
|
|
476
498
|
return fields.length ? `Created the record with ${fields.length} ${fields.length === 1 ? 'field' : 'fields'}` : 'Created the record';
|
|
477
499
|
}
|
|
@@ -45,7 +45,7 @@ export type RevisionAction = 'created' | 'updated' | 'deleted';
|
|
|
45
45
|
|
|
46
46
|
/** Which endpoint wrote. `server` is a management command or a worker — a real
|
|
47
47
|
* write that must not claim to be an endpoint. */
|
|
48
|
-
export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server';
|
|
48
|
+
export type RevisionSource = 'create' | 'patch' | 'put' | 'upsert' | 'server' | 'restore';
|
|
49
49
|
|
|
50
50
|
/** `none` always, today; `matched` arrives with the conditional write
|
|
51
51
|
* (bd startsim-3c2wc). */
|
|
@@ -98,6 +98,14 @@ export interface RevisionMetadata {
|
|
|
98
98
|
values_omitted?: boolean;
|
|
99
99
|
/** Camelised alias — see the header. */
|
|
100
100
|
valuesOmitted?: boolean;
|
|
101
|
+
/** On a `source: "restore"` row: the version whose values it put back
|
|
102
|
+
* (bd startsim-ti1yv). */
|
|
103
|
+
restored_from?: number;
|
|
104
|
+
restoredFrom?: number;
|
|
105
|
+
/** On a FIELD restore: the fields the caller named. Absent on a whole-record
|
|
106
|
+
* restore. */
|
|
107
|
+
restored_fields?: string[];
|
|
108
|
+
restoredFields?: string[];
|
|
101
109
|
}
|
|
102
110
|
|
|
103
111
|
/** One row of the append-only revision trail. Only `id` and `version` are
|
|
@@ -135,6 +143,9 @@ export interface EntityRevision {
|
|
|
135
143
|
* was held back. */
|
|
136
144
|
export interface RevisionPage {
|
|
137
145
|
count?: number;
|
|
146
|
+
/** The resolved history policy for this record's TYPE (bd startsim-jkkn7.17).
|
|
147
|
+
* `enabled: false` means new edits are not recorded. */
|
|
148
|
+
history?: RevisionHistoryPolicy | null;
|
|
138
149
|
next?: string | null;
|
|
139
150
|
previous?: string | null;
|
|
140
151
|
results?: EntityRevision[];
|
|
@@ -148,6 +159,12 @@ export interface RevisionPage {
|
|
|
148
159
|
export interface RevisionQuery {
|
|
149
160
|
page?: number;
|
|
150
161
|
pageSize?: number;
|
|
162
|
+
/** Narrow to revisions that changed this field — `?field=` (bd
|
|
163
|
+
* startsim-jkkn7.8). A key-existence test server-side, so the result is the
|
|
164
|
+
* field's own trail; it misses held-back re-sends and name changes (bd
|
|
165
|
+
* startsim-jkkn7.18), which is why a narrowed panel offers no whole-record
|
|
166
|
+
* restore. */
|
|
167
|
+
field?: string;
|
|
151
168
|
// Index signature so this satisfies an API client's query-params type; the
|
|
152
169
|
// injected client is the app's own.
|
|
153
170
|
[key: string]: unknown;
|
|
@@ -164,4 +181,139 @@ export interface RevisionQuery {
|
|
|
164
181
|
*/
|
|
165
182
|
export interface RevisionClient {
|
|
166
183
|
list: (query: RevisionQuery) => Promise<RevisionPage>;
|
|
184
|
+
/**
|
|
185
|
+
* `POST /entities/<id>/revisions/<N>/restore/` (bd startsim-ti1yv). Optional:
|
|
186
|
+
* a host that omits it gets a read-only panel with no restore actions.
|
|
187
|
+
*
|
|
188
|
+
* Like {@link ConditionalWriteClient.write} it may RESOLVE with a non-2xx
|
|
189
|
+
* outcome or THROW one carrying `status`/`body`; both are understood. Send
|
|
190
|
+
* `expectedVersion` in ONE spelling only (body `expected_version` or
|
|
191
|
+
* `If-Match`) — the server refuses if either of two disagrees.
|
|
192
|
+
*/
|
|
193
|
+
restore?: (request: RestoreRequest) => Promise<RestoreOutcome>;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The resolved history policy, as the revisions envelope and the field-authors
|
|
197
|
+
* read both carry it. */
|
|
198
|
+
export interface RevisionHistoryPolicy {
|
|
199
|
+
enabled?: boolean;
|
|
200
|
+
/** `type`, `"*"`, or `default` — who decided. */
|
|
201
|
+
declared_by?: string;
|
|
202
|
+
declaredBy?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One restore. `fields` omitted restores the WHOLE record to version N. */
|
|
206
|
+
export interface RestoreRequest {
|
|
207
|
+
/** N — the record is put back to how it stood AFTER revision N. */
|
|
208
|
+
version: number;
|
|
209
|
+
fields?: string[];
|
|
210
|
+
/** REQUIRED by the route (428 without it): the version the caller believes is
|
|
211
|
+
* current. */
|
|
212
|
+
expectedVersion: number;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** What the transport hands back — the same shape as a conditional write. */
|
|
216
|
+
export interface RestoreOutcome {
|
|
217
|
+
status?: number;
|
|
218
|
+
body?: unknown;
|
|
219
|
+
headers?: Record<string, string | null | undefined> | { get(name: string): string | null | undefined };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* -------------------------------------------------------------------------- */
|
|
223
|
+
/* FIELD AUTHORS — GET /entities/<id>/field-authors/ (bd startsim-6sso4) */
|
|
224
|
+
/* -------------------------------------------------------------------------- */
|
|
225
|
+
|
|
226
|
+
/** The latest author of ONE field. Snake and camel both declared — see the
|
|
227
|
+
* spelling trap in this module's header. */
|
|
228
|
+
export interface FieldAuthor {
|
|
229
|
+
version?: number;
|
|
230
|
+
at?: string | null;
|
|
231
|
+
actor_sub?: string | null;
|
|
232
|
+
actorSub?: string | null;
|
|
233
|
+
actor_label?: string | null;
|
|
234
|
+
actorLabel?: string | null;
|
|
235
|
+
actor_kind?: ActorKind | string | null;
|
|
236
|
+
actorKind?: ActorKind | string | null;
|
|
237
|
+
actor_kind_source?: ActorKindSource | string | null;
|
|
238
|
+
actorKindSource?: ActorKindSource | string | null;
|
|
239
|
+
source?: string | null;
|
|
240
|
+
restored_from?: number | null;
|
|
241
|
+
restoredFrom?: number | null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Where the trail begins for this record. Live rows PREDATE the trail, so a
|
|
245
|
+
* field with no entry means "not edited since this", never "never edited". */
|
|
246
|
+
export interface FieldAuthorsTrailStart {
|
|
247
|
+
version?: number;
|
|
248
|
+
at?: string | null;
|
|
249
|
+
from_creation?: boolean;
|
|
250
|
+
fromCreation?: boolean;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface FieldAuthorsResponse {
|
|
254
|
+
entity_id?: string;
|
|
255
|
+
entityId?: string;
|
|
256
|
+
version?: number;
|
|
257
|
+
history?: RevisionHistoryPolicy | null;
|
|
258
|
+
/** `false` when history is off for this type: `fields` is then `null` and
|
|
259
|
+
* NOTHING may be inferred about who edited what. */
|
|
260
|
+
recorded?: boolean;
|
|
261
|
+
trail_starts?: FieldAuthorsTrailStart | null;
|
|
262
|
+
trailStarts?: FieldAuthorsTrailStart | null;
|
|
263
|
+
/** Withheld fields are omitted entirely. `null` when not recorded. */
|
|
264
|
+
fields?: Record<string, FieldAuthor> | null;
|
|
265
|
+
/** The name COLUMN's latest author, namespaced away from data keys. */
|
|
266
|
+
name?: FieldAuthor | null;
|
|
267
|
+
/** The server's sentence when `recorded` is false. */
|
|
268
|
+
detail?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface FieldAuthorsClient {
|
|
272
|
+
get: () => Promise<FieldAuthorsResponse>;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* -------------------------------------------------------------------------- */
|
|
276
|
+
/* THE FEED — GET /api/v1/revisions/ (bd startsim-ivr3n) */
|
|
277
|
+
/* -------------------------------------------------------------------------- */
|
|
278
|
+
|
|
279
|
+
/** The record a feed row belongs to. */
|
|
280
|
+
export interface RevisionFeedEntity {
|
|
281
|
+
id: string;
|
|
282
|
+
type?: string;
|
|
283
|
+
name?: string | null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** One feed row: a revision plus the record it changed. */
|
|
287
|
+
export interface RevisionFeedRow extends EntityRevision {
|
|
288
|
+
entity?: RevisionFeedEntity | null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** The filters the route accepts, keyed exactly as the URL spells them so a
|
|
292
|
+
* host can round-trip them through search params unchanged. */
|
|
293
|
+
export interface RevisionFeedFilters {
|
|
294
|
+
/** An actor SUB, never a label. */
|
|
295
|
+
actor?: string;
|
|
296
|
+
actor_kind?: string;
|
|
297
|
+
type?: string;
|
|
298
|
+
scope?: string;
|
|
299
|
+
since?: string;
|
|
300
|
+
until?: string;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export interface RevisionFeedQuery extends RevisionFeedFilters {
|
|
304
|
+
cursor?: string;
|
|
305
|
+
pageSize?: number;
|
|
306
|
+
[key: string]: unknown;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** NO `count` — the route does not compute one, and `?page=` is a 400. */
|
|
310
|
+
export interface RevisionFeedPage {
|
|
311
|
+
next?: string | null;
|
|
312
|
+
next_cursor?: string | null;
|
|
313
|
+
nextCursor?: string | null;
|
|
314
|
+
results?: RevisionFeedRow[];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface RevisionFeedClient {
|
|
318
|
+
list: (query: RevisionFeedQuery) => Promise<RevisionFeedPage>;
|
|
167
319
|
}
|