diff --git a/docs/user/filters/cookbook.md b/docs/user/filters/cookbook.md new file mode 100644 index 00000000..541debf8 --- /dev/null +++ b/docs/user/filters/cookbook.md @@ -0,0 +1,78 @@ +# Filter cookbook + +Real questions, exact clicks. Every recipe uses the same builder described in +[the guide](index.md) — same fields, same rules, wherever filters live. + +Recipes marked **collection** become smart collections that stay current forever; the rest are +drawer filters you can save with a name. + +--- + +## "New chapters of 7th Son, automatically" — collection + +1. **Collections** → open (or create) a collection → **Rules**. +2. The scope is already *Episodes*. Add a rule: **Feed** → **in** → pick + *7th Son: Book One* (it becomes a chip — hit the × if you pick wrong). +3. Add a second rule: **Title** → **contains** → `Chapter`. +4. The header should read **MATCH ALL** (it does by default — every rule must match). +5. **Apply to Collection**. Done: new chapters appear on their own, and the listening order + starts at **Feed order (reversed)** — chapter 1 first. + +--- + +## "Lewis Carroll or George Orwell" — drawer + +1. **Filters** on the All Books page. Add: **Author** → **contains** → `Carroll`. +2. Add a second rule: **Author** → **contains** → `Orwell`. +3. Click the group header **MATCH ALL** once — it flips to **MATCH ANY** (or click the `AND` + chip between the two rules, same effect). +4. **Apply Filters**. Either author is enough. + +*Flip it back to ALL and you'd get only books they both wrote — which is none, and the counter +will tell you so before you apply.* + +--- + +## "Long fantasy books I haven't started" — drawer + +1. **Genre** → **is** → `fantasy`. +2. **Page count** → **at least** → `400`. +3. **My reading** → **Reading status** → **is** → *Unfinished* (or your app's wording — the + status list is the fixed dropdown on that field). +4. **MATCH ALL** stays on: all three must hold. +5. Apply — then **Save** it as *"Big unread fantasy"* and it's in the Load menu forever. + +--- + +## "Downloaded podcast episodes I haven't played" — collection + +1. A collection with an Episodes scope (see the first recipe — drop the Title rule if you want + whole feeds). +2. **Downloaded** → **is true**. +3. **Played** → **is false**. +4. **MATCH ALL** on. **Apply to Collection**. + +--- + +## "Added to my library this month" — drawer + +1. **Date added** → **within the last** → `30` (the days box — the field's page in + [Every filter field](fields.md) has its exact questions). +2. Optionally **MATCH ALL** with **Library** → **in** → your audiobooks library. +3. Save as *"New arrivals"*. + +--- + +## "A dashboard shelf that builds itself" — dashboard + +1. Create a collection with the rules above (or open one). +2. On the dashboard, add a section pointed at that collection. +3. The shelf re-fills itself as your library changes — no maintenance. + +--- + +## Sharing a filtered view + +Apply any filter, then copy the address bar. The filter rides in the URL — send it to another +user of your server and they open the same narrowed view (they see it through their own +libraries, not yours). diff --git a/docs/user/filters/fields.md b/docs/user/filters/fields.md new file mode 100644 index 00000000..add5e6b6 --- /dev/null +++ b/docs/user/filters/fields.md @@ -0,0 +1,755 @@ + + +# Every filter field + +This is the complete list of everything the filters can ask about, generated straight from the +same source the app's menus render — it cannot drift from what you see in the UI. + +Filters come in two worlds: + +- **Books** — rules about books and your reading of them. +- **Episodes** — rules about podcast episodes (feeds, downloaded, played). + +Every rule is **a field + a question + your values**. The "Ask" column is the question; +the "Your values" column is what you provide. The **default** question is what a new rule +starts with. + +## Books + +### Book facts + +**Title** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Subtitle** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Description** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Summary** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**Language** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Page count** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Chapter count** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Total characters** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Community rating** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Date added** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**Date imported** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**Web URL** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**Metadata notes** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**ISBN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick | +| isn't | matches anything but this | +| has | has a value *(default)* | +| missing | is empty or missing | + +**ASIN** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick | +| isn't | matches anything but this | +| has | has a value *(default)* | +| missing | is empty or missing | + +**File size** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**File availability** — Choose from a fixed list of options. + +| Ask | Means | +| --- | --- | +| is | matches exactly *(default)* | +| isn't | matches anything but this | + +**Explicit** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Abridged** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Manually edited** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Cover** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**Added by** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick | +| has | has a value *(default)* | +| missing | is empty or missing | + +### Comics & manga + +**Series** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Series number** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Series count** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Volume** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Format group** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | + +**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | + +**Reflowable** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Fixed layout** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Black & white** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Manga type** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | +| is empty | — | + +**Reading direction** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | +| is empty | — | + +### Library + +**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | + +### My reading + +**Reading status** — Choose from a fixed list of options. + +| Ask | Means | +| --- | --- | +| is | matches exactly *(default)* | + +**Progress percent** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Last read** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**My rating** — Type a number. + +| Ask | Means | +| --- | --- | +| is | matches exactly | +| at least | is at least this *(default)* | +| at most | is at most this | +| unrated | — | + +**Has review** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**Notes** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | +| within the last | — | + +**Highlights** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | +| within the last | — | + +**Note text** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is empty | — | + +**Highlight text** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is empty | — | + +**Time spent (minutes)** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | + +**Pages read** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | + +**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| in | is one of the ones you pick (any one is enough) *(default)* | +| isn't in | is none of the ones you pick | +| in any | is in at least one of the collections you pick | +| in none | — | + +### People + +**Author** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Narrator** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Contributor** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +### Publishing + +**Publisher** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Imprint** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Edition** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Published year** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Published date** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +### Topics + +**Genre** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Tags** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Story arc** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Age rating** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | +| is empty | — | + +## Podcast episodes + +### Book facts + +**Title** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Description** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Language** — Type words. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is | matches exactly | +| isn't | matches anything but this | +| is empty | — | + +**Chapter count** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Date added** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**Date imported** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**File size** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**File availability** — Choose from a fixed list of options. + +| Ask | Means | +| --- | --- | +| is | matches exactly *(default)* | +| isn't | matches anything but this | + +**Explicit** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +### Comics & manga + +**File type (mime)** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | + +### Episodes + +**Feed** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| in | is one of the ones you pick (any one is enough) *(default)* | +| isn't in | is none of the ones you pick | +| has | has a value | +| missing | is empty or missing | + +**Downloaded** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on *(default)* | +| is false | is switched off | + +**Played** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| is true | is switched on | +| is false | is switched off *(default)* | + +### Library + +**Library** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| is | is one of the ones you pick *(default)* | +| isn't | matches anything but this | + +### My reading + +**Progress percent** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Last read** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +**My rating** — Type a number. + +| Ask | Means | +| --- | --- | +| is | matches exactly | +| at least | is at least this *(default)* | +| at most | is at most this | +| unrated | — | + +**Has review** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | + +**Notes** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | +| within the last | — | + +**Highlights** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| has | has a value *(default)* | +| missing | is empty or missing | +| within the last | — | + +**Note text** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is empty | — | + +**Highlight text** — No value needed — the question is the whole rule. + +| Ask | Means | +| --- | --- | +| contains | contains these words *(default)* | +| doesn't contain | does NOT contain these words | +| is empty | — | + +**Time spent (minutes)** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | + +**In collection** — Pick from a dropdown of real names — picks become removable chips, and you can pick several. + +| Ask | Means | +| --- | --- | +| in | is one of the ones you pick (any one is enough) *(default)* | +| isn't in | is none of the ones you pick | +| in any | is in at least one of the collections you pick | +| in none | — | + +### Publishing + +**Published year** — Type a number. + +| Ask | Means | +| --- | --- | +| between | falls between two numbers | +| at least | is at least this *(default)* | +| at most | is at most this | +| is empty | — | + +**Published date** — Type a number of days. + +| Ask | Means | +| --- | --- | +| within the last | — *(default)* | +| between | falls between two numbers | +| is empty | — | + +--- + +*Anything here that doesn't match what the app actually shows is a bug — this page is generated +from the app's own menus and a test fails if they ever disagree.* diff --git a/docs/user/filters/index.md b/docs/user/filters/index.md new file mode 100644 index 00000000..1e6ee8a9 --- /dev/null +++ b/docs/user/filters/index.md @@ -0,0 +1,91 @@ +# Filters — the complete guide + +Filters answer one question: *of everything in my library, show me the ones that match.* + +You'll meet the same filter system in three places: + +| Place | What it's for | +| --- | --- | +| **The Filters drawer** (All Books / Podcasts pages) | A quick, temporary narrowing of what you see. Save it if you want to keep it. | +| **Smart collections** (a collection's **Rules** page) | A filter that *lives* — the collection's contents update automatically forever. | +| **Dashboard sections** | The same rules, feeding a shelf on your home screen. | + +One system, one set of skills, three payoffs. + +## Two worlds: Books and Episodes + +Rules about **books** (authors, series, your reading progress) and rules about **podcast episodes** +(feeds, downloaded, played) are two different worlds. The field menus only show the fields that +make sense where you are — you'll never see a podcast field on the All Books page. The complete +list of every field is in [Every filter field](fields.md). + +## Anatomy of a rule + +Every rule is **a field + a question + your values**: + +> **Author** *contains* `asc` + +> **Feed** *in* `7th Son: Book One` + +> **Page count** *at least* `400` + +The field is *what* you're asking about. The question (contains, at least, is empty…) is *how*. +Your values are *what counts as a match*. The complete questions per field are in +[Every filter field](fields.md). + +## Picking values + +Two kinds of value boxes: + +- **Dropdown boxes** (Feed, Library, Collection, Added by): start typing and real names appear, + with live counts of how much matches. Click to pick — each pick becomes a **chip** with a little + **×** to remove it. Pick as many as you like; *any one of them* matching is enough. +- **Typed boxes** (Author, Title, Page count…): just type. Several words means "contains these + words"; number boxes take numbers; date boxes take a number of days. + +The dropdown's counts are **live under your other rules**: if you've already narrowed to +*Title contains Chapter*, the feed menu shows how many Chapter-titled episodes each of your feeds +has. Rules already picked disappear from their own menu (removing a chip brings them back). + +## Groups: ALL and ANY + +When you have several rules, the group header — **MATCH ALL** or **MATCH ANY** — decides how they +combine: + +- **MATCH ALL** — *every* rule must match. (The default, and what you want almost always.) +- **MATCH ANY** — *at least one* rule matching is enough. This is how you ask for + "Lewis Carroll **or** George Orwell". + +**Click the header to switch it.** It says what it does, and clicking flips it. + +`+ group` adds a nested group with its own header (**GROUP ALL / GROUP ANY**), so bigger shapes +build naturally: + +> Match ALL: Author *contains* `Carroll` **AND** GROUP ANY (Title contains `Alice` **OR** Title +> contains `Wonderland`) + +For fine-tuning inside an ALL group, the small **AND / OR** chip between two rules mixes them — +click `AND` and it becomes `OR`, joining those neighbors together. (The header switch and these +chips are two different tools: the header sets the group's overall combine; a chip makes just two +neighboring rules special.) + +## The live count + +While you build, the counter at the bottom — *N books match* — runs your rules against the real +library. It's the exact number you'll get when you apply. + +## Defaults worth knowing + +- **Podcast listening order**: podcast pages and new podcast collections start at + **Feed order (reversed)** — book order, chapter 1 first. Change it per feed or collection from + the order dropdown; your choice sticks. +- **Hidden libraries** (family lens) never match — a filter can't accidentally surface a library + that's hidden from you. +- **Episodes that aren't downloaded** stay out of playback chains (they still appear in lists). + +## Keeping a filter + +- **Save** (drawer): name it; it appears in the **Load** menu from then on. +- **Apply to Collection** (rules page): the filter becomes the smart collection — live forever. +- Filtered views are **shareable**: the URL carries the filter, so a bookmark or sent link opens + the same view. diff --git a/docs/user/searching-and-filtering.md b/docs/user/searching-and-filtering.md index 99f6df45..2f97cafe 100644 --- a/docs/user/searching-and-filtering.md +++ b/docs/user/searching-and-filtering.md @@ -35,3 +35,12 @@ You can combine tags with other filters: - Tags use fuzzy matching - "Sci Fi" will match "Science Fiction" - Best matching tags appear first in results - Autocomplete shows existing tags as you type with relevance scores + + +## The complete filter guide + +The filters described above are one system that also powers smart collections and dashboard +sections — groups with ALL/ANY, saved filters, and every field listed. The full manual lives in +[this folder](filters/index.md): the [guide](filters/index.md), the +[cookbook](filters/cookbook.md) of ready-made recipes, and the generated +[field reference](filters/fields.md). diff --git a/internal/services/filter_docs_test.go b/internal/services/filter_docs_test.go new file mode 100644 index 00000000..0f3e59be --- /dev/null +++ b/internal/services/filter_docs_test.go @@ -0,0 +1,157 @@ +package services + +import ( + "fmt" + "os" + "sort" + "strings" + "testing" +) + +// The user manual's field reference (docs/user/filters/fields.md) is +// GENERATED from FilterFieldsPayload — the single source of truth the +// real menus render from — and pinned by this test: if the registry +// changes without regenerating the docs, this fails. Docs cannot drift +// from working code (2026-11-01, user decision). +// +// Regenerate with: FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent + +const fieldRefPath = "../../docs/user/filters/fields.md" + +func TestFieldReferenceDocIsCurrent(t *testing.T) { + doc := GenerateFieldReferenceDoc() + if os.Getenv("FILTER_DOCS_REGEN") == "1" { + if err := os.WriteFile(fieldRefPath, []byte(doc), 0o644); err != nil { + t.Fatalf("write field reference: %v", err) + } + t.Log("rewrote " + fieldRefPath) + return + } + existing, err := os.ReadFile(fieldRefPath) + if err != nil { + t.Fatalf("docs/user/filters/fields.md is missing — regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent: %v", err) + } + if string(existing) != doc { + t.Fatal("docs/user/filters/fields.md is STALE — the filter registry changed without regenerating the docs. " + + "Run FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent and commit the refreshed file.") + } +} + +// kindPhrase — what the value cell looks like for a user, per value kind. +func kindPhrase(kind string) string { + switch kind { + case VKText: + return "Type words." + case VKNumber: + return "Type a number." + case VKDays: + return "Type a number of days." + case VKId: + return "Pick from a dropdown of real names — picks become removable chips, and you can pick several." + case VKFixed: + return "Choose from a fixed list of options." + case VKNone: + return "No value needed — the question is the whole rule." + } + return "" +} + +// opPhrase — what each operator asks, in user words. +func opPhrase(op, kind string) string { + switch op { + case "contains": + return "contains these words" + case "not_contains": + return "does NOT contain these words" + case "is": + if kind == VKId { + return "is one of the ones you pick" + } + return "matches exactly" + case "is_not", "isn't": + return "matches anything but this" + case "in": + return "is one of the ones you pick (any one is enough)" + case "not_in": + return "is none of the ones you pick" + case "in_any": + return "is in at least one of the collections you pick" + case "none_of": + return "is in none of the collections you pick" + case "has": + return "has a value" + case "missing": + return "is empty or missing" + case "is_true": + return "is switched on" + case "is_false": + return "is switched off" + case "between": + return "falls between two numbers" + case "at_least": + return "is at least this" + case "at_most": + return "is at most this" + } + return "—" +} + +func GenerateFieldReferenceDoc() string { + var b strings.Builder + b.WriteString("\n\n") + b.WriteString("# Every filter field\n\n") + b.WriteString("This is the complete list of everything the filters can ask about, generated straight from the\n") + b.WriteString("same source the app's menus render — it cannot drift from what you see in the UI.\n\n") + b.WriteString("Filters come in two worlds:\n\n") + b.WriteString("- **Books** — rules about books and your reading of them.\n") + b.WriteString("- **Episodes** — rules about podcast episodes (feeds, downloaded, played).\n\n") + b.WriteString("Every rule is **a field + a question + your values**. The \"Ask\" column is the question;\n") + b.WriteString("the \"Your values\" column is what you provide. The **default** question is what a new rule\n") + b.WriteString("starts with.\n\n") + + for _, scope := range []string{ScopeBooks, ScopeEpisodes} { + scopeTitle := "Books" + if scope == ScopeEpisodes { + scopeTitle = "Podcast episodes" + } + fmt.Fprintf(&b, "## %s\n\n", scopeTitle) + + fields := FilterFieldsPayload(scope) + groups := []string{} + byGroup := map[string][]FilterFieldWire{} + for _, f := range fields { + if _, seen := byGroup[f.Group]; !seen { + groups = append(groups, f.Group) + } + byGroup[f.Group] = append(byGroup[f.Group], f) + } + sort.Strings(groups) + for _, g := range groups { + fmt.Fprintf(&b, "### %s\n\n", g) + for _, f := range byGroup[g] { + fmt.Fprintf(&b, "**%s** — %s\n\n", f.Label, kindPhrase(f.ValueKind)) + lines := make([]string, 0, len(f.Ops)) + for _, op := range f.Ops { + label := op.Label + if label == "" { + label = op.Key + } + def := "" + if op.Key == f.DefaultOp { + def = " *(default)*" + } + lines = append(lines, fmt.Sprintf("| %s | %s%s |", label, opPhrase(op.Key, f.ValueKind), def)) + } + b.WriteString("| Ask | Means |\n| --- | --- |\n") + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n\n") + } + } + } + b.WriteString("---\n\n") + b.WriteString("*Anything here that doesn't match what the app actually shows is a bug — this page is generated\n") + b.WriteString("from the app's own menus and a test fails if they ever disagree.*\n") + return b.String() +}