max / balanced_breakfast
30 files changed,
+1123 insertions,
-618 deletions
| @@ -190,6 +190,8 @@ dependencies = [ | |||
| 190 | 190 | "bb-feed", | |
| 191 | 191 | "bb-interface", | |
| 192 | 192 | "chrono", | |
| 193 | + | "futures", | |
| 194 | + | "open", | |
| 193 | 195 | "parking_lot", | |
| 194 | 196 | "rand 0.8.5", | |
| 195 | 197 | "roxmltree", | |
| @@ -243,6 +245,7 @@ dependencies = [ | |||
| 243 | 245 | "bb-interface", | |
| 244 | 246 | "chrono", | |
| 245 | 247 | "docengine", | |
| 248 | + | "futures", | |
| 246 | 249 | "html2text", | |
| 247 | 250 | "keyring", | |
| 248 | 251 | "parking_lot", | |
| @@ -920,7 +923,7 @@ dependencies = [ | |||
| 920 | 923 | "libc", | |
| 921 | 924 | "option-ext", | |
| 922 | 925 | "redox_users", | |
| 923 | - | "windows-sys 0.59.0", | |
| 926 | + | "windows-sys 0.61.2", | |
| 924 | 927 | ] | |
| 925 | 928 | ||
| 926 | 929 | [[package]] | |
| @@ -1088,7 +1091,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1088 | 1091 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" | |
| 1089 | 1092 | dependencies = [ | |
| 1090 | 1093 | "libc", | |
| 1091 | - | "windows-sys 0.59.0", | |
| 1094 | + | "windows-sys 0.61.2", | |
| 1092 | 1095 | ] | |
| 1093 | 1096 | ||
| 1094 | 1097 | [[package]] | |
| @@ -1250,6 +1253,21 @@ dependencies = [ | |||
| 1250 | 1253 | ] | |
| 1251 | 1254 | ||
| 1252 | 1255 | [[package]] | |
| 1256 | + | name = "futures" | |
| 1257 | + | version = "0.3.31" | |
| 1258 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1259 | + | checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" | |
| 1260 | + | dependencies = [ | |
| 1261 | + | "futures-channel", | |
| 1262 | + | "futures-core", | |
| 1263 | + | "futures-executor", | |
| 1264 | + | "futures-io", | |
| 1265 | + | "futures-sink", | |
| 1266 | + | "futures-task", | |
| 1267 | + | "futures-util", | |
| 1268 | + | ] | |
| 1269 | + | ||
| 1270 | + | [[package]] | |
| 1253 | 1271 | name = "futures-channel" | |
| 1254 | 1272 | version = "0.3.31" | |
| 1255 | 1273 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1322,6 +1340,7 @@ version = "0.3.31" | |||
| 1322 | 1340 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1323 | 1341 | checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" | |
| 1324 | 1342 | dependencies = [ | |
| 1343 | + | "futures-channel", | |
| 1325 | 1344 | "futures-core", | |
| 1326 | 1345 | "futures-io", | |
| 1327 | 1346 | "futures-macro", | |
| @@ -2641,7 +2660,7 @@ version = "0.50.3" | |||
| 2641 | 2660 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2642 | 2661 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" | |
| 2643 | 2662 | dependencies = [ | |
| 2644 | - | "windows-sys 0.59.0", | |
| 2663 | + | "windows-sys 0.61.2", | |
| 2645 | 2664 | ] | |
| 2646 | 2665 | ||
| 2647 | 2666 | [[package]] | |
| @@ -3026,7 +3045,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3026 | 3045 | checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" | |
| 3027 | 3046 | dependencies = [ | |
| 3028 | 3047 | "libc", | |
| 3029 | - | "windows-sys 0.45.0", | |
| 3048 | + | "windows-sys 0.61.2", | |
| 3030 | 3049 | ] | |
| 3031 | 3050 | ||
| 3032 | 3051 | [[package]] | |
| @@ -3885,7 +3904,7 @@ dependencies = [ | |||
| 3885 | 3904 | "errno", | |
| 3886 | 3905 | "libc", | |
| 3887 | 3906 | "linux-raw-sys", | |
| 3888 | - | "windows-sys 0.59.0", | |
| 3907 | + | "windows-sys 0.61.2", | |
| 3889 | 3908 | ] | |
| 3890 | 3909 | ||
| 3891 | 3910 | [[package]] | |
| @@ -3942,7 +3961,7 @@ dependencies = [ | |||
| 3942 | 3961 | "security-framework", | |
| 3943 | 3962 | "security-framework-sys", | |
| 3944 | 3963 | "webpki-root-certs", | |
| 3945 | - | "windows-sys 0.59.0", | |
| 3964 | + | "windows-sys 0.61.2", | |
| 3946 | 3965 | ] | |
| 3947 | 3966 | ||
| 3948 | 3967 | [[package]] | |
| @@ -4832,6 +4851,7 @@ dependencies = [ | |||
| 4832 | 4851 | "serde_json", | |
| 4833 | 4852 | "thiserror 2.0.18", | |
| 4834 | 4853 | "tokio", | |
| 4854 | + | "tokio-stream", | |
| 4835 | 4855 | "tracing", | |
| 4836 | 4856 | "unicode-normalization", | |
| 4837 | 4857 | "urlencoding", | |
| @@ -5307,7 +5327,7 @@ dependencies = [ | |||
| 5307 | 5327 | "getrandom 0.3.4", | |
| 5308 | 5328 | "once_cell", | |
| 5309 | 5329 | "rustix", | |
| 5310 | - | "windows-sys 0.59.0", | |
| 5330 | + | "windows-sys 0.61.2", | |
| 5311 | 5331 | ] | |
| 5312 | 5332 | ||
| 5313 | 5333 | [[package]] | |
| @@ -6285,7 +6305,7 @@ version = "0.1.11" | |||
| 6285 | 6305 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6286 | 6306 | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" | |
| 6287 | 6307 | dependencies = [ | |
| 6288 | - | "windows-sys 0.48.0", | |
| 6308 | + | "windows-sys 0.61.2", | |
| 6289 | 6309 | ] | |
| 6290 | 6310 | ||
| 6291 | 6311 | [[package]] |
| @@ -10,7 +10,7 @@ members = [ | |||
| 10 | 10 | default-members = ["src-tauri"] | |
| 11 | 11 | ||
| 12 | 12 | [workspace.package] | |
| 13 | - | version = "0.3.0" | |
| 13 | + | version = "0.3.1" | |
| 14 | 14 | edition = "2021" | |
| 15 | 15 | authors = ["BalancedBreakfast Contributors"] | |
| 16 | 16 | license-file = "LICENSE" | |
| @@ -40,6 +40,10 @@ keyring = "3" | |||
| 40 | 40 | ||
| 41 | 41 | # Concurrency | |
| 42 | 42 | parking_lot = "0.12" | |
| 43 | + | futures = "0.3" | |
| 44 | + | ||
| 45 | + | # Cross-platform file opener | |
| 46 | + | open = "5" | |
| 43 | 47 | ||
| 44 | 48 | # Utilities | |
| 45 | 49 | chrono = { version = "0.4.43", features = ["serde"] } |
| @@ -48,5 +48,8 @@ regex = "1" | |||
| 48 | 48 | # HTML sanitization for untrusted feed content | |
| 49 | 49 | docengine = { path = "../../../../Shared/docengine" } | |
| 50 | 50 | ||
| 51 | + | # Concurrent fetching | |
| 52 | + | futures = { workspace = true } | |
| 53 | + | ||
| 51 | 54 | # Article extraction (reader view) | |
| 52 | 55 | readable-readability = "0.4" |
| @@ -162,8 +162,13 @@ pub fn encrypt_config_secrets( | |||
| 162 | 162 | } | |
| 163 | 163 | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 164 | 164 | if !val.is_empty() && !val.starts_with(PREFIX) { | |
| 165 | - | if let Ok(encrypted) = encrypt_field(val, key) { | |
| 166 | - | obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); | |
| 165 | + | match encrypt_field(val, key) { | |
| 166 | + | Ok(encrypted) => { | |
| 167 | + | obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); | |
| 168 | + | } | |
| 169 | + | Err(e) => { | |
| 170 | + | tracing::warn!(field = %field.key, error = %e, "Failed to encrypt secret, plaintext retained"); | |
| 171 | + | } | |
| 167 | 172 | } | |
| 168 | 173 | } | |
| 169 | 174 | } | |
| @@ -185,8 +190,13 @@ pub fn decrypt_config_secrets( | |||
| 185 | 190 | } | |
| 186 | 191 | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 187 | 192 | if val.starts_with(PREFIX) { | |
| 188 | - | if let Ok(decrypted) = decrypt_field(val, key) { | |
| 189 | - | obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); | |
| 193 | + | match decrypt_field(val, key) { | |
| 194 | + | Ok(decrypted) => { | |
| 195 | + | obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); | |
| 196 | + | } | |
| 197 | + | Err(e) => { | |
| 198 | + | tracing::warn!(field = %field.key, error = %e, "Failed to decrypt secret, ciphertext passed through"); | |
| 199 | + | } | |
| 190 | 200 | } | |
| 191 | 201 | } | |
| 192 | 202 | } |
| @@ -8,6 +8,7 @@ use std::sync::Arc; | |||
| 8 | 8 | ||
| 9 | 9 | use bb_db::Database; | |
| 10 | 10 | use bb_interface::{BusserConfig, ConfigFieldType}; | |
| 11 | + | use futures::stream::{self, StreamExt}; | |
| 11 | 12 | use thiserror::Error; | |
| 12 | 13 | use tokio::sync::RwLock; | |
| 13 | 14 | use tracing::{debug, error, info, instrument}; | |
| @@ -291,7 +292,7 @@ impl Orchestrator { | |||
| 291 | 292 | self.fetch_plugin(plugin_id).await | |
| 292 | 293 | } | |
| 293 | 294 | ||
| 294 | - | /// Fetch from all active plugins | |
| 295 | + | /// Fetch from all active plugins concurrently (up to 4 at a time) | |
| 295 | 296 | #[instrument(skip_all)] | |
| 296 | 297 | pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> { | |
| 297 | 298 | let plugin_ids = { | |
| @@ -299,15 +300,19 @@ impl Orchestrator { | |||
| 299 | 300 | plugins.list_plugins() | |
| 300 | 301 | }; | |
| 301 | 302 | ||
| 302 | - | let mut total = 0; | |
| 303 | - | for plugin_id in plugin_ids { | |
| 304 | - | match self.fetch_plugin(&plugin_id).await { | |
| 305 | - | Ok(count) => total += count, | |
| 306 | - | Err(e) => { | |
| 307 | - | error!(error = %e, %plugin_id, "Failed to fetch from plugin"); | |
| 303 | + | let total: usize = stream::iter(plugin_ids) | |
| 304 | + | .map(|plugin_id| async move { | |
| 305 | + | match self.fetch_plugin(&plugin_id).await { | |
| 306 | + | Ok(count) => count, | |
| 307 | + | Err(e) => { | |
| 308 | + | error!(error = %e, %plugin_id, "Failed to fetch from plugin"); | |
| 309 | + | 0 | |
| 310 | + | } | |
| 308 | 311 | } | |
| 309 | - | } | |
| 310 | - | } | |
| 312 | + | }) | |
| 313 | + | .buffer_unordered(4) | |
| 314 | + | .fold(0usize, |acc, count| async move { acc + count }) | |
| 315 | + | .await; | |
| 311 | 316 | ||
| 312 | 317 | Ok(total) | |
| 313 | 318 | } |
| @@ -60,6 +60,18 @@ fn validate_url(url: &str) -> Result<(), String> { | |||
| 60 | 60 | { | |
| 61 | 61 | return Err(format!("Blocked request to internal address: {}", url)); | |
| 62 | 62 | } | |
| 63 | + | // Block IPv6 private/link-local ranges (fc00::/7, fe80::/10) | |
| 64 | + | // and IPv4-mapped IPv6 addresses (::ffff:x.x.x.x) | |
| 65 | + | if host.starts_with('[') { | |
| 66 | + | let inner = &host[1..host.len().saturating_sub(1)].to_ascii_lowercase(); | |
| 67 | + | if inner.starts_with("fc") | |
| 68 | + | || inner.starts_with("fd") | |
| 69 | + | || inner.starts_with("fe80") | |
| 70 | + | || inner.starts_with("::ffff:") | |
| 71 | + | { | |
| 72 | + | return Err(format!("Blocked request to internal address: {}", url)); | |
| 73 | + | } | |
| 74 | + | } | |
| 63 | 75 | // Block 172.16.0.0/12 | |
| 64 | 76 | if let Some(rest) = host.strip_prefix("172.") { | |
| 65 | 77 | if let Some(second) = rest.split('.').next() { | |
| @@ -630,6 +642,23 @@ mod tests { | |||
| 630 | 642 | } | |
| 631 | 643 | ||
| 632 | 644 | #[test] | |
| 645 | + | fn validate_url_blocks_ipv6_private_ranges() { | |
| 646 | + | // Unique local addresses (fc00::/7) | |
| 647 | + | assert!(validate_url("http://[fc00::1]/api").is_err()); | |
| 648 | + | assert!(validate_url("http://[fd12:3456::1]/api").is_err()); | |
| 649 | + | // Link-local (fe80::/10) | |
| 650 | + | assert!(validate_url("http://[fe80::1]/api").is_err()); | |
| 651 | + | // IPv4-mapped IPv6 | |
| 652 | + | assert!(validate_url("http://[::ffff:127.0.0.1]/api").is_err()); | |
| 653 | + | assert!(validate_url("http://[::ffff:192.168.1.1]/api").is_err()); | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | #[test] | |
| 657 | + | fn validate_url_allows_public_ipv6() { | |
| 658 | + | assert!(validate_url("http://[2001:db8::1]/api").is_ok()); | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | #[test] | |
| 633 | 662 | fn validate_url_case_insensitive_scheme() { | |
| 634 | 663 | assert!(validate_url("HTTP://example.com").is_ok()); | |
| 635 | 664 | assert!(validate_url("HTTPS://example.com").is_ok()); |
| @@ -228,7 +228,7 @@ impl RhaiPlugin { | |||
| 228 | 228 | .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?; | |
| 229 | 229 | ||
| 230 | 230 | validate_dynamic_sizes(&result, 0) | |
| 231 | - | .map_err(|e| RhaiPluginError::RuntimeError(e))?; | |
| 231 | + | .map_err(RhaiPluginError::RuntimeError)?; | |
| 232 | 232 | ||
| 233 | 233 | dynamic_to_fetch_result(result, &self.id) | |
| 234 | 234 | } | |
| @@ -236,76 +236,59 @@ impl RhaiPlugin { | |||
| 236 | 236 | ||
| 237 | 237 | /// Manager for Rhai plugins. | |
| 238 | 238 | pub struct RhaiPluginManager { | |
| 239 | - | engine: Arc<Engine>, | |
| 240 | 239 | plugins: HashMap<String, RhaiPlugin>, | |
| 241 | - | request_counter: Arc<AtomicUsize>, | |
| 242 | - | fetch_deadline: Arc<AtomicU64>, | |
| 243 | 240 | } | |
| 244 | 241 | ||
| 245 | 242 | impl RhaiPluginManager { | |
| 246 | - | /// Create a new plugin manager with the Rhai engine configured. | |
| 243 | + | /// Create a new plugin manager. | |
| 247 | 244 | /// | |
| 248 | - | /// Safety limits prevent malicious or buggy scripts from hanging the app: | |
| 249 | - | /// - `max_operations(100_000)`: caps total operations per script execution. | |
| 250 | - | /// A typical RSS fetch costs 1k–5k ops; 100k allows complex plugins while | |
| 251 | - | /// catching infinite loops. | |
| 252 | - | /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions | |
| 253 | - | /// and functions, preventing stack overflows from deeply recursive scripts. | |
| 254 | - | /// - `max_call_levels(32)`: limits function call nesting depth. | |
| 255 | - | /// - HTTP limits: 15s timeout, 2 MB response cap, 100 requests per fetch, | |
| 256 | - | /// http/https only, no localhost/internal addresses. | |
| 257 | - | /// | |
| 258 | - | /// Data size limits (max_string_size, max_array_size, max_map_size) are | |
| 259 | - | /// intentionally disabled. Rhai counts these cumulatively across the entire | |
| 260 | - | /// nested value tree (not per container), so normal API responses trip modest | |
| 261 | - | /// limits. The 2 MB HTTP response cap is the real memory gate. | |
| 245 | + | /// Each plugin gets its own Rhai engine (created in `load_plugin()`) with | |
| 246 | + | /// independent request counters and fetch deadlines. This prevents concurrent | |
| 247 | + | /// plugins from interfering with each other's rate limits. | |
| 262 | 248 | pub fn new() -> Self { | |
| 263 | - | let mut engine = Engine::new(); | |
| 264 | - | ||
| 265 | - | engine.set_max_expr_depths(128, 128); | |
| 266 | - | engine.set_max_operations(100_000); | |
| 267 | - | engine.set_max_call_levels(32); | |
| 268 | - | ||
| 269 | - | let request_counter = Arc::new(AtomicUsize::new(0)); | |
| 270 | - | let fetch_deadline = Arc::new(AtomicU64::new(0)); | |
| 271 | - | register_host_functions(&mut engine, request_counter.clone(), fetch_deadline.clone()); | |
| 272 | - | ||
| 273 | 249 | Self { | |
| 274 | - | engine: Arc::new(engine), | |
| 275 | 250 | plugins: HashMap::new(), | |
| 276 | - | request_counter, | |
| 277 | - | fetch_deadline, | |
| 278 | 251 | } | |
| 279 | 252 | } | |
| 280 | 253 | ||
| 281 | 254 | /// Load a plugin from a .rhai file. | |
| 255 | + | /// | |
| 256 | + | /// Creates a fresh Rhai engine per plugin with its own request counter and | |
| 257 | + | /// fetch deadline. This isolates concurrent plugins from interfering with | |
| 258 | + | /// each other's rate limits during `fetch_all()`. | |
| 282 | 259 | pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> { | |
| 283 | 260 | let script = std::fs::read_to_string(path) | |
| 284 | 261 | .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?; | |
| 285 | 262 | ||
| 286 | - | let ast = self | |
| 287 | - | .engine | |
| 263 | + | // Create a per-plugin engine with its own atomics | |
| 264 | + | let mut engine = Engine::new(); | |
| 265 | + | engine.set_max_expr_depths(128, 128); | |
| 266 | + | engine.set_max_operations(100_000); | |
| 267 | + | engine.set_max_call_levels(32); | |
| 268 | + | let request_counter = Arc::new(AtomicUsize::new(0)); | |
| 269 | + | let fetch_deadline = Arc::new(AtomicU64::new(0)); | |
| 270 | + | register_host_functions(&mut engine, request_counter.clone(), fetch_deadline.clone()); | |
| 271 | + | let engine = Arc::new(engine); | |
| 272 | + | ||
| 273 | + | let ast = engine | |
| 288 | 274 | .compile(&script) | |
| 289 | 275 | .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?; | |
| 290 | 276 | ||
| 291 | 277 | // Evaluate top-level code (const declarations) so they're visible in functions | |
| 292 | 278 | let mut scope = Scope::new(); | |
| 293 | - | self.engine | |
| 279 | + | engine | |
| 294 | 280 | .run_ast_with_scope(&mut scope, &ast) | |
| 295 | 281 | .map_err(|e| RhaiPluginError::CompileError(format!("Top-level eval failed: {}", e)))?; | |
| 296 | 282 | ||
| 297 | - | let id: String = self | |
| 298 | - | .engine | |
| 283 | + | let id: String = engine | |
| 299 | 284 | .call_fn(&mut scope, &ast, "id", ()) | |
| 300 | 285 | .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {}", e)))?; | |
| 301 | 286 | ||
| 302 | - | let name: String = self | |
| 303 | - | .engine | |
| 287 | + | let name: String = engine | |
| 304 | 288 | .call_fn(&mut scope, &ast, "name", ()) | |
| 305 | 289 | .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {}", e)))?; | |
| 306 | 290 | ||
| 307 | - | let _: Dynamic = self | |
| 308 | - | .engine | |
| 291 | + | let _: Dynamic = engine | |
| 309 | 292 | .call_fn(&mut scope, &ast, "config_schema", ()) | |
| 310 | 293 | .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?; | |
| 311 | 294 | ||
| @@ -316,9 +299,9 @@ impl RhaiPluginManager { | |||
| 316 | 299 | name, | |
| 317 | 300 | path: path.to_path_buf(), | |
| 318 | 301 | ast, | |
| 319 | - | engine: self.engine.clone(), | |
| 320 | - | request_counter: self.request_counter.clone(), | |
| 321 | - | fetch_deadline: self.fetch_deadline.clone(), | |
| 302 | + | engine, | |
| 303 | + | request_counter, | |
| 304 | + | fetch_deadline, | |
| 322 | 305 | }; | |
| 323 | 306 | ||
| 324 | 307 | self.plugins.insert(id.clone(), plugin); | |
| @@ -590,20 +573,20 @@ mod tests { | |||
| 590 | 573 | ||
| 591 | 574 | #[test] | |
| 592 | 575 | fn sandbox_enforces_operation_limit() { | |
| 593 | - | let manager = RhaiPluginManager::new(); | |
| 594 | - | let result = manager.engine.eval::<()>("let x = 0; loop { x += 1; }"); | |
| 576 | + | let engine = create_engine(); | |
| 577 | + | let result = engine.eval::<()>("let x = 0; loop { x += 1; }"); | |
| 595 | 578 | assert!(result.is_err(), "infinite loop should be stopped by operation limit"); | |
| 596 | 579 | } | |
| 597 | 580 | ||
| 598 | 581 | #[test] | |
| 599 | 582 | fn sandbox_enforces_call_level_limit() { | |
| 600 | - | let manager = RhaiPluginManager::new(); | |
| 583 | + | let engine = create_engine(); | |
| 601 | 584 | // Recursive function that exceeds 32 call levels | |
| 602 | 585 | let script = r#" | |
| 603 | 586 | fn recurse(n) { recurse(n + 1) } | |
| 604 | 587 | recurse(0) | |
| 605 | 588 | "#; | |
| 606 | - | let result = manager.engine.eval::<()>(script); | |
| 589 | + | let result = engine.eval::<()>(script); | |
| 607 | 590 | assert!(result.is_err(), "deep recursion should be stopped by call level limit"); | |
| 608 | 591 | } | |
| 609 | 592 |
| @@ -3,6 +3,8 @@ | |||
| 3 | 3 | //! `OrderBy` controls sort order; `FeedFilter` narrows which items | |
| 4 | 4 | //! are shown. Both are applied in-memory after the database query. | |
| 5 | 5 | ||
| 6 | + | use std::collections::HashMap; | |
| 7 | + | ||
| 6 | 8 | use bb_db::QueryCondition; | |
| 7 | 9 | use bb_interface::FeedItem; | |
| 8 | 10 | use regex::Regex; | |
| @@ -160,8 +162,35 @@ impl FeedFilter { | |||
| 160 | 162 | filter | |
| 161 | 163 | } | |
| 162 | 164 | ||
| 165 | + | /// Pre-compile regex patterns from conditions. Called once before filtering | |
| 166 | + | /// a batch of items to avoid recompiling per-item. | |
| 167 | + | fn compile_regexes(&self) -> HashMap<usize, Regex> { | |
| 168 | + | let mut cache = HashMap::new(); | |
| 169 | + | for (i, c) in self.conditions.iter().enumerate() { | |
| 170 | + | if c.operator == "matches_regex" { | |
| 171 | + | match Regex::new(&c.value) { | |
| 172 | + | Ok(re) => { cache.insert(i, re); } | |
| 173 | + | Err(e) => { | |
| 174 | + | tracing::warn!( | |
| 175 | + | regex = %c.value, | |
| 176 | + | error = %e, | |
| 177 | + | "Invalid regex in query feed condition, skipping" | |
| 178 | + | ); | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | cache | |
| 184 | + | } | |
| 185 | + | ||
| 163 | 186 | /// Check if an item matches the filter | |
| 164 | 187 | pub fn matches(&self, item: &FeedItem) -> bool { | |
| 188 | + | let cache = self.compile_regexes(); | |
| 189 | + | self.matches_with_cache(item, &cache) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// Check if an item matches using pre-compiled regex cache. | |
| 193 | + | fn matches_with_cache(&self, item: &FeedItem, regex_cache: &HashMap<usize, Regex>) -> bool { | |
| 165 | 194 | // Check source filter | |
| 166 | 195 | if let Some(ref source) = self.source { | |
| 167 | 196 | if item.id.source.as_str() != source { | |
| @@ -214,7 +243,7 @@ impl FeedFilter { | |||
| 214 | 243 | // Source/starred/unread/tag conditions are already handled by the | |
| 215 | 244 | // fast-path fields above (set in `from_conditions()`), so we only | |
| 216 | 245 | // need to evaluate title/author/body conditions here. | |
| 217 | - | for c in &self.conditions { | |
| 246 | + | for (i, c) in self.conditions.iter().enumerate() { | |
| 218 | 247 | match c.field.as_str() { | |
| 219 | 248 | "title" | "author" | "body" => { | |
| 220 | 249 | let field_value = match c.field.as_str() { | |
| @@ -240,20 +269,12 @@ impl FeedFilter { | |||
| 240 | 269 | } | |
| 241 | 270 | } | |
| 242 | 271 | "matches_regex" => { | |
| 243 | - | match Regex::new(&c.value) { | |
| 244 | - | Ok(re) => { | |
| 245 | - | if !re.is_match(field_value) { | |
| 246 | - | return false; | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | Err(e) => { | |
| 250 | - | tracing::warn!( | |
| 251 | - | regex = %c.value, | |
| 252 | - | error = %e, | |
| 253 | - | "Invalid regex in query feed condition, skipping" | |
| 254 | - | ); | |
| 272 | + | if let Some(re) = regex_cache.get(&i) { | |
| 273 | + | if !re.is_match(field_value) { | |
| 274 | + | return false; | |
| 255 | 275 | } | |
| 256 | 276 | } | |
| 277 | + | // Missing from cache = invalid regex, skip (already warned) | |
| 257 | 278 | } | |
| 258 | 279 | _ => {} | |
| 259 | 280 | } | |
| @@ -268,7 +289,8 @@ impl FeedFilter { | |||
| 268 | 289 | ||
| 269 | 290 | /// Apply filter to a list of items | |
| 270 | 291 | pub fn apply(&self, items: Vec<FeedItem>) -> Vec<FeedItem> { | |
| 271 | - | items.into_iter().filter(|item| self.matches(item)).collect() | |
| 292 | + | let cache = self.compile_regexes(); | |
| 293 | + | items.into_iter().filter(|item| self.matches_with_cache(item, &cache)).collect() | |
| 272 | 294 | } | |
| 273 | 295 | ||
| 274 | 296 | /// Apply only the tag filter to a list of items. |
| @@ -1,227 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast — Completed Work | |
| 2 | - | ||
| 3 | - | Archived completed sections from todo.md. All items here are done. | |
| 4 | - | ||
| 5 | - | ## Tauri Conversion | |
| 6 | - | ||
| 7 | - | ### Done | |
| 8 | - | - [x] Rewrote bb-db for SQLite (PgPool → SqlitePool, $N → ?N placeholders, model types to String) | |
| 9 | - | - [x] Created SQLite migrations (feeds, feed_items, busser_state) | |
| 10 | - | - [x] Removed bb-display (TUI), bb-web (Axum), old CLI entry point | |
| 11 | - | - [x] Created src-tauri Tauri 2 app shell (Cargo.toml, build.rs, tauri.conf.json, capabilities) | |
| 12 | - | - [x] Implemented AppState with Orchestrator, plugin loading, DB init | |
| 13 | - | - [x] Ported Axum handlers to 15 Tauri commands (items, sources, plugins, feeds) | |
| 14 | - | - [x] Built vanilla JS frontend with BB namespace (api, state, utils, components, sources, items, detail, feeds, app) | |
| 15 | - | - [x] Three-panel layout (sources sidebar, items list, item detail) | |
| 16 | - | - [x] Native menu bar (File: Refresh/Add Feed, View: All/Unread/Starred, Edit, Help) | |
| 17 | - | - [x] Keyboard shortcuts (j/k navigate, s star, r read, / search, Escape close) | |
| 18 | - | - [x] Plugin bundling (dev-mode fallback to project-root plugins/, production copy to config dir) | |
| 19 | - | - [x] Deleted old PostgreSQL migrations | |
| 20 | - | ||
| 21 | - | ## Phase 1: Polish | |
| 22 | - | ||
| 23 | - | ### Done | |
| 24 | - | - [x] Fix stale PostgreSQL default in OrchestratorConfig | |
| 25 | - | - [x] Remove dead code: LoadedPlugin struct, unused config field, JS computeTimeAgo/formatTime | |
| 26 | - | - [x] Extract TIMESTAMP_FMT constant (used throughout repository.rs) | |
| 27 | - | - [x] Extract shared parse_timestamp helper (deduplicated 3 copies) | |
| 28 | - | - [x] Replace magic sort-order strings with OrderBy::from_str_loose | |
| 29 | - | - [x] Fix expect() panic in state.rs plugin dir resolution (fallback chain) | |
| 30 | - | - [x] Add PluginError::FetchError variant (was misusing InitError) | |
| 31 | - | - [x] Add ApiError::bad_request variant (invalid UUIDs no longer return not_found) | |
| 32 | - | - [x] Set Rhai max operations limit to 100,000 | |
| 33 | - | - [x] Fix per-source unread count (count_unread_by_busser query, wired into generator + sidebar) | |
| 34 | - | - [x] Wrap delete_feed in SQLite transaction | |
| 35 | - | - [x] Delete feed from UI (delete button on sources sidebar, delete_feeds_by_busser command) | |
| 36 | - | - [x] Background auto-fetch per plugin (BusserCapabilities.fetch_interval_secs, default 15min, 60s check loop, frontend auto-refresh via event) | |
| 37 | - | - [x] Generate proper app icons (fried-egg AI-star parody logo, all Tauri formats) | |
| 38 | - | - [x] Plugin error handling (warn!() on capabilities/config_schema failure, debug!() on expected empty paths, auto-fetch-error event + toast) | |
| 39 | - | - [x] Fix silent unwrap_or_default() in db models (parse_or_default helper with tracing::warn, 6 call sites) | |
| 40 | - | ||
| 41 | - | ## Phase 2: Features | |
| 42 | - | ||
| 43 | - | ### Done | |
| 44 | - | - [x] OPML import (File menu + Cmd+I, parses outlines with xmlUrl, deduplicates) | |
| 45 | - | - [x] OPML export (File menu + Cmd+E, exports RSS feeds as OPML 2.0 download) | |
| 46 | - | - [x] URL tracker parameter stripping (strip utm_*, fbclid, gclid, msclkid from item links/body on fetch; exposed to Rhai plugins) | |
| 47 | - | - [x] JSON Feed format support (JSON Feed 1.0/1.1 auto-detected in parse_feed, 4 tests) | |
| 48 | - | - [x] Feed tags (feed_tags junction table, TagsRepository CRUD, sidebar tag filter bar + tag chips, 6 tests) | |
| 49 | - | - [x] Full-text search (FTS5 external content mode, 3 sync triggers, sanitize_fts_query, rank ordering, 6 tests) | |
| 50 | - | - [x] Feed health monitoring (consecutive_failures/last_error/last_success_at on feeds, record_success/failure, server-authoritative health dots in sidebar, 3 tests) | |
| 51 | - | - [x] Theming support (9 TOML themes, bundled + user custom, high-contrast accessibility theme, multi-source path resolution) | |
| 52 | - | - [x] Stale item cleanup (background task every 6h, deletes read non-starred items older than 30 days) | |
| 53 | - | - [x] Validate feed config inputs before storage (name, field types, lengths, duplicates) | |
| 54 | - | - [x] Encrypt plugin secrets at rest (AES-256-GCM, bb_enc:v1: format, auto-migration) | |
| 55 | - | ||
| 56 | - | ## Pre-Launch Fixes (from audit 2026-02-27) | |
| 57 | - | ||
| 58 | - | ### Done | |
| 59 | - | - [x] Moved raw SQL from `orchestrator.rs` to `FeedsRepository::update_config()` (layer violation fixed) | |
| 60 | - | - [x] Wrapped `TagsRepository::set_tags()` DELETE+INSERTs in a transaction (repository.rs) | |
| 61 | - | - [x] Removed unused `thiserror` dep from bb-interface Cargo.toml | |
| 62 | - | ||
| 63 | - | ## Phase 3: Testing & Docs | |
| 64 | - | ||
| 65 | - | ### Done | |
| 66 | - | - [x] 4 unit tests in bb-core plugin_manager | |
| 67 | - | - [x] Unit tests for bb-db repository layer (25 tests: 8 feeds, 12 items, 5 state + 6 tags + 6 FTS5 + 3 health) | |
| 68 | - | - [x] Unit tests for bb-core url_cleaner (10 tests) and conversions/json_feed (4 tests) | |
| 69 | - | - [x] Unit tests for bb-feed ordering/generator (~15 tests, lines 295-508) | |
| 70 | - | - [x] Integration tests for Tauri commands (33 tests: OPML import/export, item CRUD, feed CRUD, tags, orchestrator) | |
| 71 | - | - [x] CI pipeline — `.build.yml` for builds.sr.ht (check, test, clippy, audit) | |
| 72 | - | - [x] README with setup instructions, workspace architecture, and plugin authoring guide | |
| 73 | - | ||
| 74 | - | ## Audit Follow-Up (2026-02-27) | |
| 75 | - | ||
| 76 | - | ### Done — Security | |
| 77 | - | - [x] Fix single-quote XSS in tag filter bar — uses `addEventListener` (not inline onclick) (`sources.js`) | |
| 78 | - | - [x] Set restrictive file permissions on `encryption.key` — sets `0o600` on Unix (`crypto.rs`) | |
| 79 | - | - [x] Replace `.expect("poisoned")` with error propagation — all 6 uses `.map_err()` (`plugin_manager.rs`, `state.rs`) | |
| 80 | - | ||
| 81 | - | ### Done — Code Quality | |
| 82 | - | - [x] Remove deprecated stub functions `markFailed`, `clearFailed`, `clearAllFailed` — removed from `sources.js` | |
| 83 | - | - [x] `sanitizeHtml` blocks `data:` URIs alongside `javascript:` (`utils.js`) | |
| 84 | - | ||
| 85 | - | ### Done — Testing | |
| 86 | - | - [x] Add unit tests for `FeedFilter::apply_tags_only()` and tag-related `matches()` paths — 8 tests in `ordering.rs` | |
| 87 | - | - [x] Add Tauri command unit tests — extracted `validate_feed_input` from `create_feed`, 25 tests for feed validation (name, config, URL, number, select, toggle, text length), 5 tests for `format_time_ago`, 9 tests for `validate_theme_id` and `parse_meta` (`commands/feeds.rs`, `commands/items.rs`, `commands/themes.rs`) | |
| 88 | - | ||
| 89 | - | ### Done — Clippy (2026-02-28, third audit) | |
| 90 | - | - [x] Fix `items_after_test_module` in `bb-interface/src/busser.rs` — moved Busser trait above test module | |
| 91 | - | - [x] Fix `useless_vec` in `bb-interface/src/busser.rs` — changed to array literal | |
| 92 | - | - [x] Fix `bool_assert_comparison` in `bb-core/rhai_plugin/conversions.rs` — use `assert!()` | |
| 93 | - | - [x] Fix `approx_constant` in `bb-core/rhai_plugin/conversions.rs` — `#[allow]` (intentional `3.14` round-trip test) | |
| 94 | - | - [x] Fix `unnecessary_get_then_check` in `bb-core/rhai_plugin/conversions.rs` — use `!map.contains_key()` | |
| 95 | - | ||
| 96 | - | ### Done — Test Coverage (Mar 1, 2026) | |
| 97 | - | - [x] Host function tests: 12 tests (parse_int, parse_datetime, html_to_text, str_contains, str_split, timestamp_now) (`crates/bb-core/src/rhai_plugin/host_functions.rs`) | |
| 98 | - | - [x] Orchestrator tests: 5 tests (config defaults, new+migrate, fetch interval, store+dedup) (`crates/bb-core/src/orchestrator.rs`) | |
| 99 | - | - [x] State timing tests: 3 tests (first time, overdue, not yet due) — extracted `is_single_feed_due` pure helper (`src-tauri/src/state.rs`) | |
| 100 | - | ||
| 101 | - | ### Done — Error Handling (Mar 1, 2026) | |
| 102 | - | - [x] `From<sqlx::Error>` for `ApiError` — eliminates 28 `.map_err(|e| ApiError::database(e.to_string()))` calls | |
| 103 | - | - [x] `From<bb_feed::FeedError>` for `ApiError` — same pattern for feed generator errors | |
| 104 | - | - [x] `From<OrchestratorError>` for `ApiError` — semantic mapping (Database→DATABASE, Plugin→PLUGIN, Feed→DATABASE, Config→INTERNAL) | |
| 105 | - | - [x] Logged silent event emission failures in auto-fetch background task (`state.rs`) | |
| 106 | - | ||
| 107 | - | --- | |
| 108 | - | ||
| 109 | - | ## JS Audit (2026-03-11) — Complete (8/8) | |
| 110 | - | ||
| 111 | - | ### Done — Critical | |
| 112 | - | - [x] Add escapeAttr() to item.id in onclick handler (items.js, new escapeAttr function in utils.js) | |
| 113 | - | ||
| 114 | - | ### Done — Medium | |
| 115 | - | - [x] Remove stale OAuth polling loop in settings-sync.js (removed 29-line dead polling loop + unused pendingAuth) | |
| 116 | - | - [x] Deduplicate updateReadState/updateStarState into single updateItemField(id, field, value) in items.js | |
| 117 | - | - [x] Add escapeHtml() to timeAgo and score in detail.js and items.js innerHTML | |
| 118 | - | - [x] Fix detail.js state desync (onItemsChanged subscriber merges summary fields into currentItem on external refresh) | |
| 119 | - | ||
| 120 | - | ### Done — Low | |
| 121 | - | - [x] Remove unused pendingAuth (already done) and total variables (feeds.js) | |
| 122 | - | - [x] Replace prompt() with BB.ui.openFormModal() for tag editing in sources.js | |
| 123 | - | - [x] Replace inline styles with 6 CSS classes in settings-sync.js | |
| 124 | - | ||
| 125 | - | --- | |
| 126 | - | ||
| 127 | - | ## Phase 5: Query Feeds & Reader View (Done) | |
| 128 | - | ||
| 129 | - | ### Done | |
| 130 | - | - [x] Filter/query feeds: virtual feeds from keyword/regex rules across all sources (migration 009, QueryFeedsRepository CRUD, FeedFilter conditions with SQL fast-path + in-memory eval, AND logic) | |
| 131 | - | - [x] Query feed builder UI (condition builder modal, field/operator/value rows, match count preview, sidebar integration with "Saved Filters" section) | |
| 132 | - | - [x] Reader view: `extract_article` host function (readable-readability crate), `plugins/reader.rhai`, Tauri command, detail panel button | |
| 133 | - | ||
| 134 | - | ## Phase 6: SyncKit Integration (Done) | |
| 135 | - | ||
| 136 | - | Cloud sync for feed configs, read/star state, and user preferences via MNW SyncKit. Follows GO's proven pattern (changelog triggers, push/pull, E2E encryption, OAuth2 PKCE). | |
| 137 | - | ||
| 138 | - | ### Done | |
| 139 | - | - [x] Migration 007: `user_config` key-value table, `sync_state` table, `sync_changelog` table | |
| 140 | - | - [x] Triggers on `feeds` (INSERT/UPDATE/DELETE), `feed_tags` (INSERT/DELETE), `user_config` (INSERT/UPDATE/DELETE), `feed_items` UPDATE (is_read/is_starred only) | |
| 141 | - | - [x] All triggers conditional on `applying_remote != '1'` | |
| 142 | - | - [x] `synckit-client` dependency added to workspace + src-tauri | |
| 143 | - | - [x] `SyncKitClient` added to AppState, configured via `BB_SYNC_SERVER_URL` / `BB_SYNC_API_KEY` env vars | |
| 144 | - | - [x] `sync_service.rs`: table whitelist, FK ordering (UPSERT_ORDER/DELETE_ORDER), push/pull, initial snapshot, cleanup, partial feed_items sync (user state only) | |
| 145 | - | - [x] `sync_scheduler.rs`: 60s check interval, exponential backoff (2^n min, cap 15), `sync:changes-applied` event | |
| 146 | - | - [x] 8 Tauri commands: sync_status, sync_start_auth, sync_complete_auth, sync_disconnect, sync_now, sync_setup_encryption_new, sync_setup_encryption_existing, sync_update_settings | |
| 147 | - | - [x] OAuth2 PKCE flow with inline callback server | |
| 148 | - | - [x] `settings-sync.js`: 4-state sync settings UI (connect, authenticating, encryption, ready) | |
| 149 | - | - [x] Sync button in sidebar, `BB.sync` namespace, `BB.api.sync` API methods | |
| 150 | - | ||
| 151 | - | ### Done | |
| 152 | - | - [x] Move localStorage values (bb-theme, bb-welcomed) into `user_config` on app startup | |
| 153 | - | - [x] Read/write preferences via `user_config` table instead of localStorage | |
| 154 | - | ||
| 155 | - | ### Design Notes | |
| 156 | - | - `feed_items` triggers fire only on UPDATE of `is_read`/`is_starred` — avoids syncing fetched content (thousands of rows per refresh) | |
| 157 | - | - Feed content re-fetches from source on each device; only user state (read/star) syncs | |
| 158 | - | - `busser_state` (plugin cursors) does NOT sync — pagination position is device-local | |
| 159 | - | ||
| 160 | - | ## Alpha Polish (2026-03-08) | |
| 161 | - | - [x] Feed editing: `get_feed`/`update_feed` commands, `update_name` repository method, edit button + modal in sources sidebar (feeds.rs, repository.rs, sources.js, api.js) | |
| 162 | - | ||
| 163 | - | ## Audit Follow-Up (2026-02-27) | |
| 164 | - | ||
| 165 | - | ### Done — Testing | |
| 166 | - | - [x] `FeedsRepository` tests (update_name, update_config, fetch success/failure health tracking) | |
| 167 | - | - [x] `ItemsRepository` tests (count_by_busser, count_unread_by_busser, counts_by_busser bulk, search with source/unread/starred filters) | |
| 168 | - | - [x] `TagsRepository` tests (all_feed_tags bulk retrieval) | |
| 169 | - | - [x] `ConfigRepository` tests (get/set/delete, overwrite semantics) | |
| 170 | - | - [x] `StateRepository` tests (list ordering) | |
| 171 | - | ||
| 172 | - | ### Done — Testing | |
| 173 | - | - [x] Add unit tests for `generator.rs` in-memory filtering logic (26 tests: ordering, tag filtering, pagination, combined filters, sources) | |
| 174 | - | ||
| 175 | - | ### Done — Testing | |
| 176 | - | - [x] Add integration tests for Tauri commands with in-memory DB (51 tests: item CRUD, mark read/star, pagination with filters, sources, feeds, tags, config, circuit breaker, themes) | |
| 177 | - | ||
| 178 | - | ### Done — Documentation | |
| 179 | - | - [x] README with setup instructions (build, run, plugin authoring basics) | |
| 180 | - | - [x] Plugin authoring guide (Rhai API surface, host functions, capabilities, config schema, fetch_interval_secs, safety limits) | |
| 181 | - | ||
| 182 | - | ## Audit Follow-Up (2026-03-11, fifth audit) | |
| 183 | - | ||
| 184 | - | ### Done — Rhai HTTP Safety | |
| 185 | - | - [x] Add HTTP request count limit to Rhai http_get/http_get_json (100 per fetch, Arc<AtomicUsize> reset before each fetch()) | |
| 186 | - | - [x] Add HTTP response size cap to Rhai http_get/http_get_json (2 MB via .take()) | |
| 187 | - | - [x] Add per-request timeout to Rhai http_get/http_get_json (15s via ureq .timeout()) | |
| 188 | - | - [x] Add URL scheme restriction to http_get (http/https only, block localhost/internal/private ranges, IPv6 loopback) | |
| 189 | - | ||
| 190 | - | ### Done — Resilience | |
| 191 | - | - [x] Add circuit breaker: auto-disable feed after 10 consecutive failures (migration 008, circuit_broken column, Tauri event, reset command, 7 tests) | |
| 192 | - | - [x] Add sync changelog retention cap (10,000 entry cap, enforced every scheduler tick, 2 tests) | |
| 193 | - | ||
| 194 | - | ### Done — Hardening | |
| 195 | - | - [x] Harden FTS query sanitization (strip ^/* inside quotes, handle NEAR/column: via quoting, empty query guard, 16 tests) | |
| 196 | - | - [x] Verify applying_remote flag cleared on startup (crash recovery) — cleared at top of perform_sync() | |
| 197 | - | ||
| 198 | - | ### Done — Observability | |
| 199 | - | - [x] Migrate to structured logging (tracing) — 83 log statements enhanced with structured fields across 14 files | |
| 200 | - | ||
| 201 | - | ## Audit Follow-Up (2026-03-13, sixth audit — pre-launch skeptical lens) | |
| 202 | - | ||
| 203 | - | - [x] Fix `sync_disconnect` — was a no-op, now clears in-memory session via `client.clear_session()` + clears DB sync state/changelog via `clear_all_sync_state()` | |
| 204 | - | - [x] Add `<base>` to DANGEROUS_ELEMENTS in HTML sanitizer (`src-tauri/frontend/js/utils.js`) | |
| 205 | - | - [x] Add URL scheme validation to OPML import path (`src-tauri/src/commands/opml.rs` — rejects non-http(s) URLs with error) | |
| 206 | - | - [x] Test count discrepancy resolved — 520 is correct (audit agent miscounted) | |
| 207 | - | - [x] Harden `escapeAttr()` to HTML-encode all 5 special characters (`&`, `<`, `>`, `"`, `'`) | |
| 208 | - | ||
| 209 | - | ## Deferred (Done) | |
| 210 | - | - [x] Unify pagination strategy (get_all_items now uses MAX_ALL_ITEMS+1 for has_more detection, matching get_items) | |
| 211 | - | ||
| 212 | - | --- | |
| 213 | - | ||
| 214 | - | ## Rust Patterns Audit (2026-03-21) | |
| 215 | - | ||
| 216 | - | - [x] Release RwLock before calling plugin fetch — drop lock before async network I/O (`orchestrator.rs`) | |
| 217 | - | - [x] Avoid cloning item fields in conversion functions — `feed_item_to_summary` takes ownership, uses moves (`commands/items.rs`) | |
| 218 | - | - [x] Avoid cloning JSON config before in-place mutation — serialize before move (`commands/feeds.rs`) | |
| 219 | - | - [x] Avoid cloning plugin config fields when building response — `into_iter()` instead of `iter()` + clone (`commands/plugins.rs`) | |
| 220 | - | ||
| 221 | - | --- | |
| 222 | - | ||
| 223 | - | ## TagTree Integration (2026-03-21) | |
| 224 | - | ||
| 225 | - | - [x] Add `tagtree` workspace dependency | |
| 226 | - | - [x] Add `BB_TAG_CONFIG` (max_depth 3, max_length 80) validation at command boundary in `set_feed_tags()` | |
| 227 | - | - [x] All 544 tests pass |
| @@ -168,7 +168,6 @@ Clean doc set. README and plugin_authoring.md are good additions from recent aud | |||
| 168 | 168 | | Document | Status | Last Verified | Notes | | |
| 169 | 169 | |----------|:------:|:-------------:|-------| | |
| 170 | 170 | | docs/apps/bb/todo.md | Current | 2026-03-11 | Updated with audit 5 action items | | |
| 171 | - | | docs/apps/bb/todo_done.md | Current | 2026-03-04 | Completed items archive | | |
| 172 | 171 | | docs/apps/bb/description.md | Placeholder | 2026-03-04 | Intentional placeholder | | |
| 173 | 172 | | docs/apps/bb/competition.md | Current | 2026-03-04 | Competitive analysis | | |
| 174 | 173 | | docs/apps/bb/structural_metrics.md | New | 2026-03-11 | Structural metrics from audit 5 | |
| @@ -1,58 +1,14 @@ | |||
| 1 | 1 | # Balanced Breakfast TODO | |
| 2 | 2 | ||
| 3 | 3 | ## Status | |
| 4 | - | Done: All pre-beta phases, UX polish (Phase 8). Active: None. Next: Post-beta features. | |
| 4 | + | Done: All pre-beta phases. Active: None. Next: Post-beta features. | |
| 5 | 5 | ||
| 6 | - | v0.3.0. Audit grade A. 10 bundled plugins. Rhai sandbox hardened. | |
| 7 | - | ||
| 8 | - | **Scope:** Pre-beta coding complete. All remaining sections are post-beta. | |
| 9 | - | ||
| 10 | - | Completed work archived in `docs/archive/bb_todo_done.md`. | |
| 11 | - | ||
| 12 | - | --- | |
| 13 | - | ||
| 14 | - | ## Phase 8: UX Polish | |
| 15 | - | ||
| 16 | - | Addresses non-STRONG grades from the UX audit (`_meta/uxaudit.md`, 2026-04-04). | |
| 17 | - | ||
| 18 | - | ### 8A: Active User — Sources Sidebar Empty State | |
| 19 | - | First-time users see an empty sidebar with no guidance. | |
| 20 | - | ||
| 21 | - | - [x] In `sources.js` `render()`, detect when `sources.length === 0` and show onboarding message | |
| 22 | - | - [x] Message: cooking pot emoji + "Add your first feed to get started" with pointer to Add Feed button | |
| 23 | - | - [x] Remove onboarding when first source is added (reactive via `BB.state.subscribe('sources')`) | |
| 24 | - | ||
| 25 | - | ### 8B: Goal-Gradient — Positive Unread Completion State | |
| 26 | - | When a source reaches 0 unread, show a positive visual cue instead of just removing the count. | |
| 27 | - | ||
| 28 | - | - [x] In `sources.js`, when `source.unreadCount === 0` and `source.totalCount > 0`, render checkmark icon | |
| 29 | - | - [x] CSS: `.source-count.all-read` with checkmark glyph + muted green color | |
| 30 | - | - [x] "All" aggregate: when `totalUnread === 0`, show checkmark on the All row too | |
| 31 | - | ||
| 32 | - | ### 8C: Von Restorff — Stronger Selected-Item Highlight | |
| 33 | - | Currently selected item uses `background-color: var(--bg-tertiary)` which is subtle. | |
| 34 | - | ||
| 35 | - | - [x] Add left border accent to `.item.selected`: `border-left-color: var(--yolk)` | |
| 36 | - | - [x] Ensure selected + unread combination is visually distinct (darker accent background) | |
| 37 | - | ||
| 38 | - | ### 8D: Peak-End Rule — Session Summary | |
| 39 | - | Give users a sense of accomplishment about their reading session. | |
| 40 | - | ||
| 41 | - | - [x] Track session stats in `BB.state`: `sessionArticlesRead`, `sessionArticlesStarred` | |
| 42 | - | - [x] On app close (`beforeunload`), show brief toast: "This session: N read, M starred" | |
| 43 | - | - [x] Reset counters on app launch (initialized to 0 in state defaults) | |
| 44 | - | ||
| 45 | - | ### 8E: Fitts's Law — Wider Star Toggle Target | |
| 46 | - | Star button is icon-only (~18px) with no padding. | |
| 47 | - | ||
| 48 | - | - [x] Expand `.star-btn` padding: `0.25rem 0.4rem` with negative margin to maintain layout | |
| 6 | + | v0.3.0. Audit grade A. 602 tests. | |
| 49 | 7 | ||
| 50 | 8 | --- | |
| 51 | 9 | ||
| 52 | 10 | ## Phase 7: Plugin OAuth (Post-beta) | |
| 53 | 11 | ||
| 54 | - | Authenticated plugin feeds — users log into services via OAuth to access personalized content. | |
| 55 | - | ||
| 56 | 12 | ### 7A: OAuth Infrastructure | |
| 57 | 13 | - [ ] New host functions: `oauth_start(provider, scopes)`, `oauth_token(provider)`, `oauth_refresh(provider)` | |
| 58 | 14 | - [ ] Tauri deep-link handler for OAuth callback URLs | |
| @@ -69,8 +25,6 @@ Authenticated plugin feeds — users log into services via OAuth to access perso | |||
| 69 | 25 | - [ ] Dev.to — reading list, followed tags | |
| 70 | 26 | ||
| 71 | 27 | ## Phase 4: Plugin Store (Long-term) | |
| 72 | - | ||
| 73 | - | Community plugin marketplace. Prerequisites: plugin sandboxing, plugin authoring guide. | |
| 74 | 28 | - [ ] 4A: Plugin registry backend (manifest format, index, submission flow, CI validation) | |
| 75 | 29 | - [ ] 4B: Client-side store UI (browse, search, install, update, uninstall) | |
| 76 | 30 | - [ ] 4C: Publishing from the app |
| @@ -1,187 +1,12 @@ | |||
| 1 | 1 | # Balanced Breakfast - Mobile Port | |
| 2 | 2 | ||
| 3 | - | Tauri 2 iOS/Android port. CSS-first responsive design with touch gestures. Based on GoingsOn mobile patterns. | |
| 4 | - | ||
| 5 | - | **What stays the same:** | |
| 6 | - | - `crates/bb-core/` — orchestrator, plugin manager, unchanged | |
| 7 | - | - `crates/bb-db/` — SQLite repository, unchanged | |
| 8 | - | - `crates/bb-interface/` — plugin types, unchanged | |
| 9 | - | - `crates/bb-feed/` — aggregation, unchanged | |
| 10 | - | - `src-tauri/src/commands/` — Tauri commands, unchanged | |
| 11 | - | - `src-tauri/frontend/js/` — all JS modules (minor mobile branches) | |
| 12 | - | - `BB.api` with `invoke()` — same IPC mechanism | |
| 13 | - | ||
| 14 | - | **What changes:** CSS responsiveness, new touch/mobile JS modules, bottom tab bar, single-column layout, Tauri mobile config. | |
| 15 | - | ||
| 16 | - | --- | |
| 17 | - | ||
| 18 | - | ## Design: Three-Panel → Single-Column | |
| 19 | - | ||
| 20 | - | **Desktop:** Sidebar (sources) | Items (list) | Detail (article) — all visible simultaneously. | |
| 21 | - | ||
| 22 | - | **Mobile (<=768px):** One panel at a time with drill-down navigation. | |
| 23 | - | ||
| 24 | - | ### Bottom Tab Bar | |
| 25 | - | ||
| 26 | - | | Tab | Default View | Description | | |
| 27 | - | |-----|-------------|-------------| | |
| 28 | - | | Feed | Items list (All) | Current feed items, sorted by selected order | | |
| 29 | - | | Saved | Starred items | Saved/starred articles | | |
| 30 | - | | Sources | Source list | Feed sources with unread counts, tap to filter | | |
| 31 | - | | + | Add Feed | Opens add-feed modal | | |
| 32 | - | | More | Popover | Settings, Sync, OPML, Shortcuts | | |
| 33 | - | ||
| 34 | - | ### Navigation Flow | |
| 35 | - | ||
| 36 | - | ``` | |
| 37 | - | Sources tab → tap source → Feed tab (filtered to that source) | |
| 38 | - | Feed tab → tap item → Detail view (slides in full-screen) | |
| 39 | - | Detail view → back button / swipe-right → Items list | |
| 40 | - | ``` | |
| 41 | - | ||
| 42 | - | ### Panel Visibility Rules | |
| 43 | - | ||
| 44 | - | | State | Sidebar | Items | Detail | Tab Bar | | |
| 45 | - | |-------|---------|-------|--------|---------| | |
| 46 | - | | Feed tab | hidden | visible | hidden | visible | | |
| 47 | - | | Saved tab | hidden | visible (starred) | hidden | visible | | |
| 48 | - | | Sources tab | visible (full-width) | hidden | hidden | visible | | |
| 49 | - | | Reading article | hidden | hidden | visible | hidden | | |
| 3 | + | Done: Phases 1-5, Phase 6 partial. Active: None. Next: Phase 6 remaining, Phase 7 polish. | |
| 50 | 4 | ||
| 51 | 5 | --- | |
| 52 | 6 | ||
| 53 | - | ## Phase 1: CSS Foundation | |
| 54 | - | ||
| 55 | - | ### Layout | |
| 56 | - | - [ ] `viewport-fit=cover` meta tag | |
| 57 | - | - [ ] `@media (max-width: 768px)` master breakpoint (replace existing 768px + 600px rules) | |
| 58 | - | - [ ] Hide header (`display: none`) — search/sort/actions move to appropriate locations | |
| 59 | - | - [ ] Body padding: `env(safe-area-inset-top)` top, `calc(52px + env(safe-area-inset-bottom))` bottom | |
| 60 | - | - [ ] Sidebar: `display: none` by default, full-width when `.mobile-sources-visible` | |
| 61 | - | - [ ] Items panel: full-width, single column | |
| 62 | - | - [ ] Detail panel: fixed full-screen overlay when `.mobile-detail-open` | |
| 63 | - | - [ ] Remove all three-panel flex layout on mobile (`.main` → single column) | |
| 64 | - | ||
| 65 | - | ### Components | |
| 66 | - | - [ ] Modal → bottom sheet (full-width, rounded top corners, drag handle) | |
| 67 | - | - [ ] Toasts positioned above tab bar | |
| 68 | - | - [ ] `@media (hover: none)` disable hover effects on touch devices | |
| 69 | - | - [ ] Item cards: increase tap target size, larger star button | |
| 70 | - | - [ ] Source list items: full-width rows, larger tap targets | |
| 71 | - | ||
| 72 | - | ### Mobile Search | |
| 73 | - | - [ ] Search bar at top of Feed/Saved views (replaces header search) | |
| 74 | - | - [ ] Sort dropdown integrated into search bar area or as a pill row | |
| 75 | - | ||
| 76 | - | --- | |
| 77 | - | ||
| 78 | - | ## Phase 2: Bottom Tab Bar | |
| 79 | - | ||
| 80 | - | ### HTML | |
| 81 | - | - [ ] `<nav id="mobile-tab-bar" class="mobile-tab-bar">` before closing `</div>` of `#app` | |
| 82 | - | - [ ] 5 buttons: Feed, Saved, Sources, +, More | |
| 83 | - | - [ ] More popover: Settings, Sync, OPML Import/Export, Shortcuts | |
| 84 | - | ||
| 85 | - | ### CSS | |
| 86 | - | - [ ] Base styles: `display: none`, fixed bottom, z-index 1100, safe-area padding | |
| 87 | - | - [ ] Show at 768px: `display: flex` | |
| 88 | - | - [ ] Active tab highlight (accent color) | |
| 89 | - | - [ ] More popover positioned above tab bar | |
| 90 | - | ||
| 91 | - | ### JS (`navigation.js` — new file) | |
| 92 | - | - [ ] `initMobileTabBar()` — click handlers for each tab | |
| 93 | - | - [ ] Feed tab: show items panel, set source filter to current/All | |
| 94 | - | - [ ] Saved tab: show items panel, filter to starred | |
| 95 | - | - [ ] Sources tab: show sidebar full-width | |
| 96 | - | - [ ] + button: open add-feed modal | |
| 97 | - | - [ ] More button: toggle popover | |
| 98 | - | - [ ] `updateMobileTabBar(view)` — sync active tab state | |
| 99 | - | - [ ] Source tap handler: switch to Feed tab filtered to that source | |
| 100 | - | ||
| 101 | - | --- | |
| 102 | - | ||
| 103 | - | ## Phase 3: Detail View Navigation | |
| 104 | - | ||
| 105 | - | ### Back Button | |
| 106 | - | - [ ] Detail header: add back arrow button (visible only on mobile) | |
| 107 | - | - [ ] Back button returns to items list, closes detail panel | |
| 108 | - | - [ ] Update `BB.detail` to track mobile open/close state | |
| 109 | - | ||
| 110 | - | ### Transitions | |
| 111 | - | - [ ] Detail slides in from right (CSS transform + transition) | |
| 112 | - | - [ ] Items list slides out left (or stays, detail overlays) | |
| 113 | - | - [ ] Tab bar hidden when detail is open (full reading experience) | |
| 114 | - | ||
| 115 | - | ### Reader State | |
| 116 | - | - [ ] `BB.state.mobileDetailOpen` flag | |
| 117 | - | - [ ] On item select (mobile): set flag, show detail full-screen | |
| 118 | - | - [ ] On back: clear flag, return to items | |
| 119 | - | ||
| 120 | - | --- | |
| 121 | - | ||
| 122 | - | ## Phase 4: Touch Module (`js/touch.js` — new file) | |
| 123 | - | ||
| 124 | - | Port from GoingsOn's touch.js, adapted for BB's domain. | |
| 125 | - | ||
| 126 | - | ### Utilities | |
| 127 | - | - [ ] `isTouchDevice` detection | |
| 128 | - | - [ ] `addLongPress(element, callback, duration)` — 500ms hold | |
| 129 | - | - [ ] `addSwipeActions(element, config)` — reveal action behind item | |
| 130 | - | - [ ] `addPullToRefresh(container, callback)` — pull indicator + callback | |
| 131 | - | - [ ] `addDragToDismiss(element, onDismiss)` — swipe down to close | |
| 132 | - | - [ ] All functions return cleanup functions, no-op on non-touch | |
| 133 | - | ||
| 134 | - | ### Mobile Wiring (`js/mobile.js` — new file) | |
| 135 | - | - [ ] Items: swipe right = toggle star, swipe left = toggle read | |
| 136 | - | - [ ] Pull-to-refresh on items list → `BB.feeds.refreshAll()` | |
| 137 | - | - [ ] Pull-to-refresh on sources list → `BB.feeds.refreshAll()` | |
| 138 | - | - [ ] Long-press on item → show action sheet (star, read/unread, open in browser, save) | |
| 139 | - | - [ ] Detail view: swipe right → back to items | |
| 140 | - | - [ ] Modal swipe-to-dismiss wiring | |
| 141 | - | ||
| 142 | - | --- | |
| 143 | - | ||
| 144 | - | ## Phase 5: View Adaptations | |
| 145 | - | ||
| 146 | - | ### Items List (mobile) | |
| 147 | - | - [ ] Item rows: larger padding, bigger star toggle target | |
| 148 | - | - [ ] Unread indicator: bold title + accent left border (like GO task priority borders) | |
| 149 | - | - [ ] Source name badge on items (needed since sidebar isn't visible in Feed tab) | |
| 150 | - | - [ ] Inline sort pills above items (Newest / Score / Unread / Starred) | |
| 151 | - | ||
| 152 | - | ### Sources List (mobile) | |
| 153 | - | - [ ] Full-width card layout with unread counts | |
| 154 | - | - [ ] All/Saved as top sticky rows | |
| 155 | - | - [ ] Tag filter bar: horizontal scroll pills (like GO pill nav) | |
| 156 | - | - [ ] Edit/delete actions via long-press action sheet | |
| 157 | - | ||
| 158 | - | ### Detail View (mobile) | |
| 159 | - | - [ ] Full-screen reader with comfortable margins | |
| 160 | - | - [ ] Sticky header: back button + title + star + open-in-browser | |
| 161 | - | - [ ] Safe area padding top and bottom | |
| 162 | - | - [ ] Article body scrollable with momentum | |
| 163 | - | ||
| 164 | - | ### Keyboard | |
| 165 | - | - [ ] Disable keyboard shortcuts on touch devices | |
| 166 | - | ||
| 167 | - | --- | |
| 168 | - | ||
| 169 | - | ## Phase 6: Tauri Mobile Build Config | |
| 170 | - | ||
| 171 | - | ### Cargo.toml | |
| 172 | - | - [ ] Desktop-only deps gated: `tauri-plugin-shell`, `tauri-plugin-notification`, `tauri-plugin-window-state` | |
| 173 | - | - [ ] `crate-type = ["staticlib", "cdylib", "lib"]` for iOS | |
| 7 | + | ## Phase 6: Tauri Mobile Build Config — Remaining | |
| 174 | 8 | - [ ] Platform-conditional `rusqlite` bundled feature for Android | |
| 175 | - | ||
| 176 | - | ### Source Gating | |
| 177 | - | - [ ] `lib.rs`: mobile entry point (`build_mobile_app()` + `#[cfg(mobile)]`) | |
| 178 | - | - [ ] Desktop-only plugin init gated in `main.rs` | |
| 179 | - | - [ ] Desktop-only window commands gated | |
| 180 | - | ||
| 181 | - | ### Capabilities | |
| 182 | - | - [ ] Split: `default.json` (cross-platform) + `desktop.json` (shell, notifications) | |
| 183 | - | ||
| 184 | - | ### Build & Test | |
| 9 | + | - [ ] Split capabilities: `default.json` (cross-platform) + `desktop.json` (shell, notifications) | |
| 185 | 10 | - [ ] `cargo tauri ios init` → generates `gen/apple/` | |
| 186 | 11 | - [ ] `cargo tauri android init` → generates `gen/android/` | |
| 187 | 12 | - [ ] iOS simulator test | |
| @@ -191,7 +16,7 @@ Port from GoingsOn's touch.js, adapted for BB's domain. | |||
| 191 | 16 | --- | |
| 192 | 17 | ||
| 193 | 18 | ## Phase 7: Polish | |
| 194 | - | ||
| 19 | + | - [ ] Inline sort pills above items | |
| 195 | 20 | - [ ] Physical device testing (iOS + Android) | |
| 196 | 21 | - [ ] Safe area insets on various device models | |
| 197 | 22 | - [ ] VoiceOver / TalkBack accessibility | |
| @@ -207,25 +32,8 @@ Port from GoingsOn's touch.js, adapted for BB's domain. | |||
| 207 | 32 | | File | Role | | |
| 208 | 33 | |------|------| | |
| 209 | 34 | | `src-tauri/frontend/css/styles.css` | All responsive CSS | | |
| 210 | - | | `src-tauri/frontend/js/touch.js` | Gesture utilities (new) | | |
| 211 | - | | `src-tauri/frontend/js/mobile.js` | Mobile interaction wiring (new) | | |
| 212 | - | | `src-tauri/frontend/js/navigation.js` | Bottom tab bar + view switching (new) | | |
| 213 | - | | `src-tauri/frontend/js/sources.js` | Source list (mobile adaptations) | | |
| 214 | - | | `src-tauri/frontend/js/items.js` | Item list (mobile adaptations) | | |
| 215 | - | | `src-tauri/frontend/js/detail.js` | Detail panel (mobile full-screen) | | |
| 216 | - | | `src-tauri/frontend/js/components.js` | Action sheets, modal swipe-dismiss | | |
| 217 | - | | `src-tauri/frontend/js/app.js` | Bootstrap, keyboard gating | | |
| 218 | - | | `src-tauri/frontend/index.html` | Tab bar HTML, viewport meta | | |
| 35 | + | | `src-tauri/frontend/js/touch.js` | Gesture utilities | | |
| 36 | + | | `src-tauri/frontend/js/mobile.js` | Mobile interaction wiring | | |
| 37 | + | | `src-tauri/frontend/js/navigation.js` | Bottom tab bar + view switching | | |
| 219 | 38 | | `src-tauri/Cargo.toml` | Desktop-only dep gating | | |
| 220 | 39 | | `src-tauri/src/main.rs` | Desktop-only plugin/service gating | | |
| 221 | - | | `src-tauri/src/lib.rs` | Desktop-only module gating | | |
| 222 | - | ||
| 223 | - | --- | |
| 224 | - | ||
| 225 | - | ## Reference | |
| 226 | - | ||
| 227 | - | GoingsOn mobile implementation (fully complete): | |
| 228 | - | - `Apps/goingson/docs/todo/todo_mobile.md` — completed phases 1-6 | |
| 229 | - | - `Apps/goingson/src-tauri/frontend/js/touch.js` — gesture utilities to port | |
| 230 | - | - `Apps/goingson/src-tauri/frontend/js/mobile.js` — wiring patterns to adapt | |
| 231 | - | - `Apps/goingson/src-tauri/frontend/js/navigation.js` — tab bar reference |
| @@ -57,6 +57,10 @@ toml.workspace = true | |||
| 57 | 57 | ||
| 58 | 58 | # HTTP (download_and_open command) | |
| 59 | 59 | ureq = "2.12.1" | |
| 60 | + | open.workspace = true | |
| 61 | + | ||
| 62 | + | # Concurrent fetching | |
| 63 | + | futures.workspace = true | |
| 60 | 64 | ||
| 61 | 65 | # Logging | |
| 62 | 66 | tracing.workspace = true |
| @@ -999,6 +999,9 @@ body { | |||
| 999 | 999 | border: 0; | |
| 1000 | 1000 | } | |
| 1001 | 1001 | ||
| 1002 | + | /* Source badge on items — visible only on mobile where sidebar is hidden */ | |
| 1003 | + | .item-source-badge { display: none; } | |
| 1004 | + | ||
| 1002 | 1005 | /* Mobile layout */ | |
| 1003 | 1006 | @media (max-width: 768px) { | |
| 1004 | 1007 | body { | |
| @@ -1087,6 +1090,40 @@ body { | |||
| 1087 | 1090 | bottom: calc(60px + env(safe-area-inset-bottom)); | |
| 1088 | 1091 | } | |
| 1089 | 1092 | ||
| 1093 | + | /* Source badge on items */ | |
| 1094 | + | .item-source-badge { | |
| 1095 | + | display: inline-block; | |
| 1096 | + | background-color: var(--bg-tertiary); | |
| 1097 | + | color: var(--text-secondary); | |
| 1098 | + | font-size: 0.65rem; | |
| 1099 | + | font-weight: 600; | |
| 1100 | + | padding: 0.05rem 0.35rem; | |
| 1101 | + | border-radius: var(--radius-sm); | |
| 1102 | + | margin-top: 0.15rem; | |
| 1103 | + | } | |
| 1104 | + | ||
| 1105 | + | /* Bold unread item titles */ | |
| 1106 | + | .item.unread .item-text { font-weight: 700; } | |
| 1107 | + | ||
| 1108 | + | /* Sticky detail header */ | |
| 1109 | + | .detail-header { | |
| 1110 | + | position: sticky; | |
| 1111 | + | top: 0; | |
| 1112 | + | z-index: 5; | |
| 1113 | + | } | |
| 1114 | + | .detail-panel { -webkit-overflow-scrolling: touch; } | |
| 1115 | + | .item-detail { padding: 1rem 1.25rem; } | |
| 1116 | + | .detail-body { line-height: 1.8; } | |
| 1117 | + | ||
| 1118 | + | /* Horizontal-scroll tag filter bar */ | |
| 1119 | + | .tag-filter-bar { | |
| 1120 | + | flex-wrap: nowrap; | |
| 1121 | + | overflow-x: auto; | |
| 1122 | + | -webkit-overflow-scrolling: touch; | |
| 1123 | + | scrollbar-width: none; | |
| 1124 | + | } | |
| 1125 | + | .tag-filter-bar::-webkit-scrollbar { display: none; } | |
| 1126 | + | ||
| 1090 | 1127 | /* Help shortcuts single column */ | |
| 1091 | 1128 | .help-shortcuts { grid-template-columns: 1fr; } | |
| 1092 | 1129 | } | |
| @@ -1274,6 +1311,18 @@ body { | |||
| 1274 | 1311 | ||
| 1275 | 1312 | .mobile-back-btn { display: none; } | |
| 1276 | 1313 | ||
| 1314 | + | /* Pull-to-refresh indicator */ | |
| 1315 | + | .pull-to-refresh-indicator { | |
| 1316 | + | display: none; | |
| 1317 | + | text-align: center; | |
| 1318 | + | padding: 0.5rem; | |
| 1319 | + | font-size: 0.8rem; | |
| 1320 | + | color: var(--text-muted); | |
| 1321 | + | } | |
| 1322 | + | .pull-to-refresh-indicator.visible { | |
| 1323 | + | display: block; | |
| 1324 | + | } | |
| 1325 | + | ||
| 1277 | 1326 | /* Scrollbar */ | |
| 1278 | 1327 | ::-webkit-scrollbar { width: 6px; height: 6px; } | |
| 1279 | 1328 | ::-webkit-scrollbar-track { background: var(--bg-secondary); } |
| @@ -124,6 +124,8 @@ | |||
| 124 | 124 | <script src="js/shared-updater.js"></script> | |
| 125 | 125 | <script src="js/updater.js"></script> | |
| 126 | 126 | <script src="js/navigation.js"></script> | |
| 127 | + | <script src="js/touch.js"></script> | |
| 128 | + | <script src="js/mobile.js"></script> | |
| 127 | 129 | <script src="js/app.js"></script> | |
| 128 | 130 | </body> | |
| 129 | 131 | </html> |
| @@ -110,6 +110,8 @@ | |||
| 110 | 110 | * Google Reader, /=search from vim, ?=help from many CLI tools. | |
| 111 | 111 | */ | |
| 112 | 112 | function handleKeyboard(e) { | |
| 113 | + | if (BB.state.isTouchDevice) return; | |
| 114 | + | ||
| 113 | 115 | // Don't handle when typing in inputs | |
| 114 | 116 | if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.tagName === 'SELECT') { | |
| 115 | 117 | if (e.key === 'Escape') { |
| @@ -284,7 +284,7 @@ | |||
| 284 | 284 | const author = currentItem.author ? `<p>By ${escapeHtml(currentItem.author)}</p>` : ''; | |
| 285 | 285 | const source = currentItem.sourceName ? `<p>Source: ${escapeHtml(currentItem.sourceName)}</p>` : ''; | |
| 286 | 286 | const link = currentItem.url ? `<p><a href="${escapeHtml(currentItem.url)}">Original article</a></p>` : ''; | |
| 287 | - | const body = currentItem.body || escapeHtml(currentItem.text || ''); | |
| 287 | + | const body = currentItem.body ? sanitizeHtml(currentItem.body) : escapeHtml(currentItem.text || ''); | |
| 288 | 288 | ||
| 289 | 289 | // Read current theme colors for the exported HTML | |
| 290 | 290 | const style = getComputedStyle(document.documentElement); |
| @@ -83,8 +83,8 @@ | |||
| 83 | 83 | if (p === lastPage) BB.state.set('hasMore', data.hasMore); | |
| 84 | 84 | } | |
| 85 | 85 | BB.state.set('items', allItems); | |
| 86 | - | } catch (_) { | |
| 87 | - | // Silent failure — existing data remains displayed | |
| 86 | + | } catch (err) { | |
| 87 | + | console.warn('Reload failed:', err); | |
| 88 | 88 | } | |
| 89 | 89 | } | |
| 90 | 90 | ||
| @@ -137,6 +137,7 @@ | |||
| 137 | 137 | <span class="item-author">${escapeHtml(item.author)}</span> | |
| 138 | 138 | <span class="item-time">${escapeHtml(item.timeAgo)}</span> | |
| 139 | 139 | </div> | |
| 140 | + | <span class="item-source-badge">${escapeHtml(item.sourceName)}</span> | |
| 140 | 141 | <div class="item-text">${escapeHtml(item.title || item.text)}</div> | |
| 141 | 142 | ${item.secondary ? `<div class="item-secondary">${escapeHtml(item.secondary)}</div>` : ''} | |
| 142 | 143 | </div> | |
| @@ -235,7 +236,7 @@ | |||
| 235 | 236 | /** Increment the page counter and fetch the next page (appended to list). */ | |
| 236 | 237 | function loadMore() { | |
| 237 | 238 | BB.state.set('currentPage', BB.state.currentPage + 1); | |
| 238 | - | load(true); | |
| 239 | + | load(true).catch(err => console.warn('Load more failed:', err)); | |
| 239 | 240 | } | |
| 240 | 241 | ||
| 241 | 242 | /** |
| @@ -0,0 +1,321 @@ | |||
| 1 | + | /** | |
| 2 | + | * Balanced Breakfast - Mobile Interaction Wiring | |
| 3 | + | * Connects touch.js gesture utilities to list views using event delegation. | |
| 4 | + | * Handles swipe-to-action, pull-to-refresh, and long-press selection. | |
| 5 | + | * All no-ops on non-touch devices. | |
| 6 | + | */ | |
| 7 | + | ||
| 8 | + | (function() { | |
| 9 | + | 'use strict'; | |
| 10 | + | ||
| 11 | + | if (!BB.touch?.isTouchDevice) return; | |
| 12 | + | ||
| 13 | + | // ============ Swipe Delegation ============ | |
| 14 | + | ||
| 15 | + | /** | |
| 16 | + | * Add delegated swipe handling to a container. Tracks the swiped row | |
| 17 | + | * internally so DOM recycling doesn't cause issues. | |
| 18 | + | * | |
| 19 | + | * @param {HTMLElement} container - Scrollable container (e.g. #items-list) | |
| 20 | + | * @param {Object} config | |
| 21 | + | * @param {string} config.rowSelector - CSS selector for swipeable rows | |
| 22 | + | * @param {Function} config.getActions - (rowEl) => { left?: { action }, right?: { action } } | null | |
| 23 | + | * @param {number} [config.threshold=80] - Pixels to trigger action | |
| 24 | + | */ | |
| 25 | + | function addSwipeDelegate(container, config) { | |
| 26 | + | const threshold = config.threshold || 80; | |
| 27 | + | let activeRow = null; | |
| 28 | + | let startX = 0; | |
| 29 | + | let startY = 0; | |
| 30 | + | let currentX = 0; | |
| 31 | + | let isDragging = false; | |
| 32 | + | let isHorizontal = null; | |
| 33 | + | let actions = null; | |
| 34 | + | ||
| 35 | + | container.addEventListener('touchstart', function(e) { | |
| 36 | + | const row = e.target.closest(config.rowSelector); | |
| 37 | + | if (!row) return; | |
| 38 | + | ||
| 39 | + | actions = config.getActions(row); | |
| 40 | + | if (!actions) return; | |
| 41 | + | ||
| 42 | + | activeRow = row; | |
| 43 | + | const touch = e.touches[0]; | |
| 44 | + | startX = touch.clientX; | |
| 45 | + | startY = touch.clientY; | |
| 46 | + | currentX = 0; | |
| 47 | + | isDragging = true; | |
| 48 | + | isHorizontal = null; | |
| 49 | + | activeRow.style.transition = 'none'; | |
| 50 | + | }, { passive: true }); | |
| 51 | + | ||
| 52 | + | container.addEventListener('touchmove', function(e) { | |
| 53 | + | if (!isDragging || !activeRow) return; | |
| 54 | + | ||
| 55 | + | const touch = e.touches[0]; | |
| 56 | + | const dx = touch.clientX - startX; | |
| 57 | + | const dy = touch.clientY - startY; | |
| 58 | + | ||
| 59 | + | // Determine direction on first significant move | |
| 60 | + | if (isHorizontal === null) { | |
| 61 | + | if (Math.abs(dx) > 10 || Math.abs(dy) > 10) { | |
| 62 | + | isHorizontal = Math.abs(dx) > Math.abs(dy); | |
| 63 | + | } | |
| 64 | + | if (!isHorizontal) return; | |
| 65 | + | } | |
| 66 | + | if (!isHorizontal) return; | |
| 67 | + | ||
| 68 | + | e.preventDefault(); | |
| 69 | + | currentX = dx; | |
| 70 | + | ||
| 71 | + | // Clamp to allowed directions | |
| 72 | + | if (dx < 0 && !actions.left) currentX = 0; | |
| 73 | + | if (dx > 0 && !actions.right) currentX = 0; | |
| 74 | + | ||
| 75 | + | // Rubber-band past threshold | |
| 76 | + | const maxSwipe = threshold * 1.5; | |
| 77 | + | if (Math.abs(currentX) > threshold) { | |
| 78 | + | const overshoot = Math.abs(currentX) - threshold; | |
| 79 | + | const dampened = threshold + overshoot * 0.3; | |
| 80 | + | currentX = currentX > 0 ? Math.min(dampened, maxSwipe) : Math.max(-dampened, -maxSwipe); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | activeRow.style.transform = `translateX(${currentX}px)`; | |
| 84 | + | }, { passive: false }); | |
| 85 | + | ||
| 86 | + | function onEnd() { | |
| 87 | + | if (!isDragging || !activeRow) return; | |
| 88 | + | isDragging = false; | |
| 89 | + | ||
| 90 | + | activeRow.style.transition = 'transform 0.2s ease'; | |
| 91 | + | ||
| 92 | + | if (Math.abs(currentX) >= threshold) { | |
| 93 | + | if (currentX < 0 && actions?.left?.action) { | |
| 94 | + | actions.left.action(); | |
| 95 | + | } else if (currentX > 0 && actions?.right?.action) { | |
| 96 | + | actions.right.action(); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | activeRow.style.transform = 'translateX(0)'; | |
| 101 | + | activeRow = null; | |
| 102 | + | actions = null; | |
| 103 | + | currentX = 0; | |
| 104 | + | isHorizontal = null; | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | container.addEventListener('touchend', onEnd, { passive: true }); | |
| 108 | + | container.addEventListener('touchcancel', onEnd, { passive: true }); | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // ============ Long-Press Delegation ============ | |
| 112 | + | ||
| 113 | + | /** | |
| 114 | + | * Add delegated long-press handling to a container. | |
| 115 | + | * @param {HTMLElement} container | |
| 116 | + | * @param {string} rowSelector - CSS selector for pressable rows | |
| 117 | + | * @param {Function} onLongPress - (rowEl) => void | |
| 118 | + | */ | |
| 119 | + | function addLongPressDelegate(container, rowSelector, onLongPress) { | |
| 120 | + | let timer = null; | |
| 121 | + | let startX = 0; | |
| 122 | + | let startY = 0; | |
| 123 | + | let activeRow = null; | |
| 124 | + | const MOVE_THRESHOLD = 10; | |
| 125 | + | const DURATION = 500; | |
| 126 | + | ||
| 127 | + | container.addEventListener('touchstart', function(e) { | |
| 128 | + | const row = e.target.closest(rowSelector); | |
| 129 | + | if (!row) return; | |
| 130 | + | activeRow = row; | |
| 131 | + | ||
| 132 | + | const touch = e.touches[0]; | |
| 133 | + | startX = touch.clientX; | |
| 134 | + | startY = touch.clientY; | |
| 135 | + | ||
| 136 | + | timer = setTimeout(() => { | |
| 137 | + | timer = null; | |
| 138 | + | // Prevent subsequent click | |
| 139 | + | activeRow.addEventListener('click', function prevent(ev) { | |
| 140 | + | ev.preventDefault(); | |
| 141 | + | ev.stopPropagation(); | |
| 142 | + | }, { once: true, capture: true }); | |
| 143 | + | onLongPress(activeRow); | |
| 144 | + | }, DURATION); | |
| 145 | + | }, { passive: true }); | |
| 146 | + | ||
| 147 | + | container.addEventListener('touchmove', function(e) { | |
| 148 | + | if (!timer) return; | |
| 149 | + | const touch = e.touches[0]; | |
| 150 | + | if (Math.abs(touch.clientX - startX) > MOVE_THRESHOLD || | |
| 151 | + | Math.abs(touch.clientY - startY) > MOVE_THRESHOLD) { | |
| 152 | + | clearTimeout(timer); | |
| 153 | + | timer = null; | |
| 154 | + | } | |
| 155 | + | }, { passive: true }); | |
| 156 | + | ||
| 157 | + | function cancel() { | |
| 158 | + | if (timer) { | |
| 159 | + | clearTimeout(timer); | |
| 160 | + | timer = null; | |
| 161 | + | } | |
| 162 | + | activeRow = null; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | container.addEventListener('touchend', cancel, { passive: true }); | |
| 166 | + | container.addEventListener('touchcancel', cancel, { passive: true }); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // ============ Wire Everything on Init ============ | |
| 170 | + | ||
| 171 | + | function init() { | |
| 172 | + | wireItemSwipe(); | |
| 173 | + | wirePullToRefresh(); | |
| 174 | + | wireLongPress(); | |
| 175 | + | wireDetailSwipe(); | |
| 176 | + | wireModalDismiss(); | |
| 177 | + | wireSourceLongPress(); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // --- Items: swipe right = star, swipe left = toggle read --- | |
| 181 | + | ||
| 182 | + | function wireItemSwipe() { | |
| 183 | + | const container = document.getElementById('items-list'); | |
| 184 | + | if (!container) return; | |
| 185 | + | ||
| 186 | + | addSwipeDelegate(container, { | |
| 187 | + | rowSelector: '.item', | |
| 188 | + | getActions: (row) => { | |
| 189 | + | const id = row.dataset.id; | |
| 190 | + | if (!id) return null; | |
| 191 | + | const item = BB.state.items.find(i => i.id === id); | |
| 192 | + | if (!item) return null; | |
| 193 | + | return { | |
| 194 | + | right: { action: () => BB.items.toggleStar(id, item.isStarred) }, | |
| 195 | + | left: { action: () => BB.items.toggleRead(id, item.isRead) }, | |
| 196 | + | }; | |
| 197 | + | }, | |
| 198 | + | }); | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // --- Pull-to-refresh on items list and sources list --- | |
| 202 | + | ||
| 203 | + | function wirePullToRefresh() { | |
| 204 | + | const itemsList = document.getElementById('items-list'); | |
| 205 | + | const sourcesList = document.getElementById('sources-list'); | |
| 206 | + | if (itemsList) BB.touch.addPullToRefresh(itemsList, () => BB.feeds.refresh()); | |
| 207 | + | if (sourcesList) BB.touch.addPullToRefresh(sourcesList, () => BB.feeds.refresh()); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // --- Long-press on item → action sheet --- | |
| 211 | + | ||
| 212 | + | function wireLongPress() { | |
| 213 | + | const container = document.getElementById('items-list'); | |
| 214 | + | if (!container) return; | |
| 215 | + | ||
| 216 | + | addLongPressDelegate(container, '.item', (row) => { | |
| 217 | + | const id = row.dataset.id; | |
| 218 | + | const item = BB.state.items.find(i => i.id === id); | |
| 219 | + | if (!item) return; | |
| 220 | + | showItemActionSheet(item); | |
| 221 | + | }); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // --- Detail view: swipe right → back to items --- | |
| 225 | + | ||
| 226 | + | function wireDetailSwipe() { | |
| 227 | + | const detail = document.getElementById('detail-panel'); | |
| 228 | + | if (!detail) return; | |
| 229 | + | ||
| 230 | + | BB.touch.addSwipeNavigation(detail, { | |
| 231 | + | onRight: () => BB.detail.close(), | |
| 232 | + | }); | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // --- Modal: swipe-down-to-dismiss --- | |
| 236 | + | ||
| 237 | + | function wireModalDismiss() { | |
| 238 | + | const modal = document.querySelector('.modal-content'); | |
| 239 | + | if (!modal) return; | |
| 240 | + | ||
| 241 | + | BB.touch.addDragToDismiss(modal, () => BB.ui.closeModal()); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // --- Sources: long-press → action sheet --- | |
| 245 | + | ||
| 246 | + | function wireSourceLongPress() { | |
| 247 | + | const container = document.getElementById('sources-list'); | |
| 248 | + | if (!container) return; | |
| 249 | + | addLongPressDelegate(container, '.source-item', (row) => { | |
| 250 | + | const sourceId = row.dataset.source; | |
| 251 | + | if (!sourceId) return; // Skip "All" row | |
| 252 | + | const source = BB.state.sources.find(s => s.id === sourceId); | |
| 253 | + | if (!source) return; | |
| 254 | + | showSourceActionSheet(source); | |
| 255 | + | }); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | function showSourceActionSheet(source) { | |
| 259 | + | const body = document.getElementById('modal-body'); | |
| 260 | + | const title = document.getElementById('modal-title'); | |
| 261 | + | title.textContent = source.name; | |
| 262 | + | body.innerHTML = ''; | |
| 263 | + | const actions = [ | |
| 264 | + | { label: 'Edit Feed', action: () => BB.sources.editFeed(source) }, | |
| 265 | + | { label: 'Edit Tags', action: () => BB.sources.editTags(source) }, | |
| 266 | + | { label: 'Delete', action: () => BB.sources.deleteFeed(source), danger: true }, | |
| 267 | + | ]; | |
| 268 | + | for (const a of actions) { | |
| 269 | + | const btn = document.createElement('button'); | |
| 270 | + | btn.className = 'btn'; | |
| 271 | + | btn.style.display = 'block'; | |
| 272 | + | btn.style.width = '100%'; | |
| 273 | + | btn.style.marginBottom = '0.5rem'; | |
| 274 | + | btn.textContent = a.label; | |
| 275 | + | if (a.danger) btn.style.color = 'var(--accent-red)'; | |
| 276 | + | btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); | |
| 277 | + | body.appendChild(btn); | |
| 278 | + | } | |
| 279 | + | BB.ui.openModal(); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | // --- Action sheet for long-press on item --- | |
| 283 | + | ||
| 284 | + | function showItemActionSheet(item) { | |
| 285 | + | const body = document.getElementById('modal-body'); | |
| 286 | + | const title = document.getElementById('modal-title'); | |
| 287 | + | title.textContent = 'Actions'; | |
| 288 | + | body.innerHTML = ''; | |
| 289 | + | ||
| 290 | + | const actions = [ | |
| 291 | + | { label: item.isStarred ? 'Unstar' : 'Star', action: () => BB.items.toggleStar(item.id, item.isStarred) }, | |
| 292 | + | { label: item.isRead ? 'Mark Unread' : 'Mark Read', action: () => BB.items.toggleRead(item.id, item.isRead) }, | |
| 293 | + | ]; | |
| 294 | + | if (item.url) { | |
| 295 | + | actions.push({ label: 'Open in Browser', action: () => BB.detail.openUrl() }); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | for (const a of actions) { | |
| 299 | + | const btn = document.createElement('button'); | |
| 300 | + | btn.className = 'btn'; | |
| 301 | + | btn.style.display = 'block'; | |
| 302 | + | btn.style.width = '100%'; | |
| 303 | + | btn.style.marginBottom = '0.5rem'; | |
| 304 | + | btn.textContent = a.label; | |
| 305 | + | btn.addEventListener('click', () => { BB.ui.closeModal(); a.action(); }); | |
| 306 | + | body.appendChild(btn); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | BB.ui.openModal(); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // ============ Populate Namespace ============ | |
| 313 | + | ||
| 314 | + | BB.mobile = { init }; | |
| 315 | + | ||
| 316 | + | if (document.readyState === 'loading') { | |
| 317 | + | document.addEventListener('DOMContentLoaded', init); | |
| 318 | + | } else { | |
| 319 | + | setTimeout(init, 0); | |
| 320 | + | } | |
| 321 | + | })(); |
| @@ -179,7 +179,6 @@ | |||
| 179 | 179 | BB.ui.showToast('Query feed created'); | |
| 180 | 180 | } | |
| 181 | 181 | document.getElementById('modal-overlay').style.display = 'none'; | |
| 182 | - | load(); | |
| 183 | 182 | BB.sources.load(); | |
| 184 | 183 | } catch (err) { | |
| 185 | 184 | BB.ui.showToast('Failed to save: ' + BB.utils.getErrorMessage(err), 'error'); | |
| @@ -217,7 +216,6 @@ | |||
| 217 | 216 | if (BB.state.currentQueryFeed === feed.id) { | |
| 218 | 217 | BB.state.set('currentQueryFeed', null); | |
| 219 | 218 | } | |
| 220 | - | load(); | |
| 221 | 219 | BB.sources.load(); | |
| 222 | 220 | } catch (err) { | |
| 223 | 221 | BB.ui.showToast('Failed to delete: ' + BB.utils.getErrorMessage(err), 'error'); |