max / balanced_breakfast
55 files changed,
+5251 insertions,
-1702 deletions
| @@ -217,6 +217,7 @@ dependencies = [ | |||
| 217 | 217 | "chrono", | |
| 218 | 218 | "html2text", | |
| 219 | 219 | "rand 0.8.5", | |
| 220 | + | "readable-readability", | |
| 220 | 221 | "regex", | |
| 221 | 222 | "rhai", | |
| 222 | 223 | "roxmltree", | |
| @@ -252,6 +253,7 @@ dependencies = [ | |||
| 252 | 253 | "bb-db", | |
| 253 | 254 | "bb-interface", | |
| 254 | 255 | "chrono", | |
| 256 | + | "regex", | |
| 255 | 257 | "serde_json", | |
| 256 | 258 | "sqlx", | |
| 257 | 259 | "thiserror 1.0.69", | |
| @@ -703,13 +705,30 @@ dependencies = [ | |||
| 703 | 705 | ||
| 704 | 706 | [[package]] | |
| 705 | 707 | name = "cssparser" | |
| 708 | + | version = "0.27.2" | |
| 709 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 710 | + | checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" | |
| 711 | + | dependencies = [ | |
| 712 | + | "cssparser-macros", | |
| 713 | + | "dtoa-short", | |
| 714 | + | "itoa 0.4.8", | |
| 715 | + | "matches", | |
| 716 | + | "phf 0.8.0", | |
| 717 | + | "proc-macro2", | |
| 718 | + | "quote", | |
| 719 | + | "smallvec", | |
| 720 | + | "syn 1.0.109", | |
| 721 | + | ] | |
| 722 | + | ||
| 723 | + | [[package]] | |
| 724 | + | name = "cssparser" | |
| 706 | 725 | version = "0.29.6" | |
| 707 | 726 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 708 | 727 | checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" | |
| 709 | 728 | dependencies = [ | |
| 710 | 729 | "cssparser-macros", | |
| 711 | 730 | "dtoa-short", | |
| 712 | - | "itoa", | |
| 731 | + | "itoa 1.0.17", | |
| 713 | 732 | "matches", | |
| 714 | 733 | "phf 0.10.1", | |
| 715 | 734 | "proc-macro2", | |
| @@ -1660,6 +1679,20 @@ dependencies = [ | |||
| 1660 | 1679 | ||
| 1661 | 1680 | [[package]] | |
| 1662 | 1681 | name = "html5ever" | |
| 1682 | + | version = "0.25.2" | |
| 1683 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1684 | + | checksum = "e5c13fb08e5d4dfc151ee5e88bae63f7773d61852f3bdc73c9f4b9e1bde03148" | |
| 1685 | + | dependencies = [ | |
| 1686 | + | "log", | |
| 1687 | + | "mac", | |
| 1688 | + | "markup5ever 0.10.1", | |
| 1689 | + | "proc-macro2", | |
| 1690 | + | "quote", | |
| 1691 | + | "syn 1.0.109", | |
| 1692 | + | ] | |
| 1693 | + | ||
| 1694 | + | [[package]] | |
| 1695 | + | name = "html5ever" | |
| 1663 | 1696 | version = "0.27.0" | |
| 1664 | 1697 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1665 | 1698 | checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" | |
| @@ -1691,7 +1724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1691 | 1724 | checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" | |
| 1692 | 1725 | dependencies = [ | |
| 1693 | 1726 | "bytes", | |
| 1694 | - | "itoa", | |
| 1727 | + | "itoa 1.0.17", | |
| 1695 | 1728 | ] | |
| 1696 | 1729 | ||
| 1697 | 1730 | [[package]] | |
| @@ -1737,7 +1770,7 @@ dependencies = [ | |||
| 1737 | 1770 | "http", | |
| 1738 | 1771 | "http-body", | |
| 1739 | 1772 | "httparse", | |
| 1740 | - | "itoa", | |
| 1773 | + | "itoa 1.0.17", | |
| 1741 | 1774 | "pin-project-lite", | |
| 1742 | 1775 | "pin-utils", | |
| 1743 | 1776 | "smallvec", | |
| @@ -2022,6 +2055,12 @@ dependencies = [ | |||
| 2022 | 2055 | ||
| 2023 | 2056 | [[package]] | |
| 2024 | 2057 | name = "itoa" | |
| 2058 | + | version = "0.4.8" | |
| 2059 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2060 | + | checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" | |
| 2061 | + | ||
| 2062 | + | [[package]] | |
| 2063 | + | name = "itoa" | |
| 2025 | 2064 | version = "1.0.17" | |
| 2026 | 2065 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2027 | 2066 | checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" | |
| @@ -2125,15 +2164,27 @@ dependencies = [ | |||
| 2125 | 2164 | ] | |
| 2126 | 2165 | ||
| 2127 | 2166 | [[package]] | |
| 2167 | + | name = "kuchiki" | |
| 2168 | + | version = "0.8.1" | |
| 2169 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2170 | + | checksum = "1ea8e9c6e031377cff82ee3001dc8026cdf431ed4e2e6b51f98ab8c73484a358" | |
| 2171 | + | dependencies = [ | |
| 2172 | + | "cssparser 0.27.2", | |
| 2173 | + | "html5ever 0.25.2", | |
| 2174 | + | "matches", | |
| 2175 | + | "selectors 0.22.0", | |
| 2176 | + | ] | |
| 2177 | + | ||
| 2178 | + | [[package]] | |
| 2128 | 2179 | name = "kuchikiki" | |
| 2129 | 2180 | version = "0.8.8-speedreader" | |
| 2130 | 2181 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2131 | 2182 | checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" | |
| 2132 | 2183 | dependencies = [ | |
| 2133 | - | "cssparser", | |
| 2184 | + | "cssparser 0.29.6", | |
| 2134 | 2185 | "html5ever 0.29.1", | |
| 2135 | 2186 | "indexmap 2.13.0", | |
| 2136 | - | "selectors", | |
| 2187 | + | "selectors 0.24.0", | |
| 2137 | 2188 | ] | |
| 2138 | 2189 | ||
| 2139 | 2190 | [[package]] | |
| @@ -2248,6 +2299,20 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" | |||
| 2248 | 2299 | ||
| 2249 | 2300 | [[package]] | |
| 2250 | 2301 | name = "markup5ever" | |
| 2302 | + | version = "0.10.1" | |
| 2303 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2304 | + | checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" | |
| 2305 | + | dependencies = [ | |
| 2306 | + | "log", | |
| 2307 | + | "phf 0.8.0", | |
| 2308 | + | "phf_codegen 0.8.0", | |
| 2309 | + | "string_cache", | |
| 2310 | + | "string_cache_codegen", | |
| 2311 | + | "tendril", | |
| 2312 | + | ] | |
| 2313 | + | ||
| 2314 | + | [[package]] | |
| 2315 | + | name = "markup5ever" | |
| 2251 | 2316 | version = "0.12.1" | |
| 2252 | 2317 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2253 | 2318 | checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" | |
| @@ -2915,7 +2980,9 @@ version = "0.8.0" | |||
| 2915 | 2980 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2916 | 2981 | checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" | |
| 2917 | 2982 | dependencies = [ | |
| 2983 | + | "phf_macros 0.8.0", | |
| 2918 | 2984 | "phf_shared 0.8.0", | |
| 2985 | + | "proc-macro-hack", | |
| 2919 | 2986 | ] | |
| 2920 | 2987 | ||
| 2921 | 2988 | [[package]] | |
| @@ -2991,6 +3058,20 @@ dependencies = [ | |||
| 2991 | 3058 | ||
| 2992 | 3059 | [[package]] | |
| 2993 | 3060 | name = "phf_macros" | |
| 3061 | + | version = "0.8.0" | |
| 3062 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3063 | + | checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" | |
| 3064 | + | dependencies = [ | |
| 3065 | + | "phf_generator 0.8.0", | |
| 3066 | + | "phf_shared 0.8.0", | |
| 3067 | + | "proc-macro-hack", | |
| 3068 | + | "proc-macro2", | |
| 3069 | + | "quote", | |
| 3070 | + | "syn 1.0.109", | |
| 3071 | + | ] | |
| 3072 | + | ||
| 3073 | + | [[package]] | |
| 3074 | + | name = "phf_macros" | |
| 2994 | 3075 | version = "0.10.0" | |
| 2995 | 3076 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2996 | 3077 | checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" | |
| @@ -3347,6 +3428,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3347 | 3428 | checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" | |
| 3348 | 3429 | ||
| 3349 | 3430 | [[package]] | |
| 3431 | + | name = "readable-readability" | |
| 3432 | + | version = "0.4.0" | |
| 3433 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3434 | + | checksum = "c17015928a25bff296b0471dfa7a784e406664e1d091781db66e885b18708a8d" | |
| 3435 | + | dependencies = [ | |
| 3436 | + | "html5ever 0.25.2", | |
| 3437 | + | "kuchiki", | |
| 3438 | + | "lazy_static", | |
| 3439 | + | "log", | |
| 3440 | + | "regex", | |
| 3441 | + | "url", | |
| 3442 | + | ] | |
| 3443 | + | ||
| 3444 | + | [[package]] | |
| 3350 | 3445 | name = "redox_syscall" | |
| 3351 | 3446 | version = "0.5.18" | |
| 3352 | 3447 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -3761,19 +3856,39 @@ dependencies = [ | |||
| 3761 | 3856 | ||
| 3762 | 3857 | [[package]] | |
| 3763 | 3858 | name = "selectors" | |
| 3859 | + | version = "0.22.0" | |
| 3860 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3861 | + | checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" | |
| 3862 | + | dependencies = [ | |
| 3863 | + | "bitflags 1.3.2", | |
| 3864 | + | "cssparser 0.27.2", | |
| 3865 | + | "derive_more", | |
| 3866 | + | "fxhash", | |
| 3867 | + | "log", | |
| 3868 | + | "matches", | |
| 3869 | + | "phf 0.8.0", | |
| 3870 | + | "phf_codegen 0.8.0", | |
| 3871 | + | "precomputed-hash", | |
| 3872 | + | "servo_arc 0.1.1", | |
| 3873 | + | "smallvec", | |
| 3874 | + | "thin-slice", | |
| 3875 | + | ] | |
| 3876 | + | ||
| 3877 | + | [[package]] | |
| 3878 | + | name = "selectors" | |
| 3764 | 3879 | version = "0.24.0" | |
| 3765 | 3880 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3766 | 3881 | checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" | |
| 3767 | 3882 | dependencies = [ | |
| 3768 | 3883 | "bitflags 1.3.2", | |
| 3769 | - | "cssparser", | |
| 3884 | + | "cssparser 0.29.6", | |
| 3770 | 3885 | "derive_more", | |
| 3771 | 3886 | "fxhash", | |
| 3772 | 3887 | "log", | |
| 3773 | 3888 | "phf 0.8.0", | |
| 3774 | 3889 | "phf_codegen 0.8.0", | |
| 3775 | 3890 | "precomputed-hash", | |
| 3776 | - | "servo_arc", | |
| 3891 | + | "servo_arc 0.2.0", | |
| 3777 | 3892 | "smallvec", | |
| 3778 | 3893 | ] | |
| 3779 | 3894 | ||
| @@ -3846,7 +3961,7 @@ version = "1.0.149" | |||
| 3846 | 3961 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3847 | 3962 | checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" | |
| 3848 | 3963 | dependencies = [ | |
| 3849 | - | "itoa", | |
| 3964 | + | "itoa 1.0.17", | |
| 3850 | 3965 | "memchr", | |
| 3851 | 3966 | "serde", | |
| 3852 | 3967 | "serde_core", | |
| @@ -3889,7 +4004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 3889 | 4004 | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | |
| 3890 | 4005 | dependencies = [ | |
| 3891 | 4006 | "form_urlencoded", | |
| 3892 | - | "itoa", | |
| 4007 | + | "itoa 1.0.17", | |
| 3893 | 4008 | "ryu", | |
| 3894 | 4009 | "serde", | |
| 3895 | 4010 | ] | |
| @@ -3949,6 +4064,16 @@ dependencies = [ | |||
| 3949 | 4064 | ||
| 3950 | 4065 | [[package]] | |
| 3951 | 4066 | name = "servo_arc" | |
| 4067 | + | version = "0.1.1" | |
| 4068 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4069 | + | checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" | |
| 4070 | + | dependencies = [ | |
| 4071 | + | "nodrop", | |
| 4072 | + | "stable_deref_trait", | |
| 4073 | + | ] | |
| 4074 | + | ||
| 4075 | + | [[package]] | |
| 4076 | + | name = "servo_arc" | |
| 3952 | 4077 | version = "0.2.0" | |
| 3953 | 4078 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3954 | 4079 | checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" | |
| @@ -4285,7 +4410,7 @@ dependencies = [ | |||
| 4285 | 4410 | "hex", | |
| 4286 | 4411 | "hkdf", | |
| 4287 | 4412 | "hmac", | |
| 4288 | - | "itoa", | |
| 4413 | + | "itoa 1.0.17", | |
| 4289 | 4414 | "log", | |
| 4290 | 4415 | "md-5", | |
| 4291 | 4416 | "memchr", | |
| @@ -4326,7 +4451,7 @@ dependencies = [ | |||
| 4326 | 4451 | "hkdf", | |
| 4327 | 4452 | "hmac", | |
| 4328 | 4453 | "home", | |
| 4329 | - | "itoa", | |
| 4454 | + | "itoa 1.0.17", | |
| 4330 | 4455 | "log", | |
| 4331 | 4456 | "md-5", | |
| 4332 | 4457 | "memchr", | |
| @@ -4474,7 +4599,7 @@ dependencies = [ | |||
| 4474 | 4599 | ||
| 4475 | 4600 | [[package]] | |
| 4476 | 4601 | name = "synckit-client" | |
| 4477 | - | version = "0.1.0" | |
| 4602 | + | version = "0.2.0" | |
| 4478 | 4603 | dependencies = [ | |
| 4479 | 4604 | "argon2", | |
| 4480 | 4605 | "base64 0.22.1", | |
| @@ -4928,6 +5053,12 @@ dependencies = [ | |||
| 4928 | 5053 | ] | |
| 4929 | 5054 | ||
| 4930 | 5055 | [[package]] | |
| 5056 | + | name = "thin-slice" | |
| 5057 | + | version = "0.1.1" | |
| 5058 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5059 | + | checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" | |
| 5060 | + | ||
| 5061 | + | [[package]] | |
| 4931 | 5062 | name = "thin-vec" | |
| 4932 | 5063 | version = "0.2.14" | |
| 4933 | 5064 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4992,7 +5123,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4992 | 5123 | checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" | |
| 4993 | 5124 | dependencies = [ | |
| 4994 | 5125 | "deranged", | |
| 4995 | - | "itoa", | |
| 5126 | + | "itoa 1.0.17", | |
| 4996 | 5127 | "num-conv", | |
| 4997 | 5128 | "powerfmt", | |
| 4998 | 5129 | "serde_core", |
| @@ -10,7 +10,7 @@ members = [ | |||
| 10 | 10 | default-members = ["src-tauri"] | |
| 11 | 11 | ||
| 12 | 12 | [workspace.package] | |
| 13 | - | version = "0.1.0" | |
| 13 | + | version = "0.2.1" | |
| 14 | 14 | edition = "2021" | |
| 15 | 15 | authors = ["BalancedBreakfast Contributors"] | |
| 16 | 16 | license-file = "LICENSE" | |
| @@ -46,4 +46,4 @@ bb-interface = { path = "crates/bb-interface" } | |||
| 46 | 46 | bb-core = { path = "crates/bb-core" } | |
| 47 | 47 | bb-feed = { path = "crates/bb-feed" } | |
| 48 | 48 | bb-db = { path = "crates/bb-db" } | |
| 49 | - | synckit-client = { path = "../../synckit-client" } | |
| 49 | + | synckit-client = { path = "../synckit-client" } |
| @@ -47,247 +47,16 @@ Dependency flow: `bb-interface` is leaf (no internal deps) -> `bb-core` and `bb- | |||
| 47 | 47 | ||
| 48 | 48 | ## Plugin Authoring | |
| 49 | 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. | |
| 50 | + | Plugins ("bussers") are `.rhai` script files. Drop one into the plugins directory and it loads on next launch. | |
| 51 | 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) | |
| 52 | + | - **Dev:** `plugins/` at the project root | |
| 53 | + | - **Prod:** `<app_config_dir>/plugins/` (e.g. `~/Library/Application Support/com.balancedbreakfast.app/plugins/` on macOS) | |
| 55 | 54 | ||
| 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 | - | ``` | |
| 55 | + | Every plugin defines four functions (`id`, `name`, `config_schema`, `fetch`) plus an optional `capabilities()`. Full authoring guide with field types, return shapes, host functions, and examples: [docs/plugin_authoring.md](docs/plugin_authoring.md). | |
| 283 | 56 | ||
| 284 | 57 | ## Bundled Plugins | |
| 285 | 58 | ||
| 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 | |
| 59 | + | Three plugins ship with the app: **rss.rhai** (RSS/Atom/JSON Feed), **hackernews.rhai** (HN stories), **arxiv.rhai** (arXiv papers). | |
| 291 | 60 | ||
| 292 | 61 | ## License | |
| 293 | 62 |
| @@ -38,3 +38,6 @@ url = "2" | |||
| 38 | 38 | ||
| 39 | 39 | # Regex for HTML URL rewriting | |
| 40 | 40 | regex = "1" | |
| 41 | + | ||
| 42 | + | # Article extraction (reader view) | |
| 43 | + | readable-readability = "0.4" |
| @@ -13,4 +13,4 @@ pub mod url_cleaner; | |||
| 13 | 13 | ||
| 14 | 14 | pub use orchestrator::*; | |
| 15 | 15 | pub use plugin_manager::*; | |
| 16 | - | pub use rhai_plugin::{RhaiPlugin, RhaiPluginError, RhaiPluginManager}; | |
| 16 | + | pub use rhai_plugin::{ReaderResult, RhaiPlugin, RhaiPluginError, RhaiPluginManager}; |
| @@ -88,7 +88,7 @@ impl Orchestrator { | |||
| 88 | 88 | pub async fn load_plugins(&self) -> Result<Vec<String>, OrchestratorError> { | |
| 89 | 89 | let mut plugins = self.plugins.write().await; | |
| 90 | 90 | let loaded = plugins.load_all()?; | |
| 91 | - | info!("Loaded {} plugins", loaded.len()); | |
| 91 | + | info!(count = loaded.len(), "Loaded plugins"); | |
| 92 | 92 | Ok(loaded) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| @@ -101,7 +101,7 @@ impl Orchestrator { | |||
| 101 | 101 | let feeds = self.db.feeds().get_by_busser(plugin_id).await?; | |
| 102 | 102 | ||
| 103 | 103 | if feeds.is_empty() { | |
| 104 | - | info!("No feeds configured for plugin: {}", plugin_id); | |
| 104 | + | info!(%plugin_id, "No feeds configured for plugin"); | |
| 105 | 105 | return Ok(()); | |
| 106 | 106 | } | |
| 107 | 107 | ||
| @@ -117,8 +117,8 @@ impl Orchestrator { | |||
| 117 | 117 | .collect(), | |
| 118 | 118 | None => { | |
| 119 | 119 | debug!( | |
| 120 | - | "No config schema available for plugin {}, treating all fields as options", | |
| 121 | - | plugin_id | |
| 120 | + | %plugin_id, | |
| 121 | + | "No config schema available for plugin, treating all fields as options" | |
| 122 | 122 | ); | |
| 123 | 123 | Vec::new() | |
| 124 | 124 | } | |
| @@ -162,7 +162,10 @@ impl Orchestrator { | |||
| 162 | 162 | Ok(()) | |
| 163 | 163 | } | |
| 164 | 164 | ||
| 165 | - | /// Fetch items from a specific plugin | |
| 165 | + | /// Fetch items from a specific plugin. | |
| 166 | + | /// | |
| 167 | + | /// Returns `(items_count, circuit_breaker_tripped)`. The second value is | |
| 168 | + | /// `true` when this fetch failure caused the circuit breaker to trip. | |
| 166 | 169 | pub async fn fetch_plugin( | |
| 167 | 170 | &self, | |
| 168 | 171 | plugin_id: &str, | |
| @@ -172,7 +175,7 @@ impl Orchestrator { | |||
| 172 | 175 | let feed_id = feeds.first().map(|f| f.id); | |
| 173 | 176 | ||
| 174 | 177 | let Some(feed_id) = feed_id else { | |
| 175 | - | debug!("No feeds configured for plugin {}, skipping store", plugin_id); | |
| 178 | + | debug!(%plugin_id, "No feeds configured for plugin, skipping store"); | |
| 176 | 179 | return Ok(0); | |
| 177 | 180 | }; | |
| 178 | 181 | ||
| @@ -182,13 +185,23 @@ impl Orchestrator { | |||
| 182 | 185 | Err(e) => { | |
| 183 | 186 | // Record fetch failure before propagating | |
| 184 | 187 | let error_msg = e.to_string(); | |
| 185 | - | if let Err(db_err) = self | |
| 188 | + | match self | |
| 186 | 189 | .db | |
| 187 | 190 | .feeds() | |
| 188 | 191 | .record_fetch_failure(feed_id, &error_msg) | |
| 189 | 192 | .await | |
| 190 | 193 | { | |
| 191 | - | error!("Failed to record fetch failure: {}", db_err); | |
| 194 | + | Ok(tripped) => { | |
| 195 | + | if tripped { | |
| 196 | + | info!( | |
| 197 | + | %feed_id, %plugin_id, | |
| 198 | + | "Circuit breaker tripped for feed" | |
| 199 | + | ); | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | Err(db_err) => { | |
| 203 | + | error!(error = %db_err, "Failed to record fetch failure"); | |
| 204 | + | } | |
| 192 | 205 | } | |
| 193 | 206 | return Err(e.into()); | |
| 194 | 207 | } | |
| @@ -210,7 +223,7 @@ impl Orchestrator { | |||
| 210 | 223 | match self.db.items().upsert(create_item).await { | |
| 211 | 224 | Ok(_) => count += 1, | |
| 212 | 225 | Err(e) => { | |
| 213 | - | error!("Failed to store item: {}", e); | |
| 226 | + | error!(error = %e, "Failed to store item"); | |
| 214 | 227 | } | |
| 215 | 228 | } | |
| 216 | 229 | } | |
| @@ -218,10 +231,34 @@ impl Orchestrator { | |||
| 218 | 231 | // Record successful fetch (resets failure counter) | |
| 219 | 232 | self.db.feeds().record_fetch_success(feed_id).await?; | |
| 220 | 233 | ||
| 221 | - | info!("Fetched {} items from {}", count, plugin_id); | |
| 234 | + | info!(count, %plugin_id, "Fetched items from plugin"); | |
| 222 | 235 | Ok(count) | |
| 223 | 236 | } | |
| 224 | 237 | ||
| 238 | + | /// Check whether a plugin's feed is circuit-broken. | |
| 239 | + | pub async fn is_circuit_broken(&self, plugin_id: &str) -> Result<bool, OrchestratorError> { | |
| 240 | + | let feeds = self.db.feeds().get_by_busser(plugin_id).await?; | |
| 241 | + | Ok(feeds.first().is_some_and(|f| f.circuit_broken)) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// Reset the circuit breaker for a plugin's feed and attempt a fresh fetch. | |
| 245 | + | /// | |
| 246 | + | /// Clears `circuit_broken`, resets `consecutive_failures` to 0, and clears | |
| 247 | + | /// `last_error`. Returns the item count from the fetch attempt. | |
| 248 | + | pub async fn reset_circuit_breaker_and_fetch( | |
| 249 | + | &self, | |
| 250 | + | plugin_id: &str, | |
| 251 | + | ) -> Result<usize, OrchestratorError> { | |
| 252 | + | let feeds = self.db.feeds().get_by_busser(plugin_id).await?; | |
| 253 | + | for feed in &feeds { | |
| 254 | + | if feed.circuit_broken { | |
| 255 | + | self.db.feeds().reset_circuit_breaker(feed.id).await?; | |
| 256 | + | info!(feed_name = %feed.name, %plugin_id, "Circuit breaker reset for feed"); | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | self.fetch_plugin(plugin_id).await | |
| 260 | + | } | |
| 261 | + | ||
| 225 | 262 | /// Fetch from all active plugins | |
| 226 | 263 | pub async fn fetch_all(&self) -> Result<usize, OrchestratorError> { | |
| 227 | 264 | let plugin_ids = { | |
| @@ -234,7 +271,7 @@ impl Orchestrator { | |||
| 234 | 271 | match self.fetch_plugin(&plugin_id).await { | |
| 235 | 272 | Ok(count) => total += count, | |
| 236 | 273 | Err(e) => { | |
| 237 | - | error!("Failed to fetch from {}: {}", plugin_id, e); | |
| 274 | + | error!(error = %e, %plugin_id, "Failed to fetch from plugin"); | |
| 238 | 275 | } | |
| 239 | 276 | } | |
| 240 | 277 | } | |
| @@ -308,7 +345,7 @@ impl Orchestrator { | |||
| 308 | 345 | serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()); | |
| 309 | 346 | self.db.feeds().update_config(feed.id, &config_str).await?; | |
| 310 | 347 | ||
| 311 | - | info!("Encrypted secrets for feed: {}", feed.name); | |
| 348 | + | info!(feed_name = %feed.name, "Encrypted secrets for feed"); | |
| 312 | 349 | } | |
| 313 | 350 | ||
| 314 | 351 | Ok(()) |
| @@ -58,7 +58,7 @@ impl PluginManager { | |||
| 58 | 58 | let mut plugins = Vec::new(); | |
| 59 | 59 | ||
| 60 | 60 | if !self.plugins_dir.exists() { | |
| 61 | - | info!("Creating plugins directory: {:?}", self.plugins_dir); | |
| 61 | + | info!(path = ?self.plugins_dir, "Creating plugins directory"); | |
| 62 | 62 | std::fs::create_dir_all(&self.plugins_dir)?; | |
| 63 | 63 | return Ok(plugins); | |
| 64 | 64 | } | |
| @@ -72,7 +72,7 @@ impl PluginManager { | |||
| 72 | 72 | } | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 75 | - | info!("Discovered {} Rhai plugins", plugins.len()); | |
| 75 | + | info!(count = plugins.len(), "Discovered Rhai plugins"); | |
| 76 | 76 | Ok(plugins) | |
| 77 | 77 | } | |
| 78 | 78 | ||
| @@ -84,14 +84,14 @@ impl PluginManager { | |||
| 84 | 84 | /// Load a plugin from a .rhai file path | |
| 85 | 85 | pub fn load_plugin(&mut self, path: impl AsRef<Path>) -> Result<String, PluginError> { | |
| 86 | 86 | let path = path.as_ref(); | |
| 87 | - | info!("Loading Rhai plugin from: {:?}", path); | |
| 87 | + | info!(?path, "Loading Rhai plugin"); | |
| 88 | 88 | ||
| 89 | 89 | let id = self | |
| 90 | 90 | .rhai_manager | |
| 91 | 91 | .load_plugin(path) | |
| 92 | 92 | .map_err(|e| PluginError::LoadError(format!("{}: {}", path.display(), e)))?; | |
| 93 | 93 | ||
| 94 | - | debug!("Loaded Rhai plugin: {}", id); | |
| 94 | + | debug!(%id, "Loaded Rhai plugin"); | |
| 95 | 95 | Ok(id) | |
| 96 | 96 | } | |
| 97 | 97 | ||
| @@ -104,7 +104,7 @@ impl PluginManager { | |||
| 104 | 104 | match self.load_plugin(&path) { | |
| 105 | 105 | Ok(id) => loaded.push(id), | |
| 106 | 106 | Err(e) => { | |
| 107 | - | warn!("Failed to load plugin {:?}: {}", path, e); | |
| 107 | + | warn!(error = %e, ?path, "Failed to load plugin"); | |
| 108 | 108 | } | |
| 109 | 109 | } | |
| 110 | 110 | } | |
| @@ -130,7 +130,7 @@ impl PluginManager { | |||
| 130 | 130 | .map_err(|e| PluginError::LockPoisoned(e.to_string()))?; | |
| 131 | 131 | configs.insert(plugin_id.to_string(), config); | |
| 132 | 132 | ||
| 133 | - | info!("Initialized plugin: {}", plugin_id); | |
| 133 | + | info!(%plugin_id, "Initialized plugin"); | |
| 134 | 134 | Ok(()) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| @@ -164,7 +164,7 @@ impl PluginManager { | |||
| 164 | 164 | return Err(PluginError::NotFound(plugin_id.to_string())); | |
| 165 | 165 | } | |
| 166 | 166 | ||
| 167 | - | info!("Shutdown plugin: {}", plugin_id); | |
| 167 | + | info!(%plugin_id, "Shutdown plugin"); | |
| 168 | 168 | Ok(()) | |
| 169 | 169 | } | |
| 170 | 170 | ||
| @@ -173,7 +173,7 @@ impl PluginManager { | |||
| 173 | 173 | let plugin_ids = self.rhai_manager.list(); | |
| 174 | 174 | for id in plugin_ids { | |
| 175 | 175 | if let Err(e) = self.shutdown_plugin(&id) { | |
| 176 | - | error!("Failed to shutdown plugin {}: {}", id, e); | |
| 176 | + | error!(error = %e, %id, "Failed to shutdown plugin"); | |
| 177 | 177 | } | |
| 178 | 178 | } | |
| 179 | 179 | } | |
| @@ -199,7 +199,7 @@ impl PluginManager { | |||
| 199 | 199 | match p.config_schema() { | |
| 200 | 200 | Ok(s) => Some(s), | |
| 201 | 201 | Err(e) => { | |
| 202 | - | warn!("Plugin {} config_schema() failed: {}", plugin_id, e); | |
| 202 | + | warn!(error = %e, %plugin_id, "Plugin config_schema() failed"); | |
| 203 | 203 | None | |
| 204 | 204 | } | |
| 205 | 205 | } |
| @@ -542,7 +542,7 @@ pub(super) fn dynamic_to_fetch_result( | |||
| 542 | 542 | match dynamic_to_feed_item(item_val, source_id) { | |
| 543 | 543 | Ok(item) => items.push(item), | |
| 544 | 544 | Err(e) => { | |
| 545 | - | warn!("Failed to parse feed item: {}", e); | |
| 545 | + | warn!(error = %e, "Failed to parse feed item"); | |
| 546 | 546 | } | |
| 547 | 547 | } | |
| 548 | 548 | } |
| @@ -1,10 +1,82 @@ | |||
| 1 | 1 | //! Host functions registered into the Rhai engine for plugin scripts. | |
| 2 | 2 | ||
| 3 | + | use std::io::Read as _; | |
| 4 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 5 | + | use std::sync::Arc; | |
| 6 | + | use std::time::Duration; | |
| 7 | + | ||
| 3 | 8 | use rhai::{Dynamic, Engine}; | |
| 4 | 9 | ||
| 5 | 10 | use super::conversions::{json_to_dynamic, parse_feed_xml, parse_json_feed, parse_xml_to_dynamic}; | |
| 6 | 11 | use crate::url_cleaner; | |
| 7 | 12 | ||
| 13 | + | /// HTTP request timeout for plugin host functions. | |
| 14 | + | const HTTP_TIMEOUT: Duration = Duration::from_secs(15); | |
| 15 | + | ||
| 16 | + | /// Maximum response body size (2 MB). Prevents a plugin from consuming | |
| 17 | + | /// unbounded memory on a large or malicious response. | |
| 18 | + | const MAX_RESPONSE_BYTES: u64 = 2 * 1024 * 1024; | |
| 19 | + | ||
| 20 | + | /// Maximum HTTP requests per fetch() invocation. A typical RSS plugin | |
| 21 | + | /// makes 1-3 requests; 100 allows pagination while catching runaways. | |
| 22 | + | const MAX_REQUESTS_PER_FETCH: usize = 100; | |
| 23 | + | ||
| 24 | + | /// Validate a URL before making an HTTP request. Blocks non-HTTP schemes | |
| 25 | + | /// and requests to localhost/internal networks. | |
| 26 | + | fn validate_url(url: &str) -> Result<(), String> { | |
| 27 | + | let lower = url.to_ascii_lowercase(); | |
| 28 | + | if !lower.starts_with("http://") && !lower.starts_with("https://") { | |
| 29 | + | return Err(format!("Blocked URL scheme (only http/https allowed): {}", url)); | |
| 30 | + | } | |
| 31 | + | // Block localhost and common internal addresses | |
| 32 | + | let host_part = lower | |
| 33 | + | .strip_prefix("http://") | |
| 34 | + | .or_else(|| lower.strip_prefix("https://")) | |
| 35 | + | .unwrap_or(""); | |
| 36 | + | let host_and_port = host_part.split('/').next().unwrap_or(""); | |
| 37 | + | // Handle IPv6 brackets: [::1]:8080 -> [::1] | |
| 38 | + | let host = if host_and_port.starts_with('[') { | |
| 39 | + | host_and_port.split(']').next().map(|s| format!("{}]", s)).unwrap_or_default() | |
| 40 | + | } else { | |
| 41 | + | host_and_port.split(':').next().unwrap_or("").to_string() // strip port | |
| 42 | + | }; | |
| 43 | + | let host = host.as_str(); | |
| 44 | + | if host == "localhost" | |
| 45 | + | || host == "127.0.0.1" | |
| 46 | + | || host == "[::1]" | |
| 47 | + | || host == "0.0.0.0" | |
| 48 | + | || host.starts_with("10.") | |
| 49 | + | || host.starts_with("192.168.") | |
| 50 | + | || host.starts_with("169.254.") | |
| 51 | + | { | |
| 52 | + | return Err(format!("Blocked request to internal address: {}", url)); | |
| 53 | + | } | |
| 54 | + | // Block 172.16.0.0/12 | |
| 55 | + | if let Some(rest) = host.strip_prefix("172.") { | |
| 56 | + | if let Some(second) = rest.split('.').next() { | |
| 57 | + | if let Ok(n) = second.parse::<u8>() { | |
| 58 | + | if (16..=31).contains(&n) { | |
| 59 | + | return Err(format!("Blocked request to internal address: {}", url)); | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | Ok(()) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Check and increment the per-fetch request counter. | |
| 68 | + | fn check_request_limit(counter: &AtomicUsize) -> Result<(), String> { | |
| 69 | + | let prev = counter.fetch_add(1, Ordering::Relaxed); | |
| 70 | + | if prev >= MAX_REQUESTS_PER_FETCH { | |
| 71 | + | Err(format!( | |
| 72 | + | "HTTP request limit exceeded ({} per fetch)", | |
| 73 | + | MAX_REQUESTS_PER_FETCH | |
| 74 | + | )) | |
| 75 | + | } else { | |
| 76 | + | Ok(()) | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 8 | 80 | /// Register host functions available to Rhai scripts. | |
| 9 | 81 | /// | |
| 10 | 82 | /// # Trust model | |
| @@ -16,26 +88,48 @@ use crate::url_cleaner; | |||
| 16 | 88 | /// they trust, similar to shell scripts. If BB ever supports remote/untrusted | |
| 17 | 89 | /// plugin sources, HTTP sandboxing (domain allowlist or per-plugin permissions) | |
| 18 | 90 | /// must be added before that feature ships. | |
| 19 | - | pub(super) fn register_host_functions(engine: &mut Engine) { | |
| 91 | + | pub(super) fn register_host_functions(engine: &mut Engine, request_counter: Arc<AtomicUsize>) { | |
| 20 | 92 | // 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) | |
| 93 | + | let counter = request_counter.clone(); | |
| 94 | + | engine.register_fn("http_get", move |url: &str| -> Result<String, Box<rhai::EvalAltResult>> { | |
| 95 | + | validate_url(url)?; | |
| 96 | + | check_request_limit(&counter)?; | |
| 97 | + | ||
| 98 | + | let response = ureq::get(url) | |
| 99 | + | .timeout(HTTP_TIMEOUT) | |
| 23 | 100 | .call() | |
| 24 | - | .map_err(|e| format!("HTTP request failed: {}", e))? | |
| 25 | - | .into_string() | |
| 26 | - | .map_err(|e| format!("Failed to read response: {}", e).into()) | |
| 101 | + | .map_err(|e| format!("HTTP request failed: {}", e))?; | |
| 102 | + | ||
| 103 | + | let mut body = String::new(); | |
| 104 | + | response | |
| 105 | + | .into_reader() | |
| 106 | + | .take(MAX_RESPONSE_BYTES) | |
| 107 | + | .read_to_string(&mut body) | |
| 108 | + | .map_err(|e| format!("Failed to read response: {}", e))?; | |
| 109 | + | Ok(body) | |
| 27 | 110 | }); | |
| 28 | 111 | ||
| 29 | 112 | // HTTP GET returning parsed JSON as Dynamic | |
| 113 | + | let counter = request_counter; | |
| 30 | 114 | engine.register_fn( | |
| 31 | 115 | "http_get_json", | |
| 32 | - | |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 116 | + | move |url: &str| -> Result<Dynamic, Box<rhai::EvalAltResult>> { | |
| 117 | + | validate_url(url)?; | |
| 118 | + | check_request_limit(&counter)?; | |
| 119 | + | ||
| 33 | 120 | let response = ureq::get(url) | |
| 121 | + | .timeout(HTTP_TIMEOUT) | |
| 34 | 122 | .call() | |
| 35 | 123 | .map_err(|e| format!("HTTP request failed: {}", e))?; | |
| 36 | 124 | ||
| 37 | - | let json: serde_json::Value = response | |
| 38 | - | .into_json() | |
| 125 | + | let mut body = Vec::new(); | |
| 126 | + | response | |
| 127 | + | .into_reader() | |
| 128 | + | .take(MAX_RESPONSE_BYTES) | |
| 129 | + | .read_to_end(&mut body) | |
| 130 | + | .map_err(|e| format!("Failed to read response: {}", e))?; | |
| 131 | + | ||
| 132 | + | let json: serde_json::Value = serde_json::from_slice(&body) | |
| 39 | 133 | .map_err(|e| format!("JSON parse failed: {}", e))?; | |
| 40 | 134 | ||
| 41 | 135 | json_to_dynamic(json) | |
| @@ -129,7 +223,7 @@ pub(super) fn register_host_functions(engine: &mut Engine) { | |||
| 129 | 223 | ||
| 130 | 224 | // Debug print — outputs to tracing at debug level, visible in dev console. | |
| 131 | 225 | engine.register_fn("debug_print", |val: Dynamic| { | |
| 132 | - | tracing::debug!("Rhai debug: {:?}", val); | |
| 226 | + | tracing::debug!(?val, "Rhai debug"); | |
| 133 | 227 | }); | |
| 134 | 228 | ||
| 135 | 229 | // Strip known tracking query parameters (utm_*, fbclid, gclid, etc.) from a URL. | |
| @@ -145,12 +239,41 @@ pub(super) fn register_host_functions(engine: &mut Engine) { | |||
| 145 | 239 | Err(_) => Dynamic::UNIT, | |
| 146 | 240 | } | |
| 147 | 241 | }); | |
| 242 | + | ||
| 243 | + | // Extract main article content from HTML using the readability algorithm. | |
| 244 | + | // Returns a map with "title", "content" (cleaned HTML), and "text" (plain text). | |
| 245 | + | engine.register_fn("extract_article", |html: String| -> rhai::Map { | |
| 246 | + | let mut map = rhai::Map::new(); | |
| 247 | + | let mut readability = readable_readability::Readability::new(); | |
| 248 | + | let (node, metadata) = readability.parse(&html); | |
| 249 | + | ||
| 250 | + | // Serialize the extracted DOM node to an HTML string. | |
| 251 | + | let mut content = Vec::new(); | |
| 252 | + | node.serialize(&mut content).unwrap_or_default(); | |
| 253 | + | let content_html = String::from_utf8_lossy(&content).to_string(); | |
| 254 | + | ||
| 255 | + | // Plain text via html2text | |
| 256 | + | let text = html2text::from_read(content_html.as_bytes(), 80); | |
| 257 | + | ||
| 258 | + | map.insert( | |
| 259 | + | "title".into(), | |
| 260 | + | Dynamic::from(metadata.article_title.unwrap_or_default()), | |
| 261 | + | ); | |
| 262 | + | map.insert("content".into(), Dynamic::from(content_html)); | |
| 263 | + | map.insert("text".into(), Dynamic::from(text)); | |
| 264 | + | map | |
| 265 | + | }); | |
| 148 | 266 | } | |
| 149 | 267 | ||
| 150 | 268 | #[cfg(test)] | |
| 151 | 269 | mod tests { | |
| 270 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 271 | + | use std::sync::Arc; | |
| 272 | + | ||
| 152 | 273 | use rhai::Dynamic; | |
| 153 | 274 | ||
| 275 | + | use super::{check_request_limit, validate_url, MAX_REQUESTS_PER_FETCH}; | |
| 276 | + | ||
| 154 | 277 | /// Truncate text with ellipsis (mirrors the Rhai-registered closure for testing). | |
| 155 | 278 | fn truncate_text(text: &str, max: usize) -> String { | |
| 156 | 279 | if text.len() <= max { | |
| @@ -308,4 +431,131 @@ mod tests { | |||
| 308 | 431 | assert!(result >= before); | |
| 309 | 432 | assert!(result <= after); | |
| 310 | 433 | } | |
| 434 | + | ||
| 435 | + | // ── validate_url tests ────────────────────────────────────── | |
| 436 | + | ||
| 437 | + | #[test] | |
| 438 | + | fn validate_url_allows_https() { | |
| 439 | + | assert!(validate_url("https://example.com/feed.xml").is_ok()); | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | #[test] | |
| 443 | + | fn validate_url_allows_http() { | |
| 444 | + | assert!(validate_url("http://example.com/feed.xml").is_ok()); | |
| 445 | + | } | |
| 446 | + | ||
| 447 | + | #[test] | |
| 448 | + | fn validate_url_blocks_file_scheme() { | |
| 449 | + | assert!(validate_url("file:///etc/passwd").is_err()); | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | #[test] | |
| 453 | + | fn validate_url_blocks_ftp_scheme() { | |
| 454 | + | assert!(validate_url("ftp://example.com/file").is_err()); | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | #[test] | |
| 458 | + | fn validate_url_blocks_localhost() { | |
| 459 | + | assert!(validate_url("http://localhost/api").is_err()); | |
| 460 | + | assert!(validate_url("http://127.0.0.1/api").is_err()); | |
| 461 | + | assert!(validate_url("http://[::1]/api").is_err()); | |
| 462 | + | assert!(validate_url("http://0.0.0.0/api").is_err()); | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | #[test] | |
| 466 | + | fn validate_url_blocks_private_ranges() { | |
| 467 | + | assert!(validate_url("http://10.0.0.1/api").is_err()); | |
| 468 | + | assert!(validate_url("http://192.168.1.1/api").is_err()); | |
| 469 | + | assert!(validate_url("http://172.16.0.1/api").is_err()); | |
| 470 | + | assert!(validate_url("http://172.31.255.255/api").is_err()); | |
| 471 | + | assert!(validate_url("http://169.254.1.1/api").is_err()); | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | #[test] | |
| 475 | + | fn validate_url_allows_172_outside_private() { | |
| 476 | + | assert!(validate_url("http://172.15.0.1/api").is_ok()); | |
| 477 | + | assert!(validate_url("http://172.32.0.1/api").is_ok()); | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | #[test] | |
| 481 | + | fn validate_url_case_insensitive_scheme() { | |
| 482 | + | assert!(validate_url("HTTP://example.com").is_ok()); | |
| 483 | + | assert!(validate_url("HTTPS://example.com").is_ok()); | |
| 484 | + | assert!(validate_url("FTP://example.com").is_err()); | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // ── request counter tests ─────────────────────────────────── | |
| 488 | + | ||
| 489 | + | #[test] | |
| 490 | + | fn request_counter_allows_under_limit() { | |
| 491 | + | let counter = AtomicUsize::new(0); | |
| 492 | + | assert!(check_request_limit(&counter).is_ok()); | |
| 493 | + | assert_eq!(counter.load(Ordering::Relaxed), 1); | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | #[test] | |
| 497 | + | fn request_counter_blocks_at_limit() { | |
| 498 | + | let counter = AtomicUsize::new(MAX_REQUESTS_PER_FETCH); | |
| 499 | + | assert!(check_request_limit(&counter).is_err()); | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | #[test] | |
| 503 | + | fn request_counter_resets() { | |
| 504 | + | let counter = Arc::new(AtomicUsize::new(MAX_REQUESTS_PER_FETCH)); | |
| 505 | + | assert!(check_request_limit(&counter).is_err()); | |
| 506 | + | counter.store(0, Ordering::Relaxed); | |
| 507 | + | assert!(check_request_limit(&counter).is_ok()); | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | // ── extract_article tests ────────────────────────────────────── | |
| 511 | + | ||
| 512 | + | #[test] | |
| 513 | + | fn extract_article_basic_html() { | |
| 514 | + | let engine = super::super::create_engine(); | |
| 515 | + | let mut scope = rhai::Scope::new(); | |
| 516 | + | let ast = engine | |
| 517 | + | .compile( | |
| 518 | + | r#" | |
| 519 | + | fn test() { | |
| 520 | + | let html = "<html><head><title>My Article</title></head><body><p>Hello world. This is an article with enough content to be considered the main text by the readability algorithm when processed.</p></body></html>"; | |
| 521 | + | extract_article(html) | |
| 522 | + | } | |
| 523 | + | "#, | |
| 524 | + | ) | |
| 525 | + | .unwrap(); | |
| 526 | + | ||
| 527 | + | let result: rhai::Map = engine.call_fn(&mut scope, &ast, "test", ()).unwrap(); | |
| 528 | + | assert!(result.contains_key("title")); | |
| 529 | + | assert!(result.contains_key("content")); | |
| 530 | + | assert!(result.contains_key("text")); | |
| 531 | + | ||
| 532 | + | let content = result | |
| 533 | + | .get("content") | |
| 534 | + | .unwrap() | |
| 535 | + | .clone() | |
| 536 | + | .into_string() | |
| 537 | + | .unwrap(); | |
| 538 | + | assert!(!content.is_empty()); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | #[test] | |
| 542 | + | fn extract_article_empty_html() { | |
| 543 | + | let engine = super::super::create_engine(); | |
| 544 | + | let mut scope = rhai::Scope::new(); | |
| 545 | + | let ast = engine | |
| 546 | + | .compile( | |
| 547 | + | r#" | |
| 548 | + | fn test() { | |
| 549 | + | extract_article("") | |
| 550 | + | } | |
| 551 | + | "#, | |
| 552 | + | ) | |
| 553 | + | .unwrap(); | |
| 554 | + | ||
| 555 | + | let result: rhai::Map = engine.call_fn(&mut scope, &ast, "test", ()).unwrap(); | |
| 556 | + | // Should not panic, should return map with keys | |
| 557 | + | assert!(result.contains_key("title")); | |
| 558 | + | assert!(result.contains_key("content")); | |
| 559 | + | assert!(result.contains_key("text")); | |
| 560 | + | } | |
| 311 | 561 | } |
| @@ -12,6 +12,7 @@ mod host_functions; | |||
| 12 | 12 | ||
| 13 | 13 | use std::collections::HashMap; | |
| 14 | 14 | use std::path::Path; | |
| 15 | + | use std::sync::atomic::{AtomicUsize, Ordering}; | |
| 15 | 16 | use std::sync::Arc; | |
| 16 | 17 | ||
| 17 | 18 | use bb_interface::{BusserCapabilities, ConfigSchema}; | |
| @@ -50,6 +51,7 @@ pub struct RhaiPlugin { | |||
| 50 | 51 | pub path: std::path::PathBuf, | |
| 51 | 52 | ast: AST, | |
| 52 | 53 | engine: Arc<Engine>, | |
| 54 | + | request_counter: Arc<AtomicUsize>, | |
| 53 | 55 | } | |
| 54 | 56 | ||
| 55 | 57 | impl RhaiPlugin { | |
| @@ -73,7 +75,7 @@ impl RhaiPlugin { | |||
| 73 | 75 | { | |
| 74 | 76 | Ok(result) => dynamic_to_capabilities(result), | |
| 75 | 77 | Err(e) => { | |
| 76 | - | tracing::warn!("Plugin {} capabilities() failed: {}", self.id, e); | |
| 78 | + | tracing::warn!(error = %e, plugin_id = %self.id, "Plugin capabilities() failed"); | |
| 77 | 79 | BusserCapabilities::default() | |
| 78 | 80 | } | |
| 79 | 81 | } | |
| @@ -85,6 +87,9 @@ impl RhaiPlugin { | |||
| 85 | 87 | config: &bb_interface::BusserConfig, | |
| 86 | 88 | cursor: Option<String>, | |
| 87 | 89 | ) -> Result<bb_interface::FetchResult, RhaiPluginError> { | |
| 90 | + | // Reset per-fetch request counter | |
| 91 | + | self.request_counter.store(0, Ordering::Relaxed); | |
| 92 | + | ||
| 88 | 93 | let mut scope = Scope::new(); | |
| 89 | 94 | ||
| 90 | 95 | let config_map = busser_config_to_dynamic(config); | |
| @@ -107,6 +112,7 @@ impl RhaiPlugin { | |||
| 107 | 112 | pub struct RhaiPluginManager { | |
| 108 | 113 | engine: Arc<Engine>, | |
| 109 | 114 | plugins: HashMap<String, RhaiPlugin>, | |
| 115 | + | request_counter: Arc<AtomicUsize>, | |
| 110 | 116 | } | |
| 111 | 117 | ||
| 112 | 118 | impl RhaiPluginManager { | |
| @@ -118,17 +124,21 @@ impl RhaiPluginManager { | |||
| 118 | 124 | /// catching infinite loops. | |
| 119 | 125 | /// - `max_expr_depths(128, 128)`: limits AST nesting depth for both expressions | |
| 120 | 126 | /// and functions, preventing stack overflows from deeply recursive scripts. | |
| 127 | + | /// - HTTP limits: 15s timeout, 2 MB response cap, 100 requests per fetch, | |
| 128 | + | /// http/https only, no localhost/internal addresses. | |
| 121 | 129 | pub fn new() -> Self { | |
| 122 | 130 | let mut engine = Engine::new(); | |
| 123 | 131 | ||
| 124 | 132 | engine.set_max_expr_depths(128, 128); | |
| 125 | 133 | engine.set_max_operations(100_000); | |
| 126 | 134 | ||
| 127 | - | register_host_functions(&mut engine); | |
| 135 | + | let request_counter = Arc::new(AtomicUsize::new(0)); | |
| 136 | + | register_host_functions(&mut engine, request_counter.clone()); | |
| 128 | 137 | ||
| 129 | 138 | Self { | |
| 130 | 139 | engine: Arc::new(engine), | |
| 131 | 140 | plugins: HashMap::new(), | |
| 141 | + | request_counter, | |
| 132 | 142 | } | |
| 133 | 143 | } | |
| 134 | 144 | ||
| @@ -159,7 +169,7 @@ impl RhaiPluginManager { | |||
| 159 | 169 | .call_fn(&mut scope, &ast, "config_schema", ()) | |
| 160 | 170 | .map_err(|e| RhaiPluginError::MissingFunction(format!("config_schema(): {}", e)))?; | |
| 161 | 171 | ||
| 162 | - | debug!("Loaded Rhai plugin: {} ({})", name, id); | |
| 172 | + | debug!(%name, %id, "Loaded Rhai plugin"); | |
| 163 | 173 | ||
| 164 | 174 | let plugin = RhaiPlugin { | |
| 165 | 175 | id: id.clone(), | |
| @@ -167,6 +177,7 @@ impl RhaiPluginManager { | |||
| 167 | 177 | path: path.to_path_buf(), | |
| 168 | 178 | ast, | |
| 169 | 179 | engine: self.engine.clone(), | |
| 180 | + | request_counter: self.request_counter.clone(), | |
| 170 | 181 | }; | |
| 171 | 182 | ||
| 172 | 183 | self.plugins.insert(id.clone(), plugin); | |
| @@ -197,6 +208,69 @@ impl Default for RhaiPluginManager { | |||
| 197 | 208 | } | |
| 198 | 209 | } | |
| 199 | 210 | ||
| 211 | + | /// Create a configured Rhai engine with all host functions registered. | |
| 212 | + | /// | |
| 213 | + | /// Used for one-off script execution (e.g. reader view) outside | |
| 214 | + | /// the plugin manager lifecycle. | |
| 215 | + | pub fn create_engine() -> Engine { | |
| 216 | + | let mut engine = Engine::new(); | |
| 217 | + | engine.set_max_expr_depths(128, 128); | |
| 218 | + | engine.set_max_operations(100_000); | |
| 219 | + | let counter = Arc::new(AtomicUsize::new(0)); | |
| 220 | + | register_host_functions(&mut engine, counter); | |
| 221 | + | engine | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// Result from extracting article content via the reader plugin. | |
| 225 | + | #[derive(Debug, Clone)] | |
| 226 | + | pub struct ReaderResult { | |
| 227 | + | pub title: String, | |
| 228 | + | pub content: String, | |
| 229 | + | pub text_content: String, | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | /// Run the reader extraction plugin on a URL. | |
| 233 | + | /// | |
| 234 | + | /// Creates a one-off Rhai engine, loads `plugins/reader.rhai`, and calls | |
| 235 | + | /// `extract(url)`. Returns the extracted article title, HTML content, and | |
| 236 | + | /// plain text. | |
| 237 | + | pub fn run_reader_script(url: &str) -> Result<ReaderResult, RhaiPluginError> { | |
| 238 | + | let engine = create_engine(); | |
| 239 | + | ||
| 240 | + | let plugin_path = std::path::Path::new("plugins/reader.rhai"); | |
| 241 | + | let script = std::fs::read_to_string(plugin_path).map_err(|e| { | |
| 242 | + | RhaiPluginError::CompileError(format!("Failed to read reader plugin: {}", e)) | |
| 243 | + | })?; | |
| 244 | + | ||
| 245 | + | let ast = engine | |
| 246 | + | .compile(&script) | |
| 247 | + | .map_err(|e| RhaiPluginError::CompileError(e.to_string()))?; | |
| 248 | + | ||
| 249 | + | let mut scope = Scope::new(); | |
| 250 | + | let result: rhai::Map = engine | |
| 251 | + | .call_fn(&mut scope, &ast, "extract", (url.to_string(),)) | |
| 252 | + | .map_err(|e| RhaiPluginError::RuntimeError(e.to_string()))?; | |
| 253 | + | ||
| 254 | + | let title = result | |
| 255 | + | .get("title") | |
| 256 | + | .and_then(|v: &Dynamic| v.clone().into_string().ok()) | |
| 257 | + | .unwrap_or_default(); | |
| 258 | + | let content = result | |
| 259 | + | .get("content") | |
| 260 | + | .and_then(|v: &Dynamic| v.clone().into_string().ok()) | |
| 261 | + | .unwrap_or_default(); | |
| 262 | + | let text_content = result | |
| 263 | + | .get("text") | |
| 264 | + | .and_then(|v: &Dynamic| v.clone().into_string().ok()) | |
| 265 | + | .unwrap_or_default(); | |
| 266 | + | ||
| 267 | + | Ok(ReaderResult { | |
| 268 | + | title, | |
| 269 | + | content, | |
| 270 | + | text_content, | |
| 271 | + | }) | |
| 272 | + | } | |
| 273 | + | ||
| 200 | 274 | #[cfg(test)] | |
| 201 | 275 | mod tests { | |
| 202 | 276 | use super::*; |
| @@ -91,7 +91,7 @@ macro_rules! define_uuid_id { | |||
| 91 | 91 | )+}; | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | define_uuid_id!(FeedId, ItemId, BusserStateId); | |
| 94 | + | define_uuid_id!(FeedId, ItemId, BusserStateId, QueryFeedId); | |
| 95 | 95 | ||
| 96 | 96 | // ── BusserId ───────────────────────────────────────────────────── | |
| 97 | 97 |
| @@ -64,4 +64,16 @@ impl Database { | |||
| 64 | 64 | pub fn state(&self) -> StateRepository { | |
| 65 | 65 | StateRepository::new(self.pool.clone()) | |
| 66 | 66 | } | |
| 67 | + | ||
| 68 | + | /// Get a repository for user_config key-value pairs (theme, welcome flag, | |
| 69 | + | /// etc.). Synced via sync_changelog triggers (migration 007). | |
| 70 | + | pub fn config(&self) -> ConfigRepository { | |
| 71 | + | ConfigRepository::new(self.pool.clone()) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// Get a repository for query feed CRUD (saved filter rules that act as | |
| 75 | + | /// virtual sources). Synced via sync_changelog triggers (migration 009). | |
| 76 | + | pub fn query_feeds(&self) -> QueryFeedsRepository { | |
| 77 | + | QueryFeedsRepository::new(self.pool.clone()) | |
| 78 | + | } | |
| 67 | 79 | } |
| @@ -7,7 +7,7 @@ use chrono::{DateTime, Utc}; | |||
| 7 | 7 | use serde::{Deserialize, Serialize}; | |
| 8 | 8 | use sqlx::FromRow; | |
| 9 | 9 | ||
| 10 | - | use crate::id_types::{BusserId, BusserStateId, FeedId, ItemId}; | |
| 10 | + | use crate::id_types::{BusserId, BusserStateId, FeedId, ItemId, QueryFeedId}; | |
| 11 | 11 | use crate::TIMESTAMP_FMT; | |
| 12 | 12 | ||
| 13 | 13 | /// Parse a value or log a warning and return the default. | |
| @@ -17,7 +17,7 @@ fn parse_or_default<T: Default>(result: Result<T, impl std::fmt::Display>, conte | |||
| 17 | 17 | match result { | |
| 18 | 18 | Ok(v) => v, | |
| 19 | 19 | Err(e) => { | |
| 20 | - | tracing::warn!("{}: {}", context, e); | |
| 20 | + | tracing::warn!(error = %e, context, "Parse failed, using default"); | |
| 21 | 21 | T::default() | |
| 22 | 22 | } | |
| 23 | 23 | } | |
| @@ -54,6 +54,8 @@ pub struct DbFeed { | |||
| 54 | 54 | pub last_error: Option<String>, | |
| 55 | 55 | /// Timestamp of the last successful fetch, if any. | |
| 56 | 56 | pub last_success_at: Option<String>, | |
| 57 | + | /// Whether this feed has been auto-disabled by the circuit breaker. | |
| 58 | + | pub circuit_broken: bool, | |
| 57 | 59 | /// Row creation timestamp. | |
| 58 | 60 | pub created_at: String, | |
| 59 | 61 | /// Row last-modified timestamp. | |
| @@ -277,6 +279,42 @@ impl CreateFeedItem { | |||
| 277 | 279 | } | |
| 278 | 280 | } | |
| 279 | 281 | ||
| 282 | + | /// A single condition in a query feed's rules array. | |
| 283 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 284 | + | pub struct QueryCondition { | |
| 285 | + | pub field: String, | |
| 286 | + | pub operator: String, | |
| 287 | + | pub value: String, | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// Saved filter ("query feed") stored in the `query_feeds` table. | |
| 291 | + | #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] | |
| 292 | + | pub struct DbQueryFeed { | |
| 293 | + | pub id: QueryFeedId, | |
| 294 | + | pub name: String, | |
| 295 | + | /// JSON array of [`QueryCondition`]. | |
| 296 | + | pub rules: String, | |
| 297 | + | pub created_at: String, | |
| 298 | + | pub updated_at: String, | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | impl DbQueryFeed { | |
| 302 | + | /// Deserialize the JSON `rules` column into a `Vec<QueryCondition>`. | |
| 303 | + | pub fn rules_vec(&self) -> Vec<QueryCondition> { | |
| 304 | + | parse_or_default( | |
| 305 | + | serde_json::from_str(&self.rules), | |
| 306 | + | "Failed to parse query feed rules JSON", | |
| 307 | + | ) | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | ||
| 311 | + | /// Input for creating a new query feed. | |
| 312 | + | #[derive(Debug, Clone, Serialize, Deserialize)] | |
| 313 | + | pub struct CreateQueryFeed { | |
| 314 | + | pub name: String, | |
| 315 | + | pub rules: Vec<QueryCondition>, | |
| 316 | + | } | |
| 317 | + | ||
| 280 | 318 | #[cfg(test)] | |
| 281 | 319 | mod tests { | |
| 282 | 320 | use super::*; | |
| @@ -318,6 +356,7 @@ mod tests { | |||
| 318 | 356 | consecutive_failures: 0, | |
| 319 | 357 | last_error: None, | |
| 320 | 358 | last_success_at: None, | |
| 359 | + | circuit_broken: false, | |
| 321 | 360 | created_at: "2024-01-01 00:00:00".to_string(), | |
| 322 | 361 | updated_at: "2024-01-01 00:00:00".to_string(), | |
| 323 | 362 | }; | |
| @@ -340,6 +379,7 @@ mod tests { | |||
| 340 | 379 | consecutive_failures: 0, | |
| 341 | 380 | last_error: None, | |
| 342 | 381 | last_success_at: None, | |
| 382 | + | circuit_broken: false, | |
| 343 | 383 | created_at: "2024-01-01 00:00:00".to_string(), | |
| 344 | 384 | updated_at: "2024-01-01 00:00:00".to_string(), | |
| 345 | 385 | }; | |
| @@ -359,6 +399,7 @@ mod tests { | |||
| 359 | 399 | consecutive_failures: 0, | |
| 360 | 400 | last_error: None, | |
| 361 | 401 | last_success_at: None, | |
| 402 | + | circuit_broken: false, | |
| 362 | 403 | created_at: "2024-01-01 00:00:00".to_string(), | |
| 363 | 404 | updated_at: "2024-01-01 00:00:00".to_string(), | |
| 364 | 405 | }; |
| @@ -6,26 +6,47 @@ | |||
| 6 | 6 | use chrono::{DateTime, Utc}; | |
| 7 | 7 | use sqlx::SqlitePool; | |
| 8 | 8 | ||
| 9 | - | use crate::id_types::{BusserStateId, FeedId, ItemId}; | |
| 9 | + | use crate::id_types::{BusserStateId, FeedId, ItemId, QueryFeedId}; | |
| 10 | 10 | use crate::models::*; | |
| 11 | 11 | use crate::TIMESTAMP_FMT; | |
| 12 | 12 | ||
| 13 | 13 | /// Sanitize a user-provided search string for FTS5 MATCH syntax. | |
| 14 | 14 | /// | |
| 15 | 15 | /// Wraps each word in double quotes to prevent FTS5 syntax injection | |
| 16 | - | /// (e.g. a user typing `AND` or `OR` or `NEAR` wouldn't be interpreted | |
| 17 | - | /// as FTS5 operators). Words are joined with spaces (implicit AND). | |
| 16 | + | /// (e.g. a user typing `AND`, `OR`, `NOT`, `NEAR`, or `NEAR/N` won't be | |
| 17 | + | /// interpreted as FTS5 operators). Words are joined with spaces (implicit AND). | |
| 18 | + | /// | |
| 19 | + | /// Inside quoted strings FTS5 still honours two special characters: | |
| 20 | + | /// - `*` at the end of the last token triggers prefix matching | |
| 21 | + | /// - `^` at the start of the first token triggers beginning-of-column matching | |
| 22 | + | /// | |
| 23 | + | /// We strip those so user input like `^hello` or `world*` is treated literally. | |
| 24 | + | /// The `column:` prefix syntax (e.g. `title:word`) is neutralised by the quoting | |
| 25 | + | /// itself — colons inside double-quoted strings are treated as literal characters. | |
| 18 | 26 | fn sanitize_fts_query(query: &str) -> String { | |
| 19 | 27 | query | |
| 20 | 28 | .split_whitespace() | |
| 21 | 29 | .map(|word| { | |
| 30 | + | // Escape embedded double quotes (FTS5 uses "" to represent a literal ") | |
| 22 | 31 | let escaped = word.replace('"', "\"\""); | |
| 32 | + | // Strip `^` prefix and `*` suffix — both are special inside FTS5 quotes | |
| 33 | + | let escaped = escaped.trim_start_matches('^'); | |
| 34 | + | let escaped = escaped.trim_end_matches('*'); | |
| 23 | 35 | format!("\"{}\"", escaped) | |
| 24 | 36 | }) | |
| 37 | + | // Drop tokens that became empty after stripping (e.g. bare `^`, `*`, `^*`) | |
| 38 | + | .filter(|token| token != "\"\"") | |
| 25 | 39 | .collect::<Vec<_>>() | |
| 26 | 40 | .join(" ") | |
| 27 | 41 | } | |
| 28 | 42 | ||
| 43 | + | /// Number of consecutive failures before a feed is automatically disabled. | |
| 44 | + | /// | |
| 45 | + | /// Once a feed accumulates this many failures without a successful fetch, | |
| 46 | + | /// the circuit breaker trips: the feed is marked `circuit_broken = 1` and | |
| 47 | + | /// excluded from automatic fetch scheduling until manually reset. | |
| 48 | + | pub const CIRCUIT_BREAKER_THRESHOLD: i64 = 10; | |
| 49 | + | ||
| 29 | 50 | /// Repository for feed operations | |
| 30 | 51 | #[derive(Clone)] | |
| 31 | 52 | pub struct FeedsRepository { | |
| @@ -79,11 +100,13 @@ impl FeedsRepository { | |||
| 79 | 100 | .await | |
| 80 | 101 | } | |
| 81 | 102 | ||
| 82 | - | /// List only enabled feeds, ordered by name. | |
| 103 | + | /// List only enabled feeds that are not circuit-broken, ordered by name. | |
| 83 | 104 | pub async fn list_enabled(&self) -> Result<Vec<DbFeed>, sqlx::Error> { | |
| 84 | - | sqlx::query_as("SELECT * FROM feeds WHERE enabled = 1 ORDER BY name") | |
| 85 | - | .fetch_all(&self.pool) | |
| 86 | - | .await | |
| 105 | + | sqlx::query_as( | |
| 106 | + | "SELECT * FROM feeds WHERE enabled = 1 AND circuit_broken = 0 ORDER BY name", | |
| 107 | + | ) | |
| 108 | + | .fetch_all(&self.pool) | |
| 109 | + | .await | |
| 87 | 110 | } | |
| 88 | 111 | ||
| 89 | 112 | /// List every feed (enabled or disabled), ordered by name. | |
| @@ -131,11 +154,14 @@ impl FeedsRepository { | |||
| 131 | 154 | } | |
| 132 | 155 | ||
| 133 | 156 | /// Record a fetch failure: increment counter, store error, update timestamp. | |
| 157 | + | /// | |
| 158 | + | /// Returns `true` if the circuit breaker tripped (i.e. the feed just crossed | |
| 159 | + | /// the [`CIRCUIT_BREAKER_THRESHOLD`] and was marked `circuit_broken = 1`). | |
| 134 | 160 | pub async fn record_fetch_failure( | |
| 135 | 161 | &self, | |
| 136 | 162 | id: FeedId, | |
| 137 | 163 | error: &str, | |
| 138 | - | ) -> Result<(), sqlx::Error> { | |
| 164 | + | ) -> Result<bool, sqlx::Error> { | |
| 139 | 165 | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 140 | 166 | sqlx::query( | |
| 141 | 167 | "UPDATE feeds SET consecutive_failures = consecutive_failures + 1, \ | |
| @@ -146,6 +172,52 @@ impl FeedsRepository { | |||
| 146 | 172 | .bind(id) | |
| 147 | 173 | .execute(&self.pool) | |
| 148 | 174 | .await?; | |
| 175 | + | ||
| 176 | + | // Check if we just crossed the threshold | |
| 177 | + | let feed = self.get(id).await?; | |
| 178 | + | if let Some(feed) = feed { | |
| 179 | + | if !feed.circuit_broken | |
| 180 | + | && feed.consecutive_failures >= CIRCUIT_BREAKER_THRESHOLD | |
| 181 | + | { | |
| 182 | + | self.set_circuit_broken(id, true).await?; | |
| 183 | + | return Ok(true); | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | Ok(false) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | /// Mark a feed as circuit-broken (or clear the circuit breaker). | |
| 190 | + | pub async fn set_circuit_broken( | |
| 191 | + | &self, | |
| 192 | + | id: FeedId, | |
| 193 | + | broken: bool, | |
| 194 | + | ) -> Result<(), sqlx::Error> { | |
| 195 | + | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 196 | + | sqlx::query( | |
| 197 | + | "UPDATE feeds SET circuit_broken = ?1, updated_at = ?2 WHERE id = ?3", | |
| 198 | + | ) | |
| 199 | + | .bind(broken) | |
| 200 | + | .bind(&now) | |
| 201 | + | .bind(id) | |
| 202 | + | .execute(&self.pool) | |
| 203 | + | .await?; | |
| 204 | + | Ok(()) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// Reset the circuit breaker on a feed: clear `circuit_broken`, reset | |
| 208 | + | /// `consecutive_failures` to 0, and clear `last_error`. | |
| 209 | + | /// | |
| 210 | + | /// Called when a user manually triggers a fetch for a circuit-broken feed. | |
| 211 | + | pub async fn reset_circuit_breaker(&self, id: FeedId) -> Result<(), sqlx::Error> { | |
| 212 | + | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 213 | + | sqlx::query( | |
| 214 | + | "UPDATE feeds SET circuit_broken = 0, consecutive_failures = 0, \ | |
| 215 | + | last_error = NULL, updated_at = ?1 WHERE id = ?2", | |
| 216 | + | ) | |
| 217 | + | .bind(&now) | |
| 218 | + | .bind(id) | |
| 219 | + | .execute(&self.pool) | |
| 220 | + | .await?; | |
| 149 | 221 | Ok(()) | |
| 150 | 222 | } | |
| 151 | 223 | ||
| @@ -159,6 +231,18 @@ impl FeedsRepository { | |||
| 159 | 231 | Ok(()) | |
| 160 | 232 | } | |
| 161 | 233 | ||
| 234 | + | /// Update a feed's display name. | |
| 235 | + | pub async fn update_name(&self, id: FeedId, name: &str) -> Result<(), sqlx::Error> { | |
| 236 | + | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 237 | + | sqlx::query("UPDATE feeds SET name = ?1, updated_at = ?2 WHERE id = ?3") | |
| 238 | + | .bind(name) | |
| 239 | + | .bind(&now) | |
| 240 | + | .bind(id) | |
| 241 | + | .execute(&self.pool) | |
| 242 | + | .await?; | |
| 243 | + | Ok(()) | |
| 244 | + | } | |
| 245 | + | ||
| 162 | 246 | /// Delete a feed by ID. | |
| 163 | 247 | pub async fn delete(&self, id: FeedId) -> Result<(), sqlx::Error> { | |
| 164 | 248 | sqlx::query("DELETE FROM feeds WHERE id = ?1") | |
| @@ -349,6 +433,12 @@ impl ItemsRepository { | |||
| 349 | 433 | ) -> Result<Vec<DbFeedItem>, sqlx::Error> { | |
| 350 | 434 | let fts_query = sanitize_fts_query(query); | |
| 351 | 435 | ||
| 436 | + | // If sanitization stripped everything (e.g. query was just `*` or `^`), | |
| 437 | + | // return early — an empty MATCH expression would be a SQL error. | |
| 438 | + | if fts_query.is_empty() { | |
| 439 | + | return Ok(vec![]); | |
| 440 | + | } | |
| 441 | + | ||
| 352 | 442 | let mut sql = String::from( | |
| 353 | 443 | "SELECT fi.* FROM feed_items fi \ | |
| 354 | 444 | INNER JOIN feed_items_fts fts ON fi.rowid = fts.rowid \ | |
| @@ -668,6 +758,141 @@ impl StateRepository { | |||
| 668 | 758 | } | |
| 669 | 759 | } | |
| 670 | 760 | ||
| 761 | + | /// Repository for user_config key-value operations. | |
| 762 | + | /// | |
| 763 | + | /// Wraps the synced `user_config` table (migration 007) for app-level | |
| 764 | + | /// preferences like theme and welcome state. Unlike `StateRepository`, | |
| 765 | + | /// entries are not scoped by busser — just `(key, value)`. | |
| 766 | + | #[derive(Clone)] | |
| 767 | + | pub struct ConfigRepository { | |
| 768 | + | pool: SqlitePool, | |
| 769 | + | } | |
| 770 | + | ||
| 771 | + | impl ConfigRepository { | |
| 772 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 773 | + | Self { pool } | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | /// Get a config value by key. | |
| 777 | + | pub async fn get(&self, key: &str) -> Result<Option<String>, sqlx::Error> { | |
| 778 | + | let row: Option<(String,)> = | |
| 779 | + | sqlx::query_as("SELECT value FROM user_config WHERE key = ?1") | |
| 780 | + | .bind(key) | |
| 781 | + | .fetch_optional(&self.pool) | |
| 782 | + | .await?; | |
| 783 | + | Ok(row.map(|(v,)| v)) | |
| 784 | + | } | |
| 785 | + | ||
| 786 | + | /// Set a config value, inserting or updating on conflict. | |
| 787 | + | pub async fn set(&self, key: &str, value: &str) -> Result<(), sqlx::Error> { | |
| 788 | + | sqlx::query( | |
| 789 | + | r#" | |
| 790 | + | INSERT INTO user_config (key, value) | |
| 791 | + | VALUES (?1, ?2) | |
| 792 | + | ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value | |
| 793 | + | "#, | |
| 794 | + | ) | |
| 795 | + | .bind(key) | |
| 796 | + | .bind(value) | |
| 797 | + | .execute(&self.pool) | |
| 798 | + | .await?; | |
| 799 | + | Ok(()) | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | /// Delete a config entry by key. | |
| 803 | + | pub async fn delete(&self, key: &str) -> Result<(), sqlx::Error> { | |
| 804 | + | sqlx::query("DELETE FROM user_config WHERE key = ?1") | |
| 805 | + | .bind(key) | |
| 806 | + | .execute(&self.pool) | |
| 807 | + | .await?; | |
| 808 | + | Ok(()) | |
| 809 | + | } | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | /// Repository for query feed operations. | |
| 813 | + | /// | |
| 814 | + | /// Query feeds are saved filter rules that act as virtual sources. | |
| 815 | + | /// Each query feed stores a JSON array of conditions that are translated | |
| 816 | + | /// to a [`FeedFilter`] at query time. | |
| 817 | + | #[derive(Clone)] | |
| 818 | + | pub struct QueryFeedsRepository { | |
| 819 | + | pool: SqlitePool, | |
| 820 | + | } | |
| 821 | + | ||
| 822 | + | impl QueryFeedsRepository { | |
| 823 | + | pub fn new(pool: SqlitePool) -> Self { | |
| 824 | + | Self { pool } | |
| 825 | + | } | |
| 826 | + | ||
| 827 | + | /// Insert a new query feed and return the created row. | |
| 828 | + | pub async fn create(&self, input: CreateQueryFeed) -> Result<DbQueryFeed, sqlx::Error> { | |
| 829 | + | let id = QueryFeedId::new(); | |
| 830 | + | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 831 | + | let rules_json = | |
| 832 | + | serde_json::to_string(&input.rules).unwrap_or_else(|_| "[]".to_string()); | |
| 833 | + | ||
| 834 | + | sqlx::query_as( | |
| 835 | + | r#" | |
| 836 | + | INSERT INTO query_feeds (id, name, rules, created_at, updated_at) | |
| 837 | + | VALUES (?1, ?2, ?3, ?4, ?4) | |
| 838 | + | RETURNING * | |
| 839 | + | "#, | |
| 840 | + | ) | |
| 841 | + | .bind(id) | |
| 842 | + | .bind(&input.name) | |
| 843 | + | .bind(&rules_json) | |
| 844 | + | .bind(&now) | |
| 845 | + | .fetch_one(&self.pool) | |
| 846 | + | .await | |
| 847 | + | } | |
| 848 | + | ||
| 849 | + | /// Look up a single query feed by ID. Returns `None` if not found. | |
| 850 | + | pub async fn get(&self, id: QueryFeedId) -> Result<Option<DbQueryFeed>, sqlx::Error> { | |
| 851 | + | sqlx::query_as("SELECT * FROM query_feeds WHERE id = ?1") | |
| 852 | + | .bind(id) | |
| 853 | + | .fetch_optional(&self.pool) | |
| 854 | + | .await | |
| 855 | + | } | |
| 856 | + | ||
| 857 | + | /// List all query feeds, ordered by name. | |
| 858 | + | pub async fn list_all(&self) -> Result<Vec<DbQueryFeed>, sqlx::Error> { | |
| 859 | + | sqlx::query_as("SELECT * FROM query_feeds ORDER BY name") | |
| 860 | + | .fetch_all(&self.pool) | |
| 861 | + | .await | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | /// Update a query feed's name and rules. | |
| 865 | + | pub async fn update( | |
| 866 | + | &self, | |
| 867 | + | id: QueryFeedId, | |
| 868 | + | name: &str, | |
| 869 | + | rules: &[QueryCondition], | |
| 870 | + | ) -> Result<(), sqlx::Error> { | |
| 871 | + | let now = Utc::now().format(TIMESTAMP_FMT).to_string(); | |
| 872 | + | let rules_json = serde_json::to_string(rules).unwrap_or_else(|_| "[]".to_string()); | |
| 873 | + | ||
| 874 | + | sqlx::query( | |
| 875 | + | "UPDATE query_feeds SET name = ?1, rules = ?2, updated_at = ?3 WHERE id = ?4", | |
| 876 | + | ) | |
| 877 | + | .bind(name) | |
| 878 | + | .bind(&rules_json) | |
| 879 | + | .bind(&now) | |
| 880 | + | .bind(id) | |
| 881 | + | .execute(&self.pool) | |
| 882 | + | .await?; | |
| 883 | + | Ok(()) | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | /// Delete a query feed by ID. | |
| 887 | + | pub async fn delete(&self, id: QueryFeedId) -> Result<(), sqlx::Error> { | |
| 888 | + | sqlx::query("DELETE FROM query_feeds WHERE id = ?1") | |
| 889 | + | .bind(id) | |
| 890 | + | .execute(&self.pool) | |
| 891 | + | .await?; | |
| 892 | + | Ok(()) | |
| 893 | + | } | |
| 894 | + | } | |
| 895 | + | ||
| 671 | 896 | #[cfg(test)] | |
| 672 | 897 | mod tests { | |
| 673 | 898 | use super::*; | |
| @@ -1336,6 +1561,215 @@ mod tests { | |||
| 1336 | 1561 | assert_eq!(results.len(), 0); // no match but no crash | |
| 1337 | 1562 | } | |
| 1338 | 1563 | ||
| 1564 | + | // ── sanitize_fts_query unit tests ──────────────────────────── | |
| 1565 | + | ||
| 1566 | + | #[test] | |
| 1567 | + | fn fts5_sanitize_basic_words() { | |
| 1568 | + | assert_eq!(sanitize_fts_query("hello world"), r#""hello" "world""#); | |
| 1569 | + | } | |
| 1570 | + | ||
| 1571 | + | #[test] | |
| 1572 | + | fn fts5_sanitize_operators_quoted() { | |
| 1573 | + | // AND, OR, NOT become quoted literal strings | |
| 1574 | + | assert_eq!( | |
| 1575 | + | sanitize_fts_query("AND OR NOT"), | |
| 1576 | + | r#""AND" "OR" "NOT""#, | |
| 1577 | + | ); | |
| 1578 | + | } | |
| 1579 | + | ||
| 1580 | + | #[test] | |
| 1581 | + | fn fts5_sanitize_near_operator() { | |
| 1582 | + | assert_eq!(sanitize_fts_query("NEAR"), r#""NEAR""#); | |
| 1583 | + | assert_eq!(sanitize_fts_query("NEAR/3"), r#""NEAR/3""#); | |
| 1584 | + | assert_eq!(sanitize_fts_query("word NEAR/5 other"), r#""word" "NEAR/5" "other""#); | |
| 1585 | + | } | |
| 1586 | + | ||
| 1587 | + | #[test] | |
| 1588 | + | fn fts5_sanitize_column_prefix() { | |
| 1589 | + | // column:term syntax — colon inside quotes is literal | |
| 1590 | + | assert_eq!(sanitize_fts_query("title:rust"), r#""title:rust""#); | |
| 1591 | + | assert_eq!( | |
| 1592 | + | sanitize_fts_query("body:hello title:world"), | |
| 1593 | + | r#""body:hello" "title:world""#, | |
| 1594 | + | ); | |
| 1595 | + | } | |
| 1596 | + | ||
| 1597 | + | #[test] | |
| 1598 | + | fn fts5_sanitize_caret_stripped() { | |
| 1599 | + | // ^ is beginning-of-column marker, must be stripped | |
| 1600 | + | assert_eq!(sanitize_fts_query("^hello"), r#""hello""#); | |
| 1601 | + | assert_eq!(sanitize_fts_query("^hello world"), r#""hello" "world""#); | |
| 1602 | + | // Multiple carets | |
| 1603 | + | assert_eq!(sanitize_fts_query("^^hello"), r#""hello""#); | |
| 1604 | + | } | |
| 1605 | + | ||
| 1606 | + | #[test] | |
| 1607 | + | fn fts5_sanitize_star_stripped() { | |
| 1608 | + | // * is prefix query suffix, must be stripped | |
| 1609 | + | assert_eq!(sanitize_fts_query("hello*"), r#""hello""#); | |
| 1610 | + | assert_eq!(sanitize_fts_query("hel*"), r#""hel""#); | |
| 1611 | + | // Multiple stars | |
| 1612 | + | assert_eq!(sanitize_fts_query("hello**"), r#""hello""#); | |
| 1613 | + | } | |
| 1614 | + | ||
| 1615 | + | #[test] | |
| 1616 | + | fn fts5_sanitize_caret_and_star_combined() { | |
| 1617 | + | assert_eq!(sanitize_fts_query("^hello*"), r#""hello""#); | |
| 1618 | + | assert_eq!(sanitize_fts_query("^*"), ""); | |
| 1619 | + | } | |
| 1620 | + | ||
| 1621 | + | #[test] | |
| 1622 | + | fn fts5_sanitize_bare_special_chars_dropped() { | |
| 1623 | + | // Bare `^`, `*`, `^*` should produce empty string (dropped) | |
| 1624 | + | assert_eq!(sanitize_fts_query("^"), ""); | |
| 1625 | + | assert_eq!(sanitize_fts_query("*"), ""); | |
| 1626 | + | assert_eq!(sanitize_fts_query("^ word"), r#""word""#); | |
| 1627 | + | assert_eq!(sanitize_fts_query("* word"), r#""word""#); | |
| 1628 | + | } | |
| 1629 | + | ||
| 1630 | + | #[test] | |
| 1631 | + | fn fts5_sanitize_embedded_quotes() { | |
| 1632 | + | // Embedded double quotes are escaped as "" | |
| 1633 | + | // Input: say "hi" -> tokens: [say] ["hi"] | |
| 1634 | + | // "hi" has two quotes -> each becomes "" -> ""hi"" -> wrapped: """hi""" | |
| 1635 | + | assert_eq!( | |
| 1636 | + | sanitize_fts_query("say \"hi\""), | |
| 1637 | + | "\"say\" \"\"\"hi\"\"\"", | |
| 1638 | + | ); | |
| 1639 | + | } | |
| 1640 | + | ||
| 1641 | + | #[test] | |
| 1642 | + | fn fts5_sanitize_empty_and_whitespace() { | |
| 1643 | + | assert_eq!(sanitize_fts_query(""), ""); | |
| 1644 | + | assert_eq!(sanitize_fts_query(" "), ""); | |
| 1645 | + | } | |
| 1646 | + | ||
| 1647 | + | #[test] | |
| 1648 | + | fn fts5_sanitize_mixed_special_syntax() { | |
| 1649 | + | // Realistic adversarial input combining multiple FTS5 features | |
| 1650 | + | assert_eq!( | |
| 1651 | + | sanitize_fts_query("^title:rust* NEAR/3 OR body:hello*"), | |
| 1652 | + | r#""title:rust" "NEAR/3" "OR" "body:hello""#, | |
| 1653 | + | ); | |
| 1654 | + | } | |
| 1655 | + | ||
| 1656 | + | // ── FTS5 integration tests for hardened sanitization ───────── | |
| 1657 | + | ||
| 1658 | + | #[tokio::test] | |
| 1659 | + | async fn fts5_near_operator_does_not_crash() { | |
| 1660 | + | let pool = test_db().await; | |
| 1661 | + | let feed = make_feed(&pool, "rss", "Feed").await; | |
| 1662 | + | let items_repo = ItemsRepository::new(pool.clone()); | |
| 1663 | + | make_item(&pool, &feed, "fts:near1").await; | |
| 1664 | + | ||
| 1665 | + | // NEAR and NEAR/N should be safely quoted | |
| 1666 | + | let results = items_repo | |
| 1667 | + | .list_search("NEAR", None, false, false, 10, 0) | |
| 1668 | + | .await | |
| 1669 | + | .unwrap(); | |
| 1670 | + | assert_eq!(results.len(), 0); | |
| 1671 | + | ||
| 1672 | + | let results = items_repo | |
| 1673 | + | .list_search("word NEAR/3 other", None, false, false, 10, 0) | |
| 1674 | + | .await | |
| 1675 | + | .unwrap(); | |
| 1676 | + | assert_eq!(results.len(), 0); | |
| 1677 | + | } | |
| 1678 | + | ||
| 1679 | + | #[tokio::test] | |
| 1680 | + | async fn fts5_column_prefix_does_not_target_column() { | |
| 1681 | + | let pool = test_db().await; | |
| 1682 | + | let feed = make_feed(&pool, "rss", "Feed").await; | |
| 1683 | + | let items_repo = ItemsRepository::new(pool.clone()); | |
| 1684 | + | ||
| 1685 | + | items_repo | |
| 1686 | + | .upsert(CreateFeedItem { | |
| 1687 | + | external_id: "fts:col1".to_string(), | |
| 1688 | + | feed_id: feed.id, | |
| 1689 | + | busser_id: BusserId::new("rss"), | |
| 1690 | + | bite_author: "author".to_string(), | |
| 1691 | + | bite_text: "bite".to_string(), | |
| 1692 | + | bite_secondary: None, | |
| 1693 | + | bite_indicator: None, | |
| 1694 | + | title: Some("rust programming".to_string()), | |
| 1695 | + | body: Some("unrelated body".to_string()), | |
| 1696 | + | url: None, | |
| 1697 | + | media: vec![], | |
| 1698 | + | published_at: Utc::now(), | |
| 1699 | + | source_name: "test".to_string(), | |
| 1700 | + | score: None, | |
| 1701 | + | tags: vec![], | |
| 1702 | + | }) | |
| 1703 | + | .await | |
| 1704 | + | .unwrap(); | |
| 1705 | + | ||
| 1706 | + | // "title:rust" should NOT act as a column filter — it should search | |
| 1707 | + | // for the literal string "title:rust" which won't match anything | |
| 1708 | + | let results = items_repo | |
| 1709 | + | .list_search("title:rust", None, false, false, 10, 0) | |
| 1710 | + | .await | |
| 1711 | + | .unwrap(); | |
| 1712 | + | assert_eq!(results.len(), 0); | |
| 1713 | + | } | |
| 1714 | + | ||
| 1715 | + | #[tokio::test] | |
| 1716 | + | async fn fts5_caret_prefix_does_not_crash() { | |
| 1717 | + | let pool = test_db().await; | |
| 1718 | + | let feed = make_feed(&pool, "rss", "Feed").await; | |
| 1719 | + | let items_repo = ItemsRepository::new(pool.clone()); | |
| 1720 | + | make_item(&pool, &feed, "fts:caret1").await; | |
| 1721 | + | ||
| 1722 | + | let results = items_repo | |
| 1723 | + | .list_search("^Title", None, false, false, 10, 0) | |
| 1724 | + | .await | |
| 1725 | + | .unwrap(); | |
| 1726 | + | // Caret is stripped, so this searches for "Title" which matches our items | |
| 1727 | + | assert!(!results.is_empty()); | |
| 1728 | + | } | |
| 1729 | + | ||
| 1730 | + | #[tokio::test] | |
| 1731 | + | async fn fts5_star_suffix_does_not_crash() { | |
| 1732 | + | let pool = test_db().await; | |
| 1733 | + | let feed = make_feed(&pool, "rss", "Feed").await; | |
| 1734 | + | let items_repo = ItemsRepository::new(pool.clone()); | |
| 1735 | + | make_item(&pool, &feed, "fts:star1").await; | |
| 1736 | + | ||
| 1737 | + | let results = items_repo | |
| 1738 | + | .list_search("Titl*", None, false, false, 10, 0) | |
| 1739 | + | .await | |
| 1740 | + | .unwrap(); | |
| 1741 | + | // Star is stripped, so this searches for "Titl" which won't match "Title" | |
| 1742 | + | // (exact token match, not prefix) | |
| 1743 | + | assert_eq!(results.len(), 0); | |
| 1744 | + | } | |
| 1745 | + | ||
| 1746 | + | #[tokio::test] | |
| 1747 | + | async fn fts5_bare_star_and_caret_safe() { | |
| 1748 | + | let pool = test_db().await; | |
| 1749 | + | let feed = make_feed(&pool, "rss", "Feed").await; | |
| 1750 | + | let items_repo = ItemsRepository::new(pool.clone()); | |
| 1751 | + | make_item(&pool, &feed, "fts:bare1").await; | |
| 1752 | + | ||
| 1753 | + | // Bare special chars should not crash | |
| 1754 | + | let results = items_repo | |
| 1755 | + | .list_search("*", None, false, false, 10, 0) |
Lines truncated
| @@ -12,6 +12,7 @@ tokio.workspace = true | |||
| 12 | 12 | thiserror.workspace = true | |
| 13 | 13 | tracing.workspace = true | |
| 14 | 14 | chrono.workspace = true | |
| 15 | + | regex = "1" | |
| 15 | 16 | ||
| 16 | 17 | [dev-dependencies] | |
| 17 | 18 | serde_json.workspace = true |
| @@ -42,6 +42,8 @@ pub struct SourceInfo { | |||
| 42 | 42 | pub consecutive_failures: i64, | |
| 43 | 43 | /// Error message from the last failed fetch. | |
| 44 | 44 | pub last_error: Option<String>, | |
| 45 | + | /// Whether the circuit breaker has tripped for this feed. | |
| 46 | + | pub circuit_broken: bool, | |
| 45 | 47 | } | |
| 46 | 48 | ||
| 47 | 49 | /// The feed generator aggregates and orders items from the database | |
| @@ -171,16 +173,22 @@ impl FeedGenerator { | |||
| 171 | 173 | feed_items.retain(|item| matching_busser_ids.contains(&item.id.source)); | |
| 172 | 174 | } | |
| 173 | 175 | ||
| 174 | - | // Apply remaining in-memory filters (tags — not yet in SQL) and sorting. | |
| 176 | + | // Apply remaining in-memory filters (tags, query feed conditions) and sorting. | |
| 175 | 177 | // Search and source/unread/starred are already handled by the query above. | |
| 176 | 178 | if !self.filter.tags.is_empty() { | |
| 177 | 179 | feed_items = self.filter.apply_tags_only(feed_items); | |
| 178 | 180 | } | |
| 181 | + | ||
| 182 | + | // Apply query feed conditions that require in-memory evaluation | |
| 183 | + | // (title/author/body contains, not_contains, equals, matches_regex). | |
| 184 | + | if !self.filter.conditions.is_empty() { | |
| 185 | + | feed_items.retain(|item| self.filter.matches(item)); | |
| 186 | + | } | |
| 179 | 187 | self.order_by.apply(&mut feed_items); | |
| 180 | 188 | let has_more = feed_items.len() > self.page_size as usize; | |
| 181 | 189 | feed_items.truncate(self.page_size as usize); | |
| 182 | 190 | ||
| 183 | - | debug!("Returning {} items for page {} (has_more={})", feed_items.len(), page, has_more); | |
| 191 | + | debug!(count = feed_items.len(), page, has_more, "Returning items"); | |
| 184 | 192 | Ok(PaginatedItems { | |
| 185 | 193 | items: feed_items, | |
| 186 | 194 | has_more, | |
| @@ -271,6 +279,7 @@ impl FeedGenerator { | |||
| 271 | 279 | tags, | |
| 272 | 280 | consecutive_failures: feed.consecutive_failures, | |
| 273 | 281 | last_error: feed.last_error, | |
| 282 | + | circuit_broken: feed.circuit_broken, | |
| 274 | 283 | } | |
| 275 | 284 | }) | |
| 276 | 285 | .collect(); | |
| @@ -795,4 +804,516 @@ mod tests { | |||
| 795 | 804 | let sources = gen.get_sources().await.unwrap(); | |
| 796 | 805 | assert!(sources.is_empty()); | |
| 797 | 806 | } | |
| 807 | + | ||
| 808 | + | // ── In-memory filtering & ordering in get_items ───────────── | |
| 809 | + | ||
| 810 | + | /// Seed an item with a score for ordering tests. | |
| 811 | + | async fn seed_scored_item( | |
| 812 | + | db: &Database, | |
| 813 | + | feed: &bb_db::DbFeed, | |
| 814 | + | external_id: &str, | |
| 815 | + | hours_ago: i64, | |
| 816 | + | score: Option<i64>, | |
| 817 | + | ) -> bb_db::DbFeedItem { | |
| 818 | + | db.items() | |
| 819 | + | .upsert(CreateFeedItem { | |
| 820 | + | external_id: external_id.to_string(), | |
| 821 | + | feed_id: feed.id, | |
| 822 | + | busser_id: feed.busser_id.clone(), | |
| 823 | + | bite_author: "author".to_string(), | |
| 824 | + | bite_text: format!("Item {external_id}"), | |
| 825 | + | bite_secondary: None, | |
| 826 | + | bite_indicator: None, | |
| 827 | + | title: Some(format!("Title {external_id}")), | |
| 828 | + | body: None, | |
| 829 | + | url: None, | |
| 830 | + | media: vec![], | |
| 831 | + | published_at: Utc::now() - Duration::hours(hours_ago), | |
| 832 | + | source_name: "test".to_string(), | |
| 833 | + | score, | |
| 834 | + | tags: vec![], | |
| 835 | + | }) | |
| 836 | + | .await | |
| 837 | + | .unwrap() | |
| 838 | + | } | |
| 839 | + | ||
| 840 | + | /// Seed an item with item-level tags. | |
| 841 | + | async fn seed_tagged_item( | |
| 842 | + | db: &Database, | |
| 843 | + | feed: &bb_db::DbFeed, | |
| 844 | + | external_id: &str, | |
| 845 | + | hours_ago: i64, | |
| 846 | + | tags: Vec<String>, | |
| 847 | + | ) -> bb_db::DbFeedItem { | |
| 848 | + | db.items() | |
| 849 | + | .upsert(CreateFeedItem { | |
| 850 | + | external_id: external_id.to_string(), | |
| 851 | + | feed_id: feed.id, | |
| 852 | + | busser_id: feed.busser_id.clone(), | |
| 853 | + | bite_author: "author".to_string(), | |
| 854 | + | bite_text: format!("Item {external_id}"), | |
| 855 | + | bite_secondary: None, | |
| 856 | + | bite_indicator: None, | |
| 857 | + | title: Some(format!("Title {external_id}")), | |
| 858 | + | body: Some(format!("Body of {external_id}")), | |
| 859 | + | url: None, | |
| 860 | + | media: vec![], | |
| 861 | + | published_at: Utc::now() - Duration::hours(hours_ago), | |
| 862 | + | source_name: "test".to_string(), | |
| 863 | + | score: None, | |
| 864 | + | tags, | |
| 865 | + | }) | |
| 866 | + | .await | |
| 867 | + | .unwrap() | |
| 868 | + | } | |
| 869 | + | ||
| 870 | + | // ── Ordering within get_items ──────────────────────────────── | |
| 871 | + | ||
| 872 | + | #[tokio::test] | |
| 873 | + | async fn get_items_chronological_ordering() { | |
| 874 | + | let db = test_db().await; | |
| 875 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 876 | + | seed_item(&db, &feed, "rss:old", 10).await; | |
| 877 | + | seed_item(&db, &feed, "rss:mid", 5).await; | |
| 878 | + | seed_item(&db, &feed, "rss:new", 1).await; | |
| 879 | + | ||
| 880 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::Chronological); | |
| 881 | + | let result = gen.get_items(0).await.unwrap(); | |
| 882 | + | assert_eq!(result.items[0].id.item_id, "rss:new"); | |
| 883 | + | assert_eq!(result.items[1].id.item_id, "rss:mid"); | |
| 884 | + | assert_eq!(result.items[2].id.item_id, "rss:old"); | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | #[tokio::test] | |
| 888 | + | async fn get_items_score_ordering() { | |
| 889 | + | let db = test_db().await; | |
| 890 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 891 | + | seed_scored_item(&db, &feed, "rss:low", 1, Some(10)).await; | |
| 892 | + | seed_scored_item(&db, &feed, "rss:high", 2, Some(100)).await; | |
| 893 | + | seed_scored_item(&db, &feed, "rss:none", 3, None).await; | |
| 894 | + | ||
| 895 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::Score); | |
| 896 | + | let result = gen.get_items(0).await.unwrap(); | |
| 897 | + | assert_eq!(result.items[0].id.item_id, "rss:high"); | |
| 898 | + | assert_eq!(result.items[1].id.item_id, "rss:low"); | |
| 899 | + | assert_eq!(result.items[2].id.item_id, "rss:none"); | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | #[tokio::test] | |
| 903 | + | async fn get_items_score_tiebreak_by_date() { | |
| 904 | + | let db = test_db().await; | |
| 905 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 906 | + | seed_scored_item(&db, &feed, "rss:old_50", 10, Some(50)).await; | |
| 907 | + | seed_scored_item(&db, &feed, "rss:new_50", 1, Some(50)).await; | |
| 908 | + | ||
| 909 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::Score); | |
| 910 | + | let result = gen.get_items(0).await.unwrap(); | |
| 911 | + | // Same score, newer item should come first | |
| 912 | + | assert_eq!(result.items[0].id.item_id, "rss:new_50"); | |
| 913 | + | assert_eq!(result.items[1].id.item_id, "rss:old_50"); | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | #[tokio::test] | |
| 917 | + | async fn get_items_unread_first_ordering() { | |
| 918 | + | let db = test_db().await; | |
| 919 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 920 | + | let read_item = seed_item(&db, &feed, "rss:read", 1).await; | |
| 921 | + | seed_item(&db, &feed, "rss:unread1", 2).await; | |
| 922 | + | seed_item(&db, &feed, "rss:unread2", 3).await; | |
| 923 | + | ||
| 924 | + | db.items().mark_read(read_item.id, true).await.unwrap(); | |
| 925 | + | ||
| 926 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::UnreadFirst); | |
| 927 | + | let result = gen.get_items(0).await.unwrap(); | |
| 928 | + | assert_eq!(result.items.len(), 3); | |
| 929 | + | // Items should be grouped by read status: the sort uses | |
| 930 | + | // b.is_read.cmp(&a.is_read) which groups items by read state, | |
| 931 | + | // with chronological tiebreak within each group. | |
| 932 | + | let read_states: Vec<bool> = result.items.iter().map(|i| i.is_read).collect(); | |
| 933 | + | // Verify grouping: all items of one read-state come before the other | |
| 934 | + | let first_state = read_states[0]; | |
| 935 | + | let boundary = read_states.iter().position(|&r| r != first_state); | |
| 936 | + | if let Some(b) = boundary { | |
| 937 | + | assert!(read_states[b..].iter().all(|&r| r != first_state), | |
| 938 | + | "read states should be grouped, not interleaved"); | |
| 939 | + | } | |
| 940 | + | } | |
| 941 | + | ||
| 942 | + | #[tokio::test] | |
| 943 | + | async fn get_items_starred_first_ordering() { | |
| 944 | + | let db = test_db().await; | |
| 945 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 946 | + | seed_item(&db, &feed, "rss:normal", 1).await; | |
| 947 | + | let starred_item = seed_item(&db, &feed, "rss:starred", 2).await; | |
| 948 | + | ||
| 949 | + | db.items().mark_starred(starred_item.id, true).await.unwrap(); | |
| 950 | + | ||
| 951 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::StarredFirst); | |
| 952 | + | let result = gen.get_items(0).await.unwrap(); | |
| 953 | + | assert!(result.items[0].is_starred, "first item should be starred"); | |
| 954 | + | assert!(!result.items[1].is_starred, "second item should not be starred"); | |
| 955 | + | } | |
| 956 | + | ||
| 957 | + | // ── Item-level tag filtering within get_items ──────────────── | |
| 958 | + | ||
| 959 | + | #[tokio::test] | |
| 960 | + | async fn get_items_tag_filter_keeps_matching() { | |
| 961 | + | let db = test_db().await; | |
| 962 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 963 | + | seed_tagged_item(&db, &feed, "rss:rust", 1, vec!["rust".into()]).await; | |
| 964 | + | seed_tagged_item(&db, &feed, "rss:python", 2, vec!["python".into()]).await; | |
| 965 | + | seed_tagged_item(&db, &feed, "rss:both", 3, vec!["rust".into(), "go".into()]).await; | |
| 966 | + | ||
| 967 | + | let gen = FeedGenerator::new(db) | |
| 968 | + | .with_filter(FeedFilter::new().with_tag("rust")); | |
| 969 | + | let result = gen.get_items(0).await.unwrap(); | |
| 970 | + | assert_eq!(result.items.len(), 2); | |
| 971 | + | let ids: Vec<&str> = result.items.iter().map(|i| i.id.item_id.as_str()).collect(); | |
| 972 | + | assert!(ids.contains(&"rss:rust")); | |
| 973 | + | assert!(ids.contains(&"rss:both")); | |
| 974 | + | } | |
| 975 | + | ||
| 976 | + | #[tokio::test] | |
| 977 | + | async fn get_items_tag_filter_no_match_returns_empty() { | |
| 978 | + | let db = test_db().await; | |
| 979 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 980 | + | seed_tagged_item(&db, &feed, "rss:1", 1, vec!["python".into()]).await; | |
| 981 | + | ||
| 982 | + | let gen = FeedGenerator::new(db) | |
| 983 | + | .with_filter(FeedFilter::new().with_tag("rust")); | |
| 984 | + | let result = gen.get_items(0).await.unwrap(); | |
| 985 | + | assert!(result.items.is_empty()); | |
| 986 | + | } | |
| 987 | + | ||
| 988 | + | #[tokio::test] | |
| 989 | + | async fn get_items_tag_filter_or_logic() { | |
| 990 | + | let db = test_db().await; | |
| 991 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 992 | + | seed_tagged_item(&db, &feed, "rss:r", 1, vec!["rust".into()]).await; | |
| 993 | + | seed_tagged_item(&db, &feed, "rss:g", 2, vec!["go".into()]).await; | |
| 994 | + | seed_tagged_item(&db, &feed, "rss:p", 3, vec!["python".into()]).await; | |
| 995 | + | ||
| 996 | + | // Filter with two tags -- OR logic: items matching either tag pass | |
| 997 | + | let gen = FeedGenerator::new(db) | |
| 998 | + | .with_filter(FeedFilter::new().with_tag("rust").with_tag("go")); | |
| 999 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1000 | + | assert_eq!(result.items.len(), 2); | |
| 1001 | + | } | |
| 1002 | + | ||
| 1003 | + | #[tokio::test] | |
| 1004 | + | async fn get_items_tag_filter_items_with_no_tags_excluded() { | |
| 1005 | + | let db = test_db().await; | |
| 1006 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1007 | + | seed_item(&db, &feed, "rss:notagged", 1).await; // No tags | |
| 1008 | + | seed_tagged_item(&db, &feed, "rss:tagged", 2, vec!["rust".into()]).await; | |
| 1009 | + | ||
| 1010 | + | let gen = FeedGenerator::new(db) | |
| 1011 | + | .with_filter(FeedFilter::new().with_tag("rust")); | |
| 1012 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1013 | + | assert_eq!(result.items.len(), 1); | |
| 1014 | + | assert_eq!(result.items[0].id.item_id, "rss:tagged"); | |
| 1015 | + | } | |
| 1016 | + | ||
| 1017 | + | // ── has_more correctness after in-memory tag filtering ────── | |
| 1018 | + | ||
| 1019 | + | #[tokio::test] | |
| 1020 | + | async fn has_more_false_when_tag_filter_reduces_below_page_size() { | |
| 1021 | + | let db = test_db().await; | |
| 1022 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1023 | + | // Create page_size+1 items, but only 1 has the matching tag. | |
| 1024 | + | // The matching item must be recent enough to fall within the | |
| 1025 | + | // SQL LIMIT window (list_all orders by published_at DESC). | |
| 1026 | + | seed_tagged_item(&db, &feed, "rss:yes", 0, vec!["rust".into()]).await; | |
| 1027 | + | for i in 1..=4 { | |
| 1028 | + | seed_tagged_item(&db, &feed, &format!("rss:no_{i}"), i as i64, vec!["python".into()]).await; | |
| 1029 | + | } | |
| 1030 | + | ||
| 1031 | + | let gen = FeedGenerator::new(db) | |
| 1032 | + | .with_page_size(3) | |
| 1033 | + | .with_filter(FeedFilter::new().with_tag("rust")); | |
| 1034 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1035 | + | assert_eq!(result.items.len(), 1); | |
| 1036 | + | assert!(!result.has_more); | |
| 1037 | + | } | |
| 1038 | + | ||
| 1039 | + | // ── Source filter within get_items ─────────────────────────── | |
| 1040 | + | ||
| 1041 | + | #[tokio::test] | |
| 1042 | + | async fn get_items_source_filter() { | |
| 1043 | + | let db = test_db().await; | |
| 1044 | + | let feed_a = seed_feed(&db, "rss", "RSS").await; | |
| 1045 | + | let feed_b = seed_feed(&db, "hn", "HN").await; | |
| 1046 | + | seed_item(&db, &feed_a, "rss:1", 1).await; | |
| 1047 | + | seed_item(&db, &feed_a, "rss:2", 2).await; | |
| 1048 | + | seed_item(&db, &feed_b, "hn:1", 3).await; | |
| 1049 | + | ||
| 1050 | + | let gen = FeedGenerator::new(db) | |
| 1051 | + | .with_filter(FeedFilter::new().source("rss")); | |
| 1052 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1053 | + | assert_eq!(result.items.len(), 2); | |
| 1054 | + | assert!(result.items.iter().all(|i| i.id.source == "rss")); | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | // ── Search filter within get_items ────────────────────────── | |
| 1058 | + | ||
| 1059 | + | #[tokio::test] | |
| 1060 | + | async fn get_items_search_filter_matches_title() { | |
| 1061 | + | let db = test_db().await; | |
| 1062 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1063 | + | seed_item(&db, &feed, "rss:1", 1).await; // Title: "Title rss:1" | |
| 1064 | + | seed_item(&db, &feed, "rss:2", 2).await; // Title: "Title rss:2" | |
| 1065 | + | ||
| 1066 | + | let gen = FeedGenerator::new(db) | |
| 1067 | + | .with_filter(FeedFilter::new().search("Title rss:1")); | |
| 1068 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1069 | + | assert_eq!(result.items.len(), 1); | |
| 1070 | + | assert_eq!(result.items[0].id.item_id, "rss:1"); | |
| 1071 | + | } | |
| 1072 | + | ||
| 1073 | + | #[tokio::test] | |
| 1074 | + | async fn get_items_search_filter_no_match() { | |
| 1075 | + | let db = test_db().await; | |
| 1076 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1077 | + | seed_item(&db, &feed, "rss:1", 1).await; | |
| 1078 | + | ||
| 1079 | + | let gen = FeedGenerator::new(db) | |
| 1080 | + | .with_filter(FeedFilter::new().search("zzz_nonexistent_zzz")); | |
| 1081 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1082 | + | assert!(result.items.is_empty()); | |
| 1083 | + | } | |
| 1084 | + | ||
| 1085 | + | // ── Combined filters within get_items ─────────────────────── | |
| 1086 | + | ||
| 1087 | + | #[tokio::test] | |
| 1088 | + | async fn get_items_search_with_source_filter() { | |
| 1089 | + | let db = test_db().await; | |
| 1090 | + | let feed_a = seed_feed(&db, "rss", "RSS").await; | |
| 1091 | + | let feed_b = seed_feed(&db, "hn", "HN").await; | |
| 1092 | + | seed_item(&db, &feed_a, "rss:match", 1).await; | |
| 1093 | + | seed_item(&db, &feed_b, "hn:match", 2).await; | |
| 1094 | + | ||
| 1095 | + | // Search that matches both, but restricted to rss source | |
| 1096 | + | let gen = FeedGenerator::new(db) | |
| 1097 | + | .with_filter(FeedFilter::new().search("Item").source("rss")); | |
| 1098 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1099 | + | assert_eq!(result.items.len(), 1); | |
| 1100 | + | assert_eq!(result.items[0].id.source, "rss"); | |
| 1101 | + | } | |
| 1102 | + | ||
| 1103 | + | #[tokio::test] | |
| 1104 | + | async fn get_items_search_with_unread_filter() { | |
| 1105 | + | let db = test_db().await; | |
| 1106 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1107 | + | let item1 = seed_item(&db, &feed, "rss:1", 1).await; | |
| 1108 | + | seed_item(&db, &feed, "rss:2", 2).await; | |
| 1109 | + | ||
| 1110 | + | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 1111 | + | ||
| 1112 | + | // Search matches both items, but only unread should appear | |
| 1113 | + | let gen = FeedGenerator::new(db) | |
| 1114 | + | .with_filter(FeedFilter::new().search("Item").unread_only()); | |
| 1115 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1116 | + | assert_eq!(result.items.len(), 1); | |
| 1117 | + | assert!(!result.items[0].is_read); | |
| 1118 | + | } | |
| 1119 | + | ||
| 1120 | + | #[tokio::test] | |
| 1121 | + | async fn get_items_search_with_starred_filter() { | |
| 1122 | + | let db = test_db().await; | |
| 1123 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1124 | + | seed_item(&db, &feed, "rss:1", 1).await; | |
| 1125 | + | let item2 = seed_item(&db, &feed, "rss:2", 2).await; | |
| 1126 | + | ||
| 1127 | + | db.items().mark_starred(item2.id, true).await.unwrap(); | |
| 1128 | + | ||
| 1129 | + | let gen = FeedGenerator::new(db) | |
| 1130 | + | .with_filter(FeedFilter::new().search("Item").starred_only()); | |
| 1131 | + | let result = gen.get_items(0).await.unwrap(); | |
| 1132 | + | assert_eq!(result.items.len(), 1); | |
| 1133 | + | assert!(result.items[0].is_starred); | |
| 1134 | + | } | |
| 1135 | + | ||
| 1136 | + | // ── get_all_items additional coverage ──────────────────────── | |
| 1137 | + | ||
| 1138 | + | #[tokio::test] | |
| 1139 | + | async fn get_all_items_empty_db() { | |
| 1140 | + | let db = test_db().await; | |
| 1141 | + | let gen = FeedGenerator::new(db); | |
| 1142 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1143 | + | assert!(result.items.is_empty()); | |
| 1144 | + | assert!(!result.has_more); | |
| 1145 | + | } | |
| 1146 | + | ||
| 1147 | + | #[tokio::test] | |
| 1148 | + | async fn get_all_items_unread_filter() { | |
| 1149 | + | let db = test_db().await; | |
| 1150 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1151 | + | let item1 = seed_item(&db, &feed, "rss:1", 1).await; | |
| 1152 | + | seed_item(&db, &feed, "rss:2", 2).await; | |
| 1153 | + | seed_item(&db, &feed, "rss:3", 3).await; | |
| 1154 | + | ||
| 1155 | + | db.items().mark_read(item1.id, true).await.unwrap(); | |
| 1156 | + | ||
| 1157 | + | let gen = FeedGenerator::new(db) | |
| 1158 | + | .with_filter(FeedFilter::new().unread_only()); | |
| 1159 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1160 | + | assert_eq!(result.items.len(), 2); | |
| 1161 | + | assert!(result.items.iter().all(|i| !i.is_read)); | |
| 1162 | + | } | |
| 1163 | + | ||
| 1164 | + | #[tokio::test] | |
| 1165 | + | async fn get_all_items_starred_filter() { | |
| 1166 | + | let db = test_db().await; | |
| 1167 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1168 | + | seed_item(&db, &feed, "rss:1", 1).await; | |
| 1169 | + | let item2 = seed_item(&db, &feed, "rss:2", 2).await; | |
| 1170 | + | ||
| 1171 | + | db.items().mark_starred(item2.id, true).await.unwrap(); | |
| 1172 | + | ||
| 1173 | + | let gen = FeedGenerator::new(db) | |
| 1174 | + | .with_filter(FeedFilter::new().starred_only()); | |
| 1175 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1176 | + | assert_eq!(result.items.len(), 1); | |
| 1177 | + | assert!(result.items[0].is_starred); | |
| 1178 | + | } | |
| 1179 | + | ||
| 1180 | + | #[tokio::test] | |
| 1181 | + | async fn get_all_items_tag_filter() { | |
| 1182 | + | let db = test_db().await; | |
| 1183 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1184 | + | seed_tagged_item(&db, &feed, "rss:tagged", 1, vec!["rust".into()]).await; | |
| 1185 | + | seed_item(&db, &feed, "rss:plain", 2).await; | |
| 1186 | + | ||
| 1187 | + | let gen = FeedGenerator::new(db) | |
| 1188 | + | .with_filter(FeedFilter::new().with_tag("rust")); | |
| 1189 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1190 | + | assert_eq!(result.items.len(), 1); | |
| 1191 | + | assert_eq!(result.items[0].id.item_id, "rss:tagged"); | |
| 1192 | + | } | |
| 1193 | + | ||
| 1194 | + | #[tokio::test] | |
| 1195 | + | async fn get_all_items_search_filter() { | |
| 1196 | + | let db = test_db().await; | |
| 1197 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1198 | + | seed_item(&db, &feed, "rss:1", 1).await; | |
| 1199 | + | seed_item(&db, &feed, "rss:2", 2).await; | |
| 1200 | + | ||
| 1201 | + | let gen = FeedGenerator::new(db) | |
| 1202 | + | .with_filter(FeedFilter::new().search("Item rss:1")); | |
| 1203 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1204 | + | assert_eq!(result.items.len(), 1); | |
| 1205 | + | } | |
| 1206 | + | ||
| 1207 | + | #[tokio::test] | |
| 1208 | + | async fn get_all_items_combined_source_and_unread() { | |
| 1209 | + | let db = test_db().await; | |
| 1210 | + | let feed_a = seed_feed(&db, "rss", "RSS").await; | |
| 1211 | + | let feed_b = seed_feed(&db, "hn", "HN").await; | |
| 1212 | + | let item_a1 = seed_item(&db, &feed_a, "rss:1", 1).await; | |
| 1213 | + | seed_item(&db, &feed_a, "rss:2", 2).await; | |
| 1214 | + | seed_item(&db, &feed_b, "hn:1", 3).await; | |
| 1215 | + | ||
| 1216 | + | db.items().mark_read(item_a1.id, true).await.unwrap(); | |
| 1217 | + | ||
| 1218 | + | let gen = FeedGenerator::new(db) | |
| 1219 | + | .with_filter(FeedFilter::new().source("rss").unread_only()); | |
| 1220 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1221 | + | assert_eq!(result.items.len(), 1); | |
| 1222 | + | assert_eq!(result.items[0].id.source, "rss"); | |
| 1223 | + | assert!(!result.items[0].is_read); | |
| 1224 | + | } | |
| 1225 | + | ||
| 1226 | + | #[tokio::test] | |
| 1227 | + | async fn get_all_items_score_ordering() { | |
| 1228 | + | let db = test_db().await; | |
| 1229 | + | let feed = seed_feed(&db, "rss", "Feed").await; | |
| 1230 | + | seed_scored_item(&db, &feed, "rss:low", 1, Some(5)).await; | |
| 1231 | + | seed_scored_item(&db, &feed, "rss:high", 2, Some(500)).await; | |
| 1232 | + | seed_scored_item(&db, &feed, "rss:mid", 3, Some(50)).await; | |
| 1233 | + | ||
| 1234 | + | let gen = FeedGenerator::new(db).with_order(OrderBy::Score); | |
| 1235 | + | let result = gen.get_all_items().await.unwrap(); | |
| 1236 | + | assert_eq!(result.items[0].id.item_id, "rss:high"); | |
| 1237 | + | assert_eq!(result.items[1].id.item_id, "rss:mid"); | |
| 1238 | + | assert_eq!(result.items[2].id.item_id, "rss:low"); | |
| 1239 | + | } | |
| 1240 | + | ||
| 1241 | + | // ── get_sources edge cases ────────────────────────────────── | |
| 1242 | + | ||
| 1243 | + | #[tokio::test] | |
| 1244 | + | async fn get_sources_feed_with_no_items() { | |
| 1245 | + | let db = test_db().await; | |
| 1246 | + | seed_feed(&db, "rss", "Empty Feed").await; | |
| 1247 | + | ||
| 1248 | + | let gen = FeedGenerator::new(db); | |
| 1249 | + | let sources = gen.get_sources().await.unwrap(); | |
| 1250 | + | assert_eq!(sources.len(), 1); | |
| 1251 | + | assert_eq!(sources[0].total_count, 0); | |
| 1252 | + | assert_eq!(sources[0].unread_count, 0); | |
| 1253 | + | } | |
| 1254 | + | ||
| 1255 | + | #[tokio::test] | |
| 1256 | + | async fn get_sources_multiple_feeds_correct_counts() { | |
| 1257 | + | let db = test_db().await; | |
| 1258 | + | let feed_a = seed_feed(&db, "rss", "RSS").await; | |
| 1259 | + | let feed_b = seed_feed(&db, "hn", "HN").await; | |
| 1260 | + | seed_item(&db, &feed_a, "rss:1", 1).await; | |
| 1261 | + | seed_item(&db, &feed_a, "rss:2", 2).await; | |
| 1262 | + | seed_item(&db, &feed_a, "rss:3", 3).await; | |
| 1263 | + | let hn_item = seed_item(&db, &feed_b, "hn:1", 4).await; | |
| 1264 | + |
Lines truncated
| @@ -3,7 +3,9 @@ | |||
| 3 | 3 | //! `OrderBy` controls sort order; `FeedFilter` narrows which items | |
| 4 | 4 | //! are shown. Both are applied in-memory after the database query. | |
| 5 | 5 | ||
| 6 | + | use bb_db::QueryCondition; | |
| 6 | 7 | use bb_interface::FeedItem; | |
| 8 | + | use regex::Regex; | |
| 7 | 9 | ||
| 8 | 10 | /// How to order feed items | |
| 9 | 11 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] | |
| @@ -82,6 +84,10 @@ pub struct FeedFilter { | |||
| 82 | 84 | /// Filter by user-assigned feed-level tags; items must belong to a feed | |
| 83 | 85 | /// with at least one of these tags. | |
| 84 | 86 | pub feed_tags: Vec<String>, | |
| 87 | + | /// Query feed conditions (AND logic). Simple conditions (source, starred, | |
| 88 | + | /// unread, tag) are mapped to fast-path SQL fields by `from_conditions()`. | |
| 89 | + | /// Complex conditions (title/author/body contains/regex) run in-memory. | |
| 90 | + | pub conditions: Vec<QueryCondition>, | |
| 85 | 91 | } | |
| 86 | 92 | ||
| 87 | 93 | impl FeedFilter { | |
| @@ -126,6 +132,34 @@ impl FeedFilter { | |||
| 126 | 132 | self | |
| 127 | 133 | } | |
| 128 | 134 | ||
| 135 | + | /// Build a filter from query feed conditions. | |
| 136 | + | /// | |
| 137 | + | /// Simple conditions (source, starred, unread, tag) are mapped to the | |
| 138 | + | /// corresponding fast-path SQL fields. Complex conditions (title/author/body | |
| 139 | + | /// contains, regex) are stored in `self.conditions` for in-memory evaluation. | |
| 140 | + | pub fn from_conditions(conditions: Vec<QueryCondition>) -> Self { | |
| 141 | + | let mut filter = Self::new(); | |
| 142 | + | for c in &conditions { | |
| 143 | + | match (c.field.as_str(), c.operator.as_str()) { | |
| 144 | + | ("source", "equals") => { | |
| 145 | + | filter.source = Some(c.value.clone()); | |
| 146 | + | } | |
| 147 | + | ("starred", "is") if c.value == "true" => { | |
| 148 | + | filter.starred_only = true; | |
| 149 | + | } | |
| 150 | + | ("unread", "is") if c.value == "true" => { | |
| 151 | + | filter.unread_only = true; | |
| 152 | + | } | |
| 153 | + | ("tag", "equals") => { | |
| 154 | + | filter.tags.push(c.value.clone()); | |
| 155 | + | } | |
| 156 | + | _ => {} | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | filter.conditions = conditions; | |
| 160 | + | filter | |
| 161 | + | } | |
| 162 | + | ||
| 129 | 163 | /// Check if an item matches the filter | |
| 130 | 164 | pub fn matches(&self, item: &FeedItem) -> bool { | |
| 131 | 165 | // Check source filter | |
| @@ -176,6 +210,59 @@ impl FeedFilter { | |||
| 176 | 210 | } | |
| 177 | 211 | } | |
| 178 | 212 | ||
| 213 | + | // Check query feed conditions (in-memory filtering for text fields). | |
| 214 | + | // Source/starred/unread/tag conditions are already handled by the | |
| 215 | + | // fast-path fields above (set in `from_conditions()`), so we only | |
| 216 | + | // need to evaluate title/author/body conditions here. | |
| 217 | + | for c in &self.conditions { | |
| 218 | + | match c.field.as_str() { | |
| 219 | + | "title" | "author" | "body" => { | |
| 220 | + | let field_value = match c.field.as_str() { | |
| 221 | + | "title" => item.content.title.as_deref().unwrap_or(""), | |
| 222 | + | "author" => &item.bite.author, | |
| 223 | + | "body" => item.content.body.as_deref().unwrap_or(""), | |
| 224 | + | _ => "", | |
| 225 | + | }; | |
| 226 | + | match c.operator.as_str() { | |
| 227 | + | "contains" => { | |
| 228 | + | if !field_value.to_lowercase().contains(&c.value.to_lowercase()) { | |
| 229 | + | return false; | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | "not_contains" => { | |
| 233 | + | if field_value.to_lowercase().contains(&c.value.to_lowercase()) { | |
| 234 | + | return false; | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | "equals" => { | |
| 238 | + | if !field_value.eq_ignore_ascii_case(&c.value) { | |
| 239 | + | return false; | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | "matches_regex" => { | |
| 243 | + | match Regex::new(&c.value) { | |
| 244 | + | Ok(re) => { | |
| 245 | + | if !re.is_match(field_value) { | |
| 246 | + | return false; | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | Err(e) => { | |
| 250 | + | tracing::warn!( | |
| 251 | + | regex = %c.value, | |
| 252 | + | error = %e, | |
| 253 | + | "Invalid regex in query feed condition, skipping" | |
| 254 | + | ); | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | _ => {} | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | // source/starred/unread/tag already handled by fast-path fields | |
| 262 | + | _ => {} | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 179 | 266 | true | |
| 180 | 267 | } | |
| 181 | 268 | ||
| @@ -491,4 +578,180 @@ mod tests { | |||
| 491 | 578 | item.meta.tags = vec!["discussion".to_string()]; | |
| 492 | 579 | assert!(!filter.matches(&item)); | |
| 493 | 580 | } | |
| 581 | + | ||
| 582 | + | // --- FeedFilter::from_conditions --- | |
| 583 | + | ||
| 584 | + | fn cond(field: &str, operator: &str, value: &str) -> QueryCondition { | |
| 585 | + | QueryCondition { | |
| 586 | + | field: field.to_string(), | |
| 587 | + | operator: operator.to_string(), | |
| 588 | + | value: value.to_string(), | |
| 589 | + | } | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | #[test] | |
| 593 | + | fn from_conditions_source_equals_sets_fast_path() { | |
| 594 | + | let filter = FeedFilter::from_conditions(vec![cond("source", "equals", "rss")]); | |
| 595 | + | assert_eq!(filter.source, Some("rss".to_string())); | |
| 596 | + | } | |
| 597 | + | ||
| 598 | + | #[test] | |
| 599 | + | fn from_conditions_starred_sets_fast_path() { | |
| 600 | + | let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "true")]); | |
| 601 | + | assert!(filter.starred_only); | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | #[test] | |
| 605 | + | fn from_conditions_unread_sets_fast_path() { | |
| 606 | + | let filter = FeedFilter::from_conditions(vec![cond("unread", "is", "true")]); | |
| 607 | + | assert!(filter.unread_only); | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | #[test] | |
| 611 | + | fn from_conditions_tag_sets_fast_path() { | |
| 612 | + | let filter = FeedFilter::from_conditions(vec![cond("tag", "equals", "rust")]); | |
| 613 | + | assert_eq!(filter.tags, vec!["rust".to_string()]); | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | #[test] | |
| 617 | + | fn from_conditions_stores_all_conditions() { | |
| 618 | + | let conditions = vec![ | |
| 619 | + | cond("source", "equals", "rss"), | |
| 620 | + | cond("title", "contains", "rust"), | |
| 621 | + | ]; | |
| 622 | + | let filter = FeedFilter::from_conditions(conditions); | |
| 623 | + | assert_eq!(filter.conditions.len(), 2); | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | #[test] | |
| 627 | + | fn from_conditions_starred_false_no_fast_path() { | |
| 628 | + | let filter = FeedFilter::from_conditions(vec![cond("starred", "is", "false")]); | |
| 629 | + | assert!(!filter.starred_only); | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // --- Condition matching (title/author/body) --- | |
| 633 | + | ||
| 634 | + | #[test] | |
| 635 | + | fn condition_title_contains_matches() { | |
| 636 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "rust")]); | |
| 637 | + | let mut item = make_item("s", "1", 100); | |
| 638 | + | item.content.title = Some("Learning Rust today".to_string()); | |
| 639 | + | assert!(filter.matches(&item)); | |
| 640 | + | } | |
| 641 | + | ||
| 642 | + | #[test] | |
| 643 | + | fn condition_title_contains_case_insensitive() { | |
| 644 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "RUST")]); | |
| 645 | + | let mut item = make_item("s", "1", 100); | |
| 646 | + | item.content.title = Some("Learning rust today".to_string()); | |
| 647 | + | assert!(filter.matches(&item)); | |
| 648 | + | } | |
| 649 | + | ||
| 650 | + | #[test] | |
| 651 | + | fn condition_title_contains_no_match() { | |
| 652 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "contains", "python")]); | |
| 653 | + | let mut item = make_item("s", "1", 100); | |
| 654 | + | item.content.title = Some("Learning Rust today".to_string()); | |
| 655 | + | assert!(!filter.matches(&item)); | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | #[test] | |
| 659 | + | fn condition_title_not_contains() { | |
| 660 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "python")]); | |
| 661 | + | let mut item = make_item("s", "1", 100); | |
| 662 | + | item.content.title = Some("Learning Rust today".to_string()); | |
| 663 | + | assert!(filter.matches(&item)); | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | #[test] | |
| 667 | + | fn condition_title_not_contains_rejects() { | |
| 668 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "not_contains", "rust")]); | |
| 669 | + | let mut item = make_item("s", "1", 100); | |
| 670 | + | item.content.title = Some("Learning Rust today".to_string()); | |
| 671 | + | assert!(!filter.matches(&item)); | |
| 672 | + | } | |
| 673 | + | ||
| 674 | + | #[test] | |
| 675 | + | fn condition_title_equals() { | |
| 676 | + | let filter = FeedFilter::from_conditions(vec![cond("title", "equals", "Hello World")]); | |
| 677 | + | let mut item = make_item("s", "1", 100); | |
| 678 | + | item.content.title = Some("hello world".to_string()); | |
| 679 | + | assert!(filter.matches(&item), "equals should be case-insensitive"); | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | #[test] | |
| 683 | + | fn condition_title_matches_regex() { | |
| 684 | + | let filter = | |
| 685 | + | FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Rust \d+")]); | |
| 686 | + | let mut item = make_item("s", "1", 100); | |
| 687 | + | item.content.title = Some("Rust 2024 edition".to_string()); | |
| 688 | + | assert!(filter.matches(&item)); | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | #[test] | |
| 692 | + | fn condition_title_matches_regex_no_match() { | |
| 693 | + | let filter = | |
| 694 | + | FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"^Python \d+")]); | |
| 695 | + | let mut item = make_item("s", "1", 100); | |
| 696 | + | item.content.title = Some("Rust 2024 edition".to_string()); | |
| 697 | + | assert!(!filter.matches(&item)); | |
| 698 | + | } | |
| 699 | + | ||
| 700 | + | #[test] | |
| 701 | + | fn condition_invalid_regex_skipped() { | |
| 702 | + | let filter = | |
| 703 | + | FeedFilter::from_conditions(vec![cond("title", "matches_regex", r"[invalid")]); | |
| 704 | + | let mut item = make_item("s", "1", 100); | |
| 705 | + | item.content.title = Some("anything".to_string()); | |
| 706 | + | // Invalid regex should be skipped (not reject the item) | |
| 707 | + | assert!(filter.matches(&item)); | |
| 708 | + | } | |
| 709 | + | ||
| 710 | + | #[test] | |
| 711 | + | fn condition_author_contains() { | |
| 712 | + | let filter = FeedFilter::from_conditions(vec![cond("author", "contains", "alice")]); | |
| 713 | + | let mut item = make_item("s", "1", 100); | |
| 714 | + | item.bite.author = "Alice Smith".to_string(); | |
| 715 | + | assert!(filter.matches(&item)); | |
| 716 | + | } | |
| 717 | + | ||
| 718 | + | #[test] | |
| 719 | + | fn condition_body_contains() { | |
| 720 | + | let filter = FeedFilter::from_conditions(vec![cond("body", "contains", "important")]); | |
| 721 | + | let mut item = make_item("s", "1", 100); | |
| 722 | + | item.content.body = Some("This is an important article".to_string()); | |
| 723 | + | assert!(filter.matches(&item)); | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | #[test] | |
| 727 | + | fn condition_body_not_contains() { | |
| 728 | + | let filter = FeedFilter::from_conditions(vec![cond("body", "not_contains", "spam")]); | |
| 729 | + | let mut item = make_item("s", "1", 100); | |
| 730 | + | item.content.body = Some("This is a good article".to_string()); | |
| 731 | + | assert!(filter.matches(&item)); | |
| 732 | + | } | |
| 733 | + | ||
| 734 | + | #[test] | |
| 735 | + | fn conditions_and_logic() { | |
| 736 | + | let filter = FeedFilter::from_conditions(vec![ | |
| 737 | + | cond("title", "contains", "rust"), | |
| 738 | + | cond("author", "contains", "alice"), | |
| 739 | + | ]); | |
| 740 | + | let mut item = make_item("s", "1", 100); | |
| 741 | + | item.content.title = Some("Rust tips".to_string()); | |
| 742 | + | item.bite.author = "Alice".to_string(); | |
| 743 | + | assert!(filter.matches(&item)); | |
| 744 | + | ||
| 745 | + | let mut wrong_author = make_item("s", "2", 100); | |
| 746 | + | wrong_author.content.title = Some("Rust tips".to_string()); | |
| 747 | + | wrong_author.bite.author = "Bob".to_string(); | |
| 748 | + | assert!(!filter.matches(&wrong_author)); | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | #[test] | |
| 752 | + | fn empty_conditions_matches_all() { | |
| 753 | + | let filter = FeedFilter::from_conditions(vec![]); | |
| 754 | + | let item = make_item("s", "1", 100); | |
| 755 | + | assert!(filter.matches(&item)); | |
| 756 | + | } | |
| 494 | 757 | } |
| @@ -1,96 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast | |
| 2 | - | ||
| 3 | - | Native desktop feed aggregator with plugin-based sources. RSS, Hacker News, and arXiv in a unified timeline. | |
| 4 | - | ||
| 5 | - | ## What It Is | |
| 6 | - | ||
| 7 | - | A Tauri 2 desktop feed reader that aggregates content from multiple source types via a Rhai plugin system. Three-panel layout (sources, items, detail), keyboard-driven, with OPML import/export for migration from other readers. | |
| 8 | - | ||
| 9 | - | **License:** PolyForm Noncommercial 1.0.0 | |
| 10 | - | ||
| 11 | - | ## Tech Stack | |
| 12 | - | ||
| 13 | - | - **Framework:** Tauri 2.10.2 (Rust backend + Vanilla JS frontend) | |
| 14 | - | - **Database:** SQLite with sqlx 0.8 (content-addressable item storage) | |
| 15 | - | - **Plugin Runtime:** Rhai scripting engine (dynamically loaded, no recompilation) | |
| 16 | - | - **Async:** Tokio 1.49 | |
| 17 | - | - **XML:** roxmltree 0.19 (OPML parsing) | |
| 18 | - | - **Logging:** tracing 0.1 | |
| 19 | - | ||
| 20 | - | **Workspace:** 5 crates — `bb-interface` (plugin traits), `bb-core` (orchestration), `bb-feed` (aggregation), `bb-db` (SQLite layer), `src-tauri` (desktop app) | |
| 21 | - | ||
| 22 | - | ## Features | |
| 23 | - | ||
| 24 | - | ### Feed Aggregation | |
| 25 | - | - Subscribe to any RSS or Atom feed URL | |
| 26 | - | - Hacker News integration (Top, New, Best, Ask HN, Show HN, Jobs) | |
| 27 | - | - arXiv paper tracking by category (cs.AI, cs.LG, cs.CL, cs.CV, stat.ML, etc.) | |
| 28 | - | - Unified timeline across all sources | |
| 29 | - | - Per-source emoji indicators | |
| 30 | - | ||
| 31 | - | ### Reading & Browsing | |
| 32 | - | - Three-panel layout: sources sidebar, items list, detail panel | |
| 33 | - | - Item metadata: title, author, body, score, published date | |
| 34 | - | - HTML content converted to readable text | |
| 35 | - | - Open original URLs in browser | |
| 36 | - | - Keyboard shortcuts: j/k navigate, s star, r read/unread, / search, o open, Escape close | |
| 37 | - | ||
| 38 | - | ### Organization | |
| 39 | - | - Mark items read/unread, star/favorite | |
| 40 | - | - Filter views: All, Unread only, Starred only | |
| 41 | - | - Per-source filtering | |
| 42 | - | - Sort: Newest first, By score, Unread first, Starred first | |
| 43 | - | - Client-side text search (title, body, author) | |
| 44 | - | - Paginated loading (50 items/page) | |
| 45 | - | - Per-source unread count in sidebar | |
| 46 | - | ||
| 47 | - | ### Feed Management | |
| 48 | - | - Add feeds from any plugin without restart | |
| 49 | - | - Delete individual feeds or all feeds from a source | |
| 50 | - | - Manual refresh (Cmd+R) for immediate fetch | |
| 51 | - | - Auto-fetch background loop (default 15 min, configurable per plugin) | |
| 52 | - | - Toast notifications on fetch errors | |
| 53 | - | ||
| 54 | - | ### OPML Support | |
| 55 | - | - Import OPML files (Cmd+I) for migration from other readers | |
| 56 | - | - Export OPML 2.0 (Cmd+E) for backup | |
| 57 | - | - Automatic duplicate detection on import | |
| 58 | - | ||
| 59 | - | ### Plugin System | |
| 60 | - | - 3 built-in plugins: RSS/Atom, Hacker News, arXiv | |
| 61 | - | - Rhai script-based (dynamically loaded, no recompilation needed) | |
| 62 | - | - Per-plugin configuration schema with validation | |
| 63 | - | - Plugin capabilities: pagination, search, date filtering, custom fetch intervals, auth flags | |
| 64 | - | - Host functions: HTTP requests, feed parsing, logging | |
| 65 | - | - 100,000 max operations limit per execution (security) | |
| 66 | - | ||
| 67 | - | ### Data Persistence | |
| 68 | - | - Local SQLite database | |
| 69 | - | - Content-addressable items (prevents duplicates) | |
| 70 | - | - Read/starred status persists across restarts | |
| 71 | - | - Plugin state storage | |
| 72 | - | ||
| 73 | - | ## Testing | |
| 74 | - | ||
| 75 | - | - **142 tests** (all passing) | |
| 76 | - | - bb-db: 50 tests (feeds, items, plugin state repository operations) | |
| 77 | - | - bb-core: 34 tests (plugin orchestration, Rhai execution, conversions) | |
| 78 | - | - bb-feed: 31 tests (aggregation, filtering, sort strategies) | |
| 79 | - | - bb-interface: 21 tests (types, capabilities, config validation) | |
| 80 | - | - src-tauri: 6 tests (error codes, OPML escaping) | |
| 81 | - | ||
| 82 | - | ## Platform Support | |
| 83 | - | ||
| 84 | - | | Platform | Status | | |
| 85 | - | |----------|--------| | |
| 86 | - | | macOS | Primary, functional | | |
| 87 | - | | Windows | Supported via Tauri | | |
| 88 | - | | Linux | Supported via Tauri | | |
| 89 | - | ||
| 90 | - | ## Status | |
| 91 | - | ||
| 92 | - | **Done:** Tauri 2 conversion, Phase 1 polish (74 items), OPML import/export, auto-fetch loop, 142 tests. | |
| 93 | - | ||
| 94 | - | **Active:** Preparing macOS release binary. | |
| 95 | - | ||
| 96 | - | **Next:** Tags/categories, theming, feed health monitoring, stale item cleanup, full-text search, JSON Feed format, plugin secret encryption. Then integration tests, CI, plugin authoring guide. |
| @@ -1,356 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast -- Code Audit Review | |
| 2 | - | ||
| 3 | - | **Last audited:** 2026-03-01 (fourth audit) | |
| 4 | - | **Previous audit:** 2026-02-28 (third audit) | |
| 5 | - | ||
| 6 | - | ## Overall Grade: A- | |
| 7 | - | ||
| 8 | - | Excellent codebase with clean architecture, strong security posture, and comprehensive test coverage (225 tests, 0 failures). The 4-crate workspace separation is well-designed, the Rhai plugin system is properly sandboxed, and all SQL is parameterized. All security and code quality issues from previous audits resolved. One new issue: 8 clippy violations from newer Rust lints (`items_after_test_module`, `bool_assert_comparison`, `approx_constant`, `unnecessary_get_then_check`, `useless_vec`), all in test code or mechanical fixes. Remaining gaps: generator isolation tests, DB-level command integration tests, and documentation. | |
| 9 | - | ||
| 10 | - | ## Scorecard | |
| 11 | - | ||
| 12 | - | | Dimension | Grade | Notes | | |
| 13 | - | |-----------|:-----:|-------| | |
| 14 | - | | Code Quality | A- | Zero `.unwrap()` in production code. Consistent `thiserror` + `Result` error handling. Clean naming. 8 clippy violations from newer lints (2 in bb-interface, 6 in bb-core test code). All mechanical fixes. | | |
| 15 | - | | Architecture | A | Clean 4-crate layering (interface -> db -> core -> feed). Orchestrator coordinates without touching SQL. Plugin system cleanly separated into manager, engine, host functions, conversions. Tauri commands are thin wrappers. | | |
| 16 | - | | Testing | A | 225 tests across all crates, all passing. Thorough coverage of db, crypto, url_cleaner, FTS5, tags, health, ordering, generator, conversions, plugin manager. Tauri commands: 25 feed validation tests, 5 format_time_ago tests, 9 theme validation/parsing tests. Gap: no isolated generator filtering tests, no DB-level command integration tests. | | |
| 17 | - | | Security | A | All SQL parameterized. FTS5 injection prevented via `sanitize_fts_query`. HTML sanitization strips dangerous elements and `data:` URIs. Rhai sandboxed (max_operations, max_expr_depths). AES-256-GCM for secrets with `0o600` key file permissions. Tag bar uses `addEventListener` (no inline onclick XSS). All mutex locks use `.map_err()` (no panic on poisoning). | | |
| 18 | - | | Performance | A | N+1 avoided via GROUP BY aggregation. FTS5 for full-text search. Pagination everywhere with `page_size+1` has_more detection. MAX_ALL_ITEMS cap (10K). Background auto-fetch with per-plugin intervals. Stale cleanup every 6h. | | |
| 19 | - | | Documentation | A- | Good `//!` module docs on all Rust files. JSDoc on all frontend functions. `///` on public APIs. README.md and docs/PLUGIN_AUTHORING.md now exist. Minor: some module docs are brief. | | |
| 20 | - | | Dependencies | A | Workspace-level dep management with pinned versions. CI pipeline (check, test, clippy -D warnings, cargo-audit). Zero unused deps. Clean clippy (0 warnings). | | |
| 21 | - | | Frontend | A | Clean BB.* namespace (IIFE modules). Proxy-based reactive state with pub/sub. `escapeHtml` on all user content. `sanitizeHtml` for RSS bodies with `data:` URI blocking. Keyboard shortcuts (j/k/s/r///Escape). ARIA attributes. Tag bar XSS fixed. Dead stubs removed. | | |
| 22 | - | | Type Safety | A | `FeedItemId` newtype with `to_combined()`/`from_combined()`. `FeedId`, `ItemId`, `BusserStateId` UUID newtypes via `define_uuid_id!` macro. `BusserId(String)` newtype. `ApiErrorCode` enum replaces `ApiError.code: String`. Good domain enums (ConfigFieldType, OrderBy, BusserError, PluginError). Wildcard fallbacks in `OrderBy::from_str_loose` and Rhai conversions remain (intentional — loose parsing for user input). | | |
| 23 | - | | Observability | B | `tracing` used exclusively (zero println/eprintln). EnvFilter configured. Widespread info/warn/error/debug usage. But zero `#[instrument]` anywhere. No span correlation for fetch pipeline. No DB query tracing configured on sqlx. Most log calls use format strings rather than structured fields. No HTTP call instrumentation on ureq. | | |
| 24 | - | | Concurrency | A- | UNIQUE constraints enforce idempotency (external_id, busser_state, feed_tags). Transactions for multi-statement mutations (set_tags, delete_feed). Correct lock usage (Mutex for abort handles, RwLock for plugin configs). Auto-fetch processes plugins sequentially. Minor TOCTOU in create_feed duplicate detection (not transactional). | | |
| 25 | - | | Resilience | B | Graceful shutdown via Drop (abort_auto_fetch, abort_cleanup). Feed health tracking with consecutive_failures. Non-fatal plugin initialization. But no HTTP timeouts on ureq calls (default is no timeout). No retry/backoff strategy for failing feeds. No pool acquire_timeout on SqlitePool. | | |
| 26 | - | | API Consistency | A- | Uniform `Result<T, ApiError>` with 5 constructors. Consistent camelCase serialization. Unified pagination (page_size+1 has_more). ID validation consistent via Uuid::parse_str. Minor: create_feed returns void, mark_read silently succeeds on missing items, health computed in command layer for sources but not items. | | |
| 27 | - | | Migration Safety | A | All 6 migrations additive. All CREATE TABLE use IF NOT EXISTS. FTS5 migration with backfill + triggers. ALTER TABLE ADD COLUMN with sensible defaults. Foreign key cascades. 8 indexes covering common query patterns. No destructive migrations. | | |
| 28 | - | | Codebase Size | A | ~5,400 lines production Rust + 1,658 lines JS for 17+ features (RSS/Atom/JSON parsing, Rhai plugins, encryption, FTS5 search, tags, health tracking, stale cleanup, themes, etc.). ~320 lines Rust per feature. No dead code, no redundant abstractions. | | |
| 29 | - | ||
| 30 | - | ## Module Heatmap | |
| 31 | - | ||
| 32 | - | | Module | Code | Arch | Test | Security | Perf | Docs | Deps | Frontend | | |
| 33 | - | |--------|:----:|:----:|:----:|:--------:|:----:|:----:|:----:|:--------:| | |
| 34 | - | | bb-interface (858 lines) | A | A+ | A- | n/a | n/a | A | A | n/a | | |
| 35 | - | | bb-core/orchestrator (323 lines) | A | A | n/a | A | A | A | n/a | n/a | | |
| 36 | - | | bb-core/plugin_manager (343 lines) | A- | A | A- | A | n/a | A- | n/a | n/a | | |
| 37 | - | | bb-core/crypto (246 lines) | A | A | A | A | n/a | A | n/a | n/a | | |
| 38 | - | | bb-core/url_cleaner (180 lines) | A | A | A | A | n/a | A | n/a | n/a | | |
| 39 | - | | bb-core/rhai_plugin (1664 lines) | A | A | A | A | n/a | A- | n/a | n/a | | |
| 40 | - | | bb-db/models (386 lines) | A | A | A- | n/a | n/a | A- | n/a | n/a | | |
| 41 | - | | bb-db/repository (1539 lines) | A | A | A | A | A | A | n/a | n/a | | |
| 42 | - | | bb-feed/ordering (415 lines) | A | A | A | n/a | A | A- | n/a | n/a | | |
| 43 | - | | bb-feed/generator (508 lines) | A- | A | B+ | n/a | A | A- | n/a | n/a | | |
| 44 | - | | src-tauri/state (339 lines) | A- | A | n/a | A- | A | A- | n/a | n/a | | |
| 45 | - | | src-tauri/commands (1380 lines) | A | A | A- | A | n/a | A- | n/a | n/a | | |
| 46 | - | | frontend/sources (255 lines) | A- | n/a | n/a | A | n/a | A- | n/a | A- | | |
| 47 | - | | frontend/items (214 lines) | A | n/a | n/a | A | n/a | A | n/a | A | | |
| 48 | - | | frontend/feeds (209 lines) | A | n/a | n/a | A | n/a | A- | n/a | A | | |
| 49 | - | | frontend/themes (188 lines) | A | n/a | n/a | A | n/a | A- | n/a | A | | |
| 50 | - | | frontend/components (178 lines) | A | n/a | n/a | A | n/a | A | n/a | A | | |
| 51 | - | | frontend/app (246 lines) | A | n/a | n/a | A | n/a | A- | n/a | A | | |
| 52 | - | | frontend/utils (101 lines) | A- | n/a | n/a | A | n/a | A | n/a | A- | | |
| 53 | - | ||
| 54 | - | ### Cold Spots | |
| 55 | - | ||
| 56 | - | - **bb-feed/generator (Testing): B+** -- 10 tests exist but all require a database. No isolated unit tests for the in-memory tag filtering logic in `get_items`/`get_all_items`. The `ordering.rs` companion module has 25 pure unit tests; `generator.rs` should have similar isolated filter tests. | |
| 57 | - | ||
| 58 | - | - **src-tauri/commands (Testing): B** -- 6 tests total (2 for xml_escape in opml, 4 for ApiError). Zero integration tests for the 20 registered Tauri commands. The `create_feed` validation logic (90+ lines of schema-aware validation) is completely untested. This is the biggest testing gap in the codebase. | |
| 59 | - | ||
| 60 | - | - **bb-core/plugin_manager (Security): B+** -- Two `.expect("plugin_configs lock poisoned")` calls at lines 125 and 143. If a thread panics while holding the `RwLock`, these propagate a panic to the calling thread. Should return `PluginError::InitError` instead. | |
| 61 | - | ||
| 62 | - | - **frontend/sources (Security): B+** -- Single-quote XSS in tag filter bar at line 223: `onclick="BB.sources.selectTag('${escapeHtml(t)}')"`. The `escapeHtml` function does not escape `'`, so a tag like `it's` breaks out of the onclick attribute. Should use `addEventListener` or escape single quotes. | |
| 63 | - | ||
| 64 | - | - **frontend/utils (Security): B+** -- `sanitizeHtml` strips `javascript:` from `href`/`src` attributes but does not block `data:` URIs. A `data:text/html,...` URI in an `<img src>` or `<a href>` could execute scripts in some browsers. | |
| 65 | - | ||
| 66 | - | ## Strengths | |
| 67 | - | ||
| 68 | - | - **Zero `.unwrap()` in production code.** Every fallible operation uses `Result`, `unwrap_or_else`, or `unwrap_or_default` with reasoning comments. The `parse_or_default` helper in `models.rs` logs parse failures via `tracing::warn` instead of panicking. Only test code uses `.unwrap()`. | |
| 69 | - | ||
| 70 | - | - **All SQL is parameterized.** Every query in `repository.rs` (1539 lines, ~30 queries) uses `?N` placeholders with `.bind()`. The `sanitize_fts_query` function wraps search terms in double quotes to prevent FTS5 operator injection. The `feed_ids_with_tags` function builds dynamic placeholder strings (`?1, ?2, ...`) but binds all values -- no string interpolation of user input into SQL. | |
| 71 | - | ||
| 72 | - | - **Defense-in-depth security.** Multiple independent layers: (a) Rhai engine sandboxed with `max_operations(100_000)` and `max_expr_depths(128, 128)`, (b) AES-256-GCM encryption for secrets at rest with versioned format (`bb_enc:v1:`), unique nonces per call, backward-compatible plaintext passthrough, (c) HTML sanitization strips `<script>`, `<iframe>`, `<form>`, on* handlers, and `javascript:` URLs, (d) XML escaping in OPML export, (e) URL tracker parameter stripping on fetch, (f) extensive input validation in `create_feed` (name length, URL format, number parsing, select options, toggle values, text length, duplicate detection). | |
| 73 | - | ||
| 74 | - | - **Clean crate boundaries.** `bb-interface` defines types and traits with zero implementation deps (only serde + chrono). `bb-db` handles persistence with no knowledge of plugins. `bb-core` orchestrates but never touches SQL directly. `bb-feed` handles aggregation and ordering with filter/sort abstractions. No circular dependencies. | |
| 75 | - | ||
| 76 | - | - **Comprehensive test suite (178 tests).** Coverage includes: crypto roundtrips (including wrong-key, different nonces), FTS5 search (title/body/bite_text/multi-word/special characters/pagination), feed health (success resets, failure increments, defaults), tag CRUD (set/get/add/remove/cascade on delete/idempotent), stale cleanup (preserves starred/unread/recent), upsert conflict (preserves is_read/is_starred), plugin manager lifecycle (create/discover/load/init/fetch/shutdown), Rhai conversions (50+ tests: json_to_dynamic, parse_feed_xml, parse_json_feed, capabilities, config_schema, feed_item), ordering (25 tests: all OrderBy variants, filter combinations), generator (10 tests: pagination, counting, sources aggregation, mark read/starred). | |
| 77 | - | ||
| 78 | - | - **Well-designed plugin system.** Rhai plugins declare capabilities (fetch_interval_secs, pagination, date_filter, search) and config schemas (typed fields: Text, Url, Number, Select, Toggle, Secret, TextArea) via pure Rhai functions. Host functions provide a rich API (http_get, parse_json, parse_xml, parse_feed, html_to_text, strip_tracking, etc.). The trust model is documented: plugins are local files the user explicitly installs. | |
| 79 | - | ||
| 80 | - | ## Weaknesses | |
| 81 | - | ||
| 82 | - | - **Single-quote XSS in tag filter bar.** `sources.js:223` interpolates `escapeHtml(t)` into a single-quoted onclick attribute. Since `escapeHtml` (DOM textContent -> innerHTML) does not escape `'`, a tag containing a single quote breaks out of the attribute. Low severity (tags are user-created locally), but should be fixed by using `addEventListener` or escaping single quotes. | |
| 83 | - | ||
| 84 | - | - **Encryption key file permissions not set.** `crypto.rs:42` writes the 32-byte key with `std::fs::write()`, which uses the process umask (typically 0644). The key should be set to 0600 after creation to prevent other users from reading it. | |
| 85 | - | ||
| 86 | - | - **Six mutex `.expect("poisoned")` calls.** Two in `plugin_manager.rs` (lines 125, 143) on `RwLock::read/write` for plugin configs. Four in `state.rs` (lines 98, 108, 117, 126) on `Mutex::lock` for abort handles. The plugin_manager ones are more concerning because they're in the hot path (fetch operations). The state.rs ones are low-risk (only accessed during handle set/abort). All should ideally return errors, but the plugin_manager ones are the priority fix. | |
| 87 | - | ||
| 88 | - | - **No integration tests for Tauri commands.** The 20 registered commands have only 6 tests (xml_escape and ApiError). The `create_feed` command alone has 90+ lines of validation logic that is completely untested. This is the largest testing gap. | |
| 89 | - | ||
| 90 | - | - **Dead code stubs.** Three deprecated functions (`markFailed`, `clearFailed`, `clearAllFailed`) in `sources.js:16-18` are empty stubs from the old client-side health model. Nothing calls them. Should be removed. | |
| 91 | - | ||
| 92 | - | - **`sanitizeHtml` does not block `data:` URIs.** `utils.js:73-78` strips `javascript:` from href/src but does not check for `data:` URIs, which can execute scripts via `data:text/html,...` in some contexts. | |
| 93 | - | ||
| 94 | - | ## Competitive Comparison | |
| 95 | - | ||
| 96 | - | Based on `docs/competition.md`, BB holds a unique position as the only native desktop feed reader with a user-scriptable plugin system. | |
| 97 | - | ||
| 98 | - | **Gaps closed since the competition analysis was written:** | |
| 99 | - | - Full-text search: Implemented via FTS5 (was "Planned") | |
| 100 | - | - Tags/categories: Feed tags implemented with junction table, sidebar filter bar, 6 tests | |
| 101 | - | - URL tracker stripping: Implemented in `url_cleaner.rs` with 10 tests | |
| 102 | - | - JSON Feed format: Implemented in `conversions.rs` (1.0/1.1, 4 tests) | |
| 103 | - | - Feed config validation: Implemented in `create_feed` (90+ lines of schema-aware validation) | |
| 104 | - | - Secret encryption: AES-256-GCM with versioned format, auto-migration of legacy plaintext | |
| 105 | - | - Feed health monitoring: consecutive_failures tracking, server-authoritative health dots | |
| 106 | - | - Stale item cleanup: Background task every 6h, deletes read non-starred items > 30 days | |
| 107 | - | - Theming: 9 TOML themes, bundled + user custom, high-contrast accessibility variant | |
| 108 | - | ||
| 109 | - | **Remaining competitive gaps (from competition.md priority list):** | |
| 110 | - | 1. Reader view / full-article fetch -- planned as a plugin (Phase 5) | |
| 111 | - | 2. Filter/query feeds (virtual feeds from rules) -- Phase 5 | |
| 112 | - | 3. Mobile support -- deferred (Tauri mobile) | |
| 113 | - | ||
| 114 | - | **Where competitors are ahead:** | |
| 115 | - | - NetNewsWire: iCloud sync, third-party sync backends, mature iOS app, reader view | |
| 116 | - | - Reeder: Podcast playback, YouTube/Reddit/Mastodon integration, iCloud sync, polished gesture UX | |
| 117 | - | - Miniflux: Full REST API, 25+ integrations, multi-user, pixel tracker removal, content scraping | |
| 118 | - | - FreshRSS: Multi-user, WebSub push, Google Reader API compatibility, scales to 1M+ articles | |
| 119 | - | ||
| 120 | - | **Where BB has an edge:** | |
| 121 | - | - Rhai plugin system (no competitor has user-scriptable source adapters) | |
| 122 | - | - HN + arXiv as first-class sources via plugins | |
| 123 | - | - Free with no limits, no account, no telemetry | |
| 124 | - | - Cross-platform native desktop (vs. NNW/Reeder Apple-only, vs. Miniflux/FreshRSS web-only) | |
| 125 | - | - Planned community Plugin Store would be unique in the feed reader space | |
| 126 | - | ||
| 127 | - | ## Action Items | |
| 128 | - | ||
| 129 | - | All resolved. Outstanding work tracked in `docs/todo.md`. | |
| 130 | - | ||
| 131 | - | - ~~Fix single-quote XSS in tag filter~~ — sources.js now uses `escapeHtml()` throughout | |
| 132 | - | - ~~Set 0600 permissions on encryption.key~~ — `crypto.rs:46-48` sets `0o600` | |
| 133 | - | - ~~Replace `.expect("poisoned")`~~ — replaced with error propagation | |
| 134 | - | - ~~Remove deprecated health stubs~~ — `markFailed`/`clearFailed`/`clearAllFailed` removed | |
| 135 | - | - ~~Add `data:` URI blocking~~ — `utils.js:75` blocks both `javascript:` and `data:` | |
| 136 | - | - ~~bb-feed generator tests~~ — 3 feed-tag filtering tests added (`generator.rs`) | |
| 137 | - | - ~~Tauri command integration tests~~ — 33 tests added | |
| 138 | - | - ~~README with setup instructions~~ — `README.md` at project root | |
| 139 | - | - ~~Plugin authoring guide~~ — included in README | |
| 140 | - | ||
| 141 | - | ## Changes Since Last Audit | |
| 142 | - | ||
| 143 | - | Previous audit was also 2026-02-27 (first audit of the post-Tauri codebase). This update corrects and extends it: | |
| 144 | - | ||
| 145 | - | - **Test count corrected:** 178 tests (was reported as 155 in the first audit). The 178 count comes from the actual `cargo test --workspace` output: 6 (src-tauri) + 70 (bb-core) + 47 (bb-db) + 34 (bb-feed) + 21 (bb-interface). | |
| 146 | - | - **Module Heatmap added:** The first audit omitted the required per-module grading. This update adds the full heatmap with 19 modules graded across all applicable dimensions. | |
| 147 | - | - **Cold spots identified:** Five cold spots called out with specific modules, grades, and remediation. | |
| 148 | - | - **Additional `.expect()` calls noted:** Four mutex `.expect("mutex poisoned")` calls in `state.rs` (lines 98, 108, 117, 126) were not flagged in the first audit. Total is now 6 across 2 files. | |
| 149 | - | - **No regressions.** All findings from the first audit remain valid and unfixed (all are tracked in `docs/todo.md`). | |
| 150 | - | ||
| 151 | - | ## Build Verification | |
| 152 | - | ||
| 153 | - | - `cargo check --workspace`: **PASS** (0 errors, 0 warnings) | |
| 154 | - | - `cargo test --workspace`: **PASS** (225 tests, 0 failures) | |
| 155 | - | - `cargo clippy --workspace`: **PASS** (0 warnings) | |
| 156 | - | - CI pipeline: `.build.yml` configured for builds.sr.ht with check, test, clippy (-D warnings), cargo-audit | |
| 157 | - | ||
| 158 | - | ## Changes Since Last Audit (2026-02-28) | |
| 159 | - | ||
| 160 | - | ### Security items resolved (all 3) | |
| 161 | - | - Single-quote XSS in tag filter bar -- `sources.js:214,221` now uses `addEventListener('click', () => selectTag(t))` instead of inline onclick attribute. No user-controlled data in remaining `onclick` usages (hardcoded values or DOM property assignments with UUIDs). | |
| 162 | - | - Encryption key permissions -- `crypto.rs:46-48` sets `0o600` via `std::fs::Permissions::from_mode()` after key creation on Unix. | |
| 163 | - | - Mutex `.expect("poisoned")` -- all 6 instances replaced with `.map_err()` across `plugin_manager.rs` (4 calls at lines 92, 130, 151, 158) and `state.rs` (9 `.map_err()` calls). Zero `.expect()` in either file. | |
| 164 | - | ||
| 165 | - | ### Code quality items resolved (all 2) | |
| 166 | - | - Dead code stubs `markFailed`/`clearFailed`/`clearAllFailed` removed from `sources.js` (zero grep matches) | |
| 167 | - | - `sanitizeHtml` blocks `data:` URIs alongside `javascript:` in `utils.js:72-74` | |
| 168 | - | ||
| 169 | - | ### Tests added | |
| 170 | - | - 8 new unit tests for `FeedFilter::apply_tags_only()` and tag-related `matches()` paths in `ordering.rs:418-494` | |
| 171 | - | - 25 new unit tests for feed validation in `commands/feeds.rs` (extracted `validate_feed_input` from `create_feed` — tests cover name, config structure, required fields, URL, number, select, toggle, text/secret length limits) | |
| 172 | - | - 5 new unit tests for `format_time_ago` in `commands/items.rs` (just now, minutes, hours, days, old dates) | |
| 173 | - | - 9 new unit tests for `validate_theme_id` and `parse_meta` in `commands/themes.rs` (valid/invalid IDs, path traversal, TOML defaults) | |
| 174 | - | - Test count: 178 -> 225 passed, 0 failed, 0 ignored | |
| 175 | - | ||
| 176 | - | ### Grades changed | |
| 177 | - | - **Security**: A- -> A (all 3 security items resolved -- XSS, file permissions, mutex panics) | |
| 178 | - | - **Code Quality**: A (unchanged score, but dead code and `data:` URI items resolved) | |
| 179 | - | - **Frontend**: A- -> A (XSS fixed, dead stubs removed, `data:` URI gap closed) | |
| 180 | - | - **Testing**: A- -> A (tag filter tests, Tauri command validation tests, pure function tests — 47 new tests total; remaining gap is generator isolation tests and DB-level command integration tests) | |
| 181 | - | - bb-core/plugin_manager (Security): B+ -> A (no more `.expect("poisoned")`) | |
| 182 | - | - bb-core/crypto (Security): A- -> A (key file permissions now set) | |
| 183 | - | - frontend/sources (Security): B+ -> A (tag bar uses addEventListener) | |
| 184 | - | - frontend/utils (Security): B+ -> A (`data:` URIs blocked) | |
| 185 | - | - bb-feed/generator (Testing): B+ -> A- (tag filter tests added; in-memory filtering logic still needs isolated tests) | |
| 186 | - | - src-tauri/commands (Testing): B -> A- (39 tests covering feeds validation, items formatting, themes parsing; remaining: DB integration tests for OPML import/export, item CRUD, tag operations) | |
| 187 | - | ||
| 188 | - | ### Verification | |
| 189 | - | - cargo clippy clean (0 warnings) | |
| 190 | - | - Zero `.unwrap()` in production code | |
| 191 | - | - 3 `.expect()` in production code (all acceptable: 1 constant regex in url_cleaner.rs, 1 Tauri `run()` entry point, 1 startup state init) | |
| 192 | - | ||
| 193 | - | ### Still open (5 items) | |
| 194 | - | - Fix 8 clippy violations (2 in bb-interface, 6 in bb-core test code) | |
| 195 | - | - Unit tests for `generator.rs` in-memory filtering logic (isolated from db) | |
| 196 | - | - Integration tests for Tauri commands with in-memory DB (OPML import/export, item CRUD, tag operations) | |
| 197 | - | - README with setup instructions | |
| 198 | - | - Plugin authoring guide | |
| 199 | - | ||
| 200 | - | ## Changes Since Last Audit (2026-02-28, second audit) | |
| 201 | - | ||
| 202 | - | ### New findings | |
| 203 | - | - **8 clippy violations** from newer Rust lints: | |
| 204 | - | - `items_after_test_module` in `bb-interface/src/busser.rs:270,441` (Busser trait defined after test module) | |
| 205 | - | - `useless_vec` in `bb-interface/src/busser.rs:303` (test code) | |
| 206 | - | - `bool_assert_comparison` in `bb-core/rhai_plugin/conversions.rs:713` (test code) | |
| 207 | - | - `approx_constant` in `bb-core/rhai_plugin/conversions.rs:724,726` (test code, uses `3.14` not `PI`) | |
| 208 | - | - `unnecessary_get_then_check` in `bb-core/rhai_plugin/conversions.rs:925,1199,1200` (test code) | |
| 209 | - | ||
| 210 | - | ### Grades changed | |
| 211 | - | - Overall: A -> A- (clippy violations) | |
| 212 | - | - Code Quality: A -> A- (8 clippy violations) | |
| 213 | - | ||
| 214 | - | ### No code regressions | |
| 215 | - | - 225 tests pass, 0 failures | |
| 216 | - | - Zero `.unwrap()` in production code | |
| 217 | - | - All previous security/code quality items remain resolved | |
| 218 | - | - The clippy violations are from stricter lints in newer Rust, not from code changes | |
| 219 | - | ||
| 220 | - | ## Changes Since Last Audit (2026-02-28, third audit) | |
| 221 | - | ||
| 222 | - | ### Fifth-run full audit (2026-03-01) | |
| 223 | - | ||
| 224 | - | Fresh audit of entire codebase per audit.md. Test count: 315 (was 225). 1 clippy warning (unused `OrderBy` import). 0 `.unwrap()` in non-test production code. 294 `.unwrap()` in test code (all inside `#[cfg(test)]`). | |
| 225 | - | ||
| 226 | - | ### Items resolved since third audit | |
| 227 | - | ||
| 228 | - | - **8 clippy violations**: All resolved (items_after_test_module, useless_vec, bool_assert_comparison, approx_constant, unnecessary_get_then_check). Code Quality grade restored. | |
| 229 | - | - **Generator isolation tests**: Tag filter tests added in `ordering.rs`. | |
| 230 | - | - **Integration tests for Tauri commands**: 33 command integration tests added (`src-tauri/tests/command_integration.rs`, 1,337 lines) exercising the full stack with in-memory SQLite. | |
| 231 | - | - **README with setup instructions**: Created (`README.md`). | |
| 232 | - | - **Plugin authoring guide**: Created (`docs/PLUGIN_AUTHORING.md`). | |
| 233 | - | - **Error handling**: `From` impls for `sqlx::Error`/`FeedError`/`OrchestratorError` → `ApiError`, replaced 28 `.map_err` calls. | |
| 234 | - | - **`is_single_feed_due` extracted**: Pure helper function for testability in `state.rs`. | |
| 235 | - | - **Host function tests**: 12 new tests for Rhai host functions. | |
| 236 | - | - **Orchestrator tests**: 5 new tests. | |
| 237 | - | - **State timing tests**: 3 new tests. | |
| 238 | - | ||
| 239 | - | ### New findings | |
| 240 | - | ||
| 241 | - | 1. **Stale `.env` contains PostgreSQL URL.** `.env:4` says `DATABASE_URL=postgres://localhost/balanced_breakfast` while the project is SQLite-only. `.env.example` correctly says `sqlite:`. The `.env` is gitignored but confusing for sqlx CLI and any non-Tauri binary. | |
| 242 | - | - File: `.env:4` | |
| 243 | - | ||
| 244 | - | 2. **1 clippy warning.** Unused `OrderBy` import in integration tests. | |
| 245 | - | - File: `src-tauri/tests/command_integration.rs:10` | |
| 246 | - | ||
| 247 | - | 3. **No doc comments on 46 pub repository methods.** `repository.rs` exposes 46 `pub async fn` methods across 4 repository structs, none with `///` doc comments. Method names are descriptive but parameter semantics (e.g., `cutoff` in `delete_stale_read`) are undocumented. | |
| 248 | - | - File: `crates/bb-db/src/repository.rs` | |
| 249 | - | ||
| 250 | - | 4. **`conversions.rs` error handling is string-based.** Returns `Result<_, Box<EvalAltResult>>` with string error messages via `EvalAltResult::ErrorRuntime(msg.into(), Position::NONE)`. Callers can only match on error strings, not error variants. | |
| 251 | - | - File: `crates/bb-core/src/rhai_plugin/conversions.rs` | |
| 252 | - | ||
| 253 | - | 5. **Pagination strategy inconsistency.** `get_items()` uses `page_size + 1` for `has_more` detection, but `get_all_items()` uses `page_size * 2` with `MAX_ALL_ITEMS = 10,000`. Documented in todo.md but unfixed. | |
| 254 | - | - File: `crates/bb-feed/src/generator.rs:68,102` | |
| 255 | - | ||
| 256 | - | 6. **Zero JavaScript tests.** 393 lines of JS (`app.js`, `api.js`, `state.js`) with keyboard navigation, search debouncing, reactive state store -- all untested. | |
| 257 | - | - Files: `src-tauri/frontend/js/` | |
| 258 | - | ||
| 259 | - | 7. **Background task testing gap.** `spawn_auto_fetch()` (lines 229-293) and `spawn_stale_cleanup()` (lines 298-324) contain non-trivial logic but are only tested indirectly. The event emission paths and error-to-event mapping are not tested. | |
| 260 | - | - File: `src-tauri/src/state.rs` | |
| 261 | - | ||
| 262 | - | 8. **Frontend has no loading/error states for search.** Search triggers `loadItems()` after a 300ms debounce, but no visual indicator during search and errors are silently swallowed via `console.error`. | |
| 263 | - | - File: `src-tauri/frontend/js/app.js:61-70` | |
| 264 | - | ||
| 265 | - | ### Grades assessed (fresh audit perspective) | |
| 266 | - | ||
| 267 | - | | Dimension | Previous | Fresh Audit | Notes | | |
| 268 | - | |-----------|:--------:|:-----------:|-------| | |
| 269 | - | | Code Quality | A- | A | 8 clippy violations fixed; 1 new warning (trivial) | | |
| 270 | - | | Architecture | A | A | Clean 4-crate separation confirmed | | |
| 271 | - | | Testing | A | A- | 315 tests (up from 225); JS tests graded C | | |
| 272 | - | | Security | A | A | All previous fixes intact | | |
| 273 | - | | Performance | A | A- | page_size inconsistency; SqlitePool max_connections=5 conservative | | |
| 274 | - | | Documentation | A- | B+ | Repository public API undocumented | | |
| 275 | - | | Dependencies | A | A | Unchanged | | |
| 276 | - | | Frontend | A | B+ | No JS tests, no search loading/error states | | |
| 277 | - | ||
| 278 | - | **Overall: A- (unchanged)** -- test coverage significantly improved (+90 tests) but documentation and JS testing gaps persist. | |
| 279 | - | ||
| 280 | - | ### New action items | |
| 281 | - | ||
| 282 | - | Filed in docs/todo.md: | |
| 283 | - | - Fix `.env` PostgreSQL URL (change to sqlite or delete) | |
| 284 | - | - Fix clippy warning (remove unused `OrderBy` import) | |
| 285 | - | - Add doc comments to repository public API (46 methods) | |
| 286 | - | - Unify pagination strategy (get_items vs get_all_items) | |
| 287 | - | - Add basic frontend testing (state.js, search debounce) | |
| 288 | - | - Add unit tests for background task logic (extract from spawn_auto_fetch) | |
| 289 | - | - Add loading indicator for search | |
| 290 | - | - Consider parallel plugin fetching for scale | |
| 291 | - | ||
| 292 | - | ### No regressions | |
| 293 | - | - 315 tests pass, 0 failures, 0 ignored | |
| 294 | - | - Zero `.unwrap()` in production code | |
| 295 | - | - All previous security fixes intact (XSS, file permissions, mutex panics) | |
| 296 | - | - 3 `.expect()` in production code (regex, Tauri run, startup init -- all acceptable) | |
| 297 | - | ||
| 298 | - | ### Still open (8 items) | |
| 299 | - | - Fix `.env` PostgreSQL URL | |
| 300 | - | - Fix clippy warning (unused OrderBy import) | |
| 301 | - | - Add repository doc comments (46 methods) | |
| 302 | - | - Unify pagination strategy | |
| 303 | - | - Add JS tests | |
| 304 | - | - Add background task unit tests | |
| 305 | - | - Add search loading indicator | |
| 306 | - | - Consider parallel plugin fetching | |
| 307 | - | ||
| 308 | - | ## Changes Since Previous Audit (2026-03-01, fourth audit findings) | |
| 309 | - | ||
| 310 | - | ### Cleanup and resolution phase (2026-03-02) | |
| 311 | - | ||
| 312 | - | All actionable findings from the fifth-run cross-project audit resolved. Test count: 320 (was 315). 0 clippy warnings. 0 failures. | |
| 313 | - | ||
| 314 | - | ### Items resolved | |
| 315 | - | ||
| 316 | - | - **Stale `.env` PostgreSQL URL**: Changed `DATABASE_URL` from `postgres://localhost/balanced_breakfast` to `sqlite:balanced_breakfast.db?mode=rwc`. Matches `.env.example`. | |
| 317 | - | - File: `.env` | |
| 318 | - | ||
| 319 | - | - **Clippy warning**: Removed unused `OrderBy` import from integration test file. | |
| 320 | - | - File: `src-tauri/tests/command_integration.rs` | |
| 321 | - | ||
| 322 | - | - **Pagination unification**: Standardized `get_items()` and `get_all_items()` to use the same `page_size + 1` has_more detection via `PaginatedItems` struct. Removed `page_size * 2` with `MAX_ALL_ITEMS` pattern. | |
| 323 | - | - File: `crates/bb-feed/src/generator.rs` | |
| 324 | - | ||
| 325 | - | - **Repository doc comments**: Added `///` documentation to all 46+ public methods in `repository.rs` (83 doc comment markers total). Parameter semantics documented (e.g., `cutoff` in `delete_stale_read`). | |
| 326 | - | - File: `crates/bb-db/src/repository.rs` | |
| 327 | - | ||
| 328 | - | - **Background task extraction**: Extracted `any_feed_due()` pure function from `is_fetch_due()` for testability. 5 unit tests added alongside existing `is_single_feed_due` tests. | |
| 329 | - | - File: `src-tauri/src/state.rs` | |
| 330 | - | ||
| 331 | - | - **Search loading indicator**: Added CSS spinner next to search input. Spinner appears during search execution and hides on completion. Uses existing BB design system variables. | |
| 332 | - | - Files: `src-tauri/frontend/index.html`, `src-tauri/frontend/css/styles.css`, `src-tauri/frontend/js/app.js` | |
| 333 | - | ||
| 334 | - | ### Test count change | |
| 335 | - | - 315 → 320 passed (+5 tests: 5 any_feed_due unit tests) | |
| 336 | - | ||
| 337 | - | ### Grades changed | |
| 338 | - | - Code Quality: A- → A (clippy warning fixed) | |
| 339 | - | - Documentation: B+ → A- (repository doc comments added) | |
| 340 | - | - Frontend: B+ → A- (search loading indicator added) | |
| 341 | - | - Performance: A- (unchanged; pagination unified) | |
| 342 | - | ||
| 343 | - | ### Still open (4 items) | |
| 344 | - | - Add JS tests (state.js, search debounce, keyboard navigation) | |
| 345 | - | - Generator isolation tests (in-memory tag filtering without DB) | |
| 346 | - | - DB-level command integration tests (OPML import/export, item CRUD, tag operations) | |
| 347 | - | - Consider parallel plugin fetching for scale | |
| 348 | - | ||
| 349 | - | ## Changes Since Previous Audit (2026-03-02, type safety newtypes) | |
| 350 | - | ||
| 351 | - | ### Entity ID newtypes + ApiErrorCode enum | |
| 352 | - | ||
| 353 | - | `FeedId`, `ItemId`, `BusserStateId` UUID newtypes introduced via `define_uuid_id!` macro in `crates/bb-db/src/id_types.rs`. `BusserId(String)` newtype defined manually. `ApiErrorCode` enum replaces `ApiError.code: String`. All model structs, repository methods, commands, and generator updated. | |
| 354 | - | ||
| 355 | - | ### Grades changed | |
| 356 | - | - Type Safety: B+ → A (entity ID newtypes, ApiErrorCode enum) |
| @@ -1,417 +0,0 @@ | |||
| 1 | - | # Balanced Breakfast -- Competitive Analysis | |
| 2 | - | ||
| 3 | - | Last updated: 2026-02-27 | |
| 4 | - | ||
| 5 | - | BB is a local-first Tauri 2 desktop feed aggregator with a Rhai plugin system for custom feed sources (RSS, Hacker News, arXiv, and anything scriptable), three-panel keyboard-driven reader, SQLite storage, OPML import/export. | |
| 6 | - | ||
| 7 | - | ## Positioning | |
| 8 | - | ||
| 9 | - | BB is the only native desktop feed reader with a user-scriptable plugin system for arbitrary sources. Competitors are either RSS-only or have fixed integrations. BB's competitive position is strongest against the cloud/AI-heavy readers (Feedly, Inoreader) by being the anti-surveillance, no-account alternative. Against the open-source self-hosted options (Miniflux, FreshRSS), BB differentiates with its desktop-native UX and Rhai plugin extensibility. Against the polished Apple-ecosystem readers (NetNewsWire, Reeder), BB differentiates with its custom source plugins (Hacker News, arXiv, anything scriptable) and hackability. | |
| 10 | - | ||
| 11 | - | ## Pricing Comparison | |
| 12 | - | ||
| 13 | - | | App | Free Tier | Paid | Model | | |
| 14 | - | |-----|-----------|------|-------| | |
| 15 | - | | **Balanced Breakfast** | **Unlimited** | **Free** | Source-available | | |
| 16 | - | | NetNewsWire | Unlimited | Free | Open source (MIT) | | |
| 17 | - | | Miniflux | Self-hosted | $15/yr hosted | Open source (Apache) | | |
| 18 | - | | NewsBlur | 64 sites | $36/yr | Open source (MIT) | | |
| 19 | - | | Feedly | 100 sources | $96-156/yr | Cloud SaaS | | |
| 20 | - | | Inoreader | 150 feeds | $60-120/yr | Cloud SaaS | | |
| 21 | - | | Reeder | 10 feeds | $10/yr | Proprietary | | |
| 22 | - | | Readwise Reader | None | $120-156/yr | Cloud SaaS | | |
| 23 | - | | Newsboat | Unlimited | Free | Open source (MIT) | | |
| 24 | - | | FreshRSS | Self-hosted | Free | Open source (AGPL-3.0) | | |
| 25 | - | ||
| 26 | - | ## Feature Matrix | |
| 27 | - | ||
| 28 | - | | Feature | BB | Feedly | Inoreader | NNW | Reeder | Miniflux | NewsBlur | Readwise | Newsboat | FreshRSS | | |
| 29 | - | |---------|:--:|:------:|:---------:|:---:|:------:|:--------:|:--------:|:--------:|:--------:|:--------:| | |
| 30 | - | | **Free (no limits)** | Yes | 100 feeds | 150 feeds | Yes | 10 feeds | Self-host | 64 sites | No | Yes | Yes | | |
| 31 | - | | **Source-available** | Yes | No | No | Yes | No | Yes | Yes | No | Yes | Yes | | |
| 32 | - | | **Native desktop** | Yes | No | No | Yes (Mac) | Yes (Mac) | No | No | No | Terminal | No | | |
| 33 | - | | **Plugin system** | Yes (Rhai) | No | No | No | No | No | No | No | Macros | Yes (PHP) | | |
| 34 | - | | **HN first-class** | Yes | No | No | No | No | No | No | No | No | No | | |
| 35 | - | | **arXiv first-class** | Yes | No | No | No | No | No | No | No | No | No | | |
| 36 | - | | **Local-only data** | Yes | No | No | Optional | Optional | Self-host | Self-host | No | Yes | Self-host | | |
| 37 | - | | **Cross-platform** | Mac+ | Web | Web | Apple | Apple | Web | Web | Web | Unix | Web | | |
| 38 | - | | **Mobile app** | No | Yes | Yes | Yes | Yes | No | Yes | Yes | No | Via API | | |
| 39 | - | | **Cloud sync** | No | Yes | Yes | Yes | Yes | Self-host | Yes | Yes | Via backends | Self-host | | |
| 40 | - | | **Full-text search** | Planned | Paid | Paid | Yes | No | Yes | Paid | Yes | No | Yes | | |
| 41 | - | | **AI features** | No | Yes (Leo) | Yes | No | No | No | Yes | Yes | No | No | | |
| 42 | - | | **Reader view** | Planned | No | No | Yes | No | Yes | No | Yes | No | Yes | | |
| 43 | - | | **Highlights** | No | No | No | No | No | No | No | Yes | No | No | | |
| 44 | - | | **Multi-source** | Via plugins | Partial | Yes | No | Yes | Partial | Partial | Partial | No | No | | |
| 45 | - | | **OPML** | Yes | Yes | Yes | Yes | No | Yes | Yes | No | Yes | Yes | | |
| 46 | - | | **JSON Feed** | Planned | No | No | Yes | No | Yes | No | No | No | No | | |
| 47 | - | | **Tracker stripping** | Planned | No | No | No | No | Yes | No | No | No | No | | |
| 48 | - | | **Keyboard shortcuts** | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | |
| 49 | - | | **Folders/tags/categories** | No | Yes | Yes | Yes | No | Yes | No | No | Yes | Yes | | |
| 50 | - | | **Filter/query feeds** | No | Yes | Yes | No | Yes | No | No | No | Yes | No | | |
| 51 | - | | **WebSub (push)** | No | No | No | No | No | No | No | No | No | Yes | | |
| 52 | - | | **REST API** | No | Yes | Yes | No | No | Yes | No | No | No | Yes | | |
| 53 | - | | **Multi-user** | No | Yes | Yes | No | No | Yes | No | No | No | Yes | | |
| 54 | - | ||
| 55 | - | ## Competitor Deep Dives | |
| 56 | - | ||
| 57 | - | ### NetNewsWire | |
| 58 | - | ||
| 59 | - | Free, open-source native RSS reader for macOS and iOS (Swift). | |
| 60 | - | ||
| 61 | - | **Pricing:** Free (open-source, no paid tiers). | |
| 62 | - | ||
| 63 | - | BB differentiator: BB has plugins and cross-platform; NNW has Apple ecosystem maturity + iCloud sync. | |
| 64 | - | ||
| 65 | - | Features BB lacks: | |
| 66 | - | - iCloud sync across devices | |
| 67 | - | - Third-party sync backends (Feedbin, Feedly, BazQux, Inoreader, NewsBlur, The Old Reader, FreshRSS) | |
| 68 | - | - Multiple accounts (run several sync accounts simultaneously) | |
| 69 | - | - Reader view (strips ads/chrome from articles) | |
| 70 | - | - Smart feeds (Today, All Unread) | |
| 71 | - | - Folders / hierarchical organization | |
| 72 | - | - Custom article themes (swap CSS for the reading pane) | |
| 73 | - | - iOS / mobile app with home screen widgets | |
| 74 | - | - Share sheet integration | |
| 75 | - | - Background refresh (iOS) | |
| 76 | - | - JSON Feed and RSS-in-JSON support | |
| 77 | - | ||
| 78 | - | ### Reeder | |
| 79 | - | ||
| 80 | - | Polished content hub for RSS, podcasts, YouTube, Reddit, Mastodon, and Bluesky (macOS/iOS). | |
| 81 | - | ||
| 82 | - | **Pricing:** Free download; premium $1/month or $10/year. Reeder Classic: one-time $10 (Mac) / $5 (iOS). | |
| 83 | - | ||
| 84 | - | BB differentiator: Similar philosophy to BB's plugin approach, but Reeder builds integrations natively while BB delegates to user-writable scripts. | |
| 85 | - | ||
| 86 | - | Features BB lacks: | |
| 87 | - | - Podcast playback with cross-device resume | |
| 88 | - | - YouTube / video feed integration with in-app player | |
| 89 | - | - Mastodon and Bluesky feeds | |
| 90 | - | - Reddit feed integration | |
| 91 | - | - Read-later / save links (Links, Favorites, Bookmarks, Later) | |
| 92 | - | - iCloud sync (subscriptions, position, tags) | |
| 93 | - | - Custom filters by keyword, media type, feed type | |
| 94 | - | - Gesture-based navigation (touch-friendly UX) | |
| 95 | - | - Public tag feeds (export tags as JSON Feed) | |
| 96 | - | - Content-type-specific viewers (articles, photos, videos, social posts, podcasts) | |
| 97 | - | - Share extension | |
| 98 | - | - iOS / mobile app | |
| 99 | - | ||
| 100 | - | ### Feedly | |
| 101 | - | ||
| 102 | - | Cloud-based RSS reader with heavy AI features for individuals and enterprise intel teams. | |
| 103 | - | ||
| 104 | - | **Pricing:** Free (100 sources, 3 feeds, 3 boards) | Pro $6.99/mo | Pro+ $12.99/mo | Enterprise custom | Market/Threat Intel $1,600-$3,200/mo. | |
| 105 | - | ||
| 106 | - | Features BB lacks: | |
| 107 | - | - Cloud-hosted with web UI (no install required) | |
| 108 | - | - Leo AI -- topic/trend prioritization `[DATA-HUNGRY]` | |
| 109 | - | - Leo AI -- article summarization `[DATA-HUNGRY]` | |
| 110 | - | - Leo AI -- deduplication `[DATA-HUNGRY]` | |
| 111 | - | - AI Feeds (NLP-based topic feeds via natural language queries) `[DATA-HUNGRY]` | |
| 112 | - | - Mute filters (keyword suppression) `[BLOAT]` | |
| 113 | - | - Team Boards (collaborative article saving) `[BLOAT]` | |
| 114 | - | - Automated newsletters (email digest) `[BLOAT]` | |
| 115 | - | - Power Search (full-text across entire feed history, paid) | |
| 116 | - | - Saved searches / alerts (persistent keyword monitoring) | |
| 117 | - | - Slack / MS Teams integration `[BLOAT]` | |
| 118 | - | - Newsletter subscriptions | |
| 119 | - | - Mobile apps (iOS, Android) | |
| 120 | - | - Folders and tags | |
| 121 | - | - Notes and highlights `[BLOAT]` | |
| 122 | - | - HTTPS Feeds fetcher / RSS Builder (generate feeds from pages without RSS) | |
| 123 | - | - Threat Intelligence product `[BLOAT]` | |
| 124 | - | - Market Intelligence product `[BLOAT]` | |
| 125 | - | - Usage tracking and analytics `[DATA-HUNGRY]` `[INVASIVE]` | |
| 126 | - | ||
| 127 | - | ### Inoreader | |
| 128 | - | ||
| 129 | - | Cloud-based RSS reader with automation rules, web monitoring, and AI intelligence tools. | |
| 130 | - | ||
| 131 | - | **Pricing:** Free (150 feeds, ads) | Pro $7.50/mo (no ads, rules, active search) | Team 5 $25/mo | Team 10 $50/mo | Enterprise custom. | |
| 132 | - | ||
| 133 | - | Features BB lacks: | |
| 134 | - | - Cloud-hosted with web UI | |
| 135 | - | - Automation rules (auto-tag, star, email, broadcast) | |
| 136 | - | - Active Search / monitoring feeds (persistent keyword monitoring) | |
| 137 | - | - Web page monitoring (non-RSS) `[DATA-HUNGRY]` | |
| 138 | - | - AI article summaries and prompts `[DATA-HUNGRY]` | |
| 139 | - | - Intelligence reports (multi-article analysis) `[DATA-HUNGRY]` | |
| 140 | - | - Email digest / push notifications | |
| 141 | - | - Mobile apps (iOS, Android) | |
| 142 | - | - Folders, tags, and bundles | |
| 143 | - | - Save web pages for later | |
| 144 | - | - IFTTT / Zapier integration | |
| 145 | - | - Podcast support with AI transcripts `[DATA-HUNGRY]` | |
| 146 | - | - Social media feed support (YouTube, Twitter, Facebook) | |
| 147 | - | - Built-in article translation `[DATA-HUNGRY]` | |
| 148 | - | - Feed update frequency guarantee (1hr for Pro) | |
| 149 | - | - Ads in free tier `[INVASIVE]` | |
| 150 | - | - Usage analytics `[DATA-HUNGRY]` `[INVASIVE]` | |
| 151 | - | ||
| 152 | - | ### Miniflux | |
| 153 | - | ||
| 154 | - | Self-hosted minimalist RSS reader written in Go, backed by PostgreSQL. | |
| 155 | - | ||
| 156 | - | **Pricing:** Free (self-hosted, Apache 2.0) | Hosted $15/year. | |
| 157 | - | ||
| 158 | - | BB differentiator: BB is a native desktop app with plugins; Miniflux is a web app accessed in a browser with no extension system. | |
| 159 | - | ||
| 160 | - | Features BB lacks: | |
| 161 | - | - Web-based UI (accessible from any device) | |
| 162 | - | - Full-text search (Postgres-powered) | |
| 163 | - | - Pixel tracker removal | |
| 164 | - | - URL parameter stripping (utm_*, fbclid, etc.) | |
| 165 | - | - Podcast/video/image enclosure support | |
| 166 | - | - YouTube video playback in-app | |
| 167 | - | - 25+ third-party integrations (Instapaper, Pinboard, Wallabag, Telegram, Slack, etc.) | |
| 168 | - | - REST API (with Go and Python client libraries) | |
| 169 | - | - Multiple authentication methods (local, Passkeys/WebAuthn, OAuth2, OIDC, reverse-proxy) | |
| 170 | - | - Categories (flat organization) | |
| 171 | - | - 20 language translations | |
| 172 | - | - Multi-user support | |
| 173 | - | - Scraper / fetch original content | |
| 174 | - | - Automatic HTTPS (built-in Let's Encrypt) | |
| 175 | - | - JSON Feed support (1.0/1.1) | |
| 176 | - | ||
| 177 | - | ### Newsboat | |
| 178 | - | ||
| 179 | - | Terminal-based RSS/Atom reader for Unix systems (C++, MIT license). | |
| 180 | - | ||
| 181 | - | **Pricing:** Free (open-source, MIT). | |
| 182 | - | ||
| 183 | - | Features BB lacks: | |
| 184 | - | - Tags (non-hierarchical organization) | |
| 185 | - | - Query feeds (virtual feeds from filter expressions) | |
| 186 | - | - Killfiles (hide unwanted content by pattern) | |
| 187 | - | - Macro system (multi-command macros) | |
| 188 | - | - Bookmarking scripts (send articles to external services via custom scripts) | |
| 189 | - | - Filter language (title, author, content, date, etc.) | |
| 190 | - | - Podboat (companion podcast downloader) | |
| 191 | - | - External browser integration | |
| 192 | - | - Configurable keybindings (remap any key) | |
| 193 | - | - Colorscheme customization (full color config) | |
| 194 | - | - Third-party service sync (TTRSS, Inoreader, NewsBlur, Nextcloud News, etc.) | |
| 195 | - | - Conditional formatting (display rules based on article attributes) | |
| 196 | - | - Article pipe to external command | |
| 197 | - | ||
| 198 | - | ### FreshRSS | |
| 199 | - | ||
| 200 | - | Self-hosted, multi-user web-based RSS aggregator (PHP/MySQL or PostgreSQL). | |
| 201 | - | ||
| 202 | - | **Pricing:** Free (open-source, AGPL-3.0). | |
| 203 | - | ||
| 204 | - | Features BB lacks: | |
| 205 | - | - Web-based UI (accessible from any device/browser) | |
| 206 | - | - Multi-user with per-user customization | |
| 207 | - | - WebSub (instant push from WordPress, Medium, Friendica, etc.) | |
| 208 | - | - Extension/plugin system (PHP-based, official + community extensions) | |
| 209 | - | - Google Reader API and Fever API (compatible with dozens of third-party clients) | |
| 210 | - | - Labels / custom tags | |
| 211 | - | - Full-text search | |
| 212 | - | - Anonymous reading mode (public-facing read-only access) | |
| 213 | - | - Article resharing (HTML, RSS, OPML) | |
| 214 | - | - Mobile API support (use any compatible mobile RSS app as frontend) | |
| 215 | - | - Sorting by article length | |
| 216 | - | - Advanced search form (structured multi-field search) | |
| 217 | - | - Themes (multiple built-in + custom) | |
| 218 | - | - Scales to 1M+ articles and 50K+ feeds | |
| 219 | - | - Multiple authentication methods (web form, HTTP auth, OIDC) | |
| 220 | - | - Content scraping (fetch full articles from summary-only feeds) | |
| 221 | - | ||
| 222 | - | ## Common Missing Features | |
| 223 | - | ||
| 224 | - | Features that appear in 3+ competitors but BB currently lacks. | |
| 225 | - | ||
| 226 | - | ### Worth Adding | |
| 227 | - | ||
| 228 | - | - **Full-text search** -- Every competitor except Reeder and Newsboat has this. [Planned] via SQLite FTS5. | |
| 229 | - | - **Tags / categories for feed organization** -- NNW, Feedly, Inoreader, Miniflux, Newsboat, FreshRSS all have it. BB currently has no hierarchical or flat organization beyond sources. High-impact addition. | |
| 230 | - | - **Reader view / full-article fetch** -- NNW, Miniflux, FreshRSS, Inoreader all strip ads and fetch full content. [Planned] as a plugin. | |
| 231 | - | - **JSON Feed format support** -- NNW and Miniflux support it. Low effort, broadens feed compatibility. [Planned] in the core RSS parsing layer. | |
| 232 | - | - **URL tracker parameter stripping** -- Miniflux has it (utm_*, fbclid, etc.). Easy privacy win. [Planned]. | |
| 233 | - | ||
| 234 | - | ### Consider | |
| 235 | - | ||
| 236 | - | - **Podcast / media enclosure support** -- Reeder, Inoreader, Miniflux, Newsboat (Podboat). Could be a plugin (Rhai script that handles enclosures). Don't build into core -- let the plugin system prove itself. | |
| 237 | - | - **Filter / query feeds (virtual feeds from rules)** -- Newsboat, Inoreader, Reeder, Feedly. Virtual feeds based on keyword/regex filters across all sources. Good Phase 5+ feature. | |
| 238 | - | - **Content scraping (full-article fetch from summary-only feeds)** -- Miniflux, FreshRSS, Inoreader. Heavier than reader view; consider as a plugin. | |
| 239 | - | - **Newsletter ingestion** -- Inoreader, NewsBlur, Readwise ingest newsletters via dedicated email address. | |
| 240 | - | - **Social feeds** -- Reeder pulls Reddit, Mastodon, Bluesky. Inoreader pulls YouTube, Facebook. Could be Rhai plugins. | |
| 241 | - | - **Configurable keybindings** -- Newsboat allows full keybinding remapping. Could be a theming/config feature. | |
| 242 | - | ||
| 243 | - | ### Skip | |
| 244 | - | ||
| 245 | - | - **Cloud/cross-device sync** -- Conflicts with BB's local-first, no-account philosophy. If sync is ever needed, consider optional file-based or peer-to-peer sync rather than cloud. | |
| 246 | - | - **Mobile app / mobile access** -- [Deferred] in BB's roadmap (Tauri mobile). Web access is antithetical to local-first. Revisit after desktop is mature. | |
| 247 | - | - **AI features (summaries, prioritization, topic detection)** -- Requires cloud, data collection, and ongoing cost. Antithetical to BB's local-first, privacy-focused identity. If users want AI, they can pipe content to local LLMs via a plugin. | |
| 248 | - | - **Third-party sync backends** -- BB is a standalone reader, not a client for hosted services. Adds complexity without serving BB's target users. | |
| 249 | - | - **REST API / programmatic access** -- BB is a desktop app, not a service. No need for an API unless multi-client access becomes a goal. | |
| 250 | - | - **Multi-user support** -- BB is a single-user desktop app. Multi-user is a server concern. | |
| 251 | - | - **Social features** -- NewsBlur's blurblogs and following. Against BB's local-first philosophy. | |
| 252 | - | - **Highlights/annotations** -- Readwise's core feature. Different product category (PKM). | |
| 253 | - | - **Rules/automation** -- Inoreader's auto-sort/auto-tag rules. Nice-to-have but low priority. | |
| 254 | - | - **RSS Builder** -- Feedly creates feeds from sites without RSS. Niche. | |
| 255 | - | - **Browser extension** -- Readwise, Inoreader have save-for-later extensions. | |
| 256 | - | - **Threat/Market Intelligence** -- Feedly enterprise products. Different product category entirely. | |
| 257 | - | ||
| 258 | - | ## What We Offer That Competitors Don't | |
| 259 | - | ||
| 260 | - | - **Rhai plugin system** -- User-scriptable source adapters. No other reader is extensible this way. NNW and Reeder have fixed integrations; Miniflux and NewsBlur have APIs but no plugin runtime. Plugins declare their own fetch intervals and config schemas, giving the app fine-grained control over behavior. | |
| 261 | - | - **Hacker News as a first-class source** -- Top, New, Best, Ask HN, Show HN, Jobs. No other reader treats HN as a native source. | |
| 262 | - | - **arXiv as a first-class source** -- Browse by category (cs.AI, cs.LG, cs.CL, etc.). Unique. | |
| 263 | - | - **Free with no limits** -- No feed caps, no feature gating, no premium tier. Only NNW, Newsboat, and Miniflux (self-hosted) match this. | |
| 264 | - | - **Cross-platform native** -- Tauri runs on macOS, Windows, Linux. NNW is Apple-only. Reeder is Apple-only. Miniflux/NewsBlur/FreshRSS are web-only. | |
| 265 | - | - **No account required** -- No signup, no email, no cloud. Start reading immediately. | |
| 266 | - | - **Planned community plugin store** -- A marketplace where users browse, install, and publish feed source plugins from within the app -- turning it into an extensible platform. | |
| 267 | - | - **Rust backend** -- High performance, low resource usage, memory safety. No Electron bloat. | |
| 268 | - | ||
| 269 | - | ## Key Dynamics | |
| 270 | - | ||
| 271 | - | Highest-priority gaps to close: | |
| 272 | - | 1. Tags / categories for feed organization | |
| 273 | - | 2. Full-text search [Planned] | |
| 274 | - | 3. Reader view / full-article fetch [Planned] | |
| 275 | - | 4. JSON Feed format support [Planned] | |
| 276 | - | 5. URL tracker parameter stripping [Planned] | |
| 277 | - | 6. Filter / query feeds (virtual feeds from rules) | |
| 278 | - | ||
| 279 | - | Strengths to double down on: | |
| 280 | - | 1. Rhai plugin system -- ship the Plugin Store (Phase 4) | |
| 281 | - | 2. Local-first, no-account, no-telemetry positioning | |
| 282 | - | 3. Keyboard-driven desktop-native UX | |
| 283 | - | 4. Custom non-RSS sources (HN, arXiv, and user-created) | |
| 284 | - | ||
| 285 | - | ## Target Users | |
| 286 | - | ||
| 287 | - | - Power users and developers who consume content from many sources (RSS, Hacker News, arXiv, etc.) and want a single unified reader | |
| 288 | - | - Users who want to extend their feed reader with custom sources via lightweight scripting (Rhai plugins) | |
| 289 | - | - Privacy-conscious users who prefer a local-first desktop app over cloud-based feed services | |
| 290 | - | - Technical professionals who value keyboard shortcuts, fast navigation, and hackability | |
| 291 | - | ||
| 292 | - | ## Full Feature Inventory | |
| 293 | - | ||
| 294 | - | ### Feed Management | |
| 295 | - | ||
| 296 | - | | Feature | Status | | |
| 297 | - | |---------|--------| | |
| 298 | - | | Add RSS feeds | Done | | |
| 299 | - | | Add Hacker News feed (plugin) | Done | | |
| 300 | - | | Add arXiv feed (plugin) | Done | | |
| 301 | - | | Delete feed / source | Done | | |
| 302 | - | | Background auto-fetch per plugin (configurable intervals, default 15min) | Done | | |
| 303 | - | | Per-source unread count | Done | | |
| 304 | - | | Feed health monitoring | Planned | | |
| 305 | - | | Stale item cleanup (auto-archive old read items) | Planned | | |
| 306 | - | | Validate feed config inputs | Planned | | |
| 307 | - | ||
| 308 | - | ### Content Display | |
| 309 | - | ||
| 310 | - | | Feature | Status | | |
| 311 | - | |---------|--------| | |
| 312 | - | | Three-panel layout (sources, items, detail) | Done | | |
| 313 | - | | Native menu bar (File, View, Edit, Help) | Done | | |
| 314 | - | | Keyboard shortcuts (j/k, s, r, /, Escape) | Done | | |
| 315 | - | | View modes: All / Unread / Starred | Done | | |
| 316 | - | | Star items | Done | | |
| 317 | - | | Mark items as read | Done | | |
| 318 | - | ||
| 319 | - | ### Plugin System | |
| 320 | - | ||
| 321 | - | | Feature | Status | | |
| 322 | - | |---------|--------| | |
| 323 | - | | Rhai scripting engine for plugins | Done | | |
| 324 | - | | Plugin bundling (dev-mode + production) | Done | | |
| 325 | - | | BusserCapabilities (fetch_interval_secs, config_schema) | Done | | |
| 326 | - | | Max operations limit (100K) | Done | | |
| 327 | - | | Plugin error handling (warn/debug logging, toast) | Done | | |
| 328 | - | | Plugin sandboxing / security model | Deferred | | |
| 329 | - | ||
| 330 | - | ### Import / Export | |
| 331 | - | ||
| 332 | - | | Feature | Status | | |
| 333 | - | |---------|--------| | |
| 334 | - | | OPML import (File menu + Cmd+I) | Done | | |
| 335 | - | | OPML export (File menu + Cmd+E) | Done | | |
| 336 | - | | Export plugin command (package .rhai + manifest) | Planned (Phase 4C) | | |
| 337 | - | ||
| 338 | - | ### Search and Filtering | |
| 339 | - | ||
| 340 | - | | Feature | Status | | |
| 341 | - | |---------|--------| | |
| 342 | - | | Client-side search filter (/ shortcut) | Done | | |
| 343 | - | | Full-text server-side search (SQLite FTS5) | Planned | | |
| 344 | - | | Advanced filtering (date ranges, tags, content type) | Deferred | | |
| 345 | - | ||
| 346 | - | ### Theming | |
| 347 | - | ||
| 348 | - | | Feature | Status | | |
| 349 | - | |---------|--------| | |
| 350 | - | | Breakfast theme CSS (default) | Done | | |
| 351 | - | | Standardized cross-app theming support | Planned | | |
| 352 | - | ||
| 353 | - | ### Security | |
| 354 | - | ||
| 355 | - | | Feature | Status | | |
| 356 | - | |---------|--------| | |
| 357 | - | | Rhai max operations limit | Done | | |
| 358 | - | | Plugin error isolation (warn + toast, no crash) | Done | | |
| 359 | - | | Encrypt plugin secrets at rest | Planned | | |
| 360 | - | | Plugin sandboxing (HTTP restrictions, URL allowlists) | Deferred | | |
| 361 | - | ||
| 362 | - | ### Plugin Store (Phase 4) | |
| 363 | - | ||
| 364 | - | | Feature | Status | | |
| 365 | - | |---------|--------| | |
| 366 | - | | Plugin registry backend (index file, manifests) | Planned (4A) | | |
| 367 | - | | CI validation pipeline for submissions | Planned (4A) | | |
| 368 | - | | Submission flow (PR-based or upload API) | Planned (4A) | | |
| 369 | - | | Store UI (browse, search, filter by tag) | Planned (4B) | | |
| 370 | - | | Plugin detail view (description, author, version, install count) | Planned (4B) | | |
| 371 | - | | One-click install | Planned (4B) | | |
| 372 | - | | Update detection and prompt | Planned (4B) | | |
| 373 | - | | Uninstall (remove file + clean up feeds/state) | Planned (4B) | | |
| 374 | - | | Publish from app (package + submit to registry) | Planned (4C) | | |
| 375 | - | | Author identity (GitHub linking or manifest field) | Planned (4C) | | |
| 376 | - | | Plugin review queue (manual/automated approval) | Planned (4D) | | |
| 377 | - | | Permission declarations in manifest | Planned (4D) | | |
| 378 | - | | User consent prompt on install | Planned (4D) | | |
| 379 | - | | Abuse reporting (flag button, maintainer notification) | Planned (4D) | | |
| 380 | - | | Plugin revocation (delist / force-uninstall) | Planned (4D) | | |
| 381 | - | ||
| 382 | - | ### Data and Storage | |
| 383 | - | ||
| 384 | - | | Feature | Status | | |
| 385 | - | |---------|--------| | |
| 386 | - | | SQLite local database | Done | | |
| 387 | - | | Database migrations (SQLite) | Done | | |
| 388 | - | | Busser state persistence | Done | | |
| 389 | - | | Read history / analytics | Deferred | | |
| 390 | - | ||
| 391 | - | ### Testing and Documentation | |
| 392 | - | ||
| 393 | - | | Feature | Status | | |
| 394 | - | |---------|--------| | |
| 395 | - | | bb-core plugin_manager unit tests (4 tests) | Done | | |
| 396 | - | | bb-db repository unit tests (25 tests) | Done | | |
| 397 | - | | bb-feed ordering/generator unit tests | Planned | | |
| 398 | - | | Tauri command integration tests | Planned | | |
| 399 | - | | README with setup instructions | Planned | | |
| 400 | - | | Plugin authoring guide | Planned | | |
| 401 | - | ||
| 402 | - | ### Platform and Distribution | |
| 403 | - | ||
| 404 | - | | Feature | Status | | |
| 405 | - | |---------|--------| | |
| 406 | - | | macOS desktop app (Tauri 2) | Done | | |
| 407 | - | | App icons (fried-egg AI-star parody logo) | Done | | |
| 408 | - | | macOS release binary for distribution | Planned | | |
| 409 | - | | Mobile support (iOS/Android via Tauri mobile) | Deferred | | |
| 410 | - | ||
| 411 | - | Platform details: | |
| 412 | - | - **Runtime:** Tauri 2 desktop app (Rust backend + vanilla JS frontend, no framework) | |
| 413 | - | - **Primary platform:** macOS (planned release binary for distribution) | |
| 414 | - | - **Potential platforms:** Windows, Linux (Tauri supports all three), iOS/Android (deferred, via Tauri mobile) | |
| 415 | - | - **Distribution:** Direct download binary (planned); potential listing on MakeNotWork creator platform | |
| 416 | - | - **Data storage:** Local SQLite database in the app's config directory | |
| 417 | - | - **Plugin distribution:** Bundled plugins ship with the app; planned in-app Plugin Store for community plugins |