Known bugs, not yet fixed
Recorded here rather than left in a commit message. This whole list is
generated — from the open issues I have marked
accepted,
plus the ones I found and wrote up myself.
Report
a bug and it lands here once I have confirmed it, and leaves when the issue closes.
Two halves of that are deliberate. The listing is automatic, so a bug cannot sit fixed-but-still-listed or listed-but-quietly-forgotten — closing the issue is the only bookkeeping there is. The accepting is not, because anyone can file and this page is public: an ungated pipeline would publish a stranger's words to it in under a minute, and while nothing typed into an issue can inject markup here, no amount of escaping makes a wrong report right. Confirming costs me a label. Not confirming would cost you the truth of the page.
Legacy catalogue exports can still re-import as books
1.1.1 made the catalogue export always write type: movie /
type: show, which is what routes a file back to the right importer.
Files exported by 1.1.0 or earlier were not rewritten, so a bare
film export from then — no director, no collection, no character/actor/timestamp on
any line — still carries nothing identifying it. Re-export it, or add a
type: line by hand. Not fixable retroactively: a detector cannot infer
what the file never recorded, so this one stays listed rather than pretending to
be fixable.
The backlog
Everything still ahead, in priority order, in three bands. Nothing that has shipped is listed here — that lives in the changelog, and a roadmap that doubles as a trophy cabinet stops answering the only question it is for.
The § number is a position and nothing else. It moves whenever the
order does, so it is worth nothing as a name. The stable reference is the issue that
tracks the section — #15, #16 — and that is what the §
number links to. Cite the issue; the position is just where I would reach next.
Every section is a card that starts closed. Open the ones you care about, or jump straight to one from the contents.
Next
What I would pick up now. Short, mostly cheap, and each one either removes a daily friction or is the thing this app can be distinctly better at.
§1Account, continued — sign-in and tokens
#17
All local — no email round-trips anywhere in this section, which is the constraint that decides most of its shape.
- Sign in through the reverse proxy you already run — OIDC, and the cheaper half first, forward-auth: trust a configured identity header when
TRUSTED_PROXYis set, which is the same trust boundary the login rate limiter already reasons about forX-Forwarded-For. Anyone fronting their box with Authelia, Authentik or Tinyauth wants their whole estate behind one sign-in, and it is the most-asked thing on this list. Local passwords stay, and stay the default. - Stronger sign-in (opt-in) — passkeys (WebAuthn) and TOTP 2FA layered over today's password + hashed-token sessions, for boxes reachable past the LAN. Off unless you turn it on; the password stays the fallback.
- Scope the API tokens, and add webhooks. Device tokens exist; what they have no notion of is scope — a token that may read stats but not write quotes, which is what an Obsidian sync or the dashboard widget actually wants. Outbound webhooks on events are the other half.
- List and revoke browser sessions individually. Paired devices can each be unpaired by name, but browser sessions can only be dropped all at once by changing your password — so "sign out that other laptop" currently means signing out everything.
§2Mobile & PWA — offline first, then gestures
#22
Tippani installs as a PWA and then behaves like a browser tab with a nicer frame, because there is no service worker at all. That is the real reason it doesn't feel native — more than any missing gesture — and it is the first thing here.
Cache the shell and recently-viewed data, queue writes while offline, flush on reconnect. A queue flush is a burst of concurrent writes, so it needs a retry path that distinguishes "the box is busy" from "the box refused", and the write path underneath it now behaves well enough to write one against.
Gesture ground rules
Today the only gestures are scrolling, the drawer's swipe-to-close, and dragging a control thumb or a sticker. Adding more is worthwhile, but a web app that fights the operating system for a swipe loses, so the constraints come first and apply to every gesture below.
- Nothing starts at a screen edge. Ignore any gesture whose first touch lands within ~32 px of the left or right edge — that belongs to the iOS back-swipe and the Android back-gesture — or in the bottom home-indicator strip. No code measures this today, so it gets written rather than reused.
- Prove direction before capturing — 10 px on the dominant axis, dominant by about 1.5× — so a gesture can never steal a scroll.
touch-action: manipulationon card chrome to drop the double-tap-zoom delay;touch-action: noneonly on the element that owns a gesture, never on a scrolling ancestor.- Every gesture is additive. The ⋯ overflow, the ♥ button and the filter sheet all stay exactly as they are. That is both the accessibility requirement and the answer to how anyone discovers a gesture in the first place.
- One Settings toggle turns card gestures off wholesale.
prefers-reduced-motionis already honoured; the toggle is not. navigator.vibrate(10)on commit — Android only, iOS Safari has nothing — which is most of what makes a gesture feel deliberate rather than accidental.
The set, in build order
- Double-tap a quote card to toggle ♥. Understood everywhere, and it gets the same heart-bloom the button does.
- Pull down at the top of a list to refresh. Standalone display mode suppresses the browser's own pull-to-refresh, so this is mine to implement, with progressive feedback and
overscroll-behavior-y: contain. - Long-press a card away from its text for the ⋯ menu as a sheet — today it selects the card, or anchors a menu.
- Swipe up or down on a review card for got it / forgot. The best fit in the whole list: vertical, so it cannot collide with either platform's back gesture — the Anki-mobile pattern rotated onto the safe axis — and a review card isn't a scroll container, so there is nothing to conflict with. A tap still reveals, and the buttons stay.
- Swipe down to dismiss a bottom sheet.
MobileSheetalready exists, the gesture lives entirely inside it, and every native sheet works this way. - Pinch the Library or Catalogue grid for cover size and density — the direct-manipulation form of the Settings sliders that already exist, snapping to the same discrete sizes.
- Pinch and double-tap inside the cover Lightbox to zoom and pan. The one place native zoom semantics are the right answer, so implement them rather than suppressing them.
- Double-tap the top bar to scroll to top, riding the scroll memory that is already there.
- Long-press + Add for a quick-action sheet, so the same actions the installed app icon already offers on a long-press are reachable from inside the app.
- Two-finger tap to undo — genuinely unclaimed on both platforms, and it pairs with the delete-undo toast that already exists.
- Long-press a colour swatch to make it the default for new captures.
- Drag to reorder anthology entries and staged-import groupings; the sticker drag already proves the primitive.
Two more are unblocked now that trash & undo (§1) has landed and a mis-swipe is recoverable: horizontal swipe inside a card's own bounds for share/edit/delete, Gmail-style, rubber-banding back below threshold and never deleting on the swipe alone; and horizontal swipe on a work-detail hero for previous/next work in the current filtered order, which is the riskiest of the set for being screen-width and horizontal, so it stays on the hero and never the whole page.
One is rejected outright, recorded so I don't revisit it: swiping between the four bottom-nav tabs. It is a screen-width horizontal gesture that fights the back gesture at both edges and any horizontally scrollable content in between, and the bottom bar is already one tap away.
Discoverability rides the existing spotlight: a short gesture chapter in the tour. The help panel already draws the two gestures that exist — the long press and the drawer's leftward swipe — so a gesture added here arrives with a clip waiting for it.
After that
Wanted, argued for, and not blocking anything above. Roughly ordered, but the boundary between one of these and the band above is a week of my attention rather than a decision.
§3Capture from anywhere (share-target + bookmarklet)
#19
Low-cost ways to get text in without a file:
- PWA share-target — Tippani already installs as a PWA, so register it as a share target: "Share → Tippani" from any app (a reading app, a browser, your phone's own text selection or its built-in OCR) drops straight into quick capture. This is also how a photographed page gets in — the phone recognises the text, you share it.
- Bookmarklet — one click that POSTs the current page's raw HTML to Tippani, parsed server-side by reusing the existing Hardcover / Goodreads / IMDb HTML importers. Deliberately minimal: just the page, no Bookcision-style JSON layer to install or keep working.
- Paste a pile — one textarea, blank-line separated, becomes N staged quotes on one work. The "just let me dump what I typed in Notes" path that every parser-based importer misses.
§4Anthologies — ordered, curated collections of quotes
#18
A named, ordered list of quotes drawn from anywhere in the library, carrying prose of its own: an introduction, and my commentary between the entries. It is not a tag with a nicer hat — the two things a tag cannot do are hold an order and hold your writing, and those are the whole point.
This is the missing output of a commonplace book. Everything in Tippani today points inward: you file a passage, you find it again, you get asked about it. An anthology is what you make from the collection — a sequence you arranged, on a theme you chose, with the connective tissue that explains why these twelve passages belong next to each other. Letterboxd's lists are the closest proven form; the nearest thing in the annotation world is Zotero's extract-annotations-into-a-note, which is the most-used feature it has.
Most of this shipped. The API landed in 2.0.0, the screen in 2.1.0, and 2.1.2 added the themed round and per-anthology field visibility — six switches deciding what each passage shows, governing the reading view and the Markdown alike, so a collection of film lines can name its actors and a book of proverbs can carry nothing but the words. Composing works from any selection, the per-entry commentary is there, and the Markdown export round-trips.
And the rest shipped by 3.0.0
(#18, closed): EPUB export,
a small book of your own quotes, built with the standard library's
archive/zip. What comes next is its own card below,
Anthologies you can arrange and print.
§5Help & density — one registry, four consumers
#23
The registry, web/frontend/src/help.jsx, keys the help copy by screen;
the words live in the locale files. The tour and most info dots resolve out of that
same file under namespaces of their own, and the glossary is hand-written English —
so the same control is still described more than once. Making it a single
source is what is left, and it only gets more expensive.
The panel's own shape shipped in 2.0.1 and is not what remains here: an entry is now one front-loaded sentence with the rest folded behind a more, capped by a test, with a rail of screen sections, live swatches and drawn gestures. That work made this section cheaper rather than smaller — an info dot needs one short sentence, and there is now one short sentence to read.
- The info dots should read from it. The panel does; the dots still carry their copy inline, which is precisely the drift this section exists to end.
- The glossary should be generated from it —
glossary-css.mjsalready feeds it the built CSS and gates that in CI. The copy is the half still written by hand, and the likeliest to go quietly stale. - The tour should read it too. Step copy and panel copy overlap today and neither knows about the other; the same change has already had to be made twice by hand, which is the argument.
- Deep links —
/library?help=filters. Not "add a key": the shell parses a pathname and pushes a path, with noURLSearchParamsanywhere, so whoever builds this builds the app's first query-string state. - The completeness test. Tests already pin every screen to an entry and every entry to copy; the third clause — every dot resolves to a key — is unwritten, and the 33 English literals still sitting in eight files are why.
§6Access & reading comfort
#32
Most of this has shipped. The contrast work is done in both halves:
ink and hairline contrast raised to WCAG AA across all four looks, and a switch in
Settings for a reader who cannot or will not set it at the operating-system level
— resolving to the same state as prefers-contrast: more rather than
competing with it. The quote text gained the two axes that were fixed,
line height and measure, beside the size dial that was
already there; line length is the largest readability lever there is in a body of prose,
and the paper and film looks are deliberately generous with it — right for reading
a card, and exactly what somebody with low vision needs to be able to narrow. And the
quote box no longer offers only serifs: OpenDyslexic is bundled and
named in Settings → Type, for the quote text and for the interface both.
- A named, focusable equivalent for every gesture added in §2. The rule is settled, and each of the four gestures listed under §2’s ground rules now satisfies it: scrolling is the platform’s own and answers a keyboard already, the drawer closes from a button as well as a swipe, a control thumb is a real
<input type="range">and answers the arrow keys, and the seal on a quote — the last thing here that could only be dragged, and a position kept with the quote rather than a flourish — answers them now too. What is left is holding each gesture §2 adds to the same rule as it lands.
§7Data hygiene
#28
The after-import jobs the bulk editor and staging miss.
- A shared text-cleanup pass — de-hyphenate across line breaks, drop page numbers and running heads, normalise the quote marks and ligatures that get mangled in transit. §20 lists all of this as OCR post-processing, but it belongs server-side and shared, because the free-text sources and OCR both produce it, and the dedupe hash's typographic fold is a map worth borrowing.
- Per-batch import rollback. Approval deletes the staged rows and the
import_batchesrow too, and nothing it writes carries a batch id — so this needs a provenance record, not just an endpoint.
§8More import sources
#21
Kobo (KoboReader.sqlite), Apple Books, a Readwise
export, and read-later apps (Instapaper · Pocket · Matter), all folded into the
same idempotent, cross-source dedupe. Kobo is unwritten rather than unverified: no
parser exists yet, and I have no device here to test one against. The Kindle
My Clippings.txt parser already reads eight locales by structure rather
than by English, so it needs the same thing — real files from those Kindles.
Three more, added after a look at what the neighbours read:
- KOReader annotations (
.sdr/metadata.*.lua). To be clear about which half of KOReader this is: Tippani reads the annotations, not the books. Grimmory, Kavita and Calibre-Web all sync KOReader progress, which means the e-ink crowd is already the overlapping audience — and they are the people who highlight most. - A generic CSV / JSON importer with column mapping — pick the file, map its columns onto quote fields, preview, stage. This is the future-proof answer to every source there will never be a bespoke parser for, and staging is what makes it safe enough to offer.
- Subtitle files (
.srt/.vtt) — drop one, pick the lines worth keeping, and they stage as dialogues with the timestamps already correct. The film-side equivalent of a Kindle export, and the one thing the movie-quote sites prove people actually want to do.
§9Choose your metadata sources
#39
Today a lookup fans out to whatever happens to be configured, in an order baked into the code, and you find out what it consulted by reading the result. I want the sources to be mine to pick — per lookup, and as a saved default.
- A source picker on every metadata fetch. Checkboxes for the sources that apply to what is being looked up, remembered per kind: books on Google Books, Open Library and Amazon, films and shows on TMDB and TheTVDB, games on IGDB and Wikidata, people on Open Library and TMDB. "Just Open Library" is also the honest answer to a source wrong about a particular book.
- A source with no key should read as inactive where you are asking. Settings already says the state out loud — a chip for whether film lookups have a key at all, another for whether the last book lookup worked. What is still open is the same truth in the lookup: a source that cannot answer should be greyed out in the picker, with a link to the card that takes a key, rather than dropped silently from the fan-out.
- It matters most for people. Looking up Subhas Chandra Bose should query Wikidata rather than the film database a speaker is sent to today — which means writing a Wikidata person client, not ticking a box over one that exists. An actor already goes to TMDB alone.
- Pairs with the re-verify flow that already exists: once you can choose sources, "re-check this show against TheTVDB only" becomes a sensible thing to ask for.
§10Collections & tag shelves
#26
Extend tagging to books — tags live on every quote kind but on no work — then a view that groups them by tag, which Search previews for quotes today. Curated, named groupings ("Best of 2026", "to reread") as first-class collections: a third way to group these rows, after boards and anthologies.
A note on the word: this section used to be called Collections & shelves, but "shelf" now means where you stand with a work — reading, paused, completed — so what this section builds is tag shelves, and the plain word belongs to the other feature.
Tag hygiene belongs here too, because the vocabulary is load-bearing already:
- Merge tags, which every managed vocabulary needs by its second year. The usage counts skip
utterance_tags, so a tag worn only by standalone quotes reads zero uses — fix that first, then re-point three join tables. - Nested tags —
theme/grief, displayed hierarchically. Deliberately a display convention over the existingnamecolumn rather than a parent id, so there is no schema change and no migration to regret. - An optional mood and pace starter vocabulary. The thing StoryGraph is loved for is, underneath, a well-chosen tag set; offering one as a suggestion on the Tags page costs a list of strings and no new field.
§11Fields users will name
#29
Unglamorous, low-risk, and the sort of thing that turns up as an issue rather than a feature request.
- Book fields — format (paper · ebook · audio), language, publisher, subtitle. Format and language are the two that get filtered on. Page count is deliberately not on this list: it is already stored as the progress bar's denominator, and a second metadata-sourced page count would give the app two numbers that can disagree. If it is ever wanted as canonical metadata it should feed that one, not sit beside it.
- Film and show fields — runtime, original title, country, language, certification. Four ride along in the TMDB responses already fetched; certification needs one more appendix. They are what a country or language breakdown needs.
- A per-season episode map from TMDB or TheTVDB. The shelf stores a season total and the current season's episode count, because that is what a viewer knows off the top of their head; a real map means progress through a long run stops needing you to supply each season's length by hand.
- "Attributed to" on a quote — for a book quoting someone else. An epigraph is currently credited to the book's author, silently, which quietly corrupts both the author breakdown and the author multiple-choice distractors.
- A locator-kind hint per book: whether its locators are print pages, Kindle locations or percentages. The staging location formulae already show that people care about locator arithmetic.
- A spoiler flag, blurred until tapped. Standard wherever reviews are public, and it matters as soon as anything is shared.
§12Desktop keyboard
#30
The shortcut registry shipped, legends on the buttons and all. What is left of the desktop gap is the layout itself.
- An optional denser layout — one toggle, not a redesign. The paper and film aesthetics are rightly generous for reading and expensive for triage.
Further out
Real, wanted, and waiting on something — a dependency above, hardware I don't have, a date that hasn't arrived, or simply a bigger appetite than I have this year. Nothing here is a maybe; the maybes are further down under Later / maybe.
§13Serendipity & looking back
#27
- Ambient mode — full screen, one quote at a time, slowly cycling, in whichever skin is current. The paper and film aesthetics are the nicest thing about the app and are currently only ever seen at card size. This is also the version that earns its keep on a display wired to the NAS.
- Year in review — the numbers are already computed for the Stats page; what is missing is the sequence that walks you through them and the single image worth sharing at the end. Drawn from quotes, tags, people and the review loop only — deliberately not from the read log, even though "books finished this year" is now sitting right there. See the note under Considered and set aside; this is the first place anyone would reach for it.
Shuffle and on this day were the cheap half of this section and shipped in 1.16.0.
§14Achievements — quiet milestones, and one gentle streak
#31
A deliberately restrained take. Achievements mostly mark distance travelled — reading and collection milestones drawn from data already in the library and computed at query time: no counters table, no background jobs, no cron, nothing ticking. Off by default, private, nothing social and nothing that phones home; shown as a modest, dismissible shelf on Home or Profile. Candidate milestones, all derivable from what is already stored: your first hundred highlights; a whole book carried through the forgetting curve; ten authors on the shelf; a passage recalled correctly five times; a series completed; a film quoted from every act.
The one place a streak earns its keep is the spaced-repetition review, and one already counts the days you cleared the due deck, on Home and in the drawer. What this section changes is the miss: the count drops to zero, where it should spend a built-in grace instead and never be dressed up as a loss. "You broke your streak!" banners are exactly what I won't do. Streaks stop at the review; nothing else in the app grows one.
It waits on appetite alone now: the review loop its one streak rides on has landed, and being off by default makes it easy to wait for.
§15Interop — feeds, formats, a CLI
#24
The way a self-hosted app wins "works with everything" is not by writing thirty integrations; it is by speaking two or three formats other tools already read.
- A feed of recent highlights — RSS, Atom or JSON, token-scoped, riding the scoped tokens of §1. One endpoint reaches Obsidian, FreshRSS, Home Assistant, e-ink dashboards and a terminal widget, which is a better return than any single bespoke integration.
- A random-quote endpoint in SVG — the JSON form is
GET /shuffle, which a device token reaches. SVG rather than PNG: its text is string concatenation, and the dashboards that would display it render SVG natively. - CSV and JSON export. Markdown and the zip serve Obsidian well; CSV is what a spreadsheet needs and JSON is what a script needs, and both are small next to the export builders already written.
- EPUB export of your own highlights — a small readable book of your own quotes, to put back on the e-reader they came off.
archive/zipis already imported for the library export, and an EPUB is a zip with three XML files. Nothing else in this category does it. - A print stylesheet, for the paper version of the same idea.
- BibTeX / CSL-JSON per work — nearly free, since ISBN, author and year are already stored, and the one thing an academic user asks for first.
tippani quoteandtippani exportas CLI subcommands, alongside the existingserve/user/healthcheck. Afortunefor your own library.- Export presets — a named, saved combination of fields, template and filter, so the monthly dump into Obsidian is one command rather than six choices.
-
More quote-image templates.
quoteImage.jsrenders one layout in four skins; centred-serif, film-still, index-card and minimal, plus story, square and wide aspect presets. The one part of sharing an export cannot do, since a picture is not a file format anyone else reads.
The one worth building first is a portrait layout: the author's, actor's or speaker's photo bled down the left and faded into the quote text on the right — the shape that actually reads on Instagram and Facebook, where a centred serif card does not. The portraits are already fetched, disambiguated and stored locally, so the art is on disk and nothing new needs downloading; the work is the gradient mask, a legible text well over it, and a sensible fallback when a person has no photo. Square and story crops matter most here, since that is where it would be posted.
§16Homepage dashboard widget
#25
A widget for Homepage (and similar
self-hosted dashboards), so Tippani shows up as a live tile on your NAS dashboard.
GET /stats already answers a device token with the totals and the latest
quiz score, unscoped and far from small; today's pending
spaced-repetition count only the review loop knows. Opt-in; nothing exposed
without a token.
Tier 2 and most of tier 1 shipped in 3.0.0
(#25, closed): Profile →
Dashboard widget makes a read-only key, GET /api/widget answers four numbers
(works, quotes, forgotten, mastered) for that reader's own library, and the README has the
services.yaml to paste. Tier 1 landed as that README section rather than a
docs page, and without the optional homepage.* compose labels. What is left is
those labels and tier 3.
The three tiers, as planned:
- Document what already works — Tippani's unauthenticated
/healthzmeans any dashboard can ping it today, so a "Dashboards" docs page with a ready-to-pasteservices.yamlsnippet and the optionalhomepage.*compose labels costs nothing and lands first. - Custom-API widget — Homepage's
customapiwidget can render live counts with zero upstream code, but its requests come from the Homepage server with no session cookie, so this tier rides on the scoped API tokens of §1: a slim read-only stats surface acceptingAuthorization: Bearer, plus a documented field mapping. This is the real deliverable. - Native first-party widget (later) — a PR into gethomepage/homepage. Gated upstream: the widget must target a feature-request discussion with ≥20 up-votes, and widgets for projects under about a year old get declined — so the discussion gets opened early to accumulate votes, and the PR waits for 2027. It consumes the exact endpoint tier 2 already built.
§17Ops
#33
- A
/metricsendpoint (Prometheus), behind a token and off by default. This audience runs Grafana, and the numbers are already computed for Stats. - Off-box backup without a scheduler. Uploading to rclone, WebDAV or S3 on a schedule means a timer in the app and a dependency, both of which are refused elsewhere in this document for good reasons. The version that fits is to ship the primitive — a documented one-line
curlagainst the existing backup endpoint, plus atippani backup --outsubcommand — and let the box's own cron do the scheduling it is already running. The archive is encrypted before it leaves the process, so a copy sitting in someone else's bucket is not a copy of your library. - A
tippani open-backupsubcommand. The archive is sealed (AES-256-GCM, Argon2id off the password you typed, or a passphrase you set), which is right for a file that holds every password hash on the instance and wrong for the moment you want to look inside one without restoring it. A subcommand that takes the credential and writes the plain.tar.gzto stdout keeps that door open without keeping the archive open — and it belongs to the binary rather than the API, because it needs no server running. Until it exists the format is documented ininternal/httpapi/backup_crypto.go, in enough detail to write the fifty lines yourself. - A keypair per user, so revoking an admin revokes their access to the archives. Today an archive carries its key wrapped under the instance recovery key, so any password on the box opens any archive the box made — which is right, and got there by being simple rather than by design. A keypair per user would let a backup add a wrap for every admin, including ones whose password it has never seen, and let revoking an admin drop just their wrap. That is the shape to reach for if the recovery key ever needs to be per-user again; it is deliberately not the shape now, because the published contract for this format is "fifty lines you could write yourself".
- An operator-held recovery code. Forgetting your password still loses the archives you carried off the box — the recovery key covers the box, and the password covers the file, and there is nothing behind either. A code printed once at first backup, wrapping the archive key a third time, would cover it. It means one more secret for an operator to lose, a print-once flow, and a CLI enrol command, which is a release rather than a paragraph.
- A read-only mode flag. The Pages demo already simulates one client-side; a server flag would let someone share a live instance without risk.
- A per-user storage cap, for anyone hosting for family. Images are the only unbounded growth path in the data directory.
§18Out in the world — directories & icon CDNs
#36
Getting Tippani discoverable where self-hosters actually browse. No code here — outreach and asset prep, in dependency order:
-
Icon CDNs first, since dashboards and directories pull art from them:
- dashboardicons.com — open submissions via the site's form or a GitHub issue. Needs a clean
tippani.svgin kebab-case; they generate the raster sizes. The mark uses anfeTurbulenceedge-roughen filter that some raster pipelines drop, so a flattened submission variant gets prepared first — flattening displaced geometry, not a noise layer. - selfh.st/icons — no external PRs accepted; icons are requested via GitHub Discussions and added by the maintainers. This is also where the selfh.st/apps directory sources its art, so it goes in before or alongside the listing request.
- dashboardicons.com — open submissions via the site's form or a GitHub issue. Needs a clean
- selfh.st/apps — a one-person curated directory with no submission repo: reach out by email with the repo, a one-liner, a demo link and the icon. Being picked up by the This Week in Self-Hosted newsletter's "New Software" section is the usual front door.
- awesome-selfhosted — the biggest list, syndicated by many others, but it rejects projects younger than 4 months, counted from the first release (2026-07-03), so this waits until November 2026.
§19Grimmory sync (self-hosted, direct MariaDB)
#35
A pull source for anyone already running Grimmory self-hosted: point Tippani at that instance's MariaDB database and sync straight from its tables, with no export file in the loop. It reads book covers, edition metadata and annotations directly from Grimmory's schema and folds them into the same idempotent, cross-source dedupe as the file importers, so a re-sync never doubles a passage. Covers come across too, which needs read access to Grimmory's cover store — so the plan assumes both services sit in the same compose file: Tippani is handed the Grimmory DSN plus the relevant media path, wired through the environment. Read-only and opt-in — nothing runs without a configured DSN. A first pass is a one-shot pull, with a scheduled re-sync as the follow-up. Unverified until I can test it against a real Grimmory box.
§20Android app — capture by camera, with on-device OCR
#37
A native Android client, Flutter so the source stays portable, in
mobile/. Not a wrapper around the PWA: the point is the one thing a
web page on a phone cannot do well, which is photograph a page of a
physical book and turn it into a highlight. Recognition runs
on the device (ML Kit), so the server gains no dependency, no CPU
cost and no upload path — the reason server-side OCR was set aside doesn't apply
to it.
The work that isn't the OCR call itself — which is thirty lines — is what makes it worth having: reflowing recognised lines into paragraphs from their bounding boxes, de-hyphenating across line breaks, dropping running heads and page numbers, normalising the quote marks and ligatures OCR mangles, and then a correction screen with the photo beside the text. A local mirror keeps browsing and the Daily Quiz working with no server, and captures queue offline and flush when the box is reachable.
Two notes on where the work belongs. The text cleanup above should live server-side and shared rather than in the app, because every import source produces some of it; that is §7. And the photo the correction screen already holds beside the text is worth keeping, attached to the quote: one nullable path column on an image pipeline that already stores covers, posters, portraits and stickers, and it keeps the evidence next to the transcription for the next time you doubt a line.
The server half of this is done — device tokens and pairing, list paging, gzip, the
capabilities handshake (a revision behind anthologies and locales),
noted_at on create so a queued capture keeps
its real date, and duplicate 409s that make a retried flush idempotent.
What remains is the app, and it is the largest single item on this page.
Android only. Flutter compiles for iOS and the Dart here stays
platform-agnostic, but building and signing for iOS needs a Mac, and I don't have
one — so no ios/ directory ships rather than an unbuildable one
rotting in the tree. A fork with a Mac adds it with
flutter create --platforms=ios . and a signing config; the README says
Android and does not imply otherwise.
From your requests
Things somebody asked for that I have said yes to. Ask for something and it goes on the tracker; it arrives here when I accept it, and leaves when the issue closes — shipped, or thought better of. Everything open is visible on the tracker whether or not it ever gets this far, so a request is never quietly buried.
Accepting is one label on one issue and nothing else, which is the strongest thing I can say about this list: it is not a list I curate. I cannot add an entry without agreeing to it on the tracker in public, and I cannot forget to remove one, because closing the issue removes it. A hand-kept list of promises drifts from the promises actually made. This one has no way to.
Atrium, the glass material set
atrium is the eighth material set and the only one still a
placeholder — four flat tiles reserving a slot for something translucent and
lit from behind, built to Apple's own Liquid Glass guidance: a functional layer
for the rail, the top bar and sheets, never the cards a board scrolls past, with
a real edge that bends light rather than a faked rim.
Getting there starts by fixing a live defect the design pass found: choosing Atrium today saves nothing, because the server does not yet know the name. From there, the glass itself — a blur that actually turns off under reduced transparency, an accent that stays a light rather than a colour, and a performance budget measured on a real board before a line of CSS ships, not assumed after.
Episode names, and up to three orderings
A series episode has never had anywhere to hold a name — only the season and episode numbers it airs under. This gives it one, plus up to three orders to view it by: broadcast, a published DVD or streaming order, and a hand-built custom one — because some shows, Firefly among them, were never watched in the order they aired.
Names and the alternate order can be fetched from the same providers the library already trusts, never automatically and never overwriting a name typed by hand. Reordering gets a real drag control, built once and shared with the anthology entry list, with a keyboard route from the outset rather than added later. It split off the entry-form work that discovered the gap, which has shipped (3.0.0).
Locators from files — dialogue timestamps and ebook locations
Drop a subtitle file (SRT, VTT, ASS/SSA) or an ebook (EPUB, MOBI/AZW3) beside a dialogue or book, and the app reads it to find where each quote appears — as a timestamp for dialogue, as a location or chapter for ebooks. The positions are proposed for review in a new Checks section before applying, never stored in the file itself, and matched through the same deduplication fold the library already uses, so an apostrophe means the same thing in both.
An ambiguous match carries both candidates and the evidence — the matched text — so the eye can decide. A file for the wrong cut says so instead of writing 200 wrong times. A refusal is durable: reject one proposal and it stays rejected on the next scan, the same model Cleanup uses. Dropping one file on one work opens a screen listing every cue or paragraph with your own matches already highlighted, so a fuzzy one is checked by eye rather than accepted blind.
Source files — a work keeps the file its quotes came from
A quote out of context can read as nonsense, and until now a work has never kept the file its quotes came from. This gives it one source file per role — a subtitle track for a film, an ebook for a book — arriving either uploaded or found on a read-only mount of your own library, the route that scales past uploading one file at a time.
What the file is for is stored the moment it is read: where each quote sits, and the text around it, so a position and a quote's context both survive the file being deleted. Once every quote from a work has that context saved, the raw file itself can be thrown away — measured at 29 times smaller than keeping it — and added again later if new quotes need placing. Nothing here is a reader: no rendering, no pagination, no download, and nothing fetched from anywhere.
Change one surface yourself
A material set dresses four surfaces at once — the desk, the furniture, the page and the binding. This is the other half: keep the set you like and swap just one of them, out of the twenty-seven tiles the app ships. What you leave alone stays the set's own material.
Half of it is already in the app and none of it is reachable: a saved look carries per-surface tiles, and a theme file exported from another install can contain them. The missing piece is the picker.
Change one surface yourself, per slot
A material set dresses four surfaces at once — the ground, the furniture, a card and a cover. Choosing one is a single decision, which is the point of the sets, but it leaves no way to say this set, with a different card.
The machinery already exists and is reachable by nothing: a per-slot override is stored, resolved and drawn correctly, with no control anywhere that writes one. What is missing is the row — twenty-seven tiles, one slot at a time.
An Admin section that never reads your library
Settings › Server becomes Admin: the users on the server with their sign-on state, single sign-on set up in the app, maintenance, and a temporary password issued from a user's row. (Admin-set passwords are already temporary as of 3.0.0.)
Everything personal stays on Profile — your own API keys, notifications and sign-on link — and nothing in Admin opens another reader's quotes.
Anthologies you can arrange and print
Drag entries into order, add your own section headings, or group by work, person or character. Removed entries stay removed, and a cover, epigraph and dedication open the book.
A continuous reading mode, better printing, and a PDF whose text you can still select.
A signature beside a person’s quotes
Add a person's signature to their details, and put it under their quote on a shared image.
Richer author portraits
Resolve the author's Wikidata entry via the book, so a photo appears even when the Open Library record is sparse. The disambiguation already picks the right person; this only widens coverage, which is why it is small enough to say yes to.
It should land after §9, not before: once sources are mine to pick, looking a person up should query Wikidata and not waste a call on TMDB. Doing it in the other order would bake in the fan-out that section exists to end.
Quotes read aloud, in the browser only
Reading a quote aloud, using the browser's own SpeechSynthesis. It
costs the server nothing, needs no key, and works offline once §5's service worker
exists — which is the entire reason this can be accepted while server-side speech
stays refused. Those are not the same feature wearing different clothes: one is a
model and an audio pipeline on a box that cannot spare either, and the other is an
API the browser already ships.
Accepted rather than parked because the line it sat next to turned out to be drawn
in the right place already — see
OCR, and speech, on the server. Not built yet; the owner widened
the plan (docs/plans/read-aloud.md) from the review card to every quote card and a
quote's detail, whole anthologies and boards read in order, each quote in its own language with
a voice the reader picks per language, and no control at all where the device has no voices.
No stored audio, no transcription, no server route.
Later / maybe (being considered)
Not ruled out, not next. Nothing gets promoted out of here without a reason — and
promotion is now a visible act rather than a private one: everything below carries the
considered
label, and moving it to accepted is what lifts it into
From your requests above. Same issue, same number, same thread of
argument — so if you want to change my mind about one of these, there is somewhere to do
it.
-
AI summaries (opt-in)
A passive digest: batch recent highlights and summarise them with an
OpenAI-compatible model — local or remote, your endpoint, your key.
Grouped by book, tag or whole library; weekly or on-demand. Off unless configured,
generated async, and — true to the frugality goal — no cron dependency.
The digest has to be worth opening in the app on its own before any question of pushing
it arises, which is why delivery is the separate entry below and neither one waits on
the other.
-
Notifications (opt-in)
Would start with NTFY, likely routed through a multi-service notifier such as Shoutrrr so one config reaches any backend —
the email digest fallback below is that same slot with a different
transport. Non-negotiable, whatever I pick: high / urgent priority must carry
through — a resurfaced highlight is a gentle nudge, but "lookup is failing"
should be able to shout, and that case alone justifies this even if no summary ever gets
generated.
This sat in the numbered backlog until I moved it here deliberately, because anything that pushes needs something that wakes up on its own. The cheap end of the ask needs none of it: the app-icon badge already shipped and shows a due-card count with no server work and nothing scheduled.
-
Anki export / import
— bridge the daily review to and from Anki decks (
.apkg), a natural pairing for the spaced-repetition audience. Still being scoped; I need to learn the format first.
-
Backlinks & freeform notes
— manually-maintained links between related highlights, Zettelkasten-style, and
standalone notes not tied to any book. Kept deliberately manual; no auto-suggested
"related" magic.
-
Shared / household libraries
— collaborative or shared-view libraries across the users on one box.
-
Email digest fallback
(SMTP) — the same delivery slot as the notifier above, for people who would rather not
run one.
-
Semantic search
(
sqlite-vec) — a dependency, an indexing pass and a model, on a box budgeted at 20–40 MB RSS. Deferred indefinitely rather than left unsaid: the two read the same from outside and only one of them is honest.
-
Summary export
to Markdown / Obsidian.
-
Reviving a rating
The 1–5 stars were retired on purpose in 0.4.3 and ♥ is the cleaner
signal, so this is recorded as a reversal to weigh rather than as planned work — but
half-stars are the single most-cited feature of the trackers people arrive from, and the
columns are still there, inert, from before the drop. If it ever comes back it should be
per-user and opt-in, so the default library looks exactly as it does now.
-
A voice note on a quote
— record, store, play back, with no transcription, which would be a dependency. For the
reaction you can't type while reading.
Considered and set aside
OCR — and speech — on the server
Building OCR into the Go binary isn't worth the weight, and that hasn't changed: it would be a dependency, a CPU cost, and an upload path, all on a box chosen for being small. What has changed is that I wrote this as though the server were the only place it could live. On-device OCR in a native app costs the server nothing at all, so the feature moved to §20 rather than staying refused, and the share-target route (§3) is still the no-app answer and still planned.
Server-side text-to-speech is refused on identical grounds and belongs in the same entry: a model or a paid API, CPU I don't have, and audio to store or stream. The same escape applies — the browser already has a speech engine, so reading a card aloud is a client feature or it is nothing.
A built-in reader, OPDS, and file sync to a Kobo or Kindle
Tippani holds no book files, and that is a design decision rather than a gap: it is a home for what you marked, not for what you own. Grimmory, Kavita, Calibre-Web and Audiobookshelf all do the shelf properly, one of them is already a planned sync source (§19), and competing with them would mean carrying a format zoo, a streaming path and a storage story for no gain.
The distinction worth stating, since the two look similar from outside: reading annotations out of KOReader or Kobo is very much wanted and is in §8. Serving files is not.
Social features
Following, feeds, public profiles, discovery, and ActivityPub-style federation of the Bookwyrm kind. Per-user isolation here is a security property, not a layout choice: a foreign row answers 404 precisely so that nothing leaks between accounts on one box, and a social graph works against the reason for self-hosting in the first place. Letterboxd and Bookwyrm own that layer and are welcome to it.
Sharing something deliberately is a different matter, and it is already handled: the share sheet does a single quote, and the export does a work, a filtered set or the whole library as Markdown you can put anywhere. A hosted read-only page was on this list for a while until I noticed it was mostly a worse version of an export — the same content, plus a public surface to secure and a link to remember to revoke.
The shelf and the read log as a data source for anything else
The shelf gives the app a status per work, a progress figure, a position in pages or seasons, and a read log carrying start and finish dates. All of it is your input, shown back to you for your own convenience, and nothing else consumes it.
Named concretely, because these are the exact temptations and they are all plausible: no read-log series in the Stats activity calendar; no "books finished this year" in the year in review (§13); no reading-pace or completion charts; no progress- or completion-based achievements in §14; and no shelf status feeding the review deck's scheduling. Recorded here so the boundary stays a decision instead of being rediscovered later as an opportunity.