@erdemtuna/doc-review 0.9.0 → 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.
- package/README.md +52 -227
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +2255 -0
- package/lib/chrome-session.js +76 -0
- package/lib/cli.js +255 -0
- package/lib/click-target.js +28 -0
- package/lib/comment-anchor.js +23 -0
- package/lib/comment-target.js +164 -0
- package/lib/comparison-view.js +380 -0
- package/lib/contracts/feedback.js +1 -0
- package/lib/contracts/frame.js +7 -0
- package/lib/contracts/history.js +1 -0
- package/lib/contracts/index.js +2 -0
- package/lib/contracts/page.js +23 -0
- package/lib/document-execution.js +115 -0
- package/lib/edit-limits.js +24 -0
- package/lib/editing.js +107 -0
- package/lib/execution-client.js +69 -0
- package/lib/feedback-controller.js +106 -0
- package/lib/frame-channel.js +42 -0
- package/lib/frame-controller.js +313 -0
- package/lib/frame-host.js +136 -0
- package/lib/frame-policy.js +50 -0
- package/lib/history-client.js +112 -0
- package/lib/history-coordinator.js +210 -0
- package/lib/history-policy.js +43 -0
- package/lib/history-server.js +464 -0
- package/lib/html-transform.js +52 -0
- package/lib/icons.js +249 -0
- package/{src → lib}/markdown.js +25 -26
- package/lib/paths.js +83 -0
- package/lib/poll-transport.js +220 -0
- package/lib/positioning.js +97 -0
- package/lib/review-controller.js +93 -0
- package/lib/review-mode.js +66 -0
- package/lib/revision-diff.js +482 -0
- package/lib/revision-schema.js +235 -0
- package/lib/revision-store.js +190 -0
- package/lib/save-controller.js +300 -0
- package/lib/sdk.js +2629 -0
- package/lib/semantic-snapshot.js +268 -0
- package/lib/serialize.js +32 -0
- package/lib/server-entry.js +26 -0
- package/lib/server-lock.js +109 -0
- package/lib/server.js +1501 -0
- package/lib/setup-guidance.js +119 -0
- package/{src → lib}/setup.js +44 -55
- package/lib/state.js +733 -0
- package/lib/view-identity.js +112 -0
- package/package.json +24 -14
- package/src/anchor-text.js +0 -160
- package/src/atomic-write.js +0 -53
- package/src/chrome-client.js +0 -2747
- package/src/chrome-session.js +0 -85
- package/src/cli.js +0 -271
- package/src/click-target.js +0 -26
- package/src/comment-anchor.js +0 -22
- package/src/comment-target.js +0 -164
- package/src/comparison-view.js +0 -337
- package/src/document-execution.js +0 -103
- package/src/edit-limits.js +0 -23
- package/src/editing.js +0 -99
- package/src/execution-client.js +0 -63
- package/src/frame-channel.js +0 -44
- package/src/frame-policy.js +0 -54
- package/src/history-client.js +0 -104
- package/src/history-coordinator.js +0 -186
- package/src/history-policy.js +0 -43
- package/src/history-server.js +0 -463
- package/src/html-transform.js +0 -62
- package/src/icons.js +0 -251
- package/src/paths.js +0 -91
- package/src/poll-transport.js +0 -222
- package/src/positioning.js +0 -107
- package/src/review-mode.js +0 -59
- package/src/revision-diff.js +0 -440
- package/src/revision-schema.js +0 -219
- package/src/revision-store.js +0 -177
- package/src/sdk.js +0 -2607
- package/src/semantic-snapshot.js +0 -230
- package/src/serialize.js +0 -32
- package/src/server-entry.js +0 -26
- package/src/server-lock.js +0 -101
- package/src/server.js +0 -1466
- package/src/setup-guidance.js +0 -128
- package/src/state.js +0 -732
- package/src/view-identity.js +0 -99
- /package/{src → lib}/SKILL.md +0 -0
- /package/{src → lib}/chrome.css +0 -0
- /package/{src → lib}/chrome.html +0 -0
- /package/{src → lib}/document-trust.js +0 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { decodePage, record } from "./chrome-api.js";
|
|
2
|
+
export function createSaveController({ sessionId, current, policy, request, flush, send, sourceHash, pageChanged, conflict, failed, diagnostic, sending, clock = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }), setTimer = globalThis.setTimeout, clearTimer = globalThis.clearTimeout, }) {
|
|
3
|
+
const state = {
|
|
4
|
+
status: "idle", savedAt: "", baseHash: null, conflict: false, dirty: false, dynamic: false,
|
|
5
|
+
};
|
|
6
|
+
const listeners = new Set();
|
|
7
|
+
const pipelines = new Map();
|
|
8
|
+
const backlogs = new Map();
|
|
9
|
+
const errors = new Map();
|
|
10
|
+
const delays = new Map();
|
|
11
|
+
let activeSave = Promise.resolve(true);
|
|
12
|
+
let sequence = 0;
|
|
13
|
+
let editSequence = 0;
|
|
14
|
+
let pending = 0;
|
|
15
|
+
let disposed = false;
|
|
16
|
+
let lastSave = null;
|
|
17
|
+
let clean = null;
|
|
18
|
+
const matches = (identity) => !disposed && !!identity.renderId &&
|
|
19
|
+
identity.key === current().key && identity.renderId === current().renderId &&
|
|
20
|
+
identity.generation === current().generation;
|
|
21
|
+
const publish = () => { if (!disposed)
|
|
22
|
+
for (const listener of listeners)
|
|
23
|
+
listener(); };
|
|
24
|
+
const delay = (milliseconds) => new Promise((resolve) => {
|
|
25
|
+
const timer = setTimer(() => { delays.delete(timer); resolve(); }, milliseconds);
|
|
26
|
+
delays.set(timer, resolve);
|
|
27
|
+
});
|
|
28
|
+
function cancelRetries() {
|
|
29
|
+
for (const [timer, resolve] of delays) {
|
|
30
|
+
clearTimer(timer);
|
|
31
|
+
resolve();
|
|
32
|
+
}
|
|
33
|
+
delays.clear();
|
|
34
|
+
}
|
|
35
|
+
const queued = (key) => !!key &&
|
|
36
|
+
(!!backlogs.get(key)?.size || pipelines.has(key));
|
|
37
|
+
function applyClean() {
|
|
38
|
+
if (!clean || !matches(clean.identity) || clean.sequence !== sequence ||
|
|
39
|
+
clean.editSequence !== editSequence || pending || state.status === "failed" ||
|
|
40
|
+
state.conflict || queued(current().key))
|
|
41
|
+
return;
|
|
42
|
+
state.dirty = false;
|
|
43
|
+
state.status = state.savedAt ? "saved" : "idle";
|
|
44
|
+
publish();
|
|
45
|
+
}
|
|
46
|
+
function persistEdit(key, original) {
|
|
47
|
+
if (disposed)
|
|
48
|
+
return Promise.reject(new Error("Review ended"));
|
|
49
|
+
const identity = current();
|
|
50
|
+
let payload = original;
|
|
51
|
+
if (!payload.renderId && key === identity.key && identity.renderId) {
|
|
52
|
+
payload = {
|
|
53
|
+
...payload, sessionId, renderId: identity.renderId, generation: identity.generation,
|
|
54
|
+
savePolicy: policy(),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const id = `${String(payload.label || "")}\u0000${String(payload.kind || "")}`;
|
|
58
|
+
let backlog = backlogs.get(key);
|
|
59
|
+
if (!backlog) {
|
|
60
|
+
backlog = new Map();
|
|
61
|
+
backlogs.set(key, backlog);
|
|
62
|
+
}
|
|
63
|
+
backlog.set(id, payload);
|
|
64
|
+
const ownedBacklog = backlog;
|
|
65
|
+
const previous = pipelines.get(key) || Promise.resolve(true);
|
|
66
|
+
const flight = previous.then(async () => {
|
|
67
|
+
if (disposed)
|
|
68
|
+
return false;
|
|
69
|
+
try {
|
|
70
|
+
const result = record(await request(`/api/page/${key}/edit`, {
|
|
71
|
+
method: "POST", body: JSON.stringify(payload),
|
|
72
|
+
}));
|
|
73
|
+
if (ownedBacklog.get(id) === payload)
|
|
74
|
+
ownedBacklog.delete(id);
|
|
75
|
+
errors.delete(key);
|
|
76
|
+
if (matches(identity))
|
|
77
|
+
pageChanged(decodePage(result.page));
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
errors.set(key, error);
|
|
82
|
+
if (matches(identity)) {
|
|
83
|
+
failed(`${error instanceof Error ? error.message : String(error)}. Your edit is still queued locally.`);
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
pipelines.set(key, flight);
|
|
89
|
+
void flight.then(() => {
|
|
90
|
+
if (pipelines.get(key) === flight)
|
|
91
|
+
pipelines.delete(key);
|
|
92
|
+
applyClean();
|
|
93
|
+
});
|
|
94
|
+
return flight;
|
|
95
|
+
}
|
|
96
|
+
async function settleEdits(key) {
|
|
97
|
+
if (!key)
|
|
98
|
+
return;
|
|
99
|
+
await pipelines.get(key);
|
|
100
|
+
if (disposed)
|
|
101
|
+
throw new Error("Review ended");
|
|
102
|
+
if (backlogs.get(key)?.size) {
|
|
103
|
+
const retry = [...(backlogs.get(key)?.values() || [])];
|
|
104
|
+
errors.delete(key);
|
|
105
|
+
for (const payload of retry)
|
|
106
|
+
void persistEdit(key, payload);
|
|
107
|
+
await pipelines.get(key);
|
|
108
|
+
}
|
|
109
|
+
if (backlogs.get(key)?.size)
|
|
110
|
+
throw errors.get(key) || new Error("An edit could not be saved");
|
|
111
|
+
}
|
|
112
|
+
async function saveHtml(html, identity, attempts = 0) {
|
|
113
|
+
if (!matches(identity) || state.conflict)
|
|
114
|
+
return false;
|
|
115
|
+
if (!state.baseHash) {
|
|
116
|
+
if (attempts < 10) {
|
|
117
|
+
await delay(300);
|
|
118
|
+
return saveHtml(html, identity, attempts + 1);
|
|
119
|
+
}
|
|
120
|
+
state.status = "failed";
|
|
121
|
+
publish();
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const result = record(await request(`/api/page/${identity.key}/save`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
html, baseHash: state.baseHash, renderId: identity.renderId,
|
|
129
|
+
generation: identity.generation, sessionId,
|
|
130
|
+
}),
|
|
131
|
+
}));
|
|
132
|
+
if (!matches(identity))
|
|
133
|
+
return false;
|
|
134
|
+
if (result.hash != null && typeof result.hash !== "string") {
|
|
135
|
+
throw new Error("The review server returned an invalid source hash.");
|
|
136
|
+
}
|
|
137
|
+
state.baseHash = typeof result.hash === "string" ? result.hash : null;
|
|
138
|
+
if (state.baseHash)
|
|
139
|
+
sourceHash(state.baseHash);
|
|
140
|
+
state.status = "saved";
|
|
141
|
+
state.savedAt = clock();
|
|
142
|
+
publish();
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (!matches(identity))
|
|
147
|
+
return false;
|
|
148
|
+
if (error && typeof error === "object" && "status" in error && error.status === 409) {
|
|
149
|
+
state.baseHash = null;
|
|
150
|
+
state.status = "idle";
|
|
151
|
+
state.conflict = true;
|
|
152
|
+
publish();
|
|
153
|
+
conflict();
|
|
154
|
+
diagnostic("save-conflict");
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
state.status = "failed";
|
|
158
|
+
publish();
|
|
159
|
+
if (attempts < 2) {
|
|
160
|
+
await delay(500);
|
|
161
|
+
return saveHtml(html, identity, attempts + 1);
|
|
162
|
+
}
|
|
163
|
+
if (!sending())
|
|
164
|
+
failed("Couldn't save. Retry before sending feedback; your edits remain on this page.");
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function save(html) {
|
|
169
|
+
const identity = current();
|
|
170
|
+
const started = ++sequence;
|
|
171
|
+
lastSave = { html, identity };
|
|
172
|
+
pending++;
|
|
173
|
+
state.dirty = true;
|
|
174
|
+
activeSave = activeSave.then(async () => {
|
|
175
|
+
if (!matches(identity) || state.conflict)
|
|
176
|
+
return false;
|
|
177
|
+
const saved = await saveHtml(html, identity);
|
|
178
|
+
if (saved && sequence === started)
|
|
179
|
+
state.dirty = false;
|
|
180
|
+
return saved;
|
|
181
|
+
}).finally(() => { pending--; applyClean(); });
|
|
182
|
+
return activeSave;
|
|
183
|
+
}
|
|
184
|
+
async function flushEdits(strict = false) {
|
|
185
|
+
const key = current().key;
|
|
186
|
+
await flush(strict);
|
|
187
|
+
await settleEdits(key);
|
|
188
|
+
}
|
|
189
|
+
async function barrier() {
|
|
190
|
+
const identity = current();
|
|
191
|
+
await flushEdits(true);
|
|
192
|
+
await activeSave;
|
|
193
|
+
if (state.status === "failed" && !state.conflict && lastSave && matches(lastSave.identity)) {
|
|
194
|
+
await save(lastSave.html);
|
|
195
|
+
}
|
|
196
|
+
await settleEdits(identity.key);
|
|
197
|
+
applyClean();
|
|
198
|
+
if (!matches(identity))
|
|
199
|
+
throw new Error("The page changed while saving. Retry on the latest page.");
|
|
200
|
+
if (state.conflict)
|
|
201
|
+
throw new Error("Resolve the save conflict: the source changed outside this review. Reload latest; your comment drafts will be kept.");
|
|
202
|
+
if (state.status === "failed" || (policy() === "writable" && !state.dynamic && state.dirty)) {
|
|
203
|
+
throw new Error("Your page edits have not finished saving. They have not been sent.");
|
|
204
|
+
}
|
|
205
|
+
if (policy() !== "writable" || state.dynamic)
|
|
206
|
+
state.dirty = false;
|
|
207
|
+
}
|
|
208
|
+
async function captureStable(capture, signal) {
|
|
209
|
+
const identity = current();
|
|
210
|
+
const captured = await capture();
|
|
211
|
+
const capturedSequence = sequence;
|
|
212
|
+
const capturedEditSequence = editSequence;
|
|
213
|
+
await activeSave;
|
|
214
|
+
await settleEdits(identity.key);
|
|
215
|
+
applyClean();
|
|
216
|
+
if (signal?.aborted)
|
|
217
|
+
throw Object.assign(new Error("Capture cancelled"), { code: "CAPTURE_CANCELLED" });
|
|
218
|
+
if (!matches(identity) || state.conflict || state.status === "failed") {
|
|
219
|
+
throw new Error("The page changed or could not be saved during capture. Recapture the latest version.");
|
|
220
|
+
}
|
|
221
|
+
if (sequence !== capturedSequence || editSequence !== capturedEditSequence) {
|
|
222
|
+
throw new Error("The page changed while its captured edits were saving. Recapture the latest version.");
|
|
223
|
+
}
|
|
224
|
+
return captured;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
get state() { return state; },
|
|
228
|
+
subscribe(listener) {
|
|
229
|
+
listeners.add(listener);
|
|
230
|
+
return () => { listeners.delete(listener); };
|
|
231
|
+
},
|
|
232
|
+
save, persistEdit, settleEdits, flush: flushEdits, barrier, captureStable,
|
|
233
|
+
applyClean, queued, cancelRetries,
|
|
234
|
+
async settled() { await activeSave; },
|
|
235
|
+
markEdit() { editSequence++; },
|
|
236
|
+
markSaving() { state.dirty = true; state.status = "saving"; publish(); },
|
|
237
|
+
markDynamic() { state.dynamic = true; publish(); },
|
|
238
|
+
observeClean() { clean = { identity: current(), sequence, editSequence }; applyClean(); },
|
|
239
|
+
baseline(hash) { state.baseHash = hash; },
|
|
240
|
+
hold() {
|
|
241
|
+
state.baseHash = null;
|
|
242
|
+
cancelRetries();
|
|
243
|
+
if (state.dirty)
|
|
244
|
+
state.conflict = true;
|
|
245
|
+
send({ type: "eh:abortSave" });
|
|
246
|
+
},
|
|
247
|
+
markConflict() { state.conflict = true; },
|
|
248
|
+
reset() {
|
|
249
|
+
cancelRetries();
|
|
250
|
+
clean = null;
|
|
251
|
+
lastSave = null;
|
|
252
|
+
sequence++;
|
|
253
|
+
Object.assign(state, { status: "idle", savedAt: "", baseHash: null, conflict: false, dirty: false, dynamic: false });
|
|
254
|
+
},
|
|
255
|
+
async revert() {
|
|
256
|
+
const identity = current();
|
|
257
|
+
send({ type: "eh:abortSave" });
|
|
258
|
+
cancelRetries();
|
|
259
|
+
await activeSave;
|
|
260
|
+
try {
|
|
261
|
+
await settleEdits(identity.key);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
if (disposed)
|
|
265
|
+
throw error;
|
|
266
|
+
diagnostic("revert-discards-unpersisted-edits");
|
|
267
|
+
}
|
|
268
|
+
if (!matches(identity))
|
|
269
|
+
throw new Error("The page changed before reverting. Retry on the current page.");
|
|
270
|
+
const backlog = identity.key ? backlogs.get(identity.key) : undefined;
|
|
271
|
+
const discarded = new Map(backlog);
|
|
272
|
+
const result = record(await request(`/api/page/${identity.key}/revert`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: JSON.stringify({
|
|
275
|
+
sessionId, renderId: identity.renderId, generation: identity.generation, baseHash: state.baseHash,
|
|
276
|
+
}),
|
|
277
|
+
}));
|
|
278
|
+
// The server can reload the frame before confirming the successful revert.
|
|
279
|
+
for (const [id, payload] of discarded) {
|
|
280
|
+
if (backlog?.get(id) === payload)
|
|
281
|
+
backlog.delete(id);
|
|
282
|
+
}
|
|
283
|
+
if (identity.key && !backlogs.get(identity.key)?.size)
|
|
284
|
+
errors.delete(identity.key);
|
|
285
|
+
if (!matches(identity))
|
|
286
|
+
return;
|
|
287
|
+
state.status = "idle";
|
|
288
|
+
state.savedAt = "";
|
|
289
|
+
pageChanged(decodePage(result.page));
|
|
290
|
+
publish();
|
|
291
|
+
},
|
|
292
|
+
dispose() {
|
|
293
|
+
if (disposed)
|
|
294
|
+
return;
|
|
295
|
+
disposed = true;
|
|
296
|
+
cancelRetries();
|
|
297
|
+
listeners.clear();
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
}
|