Skip to main content

max / balanced_breakfast

Initial commit
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-05 18:51 UTC
Commit: 1ff74504cfda2a4f2403a0d3f58117090cdfd86f
138 files changed, +18539 insertions, -0 deletions
A .build.yml +31
@@ -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
A .gitignore +25
@@ -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
A Cargo.lock +500
@@ -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
A Cargo.toml +49
@@ -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" }
A LICENSE +118
@@ -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.
A README.md +294
@@ -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 + }
A docs/todo.md +127
A logo.svg +43
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
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file