Tag every recipe with a detected locale#11
Conversation
Cooklang's meta.locale() only accepts its strict format (de, en_US) and returns None for anything else. Unconditionally skipping the raw 'locale' key when building 'custom' meant a value cooklang rejected -- such as the hyphenated 'en-US' an author might plausibly type -- was dropped from both the typed field and custom, losing it entirely. Compute the typed locale once up front and only exclude the raw key from 'custom' when it actually parsed, so unparseable values still survive as custom metadata.
Persists the resolved recipe locale (BCP-47-style code plus its provenance: declared vs detected) on the recipes table. Adds update_recipe_locale and list_locales, and threads the two new columns through create_recipe and update_recipe_with_content. Later tasks will populate real values from the crawler, GitHub indexer, and a backfill command.
…dering update_recipe_with_content's effect on locale was untested. Pins the derived-state contract: a content update carries the newly-resolved locale through, and a resolution to None clears the columns rather than leaving a stale guess. list_locales was only exercised against a single-locale result set. Adds coverage for count-descending order, the alphabetical tie-break, and the exclusion of NULL locales.
Parse the Cooklang content once per entry and reuse it for both the image fallback and locale resolution, on the create and update paths alike. Not covered by a crawler-level test: validate_url rejects loopback and private addresses, so a mockito-backed feed cannot be crawled end to end. The locale logic itself is covered by the indexer::locale unit tests.
Add a language dropdown to the search page (folding regional codes like en-US into their base language), a language chip on recipe cards, a locale pill on the recipe detail page, and inLanguage in the Schema.org JSON-LD.
Tantivy pins field ids to the schema stored on disk, but SearchIndex::new opened an existing index with Index::open_in_dir and then addressed it with the in-code RecipeSchema. Once the two diverge every field id is wrong, and writing a document panics in the fast-field writer rather than failing cleanly -- which is what adding the `locale` field does to any index built before it. Compare the on-disk schema against ours at open time and refuse to proceed, naming the rebuild command. Failing fast beats auto-wiping the index, which would leave search silently returning zero results.
Two bugs in the same function: 1. Recipes whose locale couldn't be resolved (too little/ambiguous prose) were skipped entirely, including the index_recipe call. Following the README's upgrade path (rm -rf data/index && backfill-locales) permanently dropped those recipes from search, and every rerun re-skipped them deterministically. Locale resolution now only gates the DB write and the `updated` counter; every recipe with content is always indexed. 2. The Tantivy writer was committed once at the very end of the whole run, while each recipe's DB row was written immediately. A crash mid-run left DB rows pointing at a locale that was never actually committed to the index, and a plain rerun would skip them forever (their locale is already set). The writer is now created and committed per batch, before that batch's DB rows are written, so a crash leaves `locale IS NULL` and a rerun picks the batch back up. index_recipe deletes-then-adds by id, so redoing a batch is idempotent.
Locale codes are stored canonically as lowercase language + optional uppercase region (en, de, en-US). The Tantivy locale field is untokenized (exact match), so ?locale=EN or ?locale=en-us silently returned zero results even though the recipe exists as "en"/"en-US". The web UI always sends canonical values, but the API is public. Add locale::normalize_code and apply it to the incoming filter in SearchIndex::search, covering both the API and web UI in one place. cooklang_parser's declared-`locale:` handling normalized the same way independently; it now calls the shared helper so the two can't drift on what "canonical" means.
|
Review: Tag every recipe with a detected locale Overall this is a well-scoped, thoroughly tested feature. The declared-vs-detected precedence, the narrative-text-before-ingredients fallback for detection (avoiding "Prosciutto" outvoting an English method), the macrolanguage mapping ( Code quality & design
Potential issues
Performance
Security
Test coverage
Operational note
|
|
Thanks — addressed the one actionable item; notes on the rest. 1. Planning docs under
2. GitHub indexer: locale re-resolved from new content while 3. N+1 in the backfill batch loop. Agreed, and deliberately left: it's a one-off CLI command, and the real 2,438-recipe run was not I/O-bound in a way worth optimizing. If this pattern moves anywhere hotter, it should be batched. 4. Un-encoded 5. |
Review: Tag every recipe with a detected localeOverall this is a well-scoped, carefully tested feature. A few things stood out worth calling out. Correctness
Locale silently clears on any parse hiccup during a content update (by design — worth double-checking). Performance
Minor
What's good
SecurityNo concerns — the locale filter is applied via a parameterized Tantivy |
Summary
Every recipe now carries a locale, resolved during indexing:
locale:key always wins (meta.locale(), normalizeden_US→en-US).whatlang. Detection runs over the parsed recipe, so Cooklang markup (@flour{200%g}) and quantities never reach the detector.Stored on the
recipesrow aslocale(en,de,en-US) +locale_source(declared|detected), indexed in Tantivy as an exact-match field, and surfaced through:GET /api/search?q=…&locale=de;locale/locale_sourceon the recipe detail response.inLanguagein the Schema.org JSON-LD.federation backfill-locales [--force]tags recipes already in the database (content is already stored, so no refetching) and re-indexes them.Filtering by a base language also matches its regional variants:
locale=enfindsen-USrecipes, whilelocale=en-USmatches only those. The filter is case-insensitive (?locale=ENand?locale=en-usboth work); the field is deliberately excluded from the free-text query parser, so searching for "de" doesn't return every German recipe.Backfilled against a real 2,438-recipe database: 2,414 tagged (2,3xx
en, 32fr, 1de, 1 declareden-US); the 24 left NULL are short/ambiguous entries like "Oats" where guessing would be worse than abstaining.This adds a field to the Tantivy schema, which makes any pre-existing index incompatible.
SearchIndex::newpreviously calledIndex::open_in_dir, which opens using the on-disk schema and silently ignores the in-code one — so writing the new field would have panicked (out-of-bounds field id) the first time the GitHub indexer touched a recipe. That latent trap would have been sprung by any schema change; this branch now refuses to open a mismatched index with an actionable error instead.Documented under "Upgrading" in the README.
Note: the feed crawler never wrote to the Tantivy index (a pre-existing gap — only the GitHub indexer did), and
backfill-localesre-indexes every recipe it touches. So feed-crawled recipes become searchable for the first time, and search result counts will rise after the backfill.Pre-existing bugs found but NOT fixed here
Worth their own issues:
recipes.content. When a.cookfile changes upstream it updates only the file SHA (there's a stale// For now, we'll keep the existing recipecomment saying as much), while re-deriving ingredients/tags/locale from the new content — so the stored recipe body drifts from everything derived from it./api/search'spagination.totalsaturates at the page window (total = top_docs.len()against aTopDocs::with_limit(limit + offset)) rather than reporting the true match count.Test plan
cargo test— 112 tests pass, 0 failures (cargo clippy --all-targets -- -D warningsclean)en-US) and never invented, ISO 639-3 → 639-1 mapping incl. thecmn→zh/pes→famacrolanguage cases, too-short and non-linguistic input →None, ingredient names can't outvote the narrative?locale=dereturns only the German recipe; detail JSON carries both fields--force; still indexes recipes whose locale can't be resolved (they must not vanish from search)locale=enreturns theen-USrecipe), dropdown with counts, card chips, and the detail pill verified in real rendered HTML for all four declared/detected × parsed/fallback combinations🤖 Generated with Claude Code