Skip to content

Latest commit

 

History

History
715 lines (640 loc) · 57.3 KB

File metadata and controls

715 lines (640 loc) · 57.3 KB

MarkAI

Local web app for annotating documents (MD / PDF / DOCX) and exporting the notes in a compact, AI-friendly format so an external coding agent can apply them to the source. Single user for now, built to be deploy-ready later. Flask backend, vanilla JS/CSS frontend, SQLite storage.

Read this file before making changes — it captures decisions and gotchas that aren't obvious from the code alone. Update it when you make a structural change or fix something non-obvious.

Run it

cd MarkAI
.venv/Scripts/python run.py    # http://localhost:5000, debug=True, data in ./data

That's the development entry point and the one to use here: it pins the data dir to the repo's data/ (sample documents live there) and runs the Flask reloader. The shipped entry point is markai/cli.py → the markai console script (uvx markai), which is a different beast: waitress instead of the dev server, port 8765 with a free-port fallback, 127.0.0.1 only, browser auto-open, and data under platformdirs.user_data_dir("MarkAI"). Changes to startup behaviour usually need to happen in both.

Deps: Flask, python-docx, markdown, platformdirs, waitress — declared in pyproject.toml (requirements.txt is kept only as a convenience mirror). .venv already exists; if not, python -m venv .venv && .venv/Scripts/python -m pip install -e ..

.claude/launch.json is configured for the preview_start tool (name markai, port 5000). Flask's debug reloader spawns a child process that preview_stop doesn't always kill — if you get "port 5000 already in use" on the next preview_start, find and kill leftover .venv\Scripts\python.exe .\run.py processes first (Get-CimInstance Win32_Process -Filter "name='python.exe'" on Windows) before retrying.

There's a git repo. Check git status and git log before assuming the working tree matches what's committed — this file is updated when structural changes land, but git history (not this paragraph) is the source of truth for what's actually shipped at any given moment.

Architecture

pyproject.toml      packaging (hatchling), deps, the `markai` console script, PyPI metadata
run.py              dev entry point (repo-local data dir, Flask reloader)
tests/smoke_test.py stdlib end-to-end check, run against an *installed* MarkAI
.github/workflows/  ci.yml (smoke test on 3 OSes) + publish.yml (tag -> PyPI, trusted publishing)
markai/
  __init__.py       Flask app factory, data dir resolution, per-install secret key
  cli.py             `markai` console script: arg parsing, port choice, waitress, browser open
  db.py              sqlite3 connection (flask g) + schema (executescript, no migrations/ORM)
  auth.py            register/login/logout, session-based, login_required decorator + g.user
  documents.py       library CRUD, file upload/storage, serves parsed content or raw PDF bytes
  notes.py           notes CRUD, bulk actions, manual export download, sync-status poll endpoint
  sync.py            writes/reads the two files in a document's "source folder"
  settings.py        /settings page: change password + AI-provider CRUD (storage only) + resolve-ai stub
  ai/base.py         AIProvider ABC — NOT implemented, just an extension point (see below)
  parsers/
    markdown_parser.py   md -> HTML blocks tagged with data-line + heading outline
    docx_parser.py        docx -> HTML blocks tagged with data-paragraph-index + heading outline
    util.py                shared slug/unique-id helper
  templates/          Jinja2, server-rendered (no SPA framework)
  static/css/app.css  hand-written, CSS custom properties, light+dark via prefers-color-scheme
  static/vendor/pdfjs/  pdf.min.js + pdf.worker.min.js, vendored (NOT a CDN — see below)
  static/js/
    viewer.js          the big one — rendering, note CRUD, highlighting, search, PDF zoom/handling (~2400 lines)
    sync_poll.js        polls /documents/<id>/sync-status every 5s + wires the Refresh button
data/                 gitignored: dev-only data dir (app.db, uploads/, secret_key)

Data lives outside the repo for installed users. create_app(data_dir=...) decides where: explicit argument, else MARKAI_DATA_DIR, else the per-user platform dir. The Flask SECRET_KEY is generated once per install and stored as <data_dir>/secret_key — never hardcode a fallback there, a constant shipped inside a public package makes every install's session cookies forgeable.

No ORM, no JS framework, no build step. PDF rendering/interaction is 100% client-side via pdf.js 3.11.174, vendored under markai/static/vendor/pdfjs/ (both pdf.min.js and pdf.worker.min.js) — zero Python PDF dependency; the server just streams the raw file bytes for PDFs. It used to load from cdnjs; it doesn't any more, because a tool that promises your documents never leave the machine shouldn't need the network to open one, and an offline user got a dead viewer. viewer.html has the <script> tag, viewer.js reads the worker URL from cfg.pdfWorkerUrl (also set in viewer.html) — both must move together if the version is ever bumped, and pyproject.toml's artifacts entry is what keeps the two files inside the wheel.

The PDF text layer uses pdf.js's own pdfjsLib.renderTextLayer/updateTextLayer (both exported from the core pdf.min.js build already loaded — no extra CDN asset needed), not a hand-rolled span builder. This is version-coupled: item splitting and the textDivs array pdf.js hands back are an implementation detail of that exact build, which is why the pdf.js version is pinned in viewer.html — bumping it should be deliberate, and re-verify the "old note still resolves" case (see below) afterward. buildPdfTextLayer (viewer.js) captures textDivs in item order into per-page state (wrap.__mk, via pdfPageState(wrap)) — this is also why anchor_span_index stays a valid index into textDivs across this change: pdf.js pushes one div per text item, in order, before appending any of them, same as the old code did. --scale-factor is set on each .pdf-page-wrap (an ancestor of the text layer) and must track pdfScale — pdf.js reads it via getComputedStyle and both layout (percentage left/top) and zoom (updateTextLayer with mustRescale:true) depend on it being current. See app.css's .pdf-text-layer block — those rules are a port of pdf.js's own text_layer_builder.css and are load-bearing for glyph alignment, not decorative.

Accounts, the default login, and password recovery

A brand-new database is seeded with admin@markai.local / markai (db.seed_default_user, constants DEFAULT_EMAIL/DEFAULT_PASSWORD). Two rules make that safe enough for a local tool and they're easy to break by accident:

  • Seeding happens only when the users table is empty. An existing install must never sprout a second, publicly-documented account behind its owner's back.
  • The login page advertises those credentials via auth.default_login_hint, which returns them only while check_password_hash still matches DEFAULT_PASSWORD. It's derived from the hash rather than a stored flag, so the hint disappears by itself the moment the password changes — including a change made through markai reset-password or straight in SQLite. Don't replace it with a boolean column; the flag would go stale and the page would keep advertising a rotated password.

Password change lives in /settings and requires the current password. Password recovery is markai reset-password in cli.py: no email, no self-service web reset. Whoever can run that command can already read the SQLite file, so the terminal is the credential; adding a web reset flow would just create a second unauthenticated way into the app. The login page's "Forgot your password?" <details> block tells the user that command — keep the two in step if the CLI ever changes.

Data model (SQLite, see db.py for the authoritative schema)

  • users(id, email, password_hash, created_at, quote_char_limit) — the last column was added via a guarded ALTER TABLE in db._migrate() (see below), not the CREATE TABLE block, since SQLite has no ADD COLUMN IF NOT EXISTS.
  • documents(id, user_id, title, doc_type[md|pdf|docx], stored_filename, source_folder, last_status_sync_mtime, last_opened_at, created_at)
  • notes(id TEXT uuid, document_id, user_id, note_text, status[pending|done], position_json, created_at, updated_at)
  • ai_providers(id, user_id, name, kind[api|local], base_url, api_key, model, created_at) — unused, backs the settings page only
  • section_status(document_id, section_key, status[just_read|reviewed], updated_at), PK (document_id, section_key) — backs the outline sidebar's review-mode tinting (see below). section_key is an outline entry's own id (the heading slug for md/docx, the synthetic pdf-p<page>-<n> id for pdf — see flattenOutline in viewer.js), so it's shared with no new concept. Absent row == not_reviewed; the client DELETEs the row for that state rather than writing it, which is why the CHECK constraint doesn't list it.

db._migrate() runs a guarded ALTER TABLE ... ADD COLUMN, swallowing the "duplicate column" error from a database that already has it. That's the pattern to extend if a future column needs adding to an existing table — CREATE TABLE IF NOT EXISTS (used for section_status above) only covers brand-new tables.

The position_json blob — this is the part worth understanding

Every note stores a JSON object with fields that differ slightly by doc type, built client-side in viewer.js at click/selection time. Common fields: type (point|selection), chapter (nearest heading text), heading_path (array of ancestor headings, root→leaf — this is what makes the hierarchical export location possible), context_before/context_after (~150 chars, used only in the popover preview UI), quote (short human/AI-readable snippet — see export section), and for selections selected_text.

Type-specific anchors:

  • MD: line_number (line in the source .md, computed server-side by markdown_parser.py splitting on blank lines — approximate but monotonic), plus char_offset/char_length (exact offset within the rendered block's .textContent, used for pixel-precise marker placement and <mark> wrapping).
  • DOCX: paragraph_index (index into document.paragraphs from python-docx) + same char_offset/char_length.
  • PDF: page + anchor_span_index/anchor_span_index_end (index into that page's textDivs list, in getTextContent() item order — stable across re-renders of the same page and across zoom, since pdf.js pushes one div per item before appending any of them). Notes created since the native-text-layer rewrite also carry char_base: "span" plus char_offset/char_length (point) or char_offset/char_offset_end (selection) — offsets are relative to textDivs[anchor_span_index].textContent, not a page-wide flat string, so they can be turned directly into a DOM Range (pdfRangeFromAnchor). char_base is the version discriminator: its absence marks a pre-rewrite note that only ever resolved to a whole text item.

Backward-compat fallback is load-bearing, not optional. Notes created before char_offset / anchor_span_index existed (or any future note where the stored offset doesn't resolve) fall back to searching the current text for selected_text or anchor_text via indexOf/findIndex. This logic lives in locateAnchorInBlock (md/docx) and locatePdfAnchor (pdf) in viewer.jsalways route marker placement and highlighting through these, never read char_offset/anchor_span_index directly, or you'll silently break old notes.

locatePdfAnchor's fallback chain, in order: (1) char_base:"span" present → resolve the exact char offsets, then verify the resolved text still matches selected_text/anchor_text (catches a PDF that changed on disk, or a future pdf.js version splitting items differently) — on mismatch, fall through to (2); (2) whole-page text search (locatePdfAnchorByText, using a whitespace-normalized flat index built lazily per page and cached on wrap.__mk.flatIndex) against selected_text, its first 40 chars, anchor_text, then anchor_text's first word — tried before the index-only fallback for legacy selections specifically, because the old anchor_span_index_end often just repeats the start index and would otherwise highlight one text item instead of the whole phrase; (3) index-only — highlight the whole anchor_span_index (…_end) text item(s), i.e. today's pre-rewrite behavior, for legacy notes the text search can't place; (4) null — the caller (highlightPdfPosition) shows an explicit .pdf-anchor-missing outline instead of highlighting nothing silently.

Highlighting behavior (what a user sees when opening a note)

  • MD/DOCX: the whole paragraph (.doc-block) gets a .note-target-block background tint, and the exact word/phrase gets wrapped in a real <mark class="note-highlight"> (via Range.surroundContents, wrapped in try/catch since it can throw if the range crosses element boundaries — falls back to paragraph-only tint in that case).
  • PDF: same idea but can't mutate the PDF content, so it's done with absolutely-positioned overlay <div class="pdf-highlight-box"> elements in a dedicated .pdf-highlight-layer (sits between the canvas and the text layer used for native selection; the text layer is opacity:1 with color:transparent spans — pdf.js's own approach — not the old opacity:0.2 trick, which also made the native selection color render at ~8% alpha and was a big part of why selection used to look broken). The .target box is painted from a real DOM Range (pdfRangeFromAnchorpaintRangeBoxesmergeRectsByLine, one bar per visual line) built from the resolved char offsets, so it covers exactly the anchored text instead of a whole pdf.js text item. .block class = paragraph tint, .hover = live hover preview (mousemove-driven, coalesced via requestAnimationFrame so it doesn't rebuild boxes on every pixel of movement). "Paragraph" for PDF is still a heuristic (computeParagraphs in viewer.js), but it now groups by each text item's baseline, computed once from item.transform in unscaled PDF units (buildItemGeom) rather than by reading live span.style.top — pdf.js's own text layer expresses that as a percentage string, and re-deriving pixel geometry per zoom level was both slower and the thing that made paragraph grouping quietly degrade at high zoom before this rewrite. Same-baseline items are also split into left-to-right runs wherever the horizontal gap is far wider than normal word spacing (splitLineByColumns, using each item's rendered width from item.width — captured in buildItemGeom as right) — without this, two-column PDFs put unrelated columns on the same baseline into the same "line", which is what made hover highlighting look like it picked an almost random block of text. It's still a heuristic, not real structure — expect occasional misgrouping on unusual layouts (tables, rotated text).
  • If no anchor resolves at all (e.g. a scanned PDF page with no text layer, or a note whose quoted text genuinely isn't on the page anymore), the page still scrolls into view and gets a dashed .pdf-anchor-missing outline for ~1.2s, and the popover shows a one-line hint — this used to be completely silent (nothing highlighted, no signal), which was indistinguishable from "the feature is broken."
  • Exactly one highlight is ever active (activeHighlight module var + clearHighlight()), cleared whenever a popover closes. activeHighlightPosition (the position object behind it) is also kept so PDF zoom (rerenderPdfPreservingPosition) can re-apply the same highlight after re-render — previously zoom just wiped it and left an open popover pointing at nothing.
  • Opening a note (openEditPopover) always resolves + scrolls the highlight first (scrollIntoViewerCenter, synchronous — not scrollIntoView({behavior:"smooth"}), whose async completion used to leave the popover positioned from pre-scroll coordinates) and then derives the popover's position from where the highlight actually landed (activeHighlight.anchorRect()), unless explicit {x, y} opts are passed (e.g. clicking a marker directly). This is what makes "open a note from the list" actually jump to and show its highlight — before, openEditPopover always passed {scroll:false} and relied on a marker element existing to scroll manually, and PDF markers were frequently off-screen (see below), so in practice opening a PDF note from the list highlighted nothing the user could see.

Export format (sync.py)

Deliberately terse — this was cut down twice already after user feedback that it was too verbose / burned context. Current shape:

{
  "document": "My Doc",
  "notes": [
    {"id": "...", "status": "pending", "note": "user's note text",
     "location": "Chapter 3 / 3.3 Chunking and pre-processing", "quote": "a few words around the spot"}
  ]
}

location = heading_path joined with " / " (full ancestor path, not just the nearest heading). quote = selected_text verbatim for selection notes, or a JS-computed "~4 words before + anchor + ~4 words after" snippet for point notes (computed at creation time in viewer.js, stored in position.quote). Do not add fields back to this export without a good reason — that's the whole point of the last two rounds of changes.

Two files get written to a document's source_folder (if set) on every note mutation: <slug>-<id>.notes.json (the above) and <slug>-<id>.notes_status.json ({id, status} pairs only — meant for an external AI agent to flip to "done" as it applies fixes). MarkAI reads the status file back via check_and_pull_status() — called before every note create/update (smart pre-mutation refresh), on manual Refresh click, and on a 5s poll (sync_poll.js) while a document is open. A manual one-off download (independent of source_folder) is available via the toolbar's "Export notes…" button, which opens a chooser (openExportDialog in viewer.js) rather than downloading straight away — the two shapes are not interchangeable and guessing was wrong half the time:

  • Notes only?format=notes, the <slug>.notes.json above, on its own.
  • Source-folder bundle?format=bundle, a zip of both files (notes + status), i.e. exactly what a linked source_folder would receive, so the agent round-trip works without linking one.

Both are built from the same build_detailed_export / build_status_export pair that export_notes() writes to disk, so a download can never drift from what the folder would contain.

Routes

Auth: GET/POST /register, GET/POST /login, POST /logout. Library: GET /library, POST /library/upload, PATCH|DELETE /documents/<id>. Viewer: GET /documents/<id>/view, GET /documents/<id>/content (parsed HTML+outline JSON for md/docx, raw bytes for pdf), GET /documents/<id>/sections ({sections: {section_key: status}}), POST /documents/<id>/sections/bulk ({updates: [{section_key, status}, ...]} — the client always uses this, even for a single click, since the outline's parent/child cascade can touch several sections at once; see below). Notes: GET|POST /documents/<id>/notes, PATCH|DELETE /notes/<id>, POST /documents/<id>/notes/bulk ({ids:[...], action: "done"|"pending"|"delete"}, one transaction

  • one export write), GET /documents/<id>/notes/export?format=notes|bundle (manual download), GET /documents/<id>/sync-status (poll). Settings: GET /settings (account + AI providers on one page), POST /settings/password (change password, current one required), POST /settings/reading (quote character limit), POST /settings/ai-providers, DELETE /settings/ai-providers/<id>, GET /settings/ai-providers → 302 to /settings (the page used to live there), POST /notes/<id>/resolve-ai → always 501, not implemented.

Frontend interaction model (viewer.js)

  • Click a paragraph/PDF text → point note anchored to the nearest word (caretPositionFromPoint
    • word-boundary search for both md/docx and pdf — caretAnchorInPdf tries the caret first, falling back to nearest-text-div-by-distance capped at 28 * pdfScale CSS px so a click in empty margin doesn't silently anchor to a far-away word).
  • Select text → selection note with the exact quoted text. For PDF, anchorFromSelection walks textDivs and asks the browser Range which ones it actually intersects (range.intersectsNode) rather than trusting range.startContainer/endContainer directly, since pdf.js's .endOfContent helper div (added for smoother drag-select) is frequently the reported end container. A selection that starts and ends on different pages is rejected with a toast (showToast) instead of silently creating a note anchored to a truncated, mismatched range — MarkAI doesn't support cross-page selections.
  • Hold Ctrl/Cmd while clicking → note-creation is suppressed entirely so links work normally. This is documented in the UI itself (toolbar hint), not just here. For links that point into the same document (href="#..." — a markdown TOC, or any sidebar outline entry) "normally" cannot mean the browser default: Ctrl-click would open a second copy of the whole viewer in a new tab, and a plain click would scroll the page out from under the note popover it just opened. So navigateToFragment handles them instead — Ctrl/Cmd+click jumps to the section inside #viewer-main, a plain click navigates nowhere and just creates its note. Links to anywhere else are untouched. The outline sidebar routes every click through the same helper (the a.href is kept only so the row still reads as a link and shows a target on hover).
  • Click elsewhere while a popover is open → closes it, does not open a new one at the new spot (first click always just dismisses; a second, separate click starts a new note). This is a deliberate guard at the top of the mouseup handlers — don't remove it, it was a reported bug.
  • Two independently collapsible side panels: left = chapter/section outline (a real collapsible tree, built from the flat {level, ...} list via buildOutlineTree + renderOutlineTree, shared between md/docx and pdf), right = notes grouped by status (Pending/Done) with a "Select" mode for bulk mark-done/mark-pending/delete. Every outline node with children starts collapsed and a "Collapse all" button (collapseAllOutline) re-closes the tree — on a real document the fully expanded tree is unusable (the thesis sample is 207 rows expanded vs 11 chapters closed).
  • PDF zoom is split into a synchronous layout pass and a lazy raster pass (see the ---------- Zoom ---------- block in viewer.js) — this is the whole reason zoom feels instant, and it's easy to accidentally undo. setPdfZoom does only cheap work inline: pdfLayoutPage on every page (wrap width/height, --scale-factor, and the canvas's CSS size — the bitmap is left alone and simply stretched by the browser), restoreScrollState, and repaintPdfHighlight. The expensive canvas re-rasterization is deferred to a debounced pass (schedulePdfRasterrefreshPdfRaster) that only touches pages within PDF_RASTER_MARGIN viewport-heights of the visible area, nearest first; everything else stays marked dirty (canvasScale !== pdfScale) until onPdfScroll brings it near the viewport. Before this, every zoom step re-rendered all pages sequentially before the toolbar unfroze, which on a long PDF meant seconds of lag per click. Same split for text layers: ensurePageTextScaled runs pdfjsLib.updateTextLayer(..., mustRescale:true) for near-viewport pages only, and placePdfMarker/highlightPdfPosition call it on demand for whatever page they're about to measure. Deferring it is safe because the text divs' left/top are percentages and their font sizes are calc(... * var(--scale-factor)), so they follow the new scale by themselves; updateTextLayer only re-derives the per-div scaleX correction. computeParagraphs' output stays valid across zoom without recomputation since it's derived from unscaled PDF units (see buildItemGeom).
    • rasterizePage renders into a detached canvas and swaps it in when done — writing to the live canvas would blank the page for the render's duration (assigning canvas.width clears the bitmap), which was the visible white flash of the old zoom.
    • It refuses to touch a page whose first render is still running (st.initialRender): cancelling that would abort renderPdfPage before it ever builds the text layer, leaving a page with no anchors. initPdf calls schedulePdfRaster(0) after the first pass so pages rendered at a scale the user has since left get caught up.
    • Markers are not re-placed on zoom (their top is a percentage of page height, so they track it for free); the one active highlight is repainted from activeHighlightPosition, and each page's hover state is reset via st.clearHover — without that, the mousemove handler's "same text item as last time" short-circuit would suppress the hover highlight until the pointer crossed into a different item.
    • Scroll position is preserved via a page-number + within-page-ratio snapshot (getScrollState/restoreScrollState), not just "scroll to top of page N".
  • Zoom UI (initZoomControls): - / + step to the next round multiple of 10%, the percentage itself is a button that opens a popover with a fine-grained slider (40–400%, live input — it can be live precisely because the layout pass is synchronous) plus Fit-width/50/100/150/200 presets, and Ctrl/Cmd + wheel over the document zooms. PDF_BASE_SCALE (1.2) is the scale the UI calls 100%; pdfScale is always the raw pdf.js scale, never the percentage.
  • Markers are positioned at the actual anchor's pixel location (via Range.getClientRects() for md/docx, and — since the native text-layer rewrite — a real Range built from the resolved char offsets via pdfRangeFromAnchor for pdf), with simple vertical collision avoidance (avoidMarkerCollision, a WeakMap<element, number[]> of used offsets) — not a fixed per-block stack-by-count offset like the first version had. PDF markers sit inside the page's own right margin (.pdf-page-wrap .note-marker { right: 8px }, top written as a percentage of page height so it tracks zoom) rather than at a fixed negative offset outside a width-constrained container — .doc-pane[data-doc-type="pdf"] has no max-width, PDF pages render at their natural size instead of being squeezed into the 760px md/docx column.
  • MarkAIApplyExternalNotes (called by sync_poll.js on every 5s poll) now does a full reconciliation against the incoming note list: notes that just showed up get a marker placed for the first time, notes that disappeared (deleted externally) get their marker removed. Previously it only toggled done/pending state on markers that already existed — a note created by an external agent while the document was open never got a marker until the page was reloaded.

Find in document ("---------- Find in document ----------" in viewer.js)

Ctrl/Cmd+F opens MarkAI's own find bar instead of the browser's native one (document.addEventListener("keydown", ...) in initSearch, e.preventDefault() unconditionally on that combo). This is a deliberate override, not just an addition — the browser's own find is the wrong tool on both doc types: for md/docx it has no notion of "current match" to hang a scroll/marker off; for PDF, once char_base:"span" anchors and note markers exist, having two independent, uncoordinated "highlight the text" mechanisms (native find's own highlight, MarkAI's overlay boxes) would be actively confusing, and the browser's find can't jump across the app's own scroll container the way scrollIntoViewerCenter does.

Both doc types reuse machinery that already existed for notes rather than inventing a second highlighting path:

  • md/docx: matches come from a plain per-block textContent.indexOf scan (searchBlocks), painted as real <mark class="search-hit"|"search-hit-current"> wraps via the same rangeAtOffset + Range.surroundContents technique highlightBlockPosition uses for notes. Closing search (or re-running it) unwraps every mark and calls parent.normalize(), which is what keeps repeated searches from fragmenting a block's text nodes into an ever-growing pile — verified by checking a searched-then-closed block's innerHTML comes back byte-identical to before, with a single text node.
  • pdf: matches come from buildPageFlatIndex (the same whitespace-normalized per-page flat index locatePdfAnchorByText already builds and caches on wrap.__mk.flatIndex for the note-anchor fallback), turned into {startIdx, startOff, endIdx, endOff} anchors the same way, then painted via the exact pdfRangeFromAnchorpaintRangeBoxes pipeline notes use, with search/search-current classes instead of target. Getting this for free is the payoff of that pipeline already handling matches spanning multiple text divs (a match can straddle a pdf.js item boundary same as a note anchor can).
  • Because initPdf's renderAllPdfPages() awaits every page's text layer before returning, the whole document is searchable the moment the toolbar is interactive — there's no "only the pages I've scrolled past so far" gap to design around.
  • Zoom survives for free, but only because it's wired in: repaintPdfHighlight (called on every setPdfZoom) already wipes and rebuilds each page's .pdf-highlight-layer for the active note highlight; it now also calls repaintSearchBoxes(), which just re-runs pdfRangeFromAnchor on the same (zoom-independent, character-offset) anchors. Forgetting that call is the easy way to reintroduce "zooming with search open silently clears every highlight."
  • A match count ("n/m" or "No results") plus Enter/Shift+Enter (wrap-around) and prev/next buttons live in a small fixed-position bar (#search-bar, .search-bar in app.css) rather than the toolbar flow, so it stays on screen while the match list is being stepped through mid-scroll. Input is debounced 150ms (searchDebounceTimer) — cheap per keystroke thanks to the cached flat index, but repainting hundreds of marks on every character on a long document isn't.

Known environment limitation applies here too: verifying PDF search visually needs a real browser tab for the same page.render()-never-resolves reason as the rest of the PDF pipeline (see below). The matching/anchoring/box-painting logic was verified in-sandbox by injecting a synthetic .pdf-page-wrap with hand-built wrap.__mk.textDivs (bypassing page.render()/buildPdfTextLayer entirely, since neither function is exercised by this feature) across three fake pages with known text, confirming per-page/cross-page match counts, wrap-around navigation, and that zoom repaints the boxes — but the on-screen result on a real rendered PDF hasn't been eyeballed.

Viewer page layout (body.viewer-page)

.viewer-shell used to be height: calc(100vh - 57px), a magic number that assumed the topbar + .viewer-toolbar together were 57px tall — they're closer to 100px, so the page itself ended up taller than the viewport and became the thing that scrolled, dragging the topbar and toolbar out of view along with the content (.viewer-main has its own overflow: auto and was never supposed to need page-level scroll to help it). Fixed by making body.viewer-page (the class is set via {% block body_class %} in viewer.html, templated in from base.html — no other page sets it, so this doesn't touch library/settings/login) a fixed-height (100vh) flex column with overflow: hidden, and .viewer-shell a flex: 1; min-height: 0 item — min-height: 0 is load-bearing, a flex item's default min-height: auto would otherwise refuse to shrink below its content size and the overflow would just move up one level. This is more robust than another magic number: it no longer matters how tall the topbar or toolbar actually are.

Outline navigation, notes search, review status, reading theme (0.4.0)

A cluster of features added together, all sharing one piece of state: outlineFlatEntries (the flat outline — data.outline for md/docx, pdfOutlineFlat for pdf, assigned in buildOutlineSidebar/ buildOutlineSidebarPdf) and outlineElById (outline entry id → its sidebar <li>, populated in renderOutlineTree). Anything that needs "where in the outline am I" or "which <li> is this" goes through these two instead of re-deriving them.

  • Locate current position (locateOutlinePosition, the outline panel's "Locate" button): currentOutlineId() picks the outline entry for wherever the user currently is — for pdf, the last pdfOutlineFlat entry whose page is <= the page getScrollState() reports; for md/docx, the last heading block ([data-heading-level]) whose top is at/above the viewer's top edge (a small scroll-spy). ancestorOutlineNodes then walks up .outline-node ancestors in the sidebar DOM (not the outline tree data) to un-collapse each one, and a CSS animation (.locate-flash/markai-locate-flash) flashes the target so it's findable at a glance.
  • Jump to page (initPageJumpControl, pdf only): a popover on the toolbar reusing the zoom popover's CSS classes (.zoom-control/.zoom-popover) for free styling consistency — it isn't about zoom, the classes are just generic enough. The label refreshes on scroll via getScrollState(), which scans every page's getBoundingClientRect()trailing-debounced with setTimeout (250ms), not requestAnimationFrame. An rAF version ran that scan once per scroll event, and setPdfZoom's restoreScrollState fires a scroll event on every zoom step (slider drag, Ctrl+wheel), so it was doubling the cost of the zoom pass itself and made zoom visibly laggier — don't reintroduce a per-frame version of this.
  • Chapter rail (buildChapterRail / renderChapterRailTicks, #chapter-rail — a sibling of .viewer-main in .viewer-shell, not a child of it, so it doesn't scroll with the content): a slim minimap with one tick per top-level chapter, positioned by scrollOffsetOf(el) / totalHeight (a fraction, computed from getBoundingClientRect() math rather than offsetTop, since docPane isn't necessarily the nearest positioned ancestor). Clicking near a chapter's tick jumps straight to it; clicking between two ticks maps proportionally onto that chapter's immediate (level-2) children for "precision" sub-scrolling — see handleChapterRailClick. Event listeners are bound once (chapterRailInteractionsBound guard in buildChapterRail) and ticks are rebuilt separately (renderChapterRailTicks, called again on a debounced resize) — rebuilding ticks clears only .chapter-rail-tick/.chapter-rail-thumb children, never the rail element itself, specifically so the listeners bound to the rail don't get silently re-stacked on every rebuild.
  • Notes panel: renderNoteList now groups by chapter (noteChapterLabelheading_path[0], falling back to "No chapter") instead of Pending/Done — each group is a collapsible header (collapsedNoteChapters, a Set of chapter labels, so collapse state survives a re-render) and each note shows its status as an inline badge (.note-status, a class that already existed in app.css but was unused until now) rather than being sorted into a separate section. Chapter order follows outlineFlatEntries where a label matches, so it reads top-to-bottom like the document. initNotesSearch filters by note text + anchor + chapter (noteMatchesSearch) client-side — notesSearchQuery is read inside renderNoteList itself, so every existing call site (renderNoteList(Object.values(notesById))) picks up filtering for free.
  • Ctrl+Enter to save: both popovers (openCreatePopover/openEditPopover) bind a keydown on their <textarea> that clicks the [data-action="save"] button, and show a .popover-kbd-hint under the textarea so the shortcut is discoverable, not just functional.
  • Quote length limit (Settings → Reading, users.quote_char_limit, 0 = unlimited): truncateQuote(text, limit) in viewer.js splits the character budget evenly between the first and last few words ("first words … last words") once a selection quote would exceed it. Only applied where a quote can be arbitrarily long — handleBlockSelection and handlePdfSelection — not to point-note quotes, which are already short via shortWords. cfg.quoteCharLimit is threaded through from g.user['quote_char_limit'] in viewer.html.
  • PDF paragraph-context note creation: handlePdfClick/handlePdfSelection used to build context_before/context_after from a fixed ±8-text-item window; they now call pdfParagraphContext, which uses the (now column-aware — see the PDF highlighting section above) computeParagraphs output to confine context to the actual paragraph the anchor sits in, falling back to the old fixed window when no paragraph was detected. This is what ties "hover highlights one paragraph, not a random block" into "the note's context is that same paragraph."
  • Reading theme (initReadingTheme): two independent, localStorage-persisted preferences — deliberately not pushed to the database, since this is a per-browser display preference, not document content. A day/night switch sets <html data-theme="light"|"dark">, and app.css was restructured so the dark palette is no longer only reachable via @media (prefers-color-scheme: dark) — it's now also an explicit :root[data-theme="dark"] block, with the media query itself scoped to :root:not([data-theme="light"]) so an explicit "light" choice can override a dark OS preference. A separate reading-filter <select> sets data-reading-mode="sepia" (currently the only option besides the default), independent of theme. PDF needs its own filter, unlike the rest of the app: the canvas is a rasterized white-background bitmap, so it can't just follow CSS variables like .doc-pane (md/docx) does — dark theme instead applies filter: invert(0.92) hue-rotate(180deg) contrast(0.95) to .pdf-page-wrap canvas (see app.css), which only touches the bitmap; the text layer stays transparent, so selection and highlight colors are unaffected.
  • Section review status (outline sidebar, section_status table — see Data model above): initSectionStatuses/cycleSectionStatus in viewer.js, GET/POST .../sections/bulk in documents.py. Each outline <li> gets a small .outline-review-mark button (added in renderOutlineTree, alongside the existing caret) that cycles not_reviewed → just_read → reviewed → … on click, stopPropagation-ed so it doesn't also trigger the row's navigation. The status is tracked server-side unconditionally; review mode (#review-mode-input, a slider styled like the theme switch, persisted in localStorage since it's a display preference, not content) only toggles whether it's shown — gated via #outline-panel.review-mode in app.css. Use the #outline-panel id selector, not a .outline-panel class — the element's class is sidebar-panel left; an earlier version of this CSS targeted .outline-panel, which never matched anything, so the toggle silently had no effect no matter its state. Clicking cycles the whole subtree together: cycleSectionStatus sets the clicked section, then every descendant (allDescendantIds, via outlineChildrenOf/outlineParentOf — built once per outline load by computeOutlineRelations, which just walks the same buildOutlineTree() output used for rendering), then climbs ancestors comparing each parent's computed status against what's currently stored there, stopping the first time they already agree. A parent's computed status is the weakest (lowest-ranked) status among its own direct childrenREVIEW_STATUS_CYCLE's order, not_reviewed < just_read < reviewed, doubles as the ranking — not "uniform or nothing" and not a one-way ratchet: all-just_read children make the parent just_read (0.4.1); one of them then advancing to reviewed leaves the parent at just_read, since that's still the weakest link, not cleared just because they disagree (0.4.4 — an earlier version did clear it there, treating any disagreement as "no status", which was wrong: a chapter shouldn't drop to looking totally unread just because one subsection got fully reviewed early); one of them instead regressing to not_reviewed does pull the parent all the way down, since not_reviewed is the floor of the ranking. All of it lands in one Map and is sent as a single POST .../sections/bulk call rather than one PUT per affected section.

Chapter rail drag/snap/expand, review-mode polish, branding (0.4.2)

  • Chapter rail now actually drags (onRailPointerDown/onRailPointerMove/onRailPointerUp in viewer.js). The first version only had a click listener, so nothing happened while the mouse button was held and the view "teleported" only on release. Now mousedown starts tracking, and mousemove/mouseup are bound on document (not the 14px-wide rail) so the drag keeps working once the pointer leaves the rail's narrow column. Two distinct behaviors share the same drag machinery, disambiguated by whether the pointer moved more than a few px between down and up:
    • A real drag live-snaps to the nearest chapter tick (nearestChapterBand, closest by startFrac, not "which range contains this point") on every mousemove, calling scrollIntoViewerTop only when the target actually changes — this is the "vero e proprio snap" that was missing before, not a continuous free-scroll.
    • A plain click (mousedown → mouseup with no real movement) instead calls toggleRailExpand(), which re-maps the whole rail height onto the current chapter's immediate subsections (renderExpandedRailTicks, N equal-height ticks, gets .chapter-rail.expanded for a tinted background) so each one is a big, easy-to-hit target — clicking again returns to the normal, whole-document view (renderChapterRailTicks). "Current chapter" is read from the live scroll position at click time (bandForFraction, the range-containment version — a different query from the nearest-tick one used while dragging) rather than from the click's Y coordinate; that's already correct without extra bookkeeping because onRailPointerDown always fires one snapRailTo step immediately, so by the time mouseup checks "what chapter am I in", the view has already snapped to wherever was clicked. updateRailDisplay() dispatches resize-driven re-renders to whichever of the two render functions matches the current mode, so expanded mode survives a window resize instead of silently reverting.
  • Review-mode toggle got a custom tooltip instead of a native title (attachHoverTooltip in viewer.js, .mk-tooltip in app.css) — a title attribute can't be restyled, and the plain browser tooltip looked out of place next to the app's own popovers. The label text next to the toggle ("Review mode") was removed per feedback; the icon that used to sit beside the track now sits inside the thumb itself (a small pencil/edit glyph, .review-mode-icon, always white since the thumb's own background is always var(--accent) regardless of checked state — only the track's background and the thumb's position change on toggle). aria-label="Review mode" on the <label> keeps it accessible without a visible label.
  • Review-mode row highlight uses a same-shape box-shadow offset left, not padding, to widen the tint without moving any content: box-shadow: -6px 0 0 0 var(--warn-soft|--success-soft) alongside background. The row (.outline-row) has no padding of its own, so the tint used to start exactly at the review-mark's left edge with no breathing room, while the right side already had room from the outline link's own padding — padding would have fixed it too but would shift every row's content horizontally when its status changes; the box-shadow trick doesn't touch layout at all. A small margin: 2px 0 on the same (tinted-only) rule (0.4.3) gives consecutive highlighted rows a visible gap — they used to sit flush against each other, since plain .outline-row has no vertical spacing of its own and this margin only exists while a row is actually tinted.
  • App icon (markai/static/icon-no-bg.png): used as the browser-tab favicon (<link rel="icon"> in base.html, so every page gets it) and inline next to the wordmark in the topbar (.brand-icon, 32px, border-radius: 8px — the source PNG is already a padded rounded-square app icon, not a bare glyph, so it needs no background of its own).
  • Smoother day/night switching: a blanket *, *::before, *::after { transition: background-color, color, border-color, box-shadow, fill (all 0.28s ease) } in app.css (CSS custom properties can't be animated directly, but the concrete properties that consume them can). A more specific selector's own transition declaration (e.g. .dropzone's hover fade) still wins over this for the elements it targets, since the whole shorthand is replaced rather than merged — nothing needed updating elsewhere for that reason. See the "Known environment limitation" section above: this is also why background/box-shadow/color checks stopped being reliable in the sandboxed preview pane.

Library upload (library.html)

The upload form is a single centred card whose file input is visually hidden inside a <label class="dropzone"> (so clicking anywhere in the zone opens the picker with no JS). Dropping a file anywhere in the window works too: dragenter/dragleave are counted with a depth counter, not toggled per event — they fire for every element the pointer crosses, so toggling directly makes the .drop-overlay flicker. A dropped file is validated by extension, assigned to the real <input type="file"> via DataTransfer, and then the ordinary multipart form is submitted — there is deliberately no separate fetch-based upload path to keep in sync with documents.upload.

The AI-resolve stub (markai/ai/)

The user wants an eventual in-app "Resolve with AI" feature (pick a provider — hosted API or local Ollama — and have MarkAI apply the fix directly, bypassing the export files). This is intentionally not implemented. ai_providers table + /settings/ai-providers CRUD exist so provider config can be saved now; ai/base.py has an AIProvider ABC and a resolve_with_provider() that always raises NotImplementedError. POST /notes/<id>/resolve-ai wires this up and returns 501. If you're asked to build this out for real, this is the intended extension point — don't bolt it on elsewhere.

Known environment limitation (not a code bug)

In the sandboxed browser tool used for manual testing (preview_start + the Browser pane), document.hidden is true (the pane isn't visually displayed to the user), which throttles Chrome's rendering pipeline for anything requiring a fresh paint/compositor frame — PDF canvas rendering (page.render()) hangs before it ever resolves, and CSS transitions freeze mid-value. Confirmed thoroughly (isolated repro scripts, getComputedStyle vs display:none sanity checks) — not a bug in the app. Discrete DOM/CSS changes (classList, display:none, structural changes) work fine and are verifiable this way; only continuous paint/transition-driven behavior, and — because buildPdfTextLayer awaits page.render() first (pdf.js needs the canvas render to have bound page fonts before measuring text) — the entire PDF text-layer/selection/highlighting pipeline, cannot be visually confirmed through this tool. Anything PDF-specific (native text-layer selection, char-precise highlighting, open-from-list scroll+highlight, zoom re-render, marker placement) needs a real, visible Chrome tab (.venv/Scripts/python run.py, then open http://localhost:5000 in an actual browser window — not the sandboxed preview) before it can be considered verified, no matter how carefully the code has been checked against pdf.js's source. The md/docx side of any shared code path (e.g. highlightPosition/openEditPopover/scrollIntoViewerCenter/positionPopover) can be verified in-sandbox, since it never touches a PDF canvas — do that first as a cheap sanity check, but don't let it stand in for the PDF-specific check.

The frozen-transition limitation isn't just PDF/paint-specific — it now also swallows getComputedStyle(...).backgroundColor/.boxShadow/.color/.borderColor checks anywhere in the app, ever since 0.4.2 added a global *, *::before, *::after { transition: background-color ..., box-shadow ..., ... } rule in app.css for the smooth day/night switch (see the reading-theme section below). Re-confirmed the hard way: a review-mode highlight rule appeared not to apply in-sandbox even with !important added to it, while border-radius set by that same rule (not a transitioned property) applied instantly — the CSS was correct all along, the transition to the new color just never got a compositor frame to advance on. When checking any color/shadow-based visual state in-sandbox, assert on classList/dataset instead of the computed color, or expect a false negative that isn't a real bug.

PDF verification checklist (run in a real visible tab, against the "Test PDF Doc" / "Tesi" sample documents — see below for the login):

  1. Console clean on load: no --scale-factor warning, no Deprecated API usage (textContent vs textContentSource) message.
  2. Drag-select a phrase mid-paragraph → the highlight lands exactly on the glyphs, no offset/drift; repeat at high zoom near the bottom of a page.
  3. Selected phrase spanning two lines → one highlight bar per visual line, not one per pdf.js text item.
  4. Click a word → popover context is right; click empty margin → no popover.
  5. Open a note from the right-hand list whose page isn't the current one → viewer jumps there, highlight is visible and centered, popover is fully on-screen.
  6. Zoom in/out with a note's popover open → highlight is still there and still correct afterward; rapid zoom clicks produce no console errors. Also: the page must resize immediately on each click/slider drag (blurry-then-sharp is the intended behaviour, frozen-then-jump is the regression), the slider must track the pointer while dragging on a long document, and pages scrolled to afterwards must sharpen instead of staying stretched.
  7. A pre-existing note (created before this rewrite, i.e. no char_base in its position_json) still highlights correctly.
  8. Drag a selection from one page into the next → rejected with a toast, no note created.
  9. Ctrl/Cmd+F opens MarkAI's search bar, not the browser's — the count ("n/m") and highlight boxes land on the actual glyphs across multiple pages; zoom while a search is active keeps every highlight box in place instead of clearing them.

Versioning and releases — READ THIS BEFORE YOU FINISH A TASK

master is the release branch (this repo's default branch is master, not main — the workflows are wired to that name). A merge into it publishes to PyPI whenever markai/__init__.py's __version__ is a number that isn't on PyPI yet. Nobody tags anything by hand; the version number is the release trigger. So the version bump is part of your job, not a chore for the user afterwards.

What you must do at the end of a change

  1. Decide whether the change is user-visible (anything that changes behaviour, UI, CLI, exports, the data model, or a dependency). Internal refactors, comments, tests, CI and docs alone are not.
  2. If it is, bump __version__ in markai/__init__.py in the same commit as the change:
    • patch (0.2.00.2.1) — bug fix, or a small UI/wording tweak.
    • minor (0.2.10.3.0) — a new feature, a new route/CLI flag, a changed default.
    • major — reserved for the user to decide. Ask; don't do it unprompted. Never reuse or lower a number that has already been published: PyPI releases are permanent, and the workflow will simply skip a version it finds already uploaded.
  3. Mention the new version in your summary to the user, so they know the merge will ship it.
  4. If the change is worth telling users about, update README.md too — that text is also the PyPI project page.

Two versions do not need touching: pyproject.toml reads __version__ via hatchling ([tool.hatch.version]), and there is no changelog file. One number, one place.

What the pipeline does with it

.github/workflows/publish.yml, on every push to master (and via workflow_dispatch):

  1. reads __version__ textually and asks PyPI whether that version exists;
  2. if it exists → stops, quietly, having published nothing (this is the normal outcome for merges that didn't bump anything);
  3. if it doesn't → builds sdist + wheel, runs twine check, installs the wheel and runs tests/smoke_test.py against it, uploads via PyPI Trusted Publishing (OIDC — no API token is stored anywhere), then creates the v<version> git tag and a GitHub release with generated notes. The tag comes after a successful upload on purpose: no tag can ever claim a version that isn't actually on PyPI.

.github/workflows/ci.yml runs the smoke test on Linux/macOS/Windows for pushes to master and PRs targeting it (deliberately not on every branch — feature branches stay quiet until there's a PR). It also posts a notice on each PR saying whether merging it will publish, and which version.

uvx markai needs nothing from us beyond a successful upload: it resolves markai from PyPI like any other package, so the moment the publish job is green the new version is what users get.

One-time setup on the PyPI side (the user does this once, not you): project markai → Publishing → GitHub publisher, owner follen99, repository MarkAI, workflow publish.yml, environment pypi.

Verifying before you hand back

tests/smoke_test.py deliberately imports markai rather than the source tree (it's run from tests/, so the installed package wins) — that's what makes it catch templates, CSS/JS or the vendored pdf.js being left out of the wheel, which is the packaging bug that actually happens. Run it against a real build, not just the checkout:

.venv/Scripts/python -m build
<a throwaway venv>/Scripts/python -m pip install --force-reinstall --no-deps dist/markai-<version>-py3-none-any.whl
<a throwaway venv>/Scripts/python tests/smoke_test.py

Add a check to that file for any user-visible feature you add. It is the only automated test in the repo and it is what CI and the release pipeline both run.

Things NOT done / deliberately deferred

  • No file-watcher (e.g. watchdog) — status-file sync is polling-based on purpose, to keep the dependency list minimal.
  • No email verification and no email-based password reset (explicit user requirement) — recovery is the local markai reset-password command, see above.
  • No CSRF protection, plaintext-stored api_key in ai_providers — explicitly acceptable per the user for now ("security not a priority yet"); revisit before any real deployment.
  • .gitignore excludes .venv/, .claude/, data/, __pycache__/, *.pyc.
  • Dev/test data currently sitting in data/app.db: a user tester@example.com / testpass123 with a few sample md/docx/pdf documents and notes used for manual verification during development. Fine to keep or wipe (rm data/app.db data/uploads -rf + restart to get a clean DB).

If you're picking this up fresh

  1. Read "Versioning and releases" above before you start writing code — if your change is user-visible it needs a __version__ bump in the same commit, and merging to master will publish it.
  2. git status and git log first — don't assume the working tree matches what was last committed.
  3. If the PDF verification checklist above hasn't been run in a real visible browser tab yet, that's the highest-value next step: it's the one part of the current code that's been reviewed and reasoned through carefully but never eyeballed.
  4. Skim viewer.js top to bottom once — it's the single file where most behavior lives, and the section comments above map roughly 1:1 to its function groups.
  5. Don't reintroduce verbose fields into the export (sync.py::build_detailed_export / _location_and_quote) without checking with the user — it's been trimmed twice already for being too token-heavy.
  6. Any new note "position" field needs: (a) set client-side in viewer.js's four position-builder call sites (handleBlockClick, handleBlockSelection, handlePdfClick, handlePdfSelection), (b) a fallback path in locateAnchorInBlock/locatePdfAnchor if it's used for highlighting/marker placement, (c) a decision on whether it belongs in the compact export.
  7. For PDF specifically: don't read .pdf-text-layer span from the DOM via querySelectorAll for anything anchor-related — always go through pdfTextDivs(wrap) / pdfPageState(wrap).textDivs, which is the array pdf.js itself populated (and can contain divs it never appended to the DOM, for zero-length text items — locatePdfAnchor/pdfRangeFromAnchor already handle that via connectedDivIndex, don't re-derive span lists another way).