Moved here from the top-level README on 2026-10-11, word for word; the README is now the project's front page. This is the working log of how Daf runs on the server.
Daf¶
A personal Readwise for one reader's own stack. Goal: an hour of reading a day in Year 30 (2026-10-09 to 2027-10-08). The plan for the whole app is in PLAN.md.
Open it: https://daf.tiresia.xyz (the cloudflared tunnel on the server → localhost:8440), or
http://
Sign-in: username + password (auth.json; a new install's username is reader until changed). Change
both in Preferences → Account or with daf set-password [--user NAME]; a new password signs out
every other browser. Five wrong tries from one address lock it for 15 minutes, twenty from anywhere
in an hour pause sign-in for 15 minutes, and Ezer says so on Signal (at most every 6 hours). Behind
the tunnel the client address comes from CF-Connecting-IP (trusted only from loopback) and cookies
are Secure. Devices (Kindle, phone, AntennaPod, /api/save) use their own tokens. Links in
messages use settings public_url (now https://daf.tiresia.xyz). The Kindle keeps the tailnet URL.
What runs where¶
| Code | /srv/dev/daf (Python package daf/, web app web/, Kindle plugin kindle/) |
| Service | systemd user unit daf.service (copy in systemd/): web app + API on :8440, sync every 5 min |
| State | /srv/docker/daf: daf.db (index, rebuildable), auth.json, settings.json, kindle-token |
| Vault | Daf writes Reference/reading/Reading Log YYYY.md (paper minutes), in book notes only the region between <!-- daf --> and <!-- daf-end --> (notes added in Daf, voice and text reading notes), and a note per highlighted article, video, podcast, tweet, PDF, reel or Torah reading in Reference/clippings, youtube, podcasts, instagram and torah, with the full text inside its region (see "Vault notes with the full text" below). Edits made in Obsidian inside a region win. |
| Logs | journalctl --user -u daf -f |
After changing code: systemctl --user restart daf. The web app has no build step.
Command line (bin/daf)¶
daf doctor every source, and how fresh it is
daf sync pull every source now
daf today [--json] [--date D] today's minutes, by source and sitting
daf year [30|29] the year so far
daf log-paper 40 [--shabbat | --yesterday | --date D] [--note TEXT]
daf set-password change the web password
daf kindle-token NAME new device token (printed once)
daf review [--json] today's highlights
daf morning [--send] the morning highlight (a cron job sends it)
daf note "n: text" file a note under the morning highlight or the current Kindle book
daf voice-note FILE transcribe (faster-whisper) and file the same way
daf nightstand [ID ...] show or set the three nightstand books
daf paper-page PHOTO [--caption "page 112 middlemarch"] a marked paper page -> highlights
daf clip "TEXT" a podcast link with a time: transcribe the minute before it
daf jobs transcripts and clips, waiting or done
daf vault-backfill [--dry-run DIR] rewrite Daf's article notes the current way (DIR: copies there, the vault untouched)
Ezer (.claude/scripts/signal-bot.py, rules ahead of the router, no model call):
- read 40 paper, paper 40, shabbat 60: paper minutes.
- n: … / note: …: under this morning's highlight (if sent in the last 20 h and not yet noted),
otherwise a reading note on the book last read on the Kindle, at that page.
- A voice note with no text: transcribed and filed under the book read on the Kindle in the
last 3 hours (or if it starts with "note"); otherwise the transcript comes back unfiled.
- A photo captioned page, p: 112 or page 112 middlemarch: daf paper-page (below).
- A link with a time that isn't YouTube or X (#t=750, AntennaPod's "Starting position: 00:12:30",
or clip <link> 12:30): daf clip, the minute before that point, transcribed.
Highlights and review (Phase 2)¶
- Books (
library.py): one row per work. Kindle copies (by KOReader md5), the Audiobookshelf item and the vault note are matched to the Calibre id by normalized title, so two copies of one novel are one book. - Highlights (
sources/highlights.py): KOReader sidecars synced to/srv/koreader/Libros(parsed bysources/lua.py), KoInsight's annotation table as fallback, Karakeep highlights, and Audiobookshelf bookmarks whose title is a sentence. Ids are stable hashes of book + text. - Review (
review.py): one fixed set per day (5 by default): due first, then new ones weighted toward the nightstand and recent highlights; at most 2 per book; private books never. Keep: 4 days, then x2.5. Less: 120+ days. - Private books: titles matching
private_titles(defaults inconfig.py, more insettings.json, which is not in git) still count toward the hour but show as "Private reading" and never appear in review, the morning message, the nightstand or the Kindle.
The reader (Phase 4)¶
- Feed = FreshRSS over its Google Reader API (
sources/freshrss.py, credentials read in place bysecrets.py: the homepage's FreshRSS API password). Newest unread items back to the expiry window (30 days by default, Preferences up to a year or never) becomedocswith cleaned text (content.py). Seen in Daf = read in FreshRSS; read elsewhere = seen here on the next sync. Older unread items count as seen in Daf; a button (Feed → Seen) marks them read in FreshRSS too, only when pressed. - Library = Karakeep's REST API (
sources/karakeep.py): list, archived text, save, archive. "Later" is Daf's own status. Links shared to Daf from the phone go to Karakeep on the next sync. - Reading view (
web/reader.js): highlight by selection orh, notes (n), Ask (Claude CLI,secrets.claude_model()), read-aloud through Kokoro (/api/tts, cached in/srv/docker/daf/tts), progress, Aa (theme, typeface, size, width),[]panels, ⌘K search. Highlights write a clipping note inReference/clippingswith Daf's region (the full text since 2026-10-25, below). - Reading time: the open reading view sends a 15-second beat while you're active (or while
read-aloud plays). Each article is one session per day; finished days go into the vault reading log.
Beats made offline wait in localStorage (
daf.beatq) and are sent with their own time (at). - Offline (
web/sw.js): network first, the cached copy when the network fails, for the shell,/api/me, Today, Review, Books, Highlights, Torah, lists and single docs. Opening the Library prefetches the text of up to 30 Inbox/Later articles and tweets (?prefetch=1doesn't mark them opened). Service workers only run on a secure origin, so this works once Daf has its HTTPS address (or onlocalhost); onhttp://<server>:8440the page simply needs the network.
Watch and listen (Phase 5)¶
- Jobs (
media.py): a queue injobs, worked by one thread in the service (restarts requeue anything left running).daf jobslists them. - Videos (Karakeep video bookmarks): captions with yt-dlp first, faster-whisper when there are none
(
tools/whisper_segments.py, run with Daf's own Python; yt-dlp and faster-whisper are in its .venv from requirements.txt since the October 2026 OS upgrade to Python 3.14 left the host's copies behind). The transcript is cue blocks with timestamps, speakers and chapters. The player is YouTube's privacy-mode embed (youtube-nocookie.com); Invidious couldn't stream (its companion answers 500), so it's only a link. Click a line to seek,cor Clip 30s highlights the last 30 seconds, Follow along scrolls with it. Video time counts only if Preferences says so (off by default). - Podcasts: Daf speaks the part of the gpodder.net v2 API AntennaPod uses (
gpodder.py; login sets adaf_gpcookie, later calls use it). Password = Preferences → Podcasts → Make a password. Play actions becomepodcastsessions (shown, never counted). Feeds of subscriptions are read once a day for episode names. A share "with position" sent to Ezer becomes acliphighlight, written toReference/podcasts. Clipping a minute takes about 30 seconds. - From Nextcloud's GPodder Sync:
daf podcasts-importreads its two tables (through psql in the AIO database container, never writing) into Daf's: subscriptions, unsubscriptions and each episode's last play. Run again, it adds only what's new. Done once on 2026-10-11 (22 podcasts, 74 episodes); then AntennaPod's sync moves from Nextcloud to Daf. - Audiobookshelf bookmarks: the minute before each bookmark is transcribed (ffmpeg over the audiobook's files) into a highlight shown as "Audiobook, h:mm:ss" in the book note.
Paper and Torah (Phase 7)¶
- Paper pages (
paper_pages.py, web#paper): a photo of a marked page, from Ezer, the share target (Android: Share → Daf) or the Paper pages view. Claude (CLI, only the Read tool,--add-dir/srv/docker/daf/pages) transcribes it line by line in English or Hebrew, flags marked lines, and copies margin handwriting. Marked lines are kept at once; each run of adjacent lines is onepaperhighlight with the page, and a margin note beside it becomes its note. The book comes from the caption, then the running head, then the last paper page in the past two weeks; otherwise Daf asks. A paper book with no vault note gets a minimal one inReference/books(type: book, format: paper). Photos stay in/srv/docker/daf/pages. About 15 seconds a page. - Torah shelf (
torah.py, web#torah): Sefaria's calendar (diaspora, home time zone) gives the week's parasha with aliyot and the haftarah; each becomes a doc (where_ = 'torah') of verse blocks, Hebrew (nikud, no cantillation) beside the JPS English. Both / עברית / English toggle in the reader. The Ruach FreshRSS folder supplies the week's divrei Torah, those naming the parasha first. Highlights go toReference/torah, each with its verse. - Lookup (
lookup.py): select one word → Define (Hebrew: Sefaria's BDB, Klein, Jastrow; English: Wiktionary; cached in kv). "In this sentence" and Translate (a Hebrew phrase) ask Claude, ~10 s.
Books in Daf (the EPUB reader)¶
- Engine: foliate-js (MIT, the core of the Foliate e-reader), vendored as-is in
web/vendor/foliate-jsat commit 78914ae;web/book.jsis Daf's reader around it, inside the same reading view as articles (contents and search left; Info / Notebook / Ask right; Aa menu shared). - Files: every Calibre book (
library.rebuildnow includes all of them) with EPUB, KEPUB, AZW3, MOBI, FB2 or CBZ; served fromcwa-libraryby/api/ebooks/{id}/file. Books → Your library lists them; a book page has "Read in Daf". - Reading: Scroll or Pages (two columns on a wide screen without the panel), themes Auto / Light / Sepia / Dark, Source Serif, Literata, Frank Ruhl Libre (Hebrew), Atkinson Hyperlegible, Figtree or the book's own font, size / line spacing / width, justify. Footnotes open in a popover by the link. The footer has the chapter, minutes left in it and in the book, and the percent (tap it to jump). Keys: ← → (space in Pages), t contents, / search, l listen, h/n highlight/note a selection, b bookmark, ] panel, ? the list. The footer also shows the printed page when the EPUB has a page list. The screen stays awake while a book is open.
- Position: an EPUB CFI per book in
book_positions(and localStorage), so phone and desktop agree; the newest wins on open ("Picked up at 45%"). If the Kindle is further along (KOReader page turns), Daf offers to go there by percent. A newer Daf position also wins on the Books page. - Highlights: source
epub, anchored by CFI, into review and the book note's Daf region ("Read in Daf · chapter"). The Notebook lists every highlight in the book (Daf's, the Kindle's, audiobook and paper ones) in reading order under chapter headings; a click goes to it. - Bookmarks (
book_marks): b or the ribbon in the top bar; listed at the top of Contents. - Listen: Kokoro reads from the top of the screen a paragraph at a time (
POST /api/tts/text), highlights it, follows it across pages and chapters; speed, sleep timer (15–60 min). Counts as reading. - Time: beats every 15 s while active (2-minute idle cutoff) →
ebooksessions, "Books in Daf", counted by default, written to the vault reading log like article minutes. - Series: Info shows the Calibre series and the next book; near the end Daf offers to start it.
- Kindle highlights in the book: placed by KOReader's xpointer (
DocFragment[n]is the section, the element path narrows it), then matched on their words (first 8 and last 6, whitespace and quotes squashed), falling back to the whole section and then a search; drawn like Daf's own, with "Kindle" in the toolbar. - Daf → the Kindle (the Daf plugin, not kosync, which stays on Komga): when a book opens in KOReader the
plugin asks
GET /api/kindle/book?md5=and (1) if Daf's position is newer than the Kindle's last page turn in that book and more than 1% away, asks "You read further in Daf… Go there?" (GotoPercent; "Stay here" is remembered for that position); (2) adds Daf highlights (sourceepub) as KOReader annotations, found withfindAllTexton the first 10 and last 7 words (findTextfrom the start finds nothing on 2025.04), with the Daf note; each is acknowledged (kindle_pushed) and also remembered on the device. When those come back in the sidecar, the importer skips them as duplicates of the Daf highlight. Tools → Daf → "Get my place and highlights from Daf" does it by hand. Tested in the emulator. - Colors: yellow, green, blue, pink, purple (KOReader on the Kindle draws them all gray).
Highlights, everywhere (web/hl.js, daf/jump.py)¶
- The look: Readwise's: a soft fill with a firmer underline (
--hl-<color>and--hl-<color>-line, for light, dark and sepia). In books,HL.drawdraws on foliate's overlay from the text boxes only (an empty paragraph inside a highlight draws nothing), blended multiply in light themes and screen in dark. A dot at the end means a note. Articles use<mark class="hl">with the same tokens. - The toolbar (
#selbar, a pill): over a selection, Highlight / Note / Define or Translate / Ask / Copy; over a highlight, remove (the colored circle; Undo in the toast), note, tags, the five colors, and … (copy, ask, share as image, view in notebook, favorite, auto highlight). The reader in front supplies what only it can do (HL.active). - Auto highlight (per device, on with a mouse, off on touch): selecting more than one word highlights
it at once; Alt (⌥) selects without. Selections grow to whole words. A highlight over existing ones
merges with them (
replaces), carrying notes, tags, favorite and review history (review.carry). Article highlights can span paragraphs (pos.be, the end block). Karakeep's highlights, which come without a position, are found in the text by their words. - Open at the exact spot: every highlight row carries
open(jump.py):#ebook/<book>/hl:<id>when Calibre has a file (Daf's by CFI, the Kindle's by xpointer, audiobook and paper ones by searching their words),#read/<doc>/hl:<id>, or#book/<id>/hl:<id>. The spot flashes. Used by the Highlights page, book pages, the Notebook, review (o), On this day. - Highlights page: search (text, notes, tags, titles), source chips, color, tag, favorites, order
(newest, by book in reading order, oldest), export of what's showing as Markdown, CSV or JSON
(
/api/highlights/export). Book pages list highlights in reading order by chapter, with Export. - Tags and favorites live in
review_state(tagsJSON,fav). Tags go into the vault note as#tagson the quote's last line; a Kindle highlight with tags gets an entry there. Favorites come up twice as often in review. - Share as image: a 1080×1350 card (the quote beside its color, title, author), with Share, Copy and Download.
Home, stats, the … menu¶
- Library home (
home.py,#library): shelves of cards: Continue reading (books + articles in progress), Recently added, Quick reads (≤10 min), Long reads (≥30 min), Recently highlighted, New in the Feed. Configure hides shelves (localStoragedaf.homeHidden). - Stats (
stats.py,#stats): streaks (days at the hour, days reading, best), two weeks of dots, totals and the Year 30 projection at the last 30 days' pace, minutes by hour and weekday (90 days), sources, highlights per week, books waiting (2–97%, untouched 14+ days), reading now, finished, and "On this day". Today shows the streak and On this day; the Kindle screen shows the streak. - Summaries and tags: Library documents with text get a two- or three-sentence summary by Claude
(
reader.summarize, jobsummary, six per sync, never private ones), shown in Info ("Summarized by Claude") and as the list snippet; Feed items have a "Summarize with Claude" button. Karakeep's tags (mostly its AI's) are kept indocs.tags, shown in Info, and the Library list filters by one. - Inline tags: ".shame" or ".big-idea" in a note tags the highlight, as in Readwise: a Daf note when you
finish typing it (focus leaves, or Save in review); a Kindle note when it's new or changed on import
(
review.dot_tags,add_tags, never removing). - Add to Daf (the + by Library, a file dropped anywhere, or a link pasted outside a text field): links go
to Karakeep at once (
/api/add/link); EPUB, MOBI, AZW3, FB2 and CBZ to Calibre-Web-Automated's ingest folder/srv/docker/cwa-ingest(DAF_CWA_INGEST), then Books; PDFs to Karakeep as assets (/api/add/file). - Library and Feed lists: order by newest, oldest, published, shortest, longest or started first (per list, on this device). The Shortlist (★, f) is Karakeep's favourites, two-way, until archived. In an article, ↑ ↓ in the top bar (Shift K / Shift J) open the previous or next item of the list it came from; Preferences → Reader → "After Done or Later, open the next one" (per device).
- Never again: in review (x) or a highlight's …, a highlight leaves review for good (status
never); "Back into review" undoes it. Follow in the Feed: Add to Daf can subscribe FreshRSS to a site, in a folder. - Reading settings for one book: Aa → "Just for this book" keeps that book's typeface, size, spacing,
width, layout and justification (localStorage
bookType.<id>); the theme stays the app's. - Hardcover (
hardcover.py,web/hardcover.js; key HARDCOVER_API_KEY from the vault's.claude/.env): the vault's own two-way sync (.claude/scripts/hardcover-sync.py, every 30 min) owns the book notes' status, rating, review and progress, so Daf never writes those keys; it talks to Hardcover, and that sync pulls the change into the note. Daf sends progress from its own reader (forward only, never to a finished book; every 30 min in the sync loop) and moves a Want-to-read book it sees you start to Reading; marks a book Read with a date and an optional rating when you say so (book page, the end of a book in Daf, or Today when the Kindle reaches 98%+). Books match by the vault note'shardcoverBookId(231 of them) or a link made on the book page (hc_links, search by Calibre ISBN, then title). Books shows the Want to read shelf. Hardcover marks a book Read, dated today, the moment it's rated, so Daf only rates Read books, and re-reads the book after a status change instead of adding a second read. - Flashcards (Readwise's Mastery;
cards.py, tablecards): from a highlight's … (toolbar, row, or the review card's Flashcard), a fill-in-the-blank (tap the words to hide; stored as{{…}}in the text) or a question and answer; "Suggest with Claude" proposes both. Due cards follow the day's highlights in Review: space shows the answer, 1 Again (once more today), 2 Hard, 3 Good, 4 Easy; new cards come back in a day (Easy: four), then the interval grows by the card's ease, Anki-style. Up tocards_per_day(5) new cards a day. - Newsletters (
sources/newsletters.py): Daf reads the Proton mailbox through Proton Mail Bridge (IMAP 127.0.0.1:1143, the office agent's PROTON_* keys from/srv/office/.env), read-only (BODY.PEEK, readonly select of "All Mail"), and finds senders with List-Unsubscribe / List-Id. Preferences → Newsletters lists them (likely newsletters first, shops folded away, private ones never) and the ticked ones (settings.newsletters) come in as Library documents of kindemail(idsnl:<hash of Message-ID>, last 30 days, every 15 min), cleaned of newsletter chrome (preheader padding, app buttons, icons, footer), with the web version as the original. Reading state is Daf's. Bridge puts the UID after the literal in FETCH replies. - Themed review:
#review/tag/<tag>,#review/fav,#review/color/<c>(/api/review/themed): up to ten of those highlights, shuffled, with the same keep / less / note; linked from the Highlights page. - Article … menu: document note, read aloud, ask, move, reset progress, edit title and author
(
doc_edits, laid over Karakeep's and FreshRSS's after every sync), open/copy the original, open the vault note, keyboard shortcuts (m, ?, o, Shift L/N/R/C/M).
Goals, books, search, and sending to the Kindle (2026-10-09, late)¶
- Goals (
goals.py,web/goals.js, Preferences → Goals,#prefs/goals): - The goals: one per kind, each on or off: reading time (a day, week or month); pages (a day or week); books (in Year N, a calendar year, or by 35); days with reading a week; a streak at the goal; highlights a week; and days with the daily review a week. Presets: Gentle, Year 30, Deep.
- History: each goal keeps a dated
historyin settings.json. A change counts from today, so earlier days, streaks and the year grids are judged by the goal they had (T.gridreturns each day'stargets). - The main goal drives the ring on Today.
/api/todayand/api/phone/widgetaddmainandgoals[];/api/kindle/*addsmain.goalstays the day's minutes target, because the Kindle adds its own minutes to it. - Pages:
- Kindle page turns of 5 s or more, each page once a day;
ebook_pagesfrom Daf's reader (pages shown for 5 s);- paper pages logged as
· 32 pages ·in the reading-log line (sessions.pages).
- Books: Hardcover read dates plus Daf's ≥97%. Manga volumes don't count unless the books goal's switch says so.
- Previews and milestones: previews use your own pace (
goals.pace). Year 30 milestones get a small celebration once per device. - A book's page: reading stats from
bookstats.py: - time across the Kindle, Daf and the audiobook;
- sittings;
- Kindle pages an hour;
- the last 30 days;
- time left and a finish date at the recent pace.
"Review these" reviews that book's highlights (#review/book/<id>). How often the book comes
up in the daily review is More / As usual / Less / Never (book_review; Less is ×0.35, More
×2.5, Never leaves it out).
- Search inside saved text: FTS5 over doc_content, kept up to date by triggers and filled
once (docs_fts). ⌘K shows the title and a snippet; opening a result goes to
#read/<id>/q:<words>, which scrolls to the first match and marks it.
- Several at once: in Library and Feed lists:
- Cmd/Ctrl-click or the round checkbox picks a row, Shift-click picks a range, and x picks the
current row. On a phone, Select turns tapping into picking.
- The bar at the bottom archives, moves, marks seen, shortlists, saves or sends to the Kindle
(POST /api/docs/bulk).
- Send to Kindle (kindlesend.py): from an article's … menu or the bulk bar.
- Daf makes one EPUB 3 of the articles, right-to-left for Hebrew; several articles become a digest.
- It goes to /srv/koreader/Libros/Daf (DAF_KINDLE_DIR), which Syncthing hands to KOReader.
A sandbox must set DAF_KINDLE_DIR to a scratch folder.
- Back from the Kindle: each EPUB is recorded by KOReader's partial md5 (table kindle_epubs,
refilled from the files by kindlesend.scan, since every chapter carries <meta name="daf-doc">).
- Highlights made in it belong to their articles: chapter n of the xpointer is the nth article, and
the words place them in the article's text. They open at their spot, go to the article's clipping
note with any note typed on the Kindle, and the daily review shows them under the article.
- Deleting the EPUB on the Kindle (its sidecar goes too) keeps the highlights. Deleting one
highlight on the Kindle removes it from Daf.
- Page turns in it are article reading, split by each article's share of the text, and set each
article's progress. A digest never shows up as a book.
- Tags on documents: "+ Tag" / Edit in the info panel opens the highlight tag editor. Karakeep
items attach and detach their tags in Karakeep; feed items and emails keep them in Daf. Saving a
feed item to the Library takes its tags along.
- Saved views: "Save view" on a list keeps its tab, tag and order in settings.json (views) and
in the sidebar under Shortlist (#view/<id>).
- Flashcards for Anki: Highlights → Export → a tab-separated file Anki imports
(/api/cards/export). Cloze cards become {{c1::…}}.
- Highlights → Review these: a themed review of whatever the filters show (#review/filter/<query>).
- PDFs as printed (web/pdfview.js, pdf.js 4.10.38 vendored in web/vendor/pdfjs, Apache 2.0):
#pdf/<doc>[/hl:<id>], opened by the text view's Pages button.
- Pages: drawn as they scroll into view, with a text layer to select on; selections snap to whole
words.
- Highlights: drawn on the page in Daf's colours. A highlight made on a page keeps pos.pdf
({pg, r}, rects as fractions of the page) and, when its words are in the text view, its place
there too. One made in the text view is found on the page by its words.
- Reading: zoom with + and −. The page is remembered, progress is the page you're on, and the
time counts as reading.
- Shelves (web/shelves.js, tables shelves and shelf_books): your own groups of books.
- A Shelves row on Books, and #shelf/<id> with Rename and Delete.
- Chips on a book's page to put it on or take it off a shelf.
- Hardcover lists are shelves (shelves.hc_list, hardcover.sync_lists): every list made on Hardcover
comes in with the half-hourly Hardcover sync, and a book put on or taken off it in Daf changes the list
at once (insert_list_book / delete_list_book), as does a rename. Only books Daf knows on Hardcover
take part, private books never go up, and Daf never makes a list (lists can be public). Deleting one
is done on Hardcover; its shelf then stays in Daf as an ordinary one.
- Readwise, the years before Daf (sources/readwise.py): the Readwise exports in Reference/clippings
(bullets ending in a "View Highlight" link, with "Note:" under them) are read on every sync and never
written. Each highlight keeps its Readwise id; it hangs off the Library's copy of the page when there is
one, otherwise a rw: article whose vault note is the Readwise note. They come up in the daily review,
and Highlights has a Readwise filter.
- A highlight on a Feed item saves it to the Library, as in Readwise: the server saves it after the
first highlight and the reading view moves to the Library copy (saved_as).
- One-word highlights from the browser extension (or Karakeep) are found as whole words in the reading
view, the words before them choosing between the places they appear.
- Filters on lists: Filter in a Library or Feed list narrows it by source, author, length, date added,
highlighted or not started. The filtering happens on the server (/api/docs?source=&author=&minlen=&maxlen=&since=&hl=&fresh=).
The active filters show as chips. A saved view keeps its filters; "Save to the view" updates them.
- Send to Kindle images:
- Fetched six at a time, shrunk to 1200 px wide, and kept to 20 MB at most.
- Only public addresses are fetched, with redirects checked too. Tracking pixels and icons are left out.
- Needs Pillow, which is in requirements.txt.
- Read aloud follows the sentence: H.follow in web/hl.js marks the sentence Kokoro is on with the
CSS Custom Highlight API (::highlight(daf-speaking), the --speak-line colour), timed by
characters. It works in articles and in books. The page's DOM and the shells' [data-tts] and
[data-bt] buttons don't change.
Later additions (Phase 8)¶
- PDFs (
pdfdoc.py): opening a PDF in the Library queues apdfjob: Daf downloads it (Karakeep's asset if any, else the URL; on a certificate error it tries the host with/withoutwww., never without verification), keeps it in/srv/docker/daf/pdf, and reads its text with pypdf into paragraphs with page markers. Running heads/feet repeated on 40% of pages are dropped. "Pages" in the reader opens the file itself (/api/docs/{id}/pdf). - Tweets: X's '(2) Name on X: "…" / X' title becomes "Name on X" with the first tweet as the lead; repeated avatars are hidden; each tweet gets a thread rule.
- Vault search (
vaultsearch.py): ⌘K adds vault notes (names first, thengrep -Fover the text, hidden folders and.claudeskipped; more in settingsvault_search_exclude). Results open in Obsidian (obsidian://open). - Torah essays:
Reference/torah/<parasha>/*.md(Covenant & Conversation) for the week show on the Torah shelf; they open in Daf as docsve:<hash>(markdown → blocks, clipping furniture dropped) and highlights go back into the same note's Daf region. - Devices:
POST /api/save {url, title}with a device token saves a link (Mac, Shortcuts). Mac menu-bar brief:mac/NEXT.md. Android widget brief:android/NEXT.md.
Sources (Phase 1)¶
- Kindle: KoInsight's database (history; current only when KoInsight syncs), and the Daf KOReader plugin once installed, which posts page turns on sleep, on closing a book, and when Wi-Fi connects. Turns are stored once per (book md5, start) whichever way they arrive. Sittings = turns less than 10 minutes apart.
- Audiobookshelf:
absdatabase.sqlite, read-only. Audiobooks count; the podcast library is stored private, never named, never counted. - Paper: the reading-log notes in the vault, found by
type: reading-log.
The Kindle¶
tools/install-kindle.sh copies kindle/daf.koplugin over SSH (Tailscale, :2222), creates
settings/daf.lua with the URL and a device token the first time, and checks the Lua on the
device. Restart KOReader afterwards. Menu: Tools → Daf (Open Daf, today's minutes, send now,
sleep screen on/off, open Daf on the first wake of the morning). Turning the sleep screen off
restores the previous screensaver message.
The Daf tab (dafscreen.lua, drawn with canvas.lua like Home since 2026-10-20): today's
minutes and bar, the day's review (Keep / Less often, ‹ › or swipe), and the nightstand (tap a
book to open it). It draws from the
last /api/kindle/home answer kept in settings/daf.lua, so it works with Wi-Fi off; taps made
offline wait in pending_review and go to /api/kindle/review on the next sync. Books are found
by KOReader's partial md5 over the reading history, then by title in the file name. A gesture can
open it: Gestures → pick one → General → "Daf: open the Daf screen".
Testing without the Kindle: the KOReader Linux build (v2025.04, same as the Kindle) runs under
xvfb-run with EMULATE_READER_W=1236 EMULATE_READER_H=1648 EMULATE_READER_DPI=300, the plugin
symlinked into its plugins/, and a user patch that calls showScreen() and Screen:shot().
Point it at a copy of the state (DAF_STATE=… uvicorn daf.app:app --port 8441) so taps don't
touch the real review. JSON null arrives in Lua as a function; denull() strips them.
Tools¶
tools/shot.py OUT.png [--path /#sources] [--mobile --width 390 --height 844] [--dark] [--full]: signed-in screenshot through the headless Chrome container.
Manga (daf/manga.py, web/manga.js)¶
Komga is the library and Daf is where it's read; nothing is copied out of Komga.
- Pages:
#mangashows the series and the ones under way,#manga/<series>a series' volumes, and#mread/<book>[/<page>]is the reader, full screen. - Right to left by default, left to right, or one long strip for webtoons.
- Two pages side by side when the screen is wider than tall, with wide spreads alone and "Shift pages" for pairs that start wrong.
- Tap the sides, swipe, or use the arrows to turn pages; tap the middle for the bars; pinch or double-tap to zoom. The choices are kept per series.
- Progress: goes to Komga as the Kindle's account (
kindle@tiresia.xyz), so the Kindle opens where Daf left off andmanga-track.pytakes a finished volume to AniList. Daf reads both accounts and shows the furthest, so progress from Kahon (Mihon's Komga tracker) shows up too. - Images: pages are shrunk to the screen as WebP in four sizes and cached under
STATE/manga-cache(3 GB at most, oldest dropped first). - Minutes: time in the reader is source
manga("Manga in Daf", counted by default), one session per series a day. - Kindle volumes:
manga.refresh_index(after each sync, outside its transaction) keeps the KOReader md5 of every file in/srv/media/manga(manga_files) and ties Daf's book rows to Komga (manga_books), by md5, by file name, or byseries-map.jsonand the volume number. Those rows leave Books, their Kindle time is titled by series, and the books goal leaves them out unless manga counts. - Keys:
/srv/docker/manga-import/komga.json(mode 600, manga-track's), read in place. - AniList (
daf/anilist.py,web/anilist.js, 2026-10-26):manga-track.py(cron, :25 every hour) sets progress on AniList with the sign-in Daf keeps inSTATE/anilist/token.json(mode 600; AniList's implicit grant, a year at a time: the client id is in settings, the redirect is/anilist, whichsw.jslets through). The script leaves{at, summary, error}in/srv/docker/manga-import/last-run.jsonfor the Preferences card and the Sources check. It used Suwayomi's tracker until Suwayomi was retired the same day; what Suwayomi's reader had marked read is kept insuwayomi-snapshot.jsonand still counts.
Offline¶
What you do with no network is kept on the device and goes to the server when it's back; what you read can be kept ahead of time.
- The outbox (
app.jsOUTBOX,localStorage daf.outbox): book places, highlights, bookmarks; article highlights, notes, tags, status, progress, star and title; reading time (articles, books, manga); the daily review and cards; paper minutes; reading notes. Each write carriesX-Daf-Op(a random id); the server keeps the first answer to each op for a month (opstable) and gives it again for a repeat, so nothing counts twice. A write waits at most six seconds on a connection that doesn't answer, then goes to the outbox. While it waits, the page gets the answer it would have had (a highlight's id is the server's own hash). Time is kept with each write (at): an offline review counts on the day it was done, a highlight keeps when it was made, paper minutes their day. - Shown: "Offline · N waiting" in the corner (tap: send now); Today counts the minutes still on the device. What needs the server (Ask, summaries, Send to Kindle, adding links) says so offline.
- Kept ahead:
GET /api/offline/plannames what to keep (the pages' own requests, character for character: Today, the review for today and the next three days, the Library and Feed lists, Books, Highlights, manga;docsto keep with their pictures;ebooksfiles; covers). Daf for Android keeps it natively; in a browser or the Mac app the page fetches it through the service worker every six hours./api/review?day=builds a coming day's review now, without repeating the days before it. - The service worker keeps lists also by what they are (where, tab, kind), so a list opens offline whatever order this device shows it in.
Themes and typefaces¶
- Themes (
web/themes.js):FAMILIES, each a light and a dark palette of a few colours (bg, side, fg, accent, read, ok, bad, five highlight hues).T.varsmakes every token style.css uses from them, set inline on<html>(they win over style.css's own:root, which stays as the no-script look). settings.appearance= {mode: system|light|dark, light, dark} (config.clean_appearance), applied from/api/today's settings on every load.localStorage daf.themeVarsholds both versions, so index.html's first script paints the right one before any CSS rule shows.html[data-mode]says light or dark, andmeta[name=theme-color]follows the sidebar.- A
daf-themeevent ({name, dark, bg, side, fg, accent}) goes out on every change, for the apps' own chrome. - The old Aa themes (light, sepia, dark, black) move over once: sepia becomes Paper, black becomes Black.
- Typefaces (
web/fonts, made bytools/fetch-fonts.py): 24 OFL families from Fontsource, Latin, Latin Extended and Hebrew subsets only, about 4.4 MB, with their own@font-facerules and unicode ranges inweb/fonts/fonts.css. - No Google Fonts request any more. index.html and the book's pages (book.js
fontCSS) load Daf's copies. T.FACES/T.stack(id)give the reading stack; Hebrew falls back to Frank Ruhl Libre (serif) or Heebo (sans).- Your own fonts:
POST /api/fonts?name=<file>(the raw file; TTF, OTF, WOFF or WOFF2 by magic bytes, up to 8 MB) saves toSTATE/fonts. - The family and style come from the file name ("Bookerly-BoldItalic" → Bookerly, 700, italic).
settings.fontslists them;GETandDELETE /api/fonts/<file>.T.userFontCSSadds their@font-facerules to the page and the book.
Backups¶
- Borg (a nightly Borg script, 03:30) takes all of
/srv/docker, Daf's state included, and/srv/dev. It keeps 7 daily, 4 weekly and 6 monthly archives and mirrors them to old the server. daf backup(cron 03:00, before Borg) writes a consistent copy ofdaf.dbtoSTATE/backups/daf-<date>.db.gzand keeps 14 (mode 600).- It uses SQLite's online backup, so it's safe while Daf writes in WAL mode, which a plain file copy isn't.
- Each copy is integrity-checked; a failure goes to Signal through
notify.sh. - Borg then carries the clean copy. Restore:
gunzip -c daf-<date>.db.gz > daf.dbwith daf.service stopped. - Left out: the manga page cache lives in
STATE/cache/manga, which Borg's*/cache/*rule skips; Daf rebuilds it from Komga. - What only Daf has: reading sessions and time, the Kindle page turns the plugin sent, review history and state, cards, settings, your fonts. Highlights and notes are in the vault as well.
Delete, pictures, paywalls, keys, AI, the morning email, feedback, imports (2026-10-12)¶
Daf was held up to Readwise Reader's intro video, and this round built all of what was missing, plus four things first set aside as not worth it.
- Delete is a status,
deleted, for a doc in either place (reader.set_status;docs.deletedis when). Every list, count, search and summary job skips it. A Feed item is marked read in FreshRSS at once; a Karakeep one stays in Karakeep forUNDO_SECONDS(600) so Undo can bring it back, thenreader.purge_deleted(each sync, beforekarakeep.sync) deletes the bookmark and the row. A doc with highlights stays as a quiet row (deleted = 1) so its highlights keep their title; saving the same page again moves them to the new bookmark (pages.save_page). Karakeep's sync keepsdeletedas it is. The Feed's 30-day prune takes deleted items with the seen ones. Anything that isn't Karakeep's (emails, Torah, Feed items) keeps its row asdeleted = 1for good, so the sync that brought it (which skips ids it has) doesn't bring it back. - Undo (
reader.jspushUndo/undoLast): every Done, Later, Shortlist and Delete, single or bulk, pushes its inverse; the toast's Undo and z pop the newest. - Pictures as highlights: a highlight whose text is Markdown's picture,
, withpos.imgthe address (reader.add_highlight(img=...), http(s) only). The reader frames the figure (figure.hlimg);H.quotedraws it everywhere a highlight shows; the vault gets the Markdown, which Obsidian shows. Articles only; books and PDFs' pages still highlight text. - The page as you see it: the extension (0.3.0) sends the page's HTML (
snapshot()in content.js: scripts out, pictures and links made absolute, its own marks unwrapped, up to 6 MB) with Save and with the first highlight on a page; the popup's "Give Daf this page's text" sends it for a page already saved.pages.keep_textfinds the article with trafilatura (content.article, shared with fulltext.py) and keeps it when it has more words than Daf's copy, and clearsai_summaryso the summary is made again from the whole thing./api/web/pageanswerswhere: "deleted". URL helpers moved from webapi.py todaf/pages.py. - The extension updates itself (0.5.1 on, Firefox and Zen): the Firefox manifest has
gecko.update_url= https://daf.tiresia.xyz/extension/updates.json, which they check about once a day.GET /extension/updates.jsonoffers the newest file inextension/signed/(with its sha256), andGET /extension/daf-<version>.xpiserves it. Both are open, no sign-in: signed builds of the public extension code. To ship a change: bumpversionin extension/manifest.json, runextension/sign.sh, commit the new file inextension/signed/; no restart (read from disk). Chrome and Arc (unpacked) don't update themselves; Safari's comes with the Mac app. - Keys (
web/keys.js): one table of scope → action → default keys for lists, articles, books, the review and manga; settingskeysholds your changes (PUT /api/keys),/api/todaybrings them, localStoragedaf.keyskeeps them for a cold start. Handlers callDafCore.Keys.is(e, scope, action); a letter typed with another layout (Hebrew) matches bye.code. Every shortcut sheet isKeys.sheet(scope). shell.js presses the bound key for the menus (docs/SHELL.md). - AI (
daf/ai.py):DEFAULTSholds the instruction part of each prompt (summary, ask, ask_book, word_en, word_he, card); settingsaikeeps your changes, the model (""=DAF_CLAUDE_MODEL), the ready questions (/api/docs/{id}and/api/ebooks/{id}sendquestions) andauto_summary, whichqueue_summariesobeys.ai.runis the one place the CLI is called (paper_pages keeps its own, with its tools). - The morning email (
daf/digest.py): settingsdigest{on, hour, feed}; the sync loop'sextras()sends it once a day at the hour in the home zone, only with something new (kvdigest_last, three tries on a day it fails). SMTP through the Bridge (PROTON_SMTP_PORT, 1025), from daf@tiresia.xyz (digest.SENDER, or settingsdigest.from; tiresia.xyz is a custom domain on the owner's Proton account, so Proton signs it; changed 2026-10-13) to PROTON_USER./api/digest/previewrenders it;POST /api/digest/sendsends one now. Private titles are left out. - Feedback (
daf/feedback.py): kept in tablefeedback, thengh issue create -R franz-rosenzweig/old-daf --label feedback(the private repo, never the public one; env DAF_FEEDBACK_REPO) as lander (the label is made on first use). It carries the route, device, window, theme and the page's last errors (DafCore.errs, app.js); a private title is replaced. Ones GitHub didn't take are tried again byextras()for a day. POST /api/feedback is in the outbox, so it waits offline. - Imports (
daf/imports.py):detectreads Instapaper, Pocket (CSV zip or ril_export.html), Omnivore (zip), Readwise Reader, Raindrop, GoodLinks, Wallabag, Netscape bookmarks, OPML and any CSV/JSON with a url column into one shape; tableimportskeeps the items, the options and the counts.POST /api/importonly reads the file;/startruns a thread (_work: the items read once, one item at a time, 0.25 s apart, committing after the save so Karakeep's archive/star/tag calls run without the write lock) thatextras()restarts after a restart. Links go throughpages.save_page(keeps the saved date), then archive/star/tags in Karakeep; highlights aredafhighlights placed by their words (pos {q}), never twice. Feeds go throughfreshrss.subscribe, skipping ones already followed. - Drag and drop takes several files at once and a link dragged from another tab (
text/uri-list); a drag that starts inside Daf isn't one. - Sandbox: scratchpad
sbf.shstarts sbfake.py with stand-ins for Karakeep (save, archive, star, tags, delete, PDF), FreshRSS (mark, subscribe, subscriptions), the email and GitHub; checks in apitest.py (31), imptest.py (15) and uitest.py (29).
The audit round (2026-10-13)¶
Two read-only audit agents (server and web) went through Daf; these are the fixes, made on the
audit-fixes branch.
- Nothing slow on the event loop. The routes that wait on Karakeep, FreshRSS, Komga, GitHub, the
Kindle send or the database are plain
defs now (FastAPI runs them on worker threads), reading their JSON through thejson_bodydependency (webapi.py's_bodyis the same, as a dependency). The rest of theasync defroutes do only quick work./api/paperno longer runs a whole sync: it appends the line and reads the paper log back (paper.import_entries). - The sync commits after each source (
conn.commit()before each block insync.run), so the write lock isn't held through the next source's network calls. - X-Daf-Op is claimed before the write runs (
_op_claimputs a status-0 row inops; a repeat that arrives meanwhile waits for the answer, up to 20 s, then gets 503 and stays queued; a 5xx or 401 releases the claim). The middleware's database work runs in the thread pool, and nothing is replayed to a request without a cookie or a token. - Vault notes after a highlight changes all go through
vaultnotes.write_for: an id indocsgets its clipping note (write_article), anything else its book note. A note added in the review, over Signal ("n:"), a tag, a removal or a restore on an article's highlight no longer leaves only the noted highlights in its clipping note.write_alldoes the same. - Saving a Feed item that Karakeep already has keeps the Library copy (place, Later, note, star,
tags, summary, text) and only adds to it (
reader.save_to_library); a page saved again after its purge takes the old row's highlights, sessions, books row (its vault note) and note (pages.save_page). - Reading after midnight counts for the new day (
today.beat_startkeeps a session's start at or after that day's midnight), and the three beats (articles, books, manga) are one upsert each, so two first beats at once don't fail. - Summaries skip items under 60 words and items that already had a job, so short tweets don't
hold the queue;
pages.keep_textdrops the old job when a longer text arrives. - Private titles: the phone widget and the Kindle home show "A private book" for one on the
nightstand; picture highlights are said in words on the phone (
reader.plain). Asking about a private book yourself still sends it to the model (Preferences → AI says so). - Feedback is retried only after its first try failed and two minutes passed (no double issues), and one GitHub never took is said on Signal after a day. The morning email tries three times a day and then says so on Signal.
GET /sharefrom another site asks first (a form that posts); the share menu and Daf itself save at once (Sec-Fetch-Site).- Smaller:
vault_search_excludeis read from settings again; clipping file names stay under 200 bytes; a bad date to/api/paperis a 400, not a 500 that would block the offline queue; bulk status checks the status once and lists per-document failures; the books goal and Hardcover use the home time zone; deleted docs leave the home shelf, the Torah page's Ruach list and the caption jobs; the model's errors reach the page in Daf's words (ai.plain_error). - Web: the offline queue reads the stored queue afresh for each send and removes only what it
sent (new writes made during a flush were lost), and sets aside one Daf keeps refusing after five
tries (
daf.outbox.stuck, included in Send feedback); queued tag edits keep their tags; a sheet in front takes the keys (and Esc closes only it); a Library tag doesn't follow into the Feed; a saved view's order can change and be saved; the manga reader doesn't open over a page you've gone to; the Notebook's Delete highlight removes it from the text too; picture highlights undo, list in Contents and jump to the figure; Forget my place forgets on the server (DELETE /api/ebooks/{id}/position); leaving an article opened straight from a link goes to its list; several dragged links are each saved; key hints and/follow Preferences → Keyboard; keys work by position with a Hebrew layout (Q no longer opens search; the brackets aren't mirrored);?answers on every page; the row hover bar is off on touch screens.
Recovery (daf/recovery.py, web/recovery.js, 2026-10-13)¶
#recovery, under Library. GET /api/recovery builds it from Calibre:
- The Big Book is the book whose title matches BIG_BOOK (cal:17 now). Its EPUB's NCX contents,
in spine order, are split into the first 164 pages (The Doctor's Opinion and chapters 1-11, with
the Fourth Edition's first pages from PAGES; the file has no page markers), the stories (Part
I-III) and the appendices.
- How far: each chapter's start is its place in the spine by size, and "read" comes from the
furthest of Daf's place and the Kindle's progress.
- Readings and prayers open with #ebook/<id>/q:<words>: book.js goToWords finds the passage
with the reader's search. Only public-domain texts (the Serenity Prayer, St. Francis) and the first
words of each passage live in the code.
- More to read is A.A. literature by name (OTHER). Never widen it to general words like
"recovery" or "addiction": they match books that don't belong on this page.
There's no check-in form and no date counter, on purpose.
Speed (2026-10-13)¶
Daf answers in 5-35 ms; through daf.tiresia.xyz every request costs 250-450 ms (even from the server), so a page that made several reads and revalidated ~40 files took seconds. Now (measured with 300 ms added per request: Daf opens 2.0 s → 0.4 s, the Library list 418 → 65 ms, Books 339 → 11 ms, a book opened before 3.5 → 0.8 s):
- Fingerprinted files (
daf/webcache.py):/serves index.html with each script and style as/name.js?v=<sha1[:10]>; those go outimmutable(the headers middleware), vendor files for an hour with a week of stale-while-revalidate, fonts for a month./sw.jsis served withVERSION(a hash over every file's size and mtime in web/) andFILES(the fingerprinted list) filled in. - The service worker (web/sw.js): caches
daf-shell-<VERSION>(the page answered from here at once and refreshed behind; every other file of the page device-first),daf-data-v1(API reads, network-first, as before),daf-img-v1(covers, manga thumbnails and pages, feed icons, device-first, 3000 kept) anddaf-books-v1(book files, device-first, 60 kept;cache: 'reload'fetches again). A change anywhere in web/ is a new sw.js: the browser installs the new set on the next load and the one after runs it; old shell caches are dropped. In Daf for Android book files and manga pages pass through (the app keeps them itself,Offline.intercept). - Pages draw from the device first (
DafCore.swrin app.js): Today, the Library and Feed lists, Home, Books, Highlights, Manga, Stats, Torah and Recovery render the kept copy at once, then draw again in place (scroll kept) if the network brings something new, unless a menu, a sheet, a field or a tap since the page was drawn says you're busy. The sidebar's counts do the same. - Warm menus:
warm()in app.js reads the offline plan's URLs (not books or fonts) a few seconds after Daf opens and every ten minutes while it's in front. - Books: book.js fetches the file beside the book's details, from the device when kept, and
again only if its size differs from Calibre's (
d.size). - Covers and manga thumbnails go out for a week with a month of stale-while-revalidate.
- Test: scratchpad swtest.py (9 checks), swtime.py (timings) and swupdate.py (an update reaches a
device), through a forwarder on localhost:18443 inside docker-chrome-1 (container daf-swproxy,
scratchpad swp/proxy.py, with
/__countand/__delay?ms=), because a service worker needs a secure origin and Chrome treats localhost as one.
Phone fixes (2026-10-14)¶
- Each Android device has its own token now, named android-
(android-sm-s926u, android-sm-t580). Before, the phone and the tablet shared "android", and whichever connected last killed the other's token, which broke Save to Daf with "Daf doesn't know this phone's token". Making an android-* token deletes the old shared row. - The page tells Daf for Android two things through the
readingop (docs/SHELL.md): whether the volume keys turn pages, and whether Daf's bar owns text selection. The volume switch is per device, in localStoragedaf.volumeKeys, and off by default. tools/sandbox.pyruns a sandbox whose outside writes (Karakeep, FreshRSS, Hardcover, Komga progress, the email, GitHub) are stand-ins that print what they were asked. It refuses to start on the real state. Run straight from uvicorn, the sandbox has no sync loop, so shared links stay in its inbox.
Tips, What's new, and pages that update themselves (2026-10-14)¶
web/tips.jsholds the tips (TIPS: a feature's view, its control's selector, the words) and What's new (NEWS, newest first). Add a NEWS line with each user-facing change. State is in settings.jsontips{off, seen, news} through GET/POST /api/tips. In the console,DafCore.Tips.why()says why no tip shows.- index.html carries
<meta name="daf-version">(webcache), and /api/health returns the live version. A page that's older reloads at a calm moment: on the next move outside a reader, or on coming back after 30 s away, never while something plays. Before this, the apps could run an old page for days after an update.
Daf keeps the Library (Karakeep read only) (2026-10-14)¶
- settings.json
library.karakeep: "on" (Karakeep keeps the Library, the old way), "read" (Daf keeps it; Karakeep is only read, for bookmarks made there; nothing is written to it), "off" (Karakeep isn't asked).daf karakeep-importmoves it from "on" to "read", once;--againfills in anything still missing. - New saves are dl:<16 hex> rows. A "page" job (its own worker thread,
daf-pages, so it never waits behind a transcript) reads the page: urllib with the public-address guard, then the headless Chrome in docker-chrome-1 (daf/browser.py, DevTools on :9222) for robot checks and script pages. Pictures are kept in STATE/thumbs (/api/thumb/.jpg, at most 960 px). Uploaded PDFs are kept in pdfdoc's folder. - A "tags" job gives a new save 2 to 4 tags (ai prompt "tags", Preferences → AI switch), never a private one. A "tagtidy" job asks the model for a tidy-up of the doc tags (kv tag_tidy); Preferences → Library tags applies only the ticked changes.
- When Karakeep goes for good (with the owner's OK first): set library.karakeep to "off",
karakeep dumpinto the borg backup, stop the web and meilisearch containers, and keep docker-chrome-1: browser.py needs it. - The sandbox has no job workers: run pages.fetch / tagging.tag_doc by hand against its state (scratchpad libtest.py).
Karakeep retired (2026-10-19)¶
- settings library.karakeep = "off": Daf asks Karakeep nothing (no sync, no check on Sources). Its containers (web, meilisearch) are stopped and sit behind the compose profile "karakeep" in /srv/docker/docker-compose.yml (backup .bak-20261019-karakeep), so a plain
docker compose up -dleaves them off. Data stays in /srv/docker/karakeep-app and /srv/docker/meilisearch, in the nightly borg. To bring it back:docker compose --profile karakeep up -d web meilisearch, and library.karakeep "read". - docker-chrome-1 stays: daf/browser.py reads robot-checked pages with it.
- What used Karakeep now uses Daf's API for local tools (/api/tools/library, /api/tools/save, /api/tools/docs/{id}; /api/thumb takes a device token), each with its own device token: the desk ("desk", DESK_DAF_TOKEN in /srv/docker/desk/.env: the Saved page, k-capture, save/archive/star, pictures) and the office agent ("office", DAF_TOKEN in /srv/office/.env:
read saved). The homepage tile is Daf's (services.yaml backup .bak-20261019-karakeep). - Left for a root moment: keep.tiresia.xyz in /etc/cloudflared/config.yml (and its DNS record) now leads nowhere. The vault script karakeep-sync.py was no longer scheduled; Reference/karakeep/ keeps its 30 notes.
KoInsight retired; the Feed keeps a month (2026-10-20)¶
- settings koinsight = "off": no import, no Sources check. The KoInsight container is stopped and behind the compose profile "koinsight" (backup docker-compose.yml.bak-20261020-koinsight); its data stays in /srv/docker/koinsight (borg). Its old annotations still fill highlights (sources/highlights.py reads the file if it's there).
- The Kindle: plugins/koinsight.koplugin renamed .retired and settings/koinsight.lua sync_on_suspend false (backup .bak-20261020), so it doesn't try to sync at every sleep. To undo: rename it back, then restart KOReader.
- Kindle numbers for local tools: /api/tools/kindle?days=N (device token). The desk's Trends and reading card use it with DESK_DAF_TOKEN; the homepage tile "Kindle" uses /srv/docker/homepage/kindle-stats.sh (token "homepage" in kindle-stats.env, cron every 30 min; it replaced koinsight-stats.sh).
- Left for a root moment: koinsight.tiresia.xyz in /etc/cloudflared/config.yml.
- The Feed: feed_expire_days defaults to 30; Preferences offers a week up to a year or never (3650). A larger window makes the next sync fetch once back to its start (kv feed_window). FreshRSS's archiving keep_unreads is now true (config.php backup .bak-20261020), so unread articles stay as long as the window wants them.
The Kindle: Home, and highlights through the plugin (2026-10-20)¶
- Syncthing is off on the Kindle (its plugin is disabled), so the Daf plugin now sends each book's notes file (highlights and notes) itself: POST /api/kindle/annotations, kept per md5 in kindle_sidecars, which library.sidecars() reads before any Syncthing copy. On the plugin side the settings keep notes_sent (file → mtime).
- Home (kindle/daf.koplugin/home.lua) draws itself at fixed places, designed at 1236 px. Covers are cached in /cache/daf-home. /api/kindle/home now also gives hebrew_date (Hebcal, cached in kv), parasha (Sefaria calendar) and private_md5s. Settings home_on_start (default on).
- Later the same day: Home lost the review (it's on the Daf tab, now a canvas page too) and the shelves (on Shelves). Shelves got KOReader's own long-press dialog (fm.file_chooser:showFileDialog, after moving the browser underneath to the same folder), a "Move to shelf…" row added with fm:addFileDialogButtons("daf_move"), a Put-it-here bar after Cut/Copy, and Files ▾. canvas.lua C.watchFiles() wraps FileChooser.refreshPath, FileManager.cutFile/copyFile and onToggleSelectMode once, so a shelf redraws after KOReader's own file actions. Don't use KOReader's Menu widget for lists here: Project: Title patches Menu.init and a tap on a plain Menu crashed KOReader in the simulator; ButtonDialog with rows_per_page works.
- Project: Title lays the file browser out again after the plugins register, leaving a second FileManagerMenu with no plugins as fm.menu (the browser itself still opens the first). canvas.lua C.fileMenu() finds the one with the plugins and gives the empty one the same list, so the menu from Home is complete.
- readerprefs.lua puts the global settings KOReader only builds inside a book (status_bar, page_turns, follow_links, long_press, translation_settings, fulltext_search_settings) into the file browser's menu: each reader module's addToMainMenu runs on a stand-in for an open EPUB, with redraws as no-ops, and every callback is wrapped so an error shows a message. Placed by editing the cached ui/elements/filemanager_menu_order table once. Tested by walking every item and callback in the simulator (scratchpad walk.lua, ~1,600 calls, no errors).
- Covers (2026-10-21), covers.lua. Shelves used to make every missing cover by opening the book with FileManagerBookInfo:getCoverImage (crengine for EPUB, MuPDF for CBZ/PDF) on the main thread, one per tick, keyed by exact size, so Home, Lately and the shelf grid each opened a book again. Now a size that isn't cached is cut from, in order: a custom cover (DocSettings:findCustomCoverFile), the book's master (
.jpg, ≤600 px tall at 1236 px wide), Project: Title's settings/PT_bookinfo_cache.sqlite3, then CoverBrowser's settings/bookinfo_cache.sqlite3 when its picture is at least 90% of the height asked for (its covers are mosaic-sized). Both databases are opened read only ( SQ3.open(path, "ro")), looked up by directory + filename, then by filename alone when exactly one row has it (a book moved since it was indexed), and closed after 8 quiet seconds. Only when none of those has a cover is the book opened, and never in KOReader's own process: up to 8 at a time go to a child process (ffiutil.runInSubProcess, like Project: Title's own extraction, crengine cache in cache/daf-covers-cr3, purged after), which writes the sizes asked for and the master. The parent checks every 0.5 s and redraws the places waiting (Canvas:coverReady). A child stuck 90 s on a book is killed; a book that stopped a child leaves.try and gets .none next time. To make a book try again, delete its .none. - The cache is cache/daf-home: gray JPEGs (turbojpeg TJPF_GRAY, quality 88) named
_ x .jpg, the masters, .none markers, and keys.lua (file → size, mtime, partial md5, so util.partialMD5 runs once per file version). The key is the file's partial md5, which follows a book to another shelf. The first run of this version (no .v2 file) empties the folder of the old PNGs. C.bookInfo now carries key (the md5 now) next to md5 (the one in the notes); C.isPrivate checks both, so a private book never opened on the Kindle is caught too. - The library pass: Daf:coversAhead runs 3 s after Home shows. It walks the home folder (six deep) and makes each book's shelf-size cover (Shelf.usualCoverSize: the grid with no Cut/Copy bar) in 30 ms slices 0.2 s apart, sending books with no quick cover to the child. While a book is open it checks back every 20 s. It deletes any cached file whose md5 is on the private list, and never makes one for a private book (checked by the file's md5 and by the md5 in its notes). The log says
[Daf] covers: library pass over N books in S s: M made, K to openand[Daf] covers: opened N books in S s. A shelf page makes its covers inside layout while it has under 300 ms spent (none on a partial redraw, since only that region refreshes), the rest one per tick after it's up, then the next page's through Covers.ahead. - Measured on the Kindle (a standalone luajit over SSH, reading only): a Project: Title cover takes about 4 ms to query, 5 ms to unzstd and 12 ms to scale and crop; writing it as PNG took 62 ms and as JPEG 6.5 ms, which is why the cache is JPEG; util.partialMD5 is 4 to 5 ms a book. In the simulator, the time to fully draw a shelf page with an empty cache went from 1,363 ms (6 manga) and 850 ms (6 EPUBs), with the screen frozen for each, to 34 and 45 ms when Project: Title has the covers; books nothing has a cover for still take that long, but in the child, with the UI loop free (0 ms delay measured); a page already cached draws in 23 to 28 ms, and the next page is ready before the swipe. The Kindle is about 7 to 10 times slower than the simulator on these steps.
- Querying the Kindle's sqlite files by hand: /var (and /tmp) is a 64 MB tmpfs that's nearly full, so anything needing a temp b-tree (GROUP BY, ORDER BY on a blob table) fails with "database or disk is full" unless you run
PRAGMA temp_store=MEMORYfirst. - The KOReader simulator is /srv/dev/koreader-emu (README there): KOReader v2025.04 headless at the PW11's size. Its home/plugins/daf.koplugin links to the working copy in /srv/dev/daf-phone/kindle; point its settings/daf.lua at a sandbox.
./emu.sh start --night --ui pt --touch-only,shot,tap,hook 'event DafHome'. Never at live Daf. Since the server's October OS upgrade, Mesa's llvmpipe threads made every forked child in the simulator (Project: Title's indexing, the covers and download children) hang at exit; emu.sh now setsLP_NUM_THREADS=0 MESA_SHADER_CACHE_DISABLE=true. The Kindle never had the problem.
The taste on stuffs (2026-10-22)¶
- What it is: a public demo at stuffs.tiresia.xyz/daf/ (a static folder, no server): the real web app from
web/, running in the browser on a sample library, with screenshots and links around it. Source intaste/: the page (index.html,taste.css,taste.js,shots/), the demo layer (taste/app/) and the sample library (taste/data/). Rebuild withpython3 -I tools/build_taste.py /srv/docker/stuffs/site/daf; it replaces that folder (only one it made itself, marked.daf-taste). The cover is/srv/docker/stuffs/site/covers/daf.webp. - How it runs with no server:
taste/app/demo.jsis loaded before the app's scripts and stands in front offetch: everyapi/call is answered in the browser fromdemo-seed.js(made by the build) and what the visitor does, kept in localStoragedaftaste.v1(plus the app's owndaf.*keys). A made-up reader's year is generated per day from a fixed seed, so Today, the week, the Year 30 grid, Stats and the book pages look lived in on any date. Calls it doesn't know answer "That needs Daf's own server, so it isn't in this demo". demo.js also catcheswindow.DafCoreas app.js makes it, so the sidebar is filtered and the server-only places get their "Not in the demo" page from the very first drawing (a page can draw before reader.js and the later scripts have run; wrappingnavExtraafterwards once let Recovery through for a moment).demo-ui.js(after the classic scripts) adds About this demo (with Reset) and makes exports and downloads from the demo's own data.demo.csshides the controls that need the server (Ask, Define, Listen, Hardcover, the account and service cards). - What the build changes in the copy (never in
web/): absolute paths become relative ('/api/x'→'api/x',/style.css→style.css), covers get a.jpgending (static files inapp/api/cover/), book.js's pages loadfonts/fonts.cssfrom the folder,sw.js,pdfview.js(and pdf.js),recovery.js, the manifest and the Recovery line of What's new are left out, and the copy of foliate-js's paginator doesn't lay out a section that is still loading. That last one is a race the live app has too: a resize while a book's section loads (the Kindle notice appearing, the panel opening) runsrender()before the new<body>exists and throwsCannot destructure property 'style' of 'el' as it is null(harmless; the load lays it out a moment later). Recovery never appears in the taste. Ifindex.htmlor one of those lines changes shape, the build stops and says which; a new/api/call simply answers "not in this demo" untildemo.jslearns it. - The sample library: four Project Gutenberg EPUBs kept as Gutenberg made them in
taste/data/books(Meditations tr. Casaubon #2680, Walden #205, Pride and Prejudice #1342, The Odyssey tr. Butler #1727), covers made for the demo, three public-domain essays as Daf blocks inarticles.json(Bacon, Lamb, Schopenhauer tr. Saunders), and made-up highlights, places and notes inbooks.json. The build checks every highlight against its book or essay and stops if one isn't found. Nothing from the real state, the vault or the library goes in, ever. - The docs' screenshots (docs/images) are taken from the same sample library:
python3 -I tools/docs_shots.py [--only hero-light,feed,...]makes a--docsbuild (it addstaste/data/docs.json, a made-up Feed, Torah shelf and Hardcover status, keeps the whole sidebar except Recovery and drops the demo's notes; the build refuses to put it on stuffs), serves it on 127.0.0.1, shoots each screen in Chrome for Testing and frames it like the old ones (same names, sizes, rounded window and shadow; the three phones in their frames). Today is shot in a time zone where it's evening, so it has three sittings (41 of 60 minutes). It fails on any page error, or if Recovery ever shows. appearance.png, torah.png and torah-dark.png aren't retaken there. - Checks: load each screen in light, dark and at 390 px with no page errors, and confirm in the network log that nothing is asked outside
/daf/. The landing page's screenshots (taste/shots/) are taken from the demo; Cloudflare keeps pictures a day, so a replaced one needs a new file name.
Instagram reels (daf/social.py, web/social.js, 2026-10-25)¶
- Saving: an instagram.com
/reel/,/reels/,/p/or/tv/link is a video doc (karakeep.kind_of), stored under its canonical addresshttps://www.instagram.com/reel/<code>/(or/p/<code>/) so a re-share with?igsh=finds the same doc.pages.save_pagequeues the reel job instead of the page job. The web route ignores the extension's page snapshot for a reel.POST /api/save(phone shares) now runsreader.push_inboxstraight away when Daf keeps the Library, so a shared reel starts at once instead of at the next sync. - The reel job (
social.fetch): yt-dlp with the kept cookies (a temp copy, since yt-dlp writes its jar back), the best ≤1280 px video merged to mp4 (needs ffmpeg), at most 400 MB, intoSTATE/media/<id>.mp4. Then the picture (pages.keep_thumb), the caption as paragraphs,@accountas author, then faster-whisper on the file for a "Transcript" heading and cue blocks. It also queues tags.video_transcripton a reel runs the same job. The sync's auto-captions skip instagram.com.media.prune→social.pruneremoves videos whose doc is gone. - Playing:
GET /api/media/<id>(signed in or a device; FileResponse answers Range, so it seeks).get_docaddsreel,media,preparing,content_error.web/reader.jsreelHead/startReelwrap the<video>in the YouTube player's four methods, so the transcript, Clip and the keys work unchanged. The frame stands up (.vframe.tall) and turns to the video's own shape for square or wide posts. - The sign-in: cookies in
STATE/instagram/cookies.txt(Netscape format, 600, directory 700), never logged, never in the repo. kvinstagram= {hash (sha256[:16] of sessionid), user, by, updated, expired}. - Routes:
GET/PUT /api/instagram(status;from_browser),POST /api/instagram/cookies(signed in or any device:{cookies:[…]}or{cookie_header:"a=1; b=2"}, for the apps' sign-in),DELETE /api/instagram/cookies. For the extension:GET/POST /api/web/instagram, and the POST is 403 unlesssettings.instagram.from_browser. - On arrival, every reel without content is queued again (
social.WAITING, which doesn't depend on failed jobs surviving the 14-day prune). - Lapses: yt-dlp's login-ish errors ("login required", "rate-limit", "not available", "Failed to parse JSON", HTTP 401/403/429) are checked with
session_ok(): a GET of /accounts/edit/ with the cookies, where a signed-out session lands on /accounts/login. - If the session really lapsed, kv
expiredis set and the job fails withLOGIN_ERROR, which the reel page, Preferences → Instagram and the Sources check (check_instagram, shown once a reel exists) all show. - Otherwise it's "deleted, or a private account?".
- The same lapsed session sent again is answered
sameand nothing is retried. - The extension (0.6.0, "cookies" permission):
syncInstagram()in background.js runs on install and startup, when instagram.com'ssessionidcookie changes, and when a page on instagram.com loads (content.js). It asks/api/web/instagramand POSTs instagram.com's cookies only whenwantand the hash differs, or Daf's is expired. - The apps (to come, the native session): a "Sign in to Instagram" web view that posts
cookie_headerto/api/instagram/cookieswith the device token.
Adding books, and the Kindle's downloads (2026-10-25)¶
- Matching (
library.py): - Calibre's
hardcover-bookidentifier (CWA fills it for most books), a note'shardcoverBookIdandbook_addsgive each rowbooks.hc_id. Matching is by that id first, then by title. norm()keeps a non-Latin title's own letters (Hebrew titles all used to normalize to "" and matched each other).slug()hashes such a title for its vault id.hardcover.build_mapstarts frombooks.hc_id, so 60 more Calibre books have Hardcover sync.- rekey: when a book's id changes (hc: → vault: → cal:),
library.rekeymoves its rows in BOOK_REFS, its ebook/paper sessions, its kindle_queue row and its nightstand slot. It's found by hc_id, vault_path or calibre_id. - Add a book (
hardcover.find_books,hardcover.add_book,web/addbook.js): GET /api/books/find?q=: ISBN through editions, else Hardcover search sorted by readers. Each hit is annotated with the Daf book, whether it has a file, and its Hardcover status (cached shelves up to 6 h old).POST /api/books/add {hc_book_id, status 1|2|0, kindle_shelf?}:insert_user_book(privacy 3 for a private title; Reading also starts a read) unless it's already on a shelf, then abook_addsrow.rebuildshows that ashc:<id>until a note or Calibre has the hc id. The cover comes from Hardcover throughpages.keep_thumb, via/api/cover/hc:<id>.- hardcover-sync makes the note. Its cron runs WITHOUT
--no-createagain since 10-25. Two notes on old ids were re-pointed first (The Gay Science → 442702, Primary Greatness → 447079), after a dry run showed they'd otherwise duplicate. Backups are in that session's scratchpad. - A book's file (
bookfiles.py): POST /api/books/<id>/file(multipart: file, kindle_shelf, force) refuses a book Calibre has (by hc id, ISBN or title: 409 with its id). It writes ISBN andhardcover-book:<id>identifiers into an EPUB's OPF (OPF2opf:scheme, OPF3urn:isbn:/hardcover-book:) and puts it inCWA_INGESTas "Title - Author.ext" (hidden.partfirst). The row goes inbook_files.settle()(before each rebuild) finds the Calibre book it became among those added since: by hc id, ISBN or title. It setscal_id, writeshc_links, andoverrides()makes the chosen hc id win over CWA's.- It notes a file in CWA's
processed_books/failed, or one still not in Calibre after 6 h. - Calibre-Web's
metadata_provider_hierarchynow starts with hardcover (cwa.dbcwa_settings, backup taken 10-25). It's read fresh for each import. - The Kindle's downloads (
kindlequeue.py): Syncthing is off on the Kindle, so the plugin fetches its files itself. - Table
kindle_queue. Device routes:GET /api/kindle/inbox,GET /api/kindle/inbox/<id>/file,POST /api/kindle/inbox/<id>/done {error?},POST /api/kindle/shelves {shelves}(stored in kvkindle_shelves). - User routes:
GET /api/kindle/shelves,POST|DELETE /api/books/<id>/kindle {shelf}. Books are named "Title - Author ().epub", like the Kindle's own. - A book waiting for its file keeps
kindle_shelfon book_files/book_adds, andfollow_up()(end of rebuild) queues it once Calibre has it. - Send to Kindle's EPUBs are queued too (shelf "Daf").
- The plugin side is kindle/daf.koplugin/inbox.lua, called from Daf:send after the pages, highlights and /api/kindle/home, so on every Wi-Fi sync (Wi-Fi coming on, a book closing, Home's refresh, Send now) except the one in onSuspend (
send(true, true)), which shouldn't wait for downloads. It posts the home folder's top-level folders (no dot folders, no .sdr) to /api/kindle/shelves, then reads the inbox. An item already at<home_dir>/<shelf>/<name>with the same size is only marked done. A different file by that name, a name with a kind KOReader doesn't list (C.BOOK_EXT), a size over 210 MB, or less than 50 MB left after it (util.diskUsage) is reported as an error and not downloaded. Names and shelves have\/:*?"<>|and control characters taken out, and a leading dot is refused. Ids are percent-encoded. - The downloads run in a child process (ffiutil.runInSubProcess), one after another. Each streams to
.<name>.daf-partin the shelf folder (made with util.makePath), stops past the size Daf gave, is checked against it, renamed, and reported done or with the reason. The parent checks the child every second, kills it after an hour, and when it's over refreshes the file browser and any shown Home or shelf (C.refreshShown), starts the covers pass (so the new books get covers) and shows "Daf put N books on your Kindle." The log says[Daf] inbox: N of M files came in S s. An item reported with {error} leaves the inbox until it's sent again (server side, d955ab0), so a broken connection (the download stopped, a non-200 answer, a short file) isn't reported: the item comes again on the next sync, and only its third failure in a row is reported. Settings keepinbox_tries{id → failures in a row}, pruned to what's still waiting. - Tested in the simulator against a sandbox on a fresh state:
DAF_STATE=<scratch> python -m daf.cli kindle-token sim, tools/sandbox.py on a spare port, rows put straight into kindle_queue, and the simulator's settings/daf.lua pointed at it (and put back after).
Vault notes with the full text (daf/vaultnotes.py, daf/vaultmd.py, 2026-10-25)¶
What I highlight or write a note on outside a book now lands in the vault the way Readwise's Obsidian export does it, text and all.
- Which notes:
write_articlemakes a note for any document indocs(Feed, Library, imports, tweets, PDFs, videos, podcasts, reels, Torah readings) and for a podcast clip'spod:books row, once it has a highlight (sourcesdaf,clip,kindle,karakeep) or a document note. Folders:Reference/clippings,youtube,podcasts,torah, andinstagramfor reels. - The head is written once, for a new note: frontmatter
title,author(one"[[Name]]"per person, affiliations after a comma dropped, none for Torah),url,daf(<public_url>/#read/<id>, or#book/<id>for a clip),source,category(article, video, podcast, reel, tweet, pdf, torah),published,saved,words,cover(YouTube's, or a picture on the web),categories([[Clippings]],[[Youtube Clippings]],[[Podcast episodes]]withshow, which is what the vault's Bases filter on),tags,datasource: daf; then#ai-assisted-noteand the title. Nevercreated/updated(Obsidian adds those, the linter strips them) or the keys hardcover-sync and koreader-merge own (NOT_DAFS). After that only thedaf:line changes, when a Feed item moves to the Library. - The region (
<!-- daf -->…<!-- daf-end -->): Source/Author/Published lines and an "In Daf" link (Source and Published left out when an older Daf's## Datahead already says them),## Notes(the document note, ending^daf-note),## Highlights(quote, then([[#^daf-b<block>|in the text]])or the time or verse as the label, tags, the note,^daf-h<id>), and the text under## Full text,## Transcript,## Show notes(a podcast's), or## Caption+## Transcript(a reel's). - The text (
vaultmd.Doc): blocks → Markdown, h2/h3 →###/####(no bold inside), lists, quotes, pictures by address, PDF page marks, cues with[m:ss](youtube…&t=Ns)or**m:ss**and the speaker, verses as**n** Hebrew+ English. Plain text is escaped so a#,$,[[,==,^or a leading1.in an article never becomes a tag, math, a link, a mark, a block id or a list. Highlights are placed bypos(b/s/e/be) when the words there still match (offsets counted without whitespace, since the reader counts over the page and the Kindle over the block's plain text), otherwise by their words (prepicks between repeats; long Kindle quotes by their ends). Marked words get==…==(trimmed off punctuation that would stop the mark closing), and each highlighted block gets^daf-b<i>. PastFULL_TEXT_MAX(150,000 characters), only highlighted blocks follow, with "…" between and a link to Daf. - Only Daf's own notes (frontmatter
datasource: daf) get the text. A Readwise note, a vault essay (ve:) or a karakeep-sync note (Reference/karakeep, linked by itsid:for akk:document that has no note yet) only gets highlights, without itskarakeep-source ones and without a document note it already contains. A note Daf never wrote in doesn't get an empty region. - Vault wins, as in book notes: an edited highlight note (
review_state.vault_hash) or document note (kvvault_note:<doc>) is taken back into Daf before rewriting. Only the part above the text is read for that. - When: every highlight change (
write_for), a document note, an import (also a link's own note: Raindrop'snote, a CSV'snote/notes),save_to_library(the note follows, itsvault_pathcarried over even when the Library copy had a books row), and after areel,captions,whisper,pdforpagejob (vaultnotes.refresh), so text that comes later fills the note in. - Backfill:
daf vault-backfill --dry-run DIRcopies each affected note to DIR as it would be and rolls the database back; without it, it rewrites for real. It covers notes Daf made (datasource: daf), links Karakeep-era documents to their karakeep-sync note, and makes notes for documents that have highlights or a note and never got one. An older Daf's note keeps its head; the keys it lacks are added at the end of its frontmatter, and nothing already there changes.
Podcast episodes (daf/episodes.py, 2026-10-28)¶
An episode gets a page in Daf: Daf's own player over its transcript, chapters, Clip 30s, and where you stop going back to AntennaPod.
- One episode, one key: its audio address (the feed's enclosure), which is also what AntennaPod syncs under.
keys()adds any other feed's copy with the same guid. Its page is akind = 'podcast'doc: the Feed item when there is one, its Library copy once it's highlighted or saved (save_to_librarynow carriesenclosureandduration, and doesn't queue apagejob for an episode), or a doc of its own,ep:<sha1(audio)[:16]>withwhere_ = 'podcast'(Save to Library, no Later/Done; Back goes to #podcasts).doc_for()prefers library, then feed, then its own. - Feeds (
read_feed, at most every 6 h per feed, kvpodfeed:<url>= {at, title, image | error}):parse_feedreads guid, itunes duration/image/author, show notes (content:encoded or description), and the Podcasting 2.0podcast:transcript(best of JSON > VTT > SRT > HTML) andpodcast:chapterstags into newgp_episodescolumns, keeping what an earlier read knew. AntennaPod's subscriptions go through it daily (gpodder.refresh_feeds); a Feed item's feed is found through kvfeed_subs. - Transcripts live in
episode_text(audio → cue blocks with chapter h3s, chapters, sourcepublisher|whisper, lang), never in the doc'sdoc_content, so a FreshRSS sync, "Load the full article" or the Feed forgetting an old item can't touch them. The reader getsepisodes.blocks(): the transcript first, then an h3 "Show notes" and the doc's own blocks, so transcript highlights never move. When a transcript first arrives,_storemoves every existing highlight on the episode's docs down bylen(transcript) + 1(only the show notes could have had any), re-indexesdocs_ftswith transcript + show notes (index(), also on save_to_library), and refreshes vault notes.episodes.doc_blocks()is what vaultnotes uses, andvaultmd.Doc(daf=…)links a podcast's cue times to<daf link>/t:<sec>. - The
podtrjob (ref = audio), on its own worker thread ("daf-podcasts",media.LONG) so an hour of whisper doesn't hold up reels and captions: chapters from the chapters JSON, elseffprobe -show_chapters(remote head, or the downloaded file); the publisher's transcript (parse_transcript: JSON segments, WebVTT/SRT with optional hours and<v>voices, HTML<cite>/<time>/<p>, or Buzzsprout's SRT-in-<br>), else the audio downloaded toSTATE/tmp(disk; /tmp is RAM here; 1.5 GB cap) andmedia.whisper(small, int8, 8 threads: about 4–5× real time, so 12–15 min for an hour; English and Hebrew both fine). Cues are merged into lines (_lines): a new one per speaker, ending at a sentence after 6 s or 140 chars. A label before a colon that repeats 3+ times is a speaker (_speakers); "SPEAKER_01" becomes "Speaker 1". - Automatic (
want(…, "auto"), settingspodcasts.transcribe, default on, Preferences → Podcasts): opening an episode page (not aprefetch), a play in Daf, and AntennaPod play actions from the last two days (on_actions, called by the gpodder upload route, sopodcasts-importhistory doesn't queue hundreds). A failed auto try is retried after 6 h, three times at most; the Transcribe/Try again button ("ask") and a clip ("clip") always queue. - Where you are: the page starts from
position(), the latest play action for the episode from any device (says "where Daf/AntennaPod left off"; nothing under 15 s or within 30 s of the end). The player sends stretches toPOST /api/podcasts/position {doc, started, position, total}on pause, on a jump (the stretch before it), every two minutes while playing, and by sendBeacon when the page hides.played()turns each into a gpodder play action (devicedaf, guid, the feed aspodcast) throughgpodder.actions_upload, which also makes the listening session; AntennaPod downloads it on its next sync. Episode pages send no reading beats (reader.beatskips podcasts). - Clip: with a transcript, Clip 30s highlights its lines in the browser like a video's. Without one,
POST /api/podcasts/clip {doc, t, sec}queues aclipjob:media.podcast_clipwhispers that stretch into asource = 'clip'highlight with pos{t}on the episode's doc (the Notebook opens it by seeking), and queues the whole transcript. Clips shared from AntennaPod to Ezer land on the episode's page the same way. A clipped Feed episode goes to the Library (media._keep). - Routes:
POST /api/podcasts/open {audio}→{id};GET /api/podcasts/show?feed=(episodes, newest first, with how far each was played and whether it has a transcript);POST /api/podcasts/transcribe {doc};GET /api/podcasts/audio?u=streams a plain-http episode Daf knows through Daf, Range passed through (an https page can't play http).GET /api/podcastslists Listened lately one row per episode (with its doc if it has one), clips from every episode doc, and the shows with their pictures.GET /api/docs/<id>/transcriptanswers{podtr, ready}for an episode. - Web (reader.js):
podHead/startPodgiveV.playeran<audio>so syncCue, seek, Follow along, Clip and the keys are the video's; ← → are −15/+30; the speed isstore podRate;mediaSessionfor the Mac's media keys.#read/<doc>/t:<sec>opens at a time (a vault link). A transcript arriving while you listen re-opens the page where you were, still playing.#podcasts/<feed>is a show. - Tested in a sandbox on a copy of the state: the three publisher formats in the subscriptions (JSON, WebVTT, Buzzsprout HTML), whisper on a whole episode and on a 5-minute one (57 s), highlights moving on a Feed episode and its Library copy, the gpodder actions AntennaPod would download, a Clip before the transcript (18 s), and playback, skips, speed, stretches and resume in headless Chrome. That Chrome (Alpine) can't decode MP3, so playback was tested with Ogg served over plain http, which also tested the audio route.
Daf, focused (2026-10-30)¶
A design round toward Apple Books' calm and fewer icons (the "Daf, focused" canvas, 11 boards). Web only; the native Mac app was briefed to match.
- The sidebar (app.js
sideMode/railMode/renderSide, reader.jsnavLibrary/navFeed): per device,daf.sideShown(true/false) anddaf.sideSize(mini/full; read from the olderdaf.sideonce). Nothing depends on the window's width any more except the phone layout under 780 px: a coarse pointer starts mini, a fine one full (on the tablet the old width rules made it mini upright and full sideways, and forced full to mini under 1000 px, which felt random, 2026-10-31). One button (data-act="sidetoggle",.sidetogin the brand row,#sideshowfloating in the same corner when hidden) shows and hides; ⌘ too..sidegripon the edge is dragged: the size nearest the pointer (full ≥ 146 px, mini ≥ 28 px, else hidden); on touch a small handle shows. Above 780 px.sidedoesn't scroll (its.navfulldoes), so the handle isn't clipped.html.railis the mini look (the old tablet rail CSS),html.sidehidtakes the column away;.side.peekslides the mini one in over the page (the#sideshowbutton, a touch swipe from x < 22 px, ⌘),.side.openthe full one. Reading routes (read,ebook,pdf,mread) are always hidden (html.reading). ⌘ cycles full → mini → hidden, or peeks on a reading screen. Rows: Today, Review, Library (▸ kinds and saved views,daf.libOpen), Books, Feed (▸ folders,daf.catsOpen), Highlights; then Pinned from settingsnav_pins(Preferences → Sidebar; podcasts, manga, torah, recovery, shortlist by default; paper, sources possible); then Search, Stats, Preferences, What's new. Everything is also in ⌘K. Below 780 px the phone's tab bar and More sheet are unchanged and hold the same rows. - The article bar (reader.js
drawReaderTop): back, Aa, the title (.rtitle, shown once the h1 has scrolled away:.rv.titled), Listen (icon), Save (Feed orwhere_ = 'podcast'), Done, …, notebook. Shortlist, Later, Open original, Focus and next/previous moved intoopenMore.chrome()inonScrollhides the bar (.rv.chromehide) after scrolling down past 160 px and shows it on scrolling up, near the top, the pointer within 64 px of the top, or a menu opening; resuming at a saved place setsR.lastYfirst so it doesn't count.#rleft(sticky under the text) says the minutes left. - Books (book.js): the contents and the side panel are sheets at every width (
body.btocsheet/bpanelsheet,.bscrimcloses them,openToc/openPanel(tab)/panelShown); the grid is one column, so foliate's two columns (max-column-count2 when paginated and the window is wide and landscape, ≥ 1180 px) always have room. Pages (paginated) is the default flow when the device hasn't chosen. The bar: back, title, Contents, Aa, Search, Bookmark, … (Listen, Notebook, About, Ask, Focus are in the … menu).hideChrome()on a page turn after the first 2.5 s, guarded while a menu, sheet, highlight bar, Listen or the jump box is up;showChrome()on the pointer near the top or bottom (the parent document's mousemove: the page itself is an iframe) and on a tap in the middle on a touch screen. - Phone books (≤ 700 px): opens bare (
hideChrome600 ms after opening); the top is ✕ + title + author (drawTopbranches onPHONE()), the footer stays visible when bare, and.bctl(slider#bslide→goToFraction,#bslidet, the roundpmenubutton) shows with the chrome.openPhoneMenu→[data-pm]: toc, notebook, search, aa, listen, ask, mark, focus, more. - Themes & Settings (reader.js
openAa, for articles and books): A/A (±2 px), Pages/Scroll, sixLOOKS(day: daf, paper, eink; night: paper, eink, oled as Dusk, Night, Black: a day look setslight(and mode light if it's dark now), a night lookdark), Auto/Light/Dark, then<details>for Font and Spacing (the open one is remembered while the panel redraws,R.aaOpen).#pop.aapop, and.aasheet(a bottom sheet) under 700 px. - Panels over the page fill its height (2026-10-31):
.rpanel/.tochavealign-self:startfor their sticky-column life; Chrome (and the Android WebView) now align positioned boxes, so as fixed sheets (≤ 1180 px, the book's sheets, focus mode's drawers) they were only as tall as their content: the tablet's "empty area". They getalign-self:stretchthere. - Focus mode's buttons: the reader's panel button, the article's … → Notebook and info, and the book's Contents and … → Notebook open focus mode's drawers (
C.Focus.panel(open?),C.Focus.toc(open?)), since focus mode hides the column and the book's sheets.
Daf reads the feeds itself (2026-11-01)¶
- sources/rss.py has the same functions as sources/freshrss.py (subscriptions, folders, subscribe, edit_subscription, unsubscribe, icon, sync, mark_read…); sources/feedsrc.py picks one by
settings.feed_reader("daf" or FreshRSS by default), and every caller (reader.py, sync.py, imports.py, the /api/feed and /api/add/feed routes) asks it.connis passed along for rss.py's sake; freshrss.py ignores it. - Tables:
feeds(id "feed/12" as FreshRSS named them, so docs.feed still points at them; url unique; folder; icon key; etag/modified; fetched/ok/error/errors; attrs) andfeed_seen(feed, "id:" / "link: "), so a post read and forgotten never comes back (kept 400 days). - sync: feeds due every 30 min (feeds.every), 8 at a time, conditional GET, feedparser; posts go into docs (where='feed', id rss:
) exactly as FreshRSS's did; the expiry window and the forgetting are freshrss.sync's. Pull to refresh reads every feed (force). One read of all 96 feeds takes about 16 s. daf feeds-import(rss.import_freshrss): FreshRSS's subscriptions through its API, its per-feed attributes throughdocker exec freshrss php(read-only; only read_upon_reception, ssl_verify and timeout are honoured), icons through f.php into STATE/feed-icons/, every post it brought in the window (read or not) into feed_seen by link, and feeds that FreshRSS had twice (gluetun, jellyseerr) merged into one. Then feed_reader= "daf". Mark read and star are local from then on.- Local tools on the Feed (2026-11-01, instead of FreshRSS's SQLite, Fever and Google Reader APIs):
GET /api/tools/feed?folder=&limit=60&days=0({folders [{name, unread}], items [{id, folder, feed, feed_id, title, url, author, summary, published, kind, open}], unread, feeds, read_24h, open}; private titles left out),POST /api/tools/feed/mark({ids, state: read | unread | save}; save is save_to_library, since a Feed item has no star in Daf) andPOST /api/tools/feed/mark-folder({folder}), with a device token: the desk (reader.py, wire.py's shelves, desk-api.py's feeds/mark) and the office (read feeds). The scripts that follow feeds use the command line:daf feeds-list --json,daf feeds-add URL --folder F --title T,daf feeds-remove URL …(substack-sync.py, security-watch.py). All of them go through feedsrc, so they work whichever reader is in charge.