docs(filters): user manual — guide, cookbook, generated field reference
The filter manual (docs/user/filters/), folded in from the parked plan
now that the UI matches the backend (group ALL/ANY header toggle,
2344f568):
- index.md — the guide: the three homes of filters (drawer/saved
filters, smart collections, dashboard sections), the books/episodes
scope door, rule anatomy, value picking (chips with x, live counts
under other rules), GROUPS via the new header toggle (+ the row
chips as the fine-tuning tool), the live match count, today's
defaults (reversed listening order, family-lens scoping,
un-downloaded episodes), saving and shareable views.
- cookbook.md — seven exact-clicks recipes (born-reversed briefing,
OR-across-authors, big-unread-fantasy, downloaded-unplayed,
new-arrivals, dashboard shelf, shareable views). Every claim
cross-checked against the generated reference — which caught the
one wrong operator label in my first draft ("within last" vs the
real "within the last"), the drift protection working as designed.
- fields.md — GENERATED from FilterFieldsPayload by
TestFieldReferenceDocIsCurrent: the drift test fails the build if
the registry ever changes without regenerating the docs (user
decision: docs forced to match working code; FILTER_DOCS_REGEN=1
regenerates).
- searching-and-filtering.md cross-links to the folder.
This commit is contained in:
1 parent
2344f5686a
commit
1ed1602e70
5 files changed
+1090
No files matched your search
@@ -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).
|
||||||
@@ -0,0 +1,755 @@
|
|||||||
|
<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)
|
||||||
|
from the filter registry — the same source the real menus render. DO NOT EDIT by hand:
|
||||||
|
regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->
|
||||||
|
|
||||||
|
# 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.*
|
||||||
@@ -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.
|
||||||
@@ -35,3 +35,12 @@ You can combine tags with other filters:
|
|||||||
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
- Tags use fuzzy matching - "Sci Fi" will match "Science Fiction"
|
||||||
- Best matching tags appear first in results
|
- Best matching tags appear first in results
|
||||||
- Autocomplete shows existing tags as you type with relevance scores
|
- 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).
|
||||||
@@ -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("<!-- GENERATED by TestFieldReferenceDocIsCurrent (internal/services/filter_docs_test.go)\n")
|
||||||
|
b.WriteString(" from the filter registry — the same source the real menus render. DO NOT EDIT by hand:\n")
|
||||||
|
b.WriteString(" regenerate with FILTER_DOCS_REGEN=1 go test ./internal/services -run TestFieldReferenceDocIsCurrent -->\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()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user