@erdemtuna/doc-review 0.8.1 → 0.9.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 +112 -22
- package/package.json +4 -2
- package/src/SKILL.md +28 -4
- package/src/chrome-client.js +844 -103
- package/src/chrome.css +253 -8
- package/src/chrome.html +78 -11
- package/src/cli.js +4 -0
- package/src/comment-target.js +87 -0
- package/src/comparison-view.js +337 -0
- package/src/document-execution.js +103 -0
- package/src/document-trust.js +2 -0
- package/src/execution-client.js +63 -0
- package/src/frame-policy.js +38 -0
- package/src/history-client.js +104 -0
- package/src/history-coordinator.js +186 -0
- package/src/history-policy.js +43 -0
- package/src/history-server.js +463 -0
- package/src/paths.js +1 -1
- package/src/review-mode.js +1 -1
- package/src/revision-diff.js +440 -0
- package/src/revision-schema.js +219 -0
- package/src/revision-store.js +177 -0
- package/src/sdk.js +419 -55
- package/src/semantic-snapshot.js +230 -0
- package/src/server.js +217 -49
- package/src/state.js +240 -15
- package/src/view-identity.js +99 -0
package/src/state.js
CHANGED
|
@@ -5,6 +5,9 @@ import { normalizeCommentAnchor } from "./comment-anchor.js";
|
|
|
5
5
|
import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
|
|
6
6
|
export { atomicWrite } from "./atomic-write.js";
|
|
7
7
|
import { atomicWrite } from "./atomic-write.js";
|
|
8
|
+
import { RevisionStore } from "./revision-store.js";
|
|
9
|
+
import { CAPTURE_LEASE_MS, HISTORY_SCHEMA_VERSION, normalizeHistoryTargets, revisionError } from "./revision-schema.js";
|
|
10
|
+
import { historyRevisionReferences, retainHistory } from "./history-policy.js";
|
|
8
11
|
|
|
9
12
|
/** Anything untouched this long is review debris, not work in progress. */
|
|
10
13
|
const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -12,11 +15,50 @@ const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
|
|
|
12
15
|
|
|
13
16
|
const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
|
|
14
17
|
const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
|
|
15
|
-
const emptyState = () => ({ pages: {}, batches: {}, receipts: {} });
|
|
18
|
+
const emptyState = () => ({ pages: {}, batches: {}, receipts: {}, histories: {} });
|
|
19
|
+
const historyId = (prefix) => `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
|
|
20
|
+
|
|
21
|
+
function historyRound(data, entryKey, roundId) {
|
|
22
|
+
return data.histories[entryKey]?.rounds.find((round) => round.roundId === roundId) || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function batchRound(data, entryKey, id) {
|
|
26
|
+
return data.histories[entryKey]?.rounds.find((round) => round.batchId === id) || null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function publicRound(round) {
|
|
30
|
+
if (!round) return null;
|
|
31
|
+
const copy = structuredClone(round);
|
|
32
|
+
copy.sentAt ||= new Date(copy.createdAt).toISOString();
|
|
33
|
+
for (const target of copy.targets) {
|
|
34
|
+
target.captureStatus = target.capture?.status || "pending";
|
|
35
|
+
target.ownerSessionId = target.capture?.ownerSessionId || target.ownerSessionId || null;
|
|
36
|
+
}
|
|
37
|
+
return copy;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function finishCapture(round) {
|
|
41
|
+
const statuses = round.targets.map((target) => target.capture?.status || "pending");
|
|
42
|
+
if (statuses.every((status) => status === "ready" || status === "unavailable")) {
|
|
43
|
+
const fullContent = round.targets.every((target) => target.capture.status === "ready" &&
|
|
44
|
+
target.baselineCoverage?.semantic && target.resultCoverage?.semantic);
|
|
45
|
+
const anyResult = statuses.some((status) => status === "ready") ||
|
|
46
|
+
round.targets.some((target) => target.sourceResultRevisionId);
|
|
47
|
+
round.captureStatus = fullContent ? "ready" : anyResult ? "partial" : "failed";
|
|
48
|
+
round.completedAt ||= Date.now();
|
|
49
|
+
} else {
|
|
50
|
+
round.captureStatus = statuses.some((status) => status === "failed") ? "failed" : "pending";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
16
53
|
|
|
17
54
|
function pruneData(data, now = Date.now()) {
|
|
18
|
-
let changed =
|
|
55
|
+
let changed = retainHistory(data);
|
|
19
56
|
for (const [key, page] of Object.entries(data.pages)) {
|
|
57
|
+
const protectedPage = page.comments?.length || page.edits?.length || page.revisionRefs?.length ||
|
|
58
|
+
data.batches[key] || Object.values(data.batches).some((record) => record.cleanup.some((item) => item.key === key)) ||
|
|
59
|
+
Object.values(data.histories).some((history) => history.rounds.some((round) =>
|
|
60
|
+
round.targets.some((target) => target.key === key)));
|
|
61
|
+
if (protectedPage) continue;
|
|
20
62
|
const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
|
|
21
63
|
if (!fresh(page, now) || missingFile) {
|
|
22
64
|
delete data.pages[key];
|
|
@@ -24,13 +66,8 @@ function pruneData(data, now = Date.now()) {
|
|
|
24
66
|
changed = true;
|
|
25
67
|
}
|
|
26
68
|
}
|
|
27
|
-
for (const [key, batch] of Object.entries(data.batches)) {
|
|
28
|
-
if (!fresh(batch, now)) {
|
|
29
|
-
delete data.batches[key];
|
|
30
|
-
changed = true;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
69
|
for (const [id, receipt] of Object.entries(data.receipts)) {
|
|
70
|
+
if (Object.values(data.histories).some((history) => history.rounds.some((round) => round.batchId === id))) continue;
|
|
34
71
|
if (!fresh(receipt, now)) {
|
|
35
72
|
delete data.receipts[id];
|
|
36
73
|
changed = true;
|
|
@@ -47,8 +84,25 @@ function normalizeState(parsed, makeBatchId) {
|
|
|
47
84
|
pages: parsed.pages,
|
|
48
85
|
batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
|
|
49
86
|
receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
|
|
87
|
+
histories: parsed.histories && typeof parsed.histories === "object" ? parsed.histories : {},
|
|
50
88
|
};
|
|
51
89
|
let changed = !parsed.batches || !parsed.receipts;
|
|
90
|
+
for (const history of Object.values(data.histories)) {
|
|
91
|
+
if (history?.version !== HISTORY_SCHEMA_VERSION || !Array.isArray(history.rounds) ||
|
|
92
|
+
!Number.isSafeInteger(history.nextOrdinal)) throw new Error("Invalid doc-review history.");
|
|
93
|
+
for (const round of history.rounds) {
|
|
94
|
+
if (!round?.roundId || !round.batchId || !Array.isArray(round.targets)) throw new Error("Invalid doc-review round.");
|
|
95
|
+
for (const target of round.targets) {
|
|
96
|
+
if (target.capture?.status === "running") {
|
|
97
|
+
target.capture = {
|
|
98
|
+
...target.capture, captureId: historyId("cap"), status: "pending",
|
|
99
|
+
ownerSessionId: null, generation: null, leaseExpiresAt: 0, error: "context_lost",
|
|
100
|
+
};
|
|
101
|
+
changed = true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
52
106
|
const normalizeAnchor = (comment) => {
|
|
53
107
|
if (!comment || comment.anchor == null) return;
|
|
54
108
|
const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
|
|
@@ -109,10 +163,11 @@ function normalizeState(parsed, makeBatchId) {
|
|
|
109
163
|
* means "your feedback is safe" stays true across server restarts.
|
|
110
164
|
*/
|
|
111
165
|
export class Store {
|
|
112
|
-
constructor({ write = atomicWrite, makeBatchId = batchId } = {}) {
|
|
166
|
+
constructor({ write = atomicWrite, makeBatchId = batchId, revisions = new RevisionStore() } = {}) {
|
|
113
167
|
this.data = emptyState();
|
|
114
168
|
this.write = write;
|
|
115
169
|
this.makeBatchId = makeBatchId;
|
|
170
|
+
this.revisions = revisions;
|
|
116
171
|
this.load();
|
|
117
172
|
}
|
|
118
173
|
|
|
@@ -392,27 +447,61 @@ export class Store {
|
|
|
392
447
|
return this.data.batches;
|
|
393
448
|
}
|
|
394
449
|
|
|
395
|
-
setBatch(entryKey, { batch, cleanup, deliveryState = "queued" }) {
|
|
450
|
+
setBatch(entryKey, { batch, cleanup, deliveryState = "queued", history } = {}, options = {}) {
|
|
451
|
+
const optionHistory = options.history || (options.targets ? options : undefined);
|
|
452
|
+
if (history && optionHistory) throw revisionError("Specify history context only once.");
|
|
453
|
+
history ||= optionHistory;
|
|
396
454
|
if (!DELIVERY_STATES.has(deliveryState)) throw new Error(`Unknown delivery state: ${deliveryState}`);
|
|
455
|
+
const targets = history ? normalizeHistoryTargets(history.targets) : null;
|
|
456
|
+
for (const target of targets || []) {
|
|
457
|
+
if (!this.page(target.key)) throw revisionError("Unknown history document.");
|
|
458
|
+
if (target.baselineRevisionId) {
|
|
459
|
+
const manifest = this.revisions.verify(target.baselineRevisionId, target.key);
|
|
460
|
+
target.baselineCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
|
|
461
|
+
}
|
|
462
|
+
}
|
|
397
463
|
return this.transaction((draft) => {
|
|
398
464
|
const existing = draft.batches[entryKey];
|
|
465
|
+
const superseded = existing ? batchRound(draft, entryKey, existing.batch_id) : null;
|
|
466
|
+
if (superseded) {
|
|
467
|
+
superseded.feedbackStatus = "superseded";
|
|
468
|
+
superseded.supersededAt = Date.now();
|
|
469
|
+
superseded.captureStatus = "cancelled";
|
|
470
|
+
}
|
|
399
471
|
if (existing && existing.delivery_state !== "queued") {
|
|
400
472
|
draft.receipts[existing.batch_id] = {
|
|
401
473
|
cleanup: existing.cleanup,
|
|
402
474
|
delivery_state: existing.delivery_state,
|
|
475
|
+
batch: structuredClone(existing.batch),
|
|
403
476
|
updatedAt: Date.now(),
|
|
404
477
|
};
|
|
405
478
|
}
|
|
406
479
|
const id = batch.batch_id || this.makeBatchId();
|
|
407
|
-
const storedBatch = { ...batch, batch_id: id };
|
|
480
|
+
const storedBatch = { ...structuredClone(batch), batch_id: id };
|
|
408
481
|
const record = {
|
|
409
482
|
batch_id: id,
|
|
410
483
|
batch: storedBatch,
|
|
411
|
-
cleanup,
|
|
484
|
+
cleanup: structuredClone(cleanup),
|
|
412
485
|
delivery_state: deliveryState,
|
|
413
486
|
updatedAt: Date.now(),
|
|
414
487
|
};
|
|
415
488
|
draft.batches[entryKey] = record;
|
|
489
|
+
if (targets) {
|
|
490
|
+
const historyState = draft.histories[entryKey] ||= {
|
|
491
|
+
version: HISTORY_SCHEMA_VERSION, entryKey, nextOrdinal: 1, rounds: [],
|
|
492
|
+
};
|
|
493
|
+
const round = {
|
|
494
|
+
roundId: historyId("round"), entryKey, ordinal: historyState.nextOrdinal++,
|
|
495
|
+
batchId: id, createdAt: Date.now(),
|
|
496
|
+
sentAt: new Date().toISOString(),
|
|
497
|
+
feedbackStatus: deliveryState === "delivered" ? "delivered" : "queued",
|
|
498
|
+
captureStatus: "pending",
|
|
499
|
+
targets: targets.map((target) => ({ ...target, resultRevisionId: null, capture: null })),
|
|
500
|
+
};
|
|
501
|
+
if (deliveryState === "delivered") round.deliveredFeedback = structuredClone(storedBatch);
|
|
502
|
+
historyState.rounds.push(round);
|
|
503
|
+
record.round_id = round.roundId;
|
|
504
|
+
}
|
|
416
505
|
return record;
|
|
417
506
|
});
|
|
418
507
|
}
|
|
@@ -423,6 +512,12 @@ export class Store {
|
|
|
423
512
|
const record = draft.batches[entryKey];
|
|
424
513
|
record.delivery_state = "delivered";
|
|
425
514
|
record.updatedAt = Date.now();
|
|
515
|
+
const round = batchRound(draft, entryKey, record.batch_id);
|
|
516
|
+
if (round) {
|
|
517
|
+
round.feedbackStatus = "delivered";
|
|
518
|
+
round.deliveredAt ||= Date.now();
|
|
519
|
+
round.deliveredFeedback ||= structuredClone(record.batch);
|
|
520
|
+
}
|
|
426
521
|
return record;
|
|
427
522
|
});
|
|
428
523
|
}
|
|
@@ -442,13 +537,26 @@ export class Store {
|
|
|
442
537
|
draft.receipts[id] = {
|
|
443
538
|
cleanup: record.cleanup,
|
|
444
539
|
delivery_state: "acknowledged",
|
|
540
|
+
batch: structuredClone(record.batch),
|
|
445
541
|
updatedAt: Date.now(),
|
|
446
542
|
};
|
|
543
|
+
const round = batchRound(draft, entryKey, id);
|
|
544
|
+
if (round) {
|
|
545
|
+
round.feedbackStatus = "acknowledged";
|
|
546
|
+
round.acknowledgedAt = Date.now();
|
|
547
|
+
round.deliveredFeedback ||= structuredClone(record.batch);
|
|
548
|
+
for (const target of round.targets) {
|
|
549
|
+
target.capture = {
|
|
550
|
+
captureId: historyId("cap"), status: "pending", ownerSessionId: null,
|
|
551
|
+
generation: null, attempt: 0, leaseExpiresAt: 0,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
447
555
|
const staged = [];
|
|
448
|
-
const keys = [];
|
|
556
|
+
const keys = round ? round.targets.map((target) => target.key) : [];
|
|
449
557
|
for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
|
|
450
558
|
staged.push(...assets);
|
|
451
|
-
keys.push(key);
|
|
559
|
+
if (!keys.includes(key)) keys.push(key);
|
|
452
560
|
const page = draft.pages[key];
|
|
453
561
|
if (!page) continue;
|
|
454
562
|
const drop = new Set(ids);
|
|
@@ -459,10 +567,127 @@ export class Store {
|
|
|
459
567
|
: [];
|
|
460
568
|
page.updatedAt = Date.now();
|
|
461
569
|
}
|
|
462
|
-
return { acknowledged: true, staged, keys };
|
|
570
|
+
return { acknowledged: true, staged, keys, ...(round ? { roundId: round.roundId } : {}) };
|
|
463
571
|
});
|
|
464
572
|
}
|
|
465
573
|
|
|
574
|
+
listHistory(entryKey) {
|
|
575
|
+
return (this.data.histories[entryKey]?.rounds || []).map(publicRound).sort((a, b) => b.ordinal - a.ordinal);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
getRound(entryKey, roundId) {
|
|
579
|
+
return publicRound(historyRound(this.data, entryKey, roundId));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
recordSourceResult(entryKey, roundId, key, { revisionId, unavailable } = {}) {
|
|
583
|
+
if (revisionId && unavailable) throw revisionError("Source result availability is ambiguous.");
|
|
584
|
+
if (revisionId) {
|
|
585
|
+
const manifest = this.revisions.verify(revisionId, key);
|
|
586
|
+
if (!manifest.source) throw revisionError("Source result must contain a source snapshot.");
|
|
587
|
+
} else if (typeof unavailable !== "string" || !unavailable || unavailable.length > 500) {
|
|
588
|
+
throw revisionError("Source result needs a revision or unavailable reason.");
|
|
589
|
+
}
|
|
590
|
+
const existing = historyRound(this.data, entryKey, roundId);
|
|
591
|
+
const existingTarget = existing?.targets.find((target) => target.key === key);
|
|
592
|
+
if (existing?.feedbackStatus !== "acknowledged" || !existingTarget) throw revisionError("Source capture is not pending.");
|
|
593
|
+
if (existingTarget.sourceResultRevisionId) {
|
|
594
|
+
if (existingTarget.sourceResultRevisionId === revisionId) return { accepted: false, duplicate: true, round: publicRound(existing) };
|
|
595
|
+
throw revisionError("The source result is already frozen.", "CAPTURE_FINALIZED");
|
|
596
|
+
}
|
|
597
|
+
if (existing.completedAt || existingTarget.resultRevisionId || existingTarget.capture?.status === "unavailable") {
|
|
598
|
+
throw revisionError("The target capture is already finalized.", "CAPTURE_FINALIZED");
|
|
599
|
+
}
|
|
600
|
+
const result = this.transaction((draft) => {
|
|
601
|
+
const round = historyRound(draft, entryKey, roundId);
|
|
602
|
+
const target = round.targets.find((item) => item.key === key);
|
|
603
|
+
if (revisionId) {
|
|
604
|
+
target.sourceResultRevisionId = revisionId;
|
|
605
|
+
delete target.sourceResultUnavailable;
|
|
606
|
+
} else target.sourceResultUnavailable = unavailable;
|
|
607
|
+
return round;
|
|
608
|
+
});
|
|
609
|
+
return { accepted: true, round: publicRound(result) };
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
claimCapture(entryKey, roundId, key, { ownerSessionId, generation, leaseMs = CAPTURE_LEASE_MS } = {}) {
|
|
613
|
+
if (typeof ownerSessionId !== "string" || !ownerSessionId || ownerSessionId.length > 200 ||
|
|
614
|
+
!Number.isSafeInteger(generation) || generation < 0 ||
|
|
615
|
+
!Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > 5 * CAPTURE_LEASE_MS) {
|
|
616
|
+
throw revisionError("Invalid capture ownership.");
|
|
617
|
+
}
|
|
618
|
+
const result = this.transaction((draft) => {
|
|
619
|
+
const round = historyRound(draft, entryKey, roundId);
|
|
620
|
+
const target = round?.targets.find((item) => item.key === key);
|
|
621
|
+
if (round?.feedbackStatus !== "acknowledged" || !target?.capture) throw revisionError("Capture is not pending.");
|
|
622
|
+
if (target.capture.status === "ready" || target.capture.status === "unavailable") throw revisionError("Capture is already finalized.", "CAPTURE_FINALIZED");
|
|
623
|
+
if (target.capture.status === "running" && target.capture.leaseExpiresAt > Date.now()) {
|
|
624
|
+
if (target.capture.ownerSessionId === ownerSessionId && target.capture.generation === generation) return target.capture;
|
|
625
|
+
throw revisionError("Another frame owns this capture.", "CAPTURE_CONFLICT");
|
|
626
|
+
}
|
|
627
|
+
target.capture = {
|
|
628
|
+
captureId: historyId("cap"), status: "running", ownerSessionId, generation,
|
|
629
|
+
attempt: target.capture.attempt + 1, leaseExpiresAt: Date.now() + leaseMs,
|
|
630
|
+
};
|
|
631
|
+
round.captureStatus = "pending";
|
|
632
|
+
return target.capture;
|
|
633
|
+
});
|
|
634
|
+
return structuredClone(result);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
recordCaptureResult(entryKey, roundId, key, { captureId, ownerSessionId, generation, revisionId } = {}) {
|
|
638
|
+
const manifest = this.revisions.verify(revisionId, key);
|
|
639
|
+
const result = this.transaction((draft) => {
|
|
640
|
+
const round = historyRound(draft, entryKey, roundId);
|
|
641
|
+
const target = round?.targets.find((item) => item.key === key);
|
|
642
|
+
const capture = target?.capture;
|
|
643
|
+
if (capture?.status === "ready" && capture.captureId === captureId &&
|
|
644
|
+
capture.ownerSessionId === ownerSessionId && capture.generation === generation &&
|
|
645
|
+
target.resultRevisionId === revisionId) return { accepted: false, duplicate: true, round };
|
|
646
|
+
this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
|
|
647
|
+
target.resultRevisionId = revisionId;
|
|
648
|
+
target.resultCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
|
|
649
|
+
capture.status = "ready";
|
|
650
|
+
capture.capturedAt = Date.now();
|
|
651
|
+
capture.leaseExpiresAt = 0;
|
|
652
|
+
finishCapture(round);
|
|
653
|
+
return { accepted: true, round };
|
|
654
|
+
});
|
|
655
|
+
return structuredClone(result);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
markCaptureUnavailable(entryKey, roundId, key, { captureId, ownerSessionId, generation, reason, final = false } = {}) {
|
|
659
|
+
if (typeof reason !== "string" || !reason || reason.length > 500) throw revisionError("Invalid capture failure reason.");
|
|
660
|
+
const result = this.transaction((draft) => {
|
|
661
|
+
const round = historyRound(draft, entryKey, roundId);
|
|
662
|
+
const target = round?.targets.find((item) => item.key === key);
|
|
663
|
+
const capture = target?.capture;
|
|
664
|
+
const unowned = round?.feedbackStatus === "acknowledged" &&
|
|
665
|
+
(capture?.status === "pending" || capture?.status === "failed") &&
|
|
666
|
+
!capture.ownerSessionId && capture.captureId === captureId &&
|
|
667
|
+
!ownerSessionId && generation == null;
|
|
668
|
+
if (!unowned) this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
|
|
669
|
+
capture.status = final ? "unavailable" : "failed";
|
|
670
|
+
capture.error = reason;
|
|
671
|
+
capture.leaseExpiresAt = 0;
|
|
672
|
+
if (final) target.resultUnavailable = reason;
|
|
673
|
+
finishCapture(round);
|
|
674
|
+
return { accepted: true, round };
|
|
675
|
+
});
|
|
676
|
+
return structuredClone(result);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
assertCapture(round, capture, { captureId, ownerSessionId, generation }) {
|
|
680
|
+
if (round?.feedbackStatus !== "acknowledged" || capture?.status !== "running" ||
|
|
681
|
+
capture.captureId !== captureId || capture.ownerSessionId !== ownerSessionId ||
|
|
682
|
+
capture.generation !== generation || capture.leaseExpiresAt <= Date.now()) {
|
|
683
|
+
throw revisionError("Capture response is stale or belongs to another frame.", "CAPTURE_CONFLICT");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
collectHistoryGarbage(options) {
|
|
688
|
+
return this.revisions.collectGarbage(historyRevisionReferences(this.data), options);
|
|
689
|
+
}
|
|
690
|
+
|
|
466
691
|
clearBatch(entryKey) {
|
|
467
692
|
if (!this.batch(entryKey)) return null;
|
|
468
693
|
return this.transaction((draft) => {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
const MAX_TABS = 32;
|
|
2
|
+
const MAX_ID = 256;
|
|
3
|
+
const unknown = (reason) => ({ version: 1, status: "unverified", tabs: [], reason });
|
|
4
|
+
|
|
5
|
+
export function normalizeView(value) {
|
|
6
|
+
if (value === undefined || value === null) return unknown("View identity was not recorded.");
|
|
7
|
+
const invalid = () => { throw Object.assign(new Error("Invalid captured view identity."), { code: "INVALID_REVISION", status: 400 }); };
|
|
8
|
+
if (value.version !== 1 || !["identified", "unverified"].includes(value.status) ||
|
|
9
|
+
!Array.isArray(value.tabs) || value.tabs.length > MAX_TABS) invalid();
|
|
10
|
+
if (value.status === "unverified") {
|
|
11
|
+
if (value.tabs.length || (value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500))) invalid();
|
|
12
|
+
return unknown(value.reason || "View identity could not be verified.");
|
|
13
|
+
}
|
|
14
|
+
if (!value.tabs.length) invalid();
|
|
15
|
+
const groups = new Set();
|
|
16
|
+
const tabs = value.tabs.map((tab) => {
|
|
17
|
+
if (!tab || typeof tab !== "object") invalid();
|
|
18
|
+
for (const key of ["groupId", "tabId", "panelId", "label"]) {
|
|
19
|
+
if (typeof tab[key] !== "string" || !tab[key] || tab[key].length > MAX_ID) invalid();
|
|
20
|
+
}
|
|
21
|
+
if (groups.has(tab.groupId)) invalid();
|
|
22
|
+
groups.add(tab.groupId);
|
|
23
|
+
return { groupId: tab.groupId, tabId: tab.tabId, panelId: tab.panelId, label: tab.label };
|
|
24
|
+
}).sort((a, b) => a.groupId.localeCompare(b.groupId));
|
|
25
|
+
return { version: 1, status: "identified", tabs };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function viewKey(view) {
|
|
29
|
+
const normalized = normalizeView(view);
|
|
30
|
+
return JSON.stringify([normalized.status, normalized.tabs.map(({ groupId, tabId, panelId }) => [groupId, tabId, panelId])]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sameObservedView(before, after) {
|
|
34
|
+
return viewKey(before) === viewKey(after);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function compareCapturedViews(before, after) {
|
|
38
|
+
const left = normalizeView(before);
|
|
39
|
+
const right = normalizeView(after);
|
|
40
|
+
if (left.status === "identified") {
|
|
41
|
+
if (right.status === "identified" && sameObservedView(left, right)) {
|
|
42
|
+
return { status: "matched", message: `Matching visible view: ${left.tabs.map((tab) => tab.label).join(" / ")}.` };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
status: "mismatch",
|
|
46
|
+
message: `Return to ${left.tabs.map((tab) => tab.label).join(" / ")} to capture the result.${right.status === "unverified" ? " The current tab identity could not be verified." : ""}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return { status: "unverified", message: "Visible content only; matching view not verified." };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function observeView(document) {
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
let visited = 0;
|
|
55
|
+
let limited = false;
|
|
56
|
+
const visible = (element) => {
|
|
57
|
+
for (let node = element; node; node = node.parentElement) {
|
|
58
|
+
if (++visited > 5000 || Date.now() - started > 500) {
|
|
59
|
+
limited = true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (node.hidden || node.getAttribute("aria-hidden") === "true" || node.hasAttribute("data-eh-ui")) return false;
|
|
63
|
+
const style = document.defaultView.getComputedStyle(node);
|
|
64
|
+
if (style.display === "none" || style.visibility === "hidden") return false;
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
};
|
|
68
|
+
const uniqueId = (element) => {
|
|
69
|
+
if (!element?.id || element.id.length > MAX_ID) return false;
|
|
70
|
+
const escaped = element.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\n\r\f]/g, "");
|
|
71
|
+
return document.querySelectorAll(`[id="${escaped}"]`).length === 1;
|
|
72
|
+
};
|
|
73
|
+
const candidates = document.querySelectorAll('[role="tablist"]');
|
|
74
|
+
if (candidates.length > MAX_TABS) return unknown("Too many tab groups to identify reliably.");
|
|
75
|
+
const groups = [...candidates].filter(visible);
|
|
76
|
+
if (limited) return unknown("View identity observation exceeded its limits.");
|
|
77
|
+
if (!groups.length) return unknown("No reliably identifiable tab groups were found.");
|
|
78
|
+
if (groups.length > MAX_TABS) return unknown("Too many tab groups to identify reliably.");
|
|
79
|
+
const tabs = [];
|
|
80
|
+
for (const group of groups) {
|
|
81
|
+
const members = [...group.querySelectorAll('[role="tab"]')].filter((tab) => tab.closest('[role="tablist"]') === group);
|
|
82
|
+
if (members.length > 128 || Date.now() - started > 500) return unknown("View identity observation exceeded its limits.");
|
|
83
|
+
const selected = members.filter((tab) => tab.getAttribute("aria-selected") === "true");
|
|
84
|
+
if (selected.length !== 1) return unknown("A tab group does not have exactly one selected tab.");
|
|
85
|
+
const tab = selected[0];
|
|
86
|
+
const panelId = tab.getAttribute("aria-controls");
|
|
87
|
+
const panel = panelId && document.getElementById(panelId);
|
|
88
|
+
if (!visible(tab) || !uniqueId(tab) || !uniqueId(panel) || panel.getAttribute("role") !== "tabpanel" || !visible(panel)) {
|
|
89
|
+
return unknown("Selected tabs and visible panels do not have unique stable identifiers.");
|
|
90
|
+
}
|
|
91
|
+
// A complete set of authored tab IDs identifies an otherwise unnamed tablist.
|
|
92
|
+
if (!members.length || members.some((member) => !uniqueId(member))) return unknown("Tab identifiers are incomplete.");
|
|
93
|
+
const groupId = uniqueId(group) ? `id:${group.id}` : `tabs:${JSON.stringify(members.map((member) => member.id).sort())}`;
|
|
94
|
+
if (groupId.length > MAX_ID) return unknown("Tab group identity exceeds the capture limit.");
|
|
95
|
+
const label = (tab.getAttribute("aria-label") || tab.textContent || tab.id).replace(/\s+/g, " ").trim().slice(0, MAX_ID);
|
|
96
|
+
tabs.push({ groupId, tabId: tab.id, panelId, label: label || tab.id });
|
|
97
|
+
}
|
|
98
|
+
return normalizeView({ version: 1, status: "identified", tabs });
|
|
99
|
+
}
|