@cfbender/cesium 0.6.2 → 0.7.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/CHANGELOG.md +82 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/prompt/system-fragment.md +68 -8
- package/src/render/annotate-frozen.ts +90 -0
- package/src/render/blocks/render.ts +20 -0
- package/src/render/blocks/renderers/callout.ts +3 -2
- package/src/render/blocks/renderers/code.ts +17 -2
- package/src/render/blocks/renderers/compare-table.ts +3 -2
- package/src/render/blocks/renderers/diagram.ts +3 -2
- package/src/render/blocks/renderers/diff.ts +23 -9
- package/src/render/blocks/renderers/hero.ts +3 -2
- package/src/render/blocks/renderers/kv.ts +3 -2
- package/src/render/blocks/renderers/list.ts +5 -4
- package/src/render/blocks/renderers/pill-row.ts +3 -2
- package/src/render/blocks/renderers/prose.ts +8 -2
- package/src/render/blocks/renderers/raw-html.ts +8 -2
- package/src/render/blocks/renderers/risk-table.ts +3 -2
- package/src/render/blocks/renderers/section.ts +4 -2
- package/src/render/blocks/renderers/timeline.ts +3 -2
- package/src/render/blocks/renderers/tldr.ts +3 -2
- package/src/render/client-js.ts +804 -6
- package/src/render/critique.ts +5 -335
- package/src/render/theme.ts +431 -6
- package/src/render/validate.ts +353 -97
- package/src/render/wrap.ts +67 -9
- package/src/server/api.ts +162 -3
- package/src/storage/index-gen.ts +4 -2
- package/src/storage/mutate.ts +433 -27
- package/src/tools/annotate.ts +336 -0
- package/src/tools/ask.ts +2 -6
- package/src/tools/critique.ts +15 -45
- package/src/tools/publish.ts +16 -56
- package/src/tools/styleguide.ts +7 -1
- package/src/tools/wait.ts +77 -24
package/src/storage/mutate.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// getState: reads the artifact and returns its current status/answers without
|
|
7
7
|
// acquiring a lock (read-only, no mutation).
|
|
8
|
+
//
|
|
9
|
+
// addComment / removeComment / setVerdict: annotate-specific mutators.
|
|
8
10
|
|
|
9
11
|
import { readFile } from "node:fs/promises";
|
|
10
12
|
import { parseFragment, serialize, defaultTreeAdapter as ta } from "parse5";
|
|
@@ -12,8 +14,23 @@ import type { DefaultTreeAdapterTypes } from "parse5";
|
|
|
12
14
|
import { atomicWrite } from "./write.ts";
|
|
13
15
|
import { withLock } from "./lock.ts";
|
|
14
16
|
import { renderAnswered } from "../render/controls.ts";
|
|
15
|
-
import {
|
|
16
|
-
import
|
|
17
|
+
import { renderFrozenRail, renderVerdictPill } from "../render/annotate-frozen.ts";
|
|
18
|
+
import {
|
|
19
|
+
validateAnswerValue,
|
|
20
|
+
validateCommentValue,
|
|
21
|
+
validateVerdictValue,
|
|
22
|
+
coerceInteractiveData,
|
|
23
|
+
} from "../render/validate.ts";
|
|
24
|
+
import type {
|
|
25
|
+
Question,
|
|
26
|
+
AnswerValue,
|
|
27
|
+
Comment,
|
|
28
|
+
Verdict,
|
|
29
|
+
VerdictMode,
|
|
30
|
+
InteractiveData,
|
|
31
|
+
InteractiveAskData,
|
|
32
|
+
InteractiveAnnotateData,
|
|
33
|
+
} from "../render/validate.ts";
|
|
17
34
|
|
|
18
35
|
type ChildNode = DefaultTreeAdapterTypes.ChildNode;
|
|
19
36
|
type Element = DefaultTreeAdapterTypes.Element;
|
|
@@ -35,13 +52,63 @@ export type SubmitAnswerOutcome =
|
|
|
35
52
|
| { ok: false; reason: "unknown-question"; questionId: string }
|
|
36
53
|
| { ok: false; reason: "invalid-value"; message: string };
|
|
37
54
|
|
|
55
|
+
export type AddCommentInput = {
|
|
56
|
+
artifactPath: string;
|
|
57
|
+
anchor: string;
|
|
58
|
+
selectedText: string;
|
|
59
|
+
comment: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type AddCommentOutcome =
|
|
63
|
+
| { ok: true; comment: Comment }
|
|
64
|
+
| { ok: false; reason: "not-found" }
|
|
65
|
+
| { ok: false; reason: "not-interactive" }
|
|
66
|
+
| { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
|
|
67
|
+
| { ok: false; reason: "expired" }
|
|
68
|
+
| { ok: false; reason: "invalid-value"; message: string };
|
|
69
|
+
|
|
70
|
+
export type RemoveCommentInput = {
|
|
71
|
+
artifactPath: string;
|
|
72
|
+
commentId: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type RemoveCommentOutcome =
|
|
76
|
+
| { ok: true }
|
|
77
|
+
| { ok: false; reason: "not-found" }
|
|
78
|
+
| { ok: false; reason: "not-interactive" }
|
|
79
|
+
| { ok: false; reason: "comment-not-found" }
|
|
80
|
+
| { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
|
|
81
|
+
| { ok: false; reason: "expired" };
|
|
82
|
+
|
|
83
|
+
export type SetVerdictInput = {
|
|
84
|
+
artifactPath: string;
|
|
85
|
+
verdict: Verdict;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type SetVerdictOutcome =
|
|
89
|
+
| { ok: true; status: "complete"; verdict: { value: Verdict; decidedAt: string } }
|
|
90
|
+
| { ok: false; reason: "not-found" }
|
|
91
|
+
| { ok: false; reason: "not-interactive" }
|
|
92
|
+
| { ok: false; reason: "session-ended"; status: "complete" | "expired" | "cancelled" }
|
|
93
|
+
| { ok: false; reason: "expired" }
|
|
94
|
+
| { ok: false; reason: "invalid-value"; message: string };
|
|
95
|
+
|
|
38
96
|
export type StateOutcome =
|
|
39
97
|
| {
|
|
40
98
|
ok: true;
|
|
41
|
-
|
|
99
|
+
kind: "ask";
|
|
100
|
+
status: InteractiveAskData["status"];
|
|
42
101
|
answers: Record<string, AnswerValue>;
|
|
43
102
|
remaining: string[];
|
|
44
103
|
}
|
|
104
|
+
| {
|
|
105
|
+
ok: true;
|
|
106
|
+
kind: "annotate";
|
|
107
|
+
status: InteractiveAnnotateData["status"];
|
|
108
|
+
comments: Comment[];
|
|
109
|
+
verdict: InteractiveAnnotateData["verdict"];
|
|
110
|
+
verdictMode: VerdictMode;
|
|
111
|
+
}
|
|
45
112
|
| { ok: false; reason: "not-found" | "not-interactive" };
|
|
46
113
|
|
|
47
114
|
// ─── Embedded metadata regex (mirrors storage/write.ts) ──────────────────────
|
|
@@ -63,18 +130,6 @@ function parseEmbeddedMeta(html: string): Record<string, unknown> | null {
|
|
|
63
130
|
}
|
|
64
131
|
}
|
|
65
132
|
|
|
66
|
-
function isInteractiveData(v: unknown): v is InteractiveData {
|
|
67
|
-
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
68
|
-
const raw = v as Record<string, unknown>;
|
|
69
|
-
return (
|
|
70
|
-
(raw["status"] === "open" ||
|
|
71
|
-
raw["status"] === "complete" ||
|
|
72
|
-
raw["status"] === "expired" ||
|
|
73
|
-
raw["status"] === "cancelled") &&
|
|
74
|
-
Array.isArray(raw["questions"])
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
133
|
// ─── Cross-validation beyond structural check ─────────────────────────────────
|
|
79
134
|
|
|
80
135
|
const EPSILON = 1e-9;
|
|
@@ -272,11 +327,17 @@ export async function submitAnswer(input: SubmitAnswerInput): Promise<SubmitAnsw
|
|
|
272
327
|
|
|
273
328
|
// 2. Parse cesium-meta
|
|
274
329
|
const meta = parseEmbeddedMeta(html);
|
|
275
|
-
|
|
330
|
+
const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
|
|
331
|
+
if (coerced === null) {
|
|
276
332
|
return { ok: false, reason: "not-interactive" };
|
|
277
333
|
}
|
|
278
334
|
|
|
279
|
-
|
|
335
|
+
// submitAnswer only handles ask artifacts; annotate uses addComment/setVerdict
|
|
336
|
+
if (coerced.kind !== "ask") {
|
|
337
|
+
return { ok: false, reason: "not-interactive" };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const interactive: InteractiveAskData = coerced;
|
|
280
341
|
|
|
281
342
|
// 3. Check session status
|
|
282
343
|
if (interactive.status === "complete" || interactive.status === "cancelled") {
|
|
@@ -376,21 +437,366 @@ export async function getState(artifactPath: string): Promise<StateOutcome> {
|
|
|
376
437
|
}
|
|
377
438
|
|
|
378
439
|
const meta = parseEmbeddedMeta(html);
|
|
379
|
-
|
|
440
|
+
const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
|
|
441
|
+
if (coerced === null) {
|
|
380
442
|
return { ok: false, reason: "not-interactive" };
|
|
381
443
|
}
|
|
382
444
|
|
|
383
|
-
|
|
445
|
+
if (coerced.kind === "ask") {
|
|
446
|
+
const interactive: InteractiveAskData = coerced;
|
|
447
|
+
|
|
448
|
+
// Extract answer values (drop the answeredAt wrapper)
|
|
449
|
+
const answers: Record<string, AnswerValue> = {};
|
|
450
|
+
for (const [id, entry] of Object.entries(interactive.answers)) {
|
|
451
|
+
answers[id] = entry.value;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const remaining = interactive.questions
|
|
455
|
+
.map((q) => q.id)
|
|
456
|
+
.filter((id) => interactive.answers[id] === undefined);
|
|
457
|
+
|
|
458
|
+
return { ok: true, kind: "ask", status: interactive.status, answers, remaining };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// kind === "annotate"
|
|
462
|
+
const interactive: InteractiveAnnotateData = coerced;
|
|
463
|
+
return {
|
|
464
|
+
ok: true,
|
|
465
|
+
kind: "annotate",
|
|
466
|
+
status: interactive.status,
|
|
467
|
+
comments: interactive.comments,
|
|
468
|
+
verdict: interactive.verdict,
|
|
469
|
+
verdictMode: interactive.verdictMode,
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ─── nanoid ──────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
function defaultNanoid(): string {
|
|
476
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
477
|
+
const bytes = new Uint8Array(6);
|
|
478
|
+
crypto.getRandomValues(bytes);
|
|
479
|
+
let result = "";
|
|
480
|
+
for (const byte of bytes) {
|
|
481
|
+
result += alphabet[byte % alphabet.length];
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ─── Shared annotate-session guard ───────────────────────────────────────────
|
|
487
|
+
//
|
|
488
|
+
// Reads, parses, and validates an annotate artifact under an already-held lock.
|
|
489
|
+
// Returns either the coerced data + raw html, or an early-return outcome.
|
|
490
|
+
|
|
491
|
+
type AnnotateReadResult =
|
|
492
|
+
| { ok: true; html: string; interactive: InteractiveAnnotateData }
|
|
493
|
+
| { ok: false; outcome: AddCommentOutcome | RemoveCommentOutcome | SetVerdictOutcome };
|
|
494
|
+
|
|
495
|
+
async function readAnnotateArtifact(artifactPath: string): Promise<AnnotateReadResult> {
|
|
496
|
+
let html: string;
|
|
497
|
+
try {
|
|
498
|
+
html = await readFile(artifactPath, "utf8");
|
|
499
|
+
} catch (err) {
|
|
500
|
+
const e = err as NodeJS.ErrnoException;
|
|
501
|
+
if (e.code === "ENOENT") return { ok: false, outcome: { ok: false, reason: "not-found" } };
|
|
502
|
+
throw err;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const meta = parseEmbeddedMeta(html);
|
|
506
|
+
const coerced = coerceInteractiveData(meta === null ? null : meta["interactive"]);
|
|
507
|
+
if (coerced === null || coerced.kind !== "annotate") {
|
|
508
|
+
return { ok: false, outcome: { ok: false, reason: "not-interactive" } };
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const interactive = coerced;
|
|
384
512
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
513
|
+
// Session-ended check
|
|
514
|
+
if (interactive.status === "complete" || interactive.status === "cancelled") {
|
|
515
|
+
return {
|
|
516
|
+
ok: false,
|
|
517
|
+
outcome: { ok: false, reason: "session-ended", status: interactive.status },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (interactive.status === "expired") {
|
|
521
|
+
return {
|
|
522
|
+
ok: false,
|
|
523
|
+
outcome: { ok: false, reason: "session-ended", status: "expired" },
|
|
524
|
+
};
|
|
389
525
|
}
|
|
390
526
|
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
527
|
+
return { ok: true, html, interactive };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ─── addComment ──────────────────────────────────────────────────────────────
|
|
531
|
+
|
|
532
|
+
export async function addComment(input: AddCommentInput): Promise<AddCommentOutcome> {
|
|
533
|
+
const { artifactPath, anchor, selectedText, comment } = input;
|
|
534
|
+
const lockPath = `${artifactPath}.lock`;
|
|
535
|
+
|
|
536
|
+
return withLock({ lockPath }, async () => {
|
|
537
|
+
const read = await readAnnotateArtifact(artifactPath);
|
|
538
|
+
if (!read.ok) return read.outcome as AddCommentOutcome;
|
|
539
|
+
|
|
540
|
+
let { html } = read;
|
|
541
|
+
const { interactive } = read;
|
|
542
|
+
|
|
543
|
+
// Check expiresAt
|
|
544
|
+
if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
|
|
545
|
+
interactive.status = "expired";
|
|
546
|
+
html = patchMetaInHtml(html, interactive);
|
|
547
|
+
await atomicWrite(artifactPath, html);
|
|
548
|
+
return { ok: false, reason: "expired" };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Validate comment value
|
|
552
|
+
const validated = validateCommentValue({ anchor, selectedText, comment });
|
|
553
|
+
if (!validated.ok) {
|
|
554
|
+
return { ok: false, reason: "invalid-value", message: validated.error };
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Construct and append the comment
|
|
558
|
+
const newComment: Comment = {
|
|
559
|
+
id: defaultNanoid(),
|
|
560
|
+
anchor: validated.value.anchor,
|
|
561
|
+
selectedText: validated.value.selectedText,
|
|
562
|
+
comment: validated.value.comment,
|
|
563
|
+
createdAt: new Date().toISOString(),
|
|
564
|
+
};
|
|
565
|
+
|
|
566
|
+
interactive.comments.push(newComment);
|
|
567
|
+
|
|
568
|
+
// Patch meta JSON only — body mutation happens in Phase 5/6
|
|
569
|
+
html = patchMetaInHtml(html, interactive);
|
|
570
|
+
await atomicWrite(artifactPath, html);
|
|
571
|
+
|
|
572
|
+
return { ok: true, comment: newComment };
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// ─── removeComment ────────────────────────────────────────────────────────────
|
|
577
|
+
|
|
578
|
+
export async function removeComment(input: RemoveCommentInput): Promise<RemoveCommentOutcome> {
|
|
579
|
+
const { artifactPath, commentId } = input;
|
|
580
|
+
const lockPath = `${artifactPath}.lock`;
|
|
581
|
+
|
|
582
|
+
return withLock({ lockPath }, async () => {
|
|
583
|
+
const read = await readAnnotateArtifact(artifactPath);
|
|
584
|
+
if (!read.ok) return read.outcome as RemoveCommentOutcome;
|
|
585
|
+
|
|
586
|
+
let { html } = read;
|
|
587
|
+
const { interactive } = read;
|
|
588
|
+
|
|
589
|
+
// Check expiresAt
|
|
590
|
+
if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
|
|
591
|
+
interactive.status = "expired";
|
|
592
|
+
html = patchMetaInHtml(html, interactive);
|
|
593
|
+
await atomicWrite(artifactPath, html);
|
|
594
|
+
return { ok: false, reason: "expired" };
|
|
595
|
+
}
|
|
394
596
|
|
|
395
|
-
|
|
597
|
+
// Find the comment
|
|
598
|
+
const idx = interactive.comments.findIndex((c) => c.id === commentId);
|
|
599
|
+
if (idx === -1) {
|
|
600
|
+
return { ok: false, reason: "comment-not-found" };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Remove it
|
|
604
|
+
interactive.comments = interactive.comments.filter((c) => c.id !== commentId);
|
|
605
|
+
|
|
606
|
+
html = patchMetaInHtml(html, interactive);
|
|
607
|
+
await atomicWrite(artifactPath, html);
|
|
608
|
+
|
|
609
|
+
return { ok: true };
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// ─── setVerdict ───────────────────────────────────────────────────────────────
|
|
614
|
+
|
|
615
|
+
function setAnnotateScaffoldStatus(html: string, status: string): string {
|
|
616
|
+
// Use parse5 to find <section class="cs-annotate-scaffold"> and set
|
|
617
|
+
// data-cesium-status="<status>". Defensive: if absent, return html unchanged.
|
|
618
|
+
const doc = parseFragment(html);
|
|
619
|
+
const nodes = ta.getChildNodes(doc) as ChildNode[];
|
|
620
|
+
|
|
621
|
+
function findScaffold(children: ChildNode[]): Element | null {
|
|
622
|
+
for (const node of children) {
|
|
623
|
+
if (ta.isElementNode(node)) {
|
|
624
|
+
const el = node as Element;
|
|
625
|
+
const tag = ta.getTagName(el);
|
|
626
|
+
if (tag === "section") {
|
|
627
|
+
const attrs = ta.getAttrList(el);
|
|
628
|
+
const hasScaffold = attrs.some((a) => a.name === "data-cesium-annotate-scaffold");
|
|
629
|
+
if (hasScaffold) return el;
|
|
630
|
+
}
|
|
631
|
+
const found = findScaffold(ta.getChildNodes(el) as ChildNode[]);
|
|
632
|
+
if (found !== null) return found;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const scaffold = findScaffold(nodes);
|
|
639
|
+
if (scaffold === null) return html; // defensive: absent → no-op
|
|
640
|
+
|
|
641
|
+
const attrs = ta.getAttrList(scaffold);
|
|
642
|
+
const existing = attrs.find((a) => a.name === "data-cesium-status");
|
|
643
|
+
if (existing !== undefined) {
|
|
644
|
+
existing.value = status;
|
|
645
|
+
} else {
|
|
646
|
+
attrs.push({ name: "data-cesium-status", value: status });
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return serialize(doc);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ─── Rail replacement ─────────────────────────────────────────────────────────
|
|
653
|
+
//
|
|
654
|
+
// Replaces <aside class="cs-comment-rail" data-cesium-comment-rail></aside>
|
|
655
|
+
// with the fully-populated frozen rail via parse5.
|
|
656
|
+
|
|
657
|
+
function replaceEmptyRailInHtml(html: string, comments: Comment[]): string {
|
|
658
|
+
const doc = parseFragment(html);
|
|
659
|
+
const nodes = ta.getChildNodes(doc) as ChildNode[];
|
|
660
|
+
|
|
661
|
+
function findEmptyRail(children: ChildNode[]): Element | null {
|
|
662
|
+
for (const node of children) {
|
|
663
|
+
if (!ta.isElementNode(node)) continue;
|
|
664
|
+
const el = node as Element;
|
|
665
|
+
const tag = ta.getTagName(el);
|
|
666
|
+
if (tag === "aside") {
|
|
667
|
+
const attrs = ta.getAttrList(el);
|
|
668
|
+
const hasRailAttr = attrs.some((a) => a.name === "data-cesium-comment-rail");
|
|
669
|
+
if (hasRailAttr) return el;
|
|
670
|
+
}
|
|
671
|
+
const found = findEmptyRail(ta.getChildNodes(el) as ChildNode[]);
|
|
672
|
+
if (found !== null) return found;
|
|
673
|
+
}
|
|
674
|
+
return null;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const target = findEmptyRail(nodes);
|
|
678
|
+
if (target === null) return html;
|
|
679
|
+
|
|
680
|
+
const parent = ta.getParentNode(target) as Element | null;
|
|
681
|
+
if (parent === null) return html;
|
|
682
|
+
|
|
683
|
+
const replacement = parseFragment(renderFrozenRail(comments));
|
|
684
|
+
const replacementNodes = ta.getChildNodes(replacement) as ChildNode[];
|
|
685
|
+
|
|
686
|
+
for (const rn of replacementNodes) {
|
|
687
|
+
ta.insertBefore(parent, rn, target);
|
|
688
|
+
}
|
|
689
|
+
ta.detachNode(target);
|
|
690
|
+
|
|
691
|
+
return serialize(doc);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// ─── Verdict pill insertion ───────────────────────────────────────────────────
|
|
695
|
+
//
|
|
696
|
+
// Inserts the verdict pill immediately after <nav class="cesium-back"> via parse5.
|
|
697
|
+
|
|
698
|
+
function insertVerdictPillAfterBackNav(
|
|
699
|
+
html: string,
|
|
700
|
+
verdict: { value: Verdict; decidedAt: string },
|
|
701
|
+
): string {
|
|
702
|
+
const doc = parseFragment(html);
|
|
703
|
+
const nodes = ta.getChildNodes(doc) as ChildNode[];
|
|
704
|
+
|
|
705
|
+
function findBackNav(children: ChildNode[]): Element | null {
|
|
706
|
+
for (const node of children) {
|
|
707
|
+
if (!ta.isElementNode(node)) continue;
|
|
708
|
+
const el = node as Element;
|
|
709
|
+
const tag = ta.getTagName(el);
|
|
710
|
+
if (tag === "nav") {
|
|
711
|
+
const attrs = ta.getAttrList(el);
|
|
712
|
+
const cls = attrs.find((a) => a.name === "class");
|
|
713
|
+
if (cls?.value?.split(" ").includes("cesium-back")) return el;
|
|
714
|
+
}
|
|
715
|
+
const found = findBackNav(ta.getChildNodes(el) as ChildNode[]);
|
|
716
|
+
if (found !== null) return found;
|
|
717
|
+
}
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const backNav = findBackNav(nodes);
|
|
722
|
+
if (backNav === null) return html; // defensive: absent → no-op
|
|
723
|
+
|
|
724
|
+
const parent = ta.getParentNode(backNav) as Element | null;
|
|
725
|
+
if (parent === null) return html;
|
|
726
|
+
|
|
727
|
+
const pillFragment = parseFragment(renderVerdictPill(verdict));
|
|
728
|
+
const pillNodes = ta.getChildNodes(pillFragment) as ChildNode[];
|
|
729
|
+
|
|
730
|
+
// Find the next sibling after backNav to insert before it
|
|
731
|
+
const siblings = ta.getChildNodes(parent) as ChildNode[];
|
|
732
|
+
const backNavIdx = siblings.indexOf(backNav);
|
|
733
|
+
const nextSibling = backNavIdx !== -1 ? siblings[backNavIdx + 1] : undefined;
|
|
734
|
+
|
|
735
|
+
for (const pn of pillNodes) {
|
|
736
|
+
if (nextSibling !== undefined) {
|
|
737
|
+
ta.insertBefore(parent, pn, nextSibling);
|
|
738
|
+
} else {
|
|
739
|
+
ta.appendChild(parent, pn);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return serialize(doc);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export async function setVerdict(input: SetVerdictInput): Promise<SetVerdictOutcome> {
|
|
747
|
+
const { artifactPath, verdict } = input;
|
|
748
|
+
const lockPath = `${artifactPath}.lock`;
|
|
749
|
+
|
|
750
|
+
return withLock({ lockPath }, async () => {
|
|
751
|
+
const read = await readAnnotateArtifact(artifactPath);
|
|
752
|
+
if (!read.ok) return read.outcome as SetVerdictOutcome;
|
|
753
|
+
|
|
754
|
+
let { html } = read;
|
|
755
|
+
const { interactive } = read;
|
|
756
|
+
|
|
757
|
+
// Check expiresAt
|
|
758
|
+
if (interactive.status === "open" && Date.parse(interactive.expiresAt) < Date.now()) {
|
|
759
|
+
interactive.status = "expired";
|
|
760
|
+
html = patchMetaInHtml(html, interactive);
|
|
761
|
+
await atomicWrite(artifactPath, html);
|
|
762
|
+
return { ok: false, reason: "expired" };
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Validate verdict against the artifact's verdictMode
|
|
766
|
+
const validated = validateVerdictValue({ verdict }, interactive.verdictMode);
|
|
767
|
+
if (!validated.ok) {
|
|
768
|
+
return { ok: false, reason: "invalid-value", message: validated.error };
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const decidedAt = new Date().toISOString();
|
|
772
|
+
const completedAt = new Date().toISOString();
|
|
773
|
+
|
|
774
|
+
// Update interactive data
|
|
775
|
+
interactive.verdict = { value: validated.value, decidedAt };
|
|
776
|
+
interactive.status = "complete";
|
|
777
|
+
interactive.completedAt = completedAt;
|
|
778
|
+
|
|
779
|
+
// Patch meta JSON
|
|
780
|
+
html = patchMetaInHtml(html, interactive);
|
|
781
|
+
|
|
782
|
+
// NOTE: the client script is intentionally kept — the frozen-mode dispatch
|
|
783
|
+
// in wireAnnotate still needs it for positionBubbles() and wireHoverLinking().
|
|
784
|
+
|
|
785
|
+
// Replace the empty rail with the populated frozen rail
|
|
786
|
+
html = replaceEmptyRailInHtml(html, interactive.comments);
|
|
787
|
+
|
|
788
|
+
// Insert the verdict pill immediately after <nav class="cesium-back">
|
|
789
|
+
html = insertVerdictPillAfterBackNav(html, { value: validated.value, decidedAt });
|
|
790
|
+
|
|
791
|
+
// Mark scaffold as complete via parse5
|
|
792
|
+
html = setAnnotateScaffoldStatus(html, "complete");
|
|
793
|
+
|
|
794
|
+
await atomicWrite(artifactPath, html);
|
|
795
|
+
|
|
796
|
+
return {
|
|
797
|
+
ok: true,
|
|
798
|
+
status: "complete",
|
|
799
|
+
verdict: { value: validated.value, decidedAt },
|
|
800
|
+
};
|
|
801
|
+
});
|
|
396
802
|
}
|