diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ad071cd..455294f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -83,6 +83,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -225,6 +237,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -420,6 +454,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -456,6 +492,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -463,11 +505,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -478,6 +531,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -503,6 +574,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -526,7 +607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types", "libc", @@ -539,7 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.1", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -862,6 +943,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -964,6 +1054,23 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "feed-rs" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c0591d23efd0d595099af69a31863ac1823046b1b021e3b06ba3aae7e00991" +dependencies = [ + "chrono", + "mediatype", + "quick-xml 0.37.5", + "regex", + "serde", + "serde_json", + "siphasher 1.0.2", + "url", + "uuid", +] + [[package]] name = "field-offset" version = "0.3.6" @@ -1044,6 +1151,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1274,8 +1387,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1285,9 +1400,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1451,6 +1568,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1585,6 +1721,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1595,6 +1732,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1613,9 +1765,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1907,6 +2061,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -2057,6 +2221,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac" version = "0.1.1" @@ -2105,6 +2275,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "mediatype" +version = "0.19.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33746aadcb41349ec291e7f2f0a3aa6834d1d7c58066fb4b01f68efc4c4b7631" +dependencies = [ + "serde", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2388,6 +2567,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2688,7 +2873,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -2828,6 +3013,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.38.4" @@ -2837,6 +3032,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2883,6 +3134,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2903,6 +3164,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + [[package]] name = "rand_core" version = "0.5.1" @@ -2921,6 +3192,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -3018,6 +3298,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" name = "releader" version = "0.1.0" dependencies = [ + "chrono", + "feed-rs", + "reqwest", "rusqlite", "serde", "serde_json", @@ -3025,6 +3308,8 @@ dependencies = [ "tauri-build", "tauri-plugin-opener", "tauri-plugin-store", + "tokio", + "uuid", ] [[package]] @@ -3035,21 +3320,30 @@ checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3061,6 +3355,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3103,6 +3411,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3118,6 +3501,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3175,6 +3567,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3565,6 +3980,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3618,6 +4039,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3639,7 +4081,7 @@ checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ "bitflags 2.11.1", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -4070,6 +4512,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.1" @@ -4079,7 +4536,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -4096,6 +4555,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4235,13 +4704,18 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ + "async-compression", "bitflags 2.11.1", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", "iri-string", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -4400,6 +4874,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4648,6 +5128,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.4" @@ -4704,6 +5194,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4889,6 +5388,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4934,6 +5444,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5492,6 +6011,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 52d678e..62e739e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,3 +24,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rusqlite = { version = "0.32", features = ["bundled"] } tauri-plugin-store = "2.4.2" +reqwest = { version = "0.13.2", features = ["rustls", "json", "gzip", "brotli", "deflate"] } +feed-rs = "2.3.1" +chrono = "0.4.44" +tokio = { version = "1.52.1", features = ["full"] } +uuid = { version = "1.23.1", features = ["v4"] } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index f3ec234..4a7925e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -1,11 +1,30 @@ -use rusqlite::{params, Connection}; -use serde::Serialize; -use std::collections::HashMap; +use chrono::{DateTime, Utc}; +use feed_rs::model::{Entry, Feed}; +use reqwest::header::{ETAG, IF_MODIFIED_SINCE, IF_NONE_MATCH, LAST_MODIFIED}; +use reqwest::Client; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use std::{fs, path::PathBuf}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::Mutex; +use tokio::time::{sleep, Duration}; +use uuid::Uuid; + +const SIDEBAR_EXPANDED_FOLDER_IDS_KEY: &str = "sidebar.expandedFolderIds"; +const FEED_SYNC_POLL_INTERVAL_SECONDS: u64 = 60; +const FEED_SYNC_INTERVAL_MINUTES: i64 = 20; +const FEED_SYNC_CONCURRENCY: usize = 4; +const ARTICLE_PAGE_SIZE_DEFAULT: i64 = 50; pub struct AppState { pub db_path: PathBuf, + pub sync_state: Arc, +} + +pub struct FeedSyncState { + pub inflight_feed_ids: Mutex>, } #[derive(Debug, Serialize)] @@ -30,6 +49,8 @@ pub struct FeedRecord { pub sort_order: i64, pub icon_url: Option, pub last_fetched_at: Option, + pub last_fetch_status: Option, + pub last_fetch_error: Option, pub created_at: String, pub updated_at: String, } @@ -41,7 +62,40 @@ pub struct SidebarData { pub feeds: Vec, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArticleRecord { + pub id: String, + pub feed_id: String, + pub feed_title: String, + pub title: String, + pub url: Option, + pub author: Option, + pub summary_text: Option, + pub summary_html: Option, + pub content_html: Option, + pub thumbnail_url: Option, + pub published_at: Option, + pub fetched_at: String, + pub is_read: bool, + pub is_starred: bool, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ArticlePage { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ArticleCursor { + pub published_at: Option, + pub id: String, +} + +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FolderMoveInput { pub folder_id: String, @@ -49,7 +103,7 @@ pub struct FolderMoveInput { pub sort_order: i64, } -#[derive(Debug, serde::Deserialize)] +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FeedMoveInput { pub feed_id: String, @@ -57,6 +111,52 @@ pub struct FeedMoveInput { pub sort_order: i64, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListArticlesInput { + pub scope: String, + pub feed_id: Option, + pub limit: Option, + pub cursor: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RefetchFeedResult { + pub feed_id: String, + pub inserted_count: usize, + pub updated_count: usize, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DeleteFeedResult { + pub feed_id: String, +} + +#[derive(Debug, Clone)] +struct FeedFetchTarget { + id: String, + title: String, + url: String, + etag: Option, + last_modified: Option, +} + +#[derive(Debug)] +struct ParsedArticle { + title: String, + url: Option, + guid: Option, + external_id: String, + author: Option, + summary_text: Option, + summary_html: Option, + content_html: Option, + thumbnail_url: Option, + published_at: Option, +} + pub fn init_database(app_handle: &AppHandle) -> Result { let app_data_dir = app_handle .path() @@ -93,10 +193,42 @@ pub fn init_database(app_handle: &AppHandle) -> Result { sort_order INTEGER NOT NULL, icon_url TEXT NULL, last_fetched_at TEXT NULL, + etag TEXT NULL, + last_modified TEXT NULL, + last_fetch_status TEXT NULL, + last_fetch_error TEXT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS articles ( + id TEXT PRIMARY KEY, + feed_id TEXT NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, + guid TEXT NULL, + external_id TEXT NOT NULL, + url TEXT NULL, + title TEXT NOT NULL, + author TEXT NULL, + summary_text TEXT NULL, + summary_html TEXT NULL, + content_html TEXT NULL, + thumbnail_url TEXT NULL, + published_at TEXT NULL, + fetched_at TEXT NOT NULL, + is_read INTEGER NOT NULL DEFAULT 0, + is_starred INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(feed_id, external_id) + ); + + CREATE INDEX IF NOT EXISTS idx_articles_feed_published + ON articles(feed_id, published_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_articles_published + ON articles(published_at DESC, id DESC); + CREATE INDEX IF NOT EXISTS idx_articles_unread_published + ON articles(is_read, published_at DESC, id DESC); + CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -105,11 +237,55 @@ pub fn init_database(app_handle: &AppHandle) -> Result { ) .map_err(|error| format!("failed to initialize schema: {error}"))?; + migrate_database(&connection)?; + seed_sidebar_data(&connection)?; Ok(db_path) } +fn migrate_database(connection: &Connection) -> Result<(), String> { + ensure_column_exists(connection, "feeds", "etag", "TEXT NULL")?; + ensure_column_exists(connection, "feeds", "last_modified", "TEXT NULL")?; + ensure_column_exists(connection, "feeds", "last_fetch_status", "TEXT NULL")?; + ensure_column_exists(connection, "feeds", "last_fetch_error", "TEXT NULL")?; + + Ok(()) +} + +fn ensure_column_exists( + connection: &Connection, + table_name: &str, + column_name: &str, + column_definition: &str, +) -> Result<(), String> { + let mut statement = connection + .prepare(&format!("PRAGMA table_info({table_name})")) + .map_err(|error| format!("failed to inspect {table_name} schema: {error}"))?; + let existing_columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| format!("failed to query {table_name} schema: {error}"))? + .collect::, _>>() + .map_err(|error| format!("failed to read {table_name} schema: {error}"))?; + + if existing_columns.iter().any(|column| column == column_name) { + return Ok(()); + } + + connection + .execute( + &format!("ALTER TABLE {table_name} ADD COLUMN {column_name} {column_definition}"), + [], + ) + .map_err(|error| { + format!( + "failed to add {column_name} column to {table_name}: {error}" + ) + })?; + + Ok(()) +} + pub fn get_sidebar_data(db_path: &PathBuf) -> Result { let connection = Connection::open(db_path) .map_err(|error| format!("failed to open database: {error}"))?; @@ -142,7 +318,7 @@ pub fn get_sidebar_data(db_path: &PathBuf) -> Result { let mut feed_statement = connection .prepare( r#" - SELECT id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, created_at, updated_at + SELECT id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, last_fetch_status, last_fetch_error, created_at, updated_at FROM feeds ORDER BY COALESCE(folder_id, ''), sort_order, title "#, @@ -160,8 +336,10 @@ pub fn get_sidebar_data(db_path: &PathBuf) -> Result { sort_order: row.get(5)?, icon_url: row.get(6)?, last_fetched_at: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, + last_fetch_status: row.get(8)?, + last_fetch_error: row.get(9)?, + created_at: row.get(10)?, + updated_at: row.get(11)?, }) }) .map_err(|error| format!("failed to query feeds: {error}"))? @@ -171,6 +349,34 @@ pub fn get_sidebar_data(db_path: &PathBuf) -> Result { Ok(SidebarData { folders, feeds }) } +pub fn list_articles( + db_path: &PathBuf, + input: ListArticlesInput, +) -> Result { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let limit = input + .limit + .unwrap_or(ARTICLE_PAGE_SIZE_DEFAULT) + .clamp(1, 100); + + let rows = match input.scope.as_str() { + "feed" => query_feed_articles(&connection, input.feed_id.as_deref(), limit, input.cursor)?, + _ => Vec::new(), + }; + + let next_cursor = rows.last().map(|item| ArticleCursor { + published_at: item.published_at.clone(), + id: item.id.clone(), + }); + + Ok(ArticlePage { + items: rows, + next_cursor, + }) +} + pub fn load_sidebar_expansion_state( db_path: &PathBuf, ) -> Result, String> { @@ -179,11 +385,12 @@ pub fn load_sidebar_expansion_state( let raw_value: Option = connection .query_row( - "SELECT value FROM app_state WHERE key = 'sidebar.expandedFolderIds'", - [], + "SELECT value FROM app_state WHERE key = ?1", + params![SIDEBAR_EXPANDED_FOLDER_IDS_KEY], |row| row.get(0), ) - .ok(); + .optional() + .map_err(|error| format!("failed to load sidebar expansion state: {error}"))?; match raw_value { Some(value) => serde_json::from_str::>(&value) @@ -203,8 +410,8 @@ pub fn save_sidebar_expansion_state( connection .execute( - "INSERT INTO app_state (key, value) VALUES ('sidebar.expandedFolderIds', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", - params![serialized], + "INSERT INTO app_state (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![SIDEBAR_EXPANDED_FOLDER_IDS_KEY, serialized], ) .map_err(|error| format!("failed to save sidebar expansion state: {error}"))?; @@ -247,6 +454,718 @@ pub fn save_sidebar_structure( Ok(()) } +pub fn delete_feed(db_path: &PathBuf, feed_id: &str) -> Result { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let deleted_rows = connection + .execute("DELETE FROM feeds WHERE id = ?1", params![feed_id]) + .map_err(|error| format!("failed to delete feed: {error}"))?; + + if deleted_rows == 0 { + return Err(format!("feed not found: {feed_id}")); + } + + Ok(DeleteFeedResult { + feed_id: feed_id.to_string(), + }) +} + +pub async fn refetch_feed( + db_path: &PathBuf, + sync_state: Arc, + feed_id: &str, +) -> Result { + let target = load_feed_fetch_target(db_path, feed_id)? + .ok_or_else(|| format!("feed not found: {feed_id}"))?; + + fetch_and_store_feed(db_path, sync_state, target).await +} + +pub fn start_background_feed_sync(app_handle: AppHandle, state: Arc) { + tauri::async_runtime::spawn(async move { + let client = match Client::builder().build() { + Ok(client) => client, + Err(_) => return, + }; + + loop { + let db_path = match app_handle.state::().db_path.clone().into() { + path => path, + }; + + let due_targets = match load_due_feed_targets(&db_path) { + Ok(targets) => targets, + Err(_) => { + sleep(Duration::from_secs(FEED_SYNC_POLL_INTERVAL_SECONDS)).await; + continue; + } + }; + + for target in due_targets.into_iter().take(FEED_SYNC_CONCURRENCY) { + let db_path = db_path.clone(); + let state = state.clone(); + let app_handle = app_handle.clone(); + let client = client.clone(); + + tauri::async_runtime::spawn(async move { + let _ = fetch_and_store_feed_with_client(&db_path, state, target, client).await; + let _ = app_handle.emit("feed-sync-updated", serde_json::json!({})); + }); + } + + sleep(Duration::from_secs(FEED_SYNC_POLL_INTERVAL_SECONDS)).await; + } + }); +} + +async fn fetch_and_store_feed( + db_path: &PathBuf, + sync_state: Arc, + target: FeedFetchTarget, +) -> Result { + let client = Client::builder() + .build() + .map_err(|error| format!("failed to build http client: {error}"))?; + + fetch_and_store_feed_with_client(db_path, sync_state, target, client).await +} + +async fn fetch_and_store_feed_with_client( + db_path: &PathBuf, + sync_state: Arc, + target: FeedFetchTarget, + client: Client, +) -> Result { + { + let mut inflight_feed_ids = sync_state.inflight_feed_ids.lock().await; + if inflight_feed_ids.contains(&target.id) { + return Ok(RefetchFeedResult { + feed_id: target.id, + inserted_count: 0, + updated_count: 0, + }); + } + + inflight_feed_ids.insert(target.id.clone()); + } + + let result = fetch_and_store_feed_inner(db_path, &target, client).await; + + let mut inflight_feed_ids = sync_state.inflight_feed_ids.lock().await; + inflight_feed_ids.remove(&target.id); + + result +} + +async fn fetch_and_store_feed_inner( + db_path: &PathBuf, + target: &FeedFetchTarget, + client: Client, +) -> Result { + let mut request = client.get(&target.url); + + if let Some(etag) = &target.etag { + request = request.header(IF_NONE_MATCH, etag); + } + + if let Some(last_modified) = &target.last_modified { + request = request.header(IF_MODIFIED_SINCE, last_modified); + } + + let response = request + .send() + .await + .map_err(|error| format!("failed to fetch feed {}: {error}", target.title))?; + + if response.status() == reqwest::StatusCode::NOT_MODIFIED { + mark_feed_fetch_success( + db_path, + &target.id, + target.etag.clone(), + target.last_modified.clone(), + )?; + + return Ok(RefetchFeedResult { + feed_id: target.id.clone(), + inserted_count: 0, + updated_count: 0, + }); + } + + if response.status() == reqwest::StatusCode::NOT_FOUND { + mark_feed_fetch_error( + db_path, + &target.id, + "not_found", + "Feed does not exist.", + )?; + + return Err(format!("feed not found: {}", target.title)); + } + + if !response.status().is_success() { + let status = response.status(); + let message = format!("Feed request failed with status {status}."); + mark_feed_fetch_error(db_path, &target.id, "error", &message)?; + + return Err(format!("failed to fetch feed {}: {status}", target.title)); + } + + let next_etag = response + .headers() + .get(ETAG) + .and_then(|value| value.to_str().ok()) + .map(String::from) + .or_else(|| target.etag.clone()); + let next_last_modified = response + .headers() + .get(LAST_MODIFIED) + .and_then(|value| value.to_str().ok()) + .map(String::from) + .or_else(|| target.last_modified.clone()); + + let body = response + .bytes() + .await + .map_err(|error| format!("failed to read feed response body: {error}"))?; + let feed = feed_rs::parser::parse(&body[..]) + .map_err(|error| format!("failed to parse feed {}: {error}", target.title))?; + + let parsed_articles = parse_feed_entries(&feed); + let (inserted_count, updated_count) = upsert_articles(db_path, &target.id, parsed_articles)?; + + mark_feed_fetch_success(db_path, &target.id, next_etag, next_last_modified)?; + + Ok(RefetchFeedResult { + feed_id: target.id.clone(), + inserted_count, + updated_count, + }) +} + +fn query_feed_articles( + connection: &Connection, + feed_id: Option<&str>, + limit: i64, + cursor: Option, +) -> Result, String> { + let Some(feed_id) = feed_id else { + return Ok(Vec::new()); + }; + + let mut statement = if cursor.is_some() { + connection + .prepare( + r#" + SELECT + articles.id, + articles.feed_id, + feeds.title, + articles.title, + articles.url, + articles.author, + articles.summary_text, + articles.summary_html, + articles.content_html, + articles.thumbnail_url, + articles.published_at, + articles.fetched_at, + articles.is_read, + articles.is_starred + FROM articles + JOIN feeds ON feeds.id = articles.feed_id + WHERE articles.feed_id = ?1 + AND ( + COALESCE(articles.published_at, '') < COALESCE(?2, '') + OR ( + COALESCE(articles.published_at, '') = COALESCE(?2, '') + AND articles.id < ?3 + ) + ) + ORDER BY COALESCE(articles.published_at, '') DESC, articles.id DESC + LIMIT ?4 + "#, + ) + .map_err(|error| format!("failed to prepare feed article query: {error}"))? + } else { + connection + .prepare( + r#" + SELECT + articles.id, + articles.feed_id, + feeds.title, + articles.title, + articles.url, + articles.author, + articles.summary_text, + articles.summary_html, + articles.content_html, + articles.thumbnail_url, + articles.published_at, + articles.fetched_at, + articles.is_read, + articles.is_starred + FROM articles + JOIN feeds ON feeds.id = articles.feed_id + WHERE articles.feed_id = ?1 + ORDER BY COALESCE(articles.published_at, '') DESC, articles.id DESC + LIMIT ?2 + "#, + ) + .map_err(|error| format!("failed to prepare feed article query: {error}"))? + }; + + let rows = if let Some(cursor) = cursor { + statement + .query_map( + params![feed_id, cursor.published_at, cursor.id, limit], + map_article_row, + ) + .map_err(|error| format!("failed to query feed articles: {error}"))? + } else { + statement + .query_map(params![feed_id, limit], map_article_row) + .map_err(|error| format!("failed to query feed articles: {error}"))? + }; + + rows.collect::, _>>() + .map_err(|error| format!("failed to read feed articles: {error}")) +} + +fn map_article_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(ArticleRecord { + id: row.get(0)?, + feed_id: row.get(1)?, + feed_title: row.get(2)?, + title: row.get(3)?, + url: row.get(4)?, + author: row.get(5)?, + summary_text: row.get(6)?, + summary_html: row.get(7)?, + content_html: row.get(8)?, + thumbnail_url: row.get(9)?, + published_at: row.get(10)?, + fetched_at: row.get(11)?, + is_read: row.get::<_, i64>(12)? != 0, + is_starred: row.get::<_, i64>(13)? != 0, + }) +} + +fn load_due_feed_targets(db_path: &PathBuf) -> Result, String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + let now = Utc::now(); + let mut statement = connection + .prepare( + r#" + SELECT id, title, url, etag, last_modified, last_fetched_at + FROM feeds + ORDER BY COALESCE(last_fetched_at, ''), title + "#, + ) + .map_err(|error| format!("failed to prepare due feed query: {error}"))?; + + let feeds = statement + .query_map([], |row| { + Ok(( + FeedFetchTarget { + id: row.get(0)?, + title: row.get(1)?, + url: row.get(2)?, + etag: row.get(3)?, + last_modified: row.get(4)?, + }, + row.get::<_, Option>(5)?, + )) + }) + .map_err(|error| format!("failed to query due feeds: {error}"))? + .collect::, _>>() + .map_err(|error| format!("failed to read due feeds: {error}"))?; + + Ok(feeds + .into_iter() + .filter_map(|(target, last_fetched_at)| { + match last_fetched_at { + Some(value) => DateTime::parse_from_rfc3339(&value) + .ok() + .map(|date| date.with_timezone(&Utc)) + .filter(|date| *date + chrono::Duration::minutes(FEED_SYNC_INTERVAL_MINUTES) <= now) + .map(|_| target), + None => Some(target), + } + }) + .collect()) +} + +fn load_feed_fetch_target( + db_path: &PathBuf, + feed_id: &str, +) -> Result, String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + + connection + .query_row( + r#" + SELECT id, title, url, etag, last_modified + FROM feeds + WHERE id = ?1 + "#, + params![feed_id], + |row| { + Ok(FeedFetchTarget { + id: row.get(0)?, + title: row.get(1)?, + url: row.get(2)?, + etag: row.get(3)?, + last_modified: row.get(4)?, + }) + }, + ) + .optional() + .map_err(|error| format!("failed to load feed target: {error}")) +} + +fn parse_feed_entries(feed: &Feed) -> Vec { + feed.entries + .iter() + .map(parse_feed_entry) + .collect::>() +} + +fn parse_feed_entry(entry: &Entry) -> ParsedArticle { + let title = entry + .title + .as_ref() + .map(|content| content.content.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "Untitled article".to_string()); + let url = entry.links.first().map(|link| link.href.clone()); + let guid = if entry.id.trim().is_empty() { + None + } else { + Some(entry.id.clone()) + }; + let published_at = entry + .published + .or(entry.updated) + .map(|date| date.with_timezone(&Utc).to_rfc3339()); + let summary_html = entry.summary.as_ref().map(|content| content.content.clone()); + let summary_text = summary_html + .as_ref() + .map(|value| strip_html(value)) + .filter(|value| !value.trim().is_empty()); + let content_html = entry + .content + .as_ref() + .and_then(|content| content.body.clone()) + .filter(|value| !value.trim().is_empty()) + .or_else(|| summary_html.clone()); + let thumbnail_url = extract_thumbnail_url( + entry, + content_html.as_deref(), + summary_html.as_deref(), + ); + let author = entry.authors.first().map(|author| author.name.clone()); + let external_id = guid + .clone() + .or_else(|| url.clone()) + .unwrap_or_else(|| { + format!( + "{}:{}:{}", + title, + published_at.clone().unwrap_or_default(), + Uuid::new_v4() + ) + }); + + ParsedArticle { + title, + url, + guid, + external_id, + author, + summary_text, + summary_html, + content_html, + thumbnail_url, + published_at, + } +} + +fn extract_thumbnail_url( + entry: &Entry, + content_html: Option<&str>, + summary_html: Option<&str>, +) -> Option { + entry + .media + .iter() + .flat_map(|media| media.thumbnails.iter().map(|thumbnail| thumbnail.image.uri.clone())) + .find(|url| is_supported_thumbnail_url(url)) + .or_else(|| { + entry + .media + .iter() + .flat_map(|media| media.content.iter()) + .filter_map(|content| content.url.as_ref().map(|url| url.to_string())) + .find(|url| is_supported_image_url(url)) + }) + .or_else(|| { + entry + .links + .iter() + .filter_map(|link| { + link.media_type + .as_deref() + .filter(|media_type| media_type.starts_with("image/")) + .map(|_| link.href.clone()) + .or_else(|| { + link.rel + .as_deref() + .filter(|rel| *rel == "enclosure") + .filter(|_| is_supported_image_url(&link.href)) + .map(|_| link.href.clone()) + }) + }) + .find(|url| is_supported_thumbnail_url(url)) + }) + .or_else(|| content_html.and_then(extract_first_image_src)) + .or_else(|| summary_html.and_then(extract_first_image_src)) +} + +fn extract_first_image_src(html: &str) -> Option { + let image_tag_index = html.find("').map(|index| image_tag_index + index)?]; + + extract_html_attribute(image_tag, "src").filter(|url| is_supported_thumbnail_url(url)) +} + +fn extract_html_attribute(tag: &str, attribute_name: &str) -> Option { + let search = format!("{attribute_name}="); + let start_index = tag.find(&search)? + search.len(); + let remainder = &tag[start_index..]; + let quote = remainder.chars().next()?; + + if quote != '"' && quote != '\'' { + return None; + } + + let value = &remainder[1..]; + let end_index = value.find(quote)?; + let attribute_value = value[..end_index].trim(); + + if attribute_value.is_empty() { + return None; + } + + Some(attribute_value.to_string()) +} + +fn is_supported_thumbnail_url(url: &str) -> bool { + (url.starts_with("http://") || url.starts_with("https://")) && is_supported_image_url(url) +} + +fn is_supported_image_url(url: &str) -> bool { + let lowercase_url = url.to_ascii_lowercase(); + + lowercase_url.contains(".jpg") + || lowercase_url.contains(".jpeg") + || lowercase_url.contains(".png") + || lowercase_url.contains(".webp") + || lowercase_url.contains(".gif") + || lowercase_url.contains(".avif") + || lowercase_url.contains("image/") +} + +fn upsert_articles( + db_path: &PathBuf, + feed_id: &str, + articles: Vec, +) -> Result<(usize, usize), String> { + let mut connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let transaction = connection + .transaction() + .map_err(|error| format!("failed to start transaction: {error}"))?; + + let now = Utc::now().to_rfc3339(); + let mut inserted_count = 0; + let mut updated_count = 0; + + for article in articles { + let existing_article_id: Option = transaction + .query_row( + "SELECT id FROM articles WHERE feed_id = ?1 AND external_id = ?2", + params![feed_id, article.external_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| format!("failed to query existing article: {error}"))?; + + if let Some(article_id) = existing_article_id { + transaction + .execute( + r#" + UPDATE articles + SET guid = ?1, + url = ?2, + title = ?3, + author = ?4, + summary_text = ?5, + summary_html = ?6, + content_html = ?7, + thumbnail_url = ?8, + published_at = ?9, + fetched_at = ?10, + updated_at = ?11 + WHERE id = ?12 + "#, + params![ + article.guid, + article.url, + article.title, + article.author, + article.summary_text, + article.summary_html, + article.content_html, + article.thumbnail_url, + article.published_at, + now, + now, + article_id, + ], + ) + .map_err(|error| format!("failed to update article: {error}"))?; + updated_count += 1; + } else { + transaction + .execute( + r#" + INSERT INTO articles ( + id, + feed_id, + guid, + external_id, + url, + title, + author, + summary_text, + summary_html, + content_html, + thumbnail_url, + published_at, + fetched_at, + is_read, + is_starred, + created_at, + updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, 0, 0, ?14, ?15) + "#, + params![ + Uuid::new_v4().to_string(), + feed_id, + article.guid, + article.external_id, + article.url, + article.title, + article.author, + article.summary_text, + article.summary_html, + article.content_html, + article.thumbnail_url, + article.published_at, + now, + now, + now, + ], + ) + .map_err(|error| format!("failed to insert article: {error}"))?; + inserted_count += 1; + } + } + + transaction + .commit() + .map_err(|error| format!("failed to commit article upserts: {error}"))?; + + Ok((inserted_count, updated_count)) +} + +fn mark_feed_fetch_success( + db_path: &PathBuf, + feed_id: &str, + etag: Option, + last_modified: Option, +) -> Result<(), String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let now = Utc::now().to_rfc3339(); + + connection + .execute( + r#" + UPDATE feeds + SET last_fetched_at = ?1, + etag = ?2, + last_modified = ?3, + last_fetch_status = 'success', + last_fetch_error = NULL, + updated_at = ?1 + WHERE id = ?4 + "#, + params![now, etag, last_modified, feed_id], + ) + .map_err(|error| format!("failed to update feed fetch metadata: {error}"))?; + + Ok(()) +} + +fn mark_feed_fetch_error( + db_path: &PathBuf, + feed_id: &str, + status: &str, + error_message: &str, +) -> Result<(), String> { + let connection = Connection::open(db_path) + .map_err(|error| format!("failed to open database: {error}"))?; + let now = Utc::now().to_rfc3339(); + + connection + .execute( + r#" + UPDATE feeds + SET last_fetch_status = ?1, + last_fetch_error = ?2, + updated_at = ?3 + WHERE id = ?4 + "#, + params![status, error_message, now, feed_id], + ) + .map_err(|error| format!("failed to update feed fetch error metadata: {error}"))?; + + Ok(()) +} + +fn strip_html(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut inside_tag = false; + + for character in value.chars() { + match character { + '<' => inside_tag = true, + '>' => inside_tag = false, + _ if !inside_tag => result.push(character), + _ => {} + } + } + + result.split_whitespace().collect::>().join(" ") +} + fn seed_sidebar_data(connection: &Connection) -> Result<(), String> { let folder_count: i64 = connection .query_row("SELECT COUNT(*) FROM folders", [], |row| row.get(0)) @@ -259,7 +1178,7 @@ fn seed_sidebar_data(connection: &Connection) -> Result<(), String> { return Ok(()); } - let now = "2026-04-23T00:00:00Z"; + let now = Utc::now().to_rfc3339(); connection .execute( @@ -318,8 +1237,8 @@ fn seed_sidebar_data(connection: &Connection) -> Result<(), String> { for (id, title, url, site_url, folder_id, sort_order) in feeds { connection .execute( - "INSERT INTO feeds (id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![id, title, url, site_url, folder_id, sort_order, Option::::None, Option::::None, now, now], + "INSERT INTO feeds (id, title, url, site_url, folder_id, sort_order, icon_url, last_fetched_at, etag, last_modified, last_fetch_status, last_fetch_error, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", + params![id, title, url, site_url, folder_id, sort_order, Option::::None, Option::::None, Option::::None, Option::::None, Option::::None, Option::::None, now, now], ) .map_err(|error| format!("failed to seed feed {title}: {error}"))?; } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 962f8c9..97e2d6c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,12 +2,17 @@ mod db; use db::{ get_sidebar_data as load_sidebar_data, init_database, + list_articles as load_articles, load_sidebar_expansion_state as read_sidebar_expansion_state, + delete_feed as remove_feed, + refetch_feed as refresh_feed, save_sidebar_expansion_state as persist_sidebar_expansion_state, - save_sidebar_structure as persist_sidebar_structure, AppState, FeedMoveInput, FolderMoveInput, - SidebarData, + save_sidebar_structure as persist_sidebar_structure, start_background_feed_sync, AppState, + FeedMoveInput, FolderMoveInput, ListArticlesInput, RefetchFeedResult, SidebarData, + ArticlePage, DeleteFeedResult, FeedSyncState, }; use std::collections::HashMap; +use std::sync::Arc; use tauri::Manager; #[tauri::command] @@ -39,12 +44,45 @@ fn save_sidebar_expansion_state( persist_sidebar_expansion_state(&state.db_path, expanded_folder_ids) } +#[tauri::command] +fn list_articles( + state: tauri::State<'_, AppState>, + input: ListArticlesInput, +) -> Result { + load_articles(&state.db_path, input) +} + +#[tauri::command] +async fn refetch_feed( + state: tauri::State<'_, AppState>, + feed_id: String, +) -> Result { + refresh_feed(&state.db_path, state.sync_state.clone(), &feed_id).await +} + +#[tauri::command] +fn delete_feed( + state: tauri::State<'_, AppState>, + feed_id: String, +) -> Result { + remove_feed(&state.db_path, &feed_id) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .setup(|app| { let db_path = init_database(app.handle())?; - app.manage(AppState { db_path }); + let sync_state = Arc::new(FeedSyncState { + inflight_feed_ids: tokio::sync::Mutex::new(std::collections::HashSet::new()), + }); + + app.manage(AppState { + db_path, + sync_state: sync_state.clone(), + }); + + start_background_feed_sync(app.handle().clone(), sync_state); Ok(()) }) .plugin(tauri_plugin_opener::init()) @@ -53,7 +91,10 @@ pub fn run() { get_sidebar_data, save_sidebar_structure, load_sidebar_expansion_state, - save_sidebar_expansion_state + save_sidebar_expansion_state, + list_articles, + refetch_feed, + delete_feed ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index 89beeea..01b0772 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -1,7 +1,7 @@ import { cn } from "@/lib/cn"; import type { ArticleListDensity } from "@/components/ViewSelect"; -type ArticleListItem = { +export type ArticleListItem = { id: string; title: string; feed: string; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 9f8fc22..a8eec24 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -7,7 +7,10 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + SortableContext, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { LayoutDashboardIcon, MailIcon } from "lucide-react"; import { moveSidebarNode } from "@/lib/sidebarMove"; import { buildSidebarTree } from "@/lib/sidebarTree"; @@ -31,11 +34,15 @@ export function Sidebar() { const loadSidebarData = useSidebarStore((state) => state.loadSidebarData); const toggleFolder = useSidebarStore((state) => state.toggleFolder); const toggleFolderTree = useSidebarStore((state) => state.toggleFolderTree); - const setSidebarStructure = useSidebarStore((state) => state.setSidebarStructure); + const setSidebarStructure = useSidebarStore( + (state) => state.setSidebarStructure, + ); const persistSidebarStructure = useSidebarStore( (state) => state.persistSidebarStructure, ); - const [activeDropFolderId, setActiveDropFolderId] = useState(null); + const [activeDropFolderId, setActiveDropFolderId] = useState( + null, + ); useEffect(() => { void loadSidebarData(); @@ -148,7 +155,10 @@ export function Sidebar() { onDragEnd={handleDragEnd} onDragCancel={() => setActiveDropFolderId(null)} > - + +
+
{isExpanded ? ( - + ) : ( - + )} {label} @@ -86,7 +88,7 @@ export function SidebarFolderItem({ } const baseRowClassName = - "flex h-8 w-full items-center gap-1.5 rounded-[8px] pr-2 text-[13px] font-medium text-content-muted transition-colors"; + "flex h-8 w-full items-center gap-1.5 rounded-sm pr-2 text-xs text-content-muted transition-colors"; const hoverRowClassName = "hover:bg-interactive-hover hover:text-content"; diff --git a/src/lib/articleApi.ts b/src/lib/articleApi.ts new file mode 100644 index 0000000..292339f --- /dev/null +++ b/src/lib/articleApi.ts @@ -0,0 +1,25 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ArticleCursor, ArticlePage, RefetchFeedResult } from "@/types/article"; + +export async function listFeedArticles({ + feedId, + cursor, + limit, +}: { + feedId: string; + cursor?: ArticleCursor | null; + limit?: number; +}) { + return invoke("list_articles", { + input: { + scope: "feed", + feedId, + cursor: cursor ?? null, + limit: limit ?? 50, + }, + }); +} + +export async function refetchFeed(feedId: string) { + return invoke("refetch_feed", { feedId }); +} diff --git a/src/lib/sidebarApi.ts b/src/lib/sidebarApi.ts index b2a9347..ea5a216 100644 --- a/src/lib/sidebarApi.ts +++ b/src/lib/sidebarApi.ts @@ -22,3 +22,7 @@ export async function saveSidebarStructure({ }) { return invoke("save_sidebar_structure", { folders, feeds }); } + +export async function deleteFeed(feedId: string) { + return invoke<{ feedId: string }>("delete_feed", { feedId }); +} diff --git a/src/routes/FeedRoute.tsx b/src/routes/FeedRoute.tsx index 43b7c4d..ad0c066 100644 --- a/src/routes/FeedRoute.tsx +++ b/src/routes/FeedRoute.tsx @@ -1,18 +1,162 @@ +import { useEffect } from "react"; +import { RefreshCwIcon } from "lucide-react"; +import { listen } from "@tauri-apps/api/event"; +import { ArticleList, type ArticleListItem } from "@/components/ArticleList"; +import { Button } from "@/components/ui/Button"; +import { IconButton } from "@/components/ui/IconButton"; +import { ViewSelect } from "@/components/ViewSelect"; import { RouteLayout } from "@/routes/RouteLayout"; -import { useRoutes } from "@/stores/routeStore"; +import { ROUTE, useRoutes } from "@/stores/routeStore"; import { useSidebarStore } from "@/stores/sidebarStore"; +import { emptyFeedState, useArticleStore } from "@/stores/articleStore"; +import { useUserOptions } from "@/stores/userOptionsStore"; export function FeedRoute() { const feedId = useRoutes((state) => String(state.routeParams.feedId ?? "")); const feed = useSidebarStore((state) => state.feeds.find((item) => item.id === feedId), ); + const removeFeed = useSidebarStore((state) => state.removeFeed); + const setCurrentRoute = useRoutes((state) => state.setCurrentRoute); + const view = useUserOptions((state) => state.articleListView); + const setView = useUserOptions((state) => state.setArticleListView); + const feedView = useArticleStore((state) => state.feedViews[feedId]); + const loadFeedArticles = useArticleStore((state) => state.loadFeedArticles); + const refreshFeed = useArticleStore((state) => state.refreshFeed); + const resolvedFeedView = feedView ?? emptyFeedState; + const isMissingFeed = feed?.lastFetchStatus === "not_found"; + + useEffect(() => { + if (!feedId) { + return; + } + + void loadFeedArticles(feedId, true); + }, [feedId, loadFeedArticles]); + + useEffect(() => { + if (!feedId) { + return; + } + + let isDisposed = false; + + const unlistenPromise = listen("feed-sync-updated", () => { + if (!isDisposed) { + void loadFeedArticles(feedId, true); + } + }); + + return () => { + isDisposed = true; + void unlistenPromise.then((unlisten) => unlisten()); + }; + }, [feedId, loadFeedArticles]); + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "r") { + return; + } + + const target = event.target; + if ( + target instanceof HTMLElement && + (target.isContentEditable || + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement) + ) { + return; + } + + event.preventDefault(); + if (feedId) { + void refreshFeed(feedId); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [feedId, refreshFeed]); + + const articleItems: ArticleListItem[] = resolvedFeedView.items.map((item) => ({ + id: item.id, + title: item.title, + feed: item.feedTitle, + summary: item.summaryText ?? item.author ?? "", + publishedAt: formatPublishedAt(item.publishedAt), + thumbnailUrl: item.thumbnailUrl ?? undefined, + unread: !item.isRead, + starred: item.isStarred, + })); + + async function handleDeleteFeed() { + if (!feedId) { + return; + } + + await removeFeed(feedId); + setCurrentRoute(ROUTE.DASHBOARD); + } return ( - -
- Feed articles will appear here. -
+ + } + label="Refresh feed" + onClick={() => { + if (feedId) { + void refreshFeed(feedId); + } + }} + disabled={resolvedFeedView.isRefreshing} + /> + +
+ } + > + {isMissingFeed ? ( +
+
Feed does not exist.
+ +
+ ) : resolvedFeedView.error ? ( +
+ {resolvedFeedView.error} +
+ ) : articleItems.length === 0 && !resolvedFeedView.isLoading ? ( +
+ No articles yet. Background sync or a manual refresh will populate this feed. +
+ ) : ( + + )} ); } + +function formatPublishedAt(value: string | null) { + if (!value) { + return "-"; + } + + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return "-"; + } + + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} diff --git a/src/stores/articleStore.ts b/src/stores/articleStore.ts new file mode 100644 index 0000000..ed2275a --- /dev/null +++ b/src/stores/articleStore.ts @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import { listFeedArticles, refetchFeed } from "@/lib/articleApi"; +import type { ArticleCursor, ArticleRecord } from "@/types/article"; + +type FeedArticleState = { + items: ArticleRecord[]; + nextCursor: ArticleCursor | null; + isLoading: boolean; + isRefreshing: boolean; + error: string | null; +}; + +type ArticleStoreState = { + feedViews: Record; + loadFeedArticles: (feedId: string, reset?: boolean) => Promise; + refreshFeed: (feedId: string) => Promise; +}; + +export const emptyFeedState: FeedArticleState = { + items: [], + nextCursor: null, + isLoading: false, + isRefreshing: false, + error: null, +}; + +export const useArticleStore = create((set, get) => ({ + feedViews: {}, + loadFeedArticles: async (feedId, reset = false) => { + const currentState = get().feedViews[feedId] ?? emptyFeedState; + + if (currentState.isLoading) { + return; + } + + set((state) => ({ + feedViews: { + ...state.feedViews, + [feedId]: { + ...(state.feedViews[feedId] ?? emptyFeedState), + isLoading: true, + error: null, + ...(reset ? { items: [], nextCursor: null } : {}), + }, + }, + })); + + try { + const latestState = get().feedViews[feedId] ?? emptyFeedState; + const page = await listFeedArticles({ + feedId, + cursor: reset ? null : latestState.nextCursor, + }); + + set((state) => { + const previous = state.feedViews[feedId] ?? emptyFeedState; + + return { + feedViews: { + ...state.feedViews, + [feedId]: { + ...previous, + items: reset ? page.items : [...previous.items, ...page.items], + nextCursor: page.nextCursor, + isLoading: false, + error: null, + }, + }, + }; + }); + } catch (error) { + set((state) => ({ + feedViews: { + ...state.feedViews, + [feedId]: { + ...(state.feedViews[feedId] ?? emptyFeedState), + isLoading: false, + error: + error instanceof Error ? error.message : "Failed to load articles.", + }, + }, + })); + } + }, + refreshFeed: async (feedId) => { + set((state) => ({ + feedViews: { + ...state.feedViews, + [feedId]: { + ...(state.feedViews[feedId] ?? emptyFeedState), + isRefreshing: true, + error: null, + }, + }, + })); + + try { + await refetchFeed(feedId); + await get().loadFeedArticles(feedId, true); + set((state) => ({ + feedViews: { + ...state.feedViews, + [feedId]: { + ...(state.feedViews[feedId] ?? emptyFeedState), + isRefreshing: false, + }, + }, + })); + } catch (error) { + set((state) => ({ + feedViews: { + ...state.feedViews, + [feedId]: { + ...(state.feedViews[feedId] ?? emptyFeedState), + isRefreshing: false, + error: + error instanceof Error ? error.message : "Failed to refresh feed.", + }, + }, + })); + } + }, +})); diff --git a/src/stores/sidebarStore.ts b/src/stores/sidebarStore.ts index 9010d21..fd21e49 100644 --- a/src/stores/sidebarStore.ts +++ b/src/stores/sidebarStore.ts @@ -1,5 +1,9 @@ import { create } from "zustand"; -import { getSidebarData, saveSidebarStructure } from "@/lib/sidebarApi"; +import { + deleteFeed, + getSidebarData, + saveSidebarStructure, +} from "@/lib/sidebarApi"; import { loadExpandedFolderIds, saveExpandedFolderIds, @@ -19,6 +23,7 @@ type SidebarState = { toggleFolderTree: (folderId: string) => void; setSidebarStructure: (folders: FolderRecord[], feeds: FeedRecord[]) => void; persistSidebarStructure: () => Promise; + removeFeed: (feedId: string) => Promise; }; export const useSidebarStore = create((set, get) => ({ @@ -95,6 +100,11 @@ export const useSidebarStore = create((set, get) => ({ const { folders, feeds } = get(); await saveSidebarStructure(serializeSidebarStructure(folders, feeds)); }, + removeFeed: async (feedId) => { + await deleteFeed(feedId); + set({ hasLoaded: false }); + await get().loadSidebarData(); + }, })); function getDescendantFolderIds( diff --git a/src/types/article.ts b/src/types/article.ts new file mode 100644 index 0000000..3427c11 --- /dev/null +++ b/src/types/article.ts @@ -0,0 +1,32 @@ +export type ArticleRecord = { + id: string; + feedId: string; + feedTitle: string; + title: string; + url: string | null; + author: string | null; + summaryText: string | null; + summaryHtml: string | null; + contentHtml: string | null; + thumbnailUrl: string | null; + publishedAt: string | null; + fetchedAt: string; + isRead: boolean; + isStarred: boolean; +}; + +export type ArticleCursor = { + publishedAt: string | null; + id: string; +}; + +export type ArticlePage = { + items: ArticleRecord[]; + nextCursor: ArticleCursor | null; +}; + +export type RefetchFeedResult = { + feedId: string; + insertedCount: number; + updatedCount: number; +}; diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index 0c51a39..7dbaf6a 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -16,6 +16,8 @@ export type FeedRecord = { sortOrder: number; iconUrl: string | null; lastFetchedAt: string | null; + lastFetchStatus: string | null; + lastFetchError: string | null; createdAt: string; updatedAt: string; };