Skip to main content

max / balanced_breakfast

Audit remediation: observability, adversarial fixes, JS tests, sync hardening Multi-round audit improvements: - Observability A: 61 #[instrument(skip_all)] annotations across all Tauri commands, orchestrator methods, sync service functions - Adversarial fixes: sync_disconnect actually clears session + DB state, OPML URL scheme validation, <base> in HTML sanitizer, escapeAttr hardened (all 5 special chars), parking_lot RwLock, TOCTOU fix in plugin loading - JS test expansion: 37 -> 55 tests. Added settings-sync state machine (8), sources rendering (5), items rendering (5) coverage - Security: Rhai HTTP safety (count limit, size cap, timeout, URL scheme), FTS sanitization hardening, circuit breaker, sync changelog retention cap - Code documentation: architecture.md, plugin_authoring.md, README, module docs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-14 03:35 UTC
Commit: d6f00bae5a07da0f4286ecb128365c5f29151f82
Parent: 1f74722
29 files changed, +1271 insertions, -122 deletions
M Cargo.lock +90 -7
@@ -88,6 +88,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
88 88 checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
89 89
90 90 [[package]]
91 + name = "ammonia"
92 + version = "4.1.2"
93 + source = "registry+https://github.com/rust-lang/crates.io-index"
94 + checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
95 + dependencies = [
96 + "cssparser 0.35.0",
97 + "html5ever 0.35.0",
98 + "maplit",
99 + "tendril",
100 + "url",
101 + ]
102 +
103 + [[package]]
91 104 name = "android_system_properties"
92 105 version = "0.1.5"
93 106 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -160,7 +173,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
160 173
161 174 [[package]]
162 175 name = "balanced-breakfast-desktop"
163 - version = "0.1.0"
176 + version = "0.2.1"
164 177 dependencies = [
165 178 "base64 0.22.1",
166 179 "bb-core",
@@ -168,6 +181,7 @@ dependencies = [
168 181 "bb-feed",
169 182 "bb-interface",
170 183 "chrono",
184 + "parking_lot",
171 185 "rand 0.8.5",
172 186 "roxmltree",
173 187 "serde",
@@ -207,15 +221,17 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
207 221
208 222 [[package]]
209 223 name = "bb-core"
210 - version = "0.1.0"
224 + version = "0.2.1"
211 225 dependencies = [
212 226 "aes-gcm",
227 + "ammonia",
213 228 "base64 0.22.1",
214 229 "bb-db",
215 230 "bb-feed",
216 231 "bb-interface",
217 232 "chrono",
218 233 "html2text",
234 + "parking_lot",
219 235 "rand 0.8.5",
220 236 "readable-readability",
221 237 "regex",
@@ -233,7 +249,7 @@ dependencies = [
233 249
234 250 [[package]]
235 251 name = "bb-db"
236 - version = "0.1.0"
252 + version = "0.2.1"
237 253 dependencies = [
238 254 "bb-interface",
239 255 "chrono",
@@ -248,7 +264,7 @@ dependencies = [
248 264
249 265 [[package]]
250 266 name = "bb-feed"
251 - version = "0.1.0"
267 + version = "0.2.1"
252 268 dependencies = [
253 269 "bb-db",
254 270 "bb-interface",
@@ -263,7 +279,7 @@ dependencies = [
263 279
264 280 [[package]]
265 281 name = "bb-interface"
266 - version = "0.1.0"
282 + version = "0.2.1"
267 283 dependencies = [
268 284 "chrono",
269 285 "serde",
@@ -738,6 +754,19 @@ dependencies = [
738 754 ]
739 755
740 756 [[package]]
757 + name = "cssparser"
758 + version = "0.35.0"
759 + source = "registry+https://github.com/rust-lang/crates.io-index"
760 + checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
761 + dependencies = [
762 + "cssparser-macros",
763 + "dtoa-short",
764 + "itoa 1.0.17",
765 + "phf 0.11.3",
766 + "smallvec",
767 + ]
768 +
769 + [[package]]
741 770 name = "cssparser-macros"
742 771 version = "0.6.1"
743 772 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1714,7 +1743,18 @@ dependencies = [
1714 1743 "log",
1715 1744 "mac",
1716 1745 "markup5ever 0.14.1",
1717 - "match_token",
1746 + "match_token 0.1.0",
1747 + ]
1748 +
1749 + [[package]]
1750 + name = "html5ever"
1751 + version = "0.35.0"
1752 + source = "registry+https://github.com/rust-lang/crates.io-index"
1753 + checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
1754 + dependencies = [
1755 + "log",
1756 + "markup5ever 0.35.0",
1757 + "match_token 0.35.0",
1718 1758 ]
1719 1759
1720 1760 [[package]]
@@ -2298,6 +2338,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
2298 2338 checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
2299 2339
2300 2340 [[package]]
2341 + name = "maplit"
2342 + version = "1.0.2"
2343 + source = "registry+https://github.com/rust-lang/crates.io-index"
2344 + checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
2345 +
2346 + [[package]]
2301 2347 name = "markup5ever"
2302 2348 version = "0.10.1"
2303 2349 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2340,6 +2386,17 @@ dependencies = [
2340 2386 ]
2341 2387
2342 2388 [[package]]
2389 + name = "markup5ever"
2390 + version = "0.35.0"
2391 + source = "registry+https://github.com/rust-lang/crates.io-index"
2392 + checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
2393 + dependencies = [
2394 + "log",
2395 + "tendril",
2396 + "web_atoms",
2397 + ]
2398 +
2399 + [[package]]
2343 2400 name = "match_token"
2344 2401 version = "0.1.0"
2345 2402 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2351,6 +2408,17 @@ dependencies = [
2351 2408 ]
2352 2409
2353 2410 [[package]]
2411 + name = "match_token"
2412 + version = "0.35.0"
2413 + source = "registry+https://github.com/rust-lang/crates.io-index"
2414 + checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
2415 + dependencies = [
2416 + "proc-macro2",
2417 + "quote",
2418 + "syn 2.0.114",
2419 + ]
2420 +
2421 + [[package]]
2354 2422 name = "matchers"
2355 2423 version = "0.2.0"
2356 2424 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4599,13 +4667,15 @@ dependencies = [
4599 4667
4600 4668 [[package]]
4601 4669 name = "synckit-client"
4602 - version = "0.2.0"
4670 + version = "0.2.1"
4603 4671 dependencies = [
4604 4672 "argon2",
4605 4673 "base64 0.22.1",
4674 + "bytes",
4606 4675 "chacha20poly1305",
4607 4676 "chrono",
4608 4677 "keyring",
4678 + "parking_lot",
4609 4679 "rand 0.8.5",
4610 4680 "reqwest 0.12.28",
4611 4681 "serde",
@@ -4614,6 +4684,7 @@ dependencies = [
4614 4684 "thiserror 1.0.69",
4615 4685 "tokio",
4616 4686 "tracing",
4687 + "unicode-normalization",
4617 4688 "urlencoding",
4618 4689 "uuid",
4619 4690 ]
@@ -5846,6 +5917,18 @@ dependencies = [
5846 5917 ]
5847 5918
5848 5919 [[package]]
5920 + name = "web_atoms"
5921 + version = "0.1.3"
5922 + source = "registry+https://github.com/rust-lang/crates.io-index"
5923 + checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
5924 + dependencies = [
5925 + "phf 0.11.3",
5926 + "phf_codegen 0.11.3",
5927 + "string_cache",
5928 + "string_cache_codegen",
5929 + ]
5930 +
5931 + [[package]]
5849 5932 name = "webkit2gtk"
5850 5933 version = "2.0.2"
5851 5934 source = "registry+https://github.com/rust-lang/crates.io-index"
M Cargo.toml +3
@@ -35,6 +35,9 @@ aes-gcm = "0.10"
35 35 base64 = "0.22"
36 36 rand = "0.8"
37 37
38 + # Concurrency
39 + parking_lot = "0.12"
40 +
38 41 # Utilities
39 42 chrono = { version = "0.4.43", features = ["serde"] }
40 43 uuid = { version = "1.20.0", features = ["v4", "serde"] }
M README.md +11 -1
@@ -54,9 +54,19 @@ Plugins ("bussers") are `.rhai` script files. Drop one into the plugins director
54 54
55 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).
56 56
57 + ## Features
58 +
59 + - **Unified timeline** -- RSS, Hacker News, arXiv, and custom sources merged into one feed
60 + - **Plugin system** -- Rhai scripting for extensible feed fetching (write a plugin for any source)
61 + - **Reader view** -- clean article rendering with HTML sanitization
62 + - **Search** -- FTS5 full-text search across all items
63 + - **Organization** -- tags, starred items, read/unread tracking, query feeds (saved filters)
64 + - **Cloud sync** -- SyncKit integration with E2E encryption (feeds, tags, read state, preferences)
65 + - **Themes** -- light and dark themes, system auto-detection
66 +
57 67 ## Bundled Plugins
58 68
59 - Three plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers).
69 + Four plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers), **reader.rhai** (web page reader view).
60 70
61 71 ## License
62 72
@@ -21,6 +21,9 @@ aes-gcm = { workspace = true }
21 21 base64 = { workspace = true }
22 22 rand = { workspace = true }
23 23
24 + # Lock primitives (no poisoning)
25 + parking_lot.workspace = true
26 +
24 27 # Rhai scripting engine for plugins
25 28 rhai = { version = "1.24.0", features = ["sync", "serde"] }
26 29
@@ -39,5 +42,8 @@ url = "2"
39 42 # Regex for HTML URL rewriting
40 43 regex = "1"
41 44
45 + # HTML sanitization for untrusted feed content
46 + ammonia = "4"
47 +
42 48 # Article extraction (reader view)
43 49 readable-readability = "0.4"
@@ -10,19 +10,23 @@ use bb_db::Database;
10 10 use bb_interface::{BusserConfig, ConfigFieldType};
11 11 use thiserror::Error;
12 12 use tokio::sync::RwLock;
13 - use tracing::{debug, error, info};
13 + use tracing::{debug, error, info, instrument};
14 14
15 15 use crate::url_cleaner;
16 16 use crate::PluginManager;
17 17
18 18 #[derive(Error, Debug)]
19 19 pub enum OrchestratorError {
20 + /// A SQLite query or connection failed.
20 21 #[error("Database error: {0}")]
21 22 Database(#[from] sqlx::Error),
23 + /// A plugin operation (load, init, fetch) failed. Wraps [`PluginError`](crate::PluginError).
22 24 #[error("Plugin error: {0}")]
23 25 Plugin(#[from] crate::PluginError),
26 + /// Feed generation or ordering failed. Wraps [`FeedError`](bb_feed::FeedError).
24 27 #[error("Feed error: {0}")]
25 28 Feed(#[from] bb_feed::FeedError),
29 + /// Invalid or missing configuration (e.g. migration failure, bad plugin config).
26 30 #[error("Configuration error: {0}")]
27 31 Config(String),
28 32 }
@@ -58,6 +62,7 @@ pub struct Orchestrator {
58 62
59 63 impl Orchestrator {
60 64 /// Create a new orchestrator
65 + #[instrument(skip_all)]
61 66 pub async fn new(config: OrchestratorConfig) -> Result<Self, OrchestratorError> {
62 67 info!("Initializing orchestrator");
63 68
@@ -76,6 +81,7 @@ impl Orchestrator {
76 81 }
77 82
78 83 /// Run database migrations
84 + #[instrument(skip_all)]
79 85 pub async fn migrate(&self) -> Result<(), OrchestratorError> {
80 86 self.db.migrate().await.map_err(|e| {
81 87 OrchestratorError::Config(format!("Migration failed: {}", e))
@@ -85,6 +91,7 @@ impl Orchestrator {
85 91 }
86 92
87 93 /// Load all plugins
94 + #[instrument(skip_all)]
88 95 pub async fn load_plugins(&self) -> Result<Vec<String>, OrchestratorError> {
89 96 let mut plugins = self.plugins.write().await;
90 97 let loaded = plugins.load_all()?;
@@ -93,6 +100,7 @@ impl Orchestrator {
93 100 }
94 101
95 102 /// Initialize a plugin with config from database
103 + #[instrument(skip_all)]
96 104 pub async fn init_plugin_from_db(
97 105 &self,
98 106 plugin_id: &str,
@@ -162,10 +170,12 @@ impl Orchestrator {
162 170 Ok(())
163 171 }
164 172
165 - /// Fetch items from a specific plugin.
173 + /// Fetch items from a specific plugin and store them in the database.
166 174 ///
167 - /// Returns `(items_count, circuit_breaker_tripped)`. The second value is
168 - /// `true` when this fetch failure caused the circuit breaker to trip.
175 + /// Returns the number of items successfully stored. On fetch failure the
176 + /// error is recorded against the feed (and may trip the circuit breaker)
177 + /// before the error is propagated.
178 + #[instrument(skip_all)]
169 179 pub async fn fetch_plugin(
170 180 &self,
171 181 plugin_id: &str,
@@ -220,6 +230,12 @@ impl Orchestrator {
220 230 create_item.body = Some(url_cleaner::strip_tracking_from_html(body));
221 231 }
222 232
233 + // Sanitize HTML in body to strip scripts, event handlers, and
234 + // other dangerous markup from untrusted feed/plugin content.
235 + if let Some(ref body) = create_item.body {
236 + create_item.body = Some(ammonia::clean(body));
237 + }
238 +
223 239 match self.db.items().upsert(create_item).await {
224 240 Ok(_) => count += 1,
225 241 Err(e) => {
@@ -236,6 +252,7 @@ impl Orchestrator {
236 252 }
237 253
238 254 /// Check whether a plugin's feed is circuit-broken.
255 + #[instrument(skip_all)]
239 256 pub async fn is_circuit_broken(&self, plugin_id: &str) -> Result<bool, OrchestratorError> {
240 257 let feeds = self.db.feeds().get_by_busser(plugin_id).await?;
241 258 Ok(feeds.first().is_some_and(|f| f.circuit_broken))
@@ -245,6 +262,7 @@ impl Orchestrator {
245 262 ///
246 263 /// Clears `circuit_broken`, resets `consecutive_failures` to 0, and clears
247 264 /// `last_error`. Returns the item count from the fetch attempt.
265 + #[instrument(skip_all)]
248 266 pub async fn reset_circuit_breaker_and_fetch(
249 267 &self,
250 268 plugin_id: &str,
@@ -260,6 +278,7 @@ impl Orchestrator {
260 278 }
261 279
262 280 /// Fetch from all active plugins
281 + #[instrument(skip_all)]
263 282 pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> {
264 283 let plugin_ids = {
265 284 let plugins = self.plugins.read().await;
@@ -290,6 +309,7 @@ impl Orchestrator {
290 309 }
291 310
292 311 /// Get a plugin's preferred fetch interval in seconds.
312 + #[instrument(skip_all)]
293 313 pub async fn fetch_interval_secs(&self, plugin_id: &str) -> u64 {
294 314 let plugins = self.plugins.read().await;
295 315 plugins
@@ -310,6 +330,7 @@ impl Orchestrator {
310 330
311 331 /// Encrypt any plaintext Secret fields in existing feeds.
312 332 /// Called once after plugin load to migrate legacy configs.
333 + #[instrument(skip_all)]
313 334 pub async fn encrypt_existing_secrets(&self) -> Result<(), OrchestratorError> {
314 335 let Some(key) = self.encryption_key.as_ref() else {
315 336 return Ok(());
@@ -352,6 +373,7 @@ impl Orchestrator {
352 373 }
353 374
354 375 /// Graceful shutdown
376 + #[instrument(skip_all)]
355 377 pub async fn shutdown(&self) {
356 378 info!("Shutting down orchestrator");
357 379 let plugins = self.plugins.write().await;
@@ -4,7 +4,8 @@
4 4
5 5 use std::collections::HashMap;
6 6 use std::path::{Path, PathBuf};
7 - use std::sync::RwLock;
7 +
8 + use parking_lot::RwLock;
8 9
9 10 use bb_interface::{BusserCapabilities, BusserConfig, ConfigSchema, FetchResult};
10 11 use thiserror::Error;
@@ -14,22 +15,27 @@ use crate::rhai_plugin::{RhaiPluginError, RhaiPluginManager};
14 15
15 16 #[derive(Error, Debug)]
16 17 pub enum PluginError {
18 + /// A `.rhai` script could not be read from disk or compiled by the engine.
17 19 #[error("Failed to load plugin: {0}")]
18 20 LoadError(String),
21 + /// No loaded plugin matches the requested plugin ID.
19 22 #[error("Plugin not found: {0}")]
20 23 NotFound(String),
24 + /// Plugin exists but `initialize_plugin()` failed (e.g. bad config).
21 25 #[error("Plugin initialization failed: {0}")]
22 26 InitError(String),
27 + /// The plugin's `fetch()` function returned an error at runtime.
23 28 #[error("Plugin fetch failed: {0}")]
24 29 FetchError(String),
30 + /// Attempted to load a plugin whose ID is already registered.
25 31 #[error("Plugin already loaded: {0}")]
26 32 AlreadyLoaded(String),
33 + /// Filesystem I/O error (e.g. plugins directory unreadable).
27 34 #[error("IO error: {0}")]
28 35 IoError(#[from] std::io::Error),
36 + /// Wraps a lower-level [`RhaiPluginError`] from the Rhai runtime.
29 37 #[error("Rhai error: {0}")]
30 38 RhaiError(#[from] RhaiPluginError),
31 - #[error("Lock poisoned: {0}")]
32 - LockPoisoned(String),
33 39 }
34 40
35 41 /// Manages Rhai plugin loading and lifecycle
@@ -124,10 +130,7 @@ impl PluginManager {
124 130 }
125 131
126 132 // Store config for later use in fetch
127 - let mut configs = self
128 - .plugin_configs
129 - .write()
130 - .map_err(|e| PluginError::LockPoisoned(e.to_string()))?;
133 + let mut configs = self.plugin_configs.write();
131 134 configs.insert(plugin_id.to_string(), config);
132 135
133 136 info!(%plugin_id, "Initialized plugin");
@@ -145,10 +148,7 @@ impl PluginManager {
145 148 .get(plugin_id)
146 149 .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?;
147 150
148 - let configs = self
149 - .plugin_configs
150 - .read()
151 - .map_err(|e| PluginError::LockPoisoned(e.to_string()))?;
151 + let configs = self.plugin_configs.read();
152 152 let config = configs
153 153 .get(plugin_id)
154 154 .ok_or_else(|| PluginError::InitError("Plugin not initialized".to_string()))?;
@@ -257,10 +257,33 @@ fn parse_rss_item(node: &roxmltree::Node) -> Dynamic {
257 257 }
258 258 }
259 259
260 - // If no guid, use link as id
260 + // If no guid, use link as id; if no link either, synthesize from title + pubDate hash
261 261 if !item.contains_key("id") {
262 262 if let Some(link) = item.get("link") {
263 263 item.insert("id".into(), link.clone());
264 + } else {
265 + // Synthesize a deterministic ID from available fields so the same
266 + // item always gets the same ID across fetches.
267 + let title = item
268 + .get("title")
269 + .and_then(|v| v.clone().try_cast::<String>())
270 + .unwrap_or_default();
271 + let summary = item
272 + .get("summary")
273 + .and_then(|v| v.clone().try_cast::<String>())
274 + .unwrap_or_default();
275 + let published = item
276 + .get("published")
277 + .map(|v| v.to_string())
278 + .unwrap_or_default();
279 +
280 + use std::hash::{Hash, Hasher};
281 + let mut hasher = std::collections::hash_map::DefaultHasher::new();
282 + title.hash(&mut hasher);
283 + summary.hash(&mut hasher);
284 + published.hash(&mut hasher);
285 + let hash = hasher.finish();
286 + item.insert("id".into(), Dynamic::from(format!("synth-{:016x}", hash)));
264 287 }
265 288 }
266 289
@@ -848,6 +871,113 @@ mod tests {
848 871 );
849 872 }
850 873
874 + // --- RSS items without guid or link get synthesized ID ---
875 +
876 + #[test]
877 + fn rss_item_no_guid_no_link_gets_synthesized_id() {
878 + let xml = r#"<?xml version="1.0"?>
879 + <rss version="2.0">
880 + <channel>
881 + <title>Feed</title>
882 + <link>https://example.com</link>
883 + <item>
884 + <title>Orphan Post</title>
885 + <description>No guid or link</description>
886 + </item>
887 + </channel>
888 + </rss>"#;
889 +
890 + let result = parse_feed_xml(xml).unwrap();
891 + let map = result.try_cast::<Map>().unwrap();
892 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
893 + let item = entries[0].clone().try_cast::<Map>().unwrap();
894 +
895 + let id = item.get("id").unwrap().clone().try_cast::<String>().unwrap();
896 + assert!(id.starts_with("synth-"), "synthesized ID should start with 'synth-', got: {}", id);
897 + }
898 +
899 + #[test]
900 + fn rss_items_no_guid_no_link_different_titles_get_different_ids() {
901 + let xml = r#"<?xml version="1.0"?>
902 + <rss version="2.0">
903 + <channel>
904 + <title>Feed</title>
905 + <link>https://example.com</link>
906 + <item>
907 + <title>Post A</title>
908 + <description>First item</description>
909 + </item>
910 + <item>
911 + <title>Post B</title>
912 + <description>Second item</description>
913 + </item>
914 + </channel>
915 + </rss>"#;
916 +
917 + let result = parse_feed_xml(xml).unwrap();
918 + let map = result.try_cast::<Map>().unwrap();
919 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
920 +
921 + let id_a = entries[0].clone().try_cast::<Map>().unwrap()
922 + .get("id").unwrap().clone().try_cast::<String>().unwrap();
923 + let id_b = entries[1].clone().try_cast::<Map>().unwrap()
924 + .get("id").unwrap().clone().try_cast::<String>().unwrap();
925 +
926 + assert_ne!(id_a, id_b, "different items should get different synthesized IDs");
927 + }
928 +
929 + #[test]
930 + fn rss_item_with_guid_uses_guid() {
931 + let xml = r#"<?xml version="1.0"?>
932 + <rss version="2.0">
933 + <channel>
934 + <title>Feed</title>
935 + <link>https://example.com</link>
936 + <item>
937 + <guid>my-guid-123</guid>
938 + <title>Guided Post</title>
939 + </item>
940 + </channel>
941 + </rss>"#;
942 +
943 + let result = parse_feed_xml(xml).unwrap();
944 + let map = result.try_cast::<Map>().unwrap();
945 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
946 + let item = entries[0].clone().try_cast::<Map>().unwrap();
947 +
948 + assert_eq!(
949 + item.get("id").unwrap().clone().try_cast::<String>().unwrap(),
950 + "my-guid-123"
951 + );
952 + }
953 +
954 + #[test]
955 + fn rss_item_synthesized_id_is_deterministic() {
956 + let xml = r#"<?xml version="1.0"?>
957 + <rss version="2.0">
958 + <channel>
959 + <title>Feed</title>
960 + <link>https://example.com</link>
961 + <item>
962 + <title>Stable Post</title>
963 + <description>Same content</description>
964 + </item>
965 + </channel>
966 + </rss>"#;
967 +
968 + let result1 = parse_feed_xml(xml).unwrap();
969 + let result2 = parse_feed_xml(xml).unwrap();
970 +
971 + let get_id = |result: Dynamic| -> String {
972 + let map = result.try_cast::<Map>().unwrap();
973 + let entries = map.get("entries").unwrap().clone().try_cast::<rhai::Array>().unwrap();
974 + let item = entries[0].clone().try_cast::<Map>().unwrap();
975 + item.get("id").unwrap().clone().try_cast::<String>().unwrap()
976 + };
977 +
978 + assert_eq!(get_id(result1), get_id(result2), "same content should produce same ID");
979 + }
980 +
851 981 // --- parse_xml_to_dynamic ---
852 982
853 983 #[test]
@@ -179,10 +179,11 @@ pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<
179 179 html2text::from_read(html.as_bytes(), 80)
180 180 });
181 181
182 - // Truncate text with ellipsis
182 + // Truncate text with ellipsis (character-count aware for multibyte UTF-8)
183 183 engine.register_fn("truncate", |text: &str, max_len: i64| -> String {
184 184 let max = max_len as usize;
185 - if text.len() <= max {
185 + let char_count = text.chars().count();
186 + if char_count <= max {
186 187 text.to_string()
187 188 } else if max <= 3 {
188 189 text.chars().take(max).collect()
@@ -276,7 +277,8 @@ mod tests {
276 277
277 278 /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing).
278 279 fn truncate_text(text: &str, max: usize) -> String {
279 - if text.len() <= max {
280 + let char_count = text.chars().count();
281 + if char_count <= max {
280 282 text.to_string()
281 283 } else if max <= 3 {
282 284 text.chars().take(max).collect()
@@ -342,6 +344,46 @@ mod tests {
342 344 assert_eq!(truncate_text("hello", 2), "he");
343 345 }
344 346
347 + #[test]
348 + fn truncate_cjk_within_max() {
349 + // 5 CJK characters, each 3 bytes UTF-8; max_len=10 chars
350 + // Old bug: text.len() == 15 bytes > 10, would truncate incorrectly
351 + let cjk = "\u{4e16}\u{754c}\u{4f60}\u{597d}\u{5417}"; // 世界你好吗
352 + assert_eq!(cjk.chars().count(), 5);
353 + assert_eq!(truncate_text(cjk, 10), cjk);
354 + }
355 +
356 + #[test]
357 + fn truncate_cjk_over_max() {
358 + let cjk = "\u{4e16}\u{754c}\u{4f60}\u{597d}\u{5417}"; // 世界你好吗, 5 chars
359 + // max=3, so no ellipsis (max <= 3 path)
360 + let result = truncate_text(cjk, 3);
361 + assert_eq!(result, "\u{4e16}\u{754c}\u{4f60}"); // 世界你
362 + }
363 +
364 + #[test]
365 + fn truncate_emoji_within_max() {
366 + // Emoji: each is 4 bytes UTF-8 but 1 char
367 + let emoji = "\u{1f600}\u{1f601}\u{1f602}"; // 3 emoji chars
368 + assert_eq!(emoji.chars().count(), 3);
369 + assert_eq!(truncate_text(emoji, 5), emoji);
370 + }
371 +
372 + #[test]
373 + fn truncate_emoji_over_max_with_ellipsis() {
374 + // 5 emoji chars, max=4, so truncate to 1 char + "..."
375 + let emoji = "\u{1f600}\u{1f601}\u{1f602}\u{1f603}\u{1f604}";
376 + assert_eq!(emoji.chars().count(), 5);
377 + let result = truncate_text(emoji, 4);
378 + assert_eq!(result, "\u{1f600}...");
379 + }
380 +
381 + #[test]
382 + fn truncate_ascii_still_works() {
383 + assert_eq!(truncate_text("hello world", 8), "hello...");
384 + assert_eq!(truncate_text("short", 10), "short");
385 + }
386 +
345 387 // ── parse_int tests ─────────────────────────────────────────
346 388
347 389 #[test]
@@ -28,29 +28,52 @@ use host_functions::register_host_functions;
28 28
29 29 #[derive(Error, Debug)]
30 30 pub enum RhaiPluginError {
31 + /// The `.rhai` script has syntax errors and could not be compiled.
31 32 #[error("Script compilation failed: {0}")]
32 33 CompileError(String),
34 + /// A compiled script raised an error during execution (e.g. nil access,
35 + /// operation limit exceeded).
33 36 #[error("Script execution failed: {0}")]
34 37 RuntimeError(String),
38 + /// The script does not define a required entry-point function (`id`,
39 + /// `name`, `config_schema`, or `fetch`).
35 40 #[error("Missing required function: {0}")]
36 41 MissingFunction(String),
42 + /// A script function returned a value that could not be converted to the
43 + /// expected Rust type (e.g. `fetch()` returned a string instead of a map).
37 44 #[error("Invalid return type from function {0}: {1}")]
38 45 InvalidReturnType(String, String),
46 + /// An HTTP request made by a host function (`http_get`, `http_post`)
47 + /// failed (timeout, network error, or blocked URL).
39 48 #[error("HTTP request failed: {0}")]
40 49 HttpError(String),
50 + /// XML parsing failed when processing an RSS/Atom feed response.
41 51 #[error("XML parse error: {0}")]
42 52 XmlError(String),
53 + /// JSON parsing failed when processing an API response.
43 54 #[error("JSON parse error: {0}")]
44 55 JsonError(String),
45 56 }
46 57
47 58 /// A compiled Rhai plugin.
59 + ///
60 + /// Each plugin is a single `.rhai` script that implements the busser
61 + /// interface (`id`, `name`, `config_schema`, `fetch`). The script is
62 + /// compiled once at load time; the AST and engine are reused for every
63 + /// fetch call.
48 64 pub struct RhaiPlugin {
65 + /// Unique identifier returned by the script's `id()` function.
49 66 pub id: String,
67 + /// Human-readable name returned by the script's `name()` function.
50 68 pub name: String,
69 + /// Filesystem path to the `.rhai` source file.
51 70 pub path: std::path::PathBuf,
71 + /// Pre-compiled AST of the plugin script, reused across calls.
52 72 ast: AST,
73 + /// Shared Rhai engine with host functions and safety limits registered.
53 74 engine: Arc<Engine>,
75 + /// Per-fetch HTTP request counter, reset to 0 before each `fetch()` call
76 + /// and checked by host functions to enforce the 100-request-per-fetch limit.
54 77 request_counter: Arc<AtomicUsize>,
55 78 }
56 79
@@ -224,8 +247,12 @@ pub fn create_engine() -> Engine {
224 247 /// Result from extracting article content via the reader plugin.
225 248 #[derive(Debug, Clone)]
226 249 pub struct ReaderResult {
250 + /// The extracted article title.
227 251 pub title: String,
252 + /// Cleaned article body as HTML, suitable for rendering in a web view.
228 253 pub content: String,
254 + /// Plain text version of the article body with all HTML tags stripped.
255 + /// Useful for search indexing, summaries, and text-only display.
229 256 pub text_content: String,
230 257 }
231 258
@@ -24,7 +24,7 @@ impl Database {
24 24 /// Create a new database connection
25 25 pub async fn connect(database_url: &str) -> Result<Self, sqlx::Error> {
26 26 let pool = SqlitePoolOptions::new()
27 - .max_connections(5)
27 + .max_connections(16)
28 28 .connect(database_url)
29 29 .await?;
30 30
@@ -24,13 +24,15 @@ fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, conte
24 24 }
25 25
26 26 /// Parse a timestamp string stored in SQLite. Tries RFC 3339 first, then the
27 - /// SQLite format. Returns `Utc::now()` as a last resort.
27 + /// SQLite format. Returns `DateTime::UNIX_EPOCH` as a last resort so that
28 + /// unparseable items sort to the bottom of chronological feeds rather than
29 + /// appearing as the newest item.
28 30 pub fn parse_timestamp(s: &str) -> DateTime<Utc> {
29 31 s.parse::<DateTime<Utc>>()
30 32 .or_else(|_| {
31 33 chrono::NaiveDateTime::parse_from_str(s, TIMESTAMP_FMT).map(|ndt| ndt.and_utc())
32 34 })
33 - .unwrap_or_else(|_| Utc::now())
35 + .unwrap_or(DateTime::UNIX_EPOCH)
34 36 }
35 37
36 38 /// Registered feed/busser source stored in the `feeds` table.
@@ -280,10 +282,42 @@ impl CreateFeedItem {
280 282 }
281 283
282 284 /// A single condition in a query feed's rules array.
285 + ///
286 + /// Conditions are combined with AND logic: all conditions must match for an
287 + /// item to be included. Simple conditions (source, starred, unread, tag) are
288 + /// pushed into fast-path SQL queries; complex conditions (title, author, body)
289 + /// are evaluated in-memory.
283 290 #[derive(Debug, Clone, Serialize, Deserialize)]
284 291 pub struct QueryCondition {
292 + /// The item field to match against.
293 + ///
294 + /// Valid values:
295 + /// - `"title"` — full article/post title (in-memory filter)
296 + /// - `"author"` — bite display author (in-memory filter)
297 + /// - `"body"` — full body/content text (in-memory filter)
298 + /// - `"source"` — busser source ID (fast-path SQL)
299 + /// - `"tag"` — item-level tag (fast-path SQL)
300 + /// - `"starred"` — starred boolean state (fast-path SQL)
301 + /// - `"unread"` — unread boolean state (fast-path SQL)
285 302 pub field: String,
303 + /// The comparison operator to apply.
304 + ///
305 + /// For text fields (title, author, body):
306 + /// - `"contains"` — case-insensitive substring match
307 + /// - `"not_contains"` — negated case-insensitive substring match
308 + /// - `"equals"` — case-insensitive exact match
309 + /// - `"matches_regex"` — Rust `regex` crate pattern match
310 + ///
311 + /// For fast-path fields:
312 + /// - `"is"` — boolean check, used with starred/unread
313 + /// - `"equals"` — exact match, used with source/tag
286 314 pub operator: String,
315 + /// The comparison value.
316 + ///
317 + /// For boolean operators (`"is"`), use `"true"` or `"false"`.
318 + /// For text operators, the value is matched case-insensitively against the
319 + /// field content (substring for contains, exact for equals, regex pattern
320 + /// for matches_regex).
287 321 pub value: String,
288 322 }
289 323
@@ -311,7 +345,10 @@ impl DbQueryFeed {
311 345 /// Input for creating a new query feed.
312 346 #[derive(Debug, Clone, Serialize, Deserialize)]
313 347 pub struct CreateQueryFeed {
348 + /// Human-readable name for this query feed, displayed in the feed list.
314 349 pub name: String,
350 + /// Filter rules applied with AND logic. See [`QueryCondition`] for valid
351 + /// field/operator/value combinations.
315 352 pub rules: Vec<QueryCondition>,
316 353 }
317 354
@@ -336,11 +373,30 @@ mod tests {
336 373 }
337 374
338 375 #[test]
339 - fn parse_timestamp_garbage_returns_now() {
376 + fn parse_timestamp_garbage_returns_epoch() {
340 377 let dt = parse_timestamp("not a date");
341 - let now = Utc::now();
342 - // Should be within a few seconds of now
343 - assert!((now - dt).num_seconds().abs() < 5);
378 + assert_eq!(dt, DateTime::UNIX_EPOCH);
379 + }
380 +
381 + #[test]
382 + fn parse_timestamp_empty_returns_epoch() {
383 + let dt = parse_timestamp("");
384 + assert_eq!(dt, DateTime::UNIX_EPOCH);
385 + }
386 +
387 + #[test]
388 + fn parse_timestamp_valid_rfc3339() {
389 + let dt = parse_timestamp("2025-06-15T10:30:00Z");
390 + assert_eq!(dt.year(), 2025);
391 + assert_eq!(dt.month(), 6);
392 + assert_eq!(dt.day(), 15);
393 + }
394 +
395 + #[test]
396 + fn parse_timestamp_valid_sqlite_format() {
397 + let dt = parse_timestamp("2024-03-20 08:45:00");
398 + assert_eq!(dt.year(), 2024);
399 + assert_eq!(dt.month(), 3);
344 400 }
345 401
346 402 #[test]
@@ -40,6 +40,13 @@ fn sanitize_fts_query(query: &str) -> String {
40 40 .join(" ")
41 41 }
42 42
43 + /// Maximum allowed length for a search query string.
44 + ///
45 + /// Queries longer than this are rejected early (returning an empty result set)
46 + /// to prevent excessively large FTS5 MATCH expressions from consuming memory
47 + /// or CPU in SQLite.
48 + const MAX_SEARCH_QUERY_LENGTH: usize = 500;
49 +
43 50 /// Number of consecutive failures before a feed is automatically disabled.
44 51 ///
45 52 /// Once a feed accumulates this many failures without a successful fetch,
@@ -431,6 +438,11 @@ impl ItemsRepository {
431 438 limit: i64,
432 439 offset: i64,
433 440 ) -> Result<Vec<DbFeedItem>, sqlx::Error> {
441 + // Reject excessively long queries before any processing.
442 + if query.len() > MAX_SEARCH_QUERY_LENGTH {
443 + return Ok(vec![]);
444 + }
445 +
434 446 let fts_query = sanitize_fts_query(query);
435 447
436 448 // If sanitization stripped everything (e.g. query was just `*` or `^`),
@@ -154,6 +154,14 @@ impl FeedGenerator {
154 154 .map(|db_item| db_item.to_feed_item())
155 155 .collect();
156 156
157 + // Track whether SQL returned a full page (indicating more rows exist)
158 + // BEFORE any in-memory filtering reduces the count.
159 + let sql_had_more = feed_items.len() > self.page_size as usize;
160 +
161 + // Whether any in-memory filter is active (used for has_more logic below).
162 + let needs_inmemory_filter =
163 + !self.filter.tags.is_empty() || !self.filter.feed_tags.is_empty() || !self.filter.conditions.is_empty();
164 +
157 165 // Apply feed-tag filtering: only keep items whose feed has a matching tag.
158 166 if !self.filter.feed_tags.is_empty() {
159 167 let matching_feed_ids = self
@@ -173,8 +181,6 @@ impl FeedGenerator {
173 181 feed_items.retain(|item| matching_busser_ids.contains(&item.id.source));
174 182 }
175 183
176 - // Apply remaining in-memory filters (tags, query feed conditions) and sorting.
177 - // Search and source/unread/starred are already handled by the query above.
178 184 if !self.filter.tags.is_empty() {
179 185 feed_items = self.filter.apply_tags_only(feed_items);
180 186 }
@@ -185,7 +191,16 @@ impl FeedGenerator {
185 191 feed_items.retain(|item| self.filter.matches(item));
186 192 }
187 193 self.order_by.apply(&mut feed_items);
188 - let has_more = feed_items.len() > self.page_size as usize;
194 + // When in-memory filtering is active and SQL indicated more rows exist,
195 + // we cannot know whether subsequent SQL pages contain matching items.
196 + // Conservatively report has_more = true so the UI can request the next
197 + // page. This may occasionally produce an empty last page, which is a
198 + // better UX than silently hiding matching items.
199 + let has_more = if needs_inmemory_filter && sql_had_more && feed_items.len() <= self.page_size as usize {
200 + true
201 + } else {
202 + feed_items.len() > self.page_size as usize
203 + };
189 204 feed_items.truncate(self.page_size as usize);
190 205
191 206 debug!(count = feed_items.len(), page, has_more, "Returning items");
@@ -1017,12 +1032,14 @@ mod tests {
1017 1032 // ── has_more correctness after in-memory tag filtering ──────
1018 1033
1019 1034 #[tokio::test]
1020 - async fn has_more_false_when_tag_filter_reduces_below_page_size() {
1035 + async fn has_more_true_when_inmemory_filter_reduces_below_page_size_but_sql_had_more() {
1021 1036 let db = test_db().await;
1022 1037 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).
1038 + // Create page_size+1 items (5 items, page_size=3), but only 1 has
1039 + // the matching tag. SQL returns 4 items (page_size+1), in-memory
1040 + // filtering reduces to 1 item. Since SQL had more rows and in-memory
1041 + // filtering is active, has_more should be true (there might be
1042 + // matching items on subsequent SQL pages).
1026 1043 seed_tagged_item(&db, &feed, "rss:yes", 0, vec!["rust".into()]).await;
1027 1044 for i in 1..=4 {
1028 1045 seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await;
@@ -1033,6 +1050,41 @@ mod tests {
1033 1050 .with_filter(FeedFilter::new().with_tag("rust"));
1034 1051 let result = gen.get_items(0).await.unwrap();
1035 1052 assert_eq!(result.items.len(), 1);
1053 + // SQL returned 4 items (> page_size=3), so sql_had_more=true.
1054 + // After in-memory tag filtering, only 1 item remains (< page_size).
1055 + // Conservatively report has_more=true since more matching items
1056 + // may exist on later SQL pages.
1057 + assert!(result.has_more);
1058 + }
1059 +
1060 + #[tokio::test]
1061 + async fn has_more_false_when_no_inmemory_filter_and_few_items() {
1062 + let db = test_db().await;
1063 + let feed = seed_feed(&db, "rss", "Feed").await;
1064 + // Only 2 items, page_size=3, no in-memory filters.
1065 + seed_item(&db, &feed, "rss:1", 0).await;
1066 + seed_item(&db, &feed, "rss:2", 1).await;
1067 +
1068 + let gen = FeedGenerator::new(db).with_page_size(3);
1069 + let result = gen.get_items(0).await.unwrap();
1070 + assert_eq!(result.items.len(), 2);
1071 + assert!(!result.has_more);
1072 + }
1073 +
1074 + #[tokio::test]
1075 + async fn has_more_false_when_all_items_match_filter_and_fit_page() {
1076 + let db = test_db().await;
1077 + let feed = seed_feed(&db, "rss", "Feed").await;
1078 + // Only 2 items, both match tag, page_size=3. SQL returns 2 (< page_size+1),
1079 + // so sql_had_more=false. has_more should be false.
1080 + seed_tagged_item(&db, &feed, "rss:1", 0, vec!["rust".into()]).await;
1081 + seed_tagged_item(&db, &feed, "rss:2", 1, vec!["rust".into()]).await;
1082 +
1083 + let gen = FeedGenerator::new(db)
1084 + .with_page_size(3)
1085 + .with_filter(FeedFilter::new().with_tag("rust"));
1086 + let result = gen.get_items(0).await.unwrap();
1087 + assert_eq!(result.items.len(), 2);
1036 1088 assert!(!result.has_more);
1037 1089 }
1038 1090
@@ -25,6 +25,9 @@ bb-db.workspace = true
25 25 tauri = { version = "2.10.2", features = [] }
26 26 tauri-plugin-dialog = "2.6.0"
27 27
28 + # Lock primitives (no poisoning)
29 + parking_lot.workspace = true
30 +
28 31 # Async runtime
29 32 tokio.workspace = true
30 33
@@ -0,0 +1,696 @@
1 + #!/usr/bin/env node
2 + /**
3 + * BB Frontend JS Test Runner
4 + *
5 + * Sets up the global environment once, loads all source modules,
6 + * then runs all test suites.
7 + *
8 + * Usage: node src-tauri/frontend/js/tests/run.js
9 + */
10 +
11 + const { describe, test, assert, assertEqual, assertDeepEqual, report } = require('./test-runner');
12 +
13 + console.log('Running BB frontend tests...\n');
14 +
15 + // ============================================================
16 + // Global environment setup (mocks for browser APIs)
17 + // ============================================================
18 +
19 + const mockElements = {};
20 +
21 + function createMockElement(tag) {
22 + return {
23 + tagName: (tag || 'div').toUpperCase(),
24 + className: '',
25 + id: '',
26 + style: {},
27 + dataset: {},
28 + innerHTML: '',
29 + _text: '',
30 + set textContent(v) {
31 + this._text = v;
32 + this.innerHTML = String(v)
33 + .replace(/&/g, '&amp;')
34 + .replace(/</g, '&lt;')
35 + .replace(/>/g, '&gt;')
36 + .replace(/"/g, '&quot;')
37 + .replace(/'/g, '&#039;');
38 + },
39 + get textContent() { return this._text; },
40 + children: [],
41 + attributes: [],
42 + _listeners: {},
43 + setAttribute(k, v) { this[k] = v; },
44 + getAttribute(k) { return this[k] || null; },
45 + addEventListener(ev, fn) { this._listeners[ev] = fn; },
46 + querySelector() { return createMockElement('span'); },
47 + querySelectorAll() { return []; },
48 + appendChild(child) { this.children.push(child); },
49 + remove() {},
50 + parentNode: { insertBefore() {} },
51 + };
52 + }
53 +
54 + globalThis.document = {
55 + createElement: createMockElement,
56 + getElementById: (id) => {
57 + if (!mockElements[id]) {
58 + mockElements[id] = createMockElement('div');
59 + mockElements[id].id = id;
60 + }
61 + return mockElements[id];
62 + },
63 + };
64 +
65 + globalThis.window = {};
66 + globalThis.BB = {};
67 + globalThis.confirm = () => true;
68 +
69 + // ============================================================
70 + // Load source modules (same order as index.html)
71 + // ============================================================
72 +
73 + require('../bb'); // Initializes BB namespace
74 + require('../state'); // BB.state (Proxy-based pub/sub)
75 + require('../utils'); // BB.utils (escapeHtml, escapeAttr, debounce)
76 +
77 + // Mock BB.api before loading modules that depend on it
78 + BB.api = {
79 + sources: {
80 + list: async () => [
81 + { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
82 + { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
83 + ],
84 + },
85 + items: {
86 + list: async () => ({
87 + items: [
88 + { id: 'i1', title: 'First', author: 'Alice', isRead: false, isStarred: false, timeAgo: '2m' },
89 + { id: 'i2', title: 'Second', author: 'Bob', isRead: true, isStarred: true, timeAgo: '5m' },
90 + ],
91 + hasMore: true,
92 + }),
93 + markRead: async () => {},
94 + markUnread: async () => {},
95 + star: async () => {},
96 + unstar: async () => {},
97 + },
98 + feeds: {
99 + listAllTags: async () => ['news', 'tech'],
100 + deleteByBusser: async () => {},
101 + getByBusser: async (id) => [{ busserId: id, name: 'Test', config: {} }],
102 + create: async () => {},
103 + setTags: async () => {},
104 + get: async () => ({ name: 'Test', config: {} }),
105 + update: async () => {},
106 + },
107 + plugins: { schema: async () => ({ fields: [] }) },
108 + };
109 + BB.ui = { showToast() {}, openFormModal() {} };
110 + BB.detail = { load() {} };
111 + BB.queryFeeds = { load() {}, select() {}, openBuilder() {}, deleteFeed() {} };
112 +
113 + // Now load modules that depend on BB.utils and BB.api
114 + require('../sources'); // BB.sources
115 + require('../items'); // BB.items
116 +
117 + // ============================================================
118 + // Test: BB.state
119 + // ============================================================
120 +
121 + describe('BB.state', () => {
122 + test('subscribe registers callback and fires on set', () => {
123 + let called = false;
124 + BB.state.subscribe('_t1', () => { called = true; });
125 + BB.state.set('_t1', 'hello');
126 + assert(called, 'Subscriber should fire');
127 + });
128 +
129 + test('set passes old and new values to subscriber', () => {
130 + BB.state.set('_t2', 'first');
131 + let capturedOld;
132 + BB.state.subscribe('_t2', (n, o) => { capturedOld = o; });
133 + BB.state.set('_t2', 'second');
134 + assertEqual(capturedOld, 'first');
135 + });
136 +
137 + test('set does not trigger unrelated subscribers', () => {
138 + let called = false;
139 + BB.state.subscribe('_t3_a', () => { called = true; });
140 + BB.state.set('_t3_b', 'val');
141 + assert(!called, 'Unrelated subscriber should not fire');
142 + });
143 +
144 + test('unsubscribe removes callback', () => {
145 + let count = 0;
146 + const unsub = BB.state.subscribe('_t4', () => { count++; });
147 + BB.state.set('_t4', 'a');
148 + assertEqual(count, 1);
149 + unsub();
150 + BB.state.set('_t4', 'b');
151 + assertEqual(count, 1);
152 + });
153 +
154 + test('set with same value still triggers', () => {
155 + BB.state.set('_t5', 'same');
156 + let count = 0;
157 + BB.state.subscribe('_t5', () => { count++; });
158 + BB.state.set('_t5', 'same');
159 + assertEqual(count, 1);
160 + });
161 +
162 + test('multiple subscribers on same key all fire', () => {
163 + let a = false, b = false;
164 + BB.state.subscribe('_t6', () => { a = true; });
165 + BB.state.subscribe('_t6', () => { b = true; });
166 + BB.state.set('_t6', 'v');
167 + assert(a && b, 'Both should fire');
168 + });
169 +
170 + test('direct property assignment triggers via Proxy', () => {
171 + let called = false;
172 + BB.state.subscribe('_t7', () => { called = true; });
173 + BB.state._t7 = 'proxy';
174 + assert(called, 'Proxy set should trigger');
175 + assertEqual(BB.state._t7, 'proxy');
176 + });
177 +
178 + test('get returns current value', () => {
179 + BB.state.set('_t8', 42);
180 + assertEqual(BB.state.get('_t8'), 42);
181 + });
182 +
183 + test('initial state has expected default keys', () => {
184 + assert(Array.isArray(BB.state.sources));
185 + assertEqual(BB.state.currentOrder, 'chronological');
186 + assertEqual(BB.state.hasMore, false);
187 + assertEqual(BB.state.selectedItemId, null);
188 + });
189 + });
190 +
191 + // ============================================================
192 + // Test: BB.utils.escapeHtml
193 + // ============================================================
194 +
195 + describe('BB.utils.escapeHtml', () => {
196 + test('escapes angle brackets', () => {
197 + const r = BB.utils.escapeHtml('<b>hi</b>');
198 + assert(r.includes('&lt;') && r.includes('&gt;'));
199 + });
200 +
201 + test('escapes ampersand', () => {
202 + assert(BB.utils.escapeHtml('A & B').includes('&amp;'));
203 + });
204 +
205 + test('returns empty for falsy', () => {
206 + assertEqual(BB.utils.escapeHtml(''), '');
207 + assertEqual(BB.utils.escapeHtml(null), '');
208 + assertEqual(BB.utils.escapeHtml(undefined), '');
209 + });
210 +
211 + test('passes safe strings through', () => {
212 + assertEqual(BB.utils.escapeHtml('hello'), 'hello');
213 + });
214 + });
215 +
216 + // ============================================================
217 + // Test: BB.utils.escapeAttr
218 + // ============================================================
219 +
220 + describe('BB.utils.escapeAttr', () => {
221 + test('escapes double quotes', () => {
222 + assert(BB.utils.escapeAttr('a"b').includes('&quot;'));
223 + });
224 +
225 + test('escapes single quotes', () => {
226 + assert(BB.utils.escapeAttr("a'b").includes('&#39;'));
227 + });
228 +
229 + test('escapes < and >', () => {
230 + const r = BB.utils.escapeAttr('<>');
231 + assert(r.includes('&lt;') && r.includes('&gt;'));
232 + });
233 +
234 + test('escapes ampersand', () => {
235 + assertEqual(BB.utils.escapeAttr('a&b'), 'a&amp;b');
236 + });
237 +
238 + test('returns empty for falsy', () => {
239 + assertEqual(BB.utils.escapeAttr(''), '');
240 + assertEqual(BB.utils.escapeAttr(null), '');
241 + });
242 +
243 + test('handles all special chars together', () => {
244 + const r = BB.utils.escapeAttr(`<"&'>`);
245 + assert(!r.includes('<') || r.includes('&lt;'));
246 + });
247 +
248 + test('converts non-string to string', () => {
249 + assertEqual(BB.utils.escapeAttr(123), '123');
250 + });
251 + });
252 +
253 + // ============================================================
254 + // Test: BB.utils.debounce
255 + // ============================================================
256 +
257 + describe('BB.utils.debounce', () => {
258 + test('does not fire immediately', () => {
259 + let called = false;
260 + const fn = BB.utils.debounce(() => { called = true; }, 10);
261 + fn();
262 + assert(!called, 'Should not fire immediately');
263 + });
264 +
265 + test('rapid calls only execute last one', () => {
266 + let callCount = 0, lastArg = null;
267 + const origST = globalThis.setTimeout;
268 + const origCT = globalThis.clearTimeout;
269 + let pendingCb = null;
270 + globalThis.setTimeout = (cb) => { pendingCb = cb; return 1; };
271 + globalThis.clearTimeout = () => { pendingCb = null; };
272 +
273 + const fn = BB.utils.debounce((arg) => { callCount++; lastArg = arg; }, 100);
274 + fn('a');
275 + fn('b');
276 + fn('c');
277 + if (pendingCb) pendingCb();
278 +
279 + assertEqual(callCount, 1);
280 + assertEqual(lastArg, 'c');
281 +
282 + globalThis.setTimeout = origST;
283 + globalThis.clearTimeout = origCT;
284 + });
285 + });
286 +
287 + // ============================================================
288 + // Test: BB.sources
289 + // ============================================================
290 +
291 + describe('BB.sources.select', () => {
292 + test('sets currentSource state', () => {
293 + BB.sources.select('s1');
294 + assertEqual(BB.state.currentSource, 's1');
295 + });
296 +
297 + test('resets pagination', () => {
298 + BB.state.set('currentPage', 5);
299 + BB.sources.select('s2');
300 + assertEqual(BB.state.currentPage, 0);
301 + });
302 +
303 + test('clears selectedItemId', () => {
304 + BB.state.set('selectedItemId', 'x');
305 + BB.sources.select('');
306 + assertEqual(BB.state.selectedItemId, null);
307 + });
308 +
309 + test('clears currentQueryFeed', () => {
310 + BB.state.set('currentQueryFeed', 'qf');
311 + BB.sources.select('s1');
312 + assertEqual(BB.state.currentQueryFeed, null);
313 + });
314 + });
315 +
316 + describe('BB.sources.selectTag', () => {
317 + test('sets currentTag', () => {
318 + BB.sources.selectTag('tech');
319 + assertEqual(BB.state.currentTag, 'tech');
320 + });
321 +
322 + test('resets pagination', () => {
323 + BB.state.set('currentPage', 3);
324 + BB.sources.selectTag('news');
325 + assertEqual(BB.state.currentPage, 0);
326 + });
327 + });
328 +
329 + describe('BB.sources.load', () => {
330 + test('populates state', async () => {
331 + await BB.sources.load();
332 + assertEqual(BB.state.sources.length, 2);
333 + assertDeepEqual(BB.state.allTags, ['news', 'tech']);
334 + });
335 + });
336 +
337 + // ============================================================
338 + // Test: BB.items
339 + // ============================================================
340 +
341 + // Track API calls
342 + let apiCalls = [];
343 + BB.api.items.markRead = async (id) => { apiCalls.push({ cmd: 'markRead', id }); };
344 + BB.api.items.markUnread = async (id) => { apiCalls.push({ cmd: 'markUnread', id }); };
345 + BB.api.items.star = async (id) => { apiCalls.push({ cmd: 'star', id }); };
346 + BB.api.items.unstar = async (id) => { apiCalls.push({ cmd: 'unstar', id }); };
347 +
348 + describe('BB.items.load', () => {
349 + test('populates state with items', async () => {
350 + await BB.items.load();
351 + assertEqual(BB.state.items.length, 2);
352 + assertEqual(BB.state.items[0].title, 'First');
353 + assertEqual(BB.state.hasMore, true);
354 + });
355 +
356 + test('appends items when append=true', async () => {
357 + BB.state.set('items', [{ id: 'old', title: 'Old' }]);
358 + await BB.items.load(true);
359 + assertEqual(BB.state.items.length, 3);
360 + assertEqual(BB.state.items[0].id, 'old');
361 + });
362 + });
363 +
364 + describe('BB.items.selectItem', () => {
365 + test('sets selectedItemId', async () => {
366 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
367 + await BB.items.selectItem('i1');
368 + assertEqual(BB.state.selectedItemId, 'i1');
369 + });
370 +
371 + test('marks item as read via API', async () => {
372 + apiCalls = [];
373 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: false, isStarred: false }]);
374 + await BB.items.selectItem('i1');
375 + assert(apiCalls.some(c => c.cmd === 'markRead' && c.id === 'i1'));
376 + });
377 + });
378 +
379 + describe('BB.items.toggleStar', () => {
380 + test('unstars a starred item', async () => {
381 + apiCalls = [];
382 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: true }]);
383 + await BB.items.toggleStar('i1', true);
384 + assert(apiCalls.some(c => c.cmd === 'unstar'));
385 + assertEqual(BB.state.items[0].isStarred, false);
386 + });
387 +
388 + test('stars an unstarred item', async () => {
389 + apiCalls = [];
390 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
391 + await BB.items.toggleStar('i1', false);
392 + assert(apiCalls.some(c => c.cmd === 'star'));
393 + assertEqual(BB.state.items[0].isStarred, true);
394 + });
395 + });
396 +
397 + describe('BB.items.toggleRead', () => {
398 + test('marks read item as unread', async () => {
399 + apiCalls = [];
400 + BB.state.set('items', [{ id: 'i1', title: 'T', isRead: true, isStarred: false }]);
401 + await BB.items.toggleRead('i1', true);
402 + assert(apiCalls.some(c => c.cmd === 'markUnread'));
403 + assertEqual(BB.state.items[0].isRead, false);
404 + });
405 + });
406 +
407 + describe('BB.items.loadMore', () => {
408 + test('increments page', async () => {
409 + BB.state.set('currentPage', 0);
410 + await BB.items.loadMore();
411 + assertEqual(BB.state.currentPage, 1);
412 + });
413 + });
414 +
415 + // ============================================================
416 + // Test: BB.sources.render (rendering edge cases)
417 + // ============================================================
418 +
419 + // Helper to reset a mock element's children array
420 + function resetMockElement(id) {
421 + const el = mockElements[id];
422 + if (el) el.children = [];
423 + }
424 +
425 + describe('BB.sources.render', () => {
426 + test('renderSourceList creates correct number of source elements', () => {
427 + resetMockElement('sources-list');
428 + const sources = [
429 + { id: 's1', name: 'Feed A', totalCount: 10, unreadCount: 3, tags: ['news'], health: 'green' },
430 + { id: 's2', name: 'Feed B', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
431 + { id: 's3', name: 'Feed C', totalCount: 0, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
432 + ];
433 + BB.state.set('currentSource', '');
434 + BB.state.set('queryFeeds', []);
435 + BB.sources.render(sources);
436 + const list = document.getElementById('sources-list');
437 + // 3 source items + 1 "+ Query Feed" button appended via appendChild
438 + assertEqual(list.children.length, 4);
439 + });
440 +
441 + test('source health indicator shows correct class for yellow', () => {
442 + resetMockElement('sources-list');
443 + const sources = [
444 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'yellow', lastError: 'timeout' },
445 + ];
446 + BB.state.set('currentSource', '');
447 + BB.state.set('queryFeeds', []);
448 + BB.sources.render(sources);
449 + const list = document.getElementById('sources-list');
450 + const sourceItem = list.children[0]; // first appended child is the source
451 + assert(sourceItem.innerHTML.includes('health-yellow'), 'Should have health-yellow class');
452 + });
453 +
454 + test('source with unreadCount=0 shows total only (no slash)', () => {
455 + resetMockElement('sources-list');
456 + const sources = [
457 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'green' },
458 + ];
459 + BB.state.set('currentSource', '');
460 + BB.state.set('queryFeeds', []);
461 + BB.sources.render(sources);
462 + const list = document.getElementById('sources-list');
463 + const sourceItem = list.children[0];
464 + assert(sourceItem.innerHTML.includes('>5<'), 'Should show just total count');
465 + assert(!sourceItem.innerHTML.includes('0/5'), 'Should not show 0/X format');
466 + });
467 +
468 + test('source with lastError shows error text in health dot title', () => {
469 + resetMockElement('sources-list');
470 + const sources = [
471 + { id: 's1', name: 'Feed A', totalCount: 5, unreadCount: 0, tags: [], health: 'red', lastError: 'dns fail' },
472 + ];
473 + BB.state.set('currentSource', '');
474 + BB.state.set('queryFeeds', []);
475 + BB.sources.render(sources);
476 + const list = document.getElementById('sources-list');
477 + const sourceItem = list.children[0];
478 + assert(sourceItem.innerHTML.includes('dns fail'), 'Should include error text');
479 + });
480 +
481 + test('empty sources array renders only All item and + button', () => {
482 + resetMockElement('sources-list');
483 + BB.state.set('currentSource', '');
484 + BB.state.set('queryFeeds', []);
485 + BB.sources.render([]);
486 + const list = document.getElementById('sources-list');
487 + assert(list.innerHTML.includes('All'), 'Should have All entry');
488 + // Only the "+ Query Feed" button is appended via appendChild
489 + assertEqual(list.children.length, 1);
490 + });
491 + });
492 +
493 + // ============================================================
494 + // Test: BB.items.render (rendering edge cases)
495 + // ============================================================
496 +
497 + describe('BB.items.render', () => {
498 + test('empty items array renders placeholder message', () => {
499 + resetMockElement('items-list');
500 + BB.state.set('sources', [{ id: 's1', name: 'F' }]);
Lines truncated
@@ -0,0 +1,69 @@
1 + /**
2 + * Minimal test runner for BB frontend JS tests.
3 + * No npm dependencies — runs with plain Node.js.
4 + *
5 + * Usage: node src-tauri/frontend/js/tests/run.js
6 + */
7 +
8 + let passed = 0;
9 + let failed = 0;
10 + let currentSuite = '';
11 + const failures = [];
12 +
13 + function describe(name, fn) {
14 + currentSuite = name;
15 + fn();
16 + currentSuite = '';
17 + }
18 +
19 + function test(name, fn) {
20 + const fullName = currentSuite ? `${currentSuite} > ${name}` : name;
21 + try {
22 + fn();
23 + passed++;
24 + } catch (err) {
25 + failed++;
26 + failures.push({ name: fullName, error: err.message });
27 + }
28 + }
29 +
30 + function assert(condition, message) {
31 + if (!condition) throw new Error(message || 'Assertion failed');
32 + }
33 +
34 + function assertEqual(actual, expected, message) {
35 + if (actual !== expected) {
36 + throw new Error(
37 + message ||
38 + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`
39 + );
40 + }
41 + }
42 +
43 + function assertDeepEqual(actual, expected, message) {
44 + const a = JSON.stringify(actual);
45 + const e = JSON.stringify(expected);
46 + if (a !== e) {
47 + throw new Error(message || `Expected ${e}, got ${a}`);
48 + }
49 + }
50 +
51 + function assertThrows(fn, message) {
52 + let threw = false;
53 + try { fn(); } catch (_) { threw = true; }
54 + if (!threw) throw new Error(message || 'Expected function to throw');
55 + }
56 +
57 + function report() {
58 + console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed`);
59 + if (failures.length > 0) {
60 + console.log('\nFailures:');
61 + failures.forEach(f => {
62 + console.log(` FAIL: ${f.name}`);
63 + console.log(` ${f.error}`);
64 + });
65 + }
66 + return failed === 0;
67 + }
68 +
69 + module.exports = { describe, test, assert, assertEqual, assertDeepEqual, assertThrows, report };
@@ -19,7 +19,7 @@
19 19
20 20 /** Set of element tag names that are stripped during sanitization. */
21 21 const DANGEROUS_ELEMENTS = new Set([
22 - 'script', 'iframe', 'object', 'embed', 'form', 'style',
22 + 'script', 'iframe', 'object', 'embed', 'form', 'style', 'base',
23 23 ]);
24 24
25 25 /**
@@ -91,7 +91,7 @@
91 91 */
92 92 function escapeAttr(str) {
93 93 if (!str) return '';
94 - return String(str).replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
94 + return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
95 95 }
96 96
97 97 /**
@@ -3,9 +3,11 @@ use crate::commands::error::ApiError;
3 3 use crate::state::AppState;
4 4 use std::sync::Arc;
5 5 use tauri::State;
6 + use tracing::instrument;
6 7
7 8 /// Get a user config value by key.
8 9 #[tauri::command]
10 + #[instrument(skip_all)]
9 11 pub async fn get_config(
10 12 state: State<'_, Arc<AppState>>,
11 13 key: String,
@@ -17,6 +19,7 @@ pub async fn get_config(
17 19
18 20 /// Set a user config value (upserts on key).
19 21 #[tauri::command]
22 + #[instrument(skip_all)]
20 23 pub async fn set_config(
21 24 state: State<'_, Arc<AppState>>,
22 25 key: String,
@@ -1,12 +1,12 @@
1 1 //! Feed management commands (create, delete, fetch)
2 2 use crate::commands::error::ApiError;
3 3 use crate::state::AppState;
4 - use bb_db::{BusserId, FeedId};
4 + use bb_db::FeedId;
5 5 use serde::{Deserialize, Serialize};
6 6 use sqlx::Acquire;
7 7 use std::sync::Arc;
8 8 use tauri::State;
9 - use tracing::info;
9 + use tracing::{info, instrument};
10 10
11 11 /// Frontend input for creating a new feed subscription.
12 12 #[derive(Debug, Clone, Deserialize)]
@@ -37,6 +37,7 @@ pub struct FetchResponse {
37 37 ///
38 38 /// Used by the frontend to snapshot feed details before deletion (for undo).
39 39 #[tauri::command]
40 + #[instrument(skip_all)]
40 41 pub async fn get_feeds_by_busser(
41 42 state: State<'_, Arc<AppState>>,
42 43 busser_id: String,
@@ -162,6 +163,7 @@ fn validate_feed_input(
162 163
163 164 /// Create a new feed and re-initialize its plugin.
164 165 #[tauri::command]
166 + #[instrument(skip_all)]
165 167 pub async fn create_feed(
166 168 state: State<'_, Arc<AppState>>,
167 169 input: CreateFeedInput,
@@ -181,9 +183,32 @@ pub async fn create_feed(
181 183
182 184 let db = state.orchestrator.database();
183 185
184 - // Check for duplicate config among existing feeds for this busser
186 + // Encrypt Secret-type fields before storage
187 + let config = {
188 + let mut cfg = input.config.clone();
189 + if let Some(key) = state.orchestrator.encryption_key() {
190 + let plugins = state.orchestrator.plugins();
191 + let plugins = plugins.read().await;
192 + if let Some(schema) = plugins.get_config_schema(&input.busser_id) {
193 + bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key);
194 + }
195 + }
196 + cfg
197 + };
198 +
199 + // Check for duplicate config and insert inside a single transaction to
200 + // avoid a TOCTOU race (two concurrent create_feed calls with the same
201 + // config could both pass the duplicate check before either inserts).
202 + let mut conn = db.pool().acquire().await?;
203 + let mut tx = conn.begin().await?;
204 +
205 + // Duplicate-config check (inside the transaction)
185 206 {
186 - let existing = db.feeds().get_by_busser(&input.busser_id).await?;
207 + let existing: Vec<bb_db::DbFeed> =
208 + sqlx::query_as("SELECT * FROM feeds WHERE busser_id = ?1 ORDER BY name")
209 + .bind(&input.busser_id)
210 + .fetch_all(&mut *tx)
211 + .await?;
187 212
188 213 let key = state.orchestrator.encryption_key();
189 214 let schema = {
@@ -209,26 +234,27 @@ pub async fn create_feed(
209 234 }
210 235 }
211 236
212 - // Encrypt Secret-type fields before storage
213 - let config = {
214 - let mut cfg = input.config.clone();
215 - if let Some(key) = state.orchestrator.encryption_key() {
216 - let plugins = state.orchestrator.plugins();
217 - let plugins = plugins.read().await;
218 - if let Some(schema) = plugins.get_config_schema(&input.busser_id) {
219 - bb_core::crypto::encrypt_config_secrets(&mut cfg, &schema, key);
220 - }
221 - }
222 - cfg
223 - };
237 + // Insert the new feed (still inside the transaction)
238 + let feed_id = bb_db::FeedId::new();
239 + let now = chrono::Utc::now()
240 + .format(bb_db::TIMESTAMP_FMT)
241 + .to_string();
242 + let config_str =
243 + serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string());
244 +
245 + sqlx::query(
246 + "INSERT INTO feeds (id, busser_id, name, config, enabled, created_at, updated_at) \
247 + VALUES (?1, ?2, ?3, ?4, 1, ?5, ?5)",
248 + )
249 + .bind(feed_id)
250 + .bind(&input.busser_id)
251 + .bind(&name)
252 + .bind(&config_str)
253 + .bind(&now)
254 + .execute(&mut *tx)
255 + .await?;
224 256
225 - let create = bb_db::CreateFeed {
226 - busser_id: BusserId::new(&input.busser_id),
227 - name,
228 - config,
229 - };
230 -
231 - db.feeds().create(create).await?;
257 + tx.commit().await?;
232 258
233 259 // Re-initialize the plugin with the new feed
234 260 if let Err(e) = state
@@ -255,6 +281,7 @@ pub struct FeedResponse {
255 281
256 282 /// Get the first feed for a busser, with decrypted config for editing.
257 283 #[tauri::command]
284 + #[instrument(skip_all)]
258 285 pub async fn get_feed(
259 286 state: State<'_, Arc<AppState>>,
260 287 busser_id: String,
@@ -285,6 +312,7 @@ pub async fn get_feed(
285 312
286 313 /// Update an existing feed's name and config.
287 314 #[tauri::command]
315 + #[instrument(skip_all)]
288 316 pub async fn update_feed(
289 317 state: State<'_, Arc<AppState>>,
290 318 id: String,
@@ -339,6 +367,7 @@ pub async fn update_feed(
339 367
340 368 /// Delete a single feed and all its items in a transaction.
341 369 #[tauri::command]
370 + #[instrument(skip_all)]
342 371 pub async fn delete_feed(
343 372 state: State<'_, Arc<AppState>>,
344 373 id: String,
@@ -368,6 +397,7 @@ pub async fn delete_feed(
368 397
369 398 /// Delete all feeds (and their items) belonging to a busser.
370 399 #[tauri::command]
400 + #[instrument(skip_all)]
371 401 pub async fn delete_feeds_by_busser(
372 402 state: State<'_, Arc<AppState>>,
373 403 busser_id: String,
@@ -400,6 +430,7 @@ pub async fn delete_feeds_by_busser(
400 430
401 431 /// Set tags on all feeds belonging to a busser.
402 432 #[tauri::command]
433 + #[instrument(skip_all)]
403 434 pub async fn set_feed_tags(
404 435 state: State<'_, Arc<AppState>>,
405 436 busser_id: String,
@@ -418,6 +449,7 @@ pub async fn set_feed_tags(
418 449
419 450 /// List all distinct tags across all feeds.
420 451 #[tauri::command]
452 + #[instrument(skip_all)]
421 453 pub async fn list_all_tags(
422 454 state: State<'_, Arc<AppState>>,
423 455 ) -> Result<Vec<String>, ApiError> {
@@ -426,6 +458,7 @@ pub async fn list_all_tags(
426 458
427 459 /// Trigger a fetch from all active plugins and return the total item count.
428 460 #[tauri::command]
461 + #[instrument(skip_all)]
429 462 pub async fn fetch_all(
430 463 state: State<'_, Arc<AppState>>,
431 464 ) -> Result<FetchResponse, ApiError> {
@@ -443,6 +476,7 @@ pub async fn fetch_all(
443 476 /// this command clears the circuit-broken state, resets the failure counter,
444 477 /// and immediately retries the fetch. Returns the number of items fetched.
445 478 #[tauri::command]
479 + #[instrument(skip_all)]
446 480 pub async fn reset_circuit_breaker(
447 481 state: State<'_, Arc<AppState>>,
448 482 busser_id: String,
@@ -7,6 +7,7 @@ use bb_interface::FeedItem;
7 7 use serde::{Deserialize, Serialize};
8 8 use std::sync::Arc;
9 9 use tauri::State;
10 + use tracing::instrument;
10 11
11 12 /// Compact item representation for the feed list view.
12 13 #[derive(Debug, Clone, Serialize)]
@@ -207,6 +208,7 @@ fn format_time_ago(timestamp: &str) -> String {
207 208 /// Delegates to [`FeedGenerator::get_items`] so the filter -> sort -> paginate
208 209 /// pipeline is defined in one place (the `bb-feed` crate).
209 210 #[tauri::command]
211 + #[instrument(skip_all)]
210 212 pub async fn list_items(
211 213 state: State<'_, Arc<AppState>>,
212 214 filter: ItemsFilter,
@@ -269,6 +271,7 @@ pub async fn list_items(
269 271
270 272 /// Get the full detail view for a single item by UUID.
271 273 #[tauri::command]
274 + #[instrument(skip_all)]
272 275 pub async fn get_item(
273 276 state: State<'_, Arc<AppState>>,
274 277 id: String,
@@ -287,6 +290,7 @@ pub async fn get_item(
287 290
288 291 /// Mark an item as read.
289 292 #[tauri::command]
293 + #[instrument(skip_all)]
290 294 pub async fn mark_item_read(
291 295 state: State<'_, Arc<AppState>>,
292 296 id: String,
@@ -297,6 +301,7 @@ pub async fn mark_item_read(
297 301
298 302 /// Mark an item as unread.
299 303 #[tauri::command]
304 + #[instrument(skip_all)]
300 305 pub async fn mark_item_unread(
301 306 state: State<'_, Arc<AppState>>,
302 307 id: String,
@@ -307,6 +312,7 @@ pub async fn mark_item_unread(
307 312
308 313 /// Star (favourite) an item.
309 314 #[tauri::command]
315 + #[instrument(skip_all)]
310 316 pub async fn star_item(
311 317 state: State<'_, Arc<AppState>>,
312 318 id: String,
@@ -317,6 +323,7 @@ pub async fn star_item(
317 323
318 324 /// Remove the star from an item.
319 325 #[tauri::command]
326 + #[instrument(skip_all)]
320 327 pub async fn unstar_item(
321 328 state: State<'_, Arc<AppState>>,
322 329 id: String,
@@ -327,6 +334,7 @@ pub async fn unstar_item(
327 334
328 335 /// Get the total count of unread items.
329 336 #[tauri::command]
337 + #[instrument(skip_all)]
330 338 pub async fn get_unread_count(
331 339 state: State<'_, Arc<AppState>>,
332 340 ) -> Result<i64, ApiError> {