max / balanced_breakfast
138 files changed,
+18539 insertions,
-0 deletions
| @@ -1,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 |
| @@ -1,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 |
| @@ -1,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/ |
Binary file
| @@ -1,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
| @@ -1,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" } |
| @@ -1,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. |
| @@ -1,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 |
Binary file
| @@ -1,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" |
| @@ -1,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 | + | } |
| @@ -1,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}; |
| @@ -1,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 | + | } |
| @@ -1,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 | + | } |
| @@ -1,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
| @@ -1,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 | + | } |
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file