Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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]}
Expand Down Expand Up @@ -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]}
Expand Down
24 changes: 21 additions & 3 deletions core/trail-file.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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]));
Expand All @@ -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"}`, `<!-- group:${g.id} at:${Math.round(g.x)},${Math.round(g.y)} size:${Math.round(g.w)}x${Math.round(g.h)}${g.resolved ? " resolved" : ""} -->`, "");
Expand Down Expand Up @@ -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: <time> · SHA-256 <hash>[ · [saved copy](folder/<hash>.mhtml)]"
const copyRows = b.filter((l) => /^Copy: .+ · SHA-256 [0-9a-f]{64}/.test(l));
for (const l of copyRows) {
const m = l.match(/^Copy: (.+?) · SHA-256 ([0-9a-f]{64})(?: · \[saved copy\]\((\S+)\))?$/);
if (!m) continue;
(c.copies = c.copies || []).push({ atText: m[1], sha256: m[2], ...(m[3] ? { file: decodeURI(m[3]) } : {}) });
}
const ex = b.filter((l) => l !== kw && l !== reached && !copyRows.includes(l));
if (ex.length) c.excerpt = ex.join(" ");
c.keywords = kw ? kw.replace(/^Keywords:\s*/, "").split(/,\s*/).filter(Boolean) : [];
if (reached) {
Expand Down
33 changes: 32 additions & 1 deletion core/trail.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const GROUP_SIDE = 24; // group padding around its cards: sides and bottom
const GROUP_TOP = 48; // and top, where the name sits
const SESSION_GAP_MS = 30 * 60 * 1000; // a gap of more than 30 minutes starts a new session
const ENGAGED_MS = 20 * 1000; // the 20-second rule (doc 23); a starting guess
const EVENT_TYPES = new Set(["opened", "bounced", "captured", "asked", "reported", "noted", "placed", "removed", "linked", "unlinked", "grouped", "renamed", "resolved", "moved", "edited"]);
const EVENT_TYPES = new Set(["opened", "bounced", "captured", "asked", "reported", "noted", "placed", "removed", "linked", "unlinked", "grouped", "renamed", "resolved", "moved", "edited", "copied"]);
const CARD_KINDS = new Set(["page", "video", "capture", "report", "question", "note", "voice", "search", "media", "moment"]);
const LINE_KINDS = new Set(["nav", "auto", "you", "suggested"]);
const LINE_LABELS = new Set(["", "supports", "contradicts", "answers", "raises"]);
Expand Down Expand Up @@ -372,6 +372,7 @@ class TrailStore {
createdAt: trail.createdAt,
lastAt: trail.lastAt,
readonly: trail.readonly,
keepCopies: Boolean(trail.keepCopies),
from: trail.from || "",
note: trail.note || "",
startHere: trail.startHere || "",
Expand Down Expand Up @@ -543,6 +544,36 @@ class TrailStore {
if (card && source && !source.removed) this.link(id, source.id, card.id, { kind: "auto", label: "captured from", at });
return card;
}
// ---- saved copies (B87) ----
// A copy of a page card's page, saved on this computer (main.js keeps the
// file, named by its SHA-256). The card lists its copies, newest last.
addCopy(id, url, { sha256, bytes = 0, at = this.now() } = {}) {
const d = this.get(id);
const c = url && this.pageCard(id, url);
if (!d || d.trail.readonly || !c || c.removed || isSensitiveUrl(url) || !/^[0-9a-f]{64}$/.test(String(sha256))) return null;
c.copies = c.copies || [];
const have = c.copies.find((x) => x.sha256 === sha256);
if (have) return have; // the page has not changed since that copy
const copy = { at, sha256, bytes: Math.max(0, Math.round(Number(bytes) || 0)) };
c.copies.push(copy);
this.event(id, { type: "copied", card: c.id, at, data: { url, sha256 } });
this.save(id);
return copy;
}
// Every copy any trail names (for cleaning up files no trail needs).
copyHashes() {
const out = new Set();
for (const t of [...this.index.order, ...this.deletedIds()]) for (const c of this.load(t)?.data.cards || []) for (const x of c.copies || []) out.add(x.sha256);
return out;
}
// The trail setting: keep a copy of each page that becomes a card.
setKeepCopies(id, on) {
const d = this.get(id);
if (!d || d.trail.readonly) return null;
d.trail.keepCopies = Boolean(on);
this.save(id);
return d.trail.keepCopies;
}
// ---- media moments (B88) ----
// A section of a video or audio page, a to b in seconds, with the caption
// text shown during it when the site had captions. It goes next to its
Expand Down
2 changes: 1 addition & 1 deletion docs/02-roadmap-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ Effort: XS under 1 hour, S half a day, M 1-2 days, L 3+ days. Finding IDs (C1, K
| B84 | 4 | Show a trail's history (`shareLog`, already recorded): who added which pages and threads, and when | P1 | XS | Not started. Covered by Replay (B110) for canvas trails |
| B85 | 4 | "Searched, nothing found" entry: a reply type in a thread that records a search that found nothing | P2 | XS | Not started |
| B86 | 4 | Check before creating a share link: warn when a trail has pages naming private people or showing social posts; trails private by default. Add a shared trail file with a prompt injection to the B58 red-team set | P1 | S | Not started |
| B87 | 4 | Save a copy of each trail page (offered, not forced) with a SHA-256 hash and the time; kept on the computer, sent only through share links | P1 | M | Not started |
| B87 | 4 | Save a copy of each trail page (offered, not forced) with a SHA-256 hash and the time; kept on the computer, sent only through share links | P1 | M | Built 2026-11-08 (PR #71): "Save a Copy of This Page" (File menu, right-click) and "Save a copy" on a selected page card, plus "Keep copies" per trail (off by default); MHTML named by its SHA-256 with its text beside it; the card says "Copy · 8 Nov 10:05" and opens the copy after checking its hash; sharing sends copies only when ticked, in a folder beside the trail file (Sven, 2026-11-08: button + trail setting, MHTML + text, card mark + send when ticked). Electron build only. Tests `test:copies`, e2e `53-saved-copies` |
| B88 | 4 | Media moments: one key saves start and end time, transcript text if available, and the link, then pauses playback; reuses the B70 loop range. "ID at 41:12" requests in threads | P1 | M | Built 2026-11-07 (PR #68): ⌘⇧K marks the start, then the end (with a loop set, one press saves the loop), and ⌘⇧J saves the last 30 seconds (Sven chose both, 2026-11-07); a moment card next to the page's card with the captions shown during it; times can be changed on the card; Play opens the page at the start and loops the range; Ask for ID makes a question "ID at 41:12?" that the moment raises. Electron build only. Tests `test:moment`, e2e `52-media-moments` |
| B89 | 4 | Search past trails by what the person remembers: order ("after the Discogs page"), time, thread, keyword | P1 | M | Built 2026-11-07 (PR #67): an "In your trails" group in the CommandBar and the canvas search bar's list; local rules only, no AI; picking a result goes to its card, or its timeline row (Sven, 2026-11-07). Electron build only. Tests `test:recall`, e2e `51-trail-recall` |
| B90 | 4 | Close tabs into a trail with a reason (to read, reference, reminder). Possible answer to B75 | P2 | S | Not started |
Expand Down
8 changes: 8 additions & 0 deletions docs/03-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,14 @@ The same core modules run in the built-in extension of the Chromium build (`brow
- **Rename:** `rename(id, name)` records a `renamed` event; `applySession` now does the same when a session's name changes, so renames through research:\* are events too.
- **Channels:** `trail:rename-id` (id, name; refused for read-only trails; the current trail goes through research:\*), `trail:delete` (closes the trail's tabs first, then `changeTrail` if it was current; returns `{deleted, name, tabs, current}`), `trail:restore` (via `changeTrail`).
- **Switcher** (`renderer/app.js`): each trail row is `.sb-menu-row` with the item and a "…" button; the actions row (`.sb-menu-acts`) holds Rename and Delete…, then the question with Delete trail and Keep. `#sb-undo` under the menu shows Undo for 10 s. Keys on a trail in the menu: F2 renames, Delete or Backspace asks to delete, → reaches "…". Over a page the field is typed in the sidebar's layer (`chrome-overlay.html`); the real field does not save on losing focus there, and Enter or Esc come back through `overlayEvent`.
## Saved copies (B87)

- **Files:** `userData/copies/<sha256>.mhtml` (`webContents.savePage(…, "MHTML")`, then renamed to its SHA-256) and `<sha256>.txt` (`readableTextScript`). MHTML differs on every save (date, part boundaries), so a copy whose text equals the newest copy's text is dropped and the newest one stands (`sameAsLastCopy`). `cleanCopies` at start-up deletes files that no trail names (`TrailStore.copyHashes`, after `purge`).
- **Main:** `copyTab(tab)` (File menu, page right-click, Keep copies after `engagePage`) and `copyUrl(trail, url)` (a card with no tab: a hidden, sandboxed window, 20 s limit, refused if it lands on a sign-in page). `openCopy(sha)` checks the SHA-256 before opening the file in a tab. `trailSnapshot` adds `here` (the file is on this computer) to each copy.
- **Store:** page cards have `copies: [{ at, sha256, bytes }]`, newest last, each recorded as a `copied` event (not shown in the timeline). `trail.keepCopies` is the trail setting.
- **Sharing:** `trail:share-save` with `copies` writes the files to "<name> copies" beside the trail file and passes `{ folder, include }` to `toTrailMd`. `takeCopies(file, data)` runs before `importFile`: only files inside the trail file's folder, ending in .mhtml, under 100 MB; a match is copied in, a mismatch is marked `changed`.
- **Window:** the card's `.cv-card-copy` line, the selection bar's "Save a copy", "Keep copies: On/Off" in the switcher's trail actions, "Saved copies of pages" in the share sheet.

## Media moments (B88)

- **Page script** `momentScript(op, secs)` (`core/page-scripts.js`), run in every frame until one has media: `state`, `start` (marks the time and collects the captions on screen every 400 ms: text tracks' active cues, else `.ytp-caption-segment`, `.vp-captions` and similar), `end` (the mark to now, with what it collected, else the text-track cues in that range; pauses), `last` (the last n seconds with their cues; pauses), `loop` (the B70 loop's range; pauses), `cancel`. `loopScript("range", 60, { a, b })` seeks to a, plays and loops a–b, for video or audio.
Expand Down
Loading
Loading