Files
Marcos Paulo e8c8c06d1e
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: smart folder model, media time fragments, link card URL guard (MiniMax); specs for 10 more gaps (Codex) and the registry with bloat measurements
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 14:41:26 -03:00

14 KiB

Web clipper: browser capture into Markdown and local assets (agy gap 2, roadmap 33)

Part of specs/plugins-parity/. Reuse engine capture/ and live-session control dispatch. No new crate. Browser adapter is a contract, not JavaScript embedded in NotesCraft.

Research (2026-10-21; agy claims checked)

  • Official Obsidian Clipper exists and saves web content/highlights privately into notes: product. This supports capture UX, not a claim that every image is automatically localized. Drop unverified Notion/Joplin/Bear parity and effort estimates.
  • Extension service workers can perform cross-origin requests with host permissions; content scripts retain page-origin restrictions: Chrome network contract. Therefore endpoint calls and credentials belong to extension background code.
  • Bookmarklets can be blocked by CSP: MDN javascript URLs. Do not promise universal one-click localhost access; clipboard/manual import is required fallback.

Problem / goal

Capture selected text or article Markdown, source URL, title and explicitly supplied image bytes into an open writable vault. Browser handles DOM extraction; NotesCraft validates input and saves through engine commands. No telemetry, server-side scraping, remote fetching, cookies, or cloud account.

Verified baseline / files

crates/engine/src/capture/mod.rs registers note.capture, appends to a daily buffer or creates an Inbox/folder note; accepts one base64 attachment. capture/rules.rs formats frontmatter/slugs. control/{server,protocol,pump,token}.rs implements loopback NDJSON, not HTTP, capped at 1 MiB per line. apps/notescraft/src/main.rs starts server per vault process. crates/engine/tests/{capture,control,control_client}.rs are integration patterns. prefs/{app_settings,settings,mod}.rs own persistent settings. specs/plugins-parity/clean-paste/spec.md proposes htmlmd; it is a dependency only for optional HTML import, not needed for Markdown v1.

Non-goals

Bundled extension/store publication; executing page HTML; automatic crawling; bypassing login/paywalls; claiming full Readability extraction; automatic article deduplication; raw control-command access from web pages. Bookmarklet captures selection/title/link only; richer article extraction belongs to extension.

Design decisions

D1 Versioned envelope (new capture/web_model.rs)

{"version":1,"request_id":"clip_0123456789abcdef","title":"Example","source":"https://example.org/article","text":"# Article\n\n![figure](asset:figure)","target":"inbox","assets":[{"id":"figure","name":"figure.png","media_type":"image/png","base64":"..."}]}

Exactly these fields; reject unknown version/fields and malformed types. request_id is clip_ plus 16..64 lowercase hex characters. Source must be absolute http(s), no credentials/control characters, <=2000 characters; store source as inert metadata, never fetch. Title 1..200 characters without controls. Text UTF-8 <=256 KiB, nonempty after whitespace unless assets present. Target inbox default, daily, or safe relative vault folder <=1000 characters; reserved paths forbidden through existing Vault rules. At most 8 PNG/JPEG assets, each <=256 KiB decoded, aggregate <=512 KiB, envelope <=1 MiB encoded; reject excess, never truncate. Validate signature/media type/name, duplicate ids and missing asset:id references before any write. No SVG/HTML assets. Browser may explicitly leave uncaptured http(s) image links; show remote-link warning in preview, never fetch silently.

Use Markdown image destination spans from existing Markdown parser, not global string replacement: rewrite asset:id only in image destinations, never inside prose/code. Escaped names and duplicate filenames must remain valid. Save assets through Session attachment helpers and note through existing capture path. Factor shared capture execution only as needed; do not duplicate daily/Inbox formatting.

D2 Engine surface

Command Params Result / effects
capture.web.preview envelope {title,source,target,assets:[{id,name,bytes}],remote_images,warnings}; validate, no IO/network
capture.web.save {envelope,retry_only?} (default false) {request_id,path,appended,assets:[{id,path}],replayed}; writable gate
Add both to commands.rs registration, tests/core_commands.rs, ai/assistant_gate.rs (preview READ, save WRITE). CLI/MCP call same registry surface. Existing note.capture behavior/tests remain unchanged.

Per-process idempotency: retain at most 128 completed request ids plus envelope digest and result, clear on close. Exact retry while id is retained returns same result without write; changed envelope with same id -> request_conflict. retry_only:true never creates a capture: unknown/evicted id returns outcome_unknown and writes nothing. After eviction/restart user can explicitly choose a fresh-id submission with duplicate warning; no automatic blind resubmit. Browser retains unsent payload and must show uncertain outcome after restart; no cross-restart exactly-once promise. Use existing sha2 workspace dependency only if approved manifest usage already covers it; otherwise store bounded canonical envelopes for equality (128 x 1 MiB upper bound, prefer digest; Q3).

Preflight all paths/assets/read-only/destination checks before writes. Asset writes precede note insertion. On later failure return partial_capture with exact newly-created paths; never remove pre-existing/deduplicated files or overwrite a note. User can retry or inspect orphaned assets; no false multi-file atomicity claim. Every individual disk write goes through existing atomic Vault/Session APIs. Include save failures in result UI; no silent partial success.

D3 Local HTTP capture endpoint on control channel

Keep NDJSON listener and descriptor backward compatible. Start a separate loopback-only HTTP listener owned by same ControlHandle; it shares the control job/pump path and shuts down with it. Bind 127.0.0.1:0, never LAN. Descriptor gains optional capture_http:{port,version:1} (no capture credential); old clients ignore it. Use already approved workspace axum/tokio, added to engine manifest explicitly in task 3; no new third-party dependency or crate. HTTP parser is framework-owned, not a new hand-written parser.

Disabled by default: app setting capture.web_enabled=false. Explicit local pairing screen displays port and a separate capture-only bearer token from at least 16 OS-cryptographic random bytes (reuse Unix /dev/urandom read pattern, not new_token() noncryptographic fallback); never expose broad control token. Capture token rotates on restart/disable/revoke, remains memory-only, never logged, put in URLs, clipboard payloads or page DOM. Owner manually enters it in extension settings; no unauthenticated remote pairing endpoint. Enable and token display are local UI actions only. Existing control/token.rs::new_token falls back to RandomState/time/pid on Windows or RNG error; that fallback is NOT allowed for browser-facing capture. Add a fallible capture-only token helper; RNG failure keeps bridge disabled. Unix OS randomness works without new dependency; Windows HTTP bridge stays unsupported until owner approves secure-RNG binding/dependency Q5. Clipboard import remains available there.

Routes: POST /v1/capture/preview, POST /v1/capture map ONLY to above commands; HTTP body stays the D1 envelope, bridge wraps save params. Header X-NotesCraft-Retry: 1 sets retry_only:true; include that header in exact-origin preflight allowlist. Browser uses it for every retry after uncertain response; OPTIONS implements exact allowlisted extension origins. No generic run, commands listing or note reads. Require application/json and Authorization: Bearer ..., exact numeric-loopback Host/port, reject foreign Host (DNS rebinding), web/null origins and missing/invalid token before queuing. Origin-less authenticated CLI allowed; exact enabled extension ids configured locally. No wildcard CORS/credentials; approved preflight does not authorize POST. Return 200 preview, 201 saved, 200 replay, 400 invalid, 401 auth, 403 disabled/origin/read-only, 409 conflict, 413 over limit, 429 busy, 500 structured failure. Bodies/errors never echo bearer token or source body.

Body cap 1 MiB; header cap 16 KiB and read timeout 5 s; at most 4 connections, 1 capture in flight, no unbounded queue. Validate/decode on HTTP worker; session mutation still runs through pump. Set 10 s response deadline: before pump execution, expired job must be skipped; once mutation starts do not claim cancellation/rollback. Timeout reply carries outcome_unknown and request id so retry-only lookup resolves it while retained, otherwise returns uncertainty without another write. NDJSON 1 MiB cap unchanged.

D4 Bookmarklet / extension contract

Extension extracts Markdown locally after explicit click, preview includes destination and missing/localized images. Background worker calls endpoint; host permissions limited to loopback plus explicit image origins, never arbitrary page-supplied fetch proxy. Tokens in extension-local storage, never sync storage/content script messages. Pair each open vault explicitly; no scan of arbitrary ports or fallback to another vault. Browser fetches assets only after permission/confirmation; passes bytes, not URLs for app to fetch. Browser contract tests cover CSP/host permission failure and stale pairing. External adapter implementation/publication needs separate owner scope.

Bookmarklet v1 returns a version-1 envelope with selection as plain Markdown-escaped text, current URL/title, Inbox target, empty assets and fresh id; copies it where clipboard permission permits, otherwise shows selectable text in browser UI. User pastes JSON into NotesCraft's Import web capture dialog, previews, saves via commands. No bearer token in bookmarklet; no promise of direct HTTP calls from arbitrary websites. Browser-restricted pages show unsupported. This fallback does not require extension or an app webview.

D5 UI / settings

New import/pairing dialog in crates/ui-egui/src/views/web_capture/, declared in existing views/mod.rs; shell wiring in shell/app.rs, shell/app/window/{commands,frame}.rs. Reuse craft-ui controls and tokens. Local setting is persisted through existing prefs/settings.rs; listener enable/disable reconciles outside paint. Strings [ui.web_capture] at end of crates/i18n/locales/en.toml, table module through text/features/mod.rs. Preview, explicit save, retry/partial errors and copy/import instructions; no HTML execution or token in screenshots/logs.

Acceptance criteria

  1. Registry preview writes zero files; save from CLI and HTTP creates same Markdown and local image links. Daily capture respects dirty buffer/undo; Inbox naming collisions preserve both captures.
  2. Invalid URL/path/version/asset/missing reference rejects before writes. Boundary tests exercise exact limits and +1; HTML/script/SVG never execute.
  3. NDJSON clients remain compatible; disabled bridge binds no HTTP socket. Endpoint cannot run arbitrary commands, read notes, or use broad control token.
  4. Forged Host, disallowed origin, missing auth and oversized bodies never enqueue mutation. Enable/disable/restart rotates token and stops listener; read-only refuses save.
  5. Lost reply plus retry-only request while retained saves once; changed payload/id conflicts. Unknown/evicted id and retry-only after restart write nothing and return outcome_unknown; fresh submission requires explicit duplicate-risk confirmation. Expiry before pump writes nothing; post-dispatch timeout is explicitly uncertain.
  6. Inject failure after first asset: structured partial result lists only newly-created assets, existing files intact. Retry does not overwrite prior files.
  7. Bookmarklet clipboard import works without HTTP; CSP/clipboard refusal displays manual fallback. No extension support is claimed without testing actual adapter.
  8. UI snapshots in Mocha/Latte at 1440x900 and compact width show preview and errors without secret values; all strings localized. Flood tests show bounded memory/queue.

Open owner questions

  • Q1: approve selection-only bookmarklet plus clipboard/manual import and external extension contract as v1? Direct bookmarklet localhost auth would expose credentials to page context.
  • Q2: confirm 512 KiB aggregate local-asset limit for v1; raising beyond existing control budget requires separate streaming/worker plan.
  • Q3: approve adding already-approved sha2 workspace usage to engine for bounded idempotency digests (reason: retain 128 results without 128 MiB payload copies)? No unapproved dependency may be added.
  • Q4: confirm explicit partial-capture errors with recoverable orphaned assets rather than a durable multi-file capture journal.
  • Q5: approve a safe cross-platform OS-RNG dependency such as getrandom (exact version/license reviewed before addition) for Windows bridge tokens, or accept Unix-only HTTP bridge v1? Reason: existing control-token fallback is explicitly noncryptographic. Never relax project unsafe policy.

Feature ideas (researched, ranked)

  1. User templates/site-specific extraction, inspired by Obsidian Clipper; keep conversion in browser, never execute remote code in app.
  2. Extension highlights and reader selection workflow, same official Clipper, after basic contract adapter is tested.
  3. Native-messaging browser transport to avoid localhost permission friction: Chrome native messaging; requires separate packaging/owner approval.
  4. Larger streamed images and durable request journal after measuring local endpoint limits; Chrome network permissions informs explicit origin grants.