Skip to content

feat(notes): save a reader excerpt into the note, anchored to its source - #118

Merged
mrsibe merged 1 commit into
mainfrom
feat/note-excerpt-anchor
Sep 25, 2026
Merged

mrsibe merged 1 commit into
mainfrom
feat/note-excerpt-anchor

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Closes #73.

Selecting text in the reader had nowhere to go: notes are independent Tiptap
documents with no link back to their source, so the reading loop stopped exactly
where the provenance chain would start paying off. This connects #71's reader to
#72's SourceAnchor, and the value is the contract rather than the quote: every
"go back to the original" affordance now goes through openSourceAnchor().

The decision the issue asked for: where the anchor lives

In the note body, as an ordinary markdown link. Not in a Tiptap node
attribute, not in new notes metadata.

The repository makes this mostly a non-choice. The note body is not Tiptap
JSON: NoteEditor serialises the document to markdown via tiptap-markdown on
every change (onUpdate → getMarkdown()) and parses markdown back, and notes
has a single content column. So anything that lives only in a node attribute
is gone on the next round trip. A metadata column would not escape that either
— the body has no stable ids to bind a quote to an anchor, so you would end up
sending an identifier through the markdown anyway, plus a migration.

> Attention mechanisms allow the model to weigh tokens.
>
> Second paragraph of the quote.

[Attention Is All You Need · p.5](#know-note-source?doc=doc_1&page=5&start=10&end=58)

Three consequences, all wanted: it round-trips as core markdown (no custom node,
no half-written serializer); an unknown, truncated or malformed anchor degrades
to text plus a link rather than a note that cannot be opened; and it is pure
string in / string out, so it is testable without a DOM. The fragment reuses
SourceAnchor's existing query encoding, so a note link and a transcript
citation resolve through the same code and the same location priority.

Flow

Reader.getSelection()
  → selectionToSourceAnchor()
  → buildExcerptMarkdown()
saveExcerpt()
  ├─ a note is open  → requestAppendExcerpt(noteId, markdown)
  └─ no note open    → createNote(excerpt) → opens the note
NoteEditor anchor click
  → excerptAnchorFromHref() → sourceDocumentExists() → openSourceAnchor()

Excerpts append to the note being worked on; a note is created only when none is
open. The append is a targeted command (noteId + markdown) consumed by the
editor that owns the document — writing through updateNote from the reader
would be a second writer, and the editor's next onUpdate would overwrite the
excerpt with its older body. The noteId matters: without it, a command issued
while note A unmounts can be consumed by note B.

Escaping (the part that is not obvious)

An excerpt is what the user selected, not markdown they wrote. A PDF or web
selection can legitimately look like markdown, and without escaping the excerpt
is silently rewritten — # Attention becomes a heading, - not a list becomes a
list, [foo](bar) becomes a link, and ![x](y) / --- lose their text
entirely. escapeExcerptMarkdown follows prosemirror-markdown's esc() and adds
<, & and |, because tiptap-markdown runs with html: true, so raw HTML,
entities and tables are all live.

Leading indentation is the one case escaping cannot express (what triggers an
indented code block is whitespace, not punctuation), so it is clamped to three
spaces. It is deliberately not worked around with &#32;: that reverts to a
plain space on re-serialisation, so the next load would become a code block again
— a fix that rots is worse than the bug.

Acceptance

Criterion How it is covered
A saved excerpt renders as a source-linked quote npm test: excerpt markdown round-trips its anchor; Tiptap round trip verified in a harness (below)
Clicking the link returns to the exact page and highlight npm test: anchor parsing and priority are #72's, already tested. The click itself is manual QA
Deleting the source degrades gracefully npm test: sourceDocumentExists; markdown is never rewritten. The disabled styling is manual QA
Notes without anchors are unaffected npm test: a plain note keeps its content; a non-excerpt link is never treated as one

Verification

  • npm run typecheck — main/preload, renderer and test projects
  • npm test — 237 pass / 0 fail (18 new: escaping and its reversibility, the append seam, anchor round-trip, the targeted command's cross-note delivery)
  • npm run build
  • npm run check:design — no violations
  • npx prettier --check src test — clean (only touched files were formatted)
  • npm run lint — 0 errors, 107 warnings (the pre-existing no-explicit-any baseline; this PR removes two of them rather than adding any)

The Markdown → Tiptap round trip was checked against the real Tiptap in a
throwaway jsdom harness, deliberately not committed: jsdom is only a transitive
dependency (via cheerio), so relying on it as a test dependency would depend on
npm's hoisting. 42 markdown-sensitive selections — headings, all three bullet
markers, ordered lists (1. and 1)), nested quotes, thematic breaks, inline
links, images, inline/block HTML, entities, backslashes, setext pairs, tables,
4/8-space and tab indentation, and multi-line combinations — all keep both their
visible text and their structure. The durable artefact is the committed unit
tests over escapeExcerptMarkdown; the harness was the one-off check that the
rules are the right ones.

Not verified — needs a real window

Nothing was exercised in a running Electron window. Explicitly unverified:

  • clicking an excerpt link, and that it lands on the right page and highlight
  • the disabled state when the source has been deleted (attribute + styling + toast)
  • an excerpt appended into a dirty editor, which is the riskiest interaction here:
    the append goes through onUpdate → onChange → the existing save path, so
    the note becomes dirty and still needs the normal save. Behaviour differs
    between the two branches (a new note is persisted immediately, an appended one
    is not), which is consistent with the existing editor semantics but worth
    confirming by hand.

Out of scope

  • No SourceAnchor schema change and no DB migration.
  • The append target remains a single open note; multi-note tabs would need the
    command's noteId matching to grow, which it is shaped for but does not do.
  • The duplicated documentId in SourceAnchor is still there. It now has exactly
    one construction path, so it cannot be built inconsistently, but collapsing the
    type itself is a separate change.

Selecting text in the reader had nowhere to go: notes are independent Tiptap
documents with no link back to their source, so the reading loop stopped exactly
where the provenance chain would start paying off.

- An excerpt is a blockquote plus a `SourceAnchor` written as a plain markdown
  link (`#know-note-source?doc=…&page=…&start=…&end=…`). The anchor has to live
  in the note body itself: the body is not Tiptap JSON — `NoteEditor` serialises
  the document to markdown on every change and parses markdown back — so an
  anchor kept only in a node attribute would disappear on the next round trip.
  A quote plus an ordinary link also degrades for free: an unknown or malformed
  anchor loads as text, never as a note that cannot be opened.
- `escapeExcerptMarkdown` keeps the selected text literal. An excerpt is what
  the user *selected*, not markdown they wrote, so `# heading`, `- list`,
  `[x](y)` and `<b>` must not become a heading, a list, a link or bold. The
  table follows prosemirror-markdown's `esc()`, plus `<`, `&` and `|` because
  tiptap-markdown runs with `html: true` and raw HTML, entities and tables are
  all live. Leading indentation is clamped to three spaces — the one case
  escaping cannot express, since what triggers an indented code block is
  whitespace rather than punctuation.
- `selectionToSourceAnchor` joins `citationToSourceAnchor` as the second entry
  at the single place that constructs a `SourceAnchor`, so the duplicated
  `documentId` has exactly one writer.
- Saving appends to the open note and only creates one when none is open, so
  excerpts accumulate into the note being worked on instead of becoming a note
  each. The append travels as a targeted command (`noteId` + markdown) consumed
  by the editor that owns the document: writing through `updateNote` from the
  reader would be a second writer, and the editor's next `onUpdate` would
  overwrite the excerpt with its older body.
- Note links now have one dispatch point. `openOnClick` is off because Tiptap's
  Link calls `window.open(href)` inside an editable editor, which is wrong both
  for these anchors and for ordinary links; `#know-note-source?…` goes to
  `openSourceAnchor` and everything else to `openExternalUrl`. A deleted source
  keeps the quote and the reference and only disables navigation
  (`data-source-missing` + `aria-disabled`) — the markdown is never rewritten,
  because "the evidence is unreachable now" and "this note came from there" are
  different facts.
- `citationDocumentExists` becomes `sourceDocumentExists`, now that the excerpt
  anchor is a second consumer of the same rule.

Verification: `npm run typecheck`, `npm test` (237 pass; 18 new — escaping and
its reversibility, the append seam, anchor round-trip, and the targeted
command's cross-note delivery), `npm run build`, `npm run check:design`,
`npx prettier --check src test`.

The Markdown → Tiptap round trip was checked against the real Tiptap in a
throwaway jsdom harness (deliberately not committed — jsdom is only a transitive
dependency, so it must not become a test dependency): 42 markdown-sensitive
selections — headings, all three bullet markers, ordered lists, nested quotes,
thematic breaks, inline links, images, inline/block HTML, entities, setext
pairs, tables, 4/8-space and tab indentation, and multi-line combinations —
all keep both their visible text and their structure.

Not verified: nothing was exercised in a real Electron window. Click dispatch,
the disabled styling, the append into a dirty editor and the save path are
covered by unit tests over the pure parts only, and are listed as manual QA in
the PR.

Closes #73
@github-actions github-actions Bot added the enhancement New feature or request label Sep 25, 2026
@mrsibe
mrsibe merged commit 78a8b49 into main Sep 25, 2026
4 checks passed
@mrsibe
mrsibe deleted the feat/note-excerpt-anchor branch September 25, 2026 17:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Selection → excerpt to Note, anchored back to its source

1 participant