max / balanced_breakfast
- Co-Authored-By
- Claude Opus 4.6 <noreply@anthropic.com>
29 files changed,
+1466 insertions,
-121 deletions
| @@ -87,6 +87,19 @@ | |||
| 87 | 87 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 88 | 88 | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 89 | 89 | ||
| 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 | + | ||
| 90 | 103 | [[package]] | |
| 91 | 104 | name = "android_system_properties" | |
| 92 | 105 | version = "0.1.5" | |
| @@ -160,7 +173,7 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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 @@ | |||
| 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", | |
| @@ -737,6 +753,19 @@ | |||
| 737 | 753 | "syn 1.0.109", | |
| 738 | 754 | ] | |
| 739 | 755 | ||
| 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 | + | ||
| 740 | 769 | [[package]] | |
| 741 | 770 | name = "cssparser-macros" | |
| 742 | 771 | version = "0.6.1" | |
| @@ -1714,7 +1743,18 @@ | |||
| 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]] | |
| @@ -2297,6 +2337,12 @@ | |||
| 2297 | 2337 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2298 | 2338 | checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" | |
| 2299 | 2339 | ||
| 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 | + | ||
| 2300 | 2346 | [[package]] | |
| 2301 | 2347 | name = "markup5ever" | |
| 2302 | 2348 | version = "0.10.1" | |
| @@ -2339,6 +2385,17 @@ | |||
| 2339 | 2385 | "tendril", | |
| 2340 | 2386 | ] | |
| 2341 | 2387 | ||
| 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 | + | ||
| 2342 | 2399 | [[package]] | |
| 2343 | 2400 | name = "match_token" | |
| 2344 | 2401 | version = "0.1.0" | |
| @@ -2350,6 +2407,17 @@ | |||
| 2350 | 2407 | "syn 2.0.114", | |
| 2351 | 2408 | ] | |
| 2352 | 2409 | ||
| 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 | + | ||
| 2353 | 2421 | [[package]] | |
| 2354 | 2422 | name = "matchers" | |
| 2355 | 2423 | version = "0.2.0" | |
| @@ -4599,13 +4667,15 @@ | |||
| 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 @@ | |||
| 4614 | 4684 | "thiserror 1.0.69", | |
| 4615 | 4685 | "tokio", | |
| 4616 | 4686 | "tracing", | |
| 4687 | + | "unicode-normalization", | |
| 4617 | 4688 | "urlencoding", | |
| 4618 | 4689 | "uuid", | |
| 4619 | 4690 | ] | |
| @@ -5845,6 +5916,18 @@ | |||
| 5845 | 5916 | "wasm-bindgen", | |
| 5846 | 5917 | ] | |
| 5847 | 5918 | ||
| 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 | + | ||
| 5848 | 5931 | [[package]] | |
| 5849 | 5932 | name = "webkit2gtk" | |
| 5850 | 5933 | version = "2.0.2" |
| @@ -35,6 +35,9 @@ | |||
| 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 @@ | |||
| 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 |
| @@ -25,6 +25,9 @@ | |||
| 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 |
| @@ -21,6 +21,9 @@ | |||
| 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 @@ | |||
| 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" |
| @@ -14,9 +14,9 @@ | |||
| 14 | 14 | /// SyncKit client for cloud sync (None if not configured). | |
| 15 | 15 | pub sync_client: Option<SyncKitClient>, | |
| 16 | 16 | /// Handle to abort the background auto-fetch task on shutdown. | |
| 17 | - | auto_fetch_handle: std::sync::Mutex<Option<AbortHandle>>, | |
| 17 | + | auto_fetch_handle: parking_lot::Mutex<Option<AbortHandle>>, | |
| 18 | 18 | /// Handle to abort the background stale-item cleanup task on shutdown. | |
| 19 | - | cleanup_handle: std::sync::Mutex<Option<AbortHandle>>, | |
| 19 | + | cleanup_handle: parking_lot::Mutex<Option<AbortHandle>>, | |
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | impl AppState { | |
| @@ -116,73 +116,53 @@ | |||
| 116 | 116 | Ok(Self { | |
| 117 | 117 | orchestrator, | |
| 118 | 118 | sync_client, | |
| 119 | - | auto_fetch_handle: std::sync::Mutex::new(None), | |
| 120 | - | cleanup_handle: std::sync::Mutex::new(None), | |
| 119 | + | auto_fetch_handle: parking_lot::Mutex::new(None), | |
| 120 | + | cleanup_handle: parking_lot::Mutex::new(None), | |
| 121 | 121 | }) | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | 124 | /// Store the abort handle for the background auto-fetch task. | |
| 125 | - | pub fn set_auto_fetch_handle(&self, handle: AbortHandle) -> Result<(), String> { | |
| 126 | - | let mut guard = self | |
| 127 | - | .auto_fetch_handle | |
| 128 | - | .lock() | |
| 129 | - | .map_err(|e| format!("auto_fetch_handle mutex poisoned: {}", e))?; | |
| 125 | + | pub fn set_auto_fetch_handle(&self, handle: AbortHandle) { | |
| 126 | + | let mut guard = self.auto_fetch_handle.lock(); | |
| 130 | 127 | // If there's already a running task, abort it before replacing. | |
| 131 | 128 | if let Some(old) = guard.take() { | |
| 132 | 129 | old.abort(); | |
| 133 | 130 | } | |
| 134 | 131 | *guard = Some(handle); | |
| 135 | - | Ok(()) | |
| 136 | 132 | } | |
| 137 | 133 | ||
| 138 | 134 | /// Abort the background auto-fetch task if it is running. | |
| 139 | - | pub fn abort_auto_fetch(&self) -> Result<(), String> { | |
| 140 | - | let mut guard = self | |
| 141 | - | .auto_fetch_handle | |
| 142 | - | .lock() | |
| 143 | - | .map_err(|e| format!("auto_fetch_handle mutex poisoned: {}", e))?; | |
| 135 | + | pub fn abort_auto_fetch(&self) { | |
| 136 | + | let mut guard = self.auto_fetch_handle.lock(); | |
| 144 | 137 | if let Some(handle) = guard.take() { | |
| 145 | 138 | info!("Aborting auto-fetch background task"); | |
| 146 | 139 | handle.abort(); | |
| 147 | 140 | } | |
| 148 | - | Ok(()) | |
| 149 | 141 | } | |
| 150 | 142 | ||
| 151 | 143 | /// Store the abort handle for the background stale-item cleanup task. | |
| 152 | - | pub fn set_cleanup_handle(&self, handle: AbortHandle) -> Result<(), String> { | |
| 153 | - | let mut guard = self | |
| 154 | - | .cleanup_handle | |
| 155 | - | .lock() | |
| 156 | - | .map_err(|e| format!("cleanup_handle mutex poisoned: {}", e))?; | |
| 144 | + | pub fn set_cleanup_handle(&self, handle: AbortHandle) { | |
| 145 | + | let mut guard = self.cleanup_handle.lock(); | |
| 157 | 146 | if let Some(old) = guard.take() { | |
| 158 | 147 | old.abort(); | |
| 159 | 148 | } | |
| 160 | 149 | *guard = Some(handle); | |
| 161 | - | Ok(()) | |
| 162 | 150 | } | |
| 163 | 151 | ||
| 164 | 152 | /// Abort the background cleanup task if it is running. | |
| 165 | - | pub fn abort_cleanup(&self) -> Result<(), String> { | |
| 166 | - | let mut guard = self | |
| 167 | - | .cleanup_handle | |
| 168 | - | .lock() | |
| 169 | - | .map_err(|e| format!("cleanup_handle mutex poisoned: {}", e))?; | |
| 153 | + | pub fn abort_cleanup(&self) { | |
| 154 | + | let mut guard = self.cleanup_handle.lock(); | |
| 170 | 155 | if let Some(handle) = guard.take() { | |
| 171 | 156 | info!("Aborting stale-cleanup background task"); | |
| 172 | 157 | handle.abort(); | |
| 173 | 158 | } | |
| 174 | - | Ok(()) | |
| 175 | 159 | } | |
| 176 | 160 | } | |
| 177 | 161 | ||
| 178 | 162 | impl Drop for AppState { | |
| 179 | 163 | fn drop(&mut self) { | |
| 180 | - | if let Err(e) = self.abort_auto_fetch() { | |
| 181 | - | error!(error = %e, "Failed to abort auto-fetch on drop"); | |
| 182 | - | } | |
| 183 | - | if let Err(e) = self.abort_cleanup() { | |
| 184 | - | error!(error = %e, "Failed to abort cleanup on drop"); | |
| 185 | - | } | |
| 164 | + | self.abort_auto_fetch(); | |
| 165 | + | self.abort_cleanup(); | |
| 186 | 166 | } | |
| 187 | 167 | } | |
| 188 | 168 | ||
| @@ -344,9 +324,7 @@ | |||
| 344 | 324 | } | |
| 345 | 325 | }); | |
| 346 | 326 | ||
| 347 | - | if let Err(e) = state.set_auto_fetch_handle(handle.inner().abort_handle()) { | |
| 348 | - | error!(error = %e, "Failed to store auto-fetch handle"); | |
| 349 | - | } | |
| 327 | + | state.set_auto_fetch_handle(handle.inner().abort_handle()); | |
| 350 | 328 | } | |
| 351 | 329 | ||
| 352 | 330 | /// Spawn a background task that periodically deletes old read items. | |
| @@ -375,9 +353,7 @@ | |||
| 375 | 353 | } | |
| 376 | 354 | }); | |
| 377 | 355 | ||
| 378 | - | if let Err(e) = state.set_cleanup_handle(handle.inner().abort_handle()) { | |
| 379 | - | error!(error = %e, "Failed to store cleanup handle"); | |
| 380 | - | } | |
| 356 | + | state.set_cleanup_handle(handle.inner().abort_handle()); | |
| 381 | 357 | } | |
| 382 | 358 | ||
| 383 | 359 | /// Pure timing check: is a single feed overdue given its last_fetch timestamp? |
| @@ -41,12 +41,12 @@ | |||
| 41 | 41 | }; | |
| 42 | 42 | ||
| 43 | 43 | // Must be authenticated | |
| 44 | - | if client.session_info().is_none() { | |
| 44 | + | if client.session_info().unwrap_or(None).is_none() { | |
| 45 | 45 | continue; | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | // Must have encryption key loaded | |
| 49 | - | if !client.has_master_key() { | |
| 49 | + | if !client.has_master_key().unwrap_or(false) { | |
| 50 | 50 | continue; | |
| 51 | 51 | } | |
| 52 | 52 |
| @@ -4,7 +4,7 @@ | |||
| 4 | 4 | use serde::{Deserialize, Serialize}; | |
| 5 | 5 | use sqlx::SqlitePool; | |
| 6 | 6 | use synckit_client::{ChangeEntry, ChangeOp, SyncKitClient}; | |
| 7 | - | use tracing::{debug, info, warn}; | |
| 7 | + | use tracing::{debug, info, instrument, warn}; | |
| 8 | 8 | use uuid::Uuid; | |
| 9 | 9 | ||
| 10 | 10 | use crate::commands::error::ApiError; | |
| @@ -32,6 +32,7 @@ | |||
| 32 | 32 | ||
| 33 | 33 | // ── sync_state helpers ── | |
| 34 | 34 | ||
| 35 | + | #[instrument(skip_all)] | |
| 35 | 36 | pub async fn get_sync_state(pool: &SqlitePool, key: &str) -> Result<String, ApiError> { | |
| 36 | 37 | let row: Option<(String,)> = | |
| 37 | 38 | sqlx::query_as("SELECT value FROM sync_state WHERE key = ?") | |
| @@ -43,6 +44,7 @@ | |||
| 43 | 44 | Ok(row.map(|r| r.0).unwrap_or_default()) | |
| 44 | 45 | } | |
| 45 | 46 | ||
| 47 | + | #[instrument(skip_all)] | |
| 46 | 48 | pub async fn set_sync_state(pool: &SqlitePool, key: &str, value: &str) -> Result<(), ApiError> { | |
| 47 | 49 | sqlx::query("INSERT OR REPLACE INTO sync_state (key, value) VALUES (?, ?)") | |
| 48 | 50 | .bind(key) | |
| @@ -53,8 +55,23 @@ | |||
| 53 | 55 | Ok(()) | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 58 | + | /// Clear all sync state (device_id, cursors, flags) for disconnect. | |
| 59 | + | #[instrument(skip_all)] | |
| 60 | + | pub async fn clear_all_sync_state(pool: &SqlitePool) -> Result<(), ApiError> { | |
| 61 | + | sqlx::query("DELETE FROM sync_state") | |
| 62 | + | .execute(pool) | |
| 63 | + | .await | |
| 64 | + | .map_err(|e| ApiError::database(e.to_string()))?; | |
| 65 | + | sqlx::query("DELETE FROM sync_changelog") | |
| 66 | + | .execute(pool) | |
| 67 | + | .await | |
| 68 | + | .map_err(|e| ApiError::database(e.to_string()))?; | |
| 69 | + | Ok(()) | |
| 70 | + | } | |
| 71 | + | ||
| 56 | 72 | // ── Device registration ── | |
| 57 | 73 | ||
| 74 | + | #[instrument(skip_all)] | |
| 58 | 75 | pub async fn ensure_device_registered( | |
| 59 | 76 | pool: &SqlitePool, | |
| 60 | 77 | client: &SyncKitClient, | |
| @@ -85,6 +102,7 @@ | |||
| 85 | 102 | ||
| 86 | 103 | // ── Push ── | |
| 87 | 104 | ||
| 105 | + | #[instrument(skip_all)] | |
| 88 | 106 | pub async fn push_changes( | |
| 89 | 107 | pool: &SqlitePool, | |
| 90 | 108 | client: &SyncKitClient, | |
| @@ -151,6 +169,7 @@ | |||
| 151 | 169 | ||
| 152 | 170 | // ── Pull ── | |
| 153 | 171 | ||
| 172 | + | #[instrument(skip_all)] | |
| 154 | 173 | pub async fn pull_changes( | |
| 155 | 174 | pool: &SqlitePool, | |
| 156 | 175 | client: &SyncKitClient, | |
| @@ -353,8 +372,13 @@ | |||
| 353 | 372 | let pk = pk_column(table); | |
| 354 | 373 | ||
| 355 | 374 | if table == "feed_tags" { | |
| 356 | - | // Composite PK: row_id is "feed_id:tag" | |
| 357 | - | if let Some((feed_id, tag)) = row_id.split_once(':') { | |
| 375 | + | // Composite PK: row_id is "feed_id:tag" where feed_id is a UUID (36 chars). | |
| 376 | + | // We can't use split_once(':') because tags may contain colons (e.g. "cat:subcat"). | |
| 377 | + | // Instead, parse the first 36 characters as feed_id and everything after | |
| 378 | + | // the separator colon (char 37) as the tag. | |
| 379 | + | if row_id.len() > 36 && row_id.as_bytes()[36] == b':' { | |
| 380 | + | let feed_id = &row_id[..36]; | |
| 381 | + | let tag = &row_id[37..]; | |
| 358 | 382 | sqlx::query("DELETE FROM feed_tags WHERE feed_id = ? AND tag = ?") | |
| 359 | 383 | .bind(feed_id) | |
| 360 | 384 | .bind(tag) | |
| @@ -377,6 +401,7 @@ | |||
| 377 | 401 | ||
| 378 | 402 | // ── High-level sync ── | |
| 379 | 403 | ||
| 404 | + | #[instrument(skip_all)] | |
| 380 | 405 | pub async fn perform_sync(pool: &SqlitePool, client: &SyncKitClient) -> Result<SyncResult, ApiError> { | |
| 381 | 406 | // Clear applying_remote flag in case a previous sync crashed mid-apply. | |
| 382 | 407 | // If the flag is stuck at "1", all local changes silently skip the changelog. | |
| @@ -398,6 +423,7 @@ | |||
| 398 | 423 | // ── Initial snapshot ── | |
| 399 | 424 | ||
| 400 | 425 | /// One-time: snapshot all existing rows into the changelog for the first push. | |
| 426 | + | #[instrument(skip_all)] | |
| 401 | 427 | pub async fn create_initial_snapshot(pool: &SqlitePool) -> Result<i64, ApiError> { | |
| 402 | 428 | let mut total: i64 = 0; | |
| 403 | 429 | ||
| @@ -473,6 +499,7 @@ | |||
| 473 | 499 | // ── Changelog cleanup ── | |
| 474 | 500 | ||
| 475 | 501 | /// Prune pushed entries older than 7 days. | |
| 502 | + | #[instrument(skip_all)] | |
| 476 | 503 | pub async fn cleanup_changelog(pool: &SqlitePool) -> Result<i64, ApiError> { | |
| 477 | 504 | let result = sqlx::query( | |
| 478 | 505 | "DELETE FROM sync_changelog WHERE pushed = 1 AND timestamp < datetime('now', '-7 days')", | |
| @@ -491,6 +518,7 @@ | |||
| 491 | 518 | /// Enforce a hard cap on total changelog entries to prevent unbounded growth | |
| 492 | 519 | /// when sync is disconnected or failing. Deletes the oldest entries (by rowid) | |
| 493 | 520 | /// to bring the count back under `MAX_CHANGELOG_ENTRIES`. | |
| 521 | + | #[instrument(skip_all)] | |
| 494 | 522 | pub async fn enforce_changelog_retention(pool: &SqlitePool) -> Result<i64, ApiError> { | |
| 495 | 523 | let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sync_changelog") | |
| 496 | 524 | .fetch_one(pool) | |
| @@ -523,6 +551,7 @@ | |||
| 523 | 551 | } | |
| 524 | 552 | ||
| 525 | 553 | /// Count unpushed changes. | |
| 554 | + | #[instrument(skip_all)] | |
| 526 | 555 | pub async fn count_pending_changes(pool: &SqlitePool) -> Result<i64, ApiError> { | |
| 527 | 556 | let row: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0") | |
| 528 | 557 | .fetch_one(pool) | |
| @@ -841,6 +870,57 @@ | |||
| 841 | 870 | assert_eq!(count.0, 0); | |
| 842 | 871 | } | |
| 843 | 872 | ||
| 873 | + | #[tokio::test] | |
| 874 | + | async fn apply_delete_feed_tags_with_colon_in_tag() { | |
| 875 | + | let pool = setup_test_db().await; | |
| 876 | + | let feed_id = create_test_feed(&pool, "Colon Tag Feed").await; | |
| 877 | + | ||
| 878 | + | // Insert a tag that contains a colon | |
| 879 | + | sqlx::query("INSERT INTO feed_tags (feed_id, tag) VALUES (?, 'cat:subcat')") | |
| 880 | + | .bind(&feed_id) | |
| 881 | + | .execute(&pool) | |
| 882 | + | .await | |
| 883 | + | .unwrap(); | |
| 884 | + | ||
| 885 | + | // The row_id format is "feed_id:tag", so with a colon in the tag | |
| 886 | + | // it becomes "uuid:cat:subcat" | |
| 887 | + | let row_id = format!("{}:cat:subcat", feed_id); | |
| 888 | + | apply_delete(&pool, "feed_tags", &row_id).await.unwrap(); | |
| 889 | + | ||
| 890 | + | let count: (i64,) = sqlx::query_as( | |
| 891 | + | "SELECT COUNT(*) FROM feed_tags WHERE feed_id = ? AND tag = 'cat:subcat'" | |
| 892 | + | ) | |
| 893 | + | .bind(&feed_id) | |
| 894 | + | .fetch_one(&pool) | |
| 895 | + | .await | |
| 896 | + | .unwrap(); | |
| 897 | + | assert_eq!(count.0, 0, "tag with colon should be deleted"); | |
| 898 | + | } | |
| 899 | + | ||
| 900 | + | #[tokio::test] | |
| 901 | + | async fn apply_delete_feed_tags_simple_tag() { | |
| 902 | + | let pool = setup_test_db().await; | |
| 903 | + | let feed_id = create_test_feed(&pool, "Simple Tag Feed").await; | |
| 904 | + | ||
| 905 | + | sqlx::query("INSERT INTO feed_tags (feed_id, tag) VALUES (?, 'tech')") | |
| 906 | + | .bind(&feed_id) | |
| 907 | + | .execute(&pool) | |
| 908 | + | .await | |
| 909 | + | .unwrap(); | |
| 910 | + | ||
| 911 | + | let row_id = format!("{}:tech", feed_id); | |
| 912 | + | apply_delete(&pool, "feed_tags", &row_id).await.unwrap(); | |
| 913 | + | ||
| 914 | + | let count: (i64,) = sqlx::query_as( | |
| 915 | + | "SELECT COUNT(*) FROM feed_tags WHERE feed_id = ? AND tag = 'tech'" | |
| 916 | + | ) | |
| 917 | + | .bind(&feed_id) | |
| 918 | + | .fetch_one(&pool) | |
| 919 | + | .await | |
| 920 | + | .unwrap(); | |
| 921 | + | assert_eq!(count.0, 0, "simple tag should be deleted"); | |
| 922 | + | } | |
| 923 | + | ||
| 844 | 924 | #[tokio::test] | |
| 845 | 925 | async fn apply_delete_feed_items_is_no_op() { | |
| 846 | 926 | let pool = setup_test_db().await; |