max / balanced_breakfast
29 files changed,
+1271 insertions,
-122 deletions
| @@ -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" |
| @@ -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"] } |
| @@ -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, '&') | |
| 34 | + | .replace(/</g, '<') | |
| 35 | + | .replace(/>/g, '>') | |
| 36 | + | .replace(/"/g, '"') | |
| 37 | + | .replace(/'/g, '''); | |
| 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('<') && r.includes('>')); | |
| 199 | + | }); | |
| 200 | + | ||
| 201 | + | test('escapes ampersand', () => { | |
| 202 | + | assert(BB.utils.escapeHtml('A & B').includes('&')); | |
| 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('"')); | |
| 223 | + | }); | |
| 224 | + | ||
| 225 | + | test('escapes single quotes', () => { | |
| 226 | + | assert(BB.utils.escapeAttr("a'b").includes(''')); | |
| 227 | + | }); | |
| 228 | + | ||
| 229 | + | test('escapes < and >', () => { | |
| 230 | + | const r = BB.utils.escapeAttr('<>'); | |
| 231 | + | assert(r.includes('<') && r.includes('>')); | |
| 232 | + | }); | |
| 233 | + | ||
| 234 | + | test('escapes ampersand', () => { | |
| 235 | + | assertEqual(BB.utils.escapeAttr('a&b'), 'a&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('<')); | |
| 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '''); | |
| 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> { |