Skip to main content

max / balanced_breakfast

exorcise: convert ASCII '--' prose dashes to colons Uniform '--' dash pass: 81 conversions across README, architecture, database_schema, frontend_architecture, plugin_authoring. Dependency arrows, CLI flags, CSS vars, code fences untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-12 16:56 UTC
Commit: 26eec3245330d94e43887291e304f05d361a864f
Parent: ea68d15
5 files changed, +79 insertions, -79 deletions
M README.md +11 -11
@@ -49,17 +49,17 @@ Shared libraries from `../../MNW/shared/`: [theme-common](../../MNW/shared/theme
49 49
50 50 ## Features
51 51
52 - - **Unified timeline** -- RSS, Atom, JSON Feed, Hacker News, arXiv, and custom sources merged into one feed
53 - - **Plugin system** -- Rhai scripting for extensible feed fetching (write a plugin for any source)
54 - - **Reader view** -- clean article rendering with HTML sanitization via DocEngine
55 - - **Search** -- FTS5 full-text search across all items with sanitized indexing
56 - - **Organization** -- tags, starred items, read/unread tracking, query feeds (saved dynamic filters)
57 - - **Auto-fetch scheduling** -- configurable per-plugin fetch intervals, circuit breaker after consecutive failures
58 - - **Feed health tracking** -- visual status indicators (green/yellow/red) per source
59 - - **Cloud sync** -- SyncKit integration with E2E encryption (feeds, tags, read state, preferences)
60 - - **OTA updates** -- background update checker with consent dialog (Tauri updater v2)
61 - - **17 bundled themes** -- dark, light, and high-contrast variants in TOML format, system auto-detection
62 - - **Platforms** -- macOS, Windows, Linux (native via Tauri 2)
52 + - **Unified timeline**: RSS, Atom, JSON Feed, Hacker News, arXiv, and custom sources merged into one feed
53 + - **Plugin system**: Rhai scripting for extensible feed fetching (write a plugin for any source)
54 + - **Reader view**: clean article rendering with HTML sanitization via DocEngine
55 + - **Search**: FTS5 full-text search across all items with sanitized indexing
56 + - **Organization**: tags, starred items, read/unread tracking, query feeds (saved dynamic filters)
57 + - **Auto-fetch scheduling**: configurable per-plugin fetch intervals, circuit breaker after consecutive failures
58 + - **Feed health tracking**: visual status indicators (green/yellow/red) per source
59 + - **Cloud sync**: SyncKit integration with E2E encryption (feeds, tags, read state, preferences)
60 + - **OTA updates**: background update checker with consent dialog (Tauri updater v2)
61 + - **17 bundled themes**: dark, light, and high-contrast variants in TOML format, system auto-detection
62 + - **Platforms**: macOS, Windows, Linux (native via Tauri 2)
63 63
64 64 ## Plugin Authoring
65 65
@@ -1,4 +1,4 @@
1 - # Balanced Breakfast -- Architecture
1 + # Balanced Breakfast: Architecture
2 2
3 3 When it comes to media and news, it's good to be a picky eater.
4 4
@@ -26,11 +26,11 @@ Dependency flow: `bb-interface` (leaf) --> `bb-core`, `bb-feed`, `bb-db` --> `sr
26 26
27 27 The `Orchestrator` (`bb-core::orchestrator`) is the central coordination point. It owns the `Database` and a `PluginManager` behind an `Arc<RwLock<>>`. Its responsibilities:
28 28
29 - - **Plugin lifecycle** -- load `.rhai` scripts from the plugins directory, initialize them with config from the DB, and provide fetch/shutdown operations.
30 - - **Fetch execution** -- call a plugin's `fetch()`, strip tracking parameters from item URLs and HTML bodies, upsert results into the DB via the items repository, and record success/failure on the feed.
31 - - **Circuit breaker** -- after 10 consecutive fetch failures (`CIRCUIT_BREAKER_THRESHOLD`), the feed is marked `circuit_broken` and excluded from auto-fetch until manually reset.
32 - - **Secret management** -- holds an optional AES-256-GCM key. On startup, encrypts any plaintext Secret fields in existing feed configs (migration from legacy plaintext).
33 - - **Fetch-all** -- iterates all loaded plugins and fetches each, collecting total item counts.
29 + - **Plugin lifecycle**: load `.rhai` scripts from the plugins directory, initialize them with config from the DB, and provide fetch/shutdown operations.
30 + - **Fetch execution**: call a plugin's `fetch()`, strip tracking parameters from item URLs and HTML bodies, upsert results into the DB via the items repository, and record success/failure on the feed.
31 + - **Circuit breaker**: after 10 consecutive fetch failures (`CIRCUIT_BREAKER_THRESHOLD`), the feed is marked `circuit_broken` and excluded from auto-fetch until manually reset.
32 + - **Secret management**: holds an optional AES-256-GCM key. On startup, encrypts any plaintext Secret fields in existing feed configs (migration from legacy plaintext).
33 + - **Fetch-all**: iterates all loaded plugins and fetches each, collecting total item counts.
34 34
35 35 The orchestrator does not own the fetch scheduler or background tasks. Those are managed by `AppState` in the Tauri layer.
36 36
@@ -42,17 +42,17 @@ Plugins are `.rhai` text files dropped into the plugins directory. The Rhai engi
42 42
43 43 Every plugin must define four functions:
44 44
45 - - `id()` -- returns a unique string identifier (e.g. `"rss"`, `"hackernews"`)
46 - - `name()` -- returns a human-readable display name
47 - - `config_schema()` -- returns a map describing configuration fields (key, label, field_type, required, default, options)
48 - - `fetch(config, cursor)` -- returns `{ items: [...], has_more: bool, next_cursor: string? }`
45 + - `id()`: returns a unique string identifier (e.g. `"rss"`, `"hackernews"`)
46 + - `name()`: returns a human-readable display name
47 + - `config_schema()`: returns a map describing configuration fields (key, label, field_type, required, default, options)
48 + - `fetch(config, cursor)`: returns `{ items: [...], has_more: bool, next_cursor: string? }`
49 49
50 50 An optional `capabilities()` function can declare pagination support, custom fetch intervals, auth requirements, etc.
51 51
52 52 ### Sandboxing
53 53
54 - - **Operations cap:** `max_operations(100_000)` -- a typical RSS fetch costs 1k-5k ops; this catches infinite loops.
55 - - **Expression depth:** `max_expr_depths(128, 128)` -- prevents stack overflows from deeply nested or recursive scripts.
54 + - **Operations cap:** `max_operations(100_000)`. A typical RSS fetch costs 1k-5k ops; this catches infinite loops.
55 + - **Expression depth:** `max_expr_depths(128, 128)`. Prevents stack overflows from deeply nested or recursive scripts.
56 56 - **HTTP timeout:** 15 seconds per request.
57 57 - **Response size:** 2 MB cap per response body.
58 58 - **Request limit:** 100 HTTP requests per `fetch()` invocation. Counter resets at the start of each fetch.
@@ -96,10 +96,10 @@ The `FeedGenerator` (`bb-feed::generator`) reads items from the database, applie
96 96 - Item-level tags, feed-level tags, and query feed conditions (title/author/body contains, equals, not_contains, matches_regex) run in-memory after the SQL query.
97 97
98 98 **Ordering** is applied in-memory after filtering:
99 - - `Chronological` -- newest first (default)
100 - - `Score` -- highest score first, with chronological tiebreak
101 - - `UnreadFirst` -- unread items before read, chronological within each group
102 - - `StarredFirst` -- starred items before unstarred, chronological within each group
99 + - `Chronological`: newest first (default)
100 + - `Score`: highest score first, with chronological tiebreak
101 + - `UnreadFirst`: unread items before read, chronological within each group
102 + - `StarredFirst`: starred items before unstarred, chronological within each group
103 103
104 104 **Pagination** fetches `page_size + 1` items to detect whether more pages exist, then truncates to the exact page size.
105 105
@@ -123,12 +123,12 @@ SQLite via sqlx with compile-time migrations (12 migrations). The `Database` str
123 123
124 124 ### Repositories
125 125
126 - - `FeedsRepository` -- CRUD, enable/disable, last_fetch updates, fetch failure recording, circuit breaker management
127 - - `ItemsRepository` -- upsert (dedup by external_id), read/star toggling, paginated listing (by busser, by feed, unread, starred), FTS5 search, counts, stale item deletion
128 - - `TagsRepository` -- per-feed tag assignment, distinct tag listing, bulk feed-tag pairs
129 - - `StateRepository` -- busser key-value state (get/set/delete by busser_id + key)
130 - - `ConfigRepository` -- user_config key-value pairs (get/set/delete)
131 - - `QueryFeedsRepository` -- query feed CRUD (create/update/delete/list)
126 + - `FeedsRepository`: CRUD, enable/disable, last_fetch updates, fetch failure recording, circuit breaker management
127 + - `ItemsRepository`: upsert (dedup by external_id), read/star toggling, paginated listing (by busser, by feed, unread, starred), FTS5 search, counts, stale item deletion
128 + - `TagsRepository`: per-feed tag assignment, distinct tag listing, bulk feed-tag pairs
129 + - `StateRepository`: busser key-value state (get/set/delete by busser_id + key)
130 + - `ConfigRepository`: user_config key-value pairs (get/set/delete)
131 + - `QueryFeedsRepository`: query feed CRUD (create/update/delete/list)
132 132
133 133 FTS5 queries are sanitized by wrapping each search term in double quotes to prevent syntax injection (`AND`, `OR`, `NOT`, `NEAR` operators). The `^` prefix and `*` suffix characters are stripped.
134 134
@@ -141,7 +141,7 @@ Balanced Breakfast integrates with the SyncKit client SDK for cross-device sync.
141 141 - Feed tags
142 142 - User config (preferences)
143 143 - Query feeds (saved filter rules)
144 - - Feed item user state (is_read, is_starred changes only -- not item content)
144 + - Feed item user state (is_read, is_starred changes only, not item content)
145 145
146 146 **How it works:** SQLite triggers on synced tables write changes to `sync_changelog`. The sync engine pushes unpushed entries in batches of 500, pulls remote changes using a cursor, and applies them in FK-safe order (parents before children for upserts, children before parents for deletes). A `applying_remote` flag in `sync_state` suppresses trigger firing during remote change application to prevent echo loops.
147 147
@@ -159,11 +159,11 @@ The sync scheduler runs on a configurable interval (default 15 minutes). Encrypt
159 159 ## Concurrency Model
160 160
161 161 - **Tokio async runtime** (multi-threaded) drives all I/O: database queries, HTTP fetches, sync operations.
162 - - **`Arc<RwLock<PluginManager>>`** -- the orchestrator holds the plugin manager behind a Tokio RwLock. Read lock for fetches and schema queries; write lock only during plugin loading.
163 - - **`Arc<AppState>`** -- shared across Tauri commands and background tasks. Managed by Tauri's state system.
164 - - **AbortHandles** -- background tasks (auto-fetch loop, stale cleanup) store their `AbortHandle` in `AppState` behind `std::sync::Mutex`. On shutdown or task replacement, existing handles are aborted.
165 - - **Auto-fetch loop** -- checks every 60 seconds which plugins are due for a fetch based on their last_fetch timestamp and configured interval.
166 - - **Stale cleanup** -- runs every 6 hours, deleting read (non-starred) items older than 30 days.
162 + - **`Arc<RwLock<PluginManager>>`**: the orchestrator holds the plugin manager behind a Tokio RwLock. Read lock for fetches and schema queries; write lock only during plugin loading.
163 + - **`Arc<AppState>`**: shared across Tauri commands and background tasks. Managed by Tauri's state system.
164 + - **AbortHandles**: background tasks (auto-fetch loop, stale cleanup) store their `AbortHandle` in `AppState` behind `std::sync::Mutex`. On shutdown or task replacement, existing handles are aborted.
165 + - **Auto-fetch loop**: checks every 60 seconds which plugins are due for a fetch based on their last_fetch timestamp and configured interval.
166 + - **Stale cleanup**: runs every 6 hours, deleting read (non-starred) items older than 30 days.
167 167
168 168 ## Frontend Architecture
169 169
@@ -74,7 +74,7 @@ FTS5 virtual table in external content mode. Indexes `title`, `body`, and `bite_
74 74
75 75 - External content source: `feed_items` (via `content=` and `content_rowid=`)
76 76 - Kept in sync by three triggers: `feed_items_fts_insert`, `feed_items_fts_update`, `feed_items_fts_delete`
77 - - No data duplication -- FTS index references `feed_items` via rowid
77 + - No data duplication, FTS index references `feed_items` via rowid
78 78
79 79 ---
80 80
@@ -163,7 +163,7 @@ Index: `pushed`.
163 163 ## Design Patterns
164 164
165 165 - **Two-tier display model:** `feed_items` has compact "bite_*" fields for list rendering and full `title`/`body` fields for detail view
166 - - **External content FTS:** `feed_items_fts` uses FTS5 external content mode -- zero data duplication, trigger-maintained
166 + - **External content FTS:** `feed_items_fts` uses FTS5 external content mode, zero data duplication, trigger-maintained
167 167 - **Circuit breaker:** `feeds.circuit_broken` auto-disables feeds after `consecutive_failures` exceeds threshold
168 168 - **Sync guard triggers:** All sync triggers check `applying_remote != '1'` to prevent re-recording server-pushed changes
169 169 - **Synced tables:** `feeds`, `feed_tags`, `feed_items` (user state only: `is_read`/`is_starred`), `user_config`, `query_feeds`
@@ -102,19 +102,19 @@ const unsub = BB.state.subscribe('items', (newItems, oldItems) => {
102 102
103 103 | Property | Type | Domain |
104 104 |----------|------|--------|
105 - | `sources` | Array | Data -- feed sources with counts |
106 - | `items` | Array | Data -- feed items for current view |
107 - | `queryFeeds` | Array | Data -- saved filter rules |
108 - | `allTags` | Array | Data -- distinct tags across all feeds |
109 - | `currentSource` | string | Filter -- busser_id or `''` for all, `'__saved__'` for saved |
110 - | `currentOrder` | string | Filter -- `'chronological'`, `'score'`, `'unread'`, `'starred'` |
111 - | `currentSearch` | string | Filter -- search query text |
112 - | `currentTag` | string | Filter -- tag filter or `''` for all |
113 - | `currentQueryFeed` | string/null | Filter -- active query feed ID |
114 - | `currentPage` | number | Pagination -- zero-indexed page |
115 - | `hasMore` | boolean | Pagination -- backend has more pages |
116 - | `selectedItemId` | string/null | Selection -- active item in list |
117 - | `selectedPluginId` | string/null | UI -- plugin chosen in add-feed flow |
105 + | `sources` | Array | Data: feed sources with counts |
106 + | `items` | Array | Data: feed items for current view |
107 + | `queryFeeds` | Array | Data: saved filter rules |
108 + | `allTags` | Array | Data: distinct tags across all feeds |
109 + | `currentSource` | string | Filter: busser_id or `''` for all, `'__saved__'` for saved |
110 + | `currentOrder` | string | Filter: `'chronological'`, `'score'`, `'unread'`, `'starred'` |
111 + | `currentSearch` | string | Filter: search query text |
112 + | `currentTag` | string | Filter: tag filter or `''` for all |
113 + | `currentQueryFeed` | string/null | Filter: active query feed ID |
114 + | `currentPage` | number | Pagination: zero-indexed page |
115 + | `hasMore` | boolean | Pagination: backend has more pages |
116 + | `selectedItemId` | string/null | Selection: active item in list |
117 + | `selectedPluginId` | string/null | UI: plugin chosen in add-feed flow |
118 118
119 119 `resetPagination(clearSelection?)` is a convenience method that sets `currentPage` to 0 and optionally clears `selectedItemId`.
120 120
@@ -147,9 +147,9 @@ User clicks item in list
147 147 ### Reactive subscriptions
148 148
149 149 Two state subscriptions drive automatic re-rendering:
150 - - `BB.state.subscribe('items', render)` in items.js -- re-renders item list on any items change
151 - - `BB.state.subscribe('sources', render)` in sources.js -- re-renders sidebar on source list change
152 - - `BB.state.subscribe('items', onItemsChanged)` in detail.js -- merges updated summary fields into the detail view
150 + - `BB.state.subscribe('items', render)` in items.js: re-renders item list on any items change
151 + - `BB.state.subscribe('sources', render)` in sources.js: re-renders sidebar on source list change
152 + - `BB.state.subscribe('items', onItemsChanged)` in detail.js: merges updated summary fields into the detail view
153 153
154 154 ## API Layer
155 155
@@ -203,20 +203,20 @@ The two-step add-feed flow in feeds.js uses this system: step 1 shows a plugin p
203 203
204 204 Scripts load in order via `<script>` tags in `index.html`:
205 205
206 - 1. `bb.js` -- creates `window.BB` namespace with empty sub-objects
207 - 2. `themes.js` -- populates `BB.themes` (init, load, buildSelector)
208 - 3. `utils.js` -- populates `BB.utils` (escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage)
209 - 4. `state.js` -- replaces `BB.state` with Proxy-wrapped reactive store
210 - 5. `api.js` -- replaces `BB.api` with invoke wrappers
211 - 6. `components.js` -- replaces `BB.ui` with toast/modal/form/confirm functions
212 - 7. `query-feeds.js` -- populates `BB.queryFeeds` (depends on utils, state, api, ui)
213 - 8. `sources.js` -- populates `BB.sources` (depends on utils, state, api, ui, queryFeeds)
214 - 9. `items.js` -- populates `BB.items` (depends on utils, state, api, ui, detail)
215 - 10. `detail.js` -- populates `BB.detail` (depends on utils, state, api, ui)
216 - 11. `feeds.js` -- populates `BB.feeds` (depends on utils, state, api, ui, sources, items)
217 - 12. `settings-sync.js` -- populates `BB.sync` (depends on api, ui)
218 - 13. `updater.js` -- populates `BB.updater` (depends on ui, utils)
219 - 14. `app.js` -- populates `BB.app`, auto-calls `init()` on DOMContentLoaded
206 + 1. `bb.js`: creates `window.BB` namespace with empty sub-objects
207 + 2. `themes.js`: populates `BB.themes` (init, load, buildSelector)
208 + 3. `utils.js`: populates `BB.utils` (escapeHtml, escapeAttr, sanitizeHtml, debounce, getErrorMessage)
209 + 4. `state.js`: replaces `BB.state` with Proxy-wrapped reactive store
210 + 5. `api.js`: replaces `BB.api` with invoke wrappers
211 + 6. `components.js`: replaces `BB.ui` with toast/modal/form/confirm functions
212 + 7. `query-feeds.js`: populates `BB.queryFeeds` (depends on utils, state, api, ui)
213 + 8. `sources.js`: populates `BB.sources` (depends on utils, state, api, ui, queryFeeds)
214 + 9. `items.js`: populates `BB.items` (depends on utils, state, api, ui, detail)
215 + 10. `detail.js`: populates `BB.detail` (depends on utils, state, api, ui)
216 + 11. `feeds.js`: populates `BB.feeds` (depends on utils, state, api, ui, sources, items)
217 + 12. `settings-sync.js`: populates `BB.sync` (depends on api, ui)
218 + 13. `updater.js`: populates `BB.updater` (depends on ui, utils)
219 + 14. `app.js`: populates `BB.app`, auto-calls `init()` on DOMContentLoaded
220 220
221 221 Note: items.js (9) references `BB.detail` at call time (not load time), so the forward reference to detail.js (10) resolves by the time `init()` runs.
222 222
@@ -55,13 +55,13 @@ See "Config Schema Field Types" below for all supported `field_type` values.
55 55 The core function. Called by the runtime to retrieve feed items.
56 56
57 57 **Parameters:**
58 - - `config` -- A map containing the user's configuration values (keyed by the field `key` from the schema). Also contains a `feeds` array if additional feed URLs were configured.
59 - - `cursor` -- A string for pagination (the `next_cursor` from a previous fetch), or `()` (Rhai's unit/nil) on the first call.
58 + - `config`: A map containing the user's configuration values (keyed by the field `key` from the schema). Also contains a `feeds` array if additional feed URLs were configured.
59 + - `cursor`: A string for pagination (the `next_cursor` from a previous fetch), or `()` (Rhai's unit/nil) on the first call.
60 60
61 61 **Must return a map with:**
62 - - `items` -- An array of item maps (see "Item Structure" below)
63 - - `has_more` -- Boolean indicating whether more pages are available
64 - - `next_cursor` -- (Optional) String cursor for the next page
62 + - `items`: An array of item maps (see "Item Structure" below)
63 + - `has_more`: Boolean indicating whether more pages are available
64 + - `next_cursor`: (Optional) String cursor for the next page
65 65
66 66 ```rhai
67 67 fn fetch(config, cursor) {
@@ -239,8 +239,8 @@ Each field in the `fields` array supports these properties:
239 239
240 240 The Rhai engine enforces these limits per plugin execution:
241 241
242 - - **Max operations:** 100,000 -- Caps total operations per script call. A typical RSS fetch uses 1,000-5,000 operations. This limit catches infinite loops while allowing complex plugins.
243 - - **Max expression depth:** 128 -- Limits AST nesting depth for both expressions and function calls, preventing stack overflows from deeply recursive scripts.
242 + - **Max operations:** 100,000. Caps total operations per script call. A typical RSS fetch uses 1,000-5,000 operations. This limit catches infinite loops while allowing complex plugins.
243 + - **Max expression depth:** 128. Limits AST nesting depth for both expressions and function calls, preventing stack overflows from deeply recursive scripts.
244 244
245 245 If a plugin exceeds either limit, the engine terminates execution and returns an error.
246 246
@@ -316,7 +316,7 @@ The built-in plugins serve as working examples:
316 316 | `hackernews.rhai` | 228 | `http_get_json` for API calls, pagination with cursors, score metadata |
317 317 | `arxiv.rhai` | 179 | `parse_xml` for custom XML, `select` config field, category filtering |
318 318
319 - `rss.rhai` is the canonical example -- it covers the most common pattern (fetch XML, parse with `parse_feed`, map entries to items) in under 150 lines.
319 + `rss.rhai` is the canonical example, it covers the most common pattern (fetch XML, parse with `parse_feed`, map entries to items) in under 150 lines.
320 320
321 321 ## Plugin File Location
322 322