Skip to main content

max / balanced_breakfast

Version bump to 0.2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-12 02:03 UTC
Commit: 1f74722199c4e3e0c6a155b62bcf0ae8c6710ac8
Parent: 1ff7450
55 files changed, +5251 insertions, -1702 deletions
M Cargo.lock +144 -13
@@ -217,6 +217,7 @@ dependencies = [
217 217 "chrono",
218 218 "html2text",
219 219 "rand 0.8.5",
220 + "readable-readability",
220 221 "regex",
221 222 "rhai",
222 223 "roxmltree",
@@ -252,6 +253,7 @@ dependencies = [
252 253 "bb-db",
253 254 "bb-interface",
254 255 "chrono",
256 + "regex",
255 257 "serde_json",
256 258 "sqlx",
257 259 "thiserror 1.0.69",
@@ -703,13 +705,30 @@ dependencies = [
703 705
704 706 [[package]]
705 707 name = "cssparser"
708 + version = "0.27.2"
709 + source = "registry+https://github.com/rust-lang/crates.io-index"
710 + checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a"
711 + dependencies = [
712 + "cssparser-macros",
713 + "dtoa-short",
714 + "itoa 0.4.8",
715 + "matches",
716 + "phf 0.8.0",
717 + "proc-macro2",
718 + "quote",
719 + "smallvec",
720 + "syn 1.0.109",
721 + ]
722 +
723 + [[package]]
724 + name = "cssparser"
706 725 version = "0.29.6"
707 726 source = "registry+https://github.com/rust-lang/crates.io-index"
708 727 checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa"
709 728 dependencies = [
710 729 "cssparser-macros",
711 730 "dtoa-short",
712 - "itoa",
731 + "itoa 1.0.17",
713 732 "matches",
714 733 "phf 0.10.1",
715 734 "proc-macro2",
@@ -1660,6 +1679,20 @@ dependencies = [
1660 1679
1661 1680 [[package]]
1662 1681 name = "html5ever"
1682 + version = "0.25.2"
1683 + source = "registry+https://github.com/rust-lang/crates.io-index"
1684 + checksum = "e5c13fb08e5d4dfc151ee5e88bae63f7773d61852f3bdc73c9f4b9e1bde03148"
1685 + dependencies = [
1686 + "log",
1687 + "mac",
1688 + "markup5ever 0.10.1",
1689 + "proc-macro2",
1690 + "quote",
1691 + "syn 1.0.109",
1692 + ]
1693 +
1694 + [[package]]
1695 + name = "html5ever"
1663 1696 version = "0.27.0"
1664 1697 source = "registry+https://github.com/rust-lang/crates.io-index"
1665 1698 checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
@@ -1691,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1691 1724 checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
1692 1725 dependencies = [
1693 1726 "bytes",
1694 - "itoa",
1727 + "itoa 1.0.17",
1695 1728 ]
1696 1729
1697 1730 [[package]]
@@ -1737,7 +1770,7 @@ dependencies = [
1737 1770 "http",
1738 1771 "http-body",
1739 1772 "httparse",
1740 - "itoa",
1773 + "itoa 1.0.17",
1741 1774 "pin-project-lite",
1742 1775 "pin-utils",
1743 1776 "smallvec",
@@ -2022,6 +2055,12 @@ dependencies = [
2022 2055
2023 2056 [[package]]
2024 2057 name = "itoa"
2058 + version = "0.4.8"
2059 + source = "registry+https://github.com/rust-lang/crates.io-index"
2060 + checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4"
2061 +
2062 + [[package]]
2063 + name = "itoa"
2025 2064 version = "1.0.17"
2026 2065 source = "registry+https://github.com/rust-lang/crates.io-index"
2027 2066 checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
@@ -2125,15 +2164,27 @@ dependencies = [
2125 2164 ]
2126 2165
2127 2166 [[package]]
2167 + name = "kuchiki"
2168 + version = "0.8.1"
2169 + source = "registry+https://github.com/rust-lang/crates.io-index"
2170 + checksum = "1ea8e9c6e031377cff82ee3001dc8026cdf431ed4e2e6b51f98ab8c73484a358"
2171 + dependencies = [
2172 + "cssparser 0.27.2",
2173 + "html5ever 0.25.2",
2174 + "matches",
2175 + "selectors 0.22.0",
2176 + ]
2177 +
2178 + [[package]]
2128 2179 name = "kuchikiki"
2129 2180 version = "0.8.8-speedreader"
2130 2181 source = "registry+https://github.com/rust-lang/crates.io-index"
2131 2182 checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2"
2132 2183 dependencies = [
2133 - "cssparser",
2184 + "cssparser 0.29.6",
2134 2185 "html5ever 0.29.1",
2135 2186 "indexmap 2.13.0",
2136 - "selectors",
2187 + "selectors 0.24.0",
2137 2188 ]
2138 2189
2139 2190 [[package]]
@@ -2248,6 +2299,20 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
2248 2299
2249 2300 [[package]]
2250 2301 name = "markup5ever"
2302 + version = "0.10.1"
2303 + source = "registry+https://github.com/rust-lang/crates.io-index"
2304 + checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd"
2305 + dependencies = [
2306 + "log",
2307 + "phf 0.8.0",
2308 + "phf_codegen 0.8.0",
2309 + "string_cache",
2310 + "string_cache_codegen",
2311 + "tendril",
2312 + ]
2313 +
2314 + [[package]]
2315 + name = "markup5ever"
2251 2316 version = "0.12.1"
2252 2317 source = "registry+https://github.com/rust-lang/crates.io-index"
2253 2318 checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
@@ -2915,7 +2980,9 @@ version = "0.8.0"
2915 2980 source = "registry+https://github.com/rust-lang/crates.io-index"
2916 2981 checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12"
2917 2982 dependencies = [
2983 + "phf_macros 0.8.0",
2918 2984 "phf_shared 0.8.0",
2985 + "proc-macro-hack",
2919 2986 ]
2920 2987
2921 2988 [[package]]
@@ -2991,6 +3058,20 @@ dependencies = [
2991 3058
2992 3059 [[package]]
2993 3060 name = "phf_macros"
3061 + version = "0.8.0"
3062 + source = "registry+https://github.com/rust-lang/crates.io-index"
3063 + checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c"
3064 + dependencies = [
3065 + "phf_generator 0.8.0",
3066 + "phf_shared 0.8.0",
3067 + "proc-macro-hack",
3068 + "proc-macro2",
3069 + "quote",
3070 + "syn 1.0.109",
3071 + ]
3072 +
3073 + [[package]]
3074 + name = "phf_macros"
2994 3075 version = "0.10.0"
2995 3076 source = "registry+https://github.com/rust-lang/crates.io-index"
2996 3077 checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0"
@@ -3347,6 +3428,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3347 3428 checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
3348 3429
3349 3430 [[package]]
3431 + name = "readable-readability"
3432 + version = "0.4.0"
3433 + source = "registry+https://github.com/rust-lang/crates.io-index"
3434 + checksum = "c17015928a25bff296b0471dfa7a784e406664e1d091781db66e885b18708a8d"
3435 + dependencies = [
3436 + "html5ever 0.25.2",
3437 + "kuchiki",
3438 + "lazy_static",
3439 + "log",
3440 + "regex",
3441 + "url",
3442 + ]
3443 +
3444 + [[package]]
3350 3445 name = "redox_syscall"
3351 3446 version = "0.5.18"
3352 3447 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3761,19 +3856,39 @@ dependencies = [
3761 3856
3762 3857 [[package]]
3763 3858 name = "selectors"
3859 + version = "0.22.0"
3860 + source = "registry+https://github.com/rust-lang/crates.io-index"
3861 + checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe"
3862 + dependencies = [
3863 + "bitflags 1.3.2",
3864 + "cssparser 0.27.2",
3865 + "derive_more",
3866 + "fxhash",
3867 + "log",
3868 + "matches",
3869 + "phf 0.8.0",
3870 + "phf_codegen 0.8.0",
3871 + "precomputed-hash",
3872 + "servo_arc 0.1.1",
3873 + "smallvec",
3874 + "thin-slice",
3875 + ]
3876 +
3877 + [[package]]
3878 + name = "selectors"
3764 3879 version = "0.24.0"
3765 3880 source = "registry+https://github.com/rust-lang/crates.io-index"
3766 3881 checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416"
3767 3882 dependencies = [
3768 3883 "bitflags 1.3.2",
3769 - "cssparser",
3884 + "cssparser 0.29.6",
3770 3885 "derive_more",
3771 3886 "fxhash",
3772 3887 "log",
3773 3888 "phf 0.8.0",
3774 3889 "phf_codegen 0.8.0",
3775 3890 "precomputed-hash",
3776 - "servo_arc",
3891 + "servo_arc 0.2.0",
3777 3892 "smallvec",
3778 3893 ]
3779 3894
@@ -3846,7 +3961,7 @@ version = "1.0.149"
3846 3961 source = "registry+https://github.com/rust-lang/crates.io-index"
3847 3962 checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
3848 3963 dependencies = [
3849 - "itoa",
3964 + "itoa 1.0.17",
3850 3965 "memchr",
3851 3966 "serde",
3852 3967 "serde_core",
@@ -3889,7 +4004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3889 4004 checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
3890 4005 dependencies = [
3891 4006 "form_urlencoded",
3892 - "itoa",
4007 + "itoa 1.0.17",
3893 4008 "ryu",
3894 4009 "serde",
3895 4010 ]
@@ -3949,6 +4064,16 @@ dependencies = [
3949 4064
3950 4065 [[package]]
3951 4066 name = "servo_arc"
4067 + version = "0.1.1"
4068 + source = "registry+https://github.com/rust-lang/crates.io-index"
4069 + checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432"
4070 + dependencies = [
4071 + "nodrop",
4072 + "stable_deref_trait",
4073 + ]
4074 +
4075 + [[package]]
4076 + name = "servo_arc"
3952 4077 version = "0.2.0"
3953 4078 source = "registry+https://github.com/rust-lang/crates.io-index"
3954 4079 checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741"
@@ -4285,7 +4410,7 @@ dependencies = [
4285 4410 "hex",
4286 4411 "hkdf",
4287 4412 "hmac",
4288 - "itoa",
4413 + "itoa 1.0.17",
4289 4414 "log",
4290 4415 "md-5",
4291 4416 "memchr",
@@ -4326,7 +4451,7 @@ dependencies = [
4326 4451 "hkdf",
4327 4452 "hmac",
4328 4453 "home",
4329 - "itoa",
4454 + "itoa 1.0.17",
4330 4455 "log",
4331 4456 "md-5",
4332 4457 "memchr",
@@ -4474,7 +4599,7 @@ dependencies = [
4474 4599
4475 4600 [[package]]
4476 4601 name = "synckit-client"
4477 - version = "0.1.0"
4602 + version = "0.2.0"
4478 4603 dependencies = [
4479 4604 "argon2",
4480 4605 "base64 0.22.1",
@@ -4928,6 +5053,12 @@ dependencies = [
4928 5053 ]
4929 5054
4930 5055 [[package]]
5056 + name = "thin-slice"
5057 + version = "0.1.1"
5058 + source = "registry+https://github.com/rust-lang/crates.io-index"
5059 + checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
5060 +
5061 + [[package]]
4931 5062 name = "thin-vec"
4932 5063 version = "0.2.14"
4933 5064 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4992,7 +5123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
4992 5123 checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
4993 5124 dependencies = [
4994 5125 "deranged",
4995 - "itoa",
5126 + "itoa 1.0.17",
4996 5127 "num-conv",
4997 5128 "powerfmt",
4998 5129 "serde_core",
M Cargo.toml +2 -2
@@ -10,7 +10,7 @@ members = [
10 10 default-members = ["src-tauri"]
11 11
12 12 [workspace.package]
13 - version = "0.1.0"
13 + version = "0.2.1"
14 14 edition = "2021"
15 15 authors = ["BalancedBreakfast Contributors"]
16 16 license-file = "LICENSE"
@@ -46,4 +46,4 @@ bb-interface = { path = "crates/bb-interface" }
46 46 bb-core = { path = "crates/bb-core" }
47 47 bb-feed = { path = "crates/bb-feed" }
48 48 bb-db = { path = "crates/bb-db" }
49 - synckit-client = { path = "../../synckit-client" }
49 + synckit-client = { path = "../synckit-client" }
M README.md +5 -236
@@ -47,247 +47,16 @@ Dependency flow: `bb-interface` is leaf (no internal deps) -> `bb-core` and `bb-
47 47
48 48 ## Plugin Authoring
49 49
50 - Plugins (called "bussers") are `.rhai` script files. Drop a `.rhai` file into the plugins directory and it is loaded on next launch -- no compilation required.
50 + Plugins ("bussers") are `.rhai` script files. Drop one into the plugins directory and it loads on next launch.
51 51
52 - **Plugin directory locations:**
53 - - Development: `plugins/` at the project root
54 - - Production: `<app_config_dir>/plugins/` (e.g. `~/Library/Application Support/com.balancedbreakfast.app/plugins/` on macOS)
52 + - **Dev:** `plugins/` at the project root
53 + - **Prod:** `<app_config_dir>/plugins/` (e.g. `~/Library/Application Support/com.balancedbreakfast.app/plugins/` on macOS)
55 54
56 - ### Required Functions
57 -
58 - Every plugin must define four functions:
59 -
60 - ```rhai
61 - fn id() { "my-source" }
62 -
63 - fn name() { "My Source" }
64 -
65 - fn config_schema() {
66 - #{
67 - description: "What this plugin does.",
68 - fields: [
69 - #{
70 - key: "feed_url",
71 - label: "Feed URL",
72 - field_type: "url",
73 - required: true,
74 - description: "Help text shown below the field",
75 - placeholder: "https://example.com/feed",
76 - default_value: ""
77 - }
78 - ]
79 - }
80 - }
81 -
82 - fn fetch(config, cursor) {
83 - // config is a map with keys from your schema + `feeds` array + `feed_url` shorthand
84 - // cursor is a string on subsequent pages, or () on first fetch
85 - #{
86 - items: [],
87 - has_more: false,
88 - next_cursor: ()
89 - }
90 - }
91 - ```
92 -
93 - ### Optional: capabilities()
94 -
95 - Return a map to advertise what your plugin supports. All fields default to `false`/`900` if omitted.
96 -
97 - ```rhai
98 - fn capabilities() {
99 - #{
100 - supports_pagination: true,
101 - supports_search: false,
102 - supports_date_filter: false,
103 - supports_read_state: false,
104 - supports_starring: false,
105 - requires_auth: false,
106 - fetch_interval_secs: 900 // auto-fetch interval; 900 = 15 min, 0 = disable
107 - }
108 - }
109 - ```
110 -
111 - `fetch_interval_secs` controls how often the background scheduler re-fetches this source. The default is 900 seconds (15 minutes). Set to 0 to disable auto-fetch entirely for a plugin.
112 -
113 - ### Config Schema Fields
114 -
115 - Each field in the `fields` array is a map with these keys:
116 -
117 - | Key | Type | Required | Notes |
118 - |-----|------|----------|-------|
119 - | `key` | string | yes | Config key passed to `fetch()` via `config.key` |
120 - | `label` | string | yes | Display label in the UI |
121 - | `field_type` | string | yes | One of: `text`, `textarea`, `secret`, `url`, `number`, `toggle`, `select` |
122 - | `required` | bool | no | Whether the field must be filled (default `false`) |
123 - | `description` | string | no | Help text shown below the field |
124 - | `default_value` | string | no | Pre-filled value (use `default_value`, not `default`, since `default` is a reserved word in Rhai) |
125 - | `placeholder` | string | no | Placeholder text |
126 - | `options` | array | no | String array of choices for `select` fields |
127 -
128 - ### Fetch Return Shape
129 -
130 - `fetch(config, cursor)` must return a map:
131 -
132 - ```rhai
133 - #{
134 - items: [
135 - #{
136 - id: "unique-item-id", // string, or #{ source: "my-source", item_id: "123" }
137 - bite: #{
138 - author: "Author Name", // shown in the item list
139 - text: "Headline or summary", // primary display text
140 - secondary: "42 pts", // optional, shown as secondary info
141 - indicator: "icon" // optional, type indicator
142 - },
143 - content: #{
144 - title: "Full Title", // optional
145 - body: "<p>HTML body</p>", // optional
146 - url: "https://example.com" // optional, link to original
147 - },
148 - meta: #{
149 - source_name: "My Source", // display name in source list
150 - published_at: 1700000000, // Unix timestamp (seconds)
151 - score: 42, // optional, for score-based ordering
152 - tags: ["tag1", "tag2"] // optional
153 - }
154 - }
155 - ],
156 - has_more: false, // true if more pages available
157 - next_cursor: () // opaque string passed to next fetch() call, or () if done
158 - }
159 - ```
160 -
161 - All sub-maps (`bite`, `content`, `meta`) are optional. Missing fields fall back to sensible defaults (empty strings, current timestamp, plugin ID as source name).
162 -
163 - ### Host Functions
164 -
165 - These functions are available to all Rhai scripts:
166 -
167 - **HTTP**
168 -
169 - | Function | Signature | Description |
170 - |----------|-----------|-------------|
171 - | `http_get` | `(url: string) -> string` | GET request, returns response body as string |
172 - | `http_get_json` | `(url: string) -> Dynamic` | GET request, returns parsed JSON as a Rhai map/array |
173 -
174 - **Parsing**
175 -
176 - | Function | Signature | Description |
177 - |----------|-----------|-------------|
178 - | `parse_feed` | `(input: string) -> map` | Auto-detects RSS/Atom/JSON Feed, returns `#{ title, link, entries }` where each entry has `id`, `title`, `summary`, `link`, `published`, `author`, `tags` |
179 - | `parse_xml` | `(xml: string) -> map` | Parses XML into nested maps with `tag`, `text`, `attrs`, `children` keys |
180 - | `parse_json` | `(json: string) -> Dynamic` | Parses a JSON string into Rhai values |
181 -
182 - **String Utilities**
183 -
184 - | Function | Signature | Description |
185 - |----------|-----------|-------------|
186 - | `truncate` | `(text, max_len) -> string` | Truncate with ellipsis (`...`) |
187 - | `str_contains` | `(text, pattern) -> bool` | Substring check |
188 - | `str_split` | `(text, separator) -> array` | Split into string array |
189 - | `str_replace` | `(text, from, to) -> string` | Replace all occurrences |
190 - | `str_trim` | `(text) -> string` | Trim whitespace |
191 - | `html_to_text` | `(html) -> string` | Strip HTML tags to plain text |
192 -
193 - **Date/Time**
194 -
195 - | Function | Signature | Description |
196 - |----------|-----------|-------------|
197 - | `timestamp_now` | `() -> int` | Current UTC Unix timestamp (seconds) |
198 - | `parse_datetime` | `(date_str) -> int` | Parse RFC 3339 or RFC 2822 date to Unix timestamp |
199 -
200 - **Other**
201 -
202 - | Function | Signature | Description |
203 - |----------|-----------|-------------|
204 - | `parse_int` | `(text) -> int or ()` | Parse string to integer; returns `()` on failure |
205 - | `strip_tracking` | `(url) -> string` | Remove UTM and other tracking query parameters |
206 - | `debug_print` | `(value) -> ()` | Print to the debug log (visible in dev console) |
207 -
208 - ### Minimal Example
209 -
210 - A complete plugin that fetches an RSS feed:
211 -
212 - ```rhai
213 - fn id() { "my-rss" }
214 - fn name() { "My RSS Plugin" }
215 -
216 - fn config_schema() {
217 - #{
218 - description: "A simple RSS reader.",
219 - fields: [
220 - #{
221 - key: "feed_url",
222 - label: "Feed URL",
223 - field_type: "url",
224 - required: true
225 - }
226 - ]
227 - }
228 - }
229 -
230 - fn fetch(config, cursor) {
231 - let xml = http_get(config.feed_url);
232 - let feed = parse_feed(xml);
233 - let items = [];
234 -
235 - for entry in feed.entries {
236 - let published = timestamp_now();
237 - if entry.published != () {
238 - published = entry.published;
239 - }
240 -
241 - items.push(#{
242 - id: entry.id,
243 - bite: #{ author: feed.title, text: truncate(entry.title, 100) },
244 - content: #{ title: entry.title, body: entry.summary, url: entry.link },
245 - meta: #{ source_name: feed.title, published_at: published }
246 - });
247 - }
248 -
249 - #{ items: items, has_more: false }
250 - }
251 - ```
252 -
253 - ### Sandbox Limits
254 -
255 - Rhai scripts run with safety limits to prevent hangs:
256 -
257 - - **100,000 max operations** per execution (a typical RSS fetch uses 1k-5k)
258 - - **128 max expression depth** for both expressions and function calls
259 -
260 - Scripts that exceed these limits are terminated with an error.
261 -
262 - ### JSON API Example
263 -
264 - For sources with JSON APIs (no XML), use `http_get_json` instead of `parse_feed`:
265 -
266 - ```rhai
267 - fn fetch(config, cursor) {
268 - let data = http_get_json("https://api.example.com/posts");
269 - let items = [];
270 -
271 - for post in data {
272 - items.push(#{
273 - id: "" + post.id,
274 - bite: #{ author: post.author, text: truncate(post.title, 100) },
275 - content: #{ title: post.title, body: post.body, url: post.url },
276 - meta: #{ source_name: "Example", published_at: parse_datetime(post.date) }
277 - });
278 - }
279 -
280 - #{ items: items, has_more: false }
281 - }
282 - ```
55 + Every plugin defines four functions (`id`, `name`, `config_schema`, `fetch`) plus an optional `capabilities()`. Full authoring guide with field types, return shapes, host functions, and examples: [docs/plugin_authoring.md](docs/plugin_authoring.md).
283 56
284 57 ## Bundled Plugins
285 58
286 - Three plugins ship with the app:
287 -
288 - - **rss.rhai** -- RSS, Atom, and JSON Feed support
289 - - **hackernews.rhai** -- Hacker News stories (Top, New, Best, Ask, Show, Jobs)
290 - - **arxiv.rhai** -- arXiv papers by category
59 + Three plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers).
291 60
292 61 ## License
293 62
@@ -38,3 +38,6 @@ url = "2"
38 38
39 39 # Regex for HTML URL rewriting
40 40 regex = "1"
41 +
42 + # Article extraction (reader view)
43 + readable-readability = "0.4"
@@ -13,4 +13,4 @@ pub mod url_cleaner;
13 13
14 14 pub use orchestrator::*;
15 15 pub use plugin_manager::*;
16 - pub use rhai_plugin::{RhaiPlugin, RhaiPluginError, RhaiPluginManager};
16 + pub use rhai_plugin::{ReaderResult, RhaiPlugin, RhaiPluginError, RhaiPluginManager};
@@ -88,7 +88,7 @@ impl Orchestrator {
88 88 pub async fn load_plugins(&self) -> Result<Vec<String>, OrchestratorError> {
89 89 let mut plugins = self.plugins.write().await;
90 90 let loaded = plugins.load_all()?;
91 - info!("Loaded {} plugins", loaded.len());
91 + info!(count = loaded.len(), "Loaded plugins");
92 92 Ok(loaded)
93 93 }
94 94
@@ -101,7 +101,7 @@ impl Orchestrator {
101 101 let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
102 102
103 103 if feeds.is_empty() {
104 - info!("No feeds configured for plugin: {}", plugin_id);
104 + info!(%plugin_id, "No feeds configured for plugin");
105 105 return Ok(());
106 106 }
107 107
@@ -117,8 +117,8 @@ impl Orchestrator {
117 117 .collect(),
118 118 None => {
119 119 debug!(
120 - "No config schema available for plugin {}, treating all fields as options",
121 - plugin_id
120 + %plugin_id,
121 + "No config schema available for plugin, treating all fields as options"
122 122 );
123 123 Vec::new()
124 124 }
@@ -162,7 +162,10 @@ impl Orchestrator {
162 162 Ok(())
163 163 }
164 164
165 - /// Fetch items from a specific plugin
165 + /// Fetch items from a specific plugin.
166 + ///
167 + /// Returns `(items_count, circuit_breaker_tripped)`. The second value is
168 + /// `true` when this fetch failure caused the circuit breaker to trip.
166 169 pub async fn fetch_plugin(
167 170 &self,
168 171 plugin_id: &str,
@@ -172,7 +175,7 @@ impl Orchestrator {
172 175 let feed_id = feeds.first().map(|f| f.id);
173 176
174 177 let Some(feed_id) = feed_id else {
175 - debug!("No feeds configured for plugin {}, skipping store", plugin_id);
178 + debug!(%plugin_id, "No feeds configured for plugin, skipping store");
176 179 return Ok(0);
177 180 };
178 181
@@ -182,13 +185,23 @@ impl Orchestrator {
182 185 Err(e) => {
183 186 // Record fetch failure before propagating
184 187 let error_msg = e.to_string();
185 - if let Err(db_err) = self
188 + match self
186 189 .db
187 190 .feeds()
188 191 .record_fetch_failure(feed_id, &error_msg)
189 192 .await
190 193 {
191 - error!("Failed to record fetch failure: {}", db_err);
194 + Ok(tripped) => {
195 + if tripped {
196 + info!(
197 + %feed_id, %plugin_id,
198 + "Circuit breaker tripped for feed"
199 + );
200 + }
201 + }
202 + Err(db_err) => {
203 + error!(error = %db_err, "Failed to record fetch failure");
204 + }
192 205 }
193 206 return Err(e.into());
194 207 }
@@ -210,7 +223,7 @@ impl Orchestrator {
210 223 match self.db.items().upsert(create_item).await {
211 224 Ok(_) => count += 1,
212 225 Err(e) => {
213 - error!("Failed to store item: {}", e);
226 + error!(error = %e, "Failed to store item");
214 227 }
215 228 }
216 229 }
@@ -218,10 +231,34 @@ impl Orchestrator {
218 231 // Record successful fetch (resets failure counter)
219 232 self.db.feeds().record_fetch_success(feed_id).await?;
220 233
221 - info!("Fetched {} items from {}", count, plugin_id);
234 + info!(count, %plugin_id, "Fetched items from plugin");
222 235 Ok(count)
223 236 }
224 237
238 + /// Check whether a plugin's feed is circuit-broken.
239 + pub async fn is_circuit_broken(&self, plugin_id: &str) -> Result<bool, OrchestratorError> {
240 + let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
241 + Ok(feeds.first().is_some_and(|f| f.circuit_broken))
242 + }
243 +
244 + /// Reset the circuit breaker for a plugin's feed and attempt a fresh fetch.
245 + ///
246 + /// Clears `circuit_broken`, resets `consecutive_failures` to 0, and clears
247 + /// `last_error`. Returns the item count from the fetch attempt.
248 + pub async fn reset_circuit_breaker_and_fetch(
249 + &self,
250 + plugin_id: &str,
251 + ) -> Result<usize, OrchestratorError> {
252 + let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
253 + for feed in &feeds {
254 + if feed.circuit_broken {
255 + self.db.feeds().reset_circuit_breaker(feed.id).await?;
256 + info!(feed_name = %feed.name, %plugin_id, "Circuit breaker reset for feed");
257 + }
258 + }
259 + self.fetch_plugin(plugin_id).await
260 + }
261 +
225 262 /// Fetch from all active plugins
226 263 pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> {
227 264 let plugin_ids = {
@@ -234,7 +271,7 @@ impl Orchestrator {
234 271 match self.fetch_plugin(&plugin_id).await {
235 272 Ok(count) => total += count,
236 273 Err(e) => {
237 - error!("Failed to fetch from {}: {}", plugin_id, e);
274 + error!(error = %e, %plugin_id, "Failed to fetch from plugin");
238 275 }
239 276 }
240 277 }
@@ -308,7 +345,7 @@ impl Orchestrator {
308 345 serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
309 346 self.db.feeds().update_config(feed.id, &config_str).await?;
310 347
311 - info!("Encrypted secrets for feed: {}", feed.name);
348 + info!(feed_name = %feed.name, "Encrypted secrets for feed");
312 349 }
313 350
314 351 Ok(())
@@ -58,7 +58,7 @@ impl PluginManager {
58 58 let mut plugins = Vec::new();
59 59
60 60 if !self.plugins_dir.exists() {
61 - info!("Creating plugins directory: {:?}", self.plugins_dir);
61 + info!(path = ?self.plugins_dir, "Creating plugins directory");
62 62 std::fs::create_dir_all(&self.plugins_dir)?;
63 63 return Ok(plugins);
64 64 }
@@ -72,7 +72,7 @@ impl PluginManager {
72 72 }
73 73 }
74 74
75 - info!("Discovered {} Rhai plugins", plugins.len());
75 + info!(count = plugins.len(), "Discovered Rhai plugins");
76 76 Ok(plugins)
77 77 }
78 78
@@ -84,14 +84,14 @@ impl PluginManager {
84 84 /// Load a plugin from a .rhai file path
85 85 pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> {
86 86 let path = path.as_ref();
87 - info!("Loading Rhai plugin from: {:?}", path);
87 + info!(?path, "Loading Rhai plugin");
88 88
89 89 let id = self
90 90 .rhai_manager
91 91 .load_plugin(path)
92 92 .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?;
93 93
94 - debug!("Loaded Rhai plugin: {}", id);
94 + debug!(%id, "Loaded Rhai plugin");
95 95 Ok(id)
96 96 }
97 97
@@ -104,7 +104,7 @@ impl PluginManager {
104 104 match self.load_plugin(&path) {
105 105 Ok(id) => loaded.push(id),
106 106 Err(e) => {
107 - warn!("Failed to load plugin {:?}: {}", path, e);
107 + warn!(error = %e, ?path, "Failed to load plugin");
108 108 }
109 109 }
110 110 }
@@ -130,7 +130,7 @@ impl PluginManager {
130 130 .map_err(|e| PluginError::LockPoisoned(e.to_string()))?;
131 131 configs.insert(plugin_id.to_string(), config);
132 132
133 - info!("Initialized plugin: {}", plugin_id);
133 + info!(%plugin_id, "Initialized plugin");
134 134 Ok(())
135 135 }
136 136
@@ -164,7 +164,7 @@ impl PluginManager {
164 164 return Err(PluginError::NotFound(plugin_id.to_string()));
165 165 }
166 166
167 - info!("Shutdown plugin: {}", plugin_id);
167 + info!(%plugin_id, "Shutdown plugin");
168 168 Ok(())
169 169 }
170 170
@@ -173,7 +173,7 @@ impl PluginManager {
173 173 let plugin_ids = self.rhai_manager.list();
174 174 for id in plugin_ids {
175 175 if let Err(e) = self.shutdown_plugin(&id) {
176 - error!("Failed to shutdown plugin {}: {}", id, e);
176 + error!(error = %e, %id, "Failed to shutdown plugin");
177 177 }
178 178 }
179 179 }
@@ -199,7 +199,7 @@ impl PluginManager {
199 199 match p.config_schema() {
200 200 Ok(s) => Some(s),
201 201 Err(e) => {
202 - warn!("Plugin {} config_schema() failed: {}", plugin_id, e);
202 + warn!(error = %e, %plugin_id, "Plugin config_schema() failed");
203 203 None
204 204 }
205 205 }
@@ -542,7 +542,7 @@ pub(super) fn dynamic_to_fetch_result(
542 542 match dynamic_to_feed_item(item_val, source_id) {
543 543 Ok(item) => items.push(item),
544 544 Err(e) => {
545 - warn!("Failed to parse feed item: {}", e);
545 + warn!(error = %e, "Failed to parse feed item");
546 546 }
547 547 }
548 548 }
@@ -1,10 +1,82 @@
1 1 //! Host functions registered into the Rhai engine for plugin scripts.
2 2
3 + use std::io::Read as _;
4 + use std::sync::atomic::{AtomicUsize, Ordering};
5 + use std::sync::Arc;
6 + use std::time::Duration;
7 +
3 8 use rhai::{Dynamic, Engine};
4 9
5 10 use super::conversions::{json_to_dynamic, parse_feed_xml, parse_json_feed, parse_xml_to_dynamic};
6 11 use crate::url_cleaner;
7 12
13 + /// HTTP request timeout for plugin host functions.
14 + const HTTP_TIMEOUT: Duration = Duration::from_secs(15);
15 +
16 + /// Maximum response body size (2 MB). Prevents a plugin from consuming
17 + /// unbounded memory on a large or malicious response.
18 + const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024;
19 +
20 + /// Maximum HTTP requests per fetch() invocation. A typical RSS plugin
21 + /// makes 1-3 requests; 100 allows pagination while catching runaways.
22 + const MAX_REQUESTS_PER_FETCH: usize = 100;
23 +
24 + /// Validate a URL before making an HTTP request. Blocks non-HTTP schemes
25 + /// and requests to localhost/internal networks.
26 + fn validate_url(url: &str) -> Result<(), String> {
27 + let lower = url.to_ascii_lowercase();
28 + if !lower.starts_with("http://") && !lower.starts_with("https://") {
29 + return Err(format!("Blocked URL scheme (only http/https allowed): {}", url));
30 + }
31 + // Block localhost and common internal addresses
32 + let host_part = lower
33 + .strip_prefix("http://")
34 + .or_else(|| lower.strip_prefix("https://"))
35 + .unwrap_or("");
36 + let host_and_port = host_part.split('/').next().unwrap_or("");
37 + // Handle IPv6 brackets: [::1]:8080 -> [::1]
38 + let host = if host_and_port.starts_with('[') {
39 + host_and_port.split(']').next().map(|s| format!("{}]", s)).unwrap_or_default()
40 + } else {
41 + host_and_port.split(':').next().unwrap_or("").to_string() // strip port
42 + };
43 + let host = host.as_str();
44 + if host == "localhost"
45 + || host == "127.0.0.1"
46 + || host == "[::1]"
47 + || host == "0.0.0.0"
48 + || host.starts_with("10.")
49 + || host.starts_with("192.168.")
50 + || host.starts_with("169.254.")
51 + {
52 + return Err(format!("Blocked request to internal address: {}", url));
53 + }
54 + // Block 172.16.0.0/12
55 + if let Some(rest) = host.strip_prefix("172.") {
56 + if let Some(second) = rest.split('.').next() {
57 + if let Ok(n) = second.parse::<u8>() {
58 + if (16..=31).contains(&n) {
59 + return Err(format!("Blocked request to internal address: {}", url));
60 + }
61 + }
62 + }
63 + }
64 + Ok(())
65 + }
66 +
67 + /// Check and increment the per-fetch request counter.
68 + fn check_request_limit(counter: &AtomicUsize) -> Result<(), String> {
69 + let prev = counter.fetch_add(1, Ordering::Relaxed);
70 + if prev >= MAX_REQUESTS_PER_FETCH {
71 + Err(format!(
72 + "HTTP request limit exceeded ({} per fetch)",
73 + MAX_REQUESTS_PER_FETCH
74 + ))
75 + } else {
76 + Ok(())
77 + }
78 + }
79 +
8 80 /// Register host functions available to Rhai scripts.
9 81 ///
10 82 /// # Trust model
@@ -16,26 +88,48 @@ use crate::url_cleaner;
16 88 /// they trust, similar to shell scripts. If BB ever supports remote/untrusted
17 89 /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions)
18 90 /// must be added before that feature ships.
19 - pub(super) fn register_host_functions(engine: &mut Engine) {
91 + pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<AtomicUsize>) {
20 92 // HTTP GET returning string (see trust model above)
21 - engine.register_fn("http_get", |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
22 - ureq::get(url)
93 + let counter = request_counter.clone();
94 + engine.register_fn("http_get", move |url: &str| -> Result<String, Box<rhai::EvalAltResult>> {
95 + validate_url(url)?;
96 + check_request_limit(&counter)?;
97 +
98 + let response = ureq::get(url)
99 + .timeout(HTTP_TIMEOUT)
23 100 .call()
24 - .map_err(|e| format!("HTTP request failed: {}", e))?
25 - .into_string()
26 - .map_err(|e| format!("Failed to read response: {}", e).into())
101 + .map_err(|e| format!("HTTP request failed: {}", e))?;
102 +
103 + let mut body = String::new();
104 + response
105 + .into_reader()
106 + .take(MAX_RESPONSE_BYTES)
107 + .read_to_string(&mut body)
108 + .map_err(|e| format!("Failed to read response: {}", e))?;
109 + Ok(body)
27 110 });
28 111
29 112 // HTTP GET returning parsed JSON as Dynamic
113 + let counter = request_counter;
30 114 engine.register_fn(
31 115 "http_get_json",
32 - |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
116 + move |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> {
117 + validate_url(url)?;
118 + check_request_limit(&counter)?;
119 +
33 120 let response = ureq::get(url)
121 + .timeout(HTTP_TIMEOUT)
34 122 .call()
35 123 .map_err(|e| format!("HTTP request failed: {}", e))?;
36 124
37 - let json: serde_json::Value = response
38 - .into_json()
125 + let mut body = Vec::new();
126 + response
127 + .into_reader()
128 + .take(MAX_RESPONSE_BYTES)
129 + .read_to_end(&mut body)
130 + .map_err(|e| format!("Failed to read response: {}", e))?;
131 +
132 + let json: serde_json::Value = serde_json::from_slice(&body)
39 133 .map_err(|e| format!("JSON parse failed: {}", e))?;
40 134
41 135 json_to_dynamic(json)
@@ -129,7 +223,7 @@ pub(super) fn register_host_functions(engine: &mut Engine) {
129 223
130 224 // Debug print — outputs to tracing at debug level, visible in dev console.
131 225 engine.register_fn("debug_print", |val: Dynamic| {
132 - tracing::debug!("Rhai debug: {:?}", val);
226 + tracing::debug!(?val, "Rhai debug");
133 227 });
134 228
135 229 // Strip known tracking query parameters (utm_*, fbclid, gclid, etc.) from a URL.
@@ -145,12 +239,41 @@ pub(super) fn register_host_functions(engine: &mut Engine) {
145 239 Err(_) => Dynamic::UNIT,
146 240 }
147 241 });
242 +
243 + // Extract main article content from HTML using the readability algorithm.
244 + // Returns a map with "title", "content" (cleaned HTML), and "text" (plain text).
245 + engine.register_fn("extract_article", |html: String| -> rhai::Map {
246 + let mut map = rhai::Map::new();
247 + let mut readability = readable_readability::Readability::new();
248 + let (node, metadata) = readability.parse(&html);
249 +
250 + // Serialize the extracted DOM node to an HTML string.
251 + let mut content = Vec::new();
252 + node.serialize(&mut content).unwrap_or_default();
253 + let content_html = String::from_utf8_lossy(&content).to_string();
254 +
255 + // Plain text via html2text
256 + let text = html2text::from_read(content_html.as_bytes(), 80);
257 +
258 + map.insert(
259 + "title".into(),
260 + Dynamic::from(metadata.article_title.unwrap_or_default()),
261 + );
262 + map.insert("content".into(), Dynamic::from(content_html));
263 + map.insert("text".into(), Dynamic::from(text));
264 + map
265 + });
148 266 }
149 267
150 268 #[cfg(test)]
151 269 mod tests {
270 + use std::sync::atomic::{AtomicUsize, Ordering};
271 + use std::sync::Arc;
272 +
152 273 use rhai::Dynamic;
153 274
275 + use super::{check_request_limit, validate_url, MAX_REQUESTS_PER_FETCH};
276 +
154 277 /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
155 278 fn truncate_text(text: &str, max: usize) -> String {
156 279 if text.len() <= max {
@@ -308,4 +431,131 @@ mod tests {
308 431 assert!(result >= before);
309 432 assert!(result <= after);
310 433 }
434 +
435 + // ── validate_url tests ──────────────────────────────────────
436 +
437 + #[test]
438 + fn validate_url_allows_https() {
439 + assert!(validate_url("https://example.com/feed.xml").is_ok());
440 + }
441 +
442 + #[test]
443 + fn validate_url_allows_http() {
444 + assert!(validate_url("http://example.com/feed.xml").is_ok());
445 + }
446 +
447 + #[test]
448 + fn validate_url_blocks_file_scheme() {
449 + assert!(validate_url("file:///etc/passwd").is_err());
450 + }
451 +
452 + #[test]
453 + fn validate_url_blocks_ftp_scheme() {
454 + assert!(validate_url("ftp://example.com/file").is_err());
455 + }
456 +
457 + #[test]
458 + fn validate_url_blocks_localhost() {
459 + assert!(validate_url("http://localhost/api").is_err());
460 + assert!(validate_url("http://127.0.0.1/api").is_err());
461 + assert!(validate_url("http://[::1]/api").is_err());
462 + assert!(validate_url("http://0.0.0.0/api").is_err());
463 + }
464 +
465 + #[test]
466 + fn validate_url_blocks_private_ranges() {
467 + assert!(validate_url("http://10.0.0.1/api").is_err());
468 + assert!(validate_url("http://192.168.1.1/api").is_err());
469 + assert!(validate_url("http://172.16.0.1/api").is_err());
470 + assert!(validate_url("http://172.31.255.255/api").is_err());
471 + assert!(validate_url("http://169.254.1.1/api").is_err());
472 + }
473 +
474 + #[test]
475 + fn validate_url_allows_172_outside_private() {
476 + assert!(validate_url("http://172.15.0.1/api").is_ok());
477 + assert!(validate_url("http://172.32.0.1/api").is_ok());
478 + }
479 +
480 + #[test]
481 + fn validate_url_case_insensitive_scheme() {
482 + assert!(validate_url("HTTP://example.com").is_ok());
483 + assert!(validate_url("HTTPS://example.com").is_ok());
484 + assert!(validate_url("FTP://example.com").is_err());
485 + }
486 +
487 + // ── request counter tests ───────────────────────────────────
488 +
489 + #[test]
490 + fn request_counter_allows_under_limit() {
491 + let counter = AtomicUsize::new(0);
492 + assert!(check_request_limit(&counter).is_ok());
493 + assert_eq!(counter.load(Ordering::Relaxed), 1);
494 + }
495 +
496 + #[test]
497 + fn request_counter_blocks_at_limit() {
498 + let counter = AtomicUsize::new(MAX_REQUESTS_PER_FETCH);
499 + assert!(check_request_limit(&counter).is_err());
500 + }
501 +
502 + #[test]
503 + fn request_counter_resets() {
504 + let counter = Arc::new(AtomicUsize::new(MAX_REQUESTS_PER_FETCH));
505 + assert!(check_request_limit(&counter).is_err());
506 + counter.store(0, Ordering::Relaxed);
507 + assert!(check_request_limit(&counter).is_ok());
508 + }
509 +
510 + // ── extract_article tests ──────────────────────────────────────
511 +
512 + #[test]
513 + fn extract_article_basic_html() {
514 + let engine = super::super::create_engine();
515 + let mut scope = rhai::Scope::new();
516 + let ast = engine
517 + .compile(
518 + r#"
519 + fn test() {
520 + let html = "<html><head><title>My Article</title></head><body><p>Hello world. This is an article with enough content to be considered the main text by the readability algorithm when processed.</p></body></html>";
521 + extract_article(html)
522 + }
523 + "#,
524 + )
525 + .unwrap();
526 +
527 + let result: rhai::Map = engine.call_fn(&mut scope, &ast, "test", ()).unwrap();
528 + assert!(result.contains_key("title"));
529 + assert!(result.contains_key("content"));
530 + assert!(result.contains_key("text"));
531 +
532 + let content = result
533 + .get("content")
534 + .unwrap()
535 + .clone()
536 + .into_string()
537 + .unwrap();
538 + assert!(!content.is_empty());
539 + }
540 +
541 + #[test]
542 + fn extract_article_empty_html() {
543 + let engine = super::super::create_engine();
544 + let mut scope = rhai::Scope::new();
545 + let ast = engine
546 + .compile(
547 + r#"
548 + fn test() {
549 + extract_article("")
550 + }
551 + "#,
552 + )
553 + .unwrap();
554 +
555 + let result: rhai::Map = engine.call_fn(&mut scope, &ast, "test", ()).unwrap();
556 + // Should not panic, should return map with keys
557 + assert!(result.contains_key("title"));
558 + assert!(result.contains_key("content"));
559 + assert!(result.contains_key("text"));
560 + }
311 561 }
@@ -12,6 +12,7 @@ mod host_functions;
12 12
13 13 use std::collections::HashMap;
14 14 use std::path::Path;
15 + use std::sync::atomic::{AtomicUsize, Ordering};
15 16 use std::sync::Arc;
16 17
17 18 use bb_interface::{BusserCapabilities, ConfigSchema};
@@ -50,6 +51,7 @@ pub struct RhaiPlugin {
50 51 pub path: std::path::PathBuf,
51 52 ast: AST,
52 53 engine: Arc<Engine>,
54 + request_counter: Arc<AtomicUsize>,
53 55 }
54 56
55 57 impl RhaiPlugin {
@@ -73,7 +75,7 @@ impl RhaiPlugin {
73 75 {
74 76 Ok(result) => dynamic_to_capabilities(result),
75 77 Err(e) => {
76 - tracing::warn!("Plugin {} capabilities() failed: {}", self.id, e);
78 + tracing::warn!(error = %e, plugin_id = %self.id, "Plugin capabilities() failed");
77 79 BusserCapabilities::default()
78 80 }
79 81 }
@@ -85,6 +87,9 @@ impl RhaiPlugin {
85 87 config: &bb_interface::BusserConfig,
86 88 cursor: Option<String>,
87 89 ) -> Result<bb_interface::FetchResult, RhaiPluginError> {
90 + // Reset per-fetch request counter
91 + self.request_counter.store(0, Ordering::Relaxed);
92 +
88 93 let mut scope = Scope::new();
89 94
90 95 let config_map = busser_config_to_dynamic(config);
@@ -107,6 +112,7 @@ impl RhaiPlugin {
107 112 pub struct RhaiPluginManager {
108 113 engine: Arc<Engine>,
109 114 plugins: HashMap<String, RhaiPlugin>,
115 + request_counter: Arc<AtomicUsize>,
110 116 }
111 117
112 118 impl RhaiPluginManager {
@@ -118,17 +124,21 @@ impl RhaiPluginManager {
118 124 /// catching infinite loops.
119 125 /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions
120 126 /// and functions, preventing stack overflows from deeply recursive scripts.
127 + /// - HTTP limits: 15s timeout, 2 MB response cap, 100 requests per fetch,
128 + /// http/https only, no localhost/internal addresses.
121 129 pub fn new() -> Self {
122 130 let mut engine = Engine::new();
123 131
124 132 engine.set_max_expr_depths(128, 128);
125 133 engine.set_max_operations(100_000);
126 134
127 - register_host_functions(&mut engine);
135 + let request_counter = Arc::new(AtomicUsize::new(0));
136 + register_host_functions(&mut engine, request_counter.clone());
128 137
129 138 Self {
130 139 engine: Arc::new(engine),
131 140 plugins: HashMap::new(),
141 + request_counter,
132 142 }
133 143 }
134 144
@@ -159,7 +169,7 @@ impl RhaiPluginManager {
159 169 .call_fn(&mut scope, &ast, "config_schema", ())
160 170 .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?;
161 171
162 - debug!("Loaded Rhai plugin: {} ({})", name, id);
172 + debug!(%name, %id, "Loaded Rhai plugin");
163 173
164 174 let plugin = RhaiPlugin {
165 175 id: id.clone(),
@@ -167,6 +177,7 @@ impl RhaiPluginManager {
167 177 path: path.to_path_buf(),
168 178 ast,
169 179 engine: self.engine.clone(),
180 + request_counter: self.request_counter.clone(),
170 181 };
171 182
172 183 self.plugins.insert(id.clone(), plugin);
@@ -197,6 +208,69 @@ impl Default for RhaiPluginManager {
197 208 }
198 209 }
199 210
211 + /// Create a configured Rhai engine with all host functions registered.
212 + ///
213 + /// Used for one-off script execution (e.g. reader view) outside
214 + /// the plugin manager lifecycle.
215 + pub fn create_engine() -> Engine {
216 + let mut engine = Engine::new();
217 + engine.set_max_expr_depths(128, 128);
218 + engine.set_max_operations(100_000);
219 + let counter = Arc::new(AtomicUsize::new(0));
220 + register_host_functions(&mut engine, counter);
221 + engine
222 + }
223 +
224 + /// Result from extracting article content via the reader plugin.
225 + #[derive(Debug, Clone)]
226 + pub struct ReaderResult {
227 + pub title: String,
228 + pub content: String,
229 + pub text_content: String,
230 + }
231 +
232 + /// Run the reader extraction plugin on a URL.
233 + ///
234 + /// Creates a one-off Rhai engine, loads `plugins/reader.rhai`, and calls
235 + /// `extract(url)`. Returns the extracted article title, HTML content, and
236 + /// plain text.
237 + pub fn run_reader_script(url: &str) -> Result<ReaderResult, RhaiPluginError> {
238 + let engine = create_engine();
239 +
240 + let plugin_path = std::path::Path::new("plugins/reader.rhai");
241 + let script = std::fs::read_to_string(plugin_path).map_err(|e| {
242 + RhaiPluginError::CompileError(format!("Failed to read reader plugin: {}", e))
243 + })?;
244 +
245 + let ast = engine
246 + .compile(&script)
247 + .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?;
248 +
249 + let mut scope = Scope::new();
250 + let result: rhai::Map = engine
251 + .call_fn(&mut scope, &ast, "extract", (url.to_string(),))
252 + .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?;
253 +
254 + let title = result
255 + .get("title")
256 + .and_then(|v: &Dynamic| v.clone().into_string().ok())
257 + .unwrap_or_default();
258 + let content = result
259 + .get("content")
260 + .and_then(|v: &Dynamic| v.clone().into_string().ok())
261 + .unwrap_or_default();
262 + let text_content = result
263 + .get("text")
264 + .and_then(|v: &Dynamic| v.clone().into_string().ok())
265 + .unwrap_or_default();
266 +
267 + Ok(ReaderResult {
268 + title,
269 + content,
270 + text_content,
271 + })
272 + }
273 +
200 274 #[cfg(test)]
201 275 mod tests {
202 276 use super::*;
@@ -91,7 +91,7 @@ macro_rules! define_uuid_id {
91 91 )+};
92 92 }
93 93
94 - define_uuid_id!(FeedId, ItemId, BusserStateId);
94 + define_uuid_id!(FeedId, ItemId, BusserStateId, QueryFeedId);
95 95
96 96 // ── BusserId ─────────────────────────────────────────────────────
97 97
@@ -64,4 +64,16 @@ impl Database {
64 64 pub fn state(&self) -> StateRepository {
65 65 StateRepository::new(self.pool.clone())
66 66 }
67 +
68 + /// Get a repository for user_config key-value pairs (theme, welcome flag,
69 + /// etc.). Synced via sync_changelog triggers (migration 007).
70 + pub fn config(&self) -> ConfigRepository {
71 + ConfigRepository::new(self.pool.clone())
72 + }
73 +
74 + /// Get a repository for query feed CRUD (saved filter rules that act as
75 + /// virtual sources). Synced via sync_changelog triggers (migration 009).
76 + pub fn query_feeds(&self) -> QueryFeedsRepository {
77 + QueryFeedsRepository::new(self.pool.clone())
78 + }
67 79 }
@@ -7,7 +7,7 @@ use chrono::{DateTime, Utc};
7 7 use serde::{Deserialize, Serialize};
8 8 use sqlx::FromRow;
9 9
10 - use crate::id_types::{BusserId, BusserStateId, FeedId, ItemId};
10 + use crate::id_types::{BusserId, BusserStateId, FeedId, ItemId, QueryFeedId};
11 11 use crate::TIMESTAMP_FMT;
12 12
13 13 /// Parse a value or log a warning and return the default.
@@ -17,7 +17,7 @@ fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, conte
17 17 match result {
18 18 Ok(v) => v,
19 19 Err(e) => {
20 - tracing::warn!("{}: {}", context, e);
20 + tracing::warn!(error = %e, context, "Parse failed, using default");
21 21 T::default()
22 22 }
23 23 }
@@ -54,6 +54,8 @@ pub struct DbFeed {
54 54 pub last_error: Option<String>,
55 55 /// Timestamp of the last successful fetch, if any.
56 56 pub last_success_at: Option<String>,
57 + /// Whether this feed has been auto-disabled by the circuit breaker.
58 + pub circuit_broken: bool,
57 59 /// Row creation timestamp.
58 60 pub created_at: String,
59 61 /// Row last-modified timestamp.
@@ -277,6 +279,42 @@ impl CreateFeedItem {
277 279 }
278 280 }
279 281
282 + /// A single condition in a query feed's rules array.
283 + #[derive(Debug, Clone, Serialize, Deserialize)]
284 + pub struct QueryCondition {
285 + pub field: String,
286 + pub operator: String,
287 + pub value: String,
288 + }
289 +
290 + /// Saved filter ("query feed") stored in the `query_feeds` table.
291 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
292 + pub struct DbQueryFeed {
293 + pub id: QueryFeedId,
294 + pub name: String,
295 + /// JSON array of [`QueryCondition`].
296 + pub rules: String,
297 + pub created_at: String,
298 + pub updated_at: String,
299 + }
300 +
301 + impl DbQueryFeed {
302 + /// Deserialize the JSON `rules` column into a `Vec<QueryCondition>`.
303 + pub fn rules_vec(&self) -> Vec<QueryCondition> {
304 + parse_or_default(
305 + serde_json::from_str(&self.rules),
306 + "Failed to parse query feed rules JSON",
307 + )
308 + }
309 + }
310 +
311 + /// Input for creating a new query feed.
312 + #[derive(Debug, Clone, Serialize, Deserialize)]
313 + pub struct CreateQueryFeed {
314 + pub name: String,
315 + pub rules: Vec<QueryCondition>,
316 + }
317 +
280 318 #[cfg(test)]
281 319 mod tests {
282 320 use super::*;
@@ -318,6 +356,7 @@ mod tests {
318 356 consecutive_failures: 0,
319 357 last_error: None,
320 358 last_success_at: None,
359 + circuit_broken: false,
321 360 created_at: "2024-01-01 00:00:00".to_string(),
322 361 updated_at: "2024-01-01 00:00:00".to_string(),
323 362 };
@@ -340,6 +379,7 @@ mod tests {
340 379 consecutive_failures: 0,
341 380 last_error: None,
342 381 last_success_at: None,
382 + circuit_broken: false,
343 383 created_at: "2024-01-01 00:00:00".to_string(),
344 384 updated_at: "2024-01-01 00:00:00".to_string(),
345 385 };
@@ -359,6 +399,7 @@ mod tests {
359 399 consecutive_failures: 0,
360 400 last_error: None,
361 401 last_success_at: None,
402 + circuit_broken: false,
362 403 created_at: "2024-01-01 00:00:00".to_string(),
363 404 updated_at: "2024-01-01 00:00:00".to_string(),
364 405 };
@@ -6,26 +6,47 @@
6 6 use chrono::{DateTime, Utc};
7 7 use sqlx::SqlitePool;
8 8
9 - use crate::id_types::{BusserStateId, FeedId, ItemId};
9 + use crate::id_types::{BusserStateId, FeedId, ItemId, QueryFeedId};
10 10 use crate::models::*;
11 11 use crate::TIMESTAMP_FMT;
12 12
13 13 /// Sanitize a user-provided search string for FTS5 MATCH syntax.
14 14 ///
15 15 /// Wraps each word in double quotes to prevent FTS5 syntax injection
16 - /// (e.g. a user typing `AND` or `OR` or `NEAR` wouldn't be interpreted
17 - /// as FTS5 operators). Words are joined with spaces (implicit AND).
16 + /// (e.g. a user typing `AND`, `OR`, `NOT`, `NEAR`, or `NEAR/N` won't be
17 + /// interpreted as FTS5 operators). Words are joined with spaces (implicit AND).
18 + ///
19 + /// Inside quoted strings FTS5 still honours two special characters:
20 + /// - `*` at the end of the last token triggers prefix matching
21 + /// - `^` at the start of the first token triggers beginning-of-column matching
22 + ///
23 + /// We strip those so user input like `^hello` or `world*` is treated literally.
24 + /// The `column:` prefix syntax (e.g. `title:word`) is neutralised by the quoting
25 + /// itself — colons inside double-quoted strings are treated as literal characters.
18 26 fn sanitize_fts_query(query: &str) -> String {
19 27 query
20 28 .split_whitespace()
21 29 .map(|word| {
30 + // Escape embedded double quotes (FTS5 uses "" to represent a literal ")
22 31 let escaped = word.replace('"', "\"\"");
32 + // Strip `^` prefix and `*` suffix — both are special inside FTS5 quotes
33 + let escaped = escaped.trim_start_matches('^');
34 + let escaped = escaped.trim_end_matches('*');
23 35 format!("\"{}\"", escaped)
24 36 })
37 + // Drop tokens that became empty after stripping (e.g. bare `^`, `*`, `^*`)
38 + .filter(|token| token != "\"\"")
25 39 .collect::<Vec<_>>()
26 40 .join(" ")
27 41 }
28 42
43 + /// Number of consecutive failures before a feed is automatically disabled.
44 + ///
45 + /// Once a feed accumulates this many failures without a successful fetch,
46 + /// the circuit breaker trips: the feed is marked `circuit_broken = 1` and
47 + /// excluded from automatic fetch scheduling until manually reset.
48 + pub const CIRCUIT_BREAKER_THRESHOLD: i64 = 10;
49 +
29 50 /// Repository for feed operations
30 51 #[derive(Clone)]
31 52 pub struct FeedsRepository {
@@ -79,11 +100,13 @@ impl FeedsRepository {
79 100 .await
80 101 }
81 102
82 - /// List only enabled feeds, ordered by name.
103 + /// List only enabled feeds that are not circuit-broken, ordered by name.
83 104 pub async fn list_enabled(&self) -> Result<Vec<DbFeed>, sqlx::Error> {
84 - sqlx::query_as("SELECT * FROM feeds WHERE enabled = 1 ORDER BY name")
85 - .fetch_all(&self.pool)
86 - .await
105 + sqlx::query_as(
106 + "SELECT * FROM feeds WHERE enabled = 1 AND circuit_broken = 0 ORDER BY name",
107 + )
108 + .fetch_all(&self.pool)
109 + .await
87 110 }
88 111
89 112 /// List every feed (enabled or disabled), ordered by name.
@@ -131,11 +154,14 @@ impl FeedsRepository {
131 154 }
132 155
133 156 /// Record a fetch failure: increment counter, store error, update timestamp.
157 + ///
158 + /// Returns `true` if the circuit breaker tripped (i.e. the feed just crossed
159 + /// the [`CIRCUIT_BREAKER_THRESHOLD`] and was marked `circuit_broken = 1`).
134 160 pub async fn record_fetch_failure(
135 161 &self,
136 162 id: FeedId,
137 163 error: &str,
138 - ) -> Result<(), sqlx::Error> {
164 + ) -> Result<bool, sqlx::Error> {
139 165 let now = Utc::now().format(TIMESTAMP_FMT).to_string();
140 166 sqlx::query(
141 167 "UPDATE feeds SET consecutive_failures = consecutive_failures + 1, \
@@ -146,6 +172,52 @@ impl FeedsRepository {
146 172 .bind(id)
147 173 .execute(&self.pool)
148 174 .await?;
175 +
176 + // Check if we just crossed the threshold
177 + let feed = self.get(id).await?;
178 + if let Some(feed) = feed {
179 + if !feed.circuit_broken
180 + && feed.consecutive_failures >= CIRCUIT_BREAKER_THRESHOLD
181 + {
182 + self.set_circuit_broken(id, true).await?;
183 + return Ok(true);
184 + }
185 + }
186 + Ok(false)
187 + }
188 +
189 + /// Mark a feed as circuit-broken (or clear the circuit breaker).
190 + pub async fn set_circuit_broken(
191 + &self,
192 + id: FeedId,
193 + broken: bool,
194 + ) -> Result<(), sqlx::Error> {
195 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
196 + sqlx::query(
197 + "UPDATE feeds SET circuit_broken = ?1, updated_at = ?2 WHERE id = ?3",
198 + )
199 + .bind(broken)
200 + .bind(&now)
201 + .bind(id)
202 + .execute(&self.pool)
203 + .await?;
204 + Ok(())
205 + }
206 +
207 + /// Reset the circuit breaker on a feed: clear `circuit_broken`, reset
208 + /// `consecutive_failures` to 0, and clear `last_error`.
209 + ///
210 + /// Called when a user manually triggers a fetch for a circuit-broken feed.
211 + pub async fn reset_circuit_breaker(&self, id: FeedId) -> Result<(), sqlx::Error> {
212 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
213 + sqlx::query(
214 + "UPDATE feeds SET circuit_broken = 0, consecutive_failures = 0, \
215 + last_error = NULL, updated_at = ?1 WHERE id = ?2",
216 + )
217 + .bind(&now)
218 + .bind(id)
219 + .execute(&self.pool)
220 + .await?;
149 221 Ok(())
150 222 }
151 223
@@ -159,6 +231,18 @@ impl FeedsRepository {
159 231 Ok(())
160 232 }
161 233
234 + /// Update a feed's display name.
235 + pub async fn update_name(&self, id: FeedId, name: &str) -> Result<(), sqlx::Error> {
236 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
237 + sqlx::query("UPDATE feeds SET name = ?1, updated_at = ?2 WHERE id = ?3")
238 + .bind(name)
239 + .bind(&now)
240 + .bind(id)
241 + .execute(&self.pool)
242 + .await?;
243 + Ok(())
244 + }
245 +
162 246 /// Delete a feed by ID.
163 247 pub async fn delete(&self, id: FeedId) -> Result<(), sqlx::Error> {
164 248 sqlx::query("DELETE FROM feeds WHERE id = ?1")
@@ -349,6 +433,12 @@ impl ItemsRepository {
349 433 ) -> Result<Vec<DbFeedItem>, sqlx::Error> {
350 434 let fts_query = sanitize_fts_query(query);
351 435
436 + // If sanitization stripped everything (e.g. query was just `*` or `^`),
437 + // return early — an empty MATCH expression would be a SQL error.
438 + if fts_query.is_empty() {
439 + return Ok(vec![]);
440 + }
441 +
352 442 let mut sql = String::from(
353 443 "SELECT fi.* FROM feed_items fi \
354 444 INNER JOIN feed_items_fts fts ON fi.rowid = fts.rowid \
@@ -668,6 +758,141 @@ impl StateRepository {
668 758 }
669 759 }
670 760
761 + /// Repository for user_config key-value operations.
762 + ///
763 + /// Wraps the synced `user_config` table (migration 007) for app-level
764 + /// preferences like theme and welcome state. Unlike `StateRepository`,
765 + /// entries are not scoped by busser — just `(key, value)`.
766 + #[derive(Clone)]
767 + pub struct ConfigRepository {
768 + pool: SqlitePool,
769 + }
770 +
771 + impl ConfigRepository {
772 + pub fn new(pool: SqlitePool) -> Self {
773 + Self { pool }
774 + }
775 +
776 + /// Get a config value by key.
777 + pub async fn get(&self, key: &str) -> Result<Option<String>, sqlx::Error> {
778 + let row: Option<(String,)> =
779 + sqlx::query_as("SELECT value FROM user_config WHERE key = ?1")
780 + .bind(key)
781 + .fetch_optional(&self.pool)
782 + .await?;
783 + Ok(row.map(|(v,)| v))
784 + }
785 +
786 + /// Set a config value, inserting or updating on conflict.
787 + pub async fn set(&self, key: &str, value: &str) -> Result<(), sqlx::Error> {
788 + sqlx::query(
789 + r#"
790 + INSERT INTO user_config (key, value)
791 + VALUES (?1, ?2)
792 + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
793 + "#,
794 + )
795 + .bind(key)
796 + .bind(value)
797 + .execute(&self.pool)
798 + .await?;
799 + Ok(())
800 + }
801 +
802 + /// Delete a config entry by key.
803 + pub async fn delete(&self, key: &str) -> Result<(), sqlx::Error> {
804 + sqlx::query("DELETE FROM user_config WHERE key = ?1")
805 + .bind(key)
806 + .execute(&self.pool)
807 + .await?;
808 + Ok(())
809 + }
810 + }
811 +
812 + /// Repository for query feed operations.
813 + ///
814 + /// Query feeds are saved filter rules that act as virtual sources.
815 + /// Each query feed stores a JSON array of conditions that are translated
816 + /// to a [`FeedFilter`] at query time.
817 + #[derive(Clone)]
818 + pub struct QueryFeedsRepository {
819 + pool: SqlitePool,
820 + }
821 +
822 + impl QueryFeedsRepository {
823 + pub fn new(pool: SqlitePool) -> Self {
824 + Self { pool }
825 + }
826 +
827 + /// Insert a new query feed and return the created row.
828 + pub async fn create(&self, input: CreateQueryFeed) -> Result<DbQueryFeed, sqlx::Error> {
829 + let id = QueryFeedId::new();
830 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
831 + let rules_json =
832 + serde_json::to_string(&input.rules).unwrap_or_else(|_| "[]".to_string());
833 +
834 + sqlx::query_as(
835 + r#"
836 + INSERT INTO query_feeds (id, name, rules, created_at, updated_at)
837 + VALUES (?1, ?2, ?3, ?4, ?4)
838 + RETURNING *
839 + "#,
840 + )
841 + .bind(id)
842 + .bind(&input.name)
843 + .bind(&rules_json)
844 + .bind(&now)
845 + .fetch_one(&self.pool)
846 + .await
847 + }
848 +
849 + /// Look up a single query feed by ID. Returns `None` if not found.
850 + pub async fn get(&self, id: QueryFeedId) -> Result<Option<DbQueryFeed>, sqlx::Error> {
851 + sqlx::query_as("SELECT * FROM query_feeds WHERE id = ?1")
852 + .bind(id)
853 + .fetch_optional(&self.pool)
854 + .await
855 + }
856 +
857 + /// List all query feeds, ordered by name.
858 + pub async fn list_all(&self) -> Result<Vec<DbQueryFeed>, sqlx::Error> {
859 + sqlx::query_as("SELECT * FROM query_feeds ORDER BY name")
860 + .fetch_all(&self.pool)
861 + .await
862 + }
863 +
864 + /// Update a query feed's name and rules.
865 + pub async fn update(
866 + &self,
867 + id: QueryFeedId,
868 + name: &str,
869 + rules: &[QueryCondition],
870 + ) -> Result<(), sqlx::Error> {
871 + let now = Utc::now().format(TIMESTAMP_FMT).to_string();
872 + let rules_json = serde_json::to_string(rules).unwrap_or_else(|_| "[]".to_string());
873 +
874 + sqlx::query(
875 + "UPDATE query_feeds SET name = ?1, rules = ?2, updated_at = ?3 WHERE id = ?4",
876 + )
877 + .bind(name)
878 + .bind(&rules_json)
879 + .bind(&now)
880 + .bind(id)
881 + .execute(&self.pool)
882 + .await?;
883 + Ok(())
884 + }
885 +
886 + /// Delete a query feed by ID.
887 + pub async fn delete(&self, id: QueryFeedId) -> Result<(), sqlx::Error> {
888 + sqlx::query("DELETE FROM query_feeds WHERE id = ?1")
889 + .bind(id)
890 + .execute(&self.pool)
891 + .await?;
892 + Ok(())
893 + }
894 + }
895 +
671 896 #[cfg(test)]
672 897 mod tests {
673 898 use super::*;
@@ -1336,6 +1561,215 @@ mod tests {
1336 1561 assert_eq!(results.len(), 0); // no match but no crash
1337 1562 }
1338 1563
1564 + // ── sanitize_fts_query unit tests ────────────────────────────
1565 +
1566 + #[test]
1567 + fn fts5_sanitize_basic_words() {
1568 + assert_eq!(sanitize_fts_query("hello world"), r#""hello" "world""#);
1569 + }
1570 +
1571 + #[test]
1572 + fn fts5_sanitize_operators_quoted() {
1573 + // AND, OR, NOT become quoted literal strings
1574 + assert_eq!(
1575 + sanitize_fts_query("AND OR NOT"),
1576 + r#""AND" "OR" "NOT""#,
1577 + );
1578 + }
1579 +
1580 + #[test]
1581 + fn fts5_sanitize_near_operator() {
1582 + assert_eq!(sanitize_fts_query("NEAR"), r#""NEAR""#);
1583 + assert_eq!(sanitize_fts_query("NEAR/3"), r#""NEAR/3""#);
1584 + assert_eq!(sanitize_fts_query("word NEAR/5 other"), r#""word" "NEAR/5" "other""#);
1585 + }
1586 +
1587 + #[test]
1588 + fn fts5_sanitize_column_prefix() {
1589 + // column:term syntax — colon inside quotes is literal
1590 + assert_eq!(sanitize_fts_query("title:rust"), r#""title:rust""#);
1591 + assert_eq!(
1592 + sanitize_fts_query("body:hello title:world"),
1593 + r#""body:hello" "title:world""#,
1594 + );
1595 + }
1596 +
1597 + #[test]
1598 + fn fts5_sanitize_caret_stripped() {
1599 + // ^ is beginning-of-column marker, must be stripped
1600 + assert_eq!(sanitize_fts_query("^hello"), r#""hello""#);
1601 + assert_eq!(sanitize_fts_query("^hello world"), r#""hello" "world""#);
1602 + // Multiple carets
1603 + assert_eq!(sanitize_fts_query("^^hello"), r#""hello""#);
1604 + }
1605 +
1606 + #[test]
1607 + fn fts5_sanitize_star_stripped() {
1608 + // * is prefix query suffix, must be stripped
1609 + assert_eq!(sanitize_fts_query("hello*"), r#""hello""#);
1610 + assert_eq!(sanitize_fts_query("hel*"), r#""hel""#);
1611 + // Multiple stars
1612 + assert_eq!(sanitize_fts_query("hello**"), r#""hello""#);
1613 + }
1614 +
1615 + #[test]
1616 + fn fts5_sanitize_caret_and_star_combined() {
1617 + assert_eq!(sanitize_fts_query("^hello*"), r#""hello""#);
1618 + assert_eq!(sanitize_fts_query("^*"), "");
1619 + }
1620 +
1621 + #[test]
1622 + fn fts5_sanitize_bare_special_chars_dropped() {
1623 + // Bare `^`, `*`, `^*` should produce empty string (dropped)
1624 + assert_eq!(sanitize_fts_query("^"), "");
1625 + assert_eq!(sanitize_fts_query("*"), "");
1626 + assert_eq!(sanitize_fts_query("^ word"), r#""word""#);
1627 + assert_eq!(sanitize_fts_query("* word"), r#""word""#);
1628 + }
1629 +
1630 + #[test]
1631 + fn fts5_sanitize_embedded_quotes() {
1632 + // Embedded double quotes are escaped as ""
1633 + // Input: say "hi" -> tokens: [say] ["hi"]
1634 + // "hi" has two quotes -> each becomes "" -> ""hi"" -> wrapped: """hi"""
1635 + assert_eq!(
1636 + sanitize_fts_query("say \"hi\""),
1637 + "\"say\" \"\"\"hi\"\"\"",
1638 + );
1639 + }
1640 +
1641 + #[test]
1642 + fn fts5_sanitize_empty_and_whitespace() {
1643 + assert_eq!(sanitize_fts_query(""), "");
1644 + assert_eq!(sanitize_fts_query(" "), "");
1645 + }
1646 +
1647 + #[test]
1648 + fn fts5_sanitize_mixed_special_syntax() {
1649 + // Realistic adversarial input combining multiple FTS5 features
1650 + assert_eq!(
1651 + sanitize_fts_query("^title:rust* NEAR/3 OR body:hello*"),
1652 + r#""title:rust" "NEAR/3" "OR" "body:hello""#,
1653 + );
1654 + }
1655 +
1656 + // ── FTS5 integration tests for hardened sanitization ─────────
1657 +
1658 + #[tokio::test]
1659 + async fn fts5_near_operator_does_not_crash() {
1660 + let pool = test_db().await;
1661 + let feed = make_feed(&pool, "rss", "Feed").await;
1662 + let items_repo = ItemsRepository::new(pool.clone());
1663 + make_item(&pool, &feed, "fts:near1").await;
1664 +
1665 + // NEAR and NEAR/N should be safely quoted
1666 + let results = items_repo
1667 + .list_search("NEAR", None, false, false, 10, 0)
1668 + .await
1669 + .unwrap();
1670 + assert_eq!(results.len(), 0);
1671 +
1672 + let results = items_repo
1673 + .list_search("word NEAR/3 other", None, false, false, 10, 0)
1674 + .await
1675 + .unwrap();
1676 + assert_eq!(results.len(), 0);
1677 + }
1678 +
1679 + #[tokio::test]
1680 + async fn fts5_column_prefix_does_not_target_column() {
1681 + let pool = test_db().await;
1682 + let feed = make_feed(&pool, "rss", "Feed").await;
1683 + let items_repo = ItemsRepository::new(pool.clone());
1684 +
1685 + items_repo
1686 + .upsert(CreateFeedItem {
1687 + external_id: "fts:col1".to_string(),
1688 + feed_id: feed.id,
1689 + busser_id: BusserId::new("rss"),
1690 + bite_author: "author".to_string(),
1691 + bite_text: "bite".to_string(),
1692 + bite_secondary: None,
1693 + bite_indicator: None,
1694 + title: Some("rust programming".to_string()),
1695 + body: Some("unrelated body".to_string()),
1696 + url: None,
1697 + media: vec![],
1698 + published_at: Utc::now(),
1699 + source_name: "test".to_string(),
1700 + score: None,
1701 + tags: vec![],
1702 + })
1703 + .await
1704 + .unwrap();
1705 +
1706 + // "title:rust" should NOT act as a column filter — it should search
1707 + // for the literal string "title:rust" which won't match anything
1708 + let results = items_repo
1709 + .list_search("title:rust", None, false, false, 10, 0)
1710 + .await
1711 + .unwrap();
1712 + assert_eq!(results.len(), 0);
1713 + }
1714 +
1715 + #[tokio::test]
1716 + async fn fts5_caret_prefix_does_not_crash() {
1717 + let pool = test_db().await;
1718 + let feed = make_feed(&pool, "rss", "Feed").await;
1719 + let items_repo = ItemsRepository::new(pool.clone());
1720 + make_item(&pool, &feed, "fts:caret1").await;
1721 +
1722 + let results = items_repo
1723 + .list_search("^Title", None, false, false, 10, 0)
1724 + .await
1725 + .unwrap();
1726 + // Caret is stripped, so this searches for "Title" which matches our items
1727 + assert!(!results.is_empty());
1728 + }
1729 +
1730 + #[tokio::test]
1731 + async fn fts5_star_suffix_does_not_crash() {
1732 + let pool = test_db().await;
1733 + let feed = make_feed(&pool, "rss", "Feed").await;
1734 + let items_repo = ItemsRepository::new(pool.clone());
1735 + make_item(&pool, &feed, "fts:star1").await;
1736 +
1737 + let results = items_repo
1738 + .list_search("Titl*", None, false, false, 10, 0)
1739 + .await
1740 + .unwrap();
1741 + // Star is stripped, so this searches for "Titl" which won't match "Title"
1742 + // (exact token match, not prefix)
1743 + assert_eq!(results.len(), 0);
1744 + }
1745 +
1746 + #[tokio::test]
1747 + async fn fts5_bare_star_and_caret_safe() {
1748 + let pool = test_db().await;
1749 + let feed = make_feed(&pool, "rss", "Feed").await;
1750 + let items_repo = ItemsRepository::new(pool.clone());
1751 + make_item(&pool, &feed, "fts:bare1").await;
1752 +
1753 + // Bare special chars should not crash
1754 + let results = items_repo
1755 + .list_search("*", None, false, false, 10, 0)
Lines truncated
@@ -12,6 +12,7 @@ tokio.workspace = true
12 12 thiserror.workspace = true
13 13 tracing.workspace = true
14 14 chrono.workspace = true
15 + regex = "1"
15 16
16 17 [dev-dependencies]
17 18 serde_json.workspace = true
@@ -42,6 +42,8 @@ pub struct SourceInfo {
42 42 pub consecutive_failures: i64,
43 43 /// Error message from the last failed fetch.
44 44 pub last_error: Option<String>,
45 + /// Whether the circuit breaker has tripped for this feed.
46 + pub circuit_broken: bool,
45 47 }
46 48
47 49 /// The feed generator aggregates and orders items from the database
@@ -171,16 +173,22 @@ impl FeedGenerator {
171 173 feed_items.retain(|item| matching_busser_ids.contains(&item.id.source));
172 174 }
173 175
174 - // Apply remaining in-memory filters (tags — not yet in SQL) and sorting.
176 + // Apply remaining in-memory filters (tags, query feed conditions) and sorting.
175 177 // Search and source/unread/starred are already handled by the query above.
176 178 if !self.filter.tags.is_empty() {
177 179 feed_items = self.filter.apply_tags_only(feed_items);
178 180 }
181 +
182 + // Apply query feed conditions that require in-memory evaluation
183 + // (title/author/body contains, not_contains, equals, matches_regex).
184 + if !self.filter.conditions.is_empty() {
185 + feed_items.retain(|item| self.filter.matches(item));
186 + }
179 187 self.order_by.apply(&mut feed_items);
180 188 let has_more = feed_items.len() > self.page_size as usize;
181 189 feed_items.truncate(self.page_size as usize);
182 190
183 - debug!("Returning {} items for page {} (has_more={})", feed_items.len(), page, has_more);
191 + debug!(count = feed_items.len(), page, has_more, "Returning items");
184 192 Ok(PaginatedItems {
185 193 items: feed_items,
186 194 has_more,
@@ -271,6 +279,7 @@ impl FeedGenerator {
271 279 tags,
272 280 consecutive_failures: feed.consecutive_failures,
273 281 last_error: feed.last_error,
282 + circuit_broken: feed.circuit_broken,
274 283 }
275 284 })
276 285 .collect();
@@ -795,4 +804,516 @@ mod tests {
795 804 let sources = gen.get_sources().await.unwrap();
796 805 assert!(sources.is_empty());
797 806 }
807 +
808 + // ── In-memory filtering & ordering in get_items ─────────────
809 +
810 + /// Seed an item with a score for ordering tests.
811 + async fn seed_scored_item(
812 + db: &Database,
813 + feed: &bb_db::DbFeed,
814 + external_id: &str,
815 + hours_ago: i64,
816 + score: Option<i64>,
817 + ) -> bb_db::DbFeedItem {
818 + db.items()
819 + .upsert(CreateFeedItem {
820 + external_id: external_id.to_string(),
821 + feed_id: feed.id,
822 + busser_id: feed.busser_id.clone(),
823 + bite_author: "author".to_string(),
824 + bite_text: format!("Item {external_id}"),
825 + bite_secondary: None,
826 + bite_indicator: None,
827 + title: Some(format!("Title {external_id}")),
828 + body: None,
829 + url: None,
830 + media: vec![],
831 + published_at: Utc::now() - Duration::hours(hours_ago),
832 + source_name: "test".to_string(),
833 + score,
834 + tags: vec![],
835 + })
836 + .await
837 + .unwrap()
838 + }
839 +
840 + /// Seed an item with item-level tags.
841 + async fn seed_tagged_item(
842 + db: &Database,
843 + feed: &bb_db::DbFeed,
844 + external_id: &str,
845 + hours_ago: i64,
846 + tags: Vec<String>,
847 + ) -> bb_db::DbFeedItem {
848 + db.items()
849 + .upsert(CreateFeedItem {
850 + external_id: external_id.to_string(),
851 + feed_id: feed.id,
852 + busser_id: feed.busser_id.clone(),
853 + bite_author: "author".to_string(),
854 + bite_text: format!("Item {external_id}"),
855 + bite_secondary: None,
856 + bite_indicator: None,
857 + title: Some(format!("Title {external_id}")),
858 + body: Some(format!("Body of {external_id}")),
859 + url: None,
860 + media: vec![],
861 + published_at: Utc::now() - Duration::hours(hours_ago),
862 + source_name: "test".to_string(),
863 + score: None,
864 + tags,
865 + })
866 + .await
867 + .unwrap()
868 + }
869 +
870 + // ── Ordering within get_items ────────────────────────────────
871 +
872 + #[tokio::test]
873 + async fn get_items_chronological_ordering() {
874 + let db = test_db().await;
875 + let feed = seed_feed(&db, "rss", "Feed").await;
876 + seed_item(&db, &feed, "rss:old", 10).await;
877 + seed_item(&db, &feed, "rss:mid", 5).await;
878 + seed_item(&db, &feed, "rss:new", 1).await;
879 +
880 + let gen = FeedGenerator::new(db).with_order(OrderBy::Chronological);
881 + let result = gen.get_items(0).await.unwrap();
882 + assert_eq!(result.items[0].id.item_id, "rss:new");
883 + assert_eq!(result.items[1].id.item_id, "rss:mid");
884 + assert_eq!(result.items[2].id.item_id, "rss:old");
885 + }
886 +
887 + #[tokio::test]
888 + async fn get_items_score_ordering() {
889 + let db = test_db().await;
890 + let feed = seed_feed(&db, "rss", "Feed").await;
891 + seed_scored_item(&db, &feed, "rss:low", 1, Some(10)).await;
892 + seed_scored_item(&db, &feed, "rss:high", 2, Some(100)).await;
893 + seed_scored_item(&db, &feed, "rss:none", 3, None).await;
894 +
895 + let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
896 + let result = gen.get_items(0).await.unwrap();
897 + assert_eq!(result.items[0].id.item_id, "rss:high");
898 + assert_eq!(result.items[1].id.item_id, "rss:low");
899 + assert_eq!(result.items[2].id.item_id, "rss:none");
900 + }
901 +
902 + #[tokio::test]
903 + async fn get_items_score_tiebreak_by_date() {
904 + let db = test_db().await;
905 + let feed = seed_feed(&db, "rss", "Feed").await;
906 + seed_scored_item(&db, &feed, "rss:old_50", 10, Some(50)).await;
907 + seed_scored_item(&db, &feed, "rss:new_50", 1, Some(50)).await;
908 +
909 + let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
910 + let result = gen.get_items(0).await.unwrap();
911 + // Same score, newer item should come first
912 + assert_eq!(result.items[0].id.item_id, "rss:new_50");
913 + assert_eq!(result.items[1].id.item_id, "rss:old_50");
914 + }
915 +
916 + #[tokio::test]
917 + async fn get_items_unread_first_ordering() {
918 + let db = test_db().await;
919 + let feed = seed_feed(&db, "rss", "Feed").await;
920 + let read_item = seed_item(&db, &feed, "rss:read", 1).await;
921 + seed_item(&db, &feed, "rss:unread1", 2).await;
922 + seed_item(&db, &feed, "rss:unread2", 3).await;
923 +
924 + db.items().mark_read(read_item.id, true).await.unwrap();
925 +
926 + let gen = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst);
927 + let result = gen.get_items(0).await.unwrap();
928 + assert_eq!(result.items.len(), 3);
929 + // Items should be grouped by read status: the sort uses
930 + // b.is_read.cmp(&a.is_read) which groups items by read state,
931 + // with chronological tiebreak within each group.
932 + let read_states: Vec<bool> = result.items.iter().map(|i| i.is_read).collect();
933 + // Verify grouping: all items of one read-state come before the other
934 + let first_state = read_states[0];
935 + let boundary = read_states.iter().position(|&r| r != first_state);
936 + if let Some(b) = boundary {
937 + assert!(read_states[b..].iter().all(|&r| r != first_state),
938 + "read states should be grouped, not interleaved");
939 + }
940 + }
941 +
942 + #[tokio::test]
943 + async fn get_items_starred_first_ordering() {
944 + let db = test_db().await;
945 + let feed = seed_feed(&db, "rss", "Feed").await;
946 + seed_item(&db, &feed, "rss:normal", 1).await;
947 + let starred_item = seed_item(&db, &feed, "rss:starred", 2).await;
948 +
949 + db.items().mark_starred(starred_item.id, true).await.unwrap();
950 +
951 + let gen = FeedGenerator::new(db).with_order(OrderBy::StarredFirst);
952 + let result = gen.get_items(0).await.unwrap();
953 + assert!(result.items[0].is_starred, "first item should be starred");
954 + assert!(!result.items[1].is_starred, "second item should not be starred");
955 + }
956 +
957 + // ── Item-level tag filtering within get_items ────────────────
958 +
959 + #[tokio::test]
960 + async fn get_items_tag_filter_keeps_matching() {
961 + let db = test_db().await;
962 + let feed = seed_feed(&db, "rss", "Feed").await;
963 + seed_tagged_item(&db, &feed, "rss:rust", 1, vec!["rust".into()]).await;
964 + seed_tagged_item(&db, &feed, "rss:python", 2, vec!["python".into()]).await;
965 + seed_tagged_item(&db, &feed, "rss:both", 3, vec!["rust".into(), "go".into()]).await;
966 +
967 + let gen = FeedGenerator::new(db)
968 + .with_filter(FeedFilter::new().with_tag("rust"));
969 + let result = gen.get_items(0).await.unwrap();
970 + assert_eq!(result.items.len(), 2);
971 + let ids: Vec<&str> = result.items.iter().map(|i| i.id.item_id.as_str()).collect();
972 + assert!(ids.contains(&"rss:rust"));
973 + assert!(ids.contains(&"rss:both"));
974 + }
975 +
976 + #[tokio::test]
977 + async fn get_items_tag_filter_no_match_returns_empty() {
978 + let db = test_db().await;
979 + let feed = seed_feed(&db, "rss", "Feed").await;
980 + seed_tagged_item(&db, &feed, "rss:1", 1, vec!["python".into()]).await;
981 +
982 + let gen = FeedGenerator::new(db)
983 + .with_filter(FeedFilter::new().with_tag("rust"));
984 + let result = gen.get_items(0).await.unwrap();
985 + assert!(result.items.is_empty());
986 + }
987 +
988 + #[tokio::test]
989 + async fn get_items_tag_filter_or_logic() {
990 + let db = test_db().await;
991 + let feed = seed_feed(&db, "rss", "Feed").await;
992 + seed_tagged_item(&db, &feed, "rss:r", 1, vec!["rust".into()]).await;
993 + seed_tagged_item(&db, &feed, "rss:g", 2, vec!["go".into()]).await;
994 + seed_tagged_item(&db, &feed, "rss:p", 3, vec!["python".into()]).await;
995 +
996 + // Filter with two tags -- OR logic: items matching either tag pass
997 + let gen = FeedGenerator::new(db)
998 + .with_filter(FeedFilter::new().with_tag("rust").with_tag("go"));
999 + let result = gen.get_items(0).await.unwrap();
1000 + assert_eq!(result.items.len(), 2);
1001 + }
1002 +
1003 + #[tokio::test]
1004 + async fn get_items_tag_filter_items_with_no_tags_excluded() {
1005 + let db = test_db().await;
1006 + let feed = seed_feed(&db, "rss", "Feed").await;
1007 + seed_item(&db, &feed, "rss:notagged", 1).await; // No tags
1008 + seed_tagged_item(&db, &feed, "rss:tagged", 2, vec!["rust".into()]).await;
1009 +
1010 + let gen = FeedGenerator::new(db)
1011 + .with_filter(FeedFilter::new().with_tag("rust"));
1012 + let result = gen.get_items(0).await.unwrap();
1013 + assert_eq!(result.items.len(), 1);
1014 + assert_eq!(result.items[0].id.item_id, "rss:tagged");
1015 + }
1016 +
1017 + // ── has_more correctness after in-memory tag filtering ──────
1018 +
1019 + #[tokio::test]
1020 + async fn has_more_false_when_tag_filter_reduces_below_page_size() {
1021 + let db = test_db().await;
1022 + let feed = seed_feed(&db, "rss", "Feed").await;
1023 + // Create page_size+1 items, but only 1 has the matching tag.
1024 + // The matching item must be recent enough to fall within the
1025 + // SQL LIMIT window (list_all orders by published_at DESC).
1026 + seed_tagged_item(&db, &feed, "rss:yes", 0, vec!["rust".into()]).await;
1027 + for i in 1..=4 {
1028 + seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await;
1029 + }
1030 +
1031 + let gen = FeedGenerator::new(db)
1032 + .with_page_size(3)
1033 + .with_filter(FeedFilter::new().with_tag("rust"));
1034 + let result = gen.get_items(0).await.unwrap();
1035 + assert_eq!(result.items.len(), 1);
1036 + assert!(!result.has_more);
1037 + }
1038 +
1039 + // ── Source filter within get_items ───────────────────────────
1040 +
1041 + #[tokio::test]
1042 + async fn get_items_source_filter() {
1043 + let db = test_db().await;
1044 + let feed_a = seed_feed(&db, "rss", "RSS").await;
1045 + let feed_b = seed_feed(&db, "hn", "HN").await;
1046 + seed_item(&db, &feed_a, "rss:1", 1).await;
1047 + seed_item(&db, &feed_a, "rss:2", 2).await;
1048 + seed_item(&db, &feed_b, "hn:1", 3).await;
1049 +
1050 + let gen = FeedGenerator::new(db)
1051 + .with_filter(FeedFilter::new().source("rss"));
1052 + let result = gen.get_items(0).await.unwrap();
1053 + assert_eq!(result.items.len(), 2);
1054 + assert!(result.items.iter().all(|i| i.id.source == "rss"));
1055 + }
1056 +
1057 + // ── Search filter within get_items ──────────────────────────
1058 +
1059 + #[tokio::test]
1060 + async fn get_items_search_filter_matches_title() {
1061 + let db = test_db().await;
1062 + let feed = seed_feed(&db, "rss", "Feed").await;
1063 + seed_item(&db, &feed, "rss:1", 1).await; // Title: "Title rss:1"
1064 + seed_item(&db, &feed, "rss:2", 2).await; // Title: "Title rss:2"
1065 +
1066 + let gen = FeedGenerator::new(db)
1067 + .with_filter(FeedFilter::new().search("Title rss:1"));
1068 + let result = gen.get_items(0).await.unwrap();
1069 + assert_eq!(result.items.len(), 1);
1070 + assert_eq!(result.items[0].id.item_id, "rss:1");
1071 + }
1072 +
1073 + #[tokio::test]
1074 + async fn get_items_search_filter_no_match() {
1075 + let db = test_db().await;
1076 + let feed = seed_feed(&db, "rss", "Feed").await;
1077 + seed_item(&db, &feed, "rss:1", 1).await;
1078 +
1079 + let gen = FeedGenerator::new(db)
1080 + .with_filter(FeedFilter::new().search("zzz_nonexistent_zzz"));
1081 + let result = gen.get_items(0).await.unwrap();
1082 + assert!(result.items.is_empty());
1083 + }
1084 +
1085 + // ── Combined filters within get_items ───────────────────────
1086 +
1087 + #[tokio::test]
1088 + async fn get_items_search_with_source_filter() {
1089 + let db = test_db().await;
1090 + let feed_a = seed_feed(&db, "rss", "RSS").await;
1091 + let feed_b = seed_feed(&db, "hn", "HN").await;
1092 + seed_item(&db, &feed_a, "rss:match", 1).await;
1093 + seed_item(&db, &feed_b, "hn:match", 2).await;
1094 +
1095 + // Search that matches both, but restricted to rss source
1096 + let gen = FeedGenerator::new(db)
1097 + .with_filter(FeedFilter::new().search("Item").source("rss"));
1098 + let result = gen.get_items(0).await.unwrap();
1099 + assert_eq!(result.items.len(), 1);
1100 + assert_eq!(result.items[0].id.source, "rss");
1101 + }
1102 +
1103 + #[tokio::test]
1104 + async fn get_items_search_with_unread_filter() {
1105 + let db = test_db().await;
1106 + let feed = seed_feed(&db, "rss", "Feed").await;
1107 + let item1 = seed_item(&db, &feed, "rss:1", 1).await;
1108 + seed_item(&db, &feed, "rss:2", 2).await;
1109 +
1110 + db.items().mark_read(item1.id, true).await.unwrap();
1111 +
1112 + // Search matches both items, but only unread should appear
1113 + let gen = FeedGenerator::new(db)
1114 + .with_filter(FeedFilter::new().search("Item").unread_only());
1115 + let result = gen.get_items(0).await.unwrap();
1116 + assert_eq!(result.items.len(), 1);
1117 + assert!(!result.items[0].is_read);
1118 + }
1119 +
1120 + #[tokio::test]
1121 + async fn get_items_search_with_starred_filter() {
1122 + let db = test_db().await;
1123 + let feed = seed_feed(&db, "rss", "Feed").await;
1124 + seed_item(&db, &feed, "rss:1", 1).await;
1125 + let item2 = seed_item(&db, &feed, "rss:2", 2).await;
1126 +
1127 + db.items().mark_starred(item2.id, true).await.unwrap();
1128 +
1129 + let gen = FeedGenerator::new(db)
1130 + .with_filter(FeedFilter::new().search("Item").starred_only());
1131 + let result = gen.get_items(0).await.unwrap();
1132 + assert_eq!(result.items.len(), 1);
1133 + assert!(result.items[0].is_starred);
1134 + }
1135 +
1136 + // ── get_all_items additional coverage ────────────────────────
1137 +
1138 + #[tokio::test]
1139 + async fn get_all_items_empty_db() {
1140 + let db = test_db().await;
1141 + let gen = FeedGenerator::new(db);
1142 + let result = gen.get_all_items().await.unwrap();
1143 + assert!(result.items.is_empty());
1144 + assert!(!result.has_more);
1145 + }
1146 +
1147 + #[tokio::test]
1148 + async fn get_all_items_unread_filter() {
1149 + let db = test_db().await;
1150 + let feed = seed_feed(&db, "rss", "Feed").await;
1151 + let item1 = seed_item(&db, &feed, "rss:1", 1).await;
1152 + seed_item(&db, &feed, "rss:2", 2).await;
1153 + seed_item(&db, &feed, "rss:3", 3).await;
1154 +
1155 + db.items().mark_read(item1.id, true).await.unwrap();
1156 +
1157 + let gen = FeedGenerator::new(db)
1158 + .with_filter(FeedFilter::new().unread_only());
1159 + let result = gen.get_all_items().await.unwrap();
1160 + assert_eq!(result.items.len(), 2);
1161 + assert!(result.items.iter().all(|i| !i.is_read));
1162 + }
1163 +
1164 + #[tokio::test]
1165 + async fn get_all_items_starred_filter() {
1166 + let db = test_db().await;
1167 + let feed = seed_feed(&db, "rss", "Feed").await;
1168 + seed_item(&db, &feed, "rss:1", 1).await;
1169 + let item2 = seed_item(&db, &feed, "rss:2", 2).await;
1170 +
1171 + db.items().mark_starred(item2.id, true).await.unwrap();
1172 +
1173 + let gen = FeedGenerator::new(db)
1174 + .with_filter(FeedFilter::new().starred_only());
1175 + let result = gen.get_all_items().await.unwrap();
1176 + assert_eq!(result.items.len(), 1);
1177 + assert!(result.items[0].is_starred);
1178 + }
1179 +
1180 + #[tokio::test]
1181 + async fn get_all_items_tag_filter() {
1182 + let db = test_db().await;
1183 + let feed = seed_feed(&db, "rss", "Feed").await;
1184 + seed_tagged_item(&db, &feed, "rss:tagged", 1, vec!["rust".into()]).await;
1185 + seed_item(&db, &feed, "rss:plain", 2).await;
1186 +
1187 + let gen = FeedGenerator::new(db)
1188 + .with_filter(FeedFilter::new().with_tag("rust"));
1189 + let result = gen.get_all_items().await.unwrap();
1190 + assert_eq!(result.items.len(), 1);
1191 + assert_eq!(result.items[0].id.item_id, "rss:tagged");
1192 + }
1193 +
1194 + #[tokio::test]
1195 + async fn get_all_items_search_filter() {
1196 + let db = test_db().await;
1197 + let feed = seed_feed(&db, "rss", "Feed").await;
1198 + seed_item(&db, &feed, "rss:1", 1).await;
1199 + seed_item(&db, &feed, "rss:2", 2).await;
1200 +
1201 + let gen = FeedGenerator::new(db)
1202 + .with_filter(FeedFilter::new().search("Item rss:1"));
1203 + let result = gen.get_all_items().await.unwrap();
1204 + assert_eq!(result.items.len(), 1);
1205 + }
1206 +
1207 + #[tokio::test]
1208 + async fn get_all_items_combined_source_and_unread() {
1209 + let db = test_db().await;
1210 + let feed_a = seed_feed(&db, "rss", "RSS").await;
1211 + let feed_b = seed_feed(&db, "hn", "HN").await;
1212 + let item_a1 = seed_item(&db, &feed_a, "rss:1", 1).await;
1213 + seed_item(&db, &feed_a, "rss:2", 2).await;
1214 + seed_item(&db, &feed_b, "hn:1", 3).await;
1215 +
1216 + db.items().mark_read(item_a1.id, true).await.unwrap();
1217 +
1218 + let gen = FeedGenerator::new(db)
1219 + .with_filter(FeedFilter::new().source("rss").unread_only());
1220 + let result = gen.get_all_items().await.unwrap();
1221 + assert_eq!(result.items.len(), 1);
1222 + assert_eq!(result.items[0].id.source, "rss");
1223 + assert!(!result.items[0].is_read);
1224 + }
1225 +
1226 + #[tokio::test]
1227 + async fn get_all_items_score_ordering() {
1228 + let db = test_db().await;
1229 + let feed = seed_feed(&db, "rss", "Feed").await;
1230 + seed_scored_item(&db, &feed, "rss:low", 1, Some(5)).await;
1231 + seed_scored_item(&db, &feed, "rss:high", 2, Some(500)).await;
1232 + seed_scored_item(&db, &feed, "rss:mid", 3, Some(50)).await;
1233 +
1234 + let gen = FeedGenerator::new(db).with_order(OrderBy::Score);
1235 + let result = gen.get_all_items().await.unwrap();
1236 + assert_eq!(result.items[0].id.item_id, "rss:high");
1237 + assert_eq!(result.items[1].id.item_id, "rss:mid");
1238 + assert_eq!(result.items[2].id.item_id, "rss:low");
1239 + }
1240 +
1241 + // ── get_sources edge cases ──────────────────────────────────
1242 +
1243 + #[tokio::test]
1244 + async fn get_sources_feed_with_no_items() {
1245 + let db = test_db().await;
1246 + seed_feed(&db, "rss", "Empty Feed").await;
1247 +
1248 + let gen = FeedGenerator::new(db);
1249 + let sources = gen.get_sources().await.unwrap();
1250 + assert_eq!(sources.len(), 1);
1251 + assert_eq!(sources[0].total_count, 0);
1252 + assert_eq!(sources[0].unread_count, 0);
1253 + }
1254 +
1255 + #[tokio::test]
1256 + async fn get_sources_multiple_feeds_correct_counts() {
1257 + let db = test_db().await;
1258 + let feed_a = seed_feed(&db, "rss", "RSS").await;
1259 + let feed_b = seed_feed(&db, "hn", "HN").await;
1260 + seed_item(&db, &feed_a, "rss:1", 1).await;
1261 + seed_item(&db, &feed_a, "rss:2", 2).await;
1262 + seed_item(&db, &feed_a, "rss:3", 3).await;
1263 + let hn_item = seed_item(&db, &feed_b, "hn:1", 4).await;
1264 +
Lines truncated
@@ -3,7 +3,9 @@
3 3 //! `OrderBy` controls sort order; `FeedFilter` narrows which items
4 4 //! are shown. Both are applied in-memory after the database query.
5 5
6 + use bb_db::QueryCondition;
6 7 use bb_interface::FeedItem;
8 + use regex::Regex;
7 9
8 10 /// How to order feed items
9 11 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
@@ -82,6 +84,10 @@ pub struct FeedFilter {
82 84 /// Filter by user-assigned feed-level tags; items must belong to a feed
83 85 /// with at least one of these tags.
84 86 pub feed_tags: Vec<String>,
87 + /// Query feed conditions (AND logic). Simple conditions (source, starred,
88 + /// unread, tag) are mapped to fast-path SQL fields by `from_conditions()`.
89 + /// Complex conditions (title/author/body contains/regex) run in-memory.
90 + pub conditions: Vec<QueryCondition>,
85 91 }
86 92
87 93 impl FeedFilter {
@@ -126,6 +132,34 @@ impl FeedFilter {
126 132 self
127 133 }
128 134
135 + /// Build a filter from query feed conditions.
136 + ///
137 + /// Simple conditions (source, starred, unread, tag) are mapped to the
138 + /// corresponding fast-path SQL fields. Complex conditions (title/author/body
139 + /// contains, regex) are stored in `self.conditions` for in-memory evaluation.
140 + pub fn from_conditions(conditions: Vec<QueryCondition>) -> Self {
141 + let mut filter = Self::new();
142 + for c in &conditions {
143 + match (c.field.as_str(), c.operator.as_str()) {
144 + ("source", "equals") => {
145 + filter.source = Some(c.value.clone());
146 + }
147 + ("starred", "is") if c.value == "true" => {
148 + filter.starred_only = true;
149 + }
150 + ("unread", "is") if c.value == "true" => {
151 + filter.unread_only = true;
152 + }
153 + ("tag", "equals") => {
154 + filter.tags.push(c.value.clone());
155 + }
156 + _ => {}
157 + }
158 + }
159 + filter.conditions = conditions;
160 + filter
161 + }
162 +
129 163 /// Check if an item matches the filter
130 164 pub fn matches(&self, item: &FeedItem) -> bool {
131 165 // Check source filter
@@ -176,6 +210,59 @@ impl FeedFilter {
176 210 }
177 211 }
178 212
213 + // Check query feed conditions (in-memory filtering for text fields).
214 + // Source/starred/unread/tag conditions are already handled by the
215 + // fast-path fields above (set in `from_conditions()`), so we only
216 + // need to evaluate title/author/body conditions here.
217 + for c in &self.conditions {
218 + match c.field.as_str() {
219 + "title" | "author" | "body" => {
220 + let field_value = match c.field.as_str() {
221 + "title" => item.content.title.as_deref().unwrap_or(""),
222 + "author" => &item.bite.author,
223 + "body" => item.content.body.as_deref().unwrap_or(""),
224 + _ => "",
225 + };
226 + match c.operator.as_str() {
227 + "contains" => {
228 + if !field_value.to_lowercase().contains(&c.value.to_lowercase()) {
229 + return false;
230 + }
231 + }
232 + "not_contains" => {
233 + if field_value.to_lowercase().contains(&c.value.to_lowercase()) {
234 + return false;
235 + }
236 + }
237 + "equals" => {
238 + if !field_value.eq_ignore_ascii_case(&c.value) {
239 + return false;
240 + }
241 + }
242 + "matches_regex" => {
243 + match Regex::new(&c.value) {
244 + Ok(re) => {
245 + if !re.is_match(field_value) {
246 + return false;
247 + }
248 + }
249 + Err(e) => {
250 + tracing::warn!(
251 + regex = %c.value,
252 + error = %e,
253 + "Invalid regex in query feed condition, skipping"
254 + );
255 + }
256 + }
257 + }
258 + _ => {}
259 + }
260 + }
261 + // source/starred/unread/tag already handled by fast-path fields
262 + _ => {}
263 + }
264 + }
265 +
179 266 true
180 267 }
181 268
@@ -491,4 +578,180 @@ mod tests {
491 578 item.meta.tags = vec!["discussion".to_string()];
492 579 assert!(!filter.matches(&item));
493 580 }
581 +
582 + // --- FeedFilter::from_conditions ---
583 +
584 + fn cond(field: &str, operator: &str, value: &str) -> QueryCondition {
585 + QueryCondition {
586 + field: field.to_string(),
587 + operator: operator.to_string(),
588 + value: value.to_string(),
589 + }
590 + }
591 +
592 + #[test]
593 + fn from_conditions_source_equals_sets_fast_path() {
594 + let filter = FeedFilter::from_conditions(vec![cond("source", "equals", "rss")]);
595 + assert_eq!(filter.source, Some("rss".to_string()));
596 + }
597 +
598 + #[test]
599 + fn from_conditions_starred_sets_fast_path() {
600 + let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "true")]);
601 + assert!(filter.starred_only);
602 + }
603 +
604 + #[test]
605 + fn from_conditions_unread_sets_fast_path() {
606 + let filter = FeedFilter::from_conditions(vec![cond("unread", "is", "true")]);
607 + assert!(filter.unread_only);
608 + }
609 +
610 + #[test]
611 + fn from_conditions_tag_sets_fast_path() {
612 + let filter = FeedFilter::from_conditions(vec![cond("tag", "equals", "rust")]);
613 + assert_eq!(filter.tags, vec!["rust".to_string()]);
614 + }
615 +
616 + #[test]
617 + fn from_conditions_stores_all_conditions() {
618 + let conditions = vec![
619 + cond("source", "equals", "rss"),
620 + cond("title", "contains", "rust"),
621 + ];
622 + let filter = FeedFilter::from_conditions(conditions);
623 + assert_eq!(filter.conditions.len(), 2);
624 + }
625 +
626 + #[test]
627 + fn from_conditions_starred_false_no_fast_path() {
628 + let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "false")]);
629 + assert!(!filter.starred_only);
630 + }
631 +
632 + // --- Condition matching (title/author/body) ---
633 +
634 + #[test]
635 + fn condition_title_contains_matches() {
636 + let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "rust")]);
637 + let mut item = make_item("s", "1", 100);
638 + item.content.title = Some("Learning Rust today".to_string());
639 + assert!(filter.matches(&item));
640 + }
641 +
642 + #[test]
643 + fn condition_title_contains_case_insensitive() {
644 + let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "RUST")]);
645 + let mut item = make_item("s", "1", 100);
646 + item.content.title = Some("Learning rust today".to_string());
647 + assert!(filter.matches(&item));
648 + }
649 +
650 + #[test]
651 + fn condition_title_contains_no_match() {
652 + let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "python")]);
653 + let mut item = make_item("s", "1", 100);
654 + item.content.title = Some("Learning Rust today".to_string());
655 + assert!(!filter.matches(&item));
656 + }
657 +
658 + #[test]
659 + fn condition_title_not_contains() {
660 + let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "python")]);
661 + let mut item = make_item("s", "1", 100);
662 + item.content.title = Some("Learning Rust today".to_string());
663 + assert!(filter.matches(&item));
664 + }
665 +
666 + #[test]
667 + fn condition_title_not_contains_rejects() {
668 + let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "rust")]);
669 + let mut item = make_item("s", "1", 100);
670 + item.content.title = Some("Learning Rust today".to_string());
671 + assert!(!filter.matches(&item));
672 + }
673 +
674 + #[test]
675 + fn condition_title_equals() {
676 + let filter = FeedFilter::from_conditions(vec![cond("title", "equals", "Hello World")]);
677 + let mut item = make_item("s", "1", 100);
678 + item.content.title = Some("hello world".to_string());
679 + assert!(filter.matches(&item), "equals should be case-insensitive");
680 + }
681 +
682 + #[test]
683 + fn condition_title_matches_regex() {
684 + let filter =
685 + FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Rust \d+")]);
686 + let mut item = make_item("s", "1", 100);
687 + item.content.title = Some("Rust 2024 edition".to_string());
688 + assert!(filter.matches(&item));
689 + }
690 +
691 + #[test]
692 + fn condition_title_matches_regex_no_match() {
693 + let filter =
694 + FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Python \d+")]);
695 + let mut item = make_item("s", "1", 100);
696 + item.content.title = Some("Rust 2024 edition".to_string());
697 + assert!(!filter.matches(&item));
698 + }
699 +
700 + #[test]
701 + fn condition_invalid_regex_skipped() {
702 + let filter =
703 + FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"[invalid")]);
704 + let mut item = make_item("s", "1", 100);
705 + item.content.title = Some("anything".to_string());
706 + // Invalid regex should be skipped (not reject the item)
707 + assert!(filter.matches(&item));
708 + }
709 +
710 + #[test]
711 + fn condition_author_contains() {
712 + let filter = FeedFilter::from_conditions(vec![cond("author", "contains", "alice")]);
713 + let mut item = make_item("s", "1", 100);
714 + item.bite.author = "Alice Smith".to_string();
715 + assert!(filter.matches(&item));
716 + }
717 +
718 + #[test]
719 + fn condition_body_contains() {
720 + let filter = FeedFilter::from_conditions(vec![cond("body", "contains", "important")]);
721 + let mut item = make_item("s", "1", 100);
722 + item.content.body = Some("This is an important article".to_string());
723 + assert!(filter.matches(&item));
724 + }
725 +
726 + #[test]
727 + fn condition_body_not_contains() {
728 + let filter = FeedFilter::from_conditions(vec![cond("body", "not_contains", "spam")]);
729 + let mut item = make_item("s", "1", 100);
730 + item.content.body = Some("This is a good article".to_string());
731 + assert!(filter.matches(&item));
732 + }
733 +
734 + #[test]
735 + fn conditions_and_logic() {
736 + let filter = FeedFilter::from_conditions(vec![
737 + cond("title", "contains", "rust"),
738 + cond("author", "contains", "alice"),
739 + ]);
740 + let mut item = make_item("s", "1", 100);
741 + item.content.title = Some("Rust tips".to_string());
742 + item.bite.author = "Alice".to_string();
743 + assert!(filter.matches(&item));
744 +
745 + let mut wrong_author = make_item("s", "2", 100);
746 + wrong_author.content.title = Some("Rust tips".to_string());
747 + wrong_author.bite.author = "Bob".to_string();
748 + assert!(!filter.matches(&wrong_author));
749 + }
750 +
751 + #[test]
752 + fn empty_conditions_matches_all() {
753 + let filter = FeedFilter::from_conditions(vec![]);
754 + let item = make_item("s", "1", 100);
755 + assert!(filter.matches(&item));
756 + }
494 757 }
@@ -1,96 +0,0 @@
1 - # Balanced Breakfast
2 -
3 - Native desktop feed aggregator with plugin-based sources. RSS, Hacker News, and arXiv in a unified timeline.
4 -
5 - ## What It Is
6 -
7 - A Tauri 2 desktop feed reader that aggregates content from multiple source types via a Rhai plugin system. Three-panel layout (sources, items, detail), keyboard-driven, with OPML import/export for migration from other readers.
8 -
9 - **License:** PolyForm Noncommercial 1.0.0
10 -
11 - ## Tech Stack
12 -
13 - - **Framework:** Tauri 2.10.2 (Rust backend + Vanilla JS frontend)
14 - - **Database:** SQLite with sqlx 0.8 (content-addressable item storage)
15 - - **Plugin Runtime:** Rhai scripting engine (dynamically loaded, no recompilation)
16 - - **Async:** Tokio 1.49
17 - - **XML:** roxmltree 0.19 (OPML parsing)
18 - - **Logging:** tracing 0.1
19 -
20 - **Workspace:** 5 crates — `bb-interface` (plugin traits), `bb-core` (orchestration), `bb-feed` (aggregation), `bb-db` (SQLite layer), `src-tauri` (desktop app)
21 -
22 - ## Features
23 -
24 - ### Feed Aggregation
25 - - Subscribe to any RSS or Atom feed URL
26 - - Hacker News integration (Top, New, Best, Ask HN, Show HN, Jobs)
27 - - arXiv paper tracking by category (cs.AI, cs.LG, cs.CL, cs.CV, stat.ML, etc.)
28 - - Unified timeline across all sources
29 - - Per-source emoji indicators
30 -
31 - ### Reading & Browsing
32 - - Three-panel layout: sources sidebar, items list, detail panel
33 - - Item metadata: title, author, body, score, published date
34 - - HTML content converted to readable text
35 - - Open original URLs in browser
36 - - Keyboard shortcuts: j/k navigate, s star, r read/unread, / search, o open, Escape close
37 -
38 - ### Organization
39 - - Mark items read/unread, star/favorite
40 - - Filter views: All, Unread only, Starred only
41 - - Per-source filtering
42 - - Sort: Newest first, By score, Unread first, Starred first
43 - - Client-side text search (title, body, author)
44 - - Paginated loading (50 items/page)
45 - - Per-source unread count in sidebar
46 -
47 - ### Feed Management
48 - - Add feeds from any plugin without restart
49 - - Delete individual feeds or all feeds from a source
50 - - Manual refresh (Cmd+R) for immediate fetch
51 - - Auto-fetch background loop (default 15 min, configurable per plugin)
52 - - Toast notifications on fetch errors
53 -
54 - ### OPML Support
55 - - Import OPML files (Cmd+I) for migration from other readers
56 - - Export OPML 2.0 (Cmd+E) for backup
57 - - Automatic duplicate detection on import
58 -
59 - ### Plugin System
60 - - 3 built-in plugins: RSS/Atom, Hacker News, arXiv
61 - - Rhai script-based (dynamically loaded, no recompilation needed)
62 - - Per-plugin configuration schema with validation
63 - - Plugin capabilities: pagination, search, date filtering, custom fetch intervals, auth flags
64 - - Host functions: HTTP requests, feed parsing, logging
65 - - 100,000 max operations limit per execution (security)
66 -
67 - ### Data Persistence
68 - - Local SQLite database
69 - - Content-addressable items (prevents duplicates)
70 - - Read/starred status persists across restarts
71 - - Plugin state storage
72 -
73 - ## Testing
74 -
75 - - **142 tests** (all passing)
76 - - bb-db: 50 tests (feeds, items, plugin state repository operations)
77 - - bb-core: 34 tests (plugin orchestration, Rhai execution, conversions)
78 - - bb-feed: 31 tests (aggregation, filtering, sort strategies)
79 - - bb-interface: 21 tests (types, capabilities, config validation)
80 - - src-tauri: 6 tests (error codes, OPML escaping)
81 -
82 - ## Platform Support
83 -
84 - | Platform | Status |
85 - |----------|--------|
86 - | macOS | Primary, functional |
87 - | Windows | Supported via Tauri |
88 - | Linux | Supported via Tauri |
89 -
90 - ## Status
91 -
92 - **Done:** Tauri 2 conversion, Phase 1 polish (74 items), OPML import/export, auto-fetch loop, 142 tests.
93 -
94 - **Active:** Preparing macOS release binary.
95 -
96 - **Next:** Tags/categories, theming, feed health monitoring, stale item cleanup, full-text search, JSON Feed format, plugin secret encryption. Then integration tests, CI, plugin authoring guide.
@@ -1,356 +0,0 @@
1 - # Balanced Breakfast -- Code Audit Review
2 -
3 - **Last audited:** 2026-03-01 (fourth audit)
4 - **Previous audit:** 2026-02-28 (third audit)
5 -
6 - ## Overall Grade: A-
7 -
8 - Excellent codebase with clean architecture, strong security posture, and comprehensive test coverage (225 tests, 0 failures). The 4-crate workspace separation is well-designed, the Rhai plugin system is properly sandboxed, and all SQL is parameterized. All security and code quality issues from previous audits resolved. One new issue: 8 clippy violations from newer Rust lints (`items_after_test_module`, `bool_assert_comparison`, `approx_constant`, `unnecessary_get_then_check`, `useless_vec`), all in test code or mechanical fixes. Remaining gaps: generator isolation tests, DB-level command integration tests, and documentation.
9 -
10 - ## Scorecard
11 -
12 - | Dimension | Grade | Notes |
13 - |-----------|:-----:|-------|
14 - | Code Quality | A- | Zero `.unwrap()` in production code. Consistent `thiserror` + `Result` error handling. Clean naming. 8 clippy violations from newer lints (2 in bb-interface, 6 in bb-core test code). All mechanical fixes. |
15 - | Architecture | A | Clean 4-crate layering (interface -> db -> core -> feed). Orchestrator coordinates without touching SQL. Plugin system cleanly separated into manager, engine, host functions, conversions. Tauri commands are thin wrappers. |
16 - | Testing | A | 225 tests across all crates, all passing. Thorough coverage of db, crypto, url_cleaner, FTS5, tags, health, ordering, generator, conversions, plugin manager. Tauri commands: 25 feed validation tests, 5 format_time_ago tests, 9 theme validation/parsing tests. Gap: no isolated generator filtering tests, no DB-level command integration tests. |
17 - | Security | A | All SQL parameterized. FTS5 injection prevented via `sanitize_fts_query`. HTML sanitization strips dangerous elements and `data:` URIs. Rhai sandboxed (max_operations, max_expr_depths). AES-256-GCM for secrets with `0o600` key file permissions. Tag bar uses `addEventListener` (no inline onclick XSS). All mutex locks use `.map_err()` (no panic on poisoning). |
18 - | Performance | A | N+1 avoided via GROUP BY aggregation. FTS5 for full-text search. Pagination everywhere with `page_size+1` has_more detection. MAX_ALL_ITEMS cap (10K). Background auto-fetch with per-plugin intervals. Stale cleanup every 6h. |
19 - | Documentation | A- | Good `//!` module docs on all Rust files. JSDoc on all frontend functions. `///` on public APIs. README.md and docs/PLUGIN_AUTHORING.md now exist. Minor: some module docs are brief. |
20 - | Dependencies | A | Workspace-level dep management with pinned versions. CI pipeline (check, test, clippy -D warnings, cargo-audit). Zero unused deps. Clean clippy (0 warnings). |
21 - | Frontend | A | Clean BB.* namespace (IIFE modules). Proxy-based reactive state with pub/sub. `escapeHtml` on all user content. `sanitizeHtml` for RSS bodies with `data:` URI blocking. Keyboard shortcuts (j/k/s/r///Escape). ARIA attributes. Tag bar XSS fixed. Dead stubs removed. |
22 - | Type Safety | A | `FeedItemId` newtype with `to_combined()`/`from_combined()`. `FeedId`, `ItemId`, `BusserStateId` UUID newtypes via `define_uuid_id!` macro. `BusserId(String)` newtype. `ApiErrorCode` enum replaces `ApiError.code: String`. Good domain enums (ConfigFieldType, OrderBy, BusserError, PluginError). Wildcard fallbacks in `OrderBy::from_str_loose` and Rhai conversions remain (intentional — loose parsing for user input). |
23 - | Observability | B | `tracing` used exclusively (zero println/eprintln). EnvFilter configured. Widespread info/warn/error/debug usage. But zero `#[instrument]` anywhere. No span correlation for fetch pipeline. No DB query tracing configured on sqlx. Most log calls use format strings rather than structured fields. No HTTP call instrumentation on ureq. |
24 - | Concurrency | A- | UNIQUE constraints enforce idempotency (external_id, busser_state, feed_tags). Transactions for multi-statement mutations (set_tags, delete_feed). Correct lock usage (Mutex for abort handles, RwLock for plugin configs). Auto-fetch processes plugins sequentially. Minor TOCTOU in create_feed duplicate detection (not transactional). |
25 - | Resilience | B | Graceful shutdown via Drop (abort_auto_fetch, abort_cleanup). Feed health tracking with consecutive_failures. Non-fatal plugin initialization. But no HTTP timeouts on ureq calls (default is no timeout). No retry/backoff strategy for failing feeds. No pool acquire_timeout on SqlitePool. |
26 - | API Consistency | A- | Uniform `Result<T, ApiError>` with 5 constructors. Consistent camelCase serialization. Unified pagination (page_size+1 has_more). ID validation consistent via Uuid::parse_str. Minor: create_feed returns void, mark_read silently succeeds on missing items, health computed in command layer for sources but not items. |
27 - | Migration Safety | A | All 6 migrations additive. All CREATE TABLE use IF NOT EXISTS. FTS5 migration with backfill + triggers. ALTER TABLE ADD COLUMN with sensible defaults. Foreign key cascades. 8 indexes covering common query patterns. No destructive migrations. |
28 - | Codebase Size | A | ~5,400 lines production Rust + 1,658 lines JS for 17+ features (RSS/Atom/JSON parsing, Rhai plugins, encryption, FTS5 search, tags, health tracking, stale cleanup, themes, etc.). ~320 lines Rust per feature. No dead code, no redundant abstractions. |
29 -
30 - ## Module Heatmap
31 -
32 - | Module | Code | Arch | Test | Security | Perf | Docs | Deps | Frontend |
33 - |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:----:|:--------:|
34 - | bb-interface (858 lines) | A | A+ | A- | n/a | n/a | A | A | n/a |
35 - | bb-core/orchestrator (323 lines) | A | A | n/a | A | A | A | n/a | n/a |
36 - | bb-core/plugin_manager (343 lines) | A- | A | A- | A | n/a | A- | n/a | n/a |
37 - | bb-core/crypto (246 lines) | A | A | A | A | n/a | A | n/a | n/a |
38 - | bb-core/url_cleaner (180 lines) | A | A | A | A | n/a | A | n/a | n/a |
39 - | bb-core/rhai_plugin (1664 lines) | A | A | A | A | n/a | A- | n/a | n/a |
40 - | bb-db/models (386 lines) | A | A | A- | n/a | n/a | A- | n/a | n/a |
41 - | bb-db/repository (1539 lines) | A | A | A | A | A | A | n/a | n/a |
42 - | bb-feed/ordering (415 lines) | A | A | A | n/a | A | A- | n/a | n/a |
43 - | bb-feed/generator (508 lines) | A- | A | B+ | n/a | A | A- | n/a | n/a |
44 - | src-tauri/state (339 lines) | A- | A | n/a | A- | A | A- | n/a | n/a |
45 - | src-tauri/commands (1380 lines) | A | A | A- | A | n/a | A- | n/a | n/a |
46 - | frontend/sources (255 lines) | A- | n/a | n/a | A | n/a | A- | n/a | A- |
47 - | frontend/items (214 lines) | A | n/a | n/a | A | n/a | A | n/a | A |
48 - | frontend/feeds (209 lines) | A | n/a | n/a | A | n/a | A- | n/a | A |
49 - | frontend/themes (188 lines) | A | n/a | n/a | A | n/a | A- | n/a | A |
50 - | frontend/components (178 lines) | A | n/a | n/a | A | n/a | A | n/a | A |
51 - | frontend/app (246 lines) | A | n/a | n/a | A | n/a | A- | n/a | A |
52 - | frontend/utils (101 lines) | A- | n/a | n/a | A | n/a | A | n/a | A- |
53 -
54 - ### Cold Spots
55 -
56 - - **bb-feed/generator (Testing): B+** -- 10 tests exist but all require a database. No isolated unit tests for the in-memory tag filtering logic in `get_items`/`get_all_items`. The `ordering.rs` companion module has 25 pure unit tests; `generator.rs` should have similar isolated filter tests.
57 -
58 - - **src-tauri/commands (Testing): B** -- 6 tests total (2 for xml_escape in opml, 4 for ApiError). Zero integration tests for the 20 registered Tauri commands. The `create_feed` validation logic (90+ lines of schema-aware validation) is completely untested. This is the biggest testing gap in the codebase.
59 -
60 - - **bb-core/plugin_manager (Security): B+** -- Two `.expect("plugin_configs lock poisoned")` calls at lines 125 and 143. If a thread panics while holding the `RwLock`, these propagate a panic to the calling thread. Should return `PluginError::InitError` instead.
61 -
62 - - **frontend/sources (Security): B+** -- Single-quote XSS in tag filter bar at line 223: `onclick="BB.sources.selectTag('${escapeHtml(t)}')"`. The `escapeHtml` function does not escape `'`, so a tag like `it's` breaks out of the onclick attribute. Should use `addEventListener` or escape single quotes.
63 -
64 - - **frontend/utils (Security): B+** -- `sanitizeHtml` strips `javascript:` from `href`/`src` attributes but does not block `data:` URIs. A `data:text/html,...` URI in an `<img src>` or `<a href>` could execute scripts in some browsers.
65 -
66 - ## Strengths
67 -
68 - - **Zero `.unwrap()` in production code.** Every fallible operation uses `Result`, `unwrap_or_else`, or `unwrap_or_default` with reasoning comments. The `parse_or_default` helper in `models.rs` logs parse failures via `tracing::warn` instead of panicking. Only test code uses `.unwrap()`.
69 -
70 - - **All SQL is parameterized.** Every query in `repository.rs` (1539 lines, ~30 queries) uses `?N` placeholders with `.bind()`. The `sanitize_fts_query` function wraps search terms in double quotes to prevent FTS5 operator injection. The `feed_ids_with_tags` function builds dynamic placeholder strings (`?1, ?2, ...`) but binds all values -- no string interpolation of user input into SQL.
71 -
72 - - **Defense-in-depth security.** Multiple independent layers: (a) Rhai engine sandboxed with `max_operations(100_000)` and `max_expr_depths(128, 128)`, (b) AES-256-GCM encryption for secrets at rest with versioned format (`bb_enc:v1:`), unique nonces per call, backward-compatible plaintext passthrough, (c) HTML sanitization strips `<script>`, `<iframe>`, `<form>`, on* handlers, and `javascript:` URLs, (d) XML escaping in OPML export, (e) URL tracker parameter stripping on fetch, (f) extensive input validation in `create_feed` (name length, URL format, number parsing, select options, toggle values, text length, duplicate detection).
73 -
74 - - **Clean crate boundaries.** `bb-interface` defines types and traits with zero implementation deps (only serde + chrono). `bb-db` handles persistence with no knowledge of plugins. `bb-core` orchestrates but never touches SQL directly. `bb-feed` handles aggregation and ordering with filter/sort abstractions. No circular dependencies.
75 -
76 - - **Comprehensive test suite (178 tests).** Coverage includes: crypto roundtrips (including wrong-key, different nonces), FTS5 search (title/body/bite_text/multi-word/special characters/pagination), feed health (success resets, failure increments, defaults), tag CRUD (set/get/add/remove/cascade on delete/idempotent), stale cleanup (preserves starred/unread/recent), upsert conflict (preserves is_read/is_starred), plugin manager lifecycle (create/discover/load/init/fetch/shutdown), Rhai conversions (50+ tests: json_to_dynamic, parse_feed_xml, parse_json_feed, capabilities, config_schema, feed_item), ordering (25 tests: all OrderBy variants, filter combinations), generator (10 tests: pagination, counting, sources aggregation, mark read/starred).
77 -
78 - - **Well-designed plugin system.** Rhai plugins declare capabilities (fetch_interval_secs, pagination, date_filter, search) and config schemas (typed fields: Text, Url, Number, Select, Toggle, Secret, TextArea) via pure Rhai functions. Host functions provide a rich API (http_get, parse_json, parse_xml, parse_feed, html_to_text, strip_tracking, etc.). The trust model is documented: plugins are local files the user explicitly installs.
79 -
80 - ## Weaknesses
81 -
82 - - **Single-quote XSS in tag filter bar.** `sources.js:223` interpolates `escapeHtml(t)` into a single-quoted onclick attribute. Since `escapeHtml` (DOM textContent -> innerHTML) does not escape `'`, a tag containing a single quote breaks out of the attribute. Low severity (tags are user-created locally), but should be fixed by using `addEventListener` or escaping single quotes.
83 -
84 - - **Encryption key file permissions not set.** `crypto.rs:42` writes the 32-byte key with `std::fs::write()`, which uses the process umask (typically 0644). The key should be set to 0600 after creation to prevent other users from reading it.
85 -
86 - - **Six mutex `.expect("poisoned")` calls.** Two in `plugin_manager.rs` (lines 125, 143) on `RwLock::read/write` for plugin configs. Four in `state.rs` (lines 98, 108, 117, 126) on `Mutex::lock` for abort handles. The plugin_manager ones are more concerning because they're in the hot path (fetch operations). The state.rs ones are low-risk (only accessed during handle set/abort). All should ideally return errors, but the plugin_manager ones are the priority fix.
87 -
88 - - **No integration tests for Tauri commands.** The 20 registered commands have only 6 tests (xml_escape and ApiError). The `create_feed` command alone has 90+ lines of validation logic that is completely untested. This is the largest testing gap.
89 -
90 - - **Dead code stubs.** Three deprecated functions (`markFailed`, `clearFailed`, `clearAllFailed`) in `sources.js:16-18` are empty stubs from the old client-side health model. Nothing calls them. Should be removed.
91 -
92 - - **`sanitizeHtml` does not block `data:` URIs.** `utils.js:73-78` strips `javascript:` from href/src but does not check for `data:` URIs, which can execute scripts via `data:text/html,...` in some contexts.
93 -
94 - ## Competitive Comparison
95 -
96 - Based on `docs/competition.md`, BB holds a unique position as the only native desktop feed reader with a user-scriptable plugin system.
97 -
98 - **Gaps closed since the competition analysis was written:**
99 - - Full-text search: Implemented via FTS5 (was "Planned")
100 - - Tags/categories: Feed tags implemented with junction table, sidebar filter bar, 6 tests
101 - - URL tracker stripping: Implemented in `url_cleaner.rs` with 10 tests
102 - - JSON Feed format: Implemented in `conversions.rs` (1.0/1.1, 4 tests)
103 - - Feed config validation: Implemented in `create_feed` (90+ lines of schema-aware validation)
104 - - Secret encryption: AES-256-GCM with versioned format, auto-migration of legacy plaintext
105 - - Feed health monitoring: consecutive_failures tracking, server-authoritative health dots
106 - - Stale item cleanup: Background task every 6h, deletes read non-starred items > 30 days
107 - - Theming: 9 TOML themes, bundled + user custom, high-contrast accessibility variant
108 -
109 - **Remaining competitive gaps (from competition.md priority list):**
110 - 1. Reader view / full-article fetch -- planned as a plugin (Phase 5)
111 - 2. Filter/query feeds (virtual feeds from rules) -- Phase 5
112 - 3. Mobile support -- deferred (Tauri mobile)
113 -
114 - **Where competitors are ahead:**
115 - - NetNewsWire: iCloud sync, third-party sync backends, mature iOS app, reader view
116 - - Reeder: Podcast playback, YouTube/Reddit/Mastodon integration, iCloud sync, polished gesture UX
117 - - Miniflux: Full REST API, 25+ integrations, multi-user, pixel tracker removal, content scraping
118 - - FreshRSS: Multi-user, WebSub push, Google Reader API compatibility, scales to 1M+ articles
119 -
120 - **Where BB has an edge:**
121 - - Rhai plugin system (no competitor has user-scriptable source adapters)
122 - - HN + arXiv as first-class sources via plugins
123 - - Free with no limits, no account, no telemetry
124 - - Cross-platform native desktop (vs. NNW/Reeder Apple-only, vs. Miniflux/FreshRSS web-only)
125 - - Planned community Plugin Store would be unique in the feed reader space
126 -
127 - ## Action Items
128 -
129 - All resolved. Outstanding work tracked in `docs/todo.md`.
130 -
131 - - ~~Fix single-quote XSS in tag filter~~ — sources.js now uses `escapeHtml()` throughout
132 - - ~~Set 0600 permissions on encryption.key~~ — `crypto.rs:46-48` sets `0o600`
133 - - ~~Replace `.expect("poisoned")`~~ — replaced with error propagation
134 - - ~~Remove deprecated health stubs~~ — `markFailed`/`clearFailed`/`clearAllFailed` removed
135 - - ~~Add `data:` URI blocking~~ — `utils.js:75` blocks both `javascript:` and `data:`
136 - - ~~bb-feed generator tests~~ — 3 feed-tag filtering tests added (`generator.rs`)
137 - - ~~Tauri command integration tests~~ — 33 tests added
138 - - ~~README with setup instructions~~ — `README.md` at project root
139 - - ~~Plugin authoring guide~~ — included in README
140 -
141 - ## Changes Since Last Audit
142 -
143 - Previous audit was also 2026-02-27 (first audit of the post-Tauri codebase). This update corrects and extends it:
144 -
145 - - **Test count corrected:** 178 tests (was reported as 155 in the first audit). The 178 count comes from the actual `cargo test --workspace` output: 6 (src-tauri) + 70 (bb-core) + 47 (bb-db) + 34 (bb-feed) + 21 (bb-interface).
146 - - **Module Heatmap added:** The first audit omitted the required per-module grading. This update adds the full heatmap with 19 modules graded across all applicable dimensions.
147 - - **Cold spots identified:** Five cold spots called out with specific modules, grades, and remediation.
148 - - **Additional `.expect()` calls noted:** Four mutex `.expect("mutex poisoned")` calls in `state.rs` (lines 98, 108, 117, 126) were not flagged in the first audit. Total is now 6 across 2 files.
149 - - **No regressions.** All findings from the first audit remain valid and unfixed (all are tracked in `docs/todo.md`).
150 -
151 - ## Build Verification
152 -
153 - - `cargo check --workspace`: **PASS** (0 errors, 0 warnings)
154 - - `cargo test --workspace`: **PASS** (225 tests, 0 failures)
155 - - `cargo clippy --workspace`: **PASS** (0 warnings)
156 - - CI pipeline: `.build.yml` configured for builds.sr.ht with check, test, clippy (-D warnings), cargo-audit
157 -
158 - ## Changes Since Last Audit (2026-02-28)
159 -
160 - ### Security items resolved (all 3)
161 - - Single-quote XSS in tag filter bar -- `sources.js:214,221` now uses `addEventListener('click', () => selectTag(t))` instead of inline onclick attribute. No user-controlled data in remaining `onclick` usages (hardcoded values or DOM property assignments with UUIDs).
162 - - Encryption key permissions -- `crypto.rs:46-48` sets `0o600` via `std::fs::Permissions::from_mode()` after key creation on Unix.
163 - - Mutex `.expect("poisoned")` -- all 6 instances replaced with `.map_err()` across `plugin_manager.rs` (4 calls at lines 92, 130, 151, 158) and `state.rs` (9 `.map_err()` calls). Zero `.expect()` in either file.
164 -
165 - ### Code quality items resolved (all 2)
166 - - Dead code stubs `markFailed`/`clearFailed`/`clearAllFailed` removed from `sources.js` (zero grep matches)
167 - - `sanitizeHtml` blocks `data:` URIs alongside `javascript:` in `utils.js:72-74`
168 -
169 - ### Tests added
170 - - 8 new unit tests for `FeedFilter::apply_tags_only()` and tag-related `matches()` paths in `ordering.rs:418-494`
171 - - 25 new unit tests for feed validation in `commands/feeds.rs` (extracted `validate_feed_input` from `create_feed` — tests cover name, config structure, required fields, URL, number, select, toggle, text/secret length limits)
172 - - 5 new unit tests for `format_time_ago` in `commands/items.rs` (just now, minutes, hours, days, old dates)
173 - - 9 new unit tests for `validate_theme_id` and `parse_meta` in `commands/themes.rs` (valid/invalid IDs, path traversal, TOML defaults)
174 - - Test count: 178 -> 225 passed, 0 failed, 0 ignored
175 -
176 - ### Grades changed
177 - - **Security**: A- -> A (all 3 security items resolved -- XSS, file permissions, mutex panics)
178 - - **Code Quality**: A (unchanged score, but dead code and `data:` URI items resolved)
179 - - **Frontend**: A- -> A (XSS fixed, dead stubs removed, `data:` URI gap closed)
180 - - **Testing**: A- -> A (tag filter tests, Tauri command validation tests, pure function tests — 47 new tests total; remaining gap is generator isolation tests and DB-level command integration tests)
181 - - bb-core/plugin_manager (Security): B+ -> A (no more `.expect("poisoned")`)
182 - - bb-core/crypto (Security): A- -> A (key file permissions now set)
183 - - frontend/sources (Security): B+ -> A (tag bar uses addEventListener)
184 - - frontend/utils (Security): B+ -> A (`data:` URIs blocked)
185 - - bb-feed/generator (Testing): B+ -> A- (tag filter tests added; in-memory filtering logic still needs isolated tests)
186 - - src-tauri/commands (Testing): B -> A- (39 tests covering feeds validation, items formatting, themes parsing; remaining: DB integration tests for OPML import/export, item CRUD, tag operations)
187 -
188 - ### Verification
189 - - cargo clippy clean (0 warnings)
190 - - Zero `.unwrap()` in production code
191 - - 3 `.expect()` in production code (all acceptable: 1 constant regex in url_cleaner.rs, 1 Tauri `run()` entry point, 1 startup state init)
192 -
193 - ### Still open (5 items)
194 - - Fix 8 clippy violations (2 in bb-interface, 6 in bb-core test code)
195 - - Unit tests for `generator.rs` in-memory filtering logic (isolated from db)
196 - - Integration tests for Tauri commands with in-memory DB (OPML import/export, item CRUD, tag operations)
197 - - README with setup instructions
198 - - Plugin authoring guide
199 -
200 - ## Changes Since Last Audit (2026-02-28, second audit)
201 -
202 - ### New findings
203 - - **8 clippy violations** from newer Rust lints:
204 - - `items_after_test_module` in `bb-interface/src/busser.rs:270,441` (Busser trait defined after test module)
205 - - `useless_vec` in `bb-interface/src/busser.rs:303` (test code)
206 - - `bool_assert_comparison` in `bb-core/rhai_plugin/conversions.rs:713` (test code)
207 - - `approx_constant` in `bb-core/rhai_plugin/conversions.rs:724,726` (test code, uses `3.14` not `PI`)
208 - - `unnecessary_get_then_check` in `bb-core/rhai_plugin/conversions.rs:925,1199,1200` (test code)
209 -
210 - ### Grades changed
211 - - Overall: A -> A- (clippy violations)
212 - - Code Quality: A -> A- (8 clippy violations)
213 -
214 - ### No code regressions
215 - - 225 tests pass, 0 failures
216 - - Zero `.unwrap()` in production code
217 - - All previous security/code quality items remain resolved
218 - - The clippy violations are from stricter lints in newer Rust, not from code changes
219 -
220 - ## Changes Since Last Audit (2026-02-28, third audit)
221 -
222 - ### Fifth-run full audit (2026-03-01)
223 -
224 - Fresh audit of entire codebase per audit.md. Test count: 315 (was 225). 1 clippy warning (unused `OrderBy` import). 0 `.unwrap()` in non-test production code. 294 `.unwrap()` in test code (all inside `#[cfg(test)]`).
225 -
226 - ### Items resolved since third audit
227 -
228 - - **8 clippy violations**: All resolved (items_after_test_module, useless_vec, bool_assert_comparison, approx_constant, unnecessary_get_then_check). Code Quality grade restored.
229 - - **Generator isolation tests**: Tag filter tests added in `ordering.rs`.
230 - - **Integration tests for Tauri commands**: 33 command integration tests added (`src-tauri/tests/command_integration.rs`, 1,337 lines) exercising the full stack with in-memory SQLite.
231 - - **README with setup instructions**: Created (`README.md`).
232 - - **Plugin authoring guide**: Created (`docs/PLUGIN_AUTHORING.md`).
233 - - **Error handling**: `From` impls for `sqlx::Error`/`FeedError`/`OrchestratorError` → `ApiError`, replaced 28 `.map_err` calls.
234 - - **`is_single_feed_due` extracted**: Pure helper function for testability in `state.rs`.
235 - - **Host function tests**: 12 new tests for Rhai host functions.
236 - - **Orchestrator tests**: 5 new tests.
237 - - **State timing tests**: 3 new tests.
238 -
239 - ### New findings
240 -
241 - 1. **Stale `.env` contains PostgreSQL URL.** `.env:4` says `DATABASE_URL=postgres://localhost/balanced_breakfast` while the project is SQLite-only. `.env.example` correctly says `sqlite:`. The `.env` is gitignored but confusing for sqlx CLI and any non-Tauri binary.
242 - - File: `.env:4`
243 -
244 - 2. **1 clippy warning.** Unused `OrderBy` import in integration tests.
245 - - File: `src-tauri/tests/command_integration.rs:10`
246 -
247 - 3. **No doc comments on 46 pub repository methods.** `repository.rs` exposes 46 `pub async fn` methods across 4 repository structs, none with `///` doc comments. Method names are descriptive but parameter semantics (e.g., `cutoff` in `delete_stale_read`) are undocumented.
248 - - File: `crates/bb-db/src/repository.rs`
249 -
250 - 4. **`conversions.rs` error handling is string-based.** Returns `Result<_, Box<EvalAltResult>>` with string error messages via `EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)`. Callers can only match on error strings, not error variants.
251 - - File: `crates/bb-core/src/rhai_plugin/conversions.rs`
252 -
253 - 5. **Pagination strategy inconsistency.** `get_items()` uses `page_size + 1` for `has_more` detection, but `get_all_items()` uses `page_size * 2` with `MAX_ALL_ITEMS = 10,000`. Documented in todo.md but unfixed.
254 - - File: `crates/bb-feed/src/generator.rs:68,102`
255 -
256 - 6. **Zero JavaScript tests.** 393 lines of JS (`app.js`, `api.js`, `state.js`) with keyboard navigation, search debouncing, reactive state store -- all untested.
257 - - Files: `src-tauri/frontend/js/`
258 -
259 - 7. **Background task testing gap.** `spawn_auto_fetch()` (lines 229-293) and `spawn_stale_cleanup()` (lines 298-324) contain non-trivial logic but are only tested indirectly. The event emission paths and error-to-event mapping are not tested.
260 - - File: `src-tauri/src/state.rs`
261 -
262 - 8. **Frontend has no loading/error states for search.** Search triggers `loadItems()` after a 300ms debounce, but no visual indicator during search and errors are silently swallowed via `console.error`.
263 - - File: `src-tauri/frontend/js/app.js:61-70`
264 -
265 - ### Grades assessed (fresh audit perspective)
266 -
267 - | Dimension | Previous | Fresh Audit | Notes |
268 - |-----------|:--------:|:-----------:|-------|
269 - | Code Quality | A- | A | 8 clippy violations fixed; 1 new warning (trivial) |
270 - | Architecture | A | A | Clean 4-crate separation confirmed |
271 - | Testing | A | A- | 315 tests (up from 225); JS tests graded C |
272 - | Security | A | A | All previous fixes intact |
273 - | Performance | A | A- | page_size inconsistency; SqlitePool max_connections=5 conservative |
274 - | Documentation | A- | B+ | Repository public API undocumented |
275 - | Dependencies | A | A | Unchanged |
276 - | Frontend | A | B+ | No JS tests, no search loading/error states |
277 -
278 - **Overall: A- (unchanged)** -- test coverage significantly improved (+90 tests) but documentation and JS testing gaps persist.
279 -
280 - ### New action items
281 -
282 - Filed in docs/todo.md:
283 - - Fix `.env` PostgreSQL URL (change to sqlite or delete)
284 - - Fix clippy warning (remove unused `OrderBy` import)
285 - - Add doc comments to repository public API (46 methods)
286 - - Unify pagination strategy (get_items vs get_all_items)
287 - - Add basic frontend testing (state.js, search debounce)
288 - - Add unit tests for background task logic (extract from spawn_auto_fetch)
289 - - Add loading indicator for search
290 - - Consider parallel plugin fetching for scale
291 -
292 - ### No regressions
293 - - 315 tests pass, 0 failures, 0 ignored
294 - - Zero `.unwrap()` in production code
295 - - All previous security fixes intact (XSS, file permissions, mutex panics)
296 - - 3 `.expect()` in production code (regex, Tauri run, startup init -- all acceptable)
297 -
298 - ### Still open (8 items)
299 - - Fix `.env` PostgreSQL URL
300 - - Fix clippy warning (unused OrderBy import)
301 - - Add repository doc comments (46 methods)
302 - - Unify pagination strategy
303 - - Add JS tests
304 - - Add background task unit tests
305 - - Add search loading indicator
306 - - Consider parallel plugin fetching
307 -
308 - ## Changes Since Previous Audit (2026-03-01, fourth audit findings)
309 -
310 - ### Cleanup and resolution phase (2026-03-02)
311 -
312 - All actionable findings from the fifth-run cross-project audit resolved. Test count: 320 (was 315). 0 clippy warnings. 0 failures.
313 -
314 - ### Items resolved
315 -
316 - - **Stale `.env` PostgreSQL URL**: Changed `DATABASE_URL` from `postgres://localhost/balanced_breakfast` to `sqlite:balanced_breakfast.db?mode=rwc`. Matches `.env.example`.
317 - - File: `.env`
318 -
319 - - **Clippy warning**: Removed unused `OrderBy` import from integration test file.
320 - - File: `src-tauri/tests/command_integration.rs`
321 -
322 - - **Pagination unification**: Standardized `get_items()` and `get_all_items()` to use the same `page_size + 1` has_more detection via `PaginatedItems` struct. Removed `page_size * 2` with `MAX_ALL_ITEMS` pattern.
323 - - File: `crates/bb-feed/src/generator.rs`
324 -
325 - - **Repository doc comments**: Added `///` documentation to all 46+ public methods in `repository.rs` (83 doc comment markers total). Parameter semantics documented (e.g., `cutoff` in `delete_stale_read`).
326 - - File: `crates/bb-db/src/repository.rs`
327 -
328 - - **Background task extraction**: Extracted `any_feed_due()` pure function from `is_fetch_due()` for testability. 5 unit tests added alongside existing `is_single_feed_due` tests.
329 - - File: `src-tauri/src/state.rs`
330 -
331 - - **Search loading indicator**: Added CSS spinner next to search input. Spinner appears during search execution and hides on completion. Uses existing BB design system variables.
332 - - Files: `src-tauri/frontend/index.html`, `src-tauri/frontend/css/styles.css`, `src-tauri/frontend/js/app.js`
333 -
334 - ### Test count change
335 - - 315 → 320 passed (+5 tests: 5 any_feed_due unit tests)
336 -
337 - ### Grades changed
338 - - Code Quality: A- → A (clippy warning fixed)
339 - - Documentation: B+ → A- (repository doc comments added)
340 - - Frontend: B+ → A- (search loading indicator added)
341 - - Performance: A- (unchanged; pagination unified)
342 -
343 - ### Still open (4 items)
344 - - Add JS tests (state.js, search debounce, keyboard navigation)
345 - - Generator isolation tests (in-memory tag filtering without DB)
346 - - DB-level command integration tests (OPML import/export, item CRUD, tag operations)
347 - - Consider parallel plugin fetching for scale
348 -
349 - ## Changes Since Previous Audit (2026-03-02, type safety newtypes)
350 -
351 - ### Entity ID newtypes + ApiErrorCode enum
352 -
353 - `FeedId`, `ItemId`, `BusserStateId` UUID newtypes introduced via `define_uuid_id!` macro in `crates/bb-db/src/id_types.rs`. `BusserId(String)` newtype defined manually. `ApiErrorCode` enum replaces `ApiError.code: String`. All model structs, repository methods, commands, and generator updated.
354 -
355 - ### Grades changed
356 - - Type Safety: B+ → A (entity ID newtypes, ApiErrorCode enum)
@@ -1,417 +0,0 @@
1 - # Balanced Breakfast -- Competitive Analysis
2 -
3 - Last updated: 2026-02-27
4 -
5 - BB is a local-first Tauri 2 desktop feed aggregator with a Rhai plugin system for custom feed sources (RSS, Hacker News, arXiv, and anything scriptable), three-panel keyboard-driven reader, SQLite storage, OPML import/export.
6 -
7 - ## Positioning
8 -
9 - BB is the only native desktop feed reader with a user-scriptable plugin system for arbitrary sources. Competitors are either RSS-only or have fixed integrations. BB's competitive position is strongest against the cloud/AI-heavy readers (Feedly, Inoreader) by being the anti-surveillance, no-account alternative. Against the open-source self-hosted options (Miniflux, FreshRSS), BB differentiates with its desktop-native UX and Rhai plugin extensibility. Against the polished Apple-ecosystem readers (NetNewsWire, Reeder), BB differentiates with its custom source plugins (Hacker News, arXiv, anything scriptable) and hackability.
10 -
11 - ## Pricing Comparison
12 -
13 - | App | Free Tier | Paid | Model |
14 - |-----|-----------|------|-------|
15 - | **Balanced Breakfast** | **Unlimited** | **Free** | Source-available |
16 - | NetNewsWire | Unlimited | Free | Open source (MIT) |
17 - | Miniflux | Self-hosted | $15/yr hosted | Open source (Apache) |
18 - | NewsBlur | 64 sites | $36/yr | Open source (MIT) |
19 - | Feedly | 100 sources | $96-156/yr | Cloud SaaS |
20 - | Inoreader | 150 feeds | $60-120/yr | Cloud SaaS |
21 - | Reeder | 10 feeds | $10/yr | Proprietary |
22 - | Readwise Reader | None | $120-156/yr | Cloud SaaS |
23 - | Newsboat | Unlimited | Free | Open source (MIT) |
24 - | FreshRSS | Self-hosted | Free | Open source (AGPL-3.0) |
25 -
26 - ## Feature Matrix
27 -
28 - | Feature | BB | Feedly | Inoreader | NNW | Reeder | Miniflux | NewsBlur | Readwise | Newsboat | FreshRSS |
29 - |---------|:--:|:------:|:---------:|:---:|:------:|:--------:|:--------:|:--------:|:--------:|:--------:|
30 - | **Free (no limits)** | Yes | 100 feeds | 150 feeds | Yes | 10 feeds | Self-host | 64 sites | No | Yes | Yes |
31 - | **Source-available** | Yes | No | No | Yes | No | Yes | Yes | No | Yes | Yes |
32 - | **Native desktop** | Yes | No | No | Yes (Mac) | Yes (Mac) | No | No | No | Terminal | No |
33 - | **Plugin system** | Yes (Rhai) | No | No | No | No | No | No | No | Macros | Yes (PHP) |
34 - | **HN first-class** | Yes | No | No | No | No | No | No | No | No | No |
35 - | **arXiv first-class** | Yes | No | No | No | No | No | No | No | No | No |
36 - | **Local-only data** | Yes | No | No | Optional | Optional | Self-host | Self-host | No | Yes | Self-host |
37 - | **Cross-platform** | Mac+ | Web | Web | Apple | Apple | Web | Web | Web | Unix | Web |
38 - | **Mobile app** | No | Yes | Yes | Yes | Yes | No | Yes | Yes | No | Via API |
39 - | **Cloud sync** | No | Yes | Yes | Yes | Yes | Self-host | Yes | Yes | Via backends | Self-host |
40 - | **Full-text search** | Planned | Paid | Paid | Yes | No | Yes | Paid | Yes | No | Yes |
41 - | **AI features** | No | Yes (Leo) | Yes | No | No | No | Yes | Yes | No | No |
42 - | **Reader view** | Planned | No | No | Yes | No | Yes | No | Yes | No | Yes |
43 - | **Highlights** | No | No | No | No | No | No | No | Yes | No | No |
44 - | **Multi-source** | Via plugins | Partial | Yes | No | Yes | Partial | Partial | Partial | No | No |
45 - | **OPML** | Yes | Yes | Yes | Yes | No | Yes | Yes | No | Yes | Yes |
46 - | **JSON Feed** | Planned | No | No | Yes | No | Yes | No | No | No | No |
47 - | **Tracker stripping** | Planned | No | No | No | No | Yes | No | No | No | No |
48 - | **Keyboard shortcuts** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes |
49 - | **Folders/tags/categories** | No | Yes | Yes | Yes | No | Yes | No | No | Yes | Yes |
50 - | **Filter/query feeds** | No | Yes | Yes | No | Yes | No | No | No | Yes | No |
51 - | **WebSub (push)** | No | No | No | No | No | No | No | No | No | Yes |
52 - | **REST API** | No | Yes | Yes | No | No | Yes | No | No | No | Yes |
53 - | **Multi-user** | No | Yes | Yes | No | No | Yes | No | No | No | Yes |
54 -
55 - ## Competitor Deep Dives
56 -
57 - ### NetNewsWire
58 -
59 - Free, open-source native RSS reader for macOS and iOS (Swift).
60 -
61 - **Pricing:** Free (open-source, no paid tiers).
62 -
63 - BB differentiator: BB has plugins and cross-platform; NNW has Apple ecosystem maturity + iCloud sync.
64 -
65 - Features BB lacks:
66 - - iCloud sync across devices
67 - - Third-party sync backends (Feedbin, Feedly, BazQux, Inoreader, NewsBlur, The Old Reader, FreshRSS)
68 - - Multiple accounts (run several sync accounts simultaneously)
69 - - Reader view (strips ads/chrome from articles)
70 - - Smart feeds (Today, All Unread)
71 - - Folders / hierarchical organization
72 - - Custom article themes (swap CSS for the reading pane)
73 - - iOS / mobile app with home screen widgets
74 - - Share sheet integration
75 - - Background refresh (iOS)
76 - - JSON Feed and RSS-in-JSON support
77 -
78 - ### Reeder
79 -
80 - Polished content hub for RSS, podcasts, YouTube, Reddit, Mastodon, and Bluesky (macOS/iOS).
81 -
82 - **Pricing:** Free download; premium $1/month or $10/year. Reeder Classic: one-time $10 (Mac) / $5 (iOS).
83 -
84 - BB differentiator: Similar philosophy to BB's plugin approach, but Reeder builds integrations natively while BB delegates to user-writable scripts.
85 -
86 - Features BB lacks:
87 - - Podcast playback with cross-device resume
88 - - YouTube / video feed integration with in-app player
89 - - Mastodon and Bluesky feeds
90 - - Reddit feed integration
91 - - Read-later / save links (Links, Favorites, Bookmarks, Later)
92 - - iCloud sync (subscriptions, position, tags)
93 - - Custom filters by keyword, media type, feed type
94 - - Gesture-based navigation (touch-friendly UX)
95 - - Public tag feeds (export tags as JSON Feed)
96 - - Content-type-specific viewers (articles, photos, videos, social posts, podcasts)
97 - - Share extension
98 - - iOS / mobile app
99 -
100 - ### Feedly
101 -
102 - Cloud-based RSS reader with heavy AI features for individuals and enterprise intel teams.
103 -
104 - **Pricing:** Free (100 sources, 3 feeds, 3 boards) | Pro $6.99/mo | Pro+ $12.99/mo | Enterprise custom | Market/Threat Intel $1,600-$3,200/mo.
105 -
106 - Features BB lacks:
107 - - Cloud-hosted with web UI (no install required)
108 - - Leo AI -- topic/trend prioritization `[DATA-HUNGRY]`
109 - - Leo AI -- article summarization `[DATA-HUNGRY]`
110 - - Leo AI -- deduplication `[DATA-HUNGRY]`
111 - - AI Feeds (NLP-based topic feeds via natural language queries) `[DATA-HUNGRY]`
112 - - Mute filters (keyword suppression) `[BLOAT]`
113 - - Team Boards (collaborative article saving) `[BLOAT]`
114 - - Automated newsletters (email digest) `[BLOAT]`
115 - - Power Search (full-text across entire feed history, paid)
116 - - Saved searches / alerts (persistent keyword monitoring)
117 - - Slack / MS Teams integration `[BLOAT]`
118 - - Newsletter subscriptions
119 - - Mobile apps (iOS, Android)
120 - - Folders and tags
121 - - Notes and highlights `[BLOAT]`
122 - - HTTPS Feeds fetcher / RSS Builder (generate feeds from pages without RSS)
123 - - Threat Intelligence product `[BLOAT]`
124 - - Market Intelligence product `[BLOAT]`
125 - - Usage tracking and analytics `[DATA-HUNGRY]` `[INVASIVE]`
126 -
127 - ### Inoreader
128 -
129 - Cloud-based RSS reader with automation rules, web monitoring, and AI intelligence tools.
130 -
131 - **Pricing:** Free (150 feeds, ads) | Pro $7.50/mo (no ads, rules, active search) | Team 5 $25/mo | Team 10 $50/mo | Enterprise custom.
132 -
133 - Features BB lacks:
134 - - Cloud-hosted with web UI
135 - - Automation rules (auto-tag, star, email, broadcast)
136 - - Active Search / monitoring feeds (persistent keyword monitoring)
137 - - Web page monitoring (non-RSS) `[DATA-HUNGRY]`
138 - - AI article summaries and prompts `[DATA-HUNGRY]`
139 - - Intelligence reports (multi-article analysis) `[DATA-HUNGRY]`
140 - - Email digest / push notifications
141 - - Mobile apps (iOS, Android)
142 - - Folders, tags, and bundles
143 - - Save web pages for later
144 - - IFTTT / Zapier integration
145 - - Podcast support with AI transcripts `[DATA-HUNGRY]`
146 - - Social media feed support (YouTube, Twitter, Facebook)
147 - - Built-in article translation `[DATA-HUNGRY]`
148 - - Feed update frequency guarantee (1hr for Pro)
149 - - Ads in free tier `[INVASIVE]`
150 - - Usage analytics `[DATA-HUNGRY]` `[INVASIVE]`
151 -
152 - ### Miniflux
153 -
154 - Self-hosted minimalist RSS reader written in Go, backed by PostgreSQL.
155 -
156 - **Pricing:** Free (self-hosted, Apache 2.0) | Hosted $15/year.
157 -
158 - BB differentiator: BB is a native desktop app with plugins; Miniflux is a web app accessed in a browser with no extension system.
159 -
160 - Features BB lacks:
161 - - Web-based UI (accessible from any device)
162 - - Full-text search (Postgres-powered)
163 - - Pixel tracker removal
164 - - URL parameter stripping (utm_*, fbclid, etc.)
165 - - Podcast/video/image enclosure support
166 - - YouTube video playback in-app
167 - - 25+ third-party integrations (Instapaper, Pinboard, Wallabag, Telegram, Slack, etc.)
168 - - REST API (with Go and Python client libraries)
169 - - Multiple authentication methods (local, Passkeys/WebAuthn, OAuth2, OIDC, reverse-proxy)
170 - - Categories (flat organization)
171 - - 20 language translations
172 - - Multi-user support
173 - - Scraper / fetch original content
174 - - Automatic HTTPS (built-in Let's Encrypt)
175 - - JSON Feed support (1.0/1.1)
176 -
177 - ### Newsboat
178 -
179 - Terminal-based RSS/Atom reader for Unix systems (C++, MIT license).
180 -
181 - **Pricing:** Free (open-source, MIT).
182 -
183 - Features BB lacks:
184 - - Tags (non-hierarchical organization)
185 - - Query feeds (virtual feeds from filter expressions)
186 - - Killfiles (hide unwanted content by pattern)
187 - - Macro system (multi-command macros)
188 - - Bookmarking scripts (send articles to external services via custom scripts)
189 - - Filter language (title, author, content, date, etc.)
190 - - Podboat (companion podcast downloader)
191 - - External browser integration
192 - - Configurable keybindings (remap any key)
193 - - Colorscheme customization (full color config)
194 - - Third-party service sync (TTRSS, Inoreader, NewsBlur, Nextcloud News, etc.)
195 - - Conditional formatting (display rules based on article attributes)
196 - - Article pipe to external command
197 -
198 - ### FreshRSS
199 -
200 - Self-hosted, multi-user web-based RSS aggregator (PHP/MySQL or PostgreSQL).
201 -
202 - **Pricing:** Free (open-source, AGPL-3.0).
203 -
204 - Features BB lacks:
205 - - Web-based UI (accessible from any device/browser)
206 - - Multi-user with per-user customization
207 - - WebSub (instant push from WordPress, Medium, Friendica, etc.)
208 - - Extension/plugin system (PHP-based, official + community extensions)
209 - - Google Reader API and Fever API (compatible with dozens of third-party clients)
210 - - Labels / custom tags
211 - - Full-text search
212 - - Anonymous reading mode (public-facing read-only access)
213 - - Article resharing (HTML, RSS, OPML)
214 - - Mobile API support (use any compatible mobile RSS app as frontend)
215 - - Sorting by article length
216 - - Advanced search form (structured multi-field search)
217 - - Themes (multiple built-in + custom)
218 - - Scales to 1M+ articles and 50K+ feeds
219 - - Multiple authentication methods (web form, HTTP auth, OIDC)
220 - - Content scraping (fetch full articles from summary-only feeds)
221 -
222 - ## Common Missing Features
223 -
224 - Features that appear in 3+ competitors but BB currently lacks.
225 -
226 - ### Worth Adding
227 -
228 - - **Full-text search** -- Every competitor except Reeder and Newsboat has this. [Planned] via SQLite FTS5.
229 - - **Tags / categories for feed organization** -- NNW, Feedly, Inoreader, Miniflux, Newsboat, FreshRSS all have it. BB currently has no hierarchical or flat organization beyond sources. High-impact addition.
230 - - **Reader view / full-article fetch** -- NNW, Miniflux, FreshRSS, Inoreader all strip ads and fetch full content. [Planned] as a plugin.
231 - - **JSON Feed format support** -- NNW and Miniflux support it. Low effort, broadens feed compatibility. [Planned] in the core RSS parsing layer.
232 - - **URL tracker parameter stripping** -- Miniflux has it (utm_*, fbclid, etc.). Easy privacy win. [Planned].
233 -
234 - ### Consider
235 -
236 - - **Podcast / media enclosure support** -- Reeder, Inoreader, Miniflux, Newsboat (Podboat). Could be a plugin (Rhai script that handles enclosures). Don't build into core -- let the plugin system prove itself.
237 - - **Filter / query feeds (virtual feeds from rules)** -- Newsboat, Inoreader, Reeder, Feedly. Virtual feeds based on keyword/regex filters across all sources. Good Phase 5+ feature.
238 - - **Content scraping (full-article fetch from summary-only feeds)** -- Miniflux, FreshRSS, Inoreader. Heavier than reader view; consider as a plugin.
239 - - **Newsletter ingestion** -- Inoreader, NewsBlur, Readwise ingest newsletters via dedicated email address.
240 - - **Social feeds** -- Reeder pulls Reddit, Mastodon, Bluesky. Inoreader pulls YouTube, Facebook. Could be Rhai plugins.
241 - - **Configurable keybindings** -- Newsboat allows full keybinding remapping. Could be a theming/config feature.
242 -
243 - ### Skip
244 -
245 - - **Cloud/cross-device sync** -- Conflicts with BB's local-first, no-account philosophy. If sync is ever needed, consider optional file-based or peer-to-peer sync rather than cloud.
246 - - **Mobile app / mobile access** -- [Deferred] in BB's roadmap (Tauri mobile). Web access is antithetical to local-first. Revisit after desktop is mature.
247 - - **AI features (summaries, prioritization, topic detection)** -- Requires cloud, data collection, and ongoing cost. Antithetical to BB's local-first, privacy-focused identity. If users want AI, they can pipe content to local LLMs via a plugin.
248 - - **Third-party sync backends** -- BB is a standalone reader, not a client for hosted services. Adds complexity without serving BB's target users.
249 - - **REST API / programmatic access** -- BB is a desktop app, not a service. No need for an API unless multi-client access becomes a goal.
250 - - **Multi-user support** -- BB is a single-user desktop app. Multi-user is a server concern.
251 - - **Social features** -- NewsBlur's blurblogs and following. Against BB's local-first philosophy.
252 - - **Highlights/annotations** -- Readwise's core feature. Different product category (PKM).
253 - - **Rules/automation** -- Inoreader's auto-sort/auto-tag rules. Nice-to-have but low priority.
254 - - **RSS Builder** -- Feedly creates feeds from sites without RSS. Niche.
255 - - **Browser extension** -- Readwise, Inoreader have save-for-later extensions.
256 - - **Threat/Market Intelligence** -- Feedly enterprise products. Different product category entirely.
257 -
258 - ## What We Offer That Competitors Don't
259 -
260 - - **Rhai plugin system** -- User-scriptable source adapters. No other reader is extensible this way. NNW and Reeder have fixed integrations; Miniflux and NewsBlur have APIs but no plugin runtime. Plugins declare their own fetch intervals and config schemas, giving the app fine-grained control over behavior.
261 - - **Hacker News as a first-class source** -- Top, New, Best, Ask HN, Show HN, Jobs. No other reader treats HN as a native source.
262 - - **arXiv as a first-class source** -- Browse by category (cs.AI, cs.LG, cs.CL, etc.). Unique.
263 - - **Free with no limits** -- No feed caps, no feature gating, no premium tier. Only NNW, Newsboat, and Miniflux (self-hosted) match this.
264 - - **Cross-platform native** -- Tauri runs on macOS, Windows, Linux. NNW is Apple-only. Reeder is Apple-only. Miniflux/NewsBlur/FreshRSS are web-only.
265 - - **No account required** -- No signup, no email, no cloud. Start reading immediately.
266 - - **Planned community plugin store** -- A marketplace where users browse, install, and publish feed source plugins from within the app -- turning it into an extensible platform.
267 - - **Rust backend** -- High performance, low resource usage, memory safety. No Electron bloat.
268 -
269 - ## Key Dynamics
270 -
271 - Highest-priority gaps to close:
272 - 1. Tags / categories for feed organization
273 - 2. Full-text search [Planned]
274 - 3. Reader view / full-article fetch [Planned]
275 - 4. JSON Feed format support [Planned]
276 - 5. URL tracker parameter stripping [Planned]
277 - 6. Filter / query feeds (virtual feeds from rules)
278 -
279 - Strengths to double down on:
280 - 1. Rhai plugin system -- ship the Plugin Store (Phase 4)
281 - 2. Local-first, no-account, no-telemetry positioning
282 - 3. Keyboard-driven desktop-native UX
283 - 4. Custom non-RSS sources (HN, arXiv, and user-created)
284 -
285 - ## Target Users
286 -
287 - - Power users and developers who consume content from many sources (RSS, Hacker News, arXiv, etc.) and want a single unified reader
288 - - Users who want to extend their feed reader with custom sources via lightweight scripting (Rhai plugins)
289 - - Privacy-conscious users who prefer a local-first desktop app over cloud-based feed services
290 - - Technical professionals who value keyboard shortcuts, fast navigation, and hackability
291 -
292 - ## Full Feature Inventory
293 -
294 - ### Feed Management
295 -
296 - | Feature | Status |
297 - |---------|--------|
298 - | Add RSS feeds | Done |
299 - | Add Hacker News feed (plugin) | Done |
300 - | Add arXiv feed (plugin) | Done |
301 - | Delete feed / source | Done |
302 - | Background auto-fetch per plugin (configurable intervals, default 15min) | Done |
303 - | Per-source unread count | Done |
304 - | Feed health monitoring | Planned |
305 - | Stale item cleanup (auto-archive old read items) | Planned |
306 - | Validate feed config inputs | Planned |
307 -
308 - ### Content Display
309 -
310 - | Feature | Status |
311 - |---------|--------|
312 - | Three-panel layout (sources, items, detail) | Done |
313 - | Native menu bar (File, View, Edit, Help) | Done |
314 - | Keyboard shortcuts (j/k, s, r, /, Escape) | Done |
315 - | View modes: All / Unread / Starred | Done |
316 - | Star items | Done |
317 - | Mark items as read | Done |
318 -
319 - ### Plugin System
320 -
321 - | Feature | Status |
322 - |---------|--------|
323 - | Rhai scripting engine for plugins | Done |
324 - | Plugin bundling (dev-mode + production) | Done |
325 - | BusserCapabilities (fetch_interval_secs, config_schema) | Done |
326 - | Max operations limit (100K) | Done |
327 - | Plugin error handling (warn/debug logging, toast) | Done |
328 - | Plugin sandboxing / security model | Deferred |
329 -
330 - ### Import / Export
331 -
332 - | Feature | Status |
333 - |---------|--------|
334 - | OPML import (File menu + Cmd+I) | Done |
335 - | OPML export (File menu + Cmd+E) | Done |
336 - | Export plugin command (package .rhai + manifest) | Planned (Phase 4C) |
337 -
338 - ### Search and Filtering
339 -
340 - | Feature | Status |
341 - |---------|--------|
342 - | Client-side search filter (/ shortcut) | Done |
343 - | Full-text server-side search (SQLite FTS5) | Planned |
344 - | Advanced filtering (date ranges, tags, content type) | Deferred |
345 -
346 - ### Theming
347 -
348 - | Feature | Status |
349 - |---------|--------|
350 - | Breakfast theme CSS (default) | Done |
351 - | Standardized cross-app theming support | Planned |
352 -
353 - ### Security
354 -
355 - | Feature | Status |
356 - |---------|--------|
357 - | Rhai max operations limit | Done |
358 - | Plugin error isolation (warn + toast, no crash) | Done |
359 - | Encrypt plugin secrets at rest | Planned |
360 - | Plugin sandboxing (HTTP restrictions, URL allowlists) | Deferred |
361 -
362 - ### Plugin Store (Phase 4)
363 -
364 - | Feature | Status |
365 - |---------|--------|
366 - | Plugin registry backend (index file, manifests) | Planned (4A) |
367 - | CI validation pipeline for submissions | Planned (4A) |
368 - | Submission flow (PR-based or upload API) | Planned (4A) |
369 - | Store UI (browse, search, filter by tag) | Planned (4B) |
370 - | Plugin detail view (description, author, version, install count) | Planned (4B) |
371 - | One-click install | Planned (4B) |
372 - | Update detection and prompt | Planned (4B) |
373 - | Uninstall (remove file + clean up feeds/state) | Planned (4B) |
374 - | Publish from app (package + submit to registry) | Planned (4C) |
375 - | Author identity (GitHub linking or manifest field) | Planned (4C) |
376 - | Plugin review queue (manual/automated approval) | Planned (4D) |
377 - | Permission declarations in manifest | Planned (4D) |
378 - | User consent prompt on install | Planned (4D) |
379 - | Abuse reporting (flag button, maintainer notification) | Planned (4D) |
380 - | Plugin revocation (delist / force-uninstall) | Planned (4D) |
381 -
382 - ### Data and Storage
383 -
384 - | Feature | Status |
385 - |---------|--------|
386 - | SQLite local database | Done |
387 - | Database migrations (SQLite) | Done |
388 - | Busser state persistence | Done |
389 - | Read history / analytics | Deferred |
390 -
391 - ### Testing and Documentation
392 -
393 - | Feature | Status |
394 - |---------|--------|
395 - | bb-core plugin_manager unit tests (4 tests) | Done |
396 - | bb-db repository unit tests (25 tests) | Done |
397 - | bb-feed ordering/generator unit tests | Planned |
398 - | Tauri command integration tests | Planned |
399 - | README with setup instructions | Planned |
400 - | Plugin authoring guide | Planned |
401 -
402 - ### Platform and Distribution
403 -
404 - | Feature | Status |
405 - |---------|--------|
406 - | macOS desktop app (Tauri 2) | Done |
407 - | App icons (fried-egg AI-star parody logo) | Done |
408 - | macOS release binary for distribution | Planned |
409 - | Mobile support (iOS/Android via Tauri mobile) | Deferred |
410 -
411 - Platform details:
412 - - **Runtime:** Tauri 2 desktop app (Rust backend + vanilla JS frontend, no framework)
413 - - **Primary platform:** macOS (planned release binary for distribution)
414 - - **Potential platforms:** Windows, Linux (Tauri supports all three), iOS/Android (deferred, via Tauri mobile)
415 - - **Distribution:** Direct download binary (planned); potential listing on MakeNotWork creator platform
416 - - **Data storage:** Local SQLite database in the app's config directory
417 - - **Plugin distribution:** Bundled plugins ship with the app; planned in-app Plugin Store for community plugins
D docs/todo.md -127