@erdemtuna/doc-review 0.9.0 → 0.11.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 +58 -222
- package/{src → lib}/SKILL.md +5 -2
- package/lib/anchor-text.js +155 -0
- package/lib/atomic-write.js +54 -0
- package/lib/changes-controller.js +184 -0
- package/lib/chrome-api.js +78 -0
- package/lib/chrome-client.js +1700 -0
- package/lib/chrome-session.js +73 -0
- package/lib/chrome.html +53 -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/comments-controller.js +109 -0
- package/lib/contextual-controller.js +33 -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/controller-store.js +96 -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 +19 -0
- package/lib/feedback-controller.js +113 -0
- package/lib/feedback-panel-controller.js +161 -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 +217 -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 +325 -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/recovery-controller.js +123 -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 +303 -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/toolbar-controller.js +32 -0
- package/lib/ui/THIRD_PARTY_NOTICES.md +1254 -0
- package/lib/ui/chrome.css +4881 -0
- package/lib/ui/chrome.js +52 -0
- package/lib/view-identity.js +112 -0
- package/package.json +50 -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/chrome.css +0 -652
- package/src/chrome.html +0 -196
- 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}/document-trust.js +0 -0
package/src/editing.js
DELETED
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure editing helpers, shared by the in-page SDK and Node tests.
|
|
3
|
-
*
|
|
4
|
-
* Everything here is deliberately DOM-free: the SDK feeds in strings and
|
|
5
|
-
* computed-style snapshots, and gets back decisions it can apply.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* The list command for a marker typed at the start of a line, or null.
|
|
10
|
-
* `lead` is everything between the start of the block and the caret, so a
|
|
11
|
-
* marker typed mid-sentence never converts.
|
|
12
|
-
*/
|
|
13
|
-
export function listCommandFor(lead) {
|
|
14
|
-
const marker = String(lead || "").replace(/\u00a0/g, " ").trim();
|
|
15
|
-
if (/^[-*]$/.test(marker)) return "insertUnorderedList";
|
|
16
|
-
if (/^\d{1,3}[.)]$/.test(marker)) return "insertOrderedList";
|
|
17
|
-
return null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Style properties a fresh list needs when the page's CSS reset hides it.
|
|
22
|
-
* Tailwind preflight and similar resets set `list-style: none` and zero the
|
|
23
|
-
* indent, so a just-created list looks like nothing happened. Returns only
|
|
24
|
-
* the properties that are actually broken, so styled pages stay untouched.
|
|
25
|
-
*/
|
|
26
|
-
export function listStyleFixup(tagName, computed) {
|
|
27
|
-
const patch = {};
|
|
28
|
-
if (computed.listStyleType === "none") {
|
|
29
|
-
patch.listStyleType = /^ol$/i.test(tagName) ? "decimal" : "disc";
|
|
30
|
-
}
|
|
31
|
-
const inset = (parseFloat(computed.paddingLeft) || 0) + (parseFloat(computed.marginLeft) || 0);
|
|
32
|
-
if (inset < 16) patch.paddingLeft = "1.5em";
|
|
33
|
-
return patch;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Style a fresh link needs when the page can't distinguish it from prose.
|
|
38
|
-
* Reset stylesheets (`a { color: inherit; text-decoration: inherit }`) make a
|
|
39
|
-
* just-created link invisible; underline it only when neither color nor
|
|
40
|
-
* decoration sets it apart.
|
|
41
|
-
*/
|
|
42
|
-
export function linkStyleFixup(anchorComputed, parentComputed) {
|
|
43
|
-
const decorated = String(anchorComputed.textDecorationLine || "").includes("underline");
|
|
44
|
-
const recolored = !!parentComputed && anchorComputed.color !== parentComputed.color;
|
|
45
|
-
return decorated || recolored ? {} : { textDecoration: "underline" };
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A typed link, made openable and safe. Bare domains get https://, in-page
|
|
50
|
-
* and relative references pass through, and anything with an executable or
|
|
51
|
-
* unknown scheme (javascript:, data:, …) is rejected outright.
|
|
52
|
-
*/
|
|
53
|
-
export function normalizeHref(raw) {
|
|
54
|
-
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
55
|
-
if (!href) return "";
|
|
56
|
-
let candidate = href;
|
|
57
|
-
if (!/^(https?:|mailto:|tel:|#|\/|\.)/i.test(href)) {
|
|
58
|
-
const head = href.split(/[/?#]/)[0];
|
|
59
|
-
if (/^([\w-]+\.)+[\w-]+(:\d+)?$/.test(head) || /^localhost(:\d+)?$/i.test(head)) {
|
|
60
|
-
// A bare domain — "example.com", "localhost:3000/wiki" — gets a scheme.
|
|
61
|
-
candidate = `https://${href}`;
|
|
62
|
-
} else if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
|
|
63
|
-
return "";
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
try {
|
|
67
|
-
const parsed = new URL(candidate, "https://relative.invalid");
|
|
68
|
-
if (!/^(https?:|mailto:|tel:)$/i.test(parsed.protocol)) return "";
|
|
69
|
-
} catch {
|
|
70
|
-
return "";
|
|
71
|
-
}
|
|
72
|
-
return candidate;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Classify an authored navigation target. Relative references stay under
|
|
77
|
-
* controlled source-relative navigation; only explicitly safe external
|
|
78
|
-
* schemes leave the review.
|
|
79
|
-
*/
|
|
80
|
-
export function classifyHref(raw) {
|
|
81
|
-
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
82
|
-
if (!href) return "invalid";
|
|
83
|
-
if (href.startsWith("#")) return "hash";
|
|
84
|
-
if (/^\/\//.test(href) || /^https?:/i.test(href) || /^(?:mailto|tel):/i.test(href)) return "external";
|
|
85
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return "invalid";
|
|
86
|
-
return "navigate";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function externalHref(raw, baseHref = "https://relative.invalid/") {
|
|
90
|
-
const href = String(raw || "").replace(/[\u0000-\u001f\u007f]/g, "").trim();
|
|
91
|
-
if (classifyHref(href) !== "external") return "";
|
|
92
|
-
if (/^(?:mailto|tel):/i.test(href)) return href;
|
|
93
|
-
try {
|
|
94
|
-
const parsed = new URL(href, baseHref);
|
|
95
|
-
return /^https?:$/i.test(parsed.protocol) ? parsed.href : "";
|
|
96
|
-
} catch {
|
|
97
|
-
return "";
|
|
98
|
-
}
|
|
99
|
-
}
|
package/src/execution-client.js
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/** UI policy always describes the visible frame, not a newer on-disk source. */
|
|
2
|
-
export function executionPresentation(page, rendered, { loading = false, pendingReload = false, error = null } = {}) {
|
|
3
|
-
const eligible = page?.kind === "file" && !page.markdown;
|
|
4
|
-
const policy = rendered?.savePolicy;
|
|
5
|
-
return {
|
|
6
|
-
eligible,
|
|
7
|
-
preference: page?.executionPreference || "auto",
|
|
8
|
-
editDescription: policy === "writable" ? "Edits save directly to this file"
|
|
9
|
-
: policy === "feedback-only" ? "Edits are feedback for the agent to apply"
|
|
10
|
-
: "Waiting for the page's editing policy",
|
|
11
|
-
status: error || (pendingReload ? "Showing the previous page while reload is pending."
|
|
12
|
-
: loading ? "Loading page…" : ""),
|
|
13
|
-
detail: [
|
|
14
|
-
rendered?.executionNotice,
|
|
15
|
-
rendered?.executionMode === "static" && policy === "feedback-only" && eligible
|
|
16
|
-
? "Scripts are disabled for recovery. Edits still go to the agent; the source is not overwritten." : null,
|
|
17
|
-
].filter(Boolean).join(" "),
|
|
18
|
-
};
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function createExecutionControls({ api, sessionId, elements, changed, failed }) {
|
|
22
|
-
let busy = false;
|
|
23
|
-
let currentPage = null;
|
|
24
|
-
async function recover(preference) {
|
|
25
|
-
if (busy || !currentPage) return;
|
|
26
|
-
const key = currentPage.key;
|
|
27
|
-
busy = true;
|
|
28
|
-
if (elements.menu) {
|
|
29
|
-
elements.menu.open = false;
|
|
30
|
-
elements.menu.querySelector("summary")?.focus({ preventScroll: true });
|
|
31
|
-
}
|
|
32
|
-
elements.static.disabled = elements.auto.disabled = true;
|
|
33
|
-
try {
|
|
34
|
-
const result = await api(`/api/session/${sessionId}/execution`, {
|
|
35
|
-
method: "POST", body: JSON.stringify({ key, preference }),
|
|
36
|
-
});
|
|
37
|
-
if (currentPage?.key === key) changed(result.page);
|
|
38
|
-
// The server's reload event owns draft-safe frame replacement.
|
|
39
|
-
} catch (error) { failed(error); }
|
|
40
|
-
finally {
|
|
41
|
-
busy = false;
|
|
42
|
-
elements.static.disabled = elements.auto.disabled = false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
elements.static.addEventListener("click", () => void recover("static"));
|
|
46
|
-
elements.auto.addEventListener("click", () => void recover("auto"));
|
|
47
|
-
return {
|
|
48
|
-
render(page, rendered, options) {
|
|
49
|
-
currentPage = page;
|
|
50
|
-
const view = executionPresentation(page, rendered, options);
|
|
51
|
-
elements.static.hidden = !view.eligible || view.preference === "static";
|
|
52
|
-
elements.auto.hidden = !view.eligible || view.preference !== "static";
|
|
53
|
-
if (elements.menu) elements.menu.hidden = !view.eligible && !view.detail;
|
|
54
|
-
elements.status.textContent = view.status;
|
|
55
|
-
elements.status.hidden = !view.status;
|
|
56
|
-
if (elements.details) {
|
|
57
|
-
elements.details.textContent = view.detail;
|
|
58
|
-
elements.details.hidden = !view.detail;
|
|
59
|
-
}
|
|
60
|
-
if (elements.editDescription) elements.editDescription.textContent = view.editDescription;
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
}
|
package/src/frame-channel.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
let channel = null;
|
|
2
|
-
|
|
3
|
-
export function initializeChannel(capability, generation, pageKey) {
|
|
4
|
-
if (channel) throw new Error("Doc Review frame channel is already initialized.");
|
|
5
|
-
channel = {
|
|
6
|
-
capability: String(capability),
|
|
7
|
-
generation: Number(generation),
|
|
8
|
-
pageKey: String(pageKey),
|
|
9
|
-
};
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function initializeChannelFromDocument() {
|
|
13
|
-
const script = document.querySelector("script[data-eh-sdk][data-eh-bootstrap]");
|
|
14
|
-
if (!script) throw new Error("Doc Review frame bootstrap is missing.");
|
|
15
|
-
const capability = script.nonce;
|
|
16
|
-
const generation = Number(script.dataset.generation);
|
|
17
|
-
const pageKey = String(script.dataset.pageKey || "");
|
|
18
|
-
script.remove();
|
|
19
|
-
if (!capability || !Number.isSafeInteger(generation) || !pageKey) {
|
|
20
|
-
throw new Error("Doc Review frame bootstrap is invalid.");
|
|
21
|
-
}
|
|
22
|
-
initializeChannel(capability, generation, pageKey);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function frameMessage(type, payload = {}) {
|
|
26
|
-
if (!channel) throw new Error("Doc Review frame channel is not initialized.");
|
|
27
|
-
return {
|
|
28
|
-
...payload,
|
|
29
|
-
type,
|
|
30
|
-
capability: channel.capability,
|
|
31
|
-
generation: channel.generation,
|
|
32
|
-
pageKey: channel.pageKey,
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function matchesFrameMessage(message) {
|
|
37
|
-
return !!(
|
|
38
|
-
channel &&
|
|
39
|
-
message &&
|
|
40
|
-
message.capability === channel.capability &&
|
|
41
|
-
message.generation === channel.generation &&
|
|
42
|
-
message.pageKey === channel.pageKey
|
|
43
|
-
);
|
|
44
|
-
}
|
package/src/frame-policy.js
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
const BASE_SANDBOX = "allow-scripts allow-forms allow-modals";
|
|
2
|
-
const LOCALHOST_SANDBOX = `${BASE_SANDBOX} allow-popups allow-downloads allow-same-origin`;
|
|
3
|
-
|
|
4
|
-
// Exact immutable review modules, not the artifact directory or whole origin.
|
|
5
|
-
// Do not add strict-dynamic or a nonce: either would let authored code authorize
|
|
6
|
-
// mutable external scripts. The capability in the SDK tag is correlation only.
|
|
7
|
-
export const TRUSTED_SDK_MODULE_PATHS = Object.freeze([
|
|
8
|
-
"/sdk.js", "/anchor-text.js", "/click-target.js", "/comment-target.js",
|
|
9
|
-
"/editing.js", "/frame-channel.js", "/icons.js", "/review-mode.js",
|
|
10
|
-
"/semantic-snapshot.js", "/serialize.js", "/positioning.js",
|
|
11
|
-
"/revision-schema.js", "/view-identity.js",
|
|
12
|
-
]);
|
|
13
|
-
|
|
14
|
-
export function interactiveFileCsp(sdkOrigin) {
|
|
15
|
-
const origin = new URL(sdkOrigin);
|
|
16
|
-
if (!["http:", "https:"].includes(origin.protocol) || origin.username || origin.password ||
|
|
17
|
-
origin.pathname !== "/" || origin.search || origin.hash) {
|
|
18
|
-
throw new TypeError("The SDK origin must be an HTTP origin without a path or credentials.");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const modules = TRUSTED_SDK_MODULE_PATHS.map((route) => `${origin.origin}${route}`).join(" ");
|
|
22
|
-
return [
|
|
23
|
-
"default-src 'none'",
|
|
24
|
-
`script-src 'unsafe-inline' ${modules}`,
|
|
25
|
-
"script-src-attr 'unsafe-inline'",
|
|
26
|
-
"worker-src 'none'",
|
|
27
|
-
"child-src 'none'",
|
|
28
|
-
"frame-src 'none'",
|
|
29
|
-
"object-src 'none'",
|
|
30
|
-
"connect-src 'none'",
|
|
31
|
-
"base-uri 'none'",
|
|
32
|
-
"form-action 'none'",
|
|
33
|
-
"style-src 'unsafe-inline' http: https: data:",
|
|
34
|
-
"img-src http: https: data: blob:",
|
|
35
|
-
"font-src http: https: data:",
|
|
36
|
-
"media-src http: https: data: blob:",
|
|
37
|
-
].join("; ");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export const trustedFileCsp = interactiveFileCsp;
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Localhost apps need their real origin so their routing and JavaScript work.
|
|
44
|
-
* Files and rendered Markdown do not: An opaque iframe origin prevents them
|
|
45
|
-
* from reading sibling files served by the artifact route.
|
|
46
|
-
*/
|
|
47
|
-
export function framePolicy(page, artifactOrigin) {
|
|
48
|
-
const keepsOrigin = page?.kind === "url";
|
|
49
|
-
return {
|
|
50
|
-
sandbox: keepsOrigin ? LOCALHOST_SANDBOX : BASE_SANDBOX,
|
|
51
|
-
incomingOrigin: keepsOrigin ? artifactOrigin : "null",
|
|
52
|
-
targetOrigin: keepsOrigin ? artifactOrigin : "*",
|
|
53
|
-
};
|
|
54
|
-
}
|
package/src/history-client.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
|
|
7
|
-
export function createCaptureRequests({ send, current, timeout = 6000 }) {
|
|
8
|
-
const pending = new Map();
|
|
9
|
-
let sequence = 0;
|
|
10
|
-
return {
|
|
11
|
-
request({ timeout: requestTimeout = timeout, signal } = {}) {
|
|
12
|
-
const identity = { ...current() };
|
|
13
|
-
if (signal?.aborted) return Promise.reject(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
|
|
14
|
-
if (!identity.renderId || identity.loading) return Promise.reject(captureError("CAPTURE_NOT_READY", "The page is not ready to capture"));
|
|
15
|
-
const requestId = `capture-${++sequence}`;
|
|
16
|
-
return new Promise((resolve, reject) => {
|
|
17
|
-
const finish = (error, value) => {
|
|
18
|
-
if (!pending.has(requestId)) return;
|
|
19
|
-
pending.delete(requestId);
|
|
20
|
-
clearTimeout(timer);
|
|
21
|
-
signal?.removeEventListener("abort", abort);
|
|
22
|
-
if (error) reject(error);
|
|
23
|
-
else resolve(value);
|
|
24
|
-
};
|
|
25
|
-
const abort = () => finish(captureError("CAPTURE_CANCELLED", "Capture cancelled"));
|
|
26
|
-
const timer = setTimeout(() => finish(captureError("CAPTURE_TIMEOUT",
|
|
27
|
-
"The page did not confirm a stable capture. Recapture when it is ready.")), requestTimeout);
|
|
28
|
-
pending.set(requestId, { identity, finish });
|
|
29
|
-
signal?.addEventListener("abort", abort, { once: true });
|
|
30
|
-
try { send({ type: "eh:captureSnapshot", requestId, requireStable: true }); }
|
|
31
|
-
catch (error) { finish(error); }
|
|
32
|
-
});
|
|
33
|
-
},
|
|
34
|
-
receive(message) {
|
|
35
|
-
const request = pending.get(message.requestId);
|
|
36
|
-
if (!request) return false;
|
|
37
|
-
if (!sameRender(request.identity, current())) request.finish(captureError("CAPTURE_CHANGED", "The page changed during capture. Recapture the latest version."));
|
|
38
|
-
else if (message.error || !message.snapshot) request.finish(captureError(message.error?.code || "CAPTURE_UNAVAILABLE",
|
|
39
|
-
message.error?.message || message.error || "Semantic capture is unavailable"));
|
|
40
|
-
else request.finish(null, {
|
|
41
|
-
...request.identity, semantic: message.snapshot, sourceHash: message.sourceHash,
|
|
42
|
-
semanticCapturedAt: Number.isFinite(message.capturedAt) ? message.capturedAt : undefined,
|
|
43
|
-
view: message.view,
|
|
44
|
-
});
|
|
45
|
-
return true;
|
|
46
|
-
},
|
|
47
|
-
cancel(reason = "The page changed during capture") {
|
|
48
|
-
for (const request of pending.values()) {
|
|
49
|
-
request.finish(captureError("CAPTURE_CANCELLED", reason));
|
|
50
|
-
}
|
|
51
|
-
pending.clear();
|
|
52
|
-
},
|
|
53
|
-
get size() { return pending.size; },
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function captureError(code, message) {
|
|
58
|
-
return Object.assign(new Error(message), { code });
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function draftCount({ compose, commentUi }, note = "") {
|
|
62
|
-
return Number(!!compose) + Number(!!commentUi?.edit) + Number(!!note.trim());
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function excerpt(value) {
|
|
66
|
-
if (typeof value === "string") return value;
|
|
67
|
-
if (!value) return "";
|
|
68
|
-
return String(value.text ?? value.excerpt ?? value.content ?? value.label ?? "");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function changeKind(item) {
|
|
72
|
-
const kind = String(item.kind || item.type || "").toLowerCase();
|
|
73
|
-
return kind === "added" || kind === "removed" ? kind : "modified";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function pendingCaptureTarget(round, key, readyAt) {
|
|
77
|
-
if (round?.feedbackStatus !== "acknowledged" || !(readyAt > 0)) return null;
|
|
78
|
-
return round.targets?.find((target) => target.key === key && !target.resultRevisionId &&
|
|
79
|
-
["pending", "failed"].includes(target.capture?.status || target.captureStatus || "pending")) || null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export function defaultHistoryRound(rounds) {
|
|
83
|
-
return rounds.find((round) => round.completedAt || round.captureStatus === "ready" ||
|
|
84
|
-
["complete", "completed"].includes(round.status)) || rounds[0] || null;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export function comparisonFreshness(comparison, targetKey, current) {
|
|
88
|
-
if (targetKey !== current.key) return "Different page: this saved comparison does not describe the page open in Latest version.";
|
|
89
|
-
if (!current.ready || current.pendingReload || current.dirty) return "Latest version is unverified: reload or finish saving before comparing it with this captured result.";
|
|
90
|
-
if (current.kind === "url") {
|
|
91
|
-
return comparison.capturedSessionId === current.sessionId && comparison.capturedGeneration === current.generation
|
|
92
|
-
? "Same live render generation; the current DOM is not verified against the captured result. Live content may have changed."
|
|
93
|
-
: "Latest live render is different or unverified. This historical comparison cannot identify exact targets on the live page.";
|
|
94
|
-
}
|
|
95
|
-
if (!comparison.sourceHash || !current.sourceHash) return "Latest source is unverified against this captured result.";
|
|
96
|
-
return comparison.sourceHash === current.sourceHash
|
|
97
|
-
? "Latest file source matches the captured result. Runtime DOM changes are not verified by a source match."
|
|
98
|
-
: "Latest source is newer or different from the captured result. You are viewing a historical comparison.";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function canJumpToCurrent(item, result, current) {
|
|
102
|
-
return item.confidence === "exact" && !!item.navigation?.selector && !item.unresolved && changeKind(item) !== "removed" &&
|
|
103
|
-
!!result?.renderId && sameRender(result, current);
|
|
104
|
-
}
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { defaultHistoryRound } from "./history-client.js";
|
|
2
|
-
|
|
3
|
-
const idOf = (round) => round?.roundId || round?.id;
|
|
4
|
-
const retryDelays = [1000, 2000, 4000];
|
|
5
|
-
|
|
6
|
-
export function transientCaptureError(error) {
|
|
7
|
-
return ["CAPTURE_TIMEOUT", "CAPTURE_NOT_READY", "CAPTURE_UNSTABLE", "CAPTURE_BUSY", "VIEW_CHANGED"].includes(error?.code) ||
|
|
8
|
-
[408, 429, 502, 503, 504].includes(error?.status) ||
|
|
9
|
-
(error instanceof TypeError && /fetch|network|load failed/i.test(error.message));
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function requireCaptureSuccess(result, key) {
|
|
13
|
-
if (result?.ok === true) return result;
|
|
14
|
-
throw Object.assign(new Error(result?.round?.targets?.find((target) => target.key === key)?.capture?.error ||
|
|
15
|
-
"Content capture is unavailable"), { code: "CAPTURE_UNAVAILABLE" });
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** One capture at a time; delayed/finished attempts never block other rounds. */
|
|
19
|
-
export function createCaptureCoordinator({ candidates, ready, capture, now = Date.now,
|
|
20
|
-
setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
21
|
-
const attempts = new Map();
|
|
22
|
-
let timer = null;
|
|
23
|
-
let active = null;
|
|
24
|
-
let epoch = 0;
|
|
25
|
-
let stopped = false;
|
|
26
|
-
function tick() {
|
|
27
|
-
clearTimer(timer);
|
|
28
|
-
timer = null;
|
|
29
|
-
if (stopped || active || !ready()) return;
|
|
30
|
-
const jobs = candidates();
|
|
31
|
-
const job = jobs.find(({ id }) => {
|
|
32
|
-
const attempt = attempts.get(id);
|
|
33
|
-
return !attempt || (!attempt.done && attempt.due <= now());
|
|
34
|
-
});
|
|
35
|
-
if (!job) {
|
|
36
|
-
const due = jobs.map(({ id }) => attempts.get(id)).filter((entry) => entry && !entry.done)
|
|
37
|
-
.reduce((next, entry) => Math.min(next, entry.due), Infinity);
|
|
38
|
-
if (Number.isFinite(due)) timer = setTimer(tick, Math.max(0, due - now()));
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
const attempt = attempts.get(job.id) || { count: 0, due: 0, done: false };
|
|
42
|
-
attempts.set(job.id, attempt);
|
|
43
|
-
attempt.count++;
|
|
44
|
-
const controller = new AbortController();
|
|
45
|
-
const started = epoch;
|
|
46
|
-
active = controller;
|
|
47
|
-
Promise.resolve().then(() => {
|
|
48
|
-
if (controller.signal.aborted) return;
|
|
49
|
-
return capture(job, controller.signal);
|
|
50
|
-
}).then(() => { attempt.done = true; }, (error) => {
|
|
51
|
-
if (transientCaptureError(error) && attempt.count <= retryDelays.length) {
|
|
52
|
-
attempt.due = now() + retryDelays[attempt.count - 1];
|
|
53
|
-
} else attempt.done = true;
|
|
54
|
-
}).finally(() => {
|
|
55
|
-
if (started !== epoch) return;
|
|
56
|
-
active = null;
|
|
57
|
-
tick();
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
return {
|
|
61
|
-
tick,
|
|
62
|
-
reset() {
|
|
63
|
-
epoch++;
|
|
64
|
-
active?.abort();
|
|
65
|
-
active = null;
|
|
66
|
-
clearTimer(timer);
|
|
67
|
-
timer = null;
|
|
68
|
-
attempts.clear();
|
|
69
|
-
},
|
|
70
|
-
stop() {
|
|
71
|
-
this.reset();
|
|
72
|
-
stopped = true;
|
|
73
|
-
},
|
|
74
|
-
get busy() { return !!active; },
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function historyPresentation({ loading, error, round, target, comparison = {}, failure }) {
|
|
79
|
-
const modes = ["content", "source"].filter((mode) => comparison[mode]?.available === true);
|
|
80
|
-
if (loading && !round) return { state: "loading", message: "Loading comparison…", modes };
|
|
81
|
-
if (error && !round) return { state: "failed", message: "History could not be loaded. Try again.", modes };
|
|
82
|
-
if (!round) return { state: "empty", message: "No review rounds yet. Send feedback to start.", modes };
|
|
83
|
-
if (modes.length) {
|
|
84
|
-
const partial = modes.length < 2 || !!target?.baselineUnavailable || !!target?.resultUnavailable;
|
|
85
|
-
return { state: partial ? "partial" : "available", modes,
|
|
86
|
-
message: modes.includes("content")
|
|
87
|
-
? partial ? "Content available; some comparison coverage is missing." : "Comparison ready."
|
|
88
|
-
: "Source available. Content is pending or unavailable." };
|
|
89
|
-
}
|
|
90
|
-
if (failure || error || target?.capture?.status === "failed") {
|
|
91
|
-
return { state: "failed", message: "Content capture failed. Feedback is safe; retry when ready.", modes };
|
|
92
|
-
}
|
|
93
|
-
if (target?.capture?.status === "unavailable" || target?.resultRevisionId) {
|
|
94
|
-
return { state: "partial", message: "Comparison unavailable for these snapshots. Feedback is safe.", modes };
|
|
95
|
-
}
|
|
96
|
-
return { state: "waiting", message: round.feedbackStatus === "acknowledged"
|
|
97
|
-
? "Waiting for a stable result snapshot. You can keep reviewing."
|
|
98
|
-
: "Waiting for the agent's result.", modes };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Fetch into local values, then publish atomically so selectors never relabel stale content. */
|
|
102
|
-
export function createHistoryController({ request, changed = () => {}, refreshed = () => {} }) {
|
|
103
|
-
const state = {
|
|
104
|
-
rounds: [], selectedId: null, round: null, targetKey: null, mode: "content", preferredMode: null,
|
|
105
|
-
index: 0, loading: false, error: null, captureBusy: false, finalizing: false, failures: new Map(),
|
|
106
|
-
};
|
|
107
|
-
let revision = 0;
|
|
108
|
-
const targets = new Map();
|
|
109
|
-
async function refresh() {
|
|
110
|
-
const current = ++revision;
|
|
111
|
-
state.loading = true;
|
|
112
|
-
state.error = null;
|
|
113
|
-
if (state.round && (idOf(state.round) !== state.selectedId ||
|
|
114
|
-
!state.round.targets?.some((target) => target.key === state.targetKey && target.comparison))) state.round = null;
|
|
115
|
-
changed();
|
|
116
|
-
try {
|
|
117
|
-
const collection = await request("");
|
|
118
|
-
if (current !== revision) return false;
|
|
119
|
-
state.rounds = collection.rounds || [];
|
|
120
|
-
if (!state.rounds.some((round) => idOf(round) === state.selectedId)) {
|
|
121
|
-
state.selectedId = idOf(defaultHistoryRound(state.rounds)) || null;
|
|
122
|
-
}
|
|
123
|
-
const selectedId = state.selectedId;
|
|
124
|
-
let round = null;
|
|
125
|
-
let targetKey = state.targetKey || targets.get(selectedId);
|
|
126
|
-
if (selectedId) {
|
|
127
|
-
const detail = await request(`/${selectedId}`);
|
|
128
|
-
if (current !== revision) return false;
|
|
129
|
-
round = detail.round || detail;
|
|
130
|
-
const summary = state.rounds.find((item) => idOf(item) === selectedId);
|
|
131
|
-
for (const target of round.targets || []) {
|
|
132
|
-
target.filename ||= summary?.targets?.find((item) => item.key === target.key)?.filename;
|
|
133
|
-
}
|
|
134
|
-
const target = round.targets?.find((item) => item.key === targetKey) || round.targets?.[0];
|
|
135
|
-
targetKey = target?.key || null;
|
|
136
|
-
if (target) {
|
|
137
|
-
const results = await Promise.allSettled(["content", "source"].map((mode) =>
|
|
138
|
-
request(`/${selectedId}/compare?key=${encodeURIComponent(target.key)}&mode=${mode}`)));
|
|
139
|
-
if (current !== revision) return false;
|
|
140
|
-
target.comparison = Object.fromEntries(["content", "source"].map((mode, index) =>
|
|
141
|
-
[mode, results[index].status === "fulfilled" ? results[index].value
|
|
142
|
-
: { available: false, counts: null, reason: results[index].reason.message }]));
|
|
143
|
-
if (results.every((result) => result.status === "rejected")) state.error = results[0].reason;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
state.round = round;
|
|
147
|
-
state.targetKey = targetKey;
|
|
148
|
-
if (selectedId) targets.set(selectedId, targetKey);
|
|
149
|
-
state.loading = false;
|
|
150
|
-
changed();
|
|
151
|
-
refreshed();
|
|
152
|
-
return true;
|
|
153
|
-
} catch (error) {
|
|
154
|
-
if (current !== revision) return false;
|
|
155
|
-
state.error = error;
|
|
156
|
-
state.loading = false;
|
|
157
|
-
changed();
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
return {
|
|
162
|
-
state, refresh,
|
|
163
|
-
selectRound(id) {
|
|
164
|
-
state.selectedId = id;
|
|
165
|
-
state.targetKey = targets.get(id) || null;
|
|
166
|
-
state.index = 0;
|
|
167
|
-
return refresh();
|
|
168
|
-
},
|
|
169
|
-
selectTarget(key) { state.targetKey = key; state.index = 0; return refresh(); },
|
|
170
|
-
cancel() { revision++; state.loading = false; },
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Optional capture cannot veto delivery; a failed refresh cannot masquerade as failed Send. */
|
|
175
|
-
export async function deliverFeedback({ save, capture, deliver, committed, refresh }) {
|
|
176
|
-
await save();
|
|
177
|
-
let snapshot = null;
|
|
178
|
-
let captureFailure = null;
|
|
179
|
-
try { snapshot = await capture(); } catch (error) { captureFailure = error; }
|
|
180
|
-
await deliver(snapshot);
|
|
181
|
-
committed();
|
|
182
|
-
let refreshFailure = null;
|
|
183
|
-
try { if (await refresh() === false) refreshFailure = new Error("History could not be refreshed"); }
|
|
184
|
-
catch (error) { refreshFailure = error; }
|
|
185
|
-
return { captureFailure, refreshFailure };
|
|
186
|
-
}
|
package/src/history-policy.js
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { COMPLETED_ROUNDS_TO_KEEP, isRevisionId } from "./revision-schema.js";
|
|
2
|
-
|
|
3
|
-
function references(value, out = new Set()) {
|
|
4
|
-
if (isRevisionId(value)) out.add(value);
|
|
5
|
-
else if (Array.isArray(value)) for (const item of value) references(item, out);
|
|
6
|
-
else if (value && typeof value === "object") for (const item of Object.values(value)) references(item, out);
|
|
7
|
-
return out;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function unsentRevisionReferences(data) {
|
|
11
|
-
const out = new Set();
|
|
12
|
-
for (const page of Object.values(data.pages || {})) {
|
|
13
|
-
references(page.comments, out);
|
|
14
|
-
references(page.edits, out);
|
|
15
|
-
references(page.revisionRefs, out);
|
|
16
|
-
}
|
|
17
|
-
return out;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function historyRevisionReferences(data) {
|
|
21
|
-
const out = unsentRevisionReferences(data);
|
|
22
|
-
references(data.histories, out);
|
|
23
|
-
references(data.batches, out);
|
|
24
|
-
return out;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function retainHistory(data) {
|
|
28
|
-
const pinned = unsentRevisionReferences(data);
|
|
29
|
-
let changed = false;
|
|
30
|
-
for (const history of Object.values(data.histories || {})) {
|
|
31
|
-
const completed = history.rounds
|
|
32
|
-
.filter((round) => round.completedAt)
|
|
33
|
-
.sort((a, b) => b.completedAt - a.completedAt || b.ordinal - a.ordinal);
|
|
34
|
-
const keep = new Set(completed.slice(0, COMPLETED_ROUNDS_TO_KEEP).map((round) => round.roundId));
|
|
35
|
-
const rounds = history.rounds.filter((round) =>
|
|
36
|
-
!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
|
-
}
|