max / balanced_breakfast
138 files changed,
+18539 insertions,
-0 deletions
| @@ -0,0 +1,31 @@ | |||
| 1 | + | image: archlinux | |
| 2 | + | packages: | |
| 3 | + | - rust | |
| 4 | + | - cmake | |
| 5 | + | - clang | |
| 6 | + | - git | |
| 7 | + | - pkg-config | |
| 8 | + | - perl | |
| 9 | + | - webkit2gtk-4.1 | |
| 10 | + | - gtk3 | |
| 11 | + | - openssl | |
| 12 | + | - libayatana-appindicator | |
| 13 | + | sources: | |
| 14 | + | - https://git.sr.ht/~maxmj/balanced_breakfast | |
| 15 | + | environment: | |
| 16 | + | CARGO_INCREMENTAL: "0" | |
| 17 | + | RUST_BACKTRACE: "1" | |
| 18 | + | tasks: | |
| 19 | + | - check: | | |
| 20 | + | cd balanced_breakfast | |
| 21 | + | cargo check --workspace 2>&1 | |
| 22 | + | - test: | | |
| 23 | + | cd balanced_breakfast | |
| 24 | + | cargo test --workspace 2>&1 | |
| 25 | + | - clippy: | | |
| 26 | + | cd balanced_breakfast | |
| 27 | + | cargo clippy --workspace --all-targets -- -D warnings 2>&1 | |
| 28 | + | - audit: | | |
| 29 | + | cargo install --locked cargo-audit | |
| 30 | + | cd balanced_breakfast | |
| 31 | + | cargo audit 2>&1 |
| @@ -0,0 +1,7 @@ | |||
| 1 | + | # BalancedBreakfast Configuration | |
| 2 | + | ||
| 3 | + | # SQLite database URL | |
| 4 | + | DATABASE_URL=sqlite:balanced_breakfast.db?mode=rwc | |
| 5 | + | ||
| 6 | + | # Plugins directory (relative or absolute path) | |
| 7 | + | PLUGINS_DIR=plugins |
| @@ -0,0 +1,25 @@ | |||
| 1 | + | # Build artifacts | |
| 2 | + | /target/ | |
| 3 | + | ||
| 4 | + | # Environment | |
| 5 | + | .env | |
| 6 | + | ||
| 7 | + | # IDE | |
| 8 | + | .idea/ | |
| 9 | + | .vscode/ | |
| 10 | + | *.swp | |
| 11 | + | *.swo | |
| 12 | + | ||
| 13 | + | # macOS | |
| 14 | + | .DS_Store | |
| 15 | + | ||
| 16 | + | # Plugin binaries | |
| 17 | + | plugins/*.dylib | |
| 18 | + | plugins/*.so | |
| 19 | + | plugins/*.dll | |
| 20 | + | ||
| 21 | + | # Release artifacts | |
| 22 | + | dist/ | |
| 23 | + | ||
| 24 | + | # Tauri generated | |
| 25 | + | src-tauri/gen/ |
| @@ -0,0 +1,6549 @@ | |||
| 1 | + | # This file is automatically @generated by Cargo. | |
| 2 | + | # It is not intended for manual editing. | |
| 3 | + | version = 4 | |
| 4 | + | ||
| 5 | + | [[package]] | |
| 6 | + | name = "adler2" | |
| 7 | + | version = "2.0.1" | |
| 8 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | + | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | ||
| 11 | + | [[package]] | |
| 12 | + | name = "aead" | |
| 13 | + | version = "0.5.2" | |
| 14 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 15 | + | checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" | |
| 16 | + | dependencies = [ | |
| 17 | + | "crypto-common", | |
| 18 | + | "generic-array", | |
| 19 | + | ] | |
| 20 | + | ||
| 21 | + | [[package]] | |
| 22 | + | name = "aes" | |
| 23 | + | version = "0.8.4" | |
| 24 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 25 | + | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" | |
| 26 | + | dependencies = [ | |
| 27 | + | "cfg-if", | |
| 28 | + | "cipher", | |
| 29 | + | "cpufeatures", | |
| 30 | + | ] | |
| 31 | + | ||
| 32 | + | [[package]] | |
| 33 | + | name = "aes-gcm" | |
| 34 | + | version = "0.10.3" | |
| 35 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 36 | + | checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" | |
| 37 | + | dependencies = [ | |
| 38 | + | "aead", | |
| 39 | + | "aes", | |
| 40 | + | "cipher", | |
| 41 | + | "ctr", | |
| 42 | + | "ghash", | |
| 43 | + | "subtle", | |
| 44 | + | ] | |
| 45 | + | ||
| 46 | + | [[package]] | |
| 47 | + | name = "ahash" | |
| 48 | + | version = "0.8.12" | |
| 49 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 50 | + | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 51 | + | dependencies = [ | |
| 52 | + | "cfg-if", | |
| 53 | + | "const-random", | |
| 54 | + | "getrandom 0.3.4", | |
| 55 | + | "once_cell", | |
| 56 | + | "version_check", | |
| 57 | + | "zerocopy", | |
| 58 | + | ] | |
| 59 | + | ||
| 60 | + | [[package]] | |
| 61 | + | name = "aho-corasick" | |
| 62 | + | version = "1.1.4" | |
| 63 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 64 | + | checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" | |
| 65 | + | dependencies = [ | |
| 66 | + | "memchr", | |
| 67 | + | ] | |
| 68 | + | ||
| 69 | + | [[package]] | |
| 70 | + | name = "alloc-no-stdlib" | |
| 71 | + | version = "2.0.4" | |
| 72 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 73 | + | checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" | |
| 74 | + | ||
| 75 | + | [[package]] | |
| 76 | + | name = "alloc-stdlib" | |
| 77 | + | version = "0.2.2" | |
| 78 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 79 | + | checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" | |
| 80 | + | dependencies = [ | |
| 81 | + | "alloc-no-stdlib", | |
| 82 | + | ] | |
| 83 | + | ||
| 84 | + | [[package]] | |
| 85 | + | name = "allocator-api2" | |
| 86 | + | version = "0.2.21" | |
| 87 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 88 | + | checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" | |
| 89 | + | ||
| 90 | + | [[package]] | |
| 91 | + | name = "android_system_properties" | |
| 92 | + | version = "0.1.5" | |
| 93 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 94 | + | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" | |
| 95 | + | dependencies = [ | |
| 96 | + | "libc", | |
| 97 | + | ] | |
| 98 | + | ||
| 99 | + | [[package]] | |
| 100 | + | name = "anyhow" | |
| 101 | + | version = "1.0.101" | |
| 102 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 103 | + | checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" | |
| 104 | + | ||
| 105 | + | [[package]] | |
| 106 | + | name = "argon2" | |
| 107 | + | version = "0.5.3" | |
| 108 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 109 | + | checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" | |
| 110 | + | dependencies = [ | |
| 111 | + | "base64ct", | |
| 112 | + | "blake2", | |
| 113 | + | "cpufeatures", | |
| 114 | + | "password-hash", | |
| 115 | + | ] | |
| 116 | + | ||
| 117 | + | [[package]] | |
| 118 | + | name = "atk" | |
| 119 | + | version = "0.18.2" | |
| 120 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 121 | + | checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" | |
| 122 | + | dependencies = [ | |
| 123 | + | "atk-sys", | |
| 124 | + | "glib", | |
| 125 | + | "libc", | |
| 126 | + | ] | |
| 127 | + | ||
| 128 | + | [[package]] | |
| 129 | + | name = "atk-sys" | |
| 130 | + | version = "0.18.2" | |
| 131 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 132 | + | checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" | |
| 133 | + | dependencies = [ | |
| 134 | + | "glib-sys", | |
| 135 | + | "gobject-sys", | |
| 136 | + | "libc", | |
| 137 | + | "system-deps", | |
| 138 | + | ] | |
| 139 | + | ||
| 140 | + | [[package]] | |
| 141 | + | name = "atoi" | |
| 142 | + | version = "2.0.0" | |
| 143 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 144 | + | checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" | |
| 145 | + | dependencies = [ | |
| 146 | + | "num-traits", | |
| 147 | + | ] | |
| 148 | + | ||
| 149 | + | [[package]] | |
| 150 | + | name = "atomic-waker" | |
| 151 | + | version = "1.1.2" | |
| 152 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 153 | + | checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" | |
| 154 | + | ||
| 155 | + | [[package]] | |
| 156 | + | name = "autocfg" | |
| 157 | + | version = "1.5.0" | |
| 158 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 159 | + | checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" | |
| 160 | + | ||
| 161 | + | [[package]] | |
| 162 | + | name = "balanced-breakfast-desktop" | |
| 163 | + | version = "0.1.0" | |
| 164 | + | dependencies = [ | |
| 165 | + | "base64 0.22.1", | |
| 166 | + | "bb-core", | |
| 167 | + | "bb-db", | |
| 168 | + | "bb-feed", | |
| 169 | + | "bb-interface", | |
| 170 | + | "chrono", | |
| 171 | + | "rand 0.8.5", | |
| 172 | + | "roxmltree", | |
| 173 | + | "serde", | |
| 174 | + | "serde_json", | |
| 175 | + | "sha2", | |
| 176 | + | "sqlx", | |
| 177 | + | "synckit-client", | |
| 178 | + | "tauri", | |
| 179 | + | "tauri-build", | |
| 180 | + | "tauri-plugin-dialog", | |
| 181 | + | "tauri-plugin-shell", | |
| 182 | + | "tauri-plugin-window-state", | |
| 183 | + | "tokio", | |
| 184 | + | "toml 0.8.2", | |
| 185 | + | "tracing", | |
| 186 | + | "tracing-subscriber", | |
| 187 | + | "uuid", | |
| 188 | + | ] | |
| 189 | + | ||
| 190 | + | [[package]] | |
| 191 | + | name = "base64" | |
| 192 | + | version = "0.21.7" | |
| 193 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 194 | + | checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" | |
| 195 | + | ||
| 196 | + | [[package]] | |
| 197 | + | name = "base64" | |
| 198 | + | version = "0.22.1" | |
| 199 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 200 | + | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" | |
| 201 | + | ||
| 202 | + | [[package]] | |
| 203 | + | name = "base64ct" | |
| 204 | + | version = "1.8.3" | |
| 205 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 206 | + | checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" | |
| 207 | + | ||
| 208 | + | [[package]] | |
| 209 | + | name = "bb-core" | |
| 210 | + | version = "0.1.0" | |
| 211 | + | dependencies = [ | |
| 212 | + | "aes-gcm", | |
| 213 | + | "base64 0.22.1", | |
| 214 | + | "bb-db", | |
| 215 | + | "bb-feed", | |
| 216 | + | "bb-interface", | |
| 217 | + | "chrono", | |
| 218 | + | "html2text", | |
| 219 | + | "rand 0.8.5", | |
| 220 | + | "regex", | |
| 221 | + | "rhai", | |
| 222 | + | "roxmltree", | |
| 223 | + | "serde", | |
| 224 | + | "serde_json", | |
| 225 | + | "sqlx", | |
| 226 | + | "thiserror 1.0.69", | |
| 227 | + | "tokio", | |
| 228 | + | "tracing", | |
| 229 | + | "ureq", | |
| 230 | + | "url", | |
| 231 | + | ] | |
| 232 | + | ||
| 233 | + | [[package]] | |
| 234 | + | name = "bb-db" | |
| 235 | + | version = "0.1.0" | |
| 236 | + | dependencies = [ | |
| 237 | + | "bb-interface", | |
| 238 | + | "chrono", | |
| 239 | + | "serde", | |
| 240 | + | "serde_json", | |
| 241 | + | "sqlx", | |
| 242 | + | "thiserror 1.0.69", | |
| 243 | + | "tokio", | |
| 244 | + | "tracing", | |
| 245 | + | "uuid", | |
| 246 | + | ] | |
| 247 | + | ||
| 248 | + | [[package]] | |
| 249 | + | name = "bb-feed" | |
| 250 | + | version = "0.1.0" | |
| 251 | + | dependencies = [ | |
| 252 | + | "bb-db", | |
| 253 | + | "bb-interface", | |
| 254 | + | "chrono", | |
| 255 | + | "serde_json", | |
| 256 | + | "sqlx", | |
| 257 | + | "thiserror 1.0.69", | |
| 258 | + | "tokio", | |
| 259 | + | "tracing", | |
| 260 | + | ] | |
| 261 | + | ||
| 262 | + | [[package]] | |
| 263 | + | name = "bb-interface" | |
| 264 | + | version = "0.1.0" | |
| 265 | + | dependencies = [ | |
| 266 | + | "chrono", | |
| 267 | + | "serde", | |
| 268 | + | ] | |
| 269 | + | ||
| 270 | + | [[package]] | |
| 271 | + | name = "bitflags" | |
| 272 | + | version = "1.3.2" | |
| 273 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 274 | + | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" | |
| 275 | + | ||
| 276 | + | [[package]] | |
| 277 | + | name = "bitflags" | |
| 278 | + | version = "2.10.0" | |
| 279 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 280 | + | checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" | |
| 281 | + | dependencies = [ | |
| 282 | + | "serde_core", | |
| 283 | + | ] | |
| 284 | + | ||
| 285 | + | [[package]] | |
| 286 | + | name = "blake2" | |
| 287 | + | version = "0.10.6" | |
| 288 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 289 | + | checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" | |
| 290 | + | dependencies = [ | |
| 291 | + | "digest", | |
| 292 | + | ] | |
| 293 | + | ||
| 294 | + | [[package]] | |
| 295 | + | name = "block-buffer" | |
| 296 | + | version = "0.10.4" | |
| 297 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 298 | + | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" | |
| 299 | + | dependencies = [ | |
| 300 | + | "generic-array", | |
| 301 | + | ] | |
| 302 | + | ||
| 303 | + | [[package]] | |
| 304 | + | name = "block2" | |
| 305 | + | version = "0.6.2" | |
| 306 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 307 | + | checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" | |
| 308 | + | dependencies = [ | |
| 309 | + | "objc2", | |
| 310 | + | ] | |
| 311 | + | ||
| 312 | + | [[package]] | |
| 313 | + | name = "brotli" | |
| 314 | + | version = "8.0.2" | |
| 315 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 316 | + | checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" | |
| 317 | + | dependencies = [ | |
| 318 | + | "alloc-no-stdlib", | |
| 319 | + | "alloc-stdlib", | |
| 320 | + | "brotli-decompressor", | |
| 321 | + | ] | |
| 322 | + | ||
| 323 | + | [[package]] | |
| 324 | + | name = "brotli-decompressor" | |
| 325 | + | version = "5.0.0" | |
| 326 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 327 | + | checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" | |
| 328 | + | dependencies = [ | |
| 329 | + | "alloc-no-stdlib", | |
| 330 | + | "alloc-stdlib", | |
| 331 | + | ] | |
| 332 | + | ||
| 333 | + | [[package]] | |
| 334 | + | name = "bumpalo" | |
| 335 | + | version = "3.19.1" | |
| 336 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 337 | + | checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" | |
| 338 | + | ||
| 339 | + | [[package]] | |
| 340 | + | name = "bytemuck" | |
| 341 | + | version = "1.25.0" | |
| 342 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 343 | + | checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | |
| 344 | + | ||
| 345 | + | [[package]] | |
| 346 | + | name = "byteorder" | |
| 347 | + | version = "1.5.0" | |
| 348 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 349 | + | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" | |
| 350 | + | ||
| 351 | + | [[package]] | |
| 352 | + | name = "bytes" | |
| 353 | + | version = "1.11.1" | |
| 354 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 355 | + | checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" | |
| 356 | + | dependencies = [ | |
| 357 | + | "serde", | |
| 358 | + | ] | |
| 359 | + | ||
| 360 | + | [[package]] | |
| 361 | + | name = "cairo-rs" | |
| 362 | + | version = "0.18.5" | |
| 363 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 364 | + | checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" | |
| 365 | + | dependencies = [ | |
| 366 | + | "bitflags 2.10.0", | |
| 367 | + | "cairo-sys-rs", | |
| 368 | + | "glib", | |
| 369 | + | "libc", | |
| 370 | + | "once_cell", | |
| 371 | + | "thiserror 1.0.69", | |
| 372 | + | ] | |
| 373 | + | ||
| 374 | + | [[package]] | |
| 375 | + | name = "cairo-sys-rs" | |
| 376 | + | version = "0.18.2" | |
| 377 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 378 | + | checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" | |
| 379 | + | dependencies = [ | |
| 380 | + | "glib-sys", | |
| 381 | + | "libc", | |
| 382 | + | "system-deps", | |
| 383 | + | ] | |
| 384 | + | ||
| 385 | + | [[package]] | |
| 386 | + | name = "camino" | |
| 387 | + | version = "1.2.2" | |
| 388 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 389 | + | checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" | |
| 390 | + | dependencies = [ | |
| 391 | + | "serde_core", | |
| 392 | + | ] | |
| 393 | + | ||
| 394 | + | [[package]] | |
| 395 | + | name = "cargo-platform" | |
| 396 | + | version = "0.1.9" | |
| 397 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 398 | + | checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" | |
| 399 | + | dependencies = [ | |
| 400 | + | "serde", | |
| 401 | + | ] | |
| 402 | + | ||
| 403 | + | [[package]] | |
| 404 | + | name = "cargo_metadata" | |
| 405 | + | version = "0.19.2" | |
| 406 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 407 | + | checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" | |
| 408 | + | dependencies = [ | |
| 409 | + | "camino", | |
| 410 | + | "cargo-platform", | |
| 411 | + | "semver", | |
| 412 | + | "serde", | |
| 413 | + | "serde_json", | |
| 414 | + | "thiserror 2.0.18", | |
| 415 | + | ] | |
| 416 | + | ||
| 417 | + | [[package]] | |
| 418 | + | name = "cargo_toml" | |
| 419 | + | version = "0.22.3" | |
| 420 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 421 | + | checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" | |
| 422 | + | dependencies = [ | |
| 423 | + | "serde", | |
| 424 | + | "toml 0.9.12+spec-1.1.0", | |
| 425 | + | ] | |
| 426 | + | ||
| 427 | + | [[package]] | |
| 428 | + | name = "cc" | |
| 429 | + | version = "1.2.55" | |
| 430 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 431 | + | checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" | |
| 432 | + | dependencies = [ | |
| 433 | + | "find-msvc-tools", | |
| 434 | + | "shlex", | |
| 435 | + | ] | |
| 436 | + | ||
| 437 | + | [[package]] | |
| 438 | + | name = "cesu8" | |
| 439 | + | version = "1.1.0" | |
| 440 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 441 | + | checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" | |
| 442 | + | ||
| 443 | + | [[package]] | |
| 444 | + | name = "cfb" | |
| 445 | + | version = "0.7.3" | |
| 446 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 447 | + | checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" | |
| 448 | + | dependencies = [ | |
| 449 | + | "byteorder", | |
| 450 | + | "fnv", | |
| 451 | + | "uuid", | |
| 452 | + | ] | |
| 453 | + | ||
| 454 | + | [[package]] | |
| 455 | + | name = "cfg-expr" | |
| 456 | + | version = "0.15.8" | |
| 457 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 458 | + | checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" | |
| 459 | + | dependencies = [ | |
| 460 | + | "smallvec", | |
| 461 | + | "target-lexicon", | |
| 462 | + | ] | |
| 463 | + | ||
| 464 | + | [[package]] | |
| 465 | + | name = "cfg-if" | |
| 466 | + | version = "1.0.4" | |
| 467 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 468 | + | checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" | |
| 469 | + | ||
| 470 | + | [[package]] | |
| 471 | + | name = "chacha20" | |
| 472 | + | version = "0.9.1" | |
| 473 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 474 | + | checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" | |
| 475 | + | dependencies = [ | |
| 476 | + | "cfg-if", | |
| 477 | + | "cipher", | |
| 478 | + | "cpufeatures", | |
| 479 | + | ] | |
| 480 | + | ||
| 481 | + | [[package]] | |
| 482 | + | name = "chacha20poly1305" | |
| 483 | + | version = "0.10.1" | |
| 484 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 485 | + | checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" | |
| 486 | + | dependencies = [ | |
| 487 | + | "aead", | |
| 488 | + | "chacha20", | |
| 489 | + | "cipher", | |
| 490 | + | "poly1305", | |
| 491 | + | "zeroize", | |
| 492 | + | ] | |
| 493 | + | ||
| 494 | + | [[package]] | |
| 495 | + | name = "chrono" | |
| 496 | + | version = "0.4.43" | |
| 497 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 498 | + | checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" | |
| 499 | + | dependencies = [ | |
| 500 | + | "iana-time-zone", |
Lines truncated
| @@ -0,0 +1,49 @@ | |||
| 1 | + | [workspace] | |
| 2 | + | resolver = "2" | |
| 3 | + | members = [ | |
| 4 | + | "crates/bb-interface", | |
| 5 | + | "crates/bb-core", | |
| 6 | + | "crates/bb-feed", | |
| 7 | + | "crates/bb-db", | |
| 8 | + | "src-tauri", | |
| 9 | + | ] | |
| 10 | + | default-members = ["src-tauri"] | |
| 11 | + | ||
| 12 | + | [workspace.package] | |
| 13 | + | version = "0.1.0" | |
| 14 | + | edition = "2021" | |
| 15 | + | authors = ["BalancedBreakfast Contributors"] | |
| 16 | + | license-file = "LICENSE" | |
| 17 | + | ||
| 18 | + | [workspace.dependencies] | |
| 19 | + | # Core dependencies | |
| 20 | + | tokio = { version = "1.49.0", features = ["rt-multi-thread", "sync", "time", "macros"] } | |
| 21 | + | thiserror = "1.0.69" | |
| 22 | + | serde = { version = "1.0.228", features = ["derive"] } | |
| 23 | + | serde_json = "1.0.149" | |
| 24 | + | tracing = "0.1.44" | |
| 25 | + | tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } | |
| 26 | + | ||
| 27 | + | # Database | |
| 28 | + | sqlx = { version = "0.8.6", features = ["runtime-tokio", "sqlite", "chrono", "uuid"] } | |
| 29 | + | ||
| 30 | + | # XML parsing | |
| 31 | + | roxmltree = "0.19.0" | |
| 32 | + | ||
| 33 | + | # Encryption | |
| 34 | + | aes-gcm = "0.10" | |
| 35 | + | base64 = "0.22" | |
| 36 | + | rand = "0.8" | |
| 37 | + | ||
| 38 | + | # Utilities | |
| 39 | + | chrono = { version = "0.4.43", features = ["serde"] } | |
| 40 | + | uuid = { version = "1.20.0", features = ["v4", "serde"] } | |
| 41 | + | toml = "0.8.2" | |
| 42 | + | dirs = "6.0.0" | |
| 43 | + | ||
| 44 | + | # Internal crates | |
| 45 | + | bb-interface = { path = "crates/bb-interface" } | |
| 46 | + | bb-core = { path = "crates/bb-core" } | |
| 47 | + | bb-feed = { path = "crates/bb-feed" } | |
| 48 | + | bb-db = { path = "crates/bb-db" } | |
| 49 | + | synckit-client = { path = "../../synckit-client" } |
| @@ -0,0 +1,118 @@ | |||
| 1 | + | PolyForm Noncommercial License 1.0.0 | |
| 2 | + | ||
| 3 | + | <https://polyformproject.org/licenses/noncommercial/1.0.0> | |
| 4 | + | ||
| 5 | + | Acceptance | |
| 6 | + | ||
| 7 | + | In order to get any license under these terms, you must agree to them as | |
| 8 | + | both strict obligations and conditions to all your licenses. | |
| 9 | + | ||
| 10 | + | Copyright License | |
| 11 | + | ||
| 12 | + | The licensor grants you a copyright license for the software to do | |
| 13 | + | everything you might do with the software that would otherwise infringe | |
| 14 | + | the licensor's copyright in it for any permitted purpose. However, you | |
| 15 | + | may only distribute the software according to Distribution License and | |
| 16 | + | make changes or new works based on the software according to Changes and | |
| 17 | + | New Works License. | |
| 18 | + | ||
| 19 | + | Distribution License | |
| 20 | + | ||
| 21 | + | The licensor grants you an additional copyright license to distribute | |
| 22 | + | copies of the software. Your license to distribute covers distributing | |
| 23 | + | the software with changes and new works permitted by Changes and New | |
| 24 | + | Works License. | |
| 25 | + | ||
| 26 | + | Notices | |
| 27 | + | ||
| 28 | + | You must ensure that anyone who gets a copy of any part of the software | |
| 29 | + | from you also gets a copy of these terms or the URL for them above, as | |
| 30 | + | well as copies of any plain-text lines beginning with "Required Notice:" | |
| 31 | + | that the licensor provided with the software. For example: | |
| 32 | + | ||
| 33 | + | Required Notice: Copyright Yoyodyne, Inc. (http://example.com) | |
| 34 | + | ||
| 35 | + | Changes and New Works License | |
| 36 | + | ||
| 37 | + | The licensor grants you an additional copyright license to make changes | |
| 38 | + | and new works based on the software for any permitted purpose. | |
| 39 | + | ||
| 40 | + | Patent License | |
| 41 | + | ||
| 42 | + | The licensor grants you a patent license for the software that covers | |
| 43 | + | patent claims the licensor can license, or becomes able to license, that | |
| 44 | + | you would infringe by using the software. | |
| 45 | + | ||
| 46 | + | Noncommercial Purposes | |
| 47 | + | ||
| 48 | + | Any noncommercial purpose is a permitted purpose. | |
| 49 | + | ||
| 50 | + | Personal Uses | |
| 51 | + | ||
| 52 | + | Personal use for research, experiment, and testing for the benefit of | |
| 53 | + | public knowledge, personal study, private entertainment, hobby projects, | |
| 54 | + | amateur pursuits, or religious observance, without any anticipated | |
| 55 | + | commercial application, is use for a permitted purpose. | |
| 56 | + | ||
| 57 | + | Noncommercial Organizations | |
| 58 | + | ||
| 59 | + | Use by any charitable organization, educational institution, public | |
| 60 | + | research organization, public safety or health organization, | |
| 61 | + | environmental protection organization, or government institution is use | |
| 62 | + | for a permitted purpose regardless of the source of funding or | |
| 63 | + | obligations resulting from the funding. | |
| 64 | + | ||
| 65 | + | Fair Use | |
| 66 | + | ||
| 67 | + | You may have "fair use" rights for the software under the law. These | |
| 68 | + | terms do not limit them. | |
| 69 | + | ||
| 70 | + | No Other Rights | |
| 71 | + | ||
| 72 | + | These terms do not allow you to sublicense or transfer any of your | |
| 73 | + | licenses to anyone else, or prevent the licensor from granting licenses | |
| 74 | + | to anyone else. These terms do not imply any other licenses. | |
| 75 | + | ||
| 76 | + | Patent Defense | |
| 77 | + | ||
| 78 | + | If you make any written claim that the software infringes or contributes | |
| 79 | + | to infringement of any patent, your patent license for the software | |
| 80 | + | granted under these terms ends immediately. If your company makes such a | |
| 81 | + | claim, your patent license ends immediately for work on behalf of your | |
| 82 | + | company. | |
| 83 | + | ||
| 84 | + | Violations | |
| 85 | + | ||
| 86 | + | The first time you are notified in writing that you have violated any of | |
| 87 | + | these terms, or done anything with the software not covered by your | |
| 88 | + | licenses, your licenses can nonetheless continue if you come into full | |
| 89 | + | compliance with these terms, and take practical steps to correct past | |
| 90 | + | violations, within 32 days of receiving notice. Otherwise, all your | |
| 91 | + | licenses end immediately. | |
| 92 | + | ||
| 93 | + | No Liability | |
| 94 | + | ||
| 95 | + | As far as the law allows, the software comes as is, without any warranty | |
| 96 | + | or condition, and the licensor will not be liable to you for any damages | |
| 97 | + | arising out of these terms or the use or nature of the software, under | |
| 98 | + | any kind of legal claim. | |
| 99 | + | ||
| 100 | + | Definitions | |
| 101 | + | ||
| 102 | + | The licensor is the individual or entity offering these terms, and the | |
| 103 | + | software is the software the licensor makes available under these terms. | |
| 104 | + | ||
| 105 | + | You refers to the individual or entity agreeing to these terms. | |
| 106 | + | ||
| 107 | + | Your company is any legal entity, sole proprietorship, or other kind of | |
| 108 | + | organization that you work for, plus all organizations that have control | |
| 109 | + | over, are under the control of, or are under common control with that | |
| 110 | + | organization. Control means ownership of substantially all the assets of | |
| 111 | + | an entity, or the power to direct its management and policies by vote, | |
| 112 | + | contract, or otherwise. Control can be direct or indirect. | |
| 113 | + | ||
| 114 | + | Your licenses are all the licenses granted to you for the software under | |
| 115 | + | these terms. | |
| 116 | + | ||
| 117 | + | Use means anything you do with the software requiring one of your | |
| 118 | + | licenses. |
| @@ -0,0 +1,294 @@ | |||
| 1 | + | # Balanced Breakfast | |
| 2 | + | ||
| 3 | + | A desktop feed aggregator that unifies RSS, Hacker News, arXiv, and other sources into a single timeline. Built with Tauri 2, Rust, and a Rhai plugin system for extensible feed fetching. | |
| 4 | + | ||
| 5 | + | ## Prerequisites | |
| 6 | + | ||
| 7 | + | - **Rust** (stable toolchain, 2021 edition) | |
| 8 | + | - **Tauri 2 CLI** (`cargo install tauri-cli --version '^2'`) | |
| 9 | + | - **Linux only:** system dependencies for WebKitGTK | |
| 10 | + | ``` | |
| 11 | + | # Debian/Ubuntu | |
| 12 | + | sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file \ | |
| 13 | + | libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev | |
| 14 | + | ||
| 15 | + | # Arch | |
| 16 | + | sudo pacman -S webkit2gtk-4.1 base-devel curl wget file openssl \ | |
| 17 | + | appmenu-gtk-module libappindicator-gtk3 librsvg2-dev | |
| 18 | + | ``` | |
| 19 | + | - **macOS / Windows:** no extra system dependencies beyond Rust and the Tauri CLI. | |
| 20 | + | ||
| 21 | + | ## Build and Run | |
| 22 | + | ||
| 23 | + | ```sh | |
| 24 | + | # Development (hot-reload frontend, debug backend) | |
| 25 | + | cargo tauri dev | |
| 26 | + | ||
| 27 | + | # Production build | |
| 28 | + | cargo tauri build | |
| 29 | + | ||
| 30 | + | # Run all workspace tests | |
| 31 | + | cargo test --workspace | |
| 32 | + | ``` | |
| 33 | + | ||
| 34 | + | ## Workspace Architecture | |
| 35 | + | ||
| 36 | + | The project is a Cargo workspace with four library crates and one application crate: | |
| 37 | + | ||
| 38 | + | | Crate | Path | Role | | |
| 39 | + | |-------|------|------| | |
| 40 | + | | `bb-interface` | `crates/bb-interface/` | Shared types for the plugin system: `FeedItem`, `FetchResult`, `ConfigSchema`, `ConfigField`, `BusserCapabilities`. Defines the contract between plugins and the host. | | |
| 41 | + | | `bb-core` | `crates/bb-core/` | Orchestrator (feed refresh scheduling, lifecycle), Rhai plugin runtime (engine setup, script loading, host function registration), config encryption, URL tracker-stripping. | | |
| 42 | + | | `bb-feed` | `crates/bb-feed/` | Feed aggregation and ordering. Merges items from all active sources and applies sort modes (newest, scored, unread-first, starred-first). | | |
| 43 | + | | `bb-db` | `crates/bb-db/` | SQLite persistence via sqlx. Repositories for feeds, items, tags, and busser key-value state. FTS5 full-text search. | | |
| 44 | + | | `src-tauri` | `src-tauri/` | Tauri 2 desktop shell. Tauri commands (thin wrappers over the library crates), app state, background auto-fetch, frontend (vanilla HTML/CSS/JS). | | |
| 45 | + | ||
| 46 | + | Dependency flow: `bb-interface` is leaf (no internal deps) -> `bb-core` and `bb-feed` depend on `bb-interface` -> `bb-db` depends on `bb-interface` -> `src-tauri` depends on all four. | |
| 47 | + | ||
| 48 | + | ## Plugin Authoring | |
| 49 | + | ||
| 50 | + | Plugins (called "bussers") are `.rhai` script files. Drop a `.rhai` file into the plugins directory and it is loaded on next launch -- no compilation required. | |
| 51 | + | ||
| 52 | + | **Plugin directory locations:** | |
| 53 | + | - Development: `plugins/` at the project root | |
| 54 | + | - Production: `<app_config_dir>/plugins/` (e.g. `~/Library/Application Support/com.balancedbreakfast.app/plugins/` on macOS) | |
| 55 | + | ||
| 56 | + | ### Required Functions | |
| 57 | + | ||
| 58 | + | Every plugin must define four functions: | |
| 59 | + | ||
| 60 | + | ```rhai | |
| 61 | + | fn id() { "my-source" } | |
| 62 | + | ||
| 63 | + | fn name() { "My Source" } | |
| 64 | + | ||
| 65 | + | fn config_schema() { | |
| 66 | + | #{ | |
| 67 | + | description: "What this plugin does.", | |
| 68 | + | fields: [ | |
| 69 | + | #{ | |
| 70 | + | key: "feed_url", | |
| 71 | + | label: "Feed URL", | |
| 72 | + | field_type: "url", | |
| 73 | + | required: true, | |
| 74 | + | description: "Help text shown below the field", | |
| 75 | + | placeholder: "https://example.com/feed", | |
| 76 | + | default_value: "" | |
| 77 | + | } | |
| 78 | + | ] | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | fn fetch(config, cursor) { | |
| 83 | + | // config is a map with keys from your schema + `feeds` array + `feed_url` shorthand | |
| 84 | + | // cursor is a string on subsequent pages, or () on first fetch | |
| 85 | + | #{ | |
| 86 | + | items: [], | |
| 87 | + | has_more: false, | |
| 88 | + | next_cursor: () | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ``` | |
| 92 | + | ||
| 93 | + | ### Optional: capabilities() | |
| 94 | + | ||
| 95 | + | Return a map to advertise what your plugin supports. All fields default to `false`/`900` if omitted. | |
| 96 | + | ||
| 97 | + | ```rhai | |
| 98 | + | fn capabilities() { | |
| 99 | + | #{ | |
| 100 | + | supports_pagination: true, | |
| 101 | + | supports_search: false, | |
| 102 | + | supports_date_filter: false, | |
| 103 | + | supports_read_state: false, | |
| 104 | + | supports_starring: false, | |
| 105 | + | requires_auth: false, | |
| 106 | + | fetch_interval_secs: 900 // auto-fetch interval; 900 = 15 min, 0 = disable | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ``` | |
| 110 | + | ||
| 111 | + | `fetch_interval_secs` controls how often the background scheduler re-fetches this source. The default is 900 seconds (15 minutes). Set to 0 to disable auto-fetch entirely for a plugin. | |
| 112 | + | ||
| 113 | + | ### Config Schema Fields | |
| 114 | + | ||
| 115 | + | Each field in the `fields` array is a map with these keys: | |
| 116 | + | ||
| 117 | + | | Key | Type | Required | Notes | | |
| 118 | + | |-----|------|----------|-------| | |
| 119 | + | | `key` | string | yes | Config key passed to `fetch()` via `config.key` | | |
| 120 | + | | `label` | string | yes | Display label in the UI | | |
| 121 | + | | `field_type` | string | yes | One of: `text`, `textarea`, `secret`, `url`, `number`, `toggle`, `select` | | |
| 122 | + | | `required` | bool | no | Whether the field must be filled (default `false`) | | |
| 123 | + | | `description` | string | no | Help text shown below the field | | |
| 124 | + | | `default_value` | string | no | Pre-filled value (use `default_value`, not `default`, since `default` is a reserved word in Rhai) | | |
| 125 | + | | `placeholder` | string | no | Placeholder text | | |
| 126 | + | | `options` | array | no | String array of choices for `select` fields | | |
| 127 | + | ||
| 128 | + | ### Fetch Return Shape | |
| 129 | + | ||
| 130 | + | `fetch(config, cursor)` must return a map: | |
| 131 | + | ||
| 132 | + | ```rhai | |
| 133 | + | #{ | |
| 134 | + | items: [ | |
| 135 | + | #{ | |
| 136 | + | id: "unique-item-id", // string, or #{ source: "my-source", item_id: "123" } | |
| 137 | + | bite: #{ | |
| 138 | + | author: "Author Name", // shown in the item list | |
| 139 | + | text: "Headline or summary", // primary display text | |
| 140 | + | secondary: "42 pts", // optional, shown as secondary info | |
| 141 | + | indicator: "icon" // optional, type indicator | |
| 142 | + | }, | |
| 143 | + | content: #{ | |
| 144 | + | title: "Full Title", // optional | |
| 145 | + | body: "<p>HTML body</p>", // optional | |
| 146 | + | url: "https://example.com" // optional, link to original | |
| 147 | + | }, | |
| 148 | + | meta: #{ | |
| 149 | + | source_name: "My Source", // display name in source list | |
| 150 | + | published_at: 1700000000, // Unix timestamp (seconds) | |
| 151 | + | score: 42, // optional, for score-based ordering | |
| 152 | + | tags: ["tag1", "tag2"] // optional | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ], | |
| 156 | + | has_more: false, // true if more pages available | |
| 157 | + | next_cursor: () // opaque string passed to next fetch() call, or () if done | |
| 158 | + | } | |
| 159 | + | ``` | |
| 160 | + | ||
| 161 | + | All sub-maps (`bite`, `content`, `meta`) are optional. Missing fields fall back to sensible defaults (empty strings, current timestamp, plugin ID as source name). | |
| 162 | + | ||
| 163 | + | ### Host Functions | |
| 164 | + | ||
| 165 | + | These functions are available to all Rhai scripts: | |
| 166 | + | ||
| 167 | + | **HTTP** | |
| 168 | + | ||
| 169 | + | | Function | Signature | Description | | |
| 170 | + | |----------|-----------|-------------| | |
| 171 | + | | `http_get` | `(url: string) -> string` | GET request, returns response body as string | | |
| 172 | + | | `http_get_json` | `(url: string) -> Dynamic` | GET request, returns parsed JSON as a Rhai map/array | | |
| 173 | + | ||
| 174 | + | **Parsing** | |
| 175 | + | ||
| 176 | + | | Function | Signature | Description | | |
| 177 | + | |----------|-----------|-------------| | |
| 178 | + | | `parse_feed` | `(input: string) -> map` | Auto-detects RSS/Atom/JSON Feed, returns `#{ title, link, entries }` where each entry has `id`, `title`, `summary`, `link`, `published`, `author`, `tags` | | |
| 179 | + | | `parse_xml` | `(xml: string) -> map` | Parses XML into nested maps with `tag`, `text`, `attrs`, `children` keys | | |
| 180 | + | | `parse_json` | `(json: string) -> Dynamic` | Parses a JSON string into Rhai values | | |
| 181 | + | ||
| 182 | + | **String Utilities** | |
| 183 | + | ||
| 184 | + | | Function | Signature | Description | | |
| 185 | + | |----------|-----------|-------------| | |
| 186 | + | | `truncate` | `(text, max_len) -> string` | Truncate with ellipsis (`...`) | | |
| 187 | + | | `str_contains` | `(text, pattern) -> bool` | Substring check | | |
| 188 | + | | `str_split` | `(text, separator) -> array` | Split into string array | | |
| 189 | + | | `str_replace` | `(text, from, to) -> string` | Replace all occurrences | | |
| 190 | + | | `str_trim` | `(text) -> string` | Trim whitespace | | |
| 191 | + | | `html_to_text` | `(html) -> string` | Strip HTML tags to plain text | | |
| 192 | + | ||
| 193 | + | **Date/Time** | |
| 194 | + | ||
| 195 | + | | Function | Signature | Description | | |
| 196 | + | |----------|-----------|-------------| | |
| 197 | + | | `timestamp_now` | `() -> int` | Current UTC Unix timestamp (seconds) | | |
| 198 | + | | `parse_datetime` | `(date_str) -> int` | Parse RFC 3339 or RFC 2822 date to Unix timestamp | | |
| 199 | + | ||
| 200 | + | **Other** | |
| 201 | + | ||
| 202 | + | | Function | Signature | Description | | |
| 203 | + | |----------|-----------|-------------| | |
| 204 | + | | `parse_int` | `(text) -> int or ()` | Parse string to integer; returns `()` on failure | | |
| 205 | + | | `strip_tracking` | `(url) -> string` | Remove UTM and other tracking query parameters | | |
| 206 | + | | `debug_print` | `(value) -> ()` | Print to the debug log (visible in dev console) | | |
| 207 | + | ||
| 208 | + | ### Minimal Example | |
| 209 | + | ||
| 210 | + | A complete plugin that fetches an RSS feed: | |
| 211 | + | ||
| 212 | + | ```rhai | |
| 213 | + | fn id() { "my-rss" } | |
| 214 | + | fn name() { "My RSS Plugin" } | |
| 215 | + | ||
| 216 | + | fn config_schema() { | |
| 217 | + | #{ | |
| 218 | + | description: "A simple RSS reader.", | |
| 219 | + | fields: [ | |
| 220 | + | #{ | |
| 221 | + | key: "feed_url", | |
| 222 | + | label: "Feed URL", | |
| 223 | + | field_type: "url", | |
| 224 | + | required: true | |
| 225 | + | } | |
| 226 | + | ] | |
| 227 | + | } | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | fn fetch(config, cursor) { | |
| 231 | + | let xml = http_get(config.feed_url); | |
| 232 | + | let feed = parse_feed(xml); | |
| 233 | + | let items = []; | |
| 234 | + | ||
| 235 | + | for entry in feed.entries { | |
| 236 | + | let published = timestamp_now(); | |
| 237 | + | if entry.published != () { | |
| 238 | + | published = entry.published; | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | items.push(#{ | |
| 242 | + | id: entry.id, | |
| 243 | + | bite: #{ author: feed.title, text: truncate(entry.title, 100) }, | |
| 244 | + | content: #{ title: entry.title, body: entry.summary, url: entry.link }, | |
| 245 | + | meta: #{ source_name: feed.title, published_at: published } | |
| 246 | + | }); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #{ items: items, has_more: false } | |
| 250 | + | } | |
| 251 | + | ``` | |
| 252 | + | ||
| 253 | + | ### Sandbox Limits | |
| 254 | + | ||
| 255 | + | Rhai scripts run with safety limits to prevent hangs: | |
| 256 | + | ||
| 257 | + | - **100,000 max operations** per execution (a typical RSS fetch uses 1k-5k) | |
| 258 | + | - **128 max expression depth** for both expressions and function calls | |
| 259 | + | ||
| 260 | + | Scripts that exceed these limits are terminated with an error. | |
| 261 | + | ||
| 262 | + | ### JSON API Example | |
| 263 | + | ||
| 264 | + | For sources with JSON APIs (no XML), use `http_get_json` instead of `parse_feed`: | |
| 265 | + | ||
| 266 | + | ```rhai | |
| 267 | + | fn fetch(config, cursor) { | |
| 268 | + | let data = http_get_json("https://api.example.com/posts"); | |
| 269 | + | let items = []; | |
| 270 | + | ||
| 271 | + | for post in data { | |
| 272 | + | items.push(#{ | |
| 273 | + | id: "" + post.id, | |
| 274 | + | bite: #{ author: post.author, text: truncate(post.title, 100) }, | |
| 275 | + | content: #{ title: post.title, body: post.body, url: post.url }, | |
| 276 | + | meta: #{ source_name: "Example", published_at: parse_datetime(post.date) } | |
| 277 | + | }); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #{ items: items, has_more: false } | |
| 281 | + | } | |
| 282 | + | ``` | |
| 283 | + | ||
| 284 | + | ## Bundled Plugins | |
| 285 | + | ||
| 286 | + | Three plugins ship with the app: | |
| 287 | + | ||
| 288 | + | - **rss.rhai** -- RSS, Atom, and JSON Feed support | |
| 289 | + | - **hackernews.rhai** -- Hacker News stories (Top, New, Best, Ask, Show, Jobs) | |
| 290 | + | - **arxiv.rhai** -- arXiv papers by category | |
| 291 | + | ||
| 292 | + | ## License | |
| 293 | + | ||
| 294 | + | PolyForm Noncommercial 1.0.0 |
| @@ -0,0 +1,40 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "bb-core" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | description = "Core orchestrator and plugin manager for BalancedBreakfast" | |
| 6 | + | ||
| 7 | + | [dependencies] | |
| 8 | + | bb-interface.workspace = true | |
| 9 | + | bb-db.workspace = true | |
| 10 | + | bb-feed.workspace = true | |
| 11 | + | sqlx.workspace = true | |
| 12 | + | tokio.workspace = true | |
| 13 | + | thiserror.workspace = true | |
| 14 | + | tracing.workspace = true | |
| 15 | + | serde.workspace = true | |
| 16 | + | serde_json.workspace = true | |
| 17 | + | chrono.workspace = true | |
| 18 | + | ||
| 19 | + | # Encryption for plugin secrets | |
| 20 | + | aes-gcm = { workspace = true } | |
| 21 | + | base64 = { workspace = true } | |
| 22 | + | rand = { workspace = true } | |
| 23 | + | ||
| 24 | + | # Rhai scripting engine for plugins | |
| 25 | + | rhai = { version = "1.24.0", features = ["sync", "serde"] } | |
| 26 | + | ||
| 27 | + | # HTTP client for plugin scripts (blocking) | |
| 28 | + | ureq = { version = "2.12.1", features = ["json"] } | |
| 29 | + | ||
| 30 | + | # XML parsing for RSS/Atom feeds in plugins | |
| 31 | + | roxmltree.workspace = true | |
| 32 | + | ||
| 33 | + | # HTML to text conversion | |
| 34 | + | html2text = "0.12.6" | |
| 35 | + | ||
| 36 | + | # URL parsing for tracker stripping | |
| 37 | + | url = "2" | |
| 38 | + | ||
| 39 | + | # Regex for HTML URL rewriting | |
| 40 | + | regex = "1" |
| @@ -0,0 +1,253 @@ | |||
| 1 | + | //! Encryption helpers for plugin secrets at rest. | |
| 2 | + | //! | |
| 3 | + | //! Secret-type config fields are encrypted using AES-256-GCM before storage. | |
| 4 | + | //! The encrypted format is: `bb_enc:v1:<base64(nonce[12] || ciphertext || tag[16])>` | |
| 5 | + | //! | |
| 6 | + | //! Backward compatibility: `decrypt_field()` checks for the `bb_enc:v1:` prefix. | |
| 7 | + | //! If absent, returns the value as-is (plaintext passthrough). This allows existing | |
| 8 | + | //! configs to work without migration. | |
| 9 | + | ||
| 10 | + | use aes_gcm::aead::{Aead, OsRng}; | |
| 11 | + | use aes_gcm::{AeadCore, Aes256Gcm, KeyInit}; | |
| 12 | + | use base64::engine::general_purpose::STANDARD as BASE64; | |
| 13 | + | use base64::Engine; | |
| 14 | + | use bb_interface::{ConfigFieldType, ConfigSchema}; | |
| 15 | + | use rand::RngCore; | |
| 16 | + | use std::path::Path; | |
| 17 | + | ||
| 18 | + | const PREFIX: &str = "bb_enc:v1:"; | |
| 19 | + | ||
| 20 | + | /// Generate a random 256-bit encryption key. | |
| 21 | + | pub fn generate_key() -> [u8; 32] { | |
| 22 | + | let mut key = [0u8; 32]; | |
| 23 | + | rand::thread_rng().fill_bytes(&mut key); | |
| 24 | + | key | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Load an encryption key from a file, or generate and save one if it doesn't exist. | |
| 28 | + | pub fn load_or_create_key(path: &Path) -> Result<[u8; 32], String> { | |
| 29 | + | if path.exists() { | |
| 30 | + | let data = std::fs::read(path).map_err(|e| format!("Failed to read encryption key: {e}"))?; | |
| 31 | + | if data.len() != 32 { | |
| 32 | + | return Err(format!( | |
| 33 | + | "Encryption key file has wrong size: {} bytes (expected 32)", | |
| 34 | + | data.len() | |
| 35 | + | )); | |
| 36 | + | } | |
| 37 | + | let mut key = [0u8; 32]; | |
| 38 | + | key.copy_from_slice(&data); | |
| 39 | + | Ok(key) | |
| 40 | + | } else { | |
| 41 | + | let key = generate_key(); | |
| 42 | + | std::fs::write(path, key).map_err(|e| format!("Failed to write encryption key: {e}"))?; | |
| 43 | + | #[cfg(unix)] | |
| 44 | + | { | |
| 45 | + | use std::os::unix::fs::PermissionsExt; | |
| 46 | + | let perms = std::fs::Permissions::from_mode(0o600); | |
| 47 | + | std::fs::set_permissions(path, perms) | |
| 48 | + | .map_err(|e| format!("Failed to set key file permissions: {e}"))?; | |
| 49 | + | } | |
| 50 | + | Ok(key) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Encrypt a plaintext string field using AES-256-GCM. | |
| 55 | + | /// Returns a string in the format `bb_enc:v1:<base64(nonce || ciphertext || tag)>`. | |
| 56 | + | pub fn encrypt_field(plaintext: &str, key: &[u8; 32]) -> Result<String, String> { | |
| 57 | + | let cipher = Aes256Gcm::new(key.into()); | |
| 58 | + | let nonce = Aes256Gcm::generate_nonce(&mut OsRng); | |
| 59 | + | let ciphertext = cipher | |
| 60 | + | .encrypt(&nonce, plaintext.as_bytes()) | |
| 61 | + | .map_err(|e| format!("Encryption failed: {e}"))?; | |
| 62 | + | ||
| 63 | + | // nonce (12 bytes) || ciphertext+tag | |
| 64 | + | let mut payload = Vec::with_capacity(12 + ciphertext.len()); | |
| 65 | + | payload.extend_from_slice(&nonce); | |
| 66 | + | payload.extend_from_slice(&ciphertext); | |
| 67 | + | ||
| 68 | + | Ok(format!("{}{}", PREFIX, BASE64.encode(&payload))) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | /// Decrypt a field value. If the value has the `bb_enc:v1:` prefix, decrypts it. | |
| 72 | + | /// Otherwise returns the value as-is (plaintext passthrough for backward compatibility). | |
| 73 | + | pub fn decrypt_field(value: &str, key: &[u8; 32]) -> Result<String, String> { | |
| 74 | + | let Some(encoded) = value.strip_prefix(PREFIX) else { | |
| 75 | + | return Ok(value.to_string()); | |
| 76 | + | }; | |
| 77 | + | ||
| 78 | + | let payload = BASE64 | |
| 79 | + | .decode(encoded) | |
| 80 | + | .map_err(|e| format!("Base64 decode failed: {e}"))?; | |
| 81 | + | ||
| 82 | + | if payload.len() < 12 { | |
| 83 | + | return Err("Encrypted payload too short".to_string()); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | let (nonce_bytes, ciphertext) = payload.split_at(12); | |
| 87 | + | let nonce = aes_gcm::Nonce::from_slice(nonce_bytes); | |
| 88 | + | let cipher = Aes256Gcm::new(key.into()); | |
| 89 | + | ||
| 90 | + | let plaintext = cipher | |
| 91 | + | .decrypt(nonce, ciphertext) | |
| 92 | + | .map_err(|e| format!("Decryption failed: {e}"))?; | |
| 93 | + | ||
| 94 | + | String::from_utf8(plaintext).map_err(|e| format!("Decrypted data is not valid UTF-8: {e}")) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | /// Encrypt Secret-type fields in a config JSON object in-place. | |
| 98 | + | pub fn encrypt_config_secrets( | |
| 99 | + | config: &mut serde_json::Value, | |
| 100 | + | schema: &ConfigSchema, | |
| 101 | + | key: &[u8; 32], | |
| 102 | + | ) { | |
| 103 | + | let Some(obj) = config.as_object_mut() else { | |
| 104 | + | return; | |
| 105 | + | }; | |
| 106 | + | for field in &schema.fields { | |
| 107 | + | if field.field_type != ConfigFieldType::Secret { | |
| 108 | + | continue; | |
| 109 | + | } | |
| 110 | + | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 111 | + | if !val.is_empty() && !val.starts_with(PREFIX) { | |
| 112 | + | if let Ok(encrypted) = encrypt_field(val, key) { | |
| 113 | + | obj.insert(field.key.clone(), serde_json::Value::String(encrypted)); | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// Decrypt Secret-type fields in a config JSON object in-place. | |
| 121 | + | pub fn decrypt_config_secrets( | |
| 122 | + | config: &mut serde_json::Value, | |
| 123 | + | schema: &ConfigSchema, | |
| 124 | + | key: &[u8; 32], | |
| 125 | + | ) { | |
| 126 | + | let Some(obj) = config.as_object_mut() else { | |
| 127 | + | return; | |
| 128 | + | }; | |
| 129 | + | for field in &schema.fields { | |
| 130 | + | if field.field_type != ConfigFieldType::Secret { | |
| 131 | + | continue; | |
| 132 | + | } | |
| 133 | + | if let Some(serde_json::Value::String(val)) = obj.get(&field.key) { | |
| 134 | + | if val.starts_with(PREFIX) { | |
| 135 | + | if let Ok(decrypted) = decrypt_field(val, key) { | |
| 136 | + | obj.insert(field.key.clone(), serde_json::Value::String(decrypted)); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[cfg(test)] | |
| 144 | + | mod tests { | |
| 145 | + | use super::*; | |
| 146 | + | use bb_interface::ConfigField; | |
| 147 | + | ||
| 148 | + | #[test] | |
| 149 | + | fn roundtrip_encrypt_decrypt() { | |
| 150 | + | let key = generate_key(); | |
| 151 | + | let plaintext = "my-secret-api-key-12345"; | |
| 152 | + | let encrypted = encrypt_field(plaintext, &key).unwrap(); | |
| 153 | + | assert!(encrypted.starts_with(PREFIX)); | |
| 154 | + | assert_ne!(encrypted, plaintext); | |
| 155 | + | ||
| 156 | + | let decrypted = decrypt_field(&encrypted, &key).unwrap(); | |
| 157 | + | assert_eq!(decrypted, plaintext); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn passthrough_non_prefixed() { | |
| 162 | + | let key = generate_key(); | |
| 163 | + | let plaintext = "just-a-regular-value"; | |
| 164 | + | let result = decrypt_field(plaintext, &key).unwrap(); | |
| 165 | + | assert_eq!(result, plaintext); | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | #[test] | |
| 169 | + | fn different_nonces_per_call() { | |
| 170 | + | let key = generate_key(); | |
| 171 | + | let plaintext = "same-input"; | |
| 172 | + | let a = encrypt_field(plaintext, &key).unwrap(); | |
| 173 | + | let b = encrypt_field(plaintext, &key).unwrap(); | |
| 174 | + | // Different nonces should produce different ciphertexts | |
| 175 | + | assert_ne!(a, b); | |
| 176 | + | // Both should decrypt to the same value | |
| 177 | + | assert_eq!(decrypt_field(&a, &key).unwrap(), plaintext); | |
| 178 | + | assert_eq!(decrypt_field(&b, &key).unwrap(), plaintext); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[test] | |
| 182 | + | fn wrong_key_fails() { | |
| 183 | + | let key1 = generate_key(); | |
| 184 | + | let key2 = generate_key(); | |
| 185 | + | let encrypted = encrypt_field("secret", &key1).unwrap(); | |
| 186 | + | assert!(decrypt_field(&encrypted, &key2).is_err()); | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | #[test] | |
| 190 | + | fn encrypt_config_secrets_only_secrets() { | |
| 191 | + | let key = generate_key(); | |
| 192 | + | let schema = ConfigSchema { | |
| 193 | + | description: "test".to_string(), | |
| 194 | + | fields: vec![ | |
| 195 | + | ConfigField { | |
| 196 | + | key: "url".to_string(), | |
| 197 | + | label: "URL".to_string(), | |
| 198 | + | description: None, | |
| 199 | + | field_type: ConfigFieldType::Url, | |
| 200 | + | required: true, | |
| 201 | + | default: None, | |
| 202 | + | options: vec![], | |
| 203 | + | placeholder: None, | |
| 204 | + | }, | |
| 205 | + | ConfigField { | |
| 206 | + | key: "api_key".to_string(), | |
| 207 | + | label: "API Key".to_string(), | |
| 208 | + | description: None, | |
| 209 | + | field_type: ConfigFieldType::Secret, | |
| 210 | + | required: true, | |
| 211 | + | default: None, | |
| 212 | + | options: vec![], | |
| 213 | + | placeholder: None, | |
| 214 | + | }, | |
| 215 | + | ], | |
| 216 | + | }; | |
| 217 | + | ||
| 218 | + | let mut config = serde_json::json!({ | |
| 219 | + | "url": "https://example.com/feed", | |
| 220 | + | "api_key": "sk-12345" | |
| 221 | + | }); | |
| 222 | + | ||
| 223 | + | encrypt_config_secrets(&mut config, &schema, &key); | |
| 224 | + | ||
| 225 | + | // URL should be unchanged | |
| 226 | + | assert_eq!(config["url"], "https://example.com/feed"); | |
| 227 | + | // Secret should be encrypted | |
| 228 | + | let encrypted = config["api_key"].as_str().unwrap(); | |
| 229 | + | assert!(encrypted.starts_with(PREFIX)); | |
| 230 | + | ||
| 231 | + | // Decrypt and verify | |
| 232 | + | decrypt_config_secrets(&mut config, &schema, &key); | |
| 233 | + | assert_eq!(config["api_key"], "sk-12345"); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn load_or_create_key_creates_and_reloads() { | |
| 238 | + | let dir = std::env::temp_dir().join(format!("bb_test_{}", std::process::id())); | |
| 239 | + | std::fs::create_dir_all(&dir).unwrap(); | |
| 240 | + | let path = dir.join("test.key"); | |
| 241 | + | ||
| 242 | + | // First call creates the key | |
| 243 | + | let key1 = load_or_create_key(&path).unwrap(); | |
| 244 | + | assert!(path.exists()); | |
| 245 | + | ||
| 246 | + | // Second call loads the same key | |
| 247 | + | let key2 = load_or_create_key(&path).unwrap(); | |
| 248 | + | assert_eq!(key1, key2); | |
| 249 | + | ||
| 250 | + | // Cleanup | |
| 251 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 252 | + | } | |
| 253 | + | } |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | //! BalancedBreakfast Core | |
| 2 | + | //! | |
| 3 | + | //! Central crate containing the orchestrator (feed refresh scheduling and | |
| 4 | + | //! lifecycle management) and the Rhai-based plugin system. The orchestrator | |
| 5 | + | //! coordinates plugins, the database, and the feed generator; the plugin | |
| 6 | + | //! manager handles loading, configuration, and sandboxed script execution. | |
| 7 | + | ||
| 8 | + | pub mod crypto; | |
| 9 | + | pub mod orchestrator; | |
| 10 | + | pub mod plugin_manager; | |
| 11 | + | pub mod rhai_plugin; | |
| 12 | + | pub mod url_cleaner; | |
| 13 | + | ||
| 14 | + | pub use orchestrator::*; | |
| 15 | + | pub use plugin_manager::*; | |
| 16 | + | pub use rhai_plugin::{RhaiPlugin, RhaiPluginError, RhaiPluginManager}; |
| @@ -0,0 +1,469 @@ | |||
| 1 | + | //! Core orchestrator that ties all components together. | |
| 2 | + | //! | |
| 3 | + | //! The orchestrator owns the database, plugin manager, and feed generator. | |
| 4 | + | //! It coordinates plugin lifecycle, fetch scheduling, and data flow from | |
| 5 | + | //! bussers into the SQLite store. | |
| 6 | + | ||
| 7 | + | use std::sync::Arc; | |
| 8 | + | ||
| 9 | + | use bb_db::Database; | |
| 10 | + | use bb_interface::{BusserConfig, ConfigFieldType}; | |
| 11 | + | use thiserror::Error; | |
| 12 | + | use tokio::sync::RwLock; | |
| 13 | + | use tracing::{debug, error, info}; | |
| 14 | + | ||
| 15 | + | use crate::url_cleaner; | |
| 16 | + | use crate::PluginManager; | |
| 17 | + | ||
| 18 | + | #[derive(Error, Debug)] | |
| 19 | + | pub enum OrchestratorError { | |
| 20 | + | #[error("Database error: {0}")] | |
| 21 | + | Database(#[from] sqlx::Error), | |
| 22 | + | #[error("Plugin error: {0}")] | |
| 23 | + | Plugin(#[from] crate::PluginError), | |
| 24 | + | #[error("Feed error: {0}")] | |
| 25 | + | Feed(#[from] bb_feed::FeedError), | |
| 26 | + | #[error("Configuration error: {0}")] | |
| 27 | + | Config(String), | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Configuration for the orchestrator | |
| 31 | + | #[derive(Clone)] | |
| 32 | + | pub struct OrchestratorConfig { | |
| 33 | + | /// SQLite connection URL (e.g. `sqlite:app.db?mode=rwc`). | |
| 34 | + | pub database_url: String, | |
| 35 | + | /// Directory containing `.rhai` plugin scripts. | |
| 36 | + | pub plugins_dir: String, | |
| 37 | + | /// Default auto-fetch interval in seconds. | |
| 38 | + | pub fetch_interval_secs: u64, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl Default for OrchestratorConfig { | |
| 42 | + | fn default() -> Self { | |
| 43 | + | Self { | |
| 44 | + | database_url: "sqlite:balanced_breakfast.db?mode=rwc".to_string(), | |
| 45 | + | plugins_dir: "plugins".to_string(), | |
| 46 | + | fetch_interval_secs: 300, // 5 minutes | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// The main orchestrator that coordinates all components | |
| 52 | + | pub struct Orchestrator { | |
| 53 | + | db: Database, | |
| 54 | + | plugins: Arc<RwLock<PluginManager>>, | |
| 55 | + | /// AES-256-GCM key for encrypting/decrypting plugin secrets at rest. | |
| 56 | + | encryption_key: Option<[u8; 32]>, | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | impl Orchestrator { | |
| 60 | + | /// Create a new orchestrator | |
| 61 | + | pub async fn new(config: OrchestratorConfig) -> Result<Self, OrchestratorError> { | |
| 62 | + | info!("Initializing orchestrator"); | |
| 63 | + | ||
| 64 | + | // Connect to database | |
| 65 | + | let db = Database::connect(&config.database_url).await?; | |
| 66 | + | info!("Connected to database"); | |
| 67 | + | ||
| 68 | + | // Create plugin manager | |
| 69 | + | let plugins = PluginManager::new(&config.plugins_dir); | |
| 70 | + | ||
| 71 | + | Ok(Self { | |
| 72 | + | db, | |
| 73 | + | plugins: Arc::new(RwLock::new(plugins)), | |
| 74 | + | encryption_key: None, | |
| 75 | + | }) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Run database migrations | |
| 79 | + | pub async fn migrate(&self) -> Result<(), OrchestratorError> { | |
| 80 | + | self.db.migrate().await.map_err(|e| { | |
| 81 | + | OrchestratorError::Config(format!("Migration failed: {}", e)) | |
| 82 | + | })?; | |
| 83 | + | info!("Database migrations complete"); | |
| 84 | + | Ok(()) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Load all plugins | |
| 88 | + | pub async fn load_plugins(&self) -> Result<Vec<String>, OrchestratorError> { | |
| 89 | + | let mut plugins = self.plugins.write().await; | |
| 90 | + | let loaded = plugins.load_all()?; | |
| 91 | + | info!("Loaded {} plugins", loaded.len()); | |
| 92 | + | Ok(loaded) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | /// Initialize a plugin with config from database | |
| 96 | + | pub async fn init_plugin_from_db( | |
| 97 | + | &self, | |
| 98 | + | plugin_id: &str, | |
| 99 | + | ) -> Result<(), OrchestratorError> { | |
| 100 | + | // Get feeds for this busser from database | |
| 101 | + | let feeds = self.db.feeds().get_by_busser(plugin_id).await?; | |
| 102 | + | ||
| 103 | + | if feeds.is_empty() { | |
| 104 | + | info!("No feeds configured for plugin: {}", plugin_id); | |
| 105 | + | return Ok(()); | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // Get the plugin's config schema to identify URL fields | |
| 109 | + | let url_fields: Vec<String> = { | |
| 110 | + | let plugins = self.plugins.read().await; | |
| 111 | + | match plugins.get_config_schema(plugin_id) { | |
| 112 | + | Some(schema) => schema | |
| 113 | + | .fields | |
| 114 | + | .iter() | |
| 115 | + | .filter(|f| f.field_type == ConfigFieldType::Url) | |
| 116 | + | .map(|f| f.key.clone()) | |
| 117 | + | .collect(), | |
| 118 | + | None => { | |
| 119 | + | debug!( | |
| 120 | + | "No config schema available for plugin {}, treating all fields as options", | |
| 121 | + | plugin_id | |
| 122 | + | ); | |
| 123 | + | Vec::new() | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | }; | |
| 127 | + | ||
| 128 | + | // Get the full schema for decryption | |
| 129 | + | let full_schema: Option<bb_interface::ConfigSchema> = { | |
| 130 | + | let plugins = self.plugins.read().await; | |
| 131 | + | plugins.get_config_schema(plugin_id) | |
| 132 | + | }; | |
| 133 | + | ||
| 134 | + | // Build config from feeds | |
| 135 | + | let mut config = BusserConfig::new(); | |
| 136 | + | for feed in &feeds { | |
| 137 | + | let mut config_val = feed.config_json(); | |
| 138 | + | ||
| 139 | + | // Decrypt Secret fields before passing to plugin | |
| 140 | + | if let (Some(key), Some(schema)) = (self.encryption_key.as_ref(), &full_schema) { | |
| 141 | + | crate::crypto::decrypt_config_secrets(&mut config_val, schema, key); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | if let Some(obj) = config_val.as_object() { | |
| 145 | + | for (key, value) in obj { | |
| 146 | + | if let Some(v) = value.as_str() { | |
| 147 | + | if url_fields.contains(key) { | |
| 148 | + | // URL-type fields become feeds | |
| 149 | + | config = config.add_feed(v); | |
| 150 | + | } else { | |
| 151 | + | // Other fields become options | |
| 152 | + | config = config.add_option(key, v); | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | let plugins = self.plugins.read().await; | |
| 160 | + | plugins.initialize_plugin(plugin_id, config)?; | |
| 161 | + | ||
| 162 | + | Ok(()) | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | /// Fetch items from a specific plugin | |
| 166 | + | pub async fn fetch_plugin( | |
| 167 | + | &self, | |
| 168 | + | plugin_id: &str, | |
| 169 | + | ) -> Result<usize, OrchestratorError> { | |
| 170 | + | // Get feed ID for this busser | |
| 171 | + | let feeds = self.db.feeds().get_by_busser(plugin_id).await?; | |
| 172 | + | let feed_id = feeds.first().map(|f| f.id); | |
| 173 | + | ||
| 174 | + | let Some(feed_id) = feed_id else { | |
| 175 | + | debug!("No feeds configured for plugin {}, skipping store", plugin_id); | |
| 176 | + | return Ok(0); | |
| 177 | + | }; | |
| 178 | + | ||
| 179 | + | let plugins = self.plugins.read().await; | |
| 180 | + | let result = match plugins.fetch(plugin_id, None) { | |
| 181 | + | Ok(r) => r, | |
| 182 | + | Err(e) => { | |
| 183 | + | // Record fetch failure before propagating | |
| 184 | + | let error_msg = e.to_string(); | |
| 185 | + | if let Err(db_err) = self | |
| 186 | + | .db | |
| 187 | + | .feeds() | |
| 188 | + | .record_fetch_failure(feed_id, &error_msg) | |
| 189 | + | .await | |
| 190 | + | { | |
| 191 | + | error!("Failed to record fetch failure: {}", db_err); | |
| 192 | + | } | |
| 193 | + | return Err(e.into()); | |
| 194 | + | } | |
| 195 | + | }; | |
| 196 | + | ||
| 197 | + | // Store items in database | |
| 198 | + | let mut count = 0; | |
| 199 | + | for item in result.items.iter() { | |
| 200 | + | let mut create_item = bb_db::CreateFeedItem::from_feed_item(item, feed_id); | |
| 201 | + | ||
| 202 | + | // Strip tracking parameters from item URL and body | |
| 203 | + | if let Some(ref url) = create_item.url { | |
| 204 | + | create_item.url = Some(url_cleaner::strip_tracking_params(url)); | |
| 205 | + | } | |
| 206 | + | if let Some(ref body) = create_item.body { | |
| 207 | + | create_item.body = Some(url_cleaner::strip_tracking_from_html(body)); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | match self.db.items().upsert(create_item).await { | |
| 211 | + | Ok(_) => count += 1, | |
| 212 | + | Err(e) => { | |
| 213 | + | error!("Failed to store item: {}", e); | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | // Record successful fetch (resets failure counter) | |
| 219 | + | self.db.feeds().record_fetch_success(feed_id).await?; | |
| 220 | + | ||
| 221 | + | info!("Fetched {} items from {}", count, plugin_id); | |
| 222 | + | Ok(count) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | /// Fetch from all active plugins | |
| 226 | + | pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> { | |
| 227 | + | let plugin_ids = { | |
| 228 | + | let plugins = self.plugins.read().await; | |
| 229 | + | plugins.list_plugins() | |
| 230 | + | }; | |
| 231 | + | ||
| 232 | + | let mut total = 0; | |
| 233 | + | for plugin_id in plugin_ids { | |
| 234 | + | match self.fetch_plugin(&plugin_id).await { | |
| 235 | + | Ok(count) => total += count, | |
| 236 | + | Err(e) => { | |
| 237 | + | error!("Failed to fetch from {}: {}", plugin_id, e); | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | Ok(total) | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | /// Get the database handle | |
| 246 | + | pub fn database(&self) -> &Database { | |
| 247 | + | &self.db | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | /// Get plugin manager | |
| 251 | + | pub fn plugins(&self) -> Arc<RwLock<PluginManager>> { | |
| 252 | + | self.plugins.clone() | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | /// Get a plugin's preferred fetch interval in seconds. | |
| 256 | + | pub async fn fetch_interval_secs(&self, plugin_id: &str) -> u64 { | |
| 257 | + | let plugins = self.plugins.read().await; | |
| 258 | + | plugins | |
| 259 | + | .get_capabilities(plugin_id) | |
| 260 | + | .map(|c| c.fetch_interval_secs) | |
| 261 | + | .unwrap_or(bb_interface::DEFAULT_FETCH_INTERVAL_SECS) | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | /// Set the encryption key for Secret-field encryption at rest. | |
| 265 | + | pub fn set_encryption_key(&mut self, key: [u8; 32]) { | |
| 266 | + | self.encryption_key = Some(key); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | /// Get the encryption key, if set. | |
| 270 | + | pub fn encryption_key(&self) -> Option<&[u8; 32]> { | |
| 271 | + | self.encryption_key.as_ref() | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// Encrypt any plaintext Secret fields in existing feeds. | |
| 275 | + | /// Called once after plugin load to migrate legacy configs. | |
| 276 | + | pub async fn encrypt_existing_secrets(&self) -> Result<(), OrchestratorError> { | |
| 277 | + | let Some(key) = self.encryption_key.as_ref() else { | |
| 278 | + | return Ok(()); | |
| 279 | + | }; | |
| 280 | + | ||
| 281 | + | let all_feeds = self.db.feeds().list_all().await?; | |
| 282 | + | let plugins = self.plugins.read().await; | |
| 283 | + | ||
| 284 | + | for feed in &all_feeds { | |
| 285 | + | let schema = match plugins.get_config_schema(feed.busser_id.as_str()) { | |
| 286 | + | Some(s) => s, | |
| 287 | + | None => continue, | |
| 288 | + | }; | |
| 289 | + | ||
| 290 | + | // Check if any Secret field needs encryption | |
| 291 | + | let mut config = feed.config_json(); | |
| 292 | + | let has_plaintext_secret = schema.fields.iter().any(|f| { | |
| 293 | + | f.field_type == ConfigFieldType::Secret | |
| 294 | + | && config | |
| 295 | + | .get(&f.key) | |
| 296 | + | .and_then(|v| v.as_str()) | |
| 297 | + | .is_some_and(|s| !s.is_empty() && !s.starts_with("bb_enc:v1:")) | |
| 298 | + | }); | |
| 299 | + | ||
| 300 | + | if !has_plaintext_secret { | |
| 301 | + | continue; | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | crate::crypto::encrypt_config_secrets(&mut config, &schema, key); | |
| 305 | + | ||
| 306 | + | // Update the feed's config in the database | |
| 307 | + | let config_str = | |
| 308 | + | serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()); | |
| 309 | + | self.db.feeds().update_config(feed.id, &config_str).await?; | |
| 310 | + | ||
| 311 | + | info!("Encrypted secrets for feed: {}", feed.name); | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | Ok(()) | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | /// Graceful shutdown | |
| 318 | + | pub async fn shutdown(&self) { | |
| 319 | + | info!("Shutting down orchestrator"); | |
| 320 | + | let plugins = self.plugins.write().await; | |
| 321 | + | plugins.shutdown_all(); | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | #[cfg(test)] | |
| 326 | + | mod tests { | |
| 327 | + | use super::*; | |
| 328 | + | use bb_db::{BusserId, CreateFeed, CreateFeedItem}; | |
| 329 | + | use chrono::Utc; | |
| 330 | + | use std::env::temp_dir; | |
| 331 | + | ||
| 332 | + | /// Build an `OrchestratorConfig` that uses in-memory SQLite and an empty | |
| 333 | + | /// temp directory for plugins. | |
| 334 | + | fn test_config(suffix: &str) -> OrchestratorConfig { | |
| 335 | + | let dir = temp_dir().join(format!("bb_orch_test_{}", suffix)); | |
| 336 | + | let _ = std::fs::create_dir_all(&dir); | |
| 337 | + | OrchestratorConfig { | |
| 338 | + | database_url: "sqlite::memory:".to_string(), | |
| 339 | + | plugins_dir: dir.to_string_lossy().to_string(), | |
| 340 | + | fetch_interval_secs: 300, | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 345 | + | fn orchestrator_config_default_values() { | |
| 346 | + | let config = OrchestratorConfig::default(); | |
| 347 | + | assert_eq!(config.fetch_interval_secs, 300); | |
| 348 | + | assert_eq!(config.plugins_dir, "plugins"); | |
| 349 | + | assert!(config.database_url.contains("balanced_breakfast")); | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | #[tokio::test] | |
| 353 | + | async fn orchestrator_new_and_migrate() { | |
| 354 | + | let config = test_config("new_migrate"); | |
| 355 | + | let orchestrator = Orchestrator::new(config).await.unwrap(); | |
| 356 | + | // Migration should succeed on fresh in-memory DB. | |
| 357 | + | orchestrator.migrate().await.unwrap(); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | #[tokio::test] | |
| 361 | + | async fn fetch_interval_secs_default_for_unknown_plugin() { | |
| 362 | + | let config = test_config("fetch_interval"); | |
| 363 | + | let orchestrator = Orchestrator::new(config).await.unwrap(); | |
| 364 | + | // Unknown plugin returns the global default (900). | |
| 365 | + | let interval = orchestrator.fetch_interval_secs("nonexistent").await; | |
| 366 | + | assert_eq!(interval, bb_interface::DEFAULT_FETCH_INTERVAL_SECS); | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | #[tokio::test] | |
| 370 | + | async fn store_items_persists() { | |
| 371 | + | let config = test_config("store_items"); | |
| 372 | + | let orchestrator = Orchestrator::new(config).await.unwrap(); | |
| 373 | + | orchestrator.migrate().await.unwrap(); | |
| 374 | + | ||
| 375 | + | let db = orchestrator.database(); | |
| 376 | + | ||
| 377 | + | // Create a feed to attach items to. | |
| 378 | + | let feed = db | |
| 379 | + | .feeds() | |
| 380 | + | .create(CreateFeed { | |
| 381 | + | busser_id: BusserId::new("test"), | |
| 382 | + | name: "Test Feed".to_string(), | |
| 383 | + | config: serde_json::json!({}), | |
| 384 | + | }) | |
| 385 | + | .await | |
| 386 | + | .unwrap(); | |
| 387 | + | let feed_id = feed.id; | |
| 388 | + | ||
| 389 | + | // Insert an item directly through the DB layer. | |
| 390 | + | let item = CreateFeedItem { | |
| 391 | + | external_id: "test:item-1".to_string(), | |
| 392 | + | feed_id, | |
| 393 | + | busser_id: BusserId::new("test"), | |
| 394 | + | bite_author: "Author".to_string(), | |
| 395 | + | bite_text: "Hello world".to_string(), | |
| 396 | + | bite_secondary: None, | |
| 397 | + | bite_indicator: None, | |
| 398 | + | title: Some("Title".to_string()), | |
| 399 | + | body: None, | |
| 400 | + | url: Some("https://example.com".to_string()), | |
| 401 | + | media: vec![], | |
| 402 | + | published_at: Utc::now(), | |
| 403 | + | source_name: "Test".to_string(), | |
| 404 | + | score: None, | |
| 405 | + | tags: vec![], | |
| 406 | + | }; | |
| 407 | + | ||
| 408 | + | db.items().upsert(item).await.unwrap(); | |
| 409 | + | ||
| 410 | + | // Verify the item is persisted. | |
| 411 | + | let page = db | |
| 412 | + | .items() | |
| 413 | + | .list_by_feed(feed_id, 10, 0) | |
| 414 | + | .await | |
| 415 | + | .unwrap(); | |
| 416 | + | assert_eq!(page.len(), 1); | |
| 417 | + | assert_eq!(page[0].bite_text, "Hello world"); | |
| 418 | + | } | |
| 419 | + | ||
| 420 | + | #[tokio::test] | |
| 421 | + | async fn store_items_dedup() { | |
| 422 | + | let config = test_config("store_dedup"); | |
| 423 | + | let orchestrator = Orchestrator::new(config).await.unwrap(); | |
| 424 | + | orchestrator.migrate().await.unwrap(); | |
| 425 | + | ||
| 426 | + | let db = orchestrator.database(); | |
| 427 | + | ||
| 428 | + | let feed = db | |
| 429 | + | .feeds() | |
| 430 | + | .create(CreateFeed { | |
| 431 | + | busser_id: BusserId::new("test"), | |
| 432 | + | name: "Dedup Feed".to_string(), | |
| 433 | + | config: serde_json::json!({}), | |
| 434 | + | }) | |
| 435 | + | .await | |
| 436 | + | .unwrap(); | |
| 437 | + | let feed_id = feed.id; | |
| 438 | + | ||
| 439 | + | let make_item = || CreateFeedItem { | |
| 440 | + | external_id: "test:dup-1".to_string(), | |
| 441 | + | feed_id, | |
| 442 | + | busser_id: BusserId::new("test"), | |
| 443 | + | bite_author: "Author".to_string(), | |
| 444 | + | bite_text: "Duplicate item".to_string(), | |
| 445 | + | bite_secondary: None, | |
| 446 | + | bite_indicator: None, | |
| 447 | + | title: Some("Dup Title".to_string()), | |
| 448 | + | body: None, | |
| 449 | + | url: Some("https://example.com/dup".to_string()), | |
| 450 | + | media: vec![], | |
| 451 | + | published_at: Utc::now(), | |
| 452 | + | source_name: "Test".to_string(), | |
| 453 | + | score: None, | |
| 454 | + | tags: vec![], | |
| 455 | + | }; | |
| 456 | + | ||
| 457 | + | // Insert the same item twice (same external_id). | |
| 458 | + | db.items().upsert(make_item()).await.unwrap(); | |
| 459 | + | db.items().upsert(make_item()).await.unwrap(); | |
| 460 | + | ||
| 461 | + | // Verify only one copy exists. | |
| 462 | + | let page = db | |
| 463 | + | .items() | |
| 464 | + | .list_by_feed(feed_id, 10, 0) | |
| 465 | + | .await | |
| 466 | + | .unwrap(); | |
| 467 | + | assert_eq!(page.len(), 1); | |
| 468 | + | } | |
| 469 | + | } |
| @@ -0,0 +1,351 @@ | |||
| 1 | + | //! Plugin manager for Rhai script plugins | |
| 2 | + | //! | |
| 3 | + | //! Discovers and loads .rhai plugin scripts from the plugins directory. | |
| 4 | + | ||
| 5 | + | use std::collections::HashMap; | |
| 6 | + | use std::path::{Path, PathBuf}; | |
| 7 | + | use std::sync::RwLock; | |
| 8 | + | ||
| 9 | + | use bb_interface::{BusserCapabilities, BusserConfig, ConfigSchema, FetchResult}; | |
| 10 | + | use thiserror::Error; | |
| 11 | + | use tracing::{debug, error, info, warn}; | |
| 12 | + | ||
| 13 | + | use crate::rhai_plugin::{RhaiPluginError, RhaiPluginManager}; | |
| 14 | + | ||
| 15 | + | #[derive(Error, Debug)] | |
| 16 | + | pub enum PluginError { | |
| 17 | + | #[error("Failed to load plugin: {0}")] | |
| 18 | + | LoadError(String), | |
| 19 | + | #[error("Plugin not found: {0}")] | |
| 20 | + | NotFound(String), | |
| 21 | + | #[error("Plugin initialization failed: {0}")] | |
| 22 | + | InitError(String), | |
| 23 | + | #[error("Plugin fetch failed: {0}")] | |
| 24 | + | FetchError(String), | |
| 25 | + | #[error("Plugin already loaded: {0}")] | |
| 26 | + | AlreadyLoaded(String), | |
| 27 | + | #[error("IO error: {0}")] | |
| 28 | + | IoError(#[from] std::io::Error), | |
| 29 | + | #[error("Rhai error: {0}")] | |
| 30 | + | RhaiError(#[from] RhaiPluginError), | |
| 31 | + | #[error("Lock poisoned: {0}")] | |
| 32 | + | LockPoisoned(String), | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Manages Rhai plugin loading and lifecycle | |
| 36 | + | pub struct PluginManager { | |
| 37 | + | plugins_dir: PathBuf, | |
| 38 | + | rhai_manager: RhaiPluginManager, | |
| 39 | + | plugin_configs: RwLock<HashMap<String, BusserConfig>>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl PluginManager { | |
| 43 | + | pub fn new(plugins_dir: impl AsRef<Path>) -> Self { | |
| 44 | + | Self { | |
| 45 | + | plugins_dir: plugins_dir.as_ref().to_path_buf(), | |
| 46 | + | rhai_manager: RhaiPluginManager::new(), | |
| 47 | + | plugin_configs: RwLock::new(HashMap::new()), | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | /// Get the plugins directory | |
| 52 | + | pub fn plugins_dir(&self) -> &Path { | |
| 53 | + | &self.plugins_dir | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Discover all .rhai plugin files in the plugins directory | |
| 57 | + | pub fn discover_plugins(&self) -> Result<Vec<PathBuf>, PluginError> { | |
| 58 | + | let mut plugins = Vec::new(); | |
| 59 | + | ||
| 60 | + | if !self.plugins_dir.exists() { | |
| 61 | + | info!("Creating plugins directory: {:?}", self.plugins_dir); | |
| 62 | + | std::fs::create_dir_all(&self.plugins_dir)?; | |
| 63 | + | return Ok(plugins); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | for entry in std::fs::read_dir(&self.plugins_dir)? { | |
| 67 | + | let entry = entry?; | |
| 68 | + | let path = entry.path(); | |
| 69 | + | ||
| 70 | + | if Self::is_plugin_file(&path) { | |
| 71 | + | plugins.push(path); | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | info!("Discovered {} Rhai plugins", plugins.len()); | |
| 76 | + | Ok(plugins) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// Check if a path is a valid plugin file (.rhai extension) | |
| 80 | + | fn is_plugin_file(path: &Path) -> bool { | |
| 81 | + | path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("rhai") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Load a plugin from a .rhai file path | |
| 85 | + | pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> { | |
| 86 | + | let path = path.as_ref(); | |
| 87 | + | info!("Loading Rhai plugin from: {:?}", path); | |
| 88 | + | ||
| 89 | + | let id = self | |
| 90 | + | .rhai_manager | |
| 91 | + | .load_plugin(path) | |
| 92 | + | .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?; | |
| 93 | + | ||
| 94 | + | debug!("Loaded Rhai plugin: {}", id); | |
| 95 | + | Ok(id) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Load all discovered plugins | |
| 99 | + | pub fn load_all(&mut self) -> Result<Vec<String>, PluginError> { | |
| 100 | + | let paths = self.discover_plugins()?; | |
| 101 | + | let mut loaded = Vec::new(); | |
| 102 | + | ||
| 103 | + | for path in paths { | |
| 104 | + | match self.load_plugin(&path) { | |
| 105 | + | Ok(id) => loaded.push(id), | |
| 106 | + | Err(e) => { | |
| 107 | + | warn!("Failed to load plugin {:?}: {}", path, e); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | Ok(loaded) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /// Initialize a plugin with configuration | |
| 116 | + | pub fn initialize_plugin( | |
| 117 | + | &self, | |
| 118 | + | plugin_id: &str, | |
| 119 | + | config: BusserConfig, | |
| 120 | + | ) -> Result<(), PluginError> { | |
| 121 | + | // Verify plugin exists | |
| 122 | + | if self.rhai_manager.get(plugin_id).is_none() { | |
| 123 | + | return Err(PluginError::NotFound(plugin_id.to_string())); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Store config for later use in fetch | |
| 127 | + | let mut configs = self | |
| 128 | + | .plugin_configs | |
| 129 | + | .write() | |
| 130 | + | .map_err(|e| PluginError::LockPoisoned(e.to_string()))?; | |
| 131 | + | configs.insert(plugin_id.to_string(), config); | |
| 132 | + | ||
| 133 | + | info!("Initialized plugin: {}", plugin_id); | |
| 134 | + | Ok(()) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | /// Fetch items from a plugin | |
| 138 | + | pub fn fetch( | |
| 139 | + | &self, | |
| 140 | + | plugin_id: &str, | |
| 141 | + | cursor: Option<String>, | |
| 142 | + | ) -> Result<FetchResult, PluginError> { | |
| 143 | + | let plugin = self | |
| 144 | + | .rhai_manager | |
| 145 | + | .get(plugin_id) | |
| 146 | + | .ok_or_else(|| PluginError::NotFound(plugin_id.to_string()))?; | |
| 147 | + | ||
| 148 | + | let configs = self | |
| 149 | + | .plugin_configs | |
| 150 | + | .read() | |
| 151 | + | .map_err(|e| PluginError::LockPoisoned(e.to_string()))?; | |
| 152 | + | let config = configs | |
| 153 | + | .get(plugin_id) | |
| 154 | + | .ok_or_else(|| PluginError::InitError("Plugin not initialized".to_string()))?; | |
| 155 | + | ||
| 156 | + | plugin | |
| 157 | + | .fetch(config, cursor) | |
| 158 | + | .map_err(|e| PluginError::FetchError(e.to_string())) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Shutdown a plugin (no-op for Rhai plugins, kept for API compatibility) | |
| 162 | + | pub fn shutdown_plugin(&self, plugin_id: &str) -> Result<(), PluginError> { | |
| 163 | + | if self.rhai_manager.get(plugin_id).is_none() { | |
| 164 | + | return Err(PluginError::NotFound(plugin_id.to_string())); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | info!("Shutdown plugin: {}", plugin_id); | |
| 168 | + | Ok(()) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | /// Shutdown all plugins | |
| 172 | + | pub fn shutdown_all(&self) { | |
| 173 | + | let plugin_ids = self.rhai_manager.list(); | |
| 174 | + | for id in plugin_ids { | |
| 175 | + | if let Err(e) = self.shutdown_plugin(&id) { | |
| 176 | + | error!("Failed to shutdown plugin {}: {}", id, e); | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// Get list of loaded plugin IDs | |
| 182 | + | pub fn list_plugins(&self) -> Vec<String> { | |
| 183 | + | self.rhai_manager.list() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Get plugin info | |
| 187 | + | pub fn get_plugin_info(&self, plugin_id: &str) -> Option<(String, String, PathBuf)> { | |
| 188 | + | self.rhai_manager.get_info(plugin_id) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | /// Get plugin capabilities | |
| 192 | + | pub fn get_capabilities(&self, plugin_id: &str) -> Option<BusserCapabilities> { | |
| 193 | + | self.rhai_manager.get(plugin_id).map(|p| p.capabilities()) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | /// Get plugin configuration schema | |
| 197 | + | pub fn get_config_schema(&self, plugin_id: &str) -> Option<ConfigSchema> { | |
| 198 | + | self.rhai_manager.get(plugin_id).and_then(|p| { | |
| 199 | + | match p.config_schema() { | |
| 200 | + | Ok(s) => Some(s), | |
| 201 | + | Err(e) => { | |
| 202 | + | warn!("Plugin {} config_schema() failed: {}", plugin_id, e); | |
| 203 | + | None | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | }) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | impl Drop for PluginManager { | |
| 211 | + | fn drop(&mut self) { | |
| 212 | + | self.shutdown_all(); | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[cfg(test)] | |
| 217 | + | mod tests { | |
| 218 | + | use super::*; | |
| 219 | + | use std::env::temp_dir; | |
| 220 | + | ||
| 221 | + | #[test] | |
| 222 | + | fn test_plugin_manager_creation() { | |
| 223 | + | let dir = temp_dir().join("bb_test_plugins"); | |
| 224 | + | let manager = PluginManager::new(&dir); | |
| 225 | + | assert_eq!(manager.plugins_dir(), dir); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn test_discover_empty_dir() { | |
| 230 | + | let dir = temp_dir().join("bb_test_plugins_empty"); | |
| 231 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 232 | + | let manager = PluginManager::new(&dir); | |
| 233 | + | let plugins = manager.discover_plugins().unwrap(); | |
| 234 | + | assert!(plugins.is_empty()); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn test_is_plugin_file() { | |
| 239 | + | // Create a temporary test file | |
| 240 | + | let dir = temp_dir().join("bb_test_plugin_file"); | |
| 241 | + | let _ = std::fs::create_dir_all(&dir); | |
| 242 | + | let rhai_path = dir.join("test.rhai"); | |
| 243 | + | let rs_path = dir.join("test.rs"); | |
| 244 | + | let dylib_path = dir.join("test.dylib"); | |
| 245 | + | ||
| 246 | + | // Create the files | |
| 247 | + | std::fs::write(&rhai_path, "// test").unwrap(); | |
| 248 | + | std::fs::write(&rs_path, "// test").unwrap(); | |
| 249 | + | std::fs::write(&dylib_path, "test").unwrap(); | |
| 250 | + | ||
| 251 | + | assert!(PluginManager::is_plugin_file(&rhai_path)); | |
| 252 | + | assert!(!PluginManager::is_plugin_file(&rs_path)); | |
| 253 | + | assert!(!PluginManager::is_plugin_file(&dylib_path)); | |
| 254 | + | ||
| 255 | + | // Cleanup | |
| 256 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn test_load_rhai_plugin() { | |
| 261 | + | let dir = temp_dir().join("bb_test_rhai_plugin"); | |
| 262 | + | let _ = std::fs::create_dir_all(&dir); | |
| 263 | + | ||
| 264 | + | // Write a simple test plugin | |
| 265 | + | let plugin_code = r#" | |
| 266 | + | fn id() { "test" } | |
| 267 | + | fn name() { "Test Plugin" } | |
| 268 | + | fn config_schema() { | |
| 269 | + | #{ | |
| 270 | + | description: "Test plugin", | |
| 271 | + | fields: [] | |
| 272 | + | } | |
| 273 | + | } | |
| 274 | + | fn fetch(config, cursor) { | |
| 275 | + | #{ | |
| 276 | + | items: [], | |
| 277 | + | has_more: false | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | "#; | |
| 281 | + | let plugin_path = dir.join("test.rhai"); | |
| 282 | + | std::fs::write(&plugin_path, plugin_code).unwrap(); | |
| 283 | + | ||
| 284 | + | // Load the plugin | |
| 285 | + | let mut manager = PluginManager::new(&dir); | |
| 286 | + | let result = manager.load_plugin(&plugin_path); | |
| 287 | + | assert!(result.is_ok()); | |
| 288 | + | assert_eq!(result.unwrap(), "test"); | |
| 289 | + | ||
| 290 | + | // Verify the plugin is loaded | |
| 291 | + | let plugins = manager.list_plugins(); | |
| 292 | + | assert!(plugins.contains(&"test".to_string())); | |
| 293 | + | ||
| 294 | + | // Verify we can get the schema | |
| 295 | + | let schema = manager.get_config_schema("test"); | |
| 296 | + | assert!(schema.is_some()); | |
| 297 | + | assert_eq!(schema.unwrap().description, "Test plugin"); | |
| 298 | + | ||
| 299 | + | // Cleanup | |
| 300 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn initialize_plugin_unknown_returns_not_found() { | |
| 305 | + | let dir = temp_dir().join("bb_test_init_unknown"); | |
| 306 | + | let manager = PluginManager::new(&dir); | |
| 307 | + | let result = manager.initialize_plugin("nonexistent", BusserConfig::new()); | |
| 308 | + | assert!(result.is_err()); | |
| 309 | + | assert!(result.unwrap_err().to_string().contains("not found")); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | #[test] | |
| 313 | + | fn fetch_without_init_returns_error() { | |
| 314 | + | let dir = temp_dir().join("bb_test_fetch_no_init"); | |
| 315 | + | let _ = std::fs::create_dir_all(&dir); | |
| 316 | + | ||
| 317 | + | let plugin_code = r#" | |
| 318 | + | fn id() { "test_fetch" } | |
| 319 | + | fn name() { "Test Fetch" } | |
| 320 | + | fn config_schema() { #{ description: "test", fields: [] } } | |
| 321 | + | fn fetch(config, cursor) { #{ items: [], has_more: false } } | |
| 322 | + | "#; | |
| 323 | + | let plugin_path = dir.join("test_fetch.rhai"); | |
| 324 | + | std::fs::write(&plugin_path, plugin_code).unwrap(); | |
| 325 | + | ||
| 326 | + | let mut manager = PluginManager::new(&dir); | |
| 327 | + | manager.load_plugin(&plugin_path).unwrap(); | |
| 328 | + | ||
| 329 | + | // Fetch without calling initialize_plugin first | |
| 330 | + | let result = manager.fetch("test_fetch", None); | |
| 331 | + | assert!(result.is_err()); | |
| 332 | + | ||
| 333 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | #[test] | |
| 337 | + | fn shutdown_plugin_unknown_returns_not_found() { | |
| 338 | + | let dir = temp_dir().join("bb_test_shutdown_unknown"); | |
| 339 | + | let manager = PluginManager::new(&dir); | |
| 340 | + | let result = manager.shutdown_plugin("nonexistent"); | |
| 341 | + | assert!(result.is_err()); | |
| 342 | + | assert!(result.unwrap_err().to_string().contains("not found")); | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn get_capabilities_unknown_returns_none() { | |
| 347 | + | let dir = temp_dir().join("bb_test_caps_unknown"); | |
| 348 | + | let manager = PluginManager::new(&dir); | |
| 349 | + | assert!(manager.get_capabilities("nonexistent").is_none()); | |
| 350 | + | } | |
| 351 | + | } |
| @@ -0,0 +1,1234 @@ | |||
| 1 | + | //! Rhai <-> Rust type conversions: JSON, XML feed parsing, and Dynamic-to-domain mapping. | |
| 2 | + | //! | |
| 3 | + | //! # Plugin `fetch()` return contract | |
| 4 | + | //! | |
| 5 | + | //! A Rhai plugin's `fetch(config, cursor)` function must return a map with: | |
| 6 | + | //! | |
| 7 | + | //! - **`items`** (array, required) -- array of item maps, each containing: | |
| 8 | + | //! - `id` (string or `{source, item_id}` map, required) | |
| 9 | + | //! - `bite` (map, optional) -- `{author, text, secondary?, indicator?}` | |
| 10 | + | //! - `content` (map, optional) -- `{title?, body?, url?, media?}` | |
| 11 | + | //! - `meta` (map, optional) -- `{source_name?, published_at?, score?, tags?}` | |
| 12 | + | //! - **`has_more`** (bool, optional, default `false`) -- signals additional pages. | |
| 13 | + | //! - **`next_cursor`** (string, optional) -- opaque cursor passed to the next | |
| 14 | + | //! `fetch()` call for pagination. | |
| 15 | + | //! | |
| 16 | + | //! Missing optional fields fall back to sensible defaults (empty strings, | |
| 17 | + | //! current timestamp, etc.). See [`dynamic_to_fetch_result`] and | |
| 18 | + | //! [`dynamic_to_feed_item`] for the full conversion logic. | |
| 19 | + | ||
| 20 | + | use bb_interface::{ | |
| 21 | + | BiteDisplay, BusserCapabilities, BusserConfig, ConfigField, ConfigFieldType, ConfigSchema, | |
| 22 | + | FeedItem, FeedItemContent, FeedItemId, FeedItemMeta, FetchResult, | |
| 23 | + | }; | |
| 24 | + | use rhai::{Dynamic, Map}; | |
| 25 | + | use tracing::warn; | |
| 26 | + | ||
| 27 | + | use super::RhaiPluginError; | |
| 28 | + | ||
| 29 | + | /// Convert serde_json::Value to Rhai Dynamic. | |
| 30 | + | pub(super) fn json_to_dynamic(val: serde_json::Value) -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 31 | + | match val { | |
| 32 | + | serde_json::Value::Null => Ok(Dynamic::UNIT), | |
| 33 | + | serde_json::Value::Bool(b) => Ok(b.into()), | |
| 34 | + | serde_json::Value::Number(n) => { | |
| 35 | + | if let Some(i) = n.as_i64() { | |
| 36 | + | Ok(i.into()) | |
| 37 | + | } else if let Some(f) = n.as_f64() { | |
| 38 | + | Ok(f.into()) | |
| 39 | + | } else { | |
| 40 | + | Ok(n.to_string().into()) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | serde_json::Value::String(s) => Ok(s.into()), | |
| 44 | + | serde_json::Value::Array(arr) => { | |
| 45 | + | let rhai_arr: Result<rhai::Array, _> = arr.into_iter().map(json_to_dynamic).collect(); | |
| 46 | + | Ok(rhai_arr?.into()) | |
| 47 | + | } | |
| 48 | + | serde_json::Value::Object(obj) => { | |
| 49 | + | let mut map = Map::new(); | |
| 50 | + | for (k, v) in obj { | |
| 51 | + | map.insert(k.into(), json_to_dynamic(v)?); | |
| 52 | + | } | |
| 53 | + | Ok(map.into()) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | /// Parse XML into a simplified Dynamic structure. | |
| 59 | + | pub(super) fn parse_xml_to_dynamic(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 60 | + | let doc = | |
| 61 | + | roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?; | |
| 62 | + | ||
| 63 | + | fn element_to_dynamic(node: roxmltree::Node) -> Dynamic { | |
| 64 | + | let mut map = Map::new(); | |
| 65 | + | ||
| 66 | + | // Tag name | |
| 67 | + | if let Some(tag) = node.tag_name().name().into() { | |
| 68 | + | map.insert("tag".into(), Dynamic::from(tag.to_string())); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // Text content | |
| 72 | + | let text: String = node | |
| 73 | + | .children() | |
| 74 | + | .filter(|n| n.is_text()) | |
| 75 | + | .map(|n| n.text().unwrap_or("")) | |
| 76 | + | .collect(); | |
| 77 | + | if !text.trim().is_empty() { | |
| 78 | + | map.insert("text".into(), Dynamic::from(text.trim().to_string())); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Attributes | |
| 82 | + | let mut attrs = Map::new(); | |
| 83 | + | for attr in node.attributes() { | |
| 84 | + | attrs.insert(attr.name().into(), Dynamic::from(attr.value().to_string())); | |
| 85 | + | } | |
| 86 | + | if !attrs.is_empty() { | |
| 87 | + | map.insert("attrs".into(), Dynamic::from(attrs)); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Children | |
| 91 | + | let children: rhai::Array = node | |
| 92 | + | .children() | |
| 93 | + | .filter(|n| n.is_element()) | |
| 94 | + | .map(element_to_dynamic) | |
| 95 | + | .collect(); | |
| 96 | + | if !children.is_empty() { | |
| 97 | + | map.insert("children".into(), Dynamic::from(children)); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | Dynamic::from(map) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | Ok(element_to_dynamic(doc.root_element())) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Parse RSS/Atom feed XML into a feed-friendly structure. | |
| 107 | + | pub(super) fn parse_feed_xml(xml_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 108 | + | let doc = | |
| 109 | + | roxmltree::Document::parse(xml_str).map_err(|e| format!("XML parse error: {}", e))?; | |
| 110 | + | let root = doc.root_element(); | |
| 111 | + | ||
| 112 | + | let mut feed = Map::new(); | |
| 113 | + | let mut entries: rhai::Array = Vec::new(); | |
| 114 | + | ||
| 115 | + | // Detect feed type | |
| 116 | + | let is_atom = root.tag_name().name() == "feed"; | |
| 117 | + | let is_rss = root.tag_name().name() == "rss" || root.tag_name().name() == "RDF"; | |
| 118 | + | ||
| 119 | + | if is_atom { | |
| 120 | + | for child in root.children().filter(|n| n.is_element()) { | |
| 121 | + | match child.tag_name().name() { | |
| 122 | + | "title" => { | |
| 123 | + | feed.insert("title".into(), get_text_content(&child).into()); | |
| 124 | + | } | |
| 125 | + | "link" => { | |
| 126 | + | if let Some(href) = child.attribute("href") { | |
| 127 | + | feed.insert("link".into(), href.to_string().into()); | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | "entry" => { | |
| 131 | + | entries.push(parse_atom_entry(&child)); | |
| 132 | + | } | |
| 133 | + | _ => {} | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | } else if is_rss { | |
| 137 | + | let channel = root | |
| 138 | + | .children() | |
| 139 | + | .find(|n| n.is_element() && n.tag_name().name() == "channel"); | |
| 140 | + | ||
| 141 | + | if let Some(channel) = channel { | |
| 142 | + | for child in channel.children().filter(|n| n.is_element()) { | |
| 143 | + | match child.tag_name().name() { | |
| 144 | + | "title" => { | |
| 145 | + | feed.insert("title".into(), get_text_content(&child).into()); | |
| 146 | + | } | |
| 147 | + | "link" => { | |
| 148 | + | feed.insert("link".into(), get_text_content(&child).into()); | |
| 149 | + | } | |
| 150 | + | "item" => { | |
| 151 | + | entries.push(parse_rss_item(&child)); | |
| 152 | + | } | |
| 153 | + | _ => {} | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | feed.insert("entries".into(), entries.into()); | |
| 160 | + | Ok(feed.into()) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | fn get_text_content(node: &roxmltree::Node) -> String { | |
| 164 | + | node.text().unwrap_or("").trim().to_string() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | fn parse_atom_entry(node: &roxmltree::Node) -> Dynamic { | |
| 168 | + | let mut entry = Map::new(); | |
| 169 | + | ||
| 170 | + | for child in node.children().filter(|n| n.is_element()) { | |
| 171 | + | match child.tag_name().name() { | |
| 172 | + | "id" => { | |
| 173 | + | entry.insert("id".into(), get_text_content(&child).into()); | |
| 174 | + | } | |
| 175 | + | "title" => { | |
| 176 | + | entry.insert("title".into(), get_text_content(&child).into()); | |
| 177 | + | } | |
| 178 | + | "summary" | "content" => { | |
| 179 | + | if !entry.contains_key("summary") { | |
| 180 | + | entry.insert("summary".into(), get_text_content(&child).into()); | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | "link" => { | |
| 184 | + | if let Some(href) = child.attribute("href") { | |
| 185 | + | entry.insert("link".into(), href.to_string().into()); | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | "published" | "updated" => { | |
| 189 | + | if !entry.contains_key("published") { | |
| 190 | + | let date_str = get_text_content(&child); | |
| 191 | + | if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(&date_str) { | |
| 192 | + | entry.insert("published".into(), dt.timestamp().into()); | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | "author" => { | |
| 197 | + | if let Some(name_node) = | |
| 198 | + | child.children().find(|n| n.tag_name().name() == "name") | |
| 199 | + | { | |
| 200 | + | entry.insert("author".into(), get_text_content(&name_node).into()); | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | "category" => { | |
| 204 | + | if let Some(term) = child.attribute("term") { | |
| 205 | + | let tags_arr = entry | |
| 206 | + | .get("tags") | |
| 207 | + | .and_then(|existing| existing.clone().try_cast::<rhai::Array>()) | |
| 208 | + | .unwrap_or_default(); | |
| 209 | + | let mut tags_arr = tags_arr; | |
| 210 | + | tags_arr.push(term.to_string().into()); | |
| 211 | + | entry.insert("tags".into(), tags_arr.into()); | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | _ => {} | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | entry.into() | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | fn parse_rss_item(node: &roxmltree::Node) -> Dynamic { | |
| 222 | + | let mut item = Map::new(); | |
| 223 | + | ||
| 224 | + | for child in node.children().filter(|n| n.is_element()) { | |
| 225 | + | match child.tag_name().name() { | |
| 226 | + | "guid" => { | |
| 227 | + | item.insert("id".into(), get_text_content(&child).into()); | |
| 228 | + | } | |
| 229 | + | "title" => { | |
| 230 | + | item.insert("title".into(), get_text_content(&child).into()); | |
| 231 | + | } | |
| 232 | + | "description" => { | |
| 233 | + | item.insert("summary".into(), get_text_content(&child).into()); | |
| 234 | + | } | |
| 235 | + | "link" => { | |
| 236 | + | item.insert("link".into(), get_text_content(&child).into()); | |
| 237 | + | } | |
| 238 | + | "pubDate" => { | |
| 239 | + | let date_str = get_text_content(&child); | |
| 240 | + | if let Ok(dt) = chrono::DateTime::parse_from_rfc2822(&date_str) { | |
| 241 | + | item.insert("published".into(), dt.timestamp().into()); | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | "author" | "creator" => { | |
| 245 | + | item.insert("author".into(), get_text_content(&child).into()); | |
| 246 | + | } | |
| 247 | + | "category" => { | |
| 248 | + | let tags_arr = item | |
| 249 | + | .get("tags") | |
| 250 | + | .and_then(|existing| existing.clone().try_cast::<rhai::Array>()) | |
| 251 | + | .unwrap_or_default(); | |
| 252 | + | let mut tags_arr = tags_arr; | |
| 253 | + | tags_arr.push(get_text_content(&child).into()); | |
| 254 | + | item.insert("tags".into(), tags_arr.into()); | |
| 255 | + | } | |
| 256 | + | _ => {} | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // If no guid, use link as id | |
| 261 | + | if !item.contains_key("id") { | |
| 262 | + | if let Some(link) = item.get("link") { | |
| 263 | + | item.insert("id".into(), link.clone()); | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | item.into() | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// Parse a JSON Feed (1.0/1.1) string into the same structure as `parse_feed_xml`: | |
| 271 | + | /// `{ title, link, entries: [{ id, title, summary, link, published, author, tags }] }` | |
| 272 | + | /// | |
| 273 | + | /// JSON Feed spec: <https://www.jsonfeed.org/version/1.1/> | |
| 274 | + | pub(super) fn parse_json_feed(json_str: &str) -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 275 | + | let json: serde_json::Value = | |
| 276 | + | serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?; | |
| 277 | + | ||
| 278 | + | let obj = json | |
| 279 | + | .as_object() | |
| 280 | + | .ok_or("JSON Feed root must be an object")?; | |
| 281 | + | ||
| 282 | + | // Validate it's a JSON Feed by checking the version field | |
| 283 | + | let version = obj | |
| 284 | + | .get("version") | |
| 285 | + | .and_then(|v| v.as_str()) | |
| 286 | + | .unwrap_or(""); | |
| 287 | + | if !version.starts_with("https://jsonfeed.org/version/") { | |
| 288 | + | return Err(format!("Not a valid JSON Feed: version = {:?}", version).into()); | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | let mut feed = Map::new(); | |
| 292 | + | ||
| 293 | + | if let Some(title) = obj.get("title").and_then(|v| v.as_str()) { | |
| 294 | + | feed.insert("title".into(), title.to_string().into()); | |
| 295 | + | } | |
| 296 | + | if let Some(link) = obj.get("home_page_url").and_then(|v| v.as_str()) { | |
| 297 | + | feed.insert("link".into(), link.to_string().into()); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | let mut entries: rhai::Array = Vec::new(); | |
| 301 | + | ||
| 302 | + | if let Some(items) = obj.get("items").and_then(|v| v.as_array()) { | |
| 303 | + | for item in items { | |
| 304 | + | if let Some(item_obj) = item.as_object() { | |
| 305 | + | let mut entry = Map::new(); | |
| 306 | + | ||
| 307 | + | if let Some(id) = item_obj.get("id").and_then(|v| v.as_str()) { | |
| 308 | + | entry.insert("id".into(), id.to_string().into()); | |
| 309 | + | } | |
| 310 | + | if let Some(title) = item_obj.get("title").and_then(|v| v.as_str()) { | |
| 311 | + | entry.insert("title".into(), title.to_string().into()); | |
| 312 | + | } | |
| 313 | + | if let Some(url) = item_obj.get("url").and_then(|v| v.as_str()) { | |
| 314 | + | entry.insert("link".into(), url.to_string().into()); | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // Prefer content_html over content_text for body/summary | |
| 318 | + | let body = item_obj | |
| 319 | + | .get("content_html") | |
| 320 | + | .and_then(|v| v.as_str()) | |
| 321 | + | .or_else(|| item_obj.get("content_text").and_then(|v| v.as_str())) | |
| 322 | + | .or_else(|| item_obj.get("summary").and_then(|v| v.as_str())); | |
| 323 | + | if let Some(body) = body { | |
| 324 | + | entry.insert("summary".into(), body.to_string().into()); | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | // Published date | |
| 328 | + | if let Some(date_str) = item_obj | |
| 329 | + | .get("date_published") | |
| 330 | + | .and_then(|v| v.as_str()) | |
| 331 | + | { | |
| 332 | + | if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(date_str) { | |
| 333 | + | entry.insert("published".into(), dt.timestamp().into()); | |
| 334 | + | } | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // Author: JSON Feed 1.1 uses "authors" array, 1.0 uses "author" object | |
| 338 | + | let author_name = item_obj | |
| 339 | + | .get("authors") | |
| 340 | + | .and_then(|v| v.as_array()) | |
| 341 | + | .and_then(|arr| arr.first()) | |
| 342 | + | .and_then(|a| a.get("name")) | |
| 343 | + | .and_then(|v| v.as_str()) | |
| 344 | + | .or_else(|| { | |
| 345 | + | item_obj | |
| 346 | + | .get("author") | |
| 347 | + | .and_then(|v| v.get("name")) | |
| 348 | + | .and_then(|v| v.as_str()) | |
| 349 | + | }); | |
| 350 | + | if let Some(name) = author_name { | |
| 351 | + | entry.insert("author".into(), name.to_string().into()); | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | // Tags | |
| 355 | + | if let Some(tags) = item_obj.get("tags").and_then(|v| v.as_array()) { | |
| 356 | + | let tags_arr: rhai::Array = tags | |
| 357 | + | .iter() | |
| 358 | + | .filter_map(|t| t.as_str().map(|s| Dynamic::from(s.to_string()))) | |
| 359 | + | .collect(); | |
| 360 | + | if !tags_arr.is_empty() { | |
| 361 | + | entry.insert("tags".into(), tags_arr.into()); | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | entries.push(entry.into()); | |
| 366 | + | } | |
| 367 | + | } | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | feed.insert("entries".into(), entries.into()); | |
| 371 | + | Ok(feed.into()) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | /// Convert BusserConfig to Rhai Dynamic map. | |
| 375 | + | pub(super) fn busser_config_to_dynamic(config: &BusserConfig) -> Dynamic { | |
| 376 | + | let mut map = Map::new(); | |
| 377 | + | ||
| 378 | + | for (k, v) in &config.options { | |
| 379 | + | map.insert(k.clone().into(), v.clone().into()); | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | let feeds: rhai::Array = config.feeds.iter().map(|f| f.clone().into()).collect(); | |
| 383 | + | map.insert("feeds".into(), feeds.into()); | |
| 384 | + | ||
| 385 | + | if let Some(first_feed) = config.feeds.first() { | |
| 386 | + | map.insert("feed_url".into(), first_feed.clone().into()); | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | map.into() | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | /// Convert Dynamic to ConfigSchema. | |
| 393 | + | pub(super) fn dynamic_to_config_schema(val: Dynamic) -> Result<ConfigSchema, RhaiPluginError> { | |
| 394 | + | let map = val.try_cast::<Map>().ok_or_else(|| { | |
| 395 | + | RhaiPluginError::InvalidReturnType( | |
| 396 | + | "config_schema".into(), | |
| 397 | + | "expected object/map".into(), | |
| 398 | + | ) | |
| 399 | + | })?; | |
| 400 | + | ||
| 401 | + | let description = map | |
| 402 | + | .get("description") | |
| 403 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 404 | + | .unwrap_or_default(); | |
| 405 | + | ||
| 406 | + | let mut schema = ConfigSchema::new(description); | |
| 407 | + | ||
| 408 | + | if let Some(fields_val) = map.get("fields") { | |
| 409 | + | if let Some(fields) = fields_val.clone().try_cast::<rhai::Array>() { | |
| 410 | + | for field_val in fields { | |
| 411 | + | if let Some(field_map) = field_val.try_cast::<Map>() { | |
| 412 | + | let key = field_map | |
| 413 | + | .get("key") | |
| 414 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 415 | + | .unwrap_or_default(); | |
| 416 | + | ||
| 417 | + | let label = field_map | |
| 418 | + | .get("label") | |
| 419 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 420 | + | .unwrap_or_default(); | |
| 421 | + | ||
| 422 | + | let field_type_str = field_map | |
| 423 | + | .get("field_type") | |
| 424 | + | .and_then(|v| v.clone().try_cast::<String>()) | |
| 425 | + | .unwrap_or_else(|| "text".to_string()); | |
| 426 | + | ||
| 427 | + | let field_type = match field_type_str.to_lowercase().as_str() { | |
| 428 | + | "url" => ConfigFieldType::Url, | |
| 429 | + | "secret" => ConfigFieldType::Secret, | |
| 430 | + | "textarea" => ConfigFieldType::TextArea, | |
| 431 | + | "number" => ConfigFieldType::Number, | |
| 432 | + | "toggle" => ConfigFieldType::Toggle, | |
| 433 | + | "select" => ConfigFieldType::Select, | |
| 434 | + | _ => ConfigFieldType::Text, | |
| 435 | + | }; | |
| 436 | + | ||
| 437 | + | let required = field_map | |
| 438 | + | .get("required") | |
| 439 | + | .and_then(|v| v.clone().try_cast::<bool>()) | |
| 440 | + | .unwrap_or(false); | |
| 441 | + | ||
| 442 | + | // Try both "default" and "default_value" since "default" is reserved in Rhai | |
| 443 | + | let default_val = field_map | |
| 444 | + | .get("default_value") | |
| 445 | + | .or_else(|| field_map.get("default")) | |
| 446 | + | .and_then(|v| v.clone().try_cast::<String>()); | |
| 447 | + | ||
| 448 | + | let mut field = ConfigField { | |
| 449 | + | key, | |
| 450 | + | label, | |
| 451 | + | field_type, | |
| 452 | + | required, | |
| 453 | + | description: field_map | |
| 454 | + | .get("description") | |
| 455 | + | .and_then(|v| v.clone().try_cast::<String>()), | |
| 456 | + | default: default_val, | |
| 457 | + | placeholder: field_map | |
| 458 | + | .get("placeholder") | |
| 459 | + | .and_then(|v| v.clone().try_cast::<String>()), | |
| 460 | + | options: Vec::new(), | |
| 461 | + | }; | |
| 462 | + | ||
| 463 | + | if let Some(opts_val) = field_map.get("options") { | |
| 464 | + | if let Some(opts) = opts_val.clone().try_cast::<rhai::Array>() { | |
| 465 | + | field.options = opts | |
| 466 | + | .into_iter() | |
| 467 | + | .filter_map(|v| v.try_cast::<String>()) | |
| 468 | + | .collect(); | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | schema.fields.push(field); | |
| 473 | + | } | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | Ok(schema) | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | /// Convert Dynamic to BusserCapabilities. | |
| 482 | + | pub(super) fn dynamic_to_capabilities(val: Dynamic) -> BusserCapabilities { | |
| 483 | + | let map = match val.try_cast::<Map>() { | |
| 484 | + | Some(m) => m, | |
| 485 | + | None => return BusserCapabilities::default(), | |
| 486 | + | }; | |
| 487 | + | ||
| 488 | + | let defaults = BusserCapabilities::default(); | |
| 489 | + | ||
| 490 | + | BusserCapabilities { | |
| 491 | + | supports_pagination: map | |
| 492 | + | .get("supports_pagination") | |
| 493 | + | .and_then(|v| v.clone().try_cast::<bool>()) | |
| 494 | + | .unwrap_or(false), | |
| 495 | + | supports_search: map | |
| 496 | + | .get("supports_search") | |
| 497 | + | .and_then(|v| v.clone().try_cast::<bool>()) | |
| 498 | + | .unwrap_or(false), | |
| 499 | + | supports_date_filter: map | |
| 500 | + | .get("supports_date_filter") |
Lines truncated
| @@ -0,0 +1,311 @@ | |||
| 1 | + | //! Host functions registered into the Rhai engine for plugin scripts. | |
| 2 | + | ||
| 3 | + | use rhai::{Dynamic, Engine}; | |
| 4 | + | ||
| 5 | + | use super::conversions::{json_to_dynamic, parse_feed_xml, parse_json_feed, parse_xml_to_dynamic}; | |
| 6 | + | use crate::url_cleaner; | |
| 7 | + | ||
| 8 | + | /// Register host functions available to Rhai scripts. | |
| 9 | + | /// | |
| 10 | + | /// # Trust model | |
| 11 | + | /// | |
| 12 | + | /// `http_get` and `http_get_json` allow plugins to fetch any URL without | |
| 13 | + | /// restriction. This is acceptable because plugins are local `.rhai` files | |
| 14 | + | /// that the user explicitly installs into their plugins directory — they are | |
| 15 | + | /// not downloaded or executed automatically. Users should only install plugins | |
| 16 | + | /// they trust, similar to shell scripts. If BB ever supports remote/untrusted | |
| 17 | + | /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions) | |
| 18 | + | /// must be added before that feature ships. | |
| 19 | + | pub(super) fn register_host_functions(engine: &mut Engine) { | |
| 20 | + | // HTTP GET returning string (see trust model above) | |
| 21 | + | engine.register_fn("http_get", |url: &str| -> Result<String, Box<rhai::EvalAltResult>> { | |
| 22 | + | ureq::get(url) | |
| 23 | + | .call() | |
| 24 | + | .map_err(|e| format!("HTTP request failed: {}", e))? | |
| 25 | + | .into_string() | |
| 26 | + | .map_err(|e| format!("Failed to read response: {}", e).into()) | |
| 27 | + | }); | |
| 28 | + | ||
| 29 | + | // HTTP GET returning parsed JSON as Dynamic | |
| 30 | + | engine.register_fn( | |
| 31 | + | "http_get_json", | |
| 32 | + | |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 33 | + | let response = ureq::get(url) | |
| 34 | + | .call() | |
| 35 | + | .map_err(|e| format!("HTTP request failed: {}", e))?; | |
| 36 | + | ||
| 37 | + | let json: serde_json::Value = response | |
| 38 | + | .into_json() | |
| 39 | + | .map_err(|e| format!("JSON parse failed: {}", e))?; | |
| 40 | + | ||
| 41 | + | json_to_dynamic(json) | |
| 42 | + | }, | |
| 43 | + | ); | |
| 44 | + | ||
| 45 | + | // Parse JSON string to Dynamic | |
| 46 | + | engine.register_fn( | |
| 47 | + | "parse_json", | |
| 48 | + | |json_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 49 | + | let json: serde_json::Value = | |
| 50 | + | serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e))?; | |
| 51 | + | json_to_dynamic(json) | |
| 52 | + | }, | |
| 53 | + | ); | |
| 54 | + | ||
| 55 | + | // Parse XML string to a nested Dynamic map. "Simplified structure" means | |
| 56 | + | // each XML element becomes a map with "name", "attrs", "text", and "children" | |
| 57 | + | // keys — not a full DOM, but enough for plugins to walk RSS/Atom structures. | |
| 58 | + | engine.register_fn( | |
| 59 | + | "parse_xml", | |
| 60 | + | |xml_str: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 61 | + | parse_xml_to_dynamic(xml_str) | |
| 62 | + | }, | |
| 63 | + | ); | |
| 64 | + | ||
| 65 | + | // Parse an RSS/Atom/JSON Feed into a structured Dynamic with "title", | |
| 66 | + | // "entries", etc. Auto-detects JSON Feed (input starts with `{`) vs XML. | |
| 67 | + | // Extracts standard feed fields (title, link, published, author, body) | |
| 68 | + | // so plugins don't have to manually walk the tree. | |
| 69 | + | engine.register_fn( | |
| 70 | + | "parse_feed", | |
| 71 | + | |input: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 72 | + | if input.trim_start().starts_with('{') { | |
| 73 | + | parse_json_feed(input) | |
| 74 | + | } else { | |
| 75 | + | parse_feed_xml(input) | |
| 76 | + | } | |
| 77 | + | }, | |
| 78 | + | ); | |
| 79 | + | ||
| 80 | + | // Current UTC timestamp as Unix epoch seconds (i64). | |
| 81 | + | engine.register_fn("timestamp_now", || -> i64 { chrono::Utc::now().timestamp() }); | |
| 82 | + | ||
| 83 | + | // Strip HTML tags and render to plain text (80-char line width). | |
| 84 | + | engine.register_fn("html_to_text", |html: &str| -> String { | |
| 85 | + | html2text::from_read(html.as_bytes(), 80) | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | // Truncate text with ellipsis | |
| 89 | + | engine.register_fn("truncate", |text: &str, max_len: i64| -> String { | |
| 90 | + | let max = max_len as usize; | |
| 91 | + | if text.len() <= max { | |
| 92 | + | text.to_string() | |
| 93 | + | } else if max <= 3 { | |
| 94 | + | text.chars().take(max).collect() | |
| 95 | + | } else { | |
| 96 | + | let truncated: String = text.chars().take(max - 3).collect(); | |
| 97 | + | format!("{}...", truncated) | |
| 98 | + | } | |
| 99 | + | }); | |
| 100 | + | ||
| 101 | + | // String contains check | |
| 102 | + | engine.register_fn("str_contains", |text: &str, pattern: &str| -> bool { | |
| 103 | + | text.contains(pattern) | |
| 104 | + | }); | |
| 105 | + | ||
| 106 | + | // String split — returns a Rhai Array of strings (not an iterator). | |
| 107 | + | engine.register_fn("str_split", |text: &str, sep: &str| -> rhai::Array { | |
| 108 | + | text.split(sep).map(|s| Dynamic::from(s.to_string())).collect() | |
| 109 | + | }); | |
| 110 | + | ||
| 111 | + | // String replace | |
| 112 | + | engine.register_fn("str_replace", |text: &str, from: &str, to: &str| -> String { | |
| 113 | + | text.replace(from, to) | |
| 114 | + | }); | |
| 115 | + | ||
| 116 | + | // String trim | |
| 117 | + | engine.register_fn("str_trim", |text: &str| -> String { text.trim().to_string() }); | |
| 118 | + | ||
| 119 | + | // Parse ISO 8601 date to timestamp | |
| 120 | + | engine.register_fn( | |
| 121 | + | "parse_datetime", | |
| 122 | + | |date_str: &str| -> Result<i64, Box<rhai::EvalAltResult>> { | |
| 123 | + | chrono::DateTime::parse_from_rfc3339(date_str) | |
| 124 | + | .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str)) | |
| 125 | + | .map(|dt| dt.timestamp()) | |
| 126 | + | .map_err(|e| format!("Date parse error: {}", e).into()) | |
| 127 | + | }, | |
| 128 | + | ); | |
| 129 | + | ||
| 130 | + | // Debug print — outputs to tracing at debug level, visible in dev console. | |
| 131 | + | engine.register_fn("debug_print", |val: Dynamic| { | |
| 132 | + | tracing::debug!("Rhai debug: {:?}", val); | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | // Strip known tracking query parameters (utm_*, fbclid, gclid, etc.) from a URL. | |
| 136 | + | engine.register_fn("strip_tracking", |url: &str| -> String { | |
| 137 | + | url_cleaner::strip_tracking_params(url) | |
| 138 | + | }); | |
| 139 | + | ||
| 140 | + | // Parse string to integer. Returns Dynamic::UNIT (Rhai's nil) on failure | |
| 141 | + | // instead of Option<i64> because Rhai doesn't natively handle Rust Options. | |
| 142 | + | engine.register_fn("parse_int", |text: &str| -> Dynamic { | |
| 143 | + | match text.parse::<i64>() { | |
| 144 | + | Ok(n) => n.into(), | |
| 145 | + | Err(_) => Dynamic::UNIT, | |
| 146 | + | } | |
| 147 | + | }); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[cfg(test)] | |
| 151 | + | mod tests { | |
| 152 | + | use rhai::Dynamic; | |
| 153 | + | ||
| 154 | + | /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing). | |
| 155 | + | fn truncate_text(text: &str, max: usize) -> String { | |
| 156 | + | if text.len() <= max { | |
| 157 | + | text.to_string() | |
| 158 | + | } else if max <= 3 { | |
| 159 | + | text.chars().take(max).collect() | |
| 160 | + | } else { | |
| 161 | + | let truncated: String = text.chars().take(max - 3).collect(); | |
| 162 | + | format!("{}...", truncated) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// Parse string to integer (mirrors the Rhai-registered closure for testing). | |
| 167 | + | fn parse_int(text: &str) -> Dynamic { | |
| 168 | + | match text.parse::<i64>() { | |
| 169 | + | Ok(n) => n.into(), | |
| 170 | + | Err(_) => Dynamic::UNIT, | |
| 171 | + | } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | /// Parse ISO 8601 / RFC 2822 date to Unix timestamp (mirrors the Rhai closure). | |
| 175 | + | fn parse_datetime(date_str: &str) -> Result<i64, String> { | |
| 176 | + | chrono::DateTime::parse_from_rfc3339(date_str) | |
| 177 | + | .or_else(|_| chrono::DateTime::parse_from_rfc2822(date_str)) | |
| 178 | + | .map(|dt| dt.timestamp()) | |
| 179 | + | .map_err(|e| format!("Date parse error: {}", e)) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | /// Strip HTML tags and render to plain text (mirrors the Rhai closure). | |
| 183 | + | fn html_to_text(html: &str) -> String { | |
| 184 | + | html2text::from_read(html.as_bytes(), 80) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// String contains check (mirrors the Rhai closure). | |
| 188 | + | fn str_contains(text: &str, pattern: &str) -> bool { | |
| 189 | + | text.contains(pattern) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | /// String split (mirrors the Rhai closure, returns Vec<String> for easier testing). | |
| 193 | + | fn str_split(text: &str, sep: &str) -> Vec<String> { | |
| 194 | + | text.split(sep).map(|s| s.to_string()).collect() | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | /// Current UTC timestamp as Unix epoch seconds (mirrors the Rhai closure). | |
| 198 | + | fn timestamp_now() -> i64 { | |
| 199 | + | chrono::Utc::now().timestamp() | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn truncate_shorter_than_max() { | |
| 204 | + | assert_eq!(truncate_text("hello", 10), "hello"); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | #[test] | |
| 208 | + | fn truncate_exact_length() { | |
| 209 | + | assert_eq!(truncate_text("hello", 5), "hello"); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn truncate_longer_than_max() { | |
| 214 | + | assert_eq!(truncate_text("hello world", 8), "hello..."); | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | #[test] | |
| 218 | + | fn truncate_max_le_3() { | |
| 219 | + | assert_eq!(truncate_text("hello", 2), "he"); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | // ── parse_int tests ───────────────────────────────────────── | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn parse_int_valid() { | |
| 226 | + | let result = parse_int("42"); | |
| 227 | + | assert_eq!(result.as_int().unwrap(), 42); | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | #[test] | |
| 231 | + | fn parse_int_invalid() { | |
| 232 | + | let result = parse_int("abc"); | |
| 233 | + | assert!(result.is_unit()); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn parse_int_negative() { | |
| 238 | + | let result = parse_int("-5"); | |
| 239 | + | assert_eq!(result.as_int().unwrap(), -5); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // ── parse_datetime tests ──────────────────────────────────── | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn parse_datetime_iso8601() { | |
| 246 | + | let result = parse_datetime("2026-01-15T12:00:00Z").unwrap(); | |
| 247 | + | // 2026-01-15T12:00:00Z as a Unix timestamp | |
| 248 | + | let expected = chrono::DateTime::parse_from_rfc3339("2026-01-15T12:00:00Z") | |
| 249 | + | .unwrap() | |
| 250 | + | .timestamp(); | |
| 251 | + | assert_eq!(result, expected); | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | #[test] | |
| 255 | + | fn parse_datetime_invalid() { | |
| 256 | + | let result = parse_datetime("not-a-date"); | |
| 257 | + | assert!(result.is_err()); | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // ── html_to_text tests ────────────────────────────────────── | |
| 261 | + | ||
| 262 | + | #[test] | |
| 263 | + | fn html_to_text_basic() { | |
| 264 | + | let result = html_to_text("<p>Hello</p>"); | |
| 265 | + | assert!(result.contains("Hello")); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[test] | |
| 269 | + | fn html_to_text_nested() { | |
| 270 | + | let result = html_to_text("<div><p>First</p><p>Second</p></div>"); | |
| 271 | + | assert!(result.contains("First")); | |
| 272 | + | assert!(result.contains("Second")); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // ── str_contains tests ────────────────────────────────────── | |
| 276 | + | ||
| 277 | + | #[test] | |
| 278 | + | fn str_contains_found() { | |
| 279 | + | assert!(str_contains("hello world", "world")); | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | #[test] | |
| 283 | + | fn str_contains_not_found() { | |
| 284 | + | assert!(!str_contains("hello world", "xyz")); | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // ── str_split tests ───────────────────────────────────────── | |
| 288 | + | ||
| 289 | + | #[test] | |
| 290 | + | fn str_split_basic() { | |
| 291 | + | let result = str_split("a,b,c", ","); | |
| 292 | + | assert_eq!(result, vec!["a", "b", "c"]); | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | #[test] | |
| 296 | + | fn str_split_no_match() { | |
| 297 | + | let result = str_split("abc", ","); | |
| 298 | + | assert_eq!(result, vec!["abc"]); | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // ── timestamp_now tests ───────────────────────────────────── | |
| 302 | + | ||
| 303 | + | #[test] | |
| 304 | + | fn timestamp_now_reasonable() { | |
| 305 | + | let before = chrono::Utc::now().timestamp(); | |
| 306 | + | let result = timestamp_now(); | |
| 307 | + | let after = chrono::Utc::now().timestamp(); | |
| 308 | + | assert!(result >= before); | |
| 309 | + | assert!(result <= after); | |
| 310 | + | } | |
| 311 | + | } |
| @@ -0,0 +1,248 @@ | |||
| 1 | + | //! Rhai plugin runtime for BalancedBreakfast. | |
| 2 | + | //! | |
| 3 | + | //! This module provides the Rhai scripting engine integration for plugins. | |
| 4 | + | //! Plugins are simple .rhai text files that implement the busser interface. | |
| 5 | + | //! | |
| 6 | + | //! Submodules: | |
| 7 | + | //! - [`conversions`]: Rhai ↔ Rust type conversions, XML/JSON parsing | |
| 8 | + | //! - [`host_functions`]: Functions registered into the Rhai engine for scripts | |
| 9 | + | ||
| 10 | + | mod conversions; | |
| 11 | + | mod host_functions; | |
| 12 | + | ||
| 13 | + | use std::collections::HashMap; | |
| 14 | + | use std::path::Path; | |
| 15 | + | use std::sync::Arc; | |
| 16 | + | ||
| 17 | + | use bb_interface::{BusserCapabilities, ConfigSchema}; | |
| 18 | + | use rhai::{Dynamic, Engine, Scope, AST}; | |
| 19 | + | use thiserror::Error; | |
| 20 | + | use tracing::debug; | |
| 21 | + | ||
| 22 | + | use conversions::{ | |
| 23 | + | busser_config_to_dynamic, dynamic_to_capabilities, dynamic_to_config_schema, | |
| 24 | + | dynamic_to_fetch_result, | |
| 25 | + | }; | |
| 26 | + | use host_functions::register_host_functions; | |
| 27 | + | ||
| 28 | + | #[derive(Error, Debug)] | |
| 29 | + | pub enum RhaiPluginError { | |
| 30 | + | #[error("Script compilation failed: {0}")] | |
| 31 | + | CompileError(String), | |
| 32 | + | #[error("Script execution failed: {0}")] | |
| 33 | + | RuntimeError(String), | |
| 34 | + | #[error("Missing required function: {0}")] | |
| 35 | + | MissingFunction(String), | |
| 36 | + | #[error("Invalid return type from function {0}: {1}")] | |
| 37 | + | InvalidReturnType(String, String), | |
| 38 | + | #[error("HTTP request failed: {0}")] | |
| 39 | + | HttpError(String), | |
| 40 | + | #[error("XML parse error: {0}")] | |
| 41 | + | XmlError(String), | |
| 42 | + | #[error("JSON parse error: {0}")] | |
| 43 | + | JsonError(String), | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// A compiled Rhai plugin. | |
| 47 | + | pub struct RhaiPlugin { | |
| 48 | + | pub id: String, | |
| 49 | + | pub name: String, | |
| 50 | + | pub path: std::path::PathBuf, | |
| 51 | + | ast: AST, | |
| 52 | + | engine: Arc<Engine>, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | impl RhaiPlugin { | |
| 56 | + | /// Get the plugin's configuration schema. | |
| 57 | + | pub fn config_schema(&self) -> Result<ConfigSchema, RhaiPluginError> { | |
| 58 | + | let mut scope = Scope::new(); | |
| 59 | + | let result: Dynamic = self | |
| 60 | + | .engine | |
| 61 | + | .call_fn(&mut scope, &self.ast, "config_schema", ()) | |
| 62 | + | .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?; | |
| 63 | + | ||
| 64 | + | dynamic_to_config_schema(result) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Get the plugin's capabilities. | |
| 68 | + | pub fn capabilities(&self) -> BusserCapabilities { | |
| 69 | + | let mut scope = Scope::new(); | |
| 70 | + | match self | |
| 71 | + | .engine | |
| 72 | + | .call_fn::<Dynamic>(&mut scope, &self.ast, "capabilities", ()) | |
| 73 | + | { | |
| 74 | + | Ok(result) => dynamic_to_capabilities(result), | |
| 75 | + | Err(e) => { | |
| 76 | + | tracing::warn!("Plugin {} capabilities() failed: {}", self.id, e); | |
| 77 | + | BusserCapabilities::default() | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Fetch items from the plugin. | |
| 83 | + | pub fn fetch( | |
| 84 | + | &self, | |
| 85 | + | config: &bb_interface::BusserConfig, | |
| 86 | + | cursor: Option<String>, | |
| 87 | + | ) -> Result<bb_interface::FetchResult, RhaiPluginError> { | |
| 88 | + | let mut scope = Scope::new(); | |
| 89 | + | ||
| 90 | + | let config_map = busser_config_to_dynamic(config); | |
| 91 | + | ||
| 92 | + | let cursor_val: Dynamic = match cursor { | |
| 93 | + | Some(c) => c.into(), | |
| 94 | + | None => Dynamic::UNIT, | |
| 95 | + | }; | |
| 96 | + | ||
| 97 | + | let result: Dynamic = self | |
| 98 | + | .engine | |
| 99 | + | .call_fn(&mut scope, &self.ast, "fetch", (config_map, cursor_val)) | |
| 100 | + | .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?; | |
| 101 | + | ||
| 102 | + | dynamic_to_fetch_result(result, &self.id) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Manager for Rhai plugins. | |
| 107 | + | pub struct RhaiPluginManager { | |
| 108 | + | engine: Arc<Engine>, | |
| 109 | + | plugins: HashMap<String, RhaiPlugin>, | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | impl RhaiPluginManager { | |
| 113 | + | /// Create a new plugin manager with the Rhai engine configured. | |
| 114 | + | /// | |
| 115 | + | /// Safety limits prevent malicious or buggy scripts from hanging the app: | |
| 116 | + | /// - `max_operations(100_000)`: caps total operations per script execution. | |
| 117 | + | /// A typical RSS fetch costs 1k–5k ops; 100k allows complex plugins while | |
| 118 | + | /// catching infinite loops. | |
| 119 | + | /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions | |
| 120 | + | /// and functions, preventing stack overflows from deeply recursive scripts. | |
| 121 | + | pub fn new() -> Self { | |
| 122 | + | let mut engine = Engine::new(); | |
| 123 | + | ||
| 124 | + | engine.set_max_expr_depths(128, 128); | |
| 125 | + | engine.set_max_operations(100_000); | |
| 126 | + | ||
| 127 | + | register_host_functions(&mut engine); | |
| 128 | + | ||
| 129 | + | Self { | |
| 130 | + | engine: Arc::new(engine), | |
| 131 | + | plugins: HashMap::new(), | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// Load a plugin from a .rhai file. | |
| 136 | + | pub fn load_plugin(&mut self, path: &Path) -> Result<String, RhaiPluginError> { | |
| 137 | + | let script = std::fs::read_to_string(path) | |
| 138 | + | .map_err(|e| RhaiPluginError::CompileError(format!("Failed to read file: {}", e)))?; | |
| 139 | + | ||
| 140 | + | let ast = self | |
| 141 | + | .engine | |
| 142 | + | .compile(&script) | |
| 143 | + | .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?; | |
| 144 | + | ||
| 145 | + | let mut scope = Scope::new(); | |
| 146 | + | ||
| 147 | + | let id: String = self | |
| 148 | + | .engine | |
| 149 | + | .call_fn(&mut scope, &ast, "id", ()) | |
| 150 | + | .map_err(|e| RhaiPluginError::MissingFunction(format!("id(): {}", e)))?; | |
| 151 | + | ||
| 152 | + | let name: String = self | |
| 153 | + | .engine | |
| 154 | + | .call_fn(&mut scope, &ast, "name", ()) | |
| 155 | + | .map_err(|e| RhaiPluginError::MissingFunction(format!("name(): {}", e)))?; | |
| 156 | + | ||
| 157 | + | let _: Dynamic = self | |
| 158 | + | .engine | |
| 159 | + | .call_fn(&mut scope, &ast, "config_schema", ()) | |
| 160 | + | .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?; | |
| 161 | + | ||
| 162 | + | debug!("Loaded Rhai plugin: {} ({})", name, id); | |
| 163 | + | ||
| 164 | + | let plugin = RhaiPlugin { | |
| 165 | + | id: id.clone(), | |
| 166 | + | name, | |
| 167 | + | path: path.to_path_buf(), | |
| 168 | + | ast, | |
| 169 | + | engine: self.engine.clone(), | |
| 170 | + | }; | |
| 171 | + | ||
| 172 | + | self.plugins.insert(id.clone(), plugin); | |
| 173 | + | Ok(id) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// Get a loaded plugin by ID. | |
| 177 | + | pub fn get(&self, id: &str) -> Option<&RhaiPlugin> { | |
| 178 | + | self.plugins.get(id) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// List all loaded plugin IDs. | |
| 182 | + | pub fn list(&self) -> Vec<String> { | |
| 183 | + | self.plugins.keys().cloned().collect() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// Get plugin info (id, name, path). | |
| 187 | + | pub fn get_info(&self, id: &str) -> Option<(String, String, std::path::PathBuf)> { | |
| 188 | + | self.plugins | |
| 189 | + | .get(id) | |
| 190 | + | .map(|p| (p.id.clone(), p.name.clone(), p.path.clone())) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | impl Default for RhaiPluginManager { | |
| 195 | + | fn default() -> Self { | |
| 196 | + | Self::new() | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | #[cfg(test)] | |
| 201 | + | mod tests { | |
| 202 | + | use super::*; | |
| 203 | + | ||
| 204 | + | #[test] | |
| 205 | + | fn error_display_compile() { | |
| 206 | + | let e = RhaiPluginError::CompileError("syntax error".into()); | |
| 207 | + | assert_eq!(e.to_string(), "Script compilation failed: syntax error"); | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | #[test] | |
| 211 | + | fn error_display_runtime() { | |
| 212 | + | let e = RhaiPluginError::RuntimeError("nil access".into()); | |
| 213 | + | assert_eq!(e.to_string(), "Script execution failed: nil access"); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn error_display_missing_fn() { | |
| 218 | + | let e = RhaiPluginError::MissingFunction("fetch".into()); | |
| 219 | + | assert_eq!(e.to_string(), "Missing required function: fetch"); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[test] | |
| 223 | + | fn error_display_invalid_return() { | |
| 224 | + | let e = RhaiPluginError::InvalidReturnType("config".into(), "expected map".into()); | |
| 225 | + | assert_eq!( | |
| 226 | + | e.to_string(), | |
| 227 | + | "Invalid return type from function config: expected map" | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | #[test] | |
| 232 | + | fn error_display_http() { | |
| 233 | + | let e = RhaiPluginError::HttpError("timeout".into()); | |
| 234 | + | assert_eq!(e.to_string(), "HTTP request failed: timeout"); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | #[test] | |
| 238 | + | fn error_display_xml() { | |
| 239 | + | let e = RhaiPluginError::XmlError("unclosed tag".into()); | |
| 240 | + | assert_eq!(e.to_string(), "XML parse error: unclosed tag"); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | #[test] | |
| 244 | + | fn error_display_json() { | |
| 245 | + | let e = RhaiPluginError::JsonError("unexpected token".into()); | |
| 246 | + | assert_eq!(e.to_string(), "JSON parse error: unexpected token"); | |
| 247 | + | } | |
| 248 | + | } |
| @@ -0,0 +1,180 @@ | |||
| 1 | + | //! URL tracker parameter stripping. | |
| 2 | + | //! | |
| 3 | + | //! Removes known tracking parameters (utm_*, fbclid, gclid, etc.) from URLs | |
| 4 | + | //! to improve privacy and reduce link clutter. Also provides an HTML rewriter | |
| 5 | + | //! that cleans URLs inside `href` and `src` attributes. | |
| 6 | + | ||
| 7 | + | use std::sync::LazyLock; | |
| 8 | + | ||
| 9 | + | use regex::Regex; | |
| 10 | + | use url::Url; | |
| 11 | + | ||
| 12 | + | /// Tracking parameter prefixes — any query parameter whose name starts with one | |
| 13 | + | /// of these (case-insensitive) is stripped. | |
| 14 | + | const TRACKING_PREFIXES: &[&str] = &["utm_"]; | |
| 15 | + | ||
| 16 | + | /// Tracking parameter exact names (case-insensitive). | |
| 17 | + | const TRACKING_PARAMS: &[&str] = &[ | |
| 18 | + | "fbclid", | |
| 19 | + | "gclid", | |
| 20 | + | "msclkid", | |
| 21 | + | "twclid", | |
| 22 | + | "dclid", | |
| 23 | + | "mc_cid", | |
| 24 | + | "mc_eid", | |
| 25 | + | "oly_anon_id", | |
| 26 | + | "oly_enc_id", | |
| 27 | + | "_openstat", | |
| 28 | + | "vero_id", | |
| 29 | + | "wickedid", | |
| 30 | + | "yclid", | |
| 31 | + | "zanpid", | |
| 32 | + | "_hsenc", | |
| 33 | + | "_hsmi", | |
| 34 | + | "hsa_cam", | |
| 35 | + | "hsa_grp", | |
| 36 | + | "hsa_mt", | |
| 37 | + | "hsa_src", | |
| 38 | + | "hsa_ad", | |
| 39 | + | "hsa_acc", | |
| 40 | + | "hsa_net", | |
| 41 | + | "hsa_ver", | |
| 42 | + | "hsa_la", | |
| 43 | + | "hsa_ol", | |
| 44 | + | "hsa_kw", | |
| 45 | + | "hsa_tgt", | |
| 46 | + | ]; | |
| 47 | + | ||
| 48 | + | /// Returns `true` if a query parameter name is a known tracker. | |
| 49 | + | fn is_tracking_param(name: &str) -> bool { | |
| 50 | + | let lower = name.to_ascii_lowercase(); | |
| 51 | + | if TRACKING_PARAMS.iter().any(|&p| lower == p) { | |
| 52 | + | return true; | |
| 53 | + | } | |
| 54 | + | TRACKING_PREFIXES | |
| 55 | + | .iter() | |
| 56 | + | .any(|&prefix| lower.starts_with(prefix)) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Strip known tracking query parameters from a URL string. | |
| 60 | + | /// | |
| 61 | + | /// Returns the cleaned URL. If the input is not a valid URL it is returned unchanged. | |
| 62 | + | pub fn strip_tracking_params(url_str: &str) -> String { | |
| 63 | + | let mut parsed = match Url::parse(url_str) { | |
| 64 | + | Ok(u) => u, | |
| 65 | + | Err(_) => return url_str.to_string(), | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | let clean_pairs: Vec<(String, String)> = parsed | |
| 69 | + | .query_pairs() | |
| 70 | + | .filter(|(name, _)| !is_tracking_param(name)) | |
| 71 | + | .map(|(k, v)| (k.into_owned(), v.into_owned())) | |
| 72 | + | .collect(); | |
| 73 | + | ||
| 74 | + | // Clear and rebuild query string | |
| 75 | + | if clean_pairs.is_empty() { | |
| 76 | + | parsed.set_query(None); | |
| 77 | + | } else { | |
| 78 | + | parsed | |
| 79 | + | .query_pairs_mut() | |
| 80 | + | .clear() | |
| 81 | + | .extend_pairs(clean_pairs); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | parsed.to_string() | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Regex matching `href="..."` and `src="..."` attribute values in HTML. | |
| 88 | + | static ATTR_URL_RE: LazyLock<Regex> = LazyLock::new(|| { | |
| 89 | + | Regex::new(r#"(href|src)\s*=\s*"([^"]+)""#).expect("invalid regex") | |
| 90 | + | }); | |
| 91 | + | ||
| 92 | + | /// Strip tracking parameters from all `href` and `src` URLs in an HTML string. | |
| 93 | + | pub fn strip_tracking_from_html(html: &str) -> String { | |
| 94 | + | ATTR_URL_RE | |
| 95 | + | .replace_all(html, |caps: ®ex::Captures| { | |
| 96 | + | let attr = &caps[1]; | |
| 97 | + | let url = &caps[2]; | |
| 98 | + | let cleaned = strip_tracking_params(url); | |
| 99 | + | format!("{}=\"{}\"", attr, cleaned) | |
| 100 | + | }) | |
| 101 | + | .into_owned() | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | #[cfg(test)] | |
| 105 | + | mod tests { | |
| 106 | + | use super::*; | |
| 107 | + | ||
| 108 | + | #[test] | |
| 109 | + | fn strips_utm_params() { | |
| 110 | + | let url = "https://example.com/page?utm_source=twitter&utm_medium=social&id=42"; | |
| 111 | + | let cleaned = strip_tracking_params(url); | |
| 112 | + | assert_eq!(cleaned, "https://example.com/page?id=42"); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[test] | |
| 116 | + | fn strips_fbclid() { | |
| 117 | + | let url = "https://example.com/?fbclid=abc123&page=1"; | |
| 118 | + | let cleaned = strip_tracking_params(url); | |
| 119 | + | assert_eq!(cleaned, "https://example.com/?page=1"); | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | #[test] | |
| 123 | + | fn strips_gclid() { | |
| 124 | + | let url = "https://example.com/?gclid=xyz&ref=home"; | |
| 125 | + | let cleaned = strip_tracking_params(url); | |
| 126 | + | assert_eq!(cleaned, "https://example.com/?ref=home"); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | #[test] | |
| 130 | + | fn preserves_clean_params() { | |
| 131 | + | let url = "https://example.com/search?q=rust&page=2"; | |
| 132 | + | let cleaned = strip_tracking_params(url); | |
| 133 | + | assert_eq!(cleaned, "https://example.com/search?q=rust&page=2"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[test] | |
| 137 | + | fn handles_no_query() { | |
| 138 | + | let url = "https://example.com/page"; | |
| 139 | + | let cleaned = strip_tracking_params(url); | |
| 140 | + | assert_eq!(cleaned, "https://example.com/page"); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | #[test] | |
| 144 | + | fn handles_all_tracking_removed() { | |
| 145 | + | let url = "https://example.com/?utm_source=x&fbclid=y"; | |
| 146 | + | let cleaned = strip_tracking_params(url); | |
| 147 | + | assert_eq!(cleaned, "https://example.com/"); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | #[test] | |
| 151 | + | fn handles_invalid_url() { | |
| 152 | + | let url = "not a url at all"; | |
| 153 | + | let cleaned = strip_tracking_params(url); | |
| 154 | + | assert_eq!(cleaned, "not a url at all"); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn strips_from_html_body() { | |
| 159 | + | let html = r#"<a href="https://example.com/?utm_source=rss&id=1">click</a> and <img src="https://img.example.com/pic.jpg?fbclid=abc">"#; | |
| 160 | + | let cleaned = strip_tracking_from_html(html); | |
| 161 | + | assert_eq!( | |
| 162 | + | cleaned, | |
| 163 | + | r#"<a href="https://example.com/?id=1">click</a> and <img src="https://img.example.com/pic.jpg">"# | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | #[test] | |
| 168 | + | fn html_preserves_clean_urls() { | |
| 169 | + | let html = r#"<a href="https://example.com/page">link</a>"#; | |
| 170 | + | let cleaned = strip_tracking_from_html(html); | |
| 171 | + | assert_eq!(cleaned, html); | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | #[test] | |
| 175 | + | fn case_insensitive_param_match() { | |
| 176 | + | let url = "https://example.com/?UTM_SOURCE=x&FBCLID=y&keep=z"; | |
| 177 | + | let cleaned = strip_tracking_params(url); | |
| 178 | + | assert_eq!(cleaned, "https://example.com/?keep=z"); | |
| 179 | + | } | |
| 180 | + | } |
| @@ -0,0 +1,16 @@ | |||
| 1 | + | [package] | |
| 2 | + | name = "bb-db" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | description = "Database layer for BalancedBreakfast" | |
| 6 | + | ||
| 7 | + | [dependencies] | |
| 8 | + | bb-interface.workspace = true | |
| 9 | + | sqlx.workspace = true | |
| 10 | + | tokio.workspace = true | |
| 11 | + | thiserror.workspace = true | |
| 12 | + | tracing.workspace = true | |
| 13 | + | chrono.workspace = true | |
| 14 | + | uuid.workspace = true | |
| 15 | + | serde.workspace = true | |
| 16 | + | serde_json.workspace = true |
| @@ -0,0 +1,257 @@ | |||
| 1 | + | //! Strongly-typed entity ID newtypes. | |
| 2 | + | //! | |
| 3 | + | //! These wrappers prevent accidental ID mixups (e.g. passing a FeedId where | |
| 4 | + | //! an ItemId is expected). They serialize transparently as UUID strings. | |
| 5 | + | ||
| 6 | + | /// Define a UUID-based entity ID newtype with all necessary trait impls. | |
| 7 | + | /// | |
| 8 | + | /// Generated traits: Clone, Copy, Debug, PartialEq, Eq, Hash, Display, | |
| 9 | + | /// FromStr, Serialize, Deserialize, sqlx Type/Encode/Decode (SQLite TEXT). | |
| 10 | + | macro_rules! define_uuid_id { | |
| 11 | + | ($($name:ident),+ $(,)?) => {$( | |
| 12 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] | |
| 13 | + | pub struct $name(uuid::Uuid); | |
| 14 | + | ||
| 15 | + | impl Default for $name { | |
| 16 | + | fn default() -> Self { | |
| 17 | + | Self::new() | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl $name { | |
| 22 | + | /// Generate a new random ID. | |
| 23 | + | pub fn new() -> Self { | |
| 24 | + | Self(uuid::Uuid::new_v4()) | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Wrap an existing UUID. | |
| 28 | + | pub fn from_uuid(uuid: uuid::Uuid) -> Self { | |
| 29 | + | Self(uuid) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /// Access the inner UUID. | |
| 33 | + | pub fn as_uuid(&self) -> &uuid::Uuid { | |
| 34 | + | &self.0 | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | impl std::fmt::Display for $name { | |
| 39 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 40 | + | self.0.fmt(f) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl std::str::FromStr for $name { | |
| 45 | + | type Err = uuid::Error; | |
| 46 | + | fn from_str(s: &str) -> Result<Self, Self::Err> { | |
| 47 | + | uuid::Uuid::parse_str(s).map(Self) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl serde::Serialize for $name { | |
| 52 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 53 | + | self.0.serialize(serializer) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | impl<'de> serde::Deserialize<'de> for $name { | |
| 58 | + | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 59 | + | uuid::Uuid::deserialize(deserializer).map(Self) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | impl sqlx::Type<sqlx::Sqlite> for $name { | |
| 64 | + | fn type_info() -> sqlx::sqlite::SqliteTypeInfo { | |
| 65 | + | <String as sqlx::Type<sqlx::Sqlite>>::type_info() | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool { | |
| 69 | + | <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty) | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for $name { | |
| 74 | + | fn encode_by_ref( | |
| 75 | + | &self, | |
| 76 | + | buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>, | |
| 77 | + | ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> { | |
| 78 | + | let s = self.0.to_string(); | |
| 79 | + | <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(&s, buf) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for $name { | |
| 84 | + | fn decode( | |
| 85 | + | value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>, | |
| 86 | + | ) -> Result<Self, sqlx::error::BoxDynError> { | |
| 87 | + | let s = <&str as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value)?; | |
| 88 | + | Ok(Self(uuid::Uuid::parse_str(s)?)) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | )+}; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | define_uuid_id!(FeedId, ItemId, BusserStateId); | |
| 95 | + | ||
| 96 | + | // ── BusserId ───────────────────────────────────────────────────── | |
| 97 | + | ||
| 98 | + | /// Plugin/busser identifier (e.g. "rss", "hn", "arxiv"). | |
| 99 | + | /// | |
| 100 | + | /// Unlike the UUID-based IDs above, BusserId wraps a plain string. | |
| 101 | + | /// Implements `Deref<Target = str>` for transparent string access. | |
| 102 | + | #[derive(Clone, Debug, PartialEq, Eq, Hash)] | |
| 103 | + | pub struct BusserId(String); | |
| 104 | + | ||
| 105 | + | impl BusserId { | |
| 106 | + | pub fn new(s: impl Into<String>) -> Self { | |
| 107 | + | Self(s.into()) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | pub fn as_str(&self) -> &str { | |
| 111 | + | &self.0 | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | pub fn into_inner(self) -> String { | |
| 115 | + | self.0 | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | impl std::ops::Deref for BusserId { | |
| 120 | + | type Target = str; | |
| 121 | + | fn deref(&self) -> &str { | |
| 122 | + | &self.0 | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | impl std::fmt::Display for BusserId { | |
| 127 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 128 | + | self.0.fmt(f) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | impl PartialEq<str> for BusserId { | |
| 133 | + | fn eq(&self, other: &str) -> bool { | |
| 134 | + | self.0 == other | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | impl PartialEq<&str> for BusserId { | |
| 139 | + | fn eq(&self, other: &&str) -> bool { | |
| 140 | + | self.0 == *other | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | impl From<String> for BusserId { | |
| 145 | + | fn from(s: String) -> Self { | |
| 146 | + | Self(s) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | impl From<&str> for BusserId { | |
| 151 | + | fn from(s: &str) -> Self { | |
| 152 | + | Self(s.to_string()) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | impl serde::Serialize for BusserId { | |
| 157 | + | fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { | |
| 158 | + | self.0.serialize(serializer) | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | impl<'de> serde::Deserialize<'de> for BusserId { | |
| 163 | + | fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { | |
| 164 | + | String::deserialize(deserializer).map(Self) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | impl sqlx::Type<sqlx::Sqlite> for BusserId { | |
| 169 | + | fn type_info() -> sqlx::sqlite::SqliteTypeInfo { | |
| 170 | + | <String as sqlx::Type<sqlx::Sqlite>>::type_info() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | fn compatible(ty: &sqlx::sqlite::SqliteTypeInfo) -> bool { | |
| 174 | + | <String as sqlx::Type<sqlx::Sqlite>>::compatible(ty) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | impl<'q> sqlx::Encode<'q, sqlx::Sqlite> for BusserId { | |
| 179 | + | fn encode_by_ref( | |
| 180 | + | &self, | |
| 181 | + | buf: &mut <sqlx::Sqlite as sqlx::Database>::ArgumentBuffer<'q>, | |
| 182 | + | ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> { | |
| 183 | + | <String as sqlx::Encode<'q, sqlx::Sqlite>>::encode_by_ref(&self.0, buf) | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | impl<'r> sqlx::Decode<'r, sqlx::Sqlite> for BusserId { | |
| 188 | + | fn decode( | |
| 189 | + | value: <sqlx::Sqlite as sqlx::Database>::ValueRef<'r>, | |
| 190 | + | ) -> Result<Self, sqlx::error::BoxDynError> { | |
| 191 | + | let s = <String as sqlx::Decode<'r, sqlx::Sqlite>>::decode(value)?; | |
| 192 | + | Ok(Self(s)) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[cfg(test)] | |
| 197 | + | mod tests { | |
| 198 | + | use super::*; | |
| 199 | + | ||
| 200 | + | #[test] | |
| 201 | + | fn feed_id_roundtrip() { | |
| 202 | + | let id = FeedId::new(); | |
| 203 | + | let s = id.to_string(); | |
| 204 | + | let parsed: FeedId = s.parse().unwrap(); | |
| 205 | + | assert_eq!(id, parsed); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | #[test] | |
| 209 | + | fn item_id_roundtrip() { | |
| 210 | + | let id = ItemId::new(); | |
| 211 | + | let s = id.to_string(); | |
| 212 | + | let parsed: ItemId = s.parse().unwrap(); | |
| 213 | + | assert_eq!(id, parsed); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn different_id_types_are_distinct() { | |
| 218 | + | assert_ne!( | |
| 219 | + | std::any::TypeId::of::<FeedId>(), | |
| 220 | + | std::any::TypeId::of::<ItemId>() | |
| 221 | + | ); | |
| 222 | + | assert_ne!( | |
| 223 | + | std::any::TypeId::of::<FeedId>(), | |
| 224 | + | std::any::TypeId::of::<BusserStateId>() | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | #[test] | |
| 229 | + | fn busser_id_deref_to_str() { | |
| 230 | + | let id = BusserId::new("rss"); | |
| 231 | + | assert_eq!(&*id, "rss"); | |
| 232 | + | assert_eq!(id.as_str(), "rss"); | |
| 233 | + | assert_eq!(id, "rss"); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[test] | |
| 237 | + | fn busser_id_display() { | |
| 238 | + | let id = BusserId::new("hn"); | |
| 239 | + | assert_eq!(format!("{}", id), "hn"); | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | #[test] | |
| 243 | + | fn uuid_id_serde_roundtrip() { | |
| 244 | + | let id = FeedId::new(); | |
| 245 | + | let json = serde_json::to_string(&id).unwrap(); | |
| 246 | + | let parsed: FeedId = serde_json::from_str(&json).unwrap(); | |
| 247 | + | assert_eq!(id, parsed); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn busser_id_serde_roundtrip() { | |
| 252 | + | let id = BusserId::new("rss"); | |
| 253 | + | let json = serde_json::to_string(&id).unwrap(); | |
| 254 | + | let parsed: BusserId = serde_json::from_str(&json).unwrap(); | |
| 255 | + | assert_eq!(id, parsed); | |
| 256 | + | } | |
| 257 | + | } |