What changed this session, why, what's still open, and how we intend to keep this codebase easy to work on going forward. For Zig API specifics, see ZIG_DISCOVERIES.md and GEMINI.md — this doc is about the caching architecture, the testing strategy, and what's next.
- Windows is deprioritized.
windows_helper/,ci/*msys*, and.github/workflows/release.ymlare untouched and left working, but no new effort goes there for now. - Focus: macOS + Linux desktop (GTK4/Zig) and the Android (Kotlin) client. TDD + regression tests + CI enforcement on both.
Original-language (Hebrew OT / Greek NT) interlinear + lexicon data is
scraped from BibleHub by two Python scripts (tools/bible/interlinear_scraper.py,
tools/bible/lexicon_scraper.py) run as subprocesses from
src/scraper_client.zig, triggered once from src/services/llm_engine.zig
when a verse has no cached lexicon context yet. Three independent bugs
combined to make this "just sometimes doesn't work, silently, forever":
- Language-prefix drift. The Python scraper had its own hardcoded
Old-Testament book list, independent of
src/bible_db.zig's canonicalBIBLE_BOOKS. It said"Song of Solomon"(with a space); the app always passes"SongofSolomon"(no space) — never matched, so that book (and every deuterocanonical/Ethiopian-canon OT book: Tobit, Judith, Sirach, Wisdom, Enoch, Jubilees, 1-3Meqabyan, Tegsas) silently got tagged Greek ("G") instead of Hebrew ("H"). Fixed by generatingtools/bible_books.jsonfromBIBLE_BOOKS(one-time, checked in) and having the scraper load it instead of its own list, plus a Zig test (bible_db.zig:"BIBLE_BOOKS testament data matches tools/bible_books.json") that fails the build the moment the two ever diverge again. - Silently swallowed failures.
scraper_client.zigdiscarded the subprocess exit code entirely (_ = try child.wait(engine)), andllm_engine.zigwrapped every scrape call incatch {}. A crashed or network-failed scrape looked identical to "nothing to cache yet" — no error, no retry signal, and the next click just repeated the same failure forever. Fixed:scraper_client.zignow checksterm.success()and returnserror.ScraperFailed;llm_engine.zigsurfaces that failure via the existingonStepcallback instead of swallowing it, and skips the (expensive, whole-DB) lexicon backfill pass when the interlinear fetch itself failed. - No HTTP timeout, bare
except:.interlinear_scraper.pyhad no try/except aroundrequests.getat all;lexicon_scraper.pyhad a bareexcept:with no timeout. A hung BibleHub response hung the subprocess (and thus the blockingchild.wait) indefinitely. Fixed: both now settimeout=15and catchrequests.RequestExceptionspecifically, exiting non-zero (interlinear) so Zig's exit-code check can see it.
The Android client (mobile/) had the same bug class via a different
mechanism: BibleConstants.kt's book list has a third testament bucket,
"Eth", that the old prefix logic didn't handle — it checked
testament == "New" and defaulted everything else (including "Eth") to
Hebrew. Fixed with a single strongsLanguagePrefix() source of truth in
BibleConstants.kt, plus the same "swallowed failure" bug
(catch (e: Exception) { Log.e(...) } with no user-visible signal), fixed
by propagating exceptions and adding a scrapeError state + retry affordance
in the UI. See git history for the full diff.
init_db() in bible_db.zig only ever created highlights/notes/
book_metadata/chapter_summaries/lexical_favorites — the tables that
actually hold cached data (interlinear, lexicon, verses,
cross_references) only existed because the shipped data/bible.db
happened to have them already. A freshly created database would be missing
those tables entirely, and a missing table looks exactly like "no rows yet"
to every read function here (sqlite3_prepare_v2 just fails and the
function falls through to its empty/not-found default) — the same silent-gap
shape as the caching bug itself. init_db() now creates all of them with
CREATE TABLE IF NOT EXISTS, matching the real schema exactly.
While adding a regression test that fires several threads at a shared
bible_db connection (see below), the test reliably SEGV'd. Root cause:
the system libsqlite3 linked here is commonly compiled
-DSQLITE_THREADSAFE=2 ("multi-thread" mode), which explicitly forbids
using the same connection from more than one OS thread concurrently —
and llm_engine.zig does exactly that via g_thread_new against the one
state.db connection opened in main(). Fixed with a small mutex around
every bible_db.zig public function (see
ZIG_DISCOVERIES.md
for why it's a hand-rolled atomic spinlock rather than std.Thread.Mutex,
which no longer exists in this Zig nightly). This was a real crash risk in
the shipped app any time two background operations touched the db at once
— now it's a passing regression test instead.
The ask was "regression testing... to drive stability yet ease of maintaining" — not maximum test count. The rule we followed, and should keep following:
One test per bug class, not per function permutation. Every test added this session maps to something that was either (a) a real bug we just fixed, or (b) a function that sits directly on the caching hot path and had zero coverage before. We did not add exhaustive property tests, fuzzing, or a test for every getter's every edge case.
bible_db.ziground-trip tests usesqlite3_open(":memory:")— no fixtures, no network, no shared state, sub-second. This is the model for any new DB function: a "missing" case + a "round-trips after write" case, nothing more.- The drift-guard test (
bible_books.jsonvsBIBLE_BOOKS) is the template for "prevent this exact bug class from coming back" — cheap, and it directly encodes the lesson learned rather than re-testing generic JSON parsing. - The concurrency test (
"concurrent writers on a shared connection don't corrupt or drop data") is deliberately singular — it's the pattern to extend if a new function starts being called from a background thread, not something to duplicate per-function. Real OS threads, no mocking, asserts on the actual data afterward rather than just "didn't crash." scraper_client.zigtests use real subprocesses (/bin/sh -c "exit 0/1") instead of mockingstd.process— faster to write, and it actually exercises theTerm-union logic rather than a hand-maintained fake of it.
What "E2E" means for this app in practice. Driving the real GTK UI
headlessly (fake X server / accessibility-tree automation) is heavy, flaky,
and — for a project this size — low payoff, because the business logic that
actually breaks (SQL, subprocess handling, language-tagging, concurrency)
lives below the GTK layer, not in it. What we're calling "E2E" here is
boundary-level integration testing of the real chain
(scraper_client → subprocess → sqlite) with GTK excluded, which is where
the bugs actually were. If a future feature's correctness genuinely lives
in GTK widget behavior (not just "does this button call this function"),
that's the point to reconsider a real UI-driving harness — not before.
CI enforcement. Neither release.yml nor release-unix.yml ever ran
the test suite — they only build release artifacts on a tag push, so tests
added alongside a change were never actually checked anywhere. Added
.github/workflows/test.yml: zig build test on macOS + Linux, and
gradlew testDebugUnitTest for the mobile client, on every push/PR. This is
the thing that makes "we wrote a regression test" mean "it's enforced,"
not just "it exists on someone's machine."
Follow-up to the caching-bug section above: src/scraper_client.zig's
scrape_interlinear/scrape_lexicon no longer shell out to
uv run python tools/bible/interlinear_scraper.py/tools/bible/lexicon_scraper.py —
they call a native Zig port, src/native_scraper.zig, directly. (tools/bible/scraper.py,
the separate verse-text scraper reachable via scrape_verses, is untouched —
out of scope for this pass.) tools/bible/interlinear_scraper.py,
tools/bible/lexicon_scraper.py, and tools/bible/scraper_common.py are left in place
(nothing else references them, and removing them wasn't asked for) but are no
longer invoked by the app.
What's in native_scraper.zig:
- A small hand-rolled HTML tag/class scanner (
findElement/findElementById- a depth-aware close-tag matcher, needed because BibleHub nests a plain
<table>inside each Hebrewtablefloathebword cell) — not a general parser, just enough to replicate BeautifulSoup's.find()/.find_all()calls in the Python scrapers.
- a depth-aware close-tag matcher, needed because BibleHub nests a plain
parseInterlinearHtml/parseLexiconHtml— pure functions ([]const u8in, nostd.Io), so they're unit-tested against fixture strings with zero network access, per the brief's TDD preference.fetchWithRetry— mirrorstools/bible/scraper_common.py'sfetch_with_retryexactly: 3 attempts, 1s/2s/4s backoff, retries timeout/connection-failure/5xx, does not retry 4xx. The real per-attempt 15s timeout is implemented viastd.Io.Selectracing the fetch against a timer and canceling the loser — the closest native equivalent torequests.get(..., timeout=15)available in this Zig nightly (0.17.0-dev.1422+e863bf3be).- Four new small
bible_db.zigfunctions (insert_interlinear_word,insert_lexicon_entry,lexicon_has_strongs,distinct_interlinear_strongs) so the scraper's DB writes go through the existinglockDb()/unlockDb()mutex (see "A real concurrency bug this surfaced" above) instead of hitting sqlite3 directly. These use real parameterized binds (sqlite3_bind_text/sqlite3_bind_int, newly declared) rather than this file's existing string-interpolated-SQL pattern, since scraped web text can contain single quotes that would corrupt an interpolated query — this actually matches the Python side more closely, since itscursor.execute(sql, params)calls were already parameterized.
Known behavioral notes (preserved from the Python originals, not fixed — see native_scraper.zig's docstring and inline comments for detail):
- On today's live BibleHub markup, Hebrew (
tablefloatheb) word tables useclass="strongsnt"for their Strong's-number span, notclass="pos"/class="strongs"like Greek tables — sotools/bible/interlinear_scraper.py's (and this port's)find(class_=["pos","strongs"])never matches for Hebrew, and OT strongs numbers come back empty on a fresh scrape. Same gap, same class list, both languages. (Confirmed by fetching a real page,curl -A "Mozilla/5.0" https://biblehub.com/interlinear/obadiah/1.htm.) tools/bible/lexicon_scraper.py'sscrape_strongs()only populatesdefinition(and only if the page hasclass="strongs"and/orclass="strongsnt"elements it never finds on today's real/greek/N.htm//hebrew/N.htmpages);lemma/transliteration/usageare dead code, always"". Confirmed against the shippeddata/bible.db: all 6190 existing lexicon rows already have empty lemma/transliteration/definition/usage — this isn't a regression, the feature has been a no-op against the live site for a while. Ported faithfully rather than silently "fixed", since guessing at better selectors would be a bigger behavioral deviation than preserving a documented no-op — fixing the actual extraction (real BibleHub lexicon page selectors) is a good follow-up but is a product/scope decision, not a silent side effect of a subprocess-elimination task.- No NFC Unicode normalization. The Python scraper does
unicodedata.normalize('NFC', text)on original-language text; Zig's standard library has no built-in Unicode normalizer and hand-rolling one was out of scope. In practice BibleHub's Greek text is raw UTF-8 (not entity-encoded) and its Hebrew text is decimal-entity-encoded — untested whether either ever arrives in a form NFC would actually change, but this is a known, disclosed gap, not a verified non-issue. fetchWithRetry's "is this error worth retrying" gate (isPermanentFetchError) is a hand-picked allowlist of Zig errors (UnsupportedUriScheme/UriMissingHost/InvalidHostName) standing in for Python'sexcept (requests.Timeout, requests.ConnectionError)vs "otherRequestExceptionsubtypes propagate immediately" distinction — the two error taxonomies don't line up 1:1, so this is a reasonable approximation, not a literal port.
Testing status: zig build test passes clean (42 fixture/unit tests: HTML
parser against captured-real-page fixtures, retry-driver tests via a fake
attempt function, bible_db.zig round-trip tests for the four new
functions — all network-free, per the brief's preference for separating
parse-from-fetch over standing up a fake HTTP server). A
METANOIA_LIVE_SCRAPER_TEST=1 zig build test run against the real
https://biblehub.com/interlinear/philemon/1.htm (chosen for being short)
confirmed real-world, non-fixture behavior:
LIVE interlinear: 141 distinct strongs numbers cached for Philemon 1 and
LIVE lexicon sample: strongs=G3972 language=greek lemma="" definition="" —
the empty lemma/definition on the very first live-fetched lexicon entry is
expected, not a bug: it's exactly known-behavioral-note 2 above, confirmed
live rather than just inferred from data/bible.db's existing rows.
Next-session TODO:
- Consider whether
tools/bible/interlinear_scraper.py/lexicon_scraper.py/scraper_common.pyshould now be deleted, since nothing in the app invokes them anymore (kept for this pass only because deleting wasn't explicitly asked for). - Item 2 above (lexicon extraction being a near-total no-op against the real
site) is the highest-value real follow-up — the feature exists end-to-end
(fetch → parse → cache → read) but the parse step currently can't find real
data on today's BibleHub markup. Needs someone to look at a real
/greek/N.htmpage and find the actual current selectors for lemma/transliteration/definition/usage, in both Python and this Zig port simultaneously so they don't drift again. - The
std.Io.Select-based per-attempt timeout (httpGetOnceinnative_scraper.zig) is the first use ofIo.Select/Io.asyncin this codebase — worth a ZIG_DISCOVERIES.md entry once it's been exercised more (the live test above is the first real-network exercise of it).
— fixed 2026-07-20: the query now takes an explicitget_chapter_versesignores theversioncolumn.version: []const u8parameter and filtersWHERE version='{s}'(bible_db.zig). There's still no app-wide "currently selected translation" concept (checkedconfig.zigand every call site — nothing tracks one), so abible.DEFAULT_VERSIONconstant ("NKJV") was added as the minimal stand-in and is whatllm_engine.zig's one call site passes today. A regression test ("get_chapter_verses: filters by version instead of mixing translations") inserts two versions of the same book/chapter/verse and asserts eachversionargument returns only its own rows. The real follow-up, if a second translation is ever actually loaded intodata/bible.db, is threading a user-selected translation through from config/UI instead of the hardcoded default — that's a product decision, not something this fix guessed at.bookmarks,vocab_list,versionstables — documented, left alone, 2026-07-20: re-confirmed viagrep -rnfor these three table names acrosssrc/*.zig(including subdirectories) andtools/*.py— zero hits in either language; nothing reads or writes them. Decision: leave the schema as-is (option (a) from the original note), not drop it. Reasoning —bookmarks(per-verse notes with anotecolumn) andvocab_list(a personal Strong's/word list keyed by language) read as planned-but-unbuilt personal-study features that overlap conceptually with thenotes/lexical_favoritestables that did get built, so they're plausible future work rather than leftover cruft — worth keeping around rather than a destructiveDROP TABLEagainst a schema shipped inside a tracked, in-git production database file with no migration tooling in this codebase.versions(slug/namecolumns) looks like it was meant to be the lookup table for exactly the multi-translation featureget_chapter_verses'sversioncolumn above anticipates, so the two backlog items are likely related: if a second Bible translation is ever added, populatingversionsand building real UI/config around it is the point to revisit whether these three tables get used or removed. No code or migration was written for this item — it's a documentation-only resolution per the original scope.- Deuterocanonical/Ethiopian OT books likely have no BibleHub page at
all. Even with the language-prefix fix, scraping Tobit/Judith/Sirach/
Enoch/Jubilees/etc. will probably still return zero interlinear rows
(BibleHub doesn't cover them) — the scraper now exits 0 (not an error,
correctly, since "found nothing" isn't the same as "the fetch failed"),
but that means
llm_engine.zigwill keep re-attempting on every click for books that can never succeed. A future improvement: cache a "no source available" sentinel for these so we stop re-fetching, and say so in the UI instead of leaving it looking like a still-loading state. Partially resolved 2026-08-25: confirmed BibleHub does cover the Catholic/Orthodox deuterocanon (Tobit, Judith, Wisdom, Sirach, Baruch, 1-2 Maccabees — the latter two newly added toBIBLE_BOOKS) via two sources it wasn't obvious carried them: the Apostolic Bible Polyglot Septuagint interlinear (biblehub.com/interlinear/apostolic/, Greek — seetools/bible/cache_lxx_interlinear.py) and eBible.org's full offline Brenton's Septuagint English translation archive (not BibleHub —tools/bible/import_brenton_septuagint.py, version'LXXE'inversions, resolving the "if a second translation is ever added" note above). Only the Ethiopian-canon-exclusive books (Enoch, Jubilees, Meqabyan, Tegsas, the church-order books) remain a real content gap — neither source covers those.interlinear.source('MT'/'LXX'/'GNT', seetools/bible/migrate_add_interlinear_source.py) andverses.versionnow being part of the unique key is what makes two texts coexist for the same verse without one silently overwriting the other viaINSERT OR REPLACE— that was a real, if latent, bug before this session (a second translation for an already-covered book would have clobbered the first). lexicon_scraper.py's backfill is whole-database, not chapter-scoped, with a hardcodedtime.sleep(1.0)per new Strong's number. On a large fresh DB this can look "stuck" rather than broken. A chapter/verse-scoped variant would be a more targeted (and faster) future fix.— resolved 2026-07-19:data/bible.dbanddata/*.wavare intentionally gitignoreddata/bible.db(31MB) and the 7 wav clipsdata/voices.jsonactually references (~7MB) are now tracked directly in git as explicit.gitignoreexceptions — no git-lfs needed, well under GitHub's size limits. The other ~100MB ofdata/(an unreferenced 55MBreference.wav, supersededroumie*.waviterations, etc.) stays gitignored since it was never shipped to users anyway. See PACKAGING.md.- Homebrew formula (
Formula/metanoia.rb) has a placeholdersha256— needs a real release tag cut first, thenshasum -a 256 <tarball>filled in, plus a separatehomebrew-metanoiatap repo created to host it. - Android release signing is currently debug-signed only (sideload,
not Play-Store-eligible) — no
signingConfigs.releaseexists yet; needs a real keystore + CI secret when that matters.