@erdemtuna/doc-review 0.8.1 → 0.10.0

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.
Files changed (82) hide show
  1. package/README.md +52 -137
  2. package/{src → lib}/SKILL.md +28 -4
  3. package/lib/anchor-text.js +155 -0
  4. package/lib/atomic-write.js +54 -0
  5. package/lib/chrome-api.js +78 -0
  6. package/lib/chrome-client.js +2255 -0
  7. package/lib/chrome-session.js +76 -0
  8. package/{src → lib}/chrome.css +253 -8
  9. package/lib/chrome.html +196 -0
  10. package/lib/cli.js +255 -0
  11. package/lib/click-target.js +28 -0
  12. package/lib/comment-anchor.js +23 -0
  13. package/lib/comment-target.js +164 -0
  14. package/lib/comparison-view.js +380 -0
  15. package/lib/contracts/feedback.js +1 -0
  16. package/lib/contracts/frame.js +7 -0
  17. package/lib/contracts/history.js +1 -0
  18. package/lib/contracts/index.js +2 -0
  19. package/lib/contracts/page.js +23 -0
  20. package/lib/document-execution.js +115 -0
  21. package/lib/document-trust.js +2 -0
  22. package/lib/edit-limits.js +24 -0
  23. package/lib/editing.js +107 -0
  24. package/lib/execution-client.js +69 -0
  25. package/lib/feedback-controller.js +106 -0
  26. package/lib/frame-channel.js +42 -0
  27. package/lib/frame-controller.js +313 -0
  28. package/lib/frame-host.js +136 -0
  29. package/lib/frame-policy.js +50 -0
  30. package/lib/history-client.js +112 -0
  31. package/lib/history-coordinator.js +210 -0
  32. package/lib/history-policy.js +43 -0
  33. package/lib/history-server.js +464 -0
  34. package/lib/html-transform.js +52 -0
  35. package/lib/icons.js +249 -0
  36. package/{src → lib}/markdown.js +25 -26
  37. package/lib/paths.js +83 -0
  38. package/lib/poll-transport.js +220 -0
  39. package/lib/positioning.js +97 -0
  40. package/lib/review-controller.js +93 -0
  41. package/lib/review-mode.js +66 -0
  42. package/lib/revision-diff.js +482 -0
  43. package/lib/revision-schema.js +235 -0
  44. package/lib/revision-store.js +190 -0
  45. package/lib/save-controller.js +300 -0
  46. package/lib/sdk.js +2629 -0
  47. package/lib/semantic-snapshot.js +268 -0
  48. package/lib/serialize.js +32 -0
  49. package/lib/server-entry.js +26 -0
  50. package/lib/server-lock.js +109 -0
  51. package/lib/server.js +1501 -0
  52. package/lib/setup-guidance.js +119 -0
  53. package/{src → lib}/setup.js +44 -55
  54. package/lib/state.js +733 -0
  55. package/lib/view-identity.js +112 -0
  56. package/package.json +24 -12
  57. package/src/anchor-text.js +0 -160
  58. package/src/atomic-write.js +0 -53
  59. package/src/chrome-client.js +0 -2006
  60. package/src/chrome-session.js +0 -85
  61. package/src/chrome.html +0 -129
  62. package/src/cli.js +0 -267
  63. package/src/click-target.js +0 -26
  64. package/src/comment-anchor.js +0 -22
  65. package/src/comment-target.js +0 -77
  66. package/src/edit-limits.js +0 -23
  67. package/src/editing.js +0 -99
  68. package/src/frame-channel.js +0 -44
  69. package/src/frame-policy.js +0 -16
  70. package/src/html-transform.js +0 -62
  71. package/src/icons.js +0 -251
  72. package/src/paths.js +0 -91
  73. package/src/poll-transport.js +0 -222
  74. package/src/positioning.js +0 -107
  75. package/src/review-mode.js +0 -59
  76. package/src/sdk.js +0 -2243
  77. package/src/serialize.js +0 -32
  78. package/src/server-entry.js +0 -26
  79. package/src/server-lock.js +0 -101
  80. package/src/server.js +0 -1298
  81. package/src/setup-guidance.js +0 -128
  82. package/src/state.js +0 -507
@@ -0,0 +1,136 @@
1
+ export function createFrameHost(initial, artifactOrigin) {
2
+ let current = initial;
3
+ let previous = null;
4
+ let disposed = false;
5
+ const loadListeners = new Set();
6
+ const focusListeners = new Set();
7
+ const detach = new Map();
8
+ const paintFrames = new Set();
9
+ function paint(callback) {
10
+ const id = requestAnimationFrame(() => {
11
+ paintFrames.delete(id);
12
+ if (!disposed)
13
+ callback();
14
+ });
15
+ paintFrames.add(id);
16
+ }
17
+ function attach(frame) {
18
+ const loaded = () => {
19
+ if (!disposed && frame === current)
20
+ for (const listener of loadListeners)
21
+ listener();
22
+ };
23
+ const focused = () => {
24
+ if (!disposed && frame === current)
25
+ for (const listener of focusListeners)
26
+ listener();
27
+ };
28
+ frame.addEventListener("load", loaded);
29
+ frame.addEventListener("focus", focused);
30
+ detach.set(frame, () => {
31
+ frame.removeEventListener("load", loaded);
32
+ frame.removeEventListener("focus", focused);
33
+ detach.delete(frame);
34
+ });
35
+ }
36
+ function remove(frame) {
37
+ detach.get(frame)?.();
38
+ frame.remove();
39
+ }
40
+ attach(initial);
41
+ return {
42
+ get current() { return current; },
43
+ get previous() { return previous; },
44
+ get visibleExecution() {
45
+ const visible = previous || current;
46
+ const savePolicy = visible.dataset.savePolicy;
47
+ const executionMode = visible.dataset.executionMode;
48
+ if ((savePolicy !== "writable" && savePolicy !== "feedback-only") ||
49
+ (executionMode !== "static" && executionMode !== "interactive" && executionMode !== "application"))
50
+ return null;
51
+ return {
52
+ executionMode,
53
+ savePolicy,
54
+ executionNotice: visible.dataset.executionNotice || null,
55
+ };
56
+ },
57
+ onLoad(listener) {
58
+ loadListeners.add(listener);
59
+ return () => { loadListeners.delete(listener); };
60
+ },
61
+ onFocus(listener) {
62
+ focusListeners.add(listener);
63
+ return () => { focusListeners.delete(listener); };
64
+ },
65
+ setPolicy(policy) { current.setAttribute("sandbox", policy.sandbox); },
66
+ navigate(path, replacing) {
67
+ if (disposed)
68
+ return;
69
+ const url = `${artifactOrigin}${path}`;
70
+ if (replacing && current.hasAttribute("src")) {
71
+ const next = current.cloneNode(false);
72
+ const Frame = current.ownerDocument.defaultView?.HTMLIFrameElement;
73
+ if (!Frame || !(next instanceof Frame))
74
+ throw new Error("Invalid review iframe.");
75
+ next.src = url;
76
+ next.removeAttribute("data-sdk-ready");
77
+ delete next.dataset.executionMode;
78
+ delete next.dataset.savePolicy;
79
+ delete next.dataset.executionNotice;
80
+ if (previous)
81
+ remove(current);
82
+ else {
83
+ previous = current;
84
+ previous.id = "previousFrame";
85
+ previous.inert = true;
86
+ previous.setAttribute("aria-hidden", "true");
87
+ previous.tabIndex = -1;
88
+ }
89
+ current = next;
90
+ current.dataset.replacing = "true";
91
+ attach(current);
92
+ previous.after(current);
93
+ }
94
+ if (current.src !== url)
95
+ current.src = url;
96
+ },
97
+ finishReplacement() {
98
+ if (previous)
99
+ remove(previous);
100
+ previous = null;
101
+ delete current.dataset.replacing;
102
+ },
103
+ suspend() { current.removeAttribute("data-sdk-ready"); },
104
+ ready(execution) {
105
+ current.dataset.sdkReady = "true";
106
+ current.dataset.executionMode = execution.executionMode;
107
+ current.dataset.savePolicy = execution.savePolicy;
108
+ current.dataset.executionNotice = execution.executionNotice || "";
109
+ },
110
+ send(message, origin) {
111
+ current.contentWindow?.postMessage(message, origin);
112
+ },
113
+ acceptsSource(source) {
114
+ return !!current.contentWindow && source === current.contentWindow;
115
+ },
116
+ afterPaint(callback) {
117
+ const target = current;
118
+ paint(() => paint(() => {
119
+ if (!disposed && target === current)
120
+ callback();
121
+ }));
122
+ },
123
+ dispose() {
124
+ if (disposed)
125
+ return;
126
+ disposed = true;
127
+ for (const stop of [...detach.values()])
128
+ stop();
129
+ for (const id of paintFrames)
130
+ cancelAnimationFrame(id);
131
+ paintFrames.clear();
132
+ loadListeners.clear();
133
+ focusListeners.clear();
134
+ },
135
+ };
136
+ }
@@ -0,0 +1,50 @@
1
+ const BASE_SANDBOX = "allow-scripts allow-forms allow-modals";
2
+ const LOCALHOST_SANDBOX = `${BASE_SANDBOX} allow-popups allow-downloads allow-same-origin`;
3
+ // Exact immutable review modules, not the artifact directory or whole origin.
4
+ // Do not add strict-dynamic or a nonce: either would let authored code authorize
5
+ // mutable external scripts. The capability in the SDK tag is correlation only.
6
+ export const TRUSTED_SDK_MODULE_PATHS = Object.freeze([
7
+ "/sdk.js", "/anchor-text.js", "/click-target.js", "/comment-target.js",
8
+ "/editing.js", "/frame-channel.js", "/icons.js", "/review-mode.js",
9
+ "/semantic-snapshot.js", "/serialize.js", "/positioning.js",
10
+ "/revision-schema.js", "/view-identity.js",
11
+ ]);
12
+ export function interactiveFileCsp(sdkOrigin) {
13
+ const origin = new URL(sdkOrigin);
14
+ if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password ||
15
+ origin.pathname !== "/" || origin.search || origin.hash) {
16
+ throw new TypeError("The SDK origin must be an HTTP origin without a path or credentials.");
17
+ }
18
+ const modules = TRUSTED_SDK_MODULE_PATHS.map((route) => `${origin.origin}${route}`).join(" ");
19
+ return [
20
+ "default-src 'none'",
21
+ `script-src 'unsafe-inline' ${modules}`,
22
+ "script-src-attr 'unsafe-inline'",
23
+ "worker-src 'none'",
24
+ "child-src 'none'",
25
+ "frame-src 'none'",
26
+ "object-src 'none'",
27
+ "connect-src 'none'",
28
+ "base-uri 'none'",
29
+ "form-action 'none'",
30
+ "style-src 'unsafe-inline' http: https: data:",
31
+ "img-src http: https: data: blob:",
32
+ "font-src http: https: data:",
33
+ "media-src http: https: data: blob:",
34
+ ].join("; ");
35
+ }
36
+ export const trustedFileCsp = interactiveFileCsp;
37
+ /**
38
+ * Localhost apps need their real origin so their routing and JavaScript work.
39
+ * Files and rendered Markdown do not: An opaque iframe origin prevents them
40
+ * from reading sibling files served by the artifact route.
41
+ */
42
+ export function framePolicy(page, artifactOrigin) {
43
+ const keepsOrigin = page !== null && (typeof page === "object" || typeof page === "function") &&
44
+ "kind" in page && page.kind === "url";
45
+ return {
46
+ sandbox: keepsOrigin ? LOCALHOST_SANDBOX : BASE_SANDBOX,
47
+ incomingOrigin: keepsOrigin ? artifactOrigin : "null",
48
+ targetOrigin: keepsOrigin ? artifactOrigin : "*",
49
+ };
50
+ }
@@ -0,0 +1,112 @@
1
+ /** A capture is valid only for the exact frame that answered the request. */
2
+ export function sameRender(a, b) {
3
+ return !!a && !!b && a.key === b.key && a.renderId === b.renderId &&
4
+ a.generation === b.generation && !!a.renderId;
5
+ }
6
+ export function createCaptureRequests({ send, current, timeout = 6000 }) {
7
+ const pending = new Map();
8
+ let sequence = 0;
9
+ return {
10
+ request({ timeout: requestTimeout = timeout, signal } = {}) {
11
+ const identity = { ...current() };
12
+ if (signal?.aborted)
13
+ return Promise.reject(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
14
+ if (!identity.renderId || identity.loading)
15
+ return Promise.reject(captureError("CAPTURE_NOT_READY", "The page is not ready to capture"));
16
+ const requestId = `capture-${++sequence}`;
17
+ return new Promise((resolve, reject) => {
18
+ const finish = (error, value) => {
19
+ if (!pending.has(requestId))
20
+ return;
21
+ pending.delete(requestId);
22
+ clearTimeout(timer);
23
+ signal?.removeEventListener("abort", abort);
24
+ if (error)
25
+ reject(error);
26
+ else
27
+ resolve(value);
28
+ };
29
+ const abort = () => finish(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
30
+ const timer = setTimeout(() => finish(captureError("CAPTURE_TIMEOUT", "The page did not confirm a stable capture. Recapture when it is ready.")), requestTimeout);
31
+ pending.set(requestId, { identity, finish });
32
+ signal?.addEventListener("abort", abort, { once: true });
33
+ try {
34
+ send({ type: "eh:captureSnapshot", requestId, requireStable: true });
35
+ }
36
+ catch (error) {
37
+ finish(error);
38
+ }
39
+ });
40
+ },
41
+ receive(message) {
42
+ const request = pending.get(message.requestId);
43
+ if (!request)
44
+ return false;
45
+ if (!sameRender(request.identity, current()))
46
+ request.finish(captureError("CAPTURE_CHANGED", "The page changed during capture. Recapture the latest version."));
47
+ else if (message.error || !message.snapshot)
48
+ request.finish(captureError(message.error?.code || "CAPTURE_UNAVAILABLE", message.error?.message || message.error || "Semantic capture is unavailable"));
49
+ else
50
+ request.finish(null, {
51
+ ...request.identity, semantic: message.snapshot, sourceHash: message.sourceHash,
52
+ semanticCapturedAt: Number.isFinite(message.capturedAt) ? message.capturedAt : undefined,
53
+ view: message.view,
54
+ });
55
+ return true;
56
+ },
57
+ cancel(reason = "The page changed during capture") {
58
+ for (const request of pending.values()) {
59
+ request.finish(captureError("CAPTURE_CANCELLED", reason));
60
+ }
61
+ pending.clear();
62
+ },
63
+ get size() { return pending.size; },
64
+ };
65
+ }
66
+ export function captureError(code, message) {
67
+ return Object.assign(new Error(message), { code });
68
+ }
69
+ export function draftCount({ compose, commentUi }, note = "") {
70
+ return Number(!!compose) + Number(!!commentUi?.edit) + Number(!!note.trim());
71
+ }
72
+ export function excerpt(value) {
73
+ if (typeof value === "string")
74
+ return value;
75
+ if (!value)
76
+ return "";
77
+ return String(value.text ?? value.excerpt ?? value.content ?? value.label ?? "");
78
+ }
79
+ export function changeKind(item) {
80
+ const kind = String(item.kind || item.type || "").toLowerCase();
81
+ return kind === "added" || kind === "removed" ? kind : "modified";
82
+ }
83
+ export function pendingCaptureTarget(round, key, readyAt) {
84
+ if (round?.feedbackStatus !== "acknowledged" || !(readyAt > 0))
85
+ return null;
86
+ return round.targets?.find((target) => target.key === key && !target.resultRevisionId &&
87
+ ["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending")) || null;
88
+ }
89
+ export function defaultHistoryRound(rounds) {
90
+ return rounds.find((round) => round.completedAt || round.captureStatus === "ready" ||
91
+ ["complete", "completed"].includes(round.status)) || rounds[0] || null;
92
+ }
93
+ export function comparisonFreshness(comparison, targetKey, current) {
94
+ if (targetKey !== current.key)
95
+ return "Different page: this saved comparison does not describe the page open in Latest version.";
96
+ if (!current.ready || current.pendingReload || current.dirty)
97
+ return "Latest version is unverified: reload or finish saving before comparing it with this captured result.";
98
+ if (current.kind === "url") {
99
+ return comparison.capturedSessionId === current.sessionId && comparison.capturedGeneration === current.generation
100
+ ? "Same live render generation; the current DOM is not verified against the captured result. Live content may have changed."
101
+ : "Latest live render is different or unverified. This historical comparison cannot identify exact targets on the live page.";
102
+ }
103
+ if (!comparison.sourceHash || !current.sourceHash)
104
+ return "Latest source is unverified against this captured result.";
105
+ return comparison.sourceHash === current.sourceHash
106
+ ? "Latest file source matches the captured result. Runtime DOM changes are not verified by a source match."
107
+ : "Latest source is newer or different from the captured result. You are viewing a historical comparison.";
108
+ }
109
+ export function canJumpToCurrent(item, result, current) {
110
+ return item.confidence === "exact" && !!item.navigation?.selector && !item.unresolved && changeKind(item) !== "removed" &&
111
+ !!result?.renderId && sameRender(result, current);
112
+ }
@@ -0,0 +1,210 @@
1
+ import { defaultHistoryRound } from "./history-client.js";
2
+ const idOf = (round) => round?.roundId || round?.id;
3
+ const retryDelays = [1000, 2000, 4000];
4
+ export function transientCaptureError(error) {
5
+ return ["CAPTURE_TIMEOUT", "CAPTURE_NOT_READY", "CAPTURE_UNSTABLE", "CAPTURE_BUSY", "VIEW_CHANGED"].includes(error?.code) ||
6
+ [408, 429, 502, 503, 504].includes(error?.status) ||
7
+ (error instanceof TypeError && /fetch|network|load failed/i.test(error.message));
8
+ }
9
+ export function requireCaptureSuccess(result, key) {
10
+ if (result?.ok === true)
11
+ return result;
12
+ throw Object.assign(new Error(result?.round?.targets?.find((target) => target.key === key)?.capture?.error ||
13
+ "Content capture is unavailable"), { code: "CAPTURE_UNAVAILABLE" });
14
+ }
15
+ /** One capture at a time; delayed/finished attempts never block other rounds. */
16
+ export function createCaptureCoordinator({ candidates, ready, capture, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout }) {
17
+ const attempts = new Map();
18
+ let timer = null;
19
+ let active = null;
20
+ let epoch = 0;
21
+ let stopped = false;
22
+ function tick() {
23
+ clearTimer(timer);
24
+ timer = null;
25
+ if (stopped || active || !ready())
26
+ return;
27
+ const jobs = candidates();
28
+ const job = jobs.find(({ id }) => {
29
+ const attempt = attempts.get(id);
30
+ return !attempt || (!attempt.done && attempt.due <= now());
31
+ });
32
+ if (!job) {
33
+ const due = jobs.map(({ id }) => attempts.get(id)).filter((entry) => entry && !entry.done)
34
+ .reduce((next, entry) => Math.min(next, entry.due), Infinity);
35
+ if (Number.isFinite(due))
36
+ timer = setTimer(tick, Math.max(0, due - now()));
37
+ return;
38
+ }
39
+ const attempt = attempts.get(job.id) || { count: 0, due: 0, done: false };
40
+ attempts.set(job.id, attempt);
41
+ attempt.count++;
42
+ const controller = new AbortController();
43
+ const started = epoch;
44
+ active = controller;
45
+ Promise.resolve().then(() => {
46
+ if (controller.signal.aborted)
47
+ return;
48
+ return capture(job, controller.signal);
49
+ }).then(() => { attempt.done = true; }, (error) => {
50
+ if (transientCaptureError(error) && attempt.count <= retryDelays.length) {
51
+ attempt.due = now() + retryDelays[attempt.count - 1];
52
+ }
53
+ else
54
+ attempt.done = true;
55
+ }).finally(() => {
56
+ if (started !== epoch)
57
+ return;
58
+ active = null;
59
+ tick();
60
+ });
61
+ }
62
+ return {
63
+ tick,
64
+ reset() {
65
+ epoch++;
66
+ active?.abort();
67
+ active = null;
68
+ clearTimer(timer);
69
+ timer = null;
70
+ attempts.clear();
71
+ },
72
+ stop() {
73
+ this.reset();
74
+ stopped = true;
75
+ },
76
+ get busy() { return !!active; },
77
+ };
78
+ }
79
+ export function historyPresentation({ loading, error, round, target, comparison = {}, failure }) {
80
+ const modes = ["content", "source"].filter((mode) => comparison[mode]?.available === true);
81
+ if (loading && !round)
82
+ return { state: "loading", message: "Loading comparison…", modes };
83
+ if (error && !round)
84
+ return { state: "failed", message: "History could not be loaded. Try again.", modes };
85
+ if (!round)
86
+ return { state: "empty", message: "No review rounds yet. Send feedback to start.", modes };
87
+ if (modes.length) {
88
+ const partial = modes.length < 2 || !!target?.baselineUnavailable || !!target?.resultUnavailable;
89
+ return { state: partial ? "partial" : "available", modes,
90
+ message: modes.includes("content")
91
+ ? partial ? "Content available; some comparison coverage is missing." : "Comparison ready."
92
+ : "Source available. Content is pending or unavailable." };
93
+ }
94
+ if (failure || error || target?.capture?.status === "failed") {
95
+ return { state: "failed", message: "Content capture failed. Feedback is safe; retry when ready.", modes };
96
+ }
97
+ if (target?.capture?.status === "unavailable" || target?.resultRevisionId) {
98
+ return { state: "partial", message: "Comparison unavailable for these snapshots. Feedback is safe.", modes };
99
+ }
100
+ return { state: "waiting", message: round.feedbackStatus === "acknowledged"
101
+ ? "Waiting for a stable result snapshot. You can keep reviewing."
102
+ : "Waiting for the agent's result.", modes };
103
+ }
104
+ /** Fetch into local values, then publish atomically so selectors never relabel stale content. */
105
+ export function createHistoryController({ request, changed = () => { }, refreshed = () => { } }) {
106
+ const state = {
107
+ rounds: [], selectedId: null, round: null, targetKey: null, mode: "content", preferredMode: null,
108
+ index: 0, loading: false, error: null, captureBusy: false, finalizing: false, failures: new Map(),
109
+ };
110
+ let revision = 0;
111
+ const targets = new Map();
112
+ async function refresh() {
113
+ const current = ++revision;
114
+ state.loading = true;
115
+ state.error = null;
116
+ if (state.round && (idOf(state.round) !== state.selectedId ||
117
+ !state.round.targets?.some((target) => target.key === state.targetKey && target.comparison)))
118
+ state.round = null;
119
+ changed();
120
+ try {
121
+ const collection = await request("");
122
+ if (current !== revision)
123
+ return false;
124
+ state.rounds = collection.rounds || [];
125
+ if (!state.rounds.some((round) => idOf(round) === state.selectedId)) {
126
+ state.selectedId = idOf(defaultHistoryRound(state.rounds)) || null;
127
+ }
128
+ const selectedId = state.selectedId;
129
+ let round = null;
130
+ let targetKey = state.targetKey || targets.get(selectedId);
131
+ if (selectedId) {
132
+ const detail = await request(`/${selectedId}`);
133
+ if (current !== revision)
134
+ return false;
135
+ round = detail.round || detail;
136
+ const summary = state.rounds.find((item) => idOf(item) === selectedId);
137
+ for (const target of round.targets || []) {
138
+ target.filename ||= summary?.targets?.find((item) => item.key === target.key)?.filename;
139
+ }
140
+ const target = round.targets?.find((item) => item.key === targetKey) || round.targets?.[0];
141
+ targetKey = target?.key || null;
142
+ if (target) {
143
+ const results = await Promise.allSettled(["content", "source"].map((mode) => request(`/${selectedId}/compare?key=${encodeURIComponent(target.key)}&mode=${mode}`)));
144
+ if (current !== revision)
145
+ return false;
146
+ target.comparison = Object.fromEntries(["content", "source"].map((mode, index) => [mode, results[index].status === "fulfilled" ? results[index].value
147
+ : { available: false, counts: null, reason: results[index].reason.message }]));
148
+ if (results.every((result) => result.status === "rejected"))
149
+ state.error = results[0].reason;
150
+ }
151
+ }
152
+ state.round = round;
153
+ state.targetKey = targetKey;
154
+ if (selectedId)
155
+ targets.set(selectedId, targetKey);
156
+ state.loading = false;
157
+ changed();
158
+ refreshed();
159
+ return true;
160
+ }
161
+ catch (error) {
162
+ if (current !== revision)
163
+ return false;
164
+ state.error = error;
165
+ state.loading = false;
166
+ changed();
167
+ return false;
168
+ }
169
+ }
170
+ return {
171
+ state, refresh,
172
+ selectRound(id) {
173
+ state.selectedId = id;
174
+ state.targetKey = targets.get(id) || null;
175
+ state.index = 0;
176
+ return refresh();
177
+ },
178
+ selectTarget(key) { state.targetKey = key; state.index = 0; return refresh(); },
179
+ cancel() { revision++; state.loading = false; },
180
+ };
181
+ }
182
+ /**
183
+ * Optional capture cannot veto delivery; a failed refresh cannot masquerade as failed Send.
184
+ * @template T
185
+ * @param {{save: () => Promise<unknown>, capture: () => Promise<T>,
186
+ * deliver: (snapshot: T | null) => Promise<unknown>, committed: () => void,
187
+ * refresh: () => Promise<unknown>}} options
188
+ */
189
+ export async function deliverFeedback({ save, capture, deliver, committed, refresh }) {
190
+ await save();
191
+ let snapshot = null;
192
+ let captureFailure = null;
193
+ try {
194
+ snapshot = await capture();
195
+ }
196
+ catch (error) {
197
+ captureFailure = error;
198
+ }
199
+ await deliver(snapshot);
200
+ committed();
201
+ let refreshFailure = null;
202
+ try {
203
+ if (await refresh() === false)
204
+ refreshFailure = new Error("History could not be refreshed");
205
+ }
206
+ catch (error) {
207
+ refreshFailure = error;
208
+ }
209
+ return { captureFailure, refreshFailure };
210
+ }
@@ -0,0 +1,43 @@
1
+ import { COMPLETED_ROUNDS_TO_KEEP, isRevisionId } from "./revision-schema.js";
2
+ function references(value, out = new Set()) {
3
+ if (isRevisionId(value))
4
+ out.add(value);
5
+ else if (Array.isArray(value))
6
+ for (const item of value)
7
+ references(item, out);
8
+ else if (value && typeof value === "object")
9
+ for (const item of Object.values(value))
10
+ references(item, out);
11
+ return out;
12
+ }
13
+ export function unsentRevisionReferences(data) {
14
+ const out = new Set();
15
+ for (const page of Object.values(data.pages || {})) {
16
+ references(page.comments, out);
17
+ references(page.edits, out);
18
+ references(page.revisionRefs, out);
19
+ }
20
+ return out;
21
+ }
22
+ export function historyRevisionReferences(data) {
23
+ const out = unsentRevisionReferences(data);
24
+ references(data.histories, out);
25
+ references(data.batches, out);
26
+ return out;
27
+ }
28
+ export function retainHistory(data) {
29
+ const pinned = unsentRevisionReferences(data);
30
+ let changed = false;
31
+ for (const history of Object.values(data.histories || {})) {
32
+ const completed = history.rounds
33
+ .filter((round) => round.completedAt)
34
+ .sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
35
+ const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
36
+ const rounds = history.rounds.filter((round) => !round.completedAt || keep.has(round.roundId) || [...references(round.targets)].some((id) => pinned.has(id)));
37
+ if (rounds.length !== history.rounds.length) {
38
+ history.rounds = rounds;
39
+ changed = true;
40
+ }
41
+ }
42
+ return changed;
43
+ }