From 6ec0b6ceb6046d200c2f010056f5cde13af85d7d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Nov 2026 08:02:34 -0500 Subject: [PATCH] B87: saved copies of trail pages Save a Copy of This Page (File menu, right-click) and Save a copy on a page card save the page as an MHTML file named by its SHA-256, with its text, on this computer. Keep copies in a trail's menu saves one for each page that becomes a card. The card shows the newest copy and opens it after checking its hash. Trail files list copies; they are sent only when ticked, in a folder beside the file, and checked when the file is opened. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011V7Bv7WZ9Q92MZoNWYwevQ --- .github/workflows/ci.yml | 4 +- core/trail-file.js | 24 +++- core/trail.js | 33 +++++- docs/02-roadmap-backlog.md | 2 +- docs/03-architecture.md | 8 ++ docs/05-changelog.md | 1 + docs/26-trail-file-format.md | 2 + main.js | 210 ++++++++++++++++++++++++++++++++++- package.json | 3 +- preload.js | 4 + renderer/app.js | 22 +++- renderer/canvas.js | 40 ++++++- renderer/index.html | 1 + renderer/styles.css | 5 + test/copies-demo.js | 70 ++++++++++++ test/e2e/53-saved-copies.js | 161 +++++++++++++++++++++++++++ test/e2e/README.md | 1 + 17 files changed, 577 insertions(+), 14 deletions(-) create mode 100644 test/copies-demo.js create mode 100644 test/e2e/53-saved-copies.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 551c77f..1aaa93e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: (cd test/e2e/site && python3 -m http.server 8765 >/dev/null 2>&1 &) sleep 1 mkdir -p e2e-out - for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments; do + for t in 01-basics 03-rules-and-fast-results 04-find-zoom-history 05-research-session 09-playback-limit 12-pip-bookmarks-adblock 13-ask-ai 15-agents-mcp 16-research-agent 17-talk 18-redteam 19-browser-basics 20-first-run-and-help 21-rules-editor 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies; do echo "::group::$t" xvfb-run -a node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} @@ -71,7 +71,7 @@ jobs: (cd test/e2e/site && python -m http.server 8765 --bind 127.0.0.1 >/dev/null 2>&1 &) sleep 2 mkdir -p e2e-out - for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments; do + for t in 01-basics 05-research-session 19-browser-basics 20-first-run-and-help 22-ui-refresh 23-beta-survey 24-mosaic-pick 25-video-loop 26-gestures-tour 27-privacy-visible 28-ask-across-tabs 29-blocker-onpage 30-tab-focus-sleep 31-passwords 32-passwords-more 33-sidebar-chrome 34-command-bar 35-frames-tabs 36-trail-tabs 37-threads 38-trail-share 39-trail-open 40-share-link 41-survey-newtab 42-trail-canvas 43-canvas-tools 44-share-open 45-mosaic-gallery 48-canvas-media 49-trail-rename-delete 50-provenance 51-trail-recall 52-media-moments 53-saved-copies; do echo "::group::$t" node test/e2e/$t.js "$PWD/e2e-out" 2>&1 | tee e2e-out/$t.log status=${PIPESTATUS[0]} diff --git a/core/trail-file.js b/core/trail-file.js index 2d9bab1..942e3c4 100644 --- a/core/trail-file.js +++ b/core/trail-file.js @@ -516,6 +516,12 @@ function merge(base, incoming, answers = {}) { const FORMAT_V2 = 2; const SIZE_V2 = { page: [260, 200], video: [260, 220], capture: [260, 210], report: [260, 240], question: [260, 170], note: [260, 150], voice: [260, 170], search: [260, 140], media: [300, 240], moment: [280, 190] }; +// Saved copies (B87): "7 Nov 2026 21:40". +const copyTime = (t) => { + const d = new Date(t); + return `${d.getDate()} ${MONTH_NAMES[d.getMonth()]} ${d.getFullYear()} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +}; +const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; // Media moments (B88): "41:12" and back, in seconds. const clockOf = (t) => { t = Math.max(0, Math.round(Number(t) || 0)); @@ -695,7 +701,9 @@ function timelineLine(sess, byId) { // options: { by, at (ms or ISO), note, startHere (card id), timeline (true), // openTabs: [card ids] (none by default) } -function toTrailMd(snap, { by = "", at = Date.now(), note, startHere, timeline = true, openTabs = [] } = {}) { +// copies (B87): { folder, include: [sha256] } — saved copies sent beside the +// file, in that folder. Copies not sent are still listed, as kept by the sender. +function toTrailMd(snap, { by = "", at = Date.now(), note, startHere, timeline = true, openTabs = [], copies = null } = {}) { const t = snap.trail || {}; const cards = (snap.cards || []).filter(sendable).map(forFile); const byId = new Map(cards.map((c) => [c.id, c])); @@ -708,7 +716,10 @@ function toTrailMd(snap, { by = "", at = Date.now(), note, startHere, timeline = L.push("---", `# ${oneLine(t.name) || "Trail"}`, ""); if (oneLine(words)) L.push(oneLine(words), ""); if (start) L.push(`**Start here:** [${mdText(cardTitle(start))}](#${start.id})`, ""); - const block = (c) => L.push(cardHeading(c), layoutComment(c), ...cardBody(c), ""); + const sent = new Set(copies?.include || []); + const copyLines = (c) => + (c.copies || []).map((x) => `Copy: ${x.atText || copyTime(x.at)} · SHA-256 ${x.sha256}${copies?.folder && sent.has(x.sha256) ? ` · [saved copy](${encodeURI(`${copies.folder}/${x.sha256}.mhtml`)})` : ""}`); + const block = (c) => L.push(cardHeading(c), layoutComment(c), ...cardBody(c), ...(c.kind === "page" ? copyLines(c) : []), ""); const placed = new Set(); for (const g of groups) { L.push(`## ${oneLine(g.label) || "New group"}`, ``, ""); @@ -889,7 +900,14 @@ function readCard(raw) { case "page": { const kw = b.find((l) => /^Keywords:/.test(l)); const reached = b.find((l) => /^Reached: /.test(l)); - const ex = b.filter((l) => l !== kw && l !== reached); + // Saved copies (B87): "Copy: