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.
cd MarkAI
.venv/Scripts/python run.py # http://localhost:5000, debug=True, data in ./dataThat'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.
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.
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 whilecheck_password_hashstill matchesDEFAULT_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 throughmarkai reset-passwordor 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.
users(id, email, password_hash, created_at, quote_char_limit)— the last column was added via a guardedALTER TABLEindb._migrate()(see below), not theCREATE TABLEblock, since SQLite has noADD 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 onlysection_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_keyis an outline entry's ownid(the heading slug for md/docx, the syntheticpdf-p<page>-<n>id for pdf — seeflattenOutlineinviewer.js), so it's shared with no new concept. Absent row ==not_reviewed; the clientDELETEs 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.
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 bymarkdown_parser.pysplitting on blank lines — approximate but monotonic), pluschar_offset/char_length(exact offset within the rendered block's.textContent, used for pixel-precise marker placement and<mark>wrapping). - DOCX:
paragraph_index(index intodocument.paragraphsfrom python-docx) + samechar_offset/char_length. - PDF:
page+anchor_span_index/anchor_span_index_end(index into that page'stextDivslist, ingetTextContent()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 carrychar_base: "span"pluschar_offset/char_length(point) orchar_offset/char_offset_end(selection) — offsets are relative totextDivs[anchor_span_index].textContent, not a page-wide flat string, so they can be turned directly into a DOMRange(pdfRangeFromAnchor).char_baseis 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.js — always
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.
- MD/DOCX: the whole paragraph (
.doc-block) gets a.note-target-blockbackground tint, and the exact word/phrase gets wrapped in a real<mark class="note-highlight">(viaRange.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 isopacity:1withcolor:transparentspans — pdf.js's own approach — not the oldopacity:0.2trick, which also made the native selection color render at ~8% alpha and was a big part of why selection used to look broken). The.targetbox is painted from a real DOMRange(pdfRangeFromAnchor→paintRangeBoxes→mergeRectsByLine, 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..blockclass = paragraph tint,.hover= live hover preview (mousemove-driven, coalesced viarequestAnimationFrameso it doesn't rebuild boxes on every pixel of movement). "Paragraph" for PDF is still a heuristic (computeParagraphsinviewer.js), but it now groups by each text item's baseline, computed once fromitem.transformin unscaled PDF units (buildItemGeom) rather than by reading livespan.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 fromitem.width— captured inbuildItemGeomasright) — 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-missingoutline 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 (
activeHighlightmodule 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 — notscrollIntoView({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,openEditPopoveralways 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.
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.jsonabove, on its own. - Source-folder bundle →
?format=bundle, a zip of both files (notes + status), i.e. exactly what a linkedsource_folderwould 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.
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.
- Click a paragraph/PDF text → point note anchored to the nearest word (
caretPositionFromPoint- word-boundary search for both md/docx and pdf —
caretAnchorInPdftries the caret first, falling back to nearest-text-div-by-distance capped at28 * pdfScaleCSS px so a click in empty margin doesn't silently anchor to a far-away word).
- word-boundary search for both md/docx and pdf —
- Select text → selection note with the exact quoted text. For PDF,
anchorFromSelectionwalkstextDivsand asks the browserRangewhich ones it actually intersects (range.intersectsNode) rather than trustingrange.startContainer/endContainerdirectly, since pdf.js's.endOfContenthelper 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. SonavigateToFragmenthandles 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 (thea.hrefis 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
mouseuphandlers — 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 viabuildOutlineTree+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 startscollapsedand 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 inviewer.js) — this is the whole reason zoom feels instant, and it's easy to accidentally undo.setPdfZoomdoes only cheap work inline:pdfLayoutPageon 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, andrepaintPdfHighlight. The expensive canvas re-rasterization is deferred to a debounced pass (schedulePdfRaster→refreshPdfRaster) that only touches pages withinPDF_RASTER_MARGINviewport-heights of the visible area, nearest first; everything else stays marked dirty (canvasScale !== pdfScale) untilonPdfScrollbrings 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:ensurePageTextScaledrunspdfjsLib.updateTextLayer(..., mustRescale:true)for near-viewport pages only, andplacePdfMarker/highlightPdfPositioncall 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 arecalc(... * var(--scale-factor)), so they follow the new scale by themselves;updateTextLayeronly re-derives the per-divscaleXcorrection.computeParagraphs' output stays valid across zoom without recomputation since it's derived from unscaled PDF units (seebuildItemGeom).rasterizePagerenders into a detached canvas and swaps it in when done — writing to the live canvas would blank the page for the render's duration (assigningcanvas.widthclears 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 abortrenderPdfPagebefore it ever builds the text layer, leaving a page with no anchors.initPdfcallsschedulePdfRaster(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
topis a percentage of page height, so they track it for free); the one active highlight is repainted fromactiveHighlightPosition, and each page's hover state is reset viast.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%, liveinput— 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%;pdfScaleis 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 realRangebuilt from the resolved char offsets viapdfRangeFromAnchorfor pdf), with simple vertical collision avoidance (avoidMarkerCollision, aWeakMap<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 },topwritten 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 nomax-width, PDF pages render at their natural size instead of being squeezed into the 760px md/docx column. MarkAIApplyExternalNotes(called bysync_poll.json 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.
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.indexOfscan (searchBlocks), painted as real<mark class="search-hit"|"search-hit-current">wraps via the samerangeAtOffset+Range.surroundContentstechniquehighlightBlockPositionuses for notes. Closing search (or re-running it) unwraps every mark and callsparent.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'sinnerHTMLcomes back byte-identical to before, with a single text node. - pdf: matches come from
buildPageFlatIndex(the same whitespace-normalized per-page flat indexlocatePdfAnchorByTextalready builds and caches onwrap.__mk.flatIndexfor the note-anchor fallback), turned into{startIdx, startOff, endIdx, endOff}anchors the same way, then painted via the exactpdfRangeFromAnchor→paintRangeBoxespipeline notes use, withsearch/search-currentclasses instead oftarget. 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'srenderAllPdfPages()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 everysetPdfZoom) already wipes and rebuilds each page's.pdf-highlight-layerfor the active note highlight; it now also callsrepaintSearchBoxes(), which just re-runspdfRangeFromAnchoron 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-barinapp.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-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.
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 lastpdfOutlineFlatentry whose page is<=the pagegetScrollState()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).ancestorOutlineNodesthen walks up.outline-nodeancestors 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 viagetScrollState(), which scans every page'sgetBoundingClientRect()— trailing-debounced withsetTimeout(250ms), notrequestAnimationFrame. An rAF version ran that scan once per scroll event, andsetPdfZoom'srestoreScrollStatefires 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-mainin.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 byscrollOffsetOf(el) / totalHeight(a fraction, computed fromgetBoundingClientRect()math rather thanoffsetTop, sincedocPaneisn'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 — seehandleChapterRailClick. Event listeners are bound once (chapterRailInteractionsBoundguard inbuildChapterRail) and ticks are rebuilt separately (renderChapterRailTicks, called again on a debouncedresize) — rebuilding ticks clears only.chapter-rail-tick/.chapter-rail-thumbchildren, never the rail element itself, specifically so the listeners bound to the rail don't get silently re-stacked on every rebuild. - Notes panel:
renderNoteListnow groups by chapter (noteChapterLabel→heading_path[0], falling back to "No chapter") instead of Pending/Done — each group is a collapsible header (collapsedNoteChapters, aSetof 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 inapp.cssbut was unused until now) rather than being sorted into a separate section. Chapter order followsoutlineFlatEntrieswhere a label matches, so it reads top-to-bottom like the document.initNotesSearchfilters by note text + anchor + chapter (noteMatchesSearch) client-side —notesSearchQueryis read insiderenderNoteListitself, so every existing call site (renderNoteList(Object.values(notesById))) picks up filtering for free. - Ctrl+Enter to save: both popovers (
openCreatePopover/openEditPopover) bind akeydownon their<textarea>that clicks the[data-action="save"]button, and show a.popover-kbd-hintunder the textarea so the shortcut is discoverable, not just functional. - Quote length limit (Settings → Reading,
users.quote_char_limit, 0 = unlimited):truncateQuote(text, limit)inviewer.jssplits 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 —handleBlockSelectionandhandlePdfSelection— not to point-note quotes, which are already short viashortWords.cfg.quoteCharLimitis threaded through fromg.user['quote_char_limit']inviewer.html. - PDF paragraph-context note creation:
handlePdfClick/handlePdfSelectionused to buildcontext_before/context_afterfrom a fixed ±8-text-item window; they now callpdfParagraphContext, which uses the (now column-aware — see the PDF highlighting section above)computeParagraphsoutput 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">, andapp.csswas 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>setsdata-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 appliesfilter: invert(0.92) hue-rotate(180deg) contrast(0.95)to.pdf-page-wrap canvas(seeapp.css), which only touches the bitmap; the text layer stays transparent, so selection and highlight colors are unaffected. - Section review status (outline sidebar,
section_statustable — see Data model above):initSectionStatuses/cycleSectionStatusinviewer.js,GET/POST .../sections/bulkindocuments.py. Each outline<li>gets a small.outline-review-markbutton (added inrenderOutlineTree, alongside the existing caret) that cyclesnot_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 inlocalStoragesince it's a display preference, not content) only toggles whether it's shown — gated via#outline-panel.review-modeinapp.css. Use the#outline-panelid selector, not a.outline-panelclass — the element's class issidebar-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:cycleSectionStatussets the clicked section, then every descendant (allDescendantIds, viaoutlineChildrenOf/outlineParentOf— built once per outline load bycomputeOutlineRelations, which just walks the samebuildOutlineTree()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 children —REVIEW_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 oneMapand is sent as a singlePOST .../sections/bulkcall rather than onePUTper affected section.
- Chapter rail now actually drags (
onRailPointerDown/onRailPointerMove/onRailPointerUpinviewer.js). The first version only had aclicklistener, so nothing happened while the mouse button was held and the view "teleported" only on release. Nowmousedownstarts tracking, andmousemove/mouseupare bound ondocument(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 bystartFrac, not "which range contains this point") on everymousemove, callingscrollIntoViewerToponly 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.expandedfor 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 becauseonRailPointerDownalways fires onesnapRailTostep immediately, so by the timemouseupchecks "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.
- A real drag live-snaps to the nearest chapter tick (
- Review-mode toggle got a custom tooltip instead of a native
title(attachHoverTooltipinviewer.js,.mk-tooltipinapp.css) — atitleattribute 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 alwaysvar(--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-shadowoffset left, not padding, to widen the tint without moving any content:box-shadow: -6px 0 0 0 var(--warn-soft|--success-soft)alongsidebackground. 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 smallmargin: 2px 0on 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-rowhas 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">inbase.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) }inapp.css(CSS custom properties can't be animated directly, but the concrete properties that consume them can). A more specific selector's owntransitiondeclaration (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.
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 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.
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):
- Console clean on load: no
--scale-factorwarning, noDeprecated API usage(textContent vs textContentSource) message. - 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.
- Selected phrase spanning two lines → one highlight bar per visual line, not one per pdf.js text item.
- Click a word → popover context is right; click empty margin → no popover.
- 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.
- 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.
- A pre-existing note (created before this rewrite, i.e. no
char_basein itsposition_json) still highlights correctly. - Drag a selection from one page into the next → rejected with a toast, no note created.
- 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.
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.
- 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.
- If it is, bump
__version__inmarkai/__init__.pyin the same commit as the change:- patch (
0.2.0→0.2.1) — bug fix, or a small UI/wording tweak. - minor (
0.2.1→0.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.
- patch (
- Mention the new version in your summary to the user, so they know the merge will ship it.
- If the change is worth telling users about, update
README.mdtoo — 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.
.github/workflows/publish.yml, on every push to master (and via workflow_dispatch):
- reads
__version__textually and asks PyPI whether that version exists; - if it exists → stops, quietly, having published nothing (this is the normal outcome for merges that didn't bump anything);
- if it doesn't → builds sdist + wheel, runs
twine check, installs the wheel and runstests/smoke_test.pyagainst it, uploads via PyPI Trusted Publishing (OIDC — no API token is stored anywhere), then creates thev<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.
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.pyAdd 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.
- 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-passwordcommand, see above. - No CSRF protection, plaintext-stored
api_keyinai_providers— explicitly acceptable per the user for now ("security not a priority yet"); revisit before any real deployment. .gitignoreexcludes.venv/,.claude/,data/,__pycache__/,*.pyc.- Dev/test data currently sitting in
data/app.db: a usertester@example.com/testpass123with 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).
- 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 tomasterwill publish it. git statusandgit logfirst — don't assume the working tree matches what was last committed.- 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.
- Skim
viewer.jstop 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. - 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. - 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 inlocateAnchorInBlock/locatePdfAnchorif it's used for highlighting/marker placement, (c) a decision on whether it belongs in the compact export. - For PDF specifically: don't read
.pdf-text-layer spanfrom the DOM viaquerySelectorAllfor anything anchor-related — always go throughpdfTextDivs(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/pdfRangeFromAnchoralready handle that viaconnectedDivIndex, don't re-derive span lists another way).