From 54bc6da09ac06262393004bf46ddc5681e37289c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atay=20=C3=96zcan?= Date: Tue, 28 Apr 2026 03:15:09 +0200 Subject: [PATCH 1/5] feat: add CalDAV sync (server URL, test connection, sync now) Adds a Sync section to settings with server URL, username, password, Test Connection and Sync Now actions. New sync module implements a minimal CalDAV client (PROPFIND principal/home, REPORT VTODOs, PUT) and a sync engine that pulls remote VTODO calendars as lists and syncs tasks both ways using last-modified. Tested against the build; runtime sync needs a CalDAV server (e.g. Stalwart) to validate end-to-end. --- Cargo.lock | 484 +++++++++++++++++++++++++++++++++-- Cargo.toml | 39 +-- i18n/en/tasks.ftl | 17 ++ src/app.rs | 134 +++++++++- src/app/actions.rs | 7 + src/core/config.rs | 3 + src/main.rs | 1 + src/sync/caldav.rs | 609 +++++++++++++++++++++++++++++++++++++++++++++ src/sync/engine.rs | 201 +++++++++++++++ src/sync/mod.rs | 2 + 10 files changed, 1464 insertions(+), 33 deletions(-) create mode 100644 src/sync/caldav.rs create mode 100644 src/sync/engine.rs create mode 100644 src/sync/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 36babcb3..61cf71f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c1f86859c1af3d514fa19e8323147ff10ea98684e6c7b307912509f50e67b2" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-executor" version = "1.13.3" @@ -957,6 +970,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680dc087785c5230f8e8843e2e57ac7c1c90488b6a91b88caa265410568f441b" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1041,7 +1071,7 @@ dependencies = [ "iced_futures", "known-folders", "notify", - "ron 0.12.0", + "ron", "serde", "tokio", "tracing", @@ -1127,7 +1157,7 @@ dependencies = [ "csscolorparser", "dirs", "palette", - "ron 0.12.0", + "ron", "serde", "serde_json", "thiserror 2.0.18", @@ -1352,7 +1382,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -2002,8 +2032,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.0+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2013,9 +2045,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasi 0.14.7+wasi-0.2.4", + "wasm-bindgen", ] [[package]] @@ -2240,6 +2274,104 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "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", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.0", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "i18n-config" version = "0.4.8" @@ -2331,6 +2463,19 @@ dependencies = [ "cc", ] +[[package]] +name = "icalendar" +version = "0.17.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3b69b799a03e059f6dc984c25a8bf847d8ca4cbddb079c39ede7b3d24854c3" +dependencies = [ + "chrono", + "iso8601", + "nom 8.0.0", + "nom-language", + "uuid", +] + [[package]] name = "iced" version = "0.14.0-dev" @@ -2764,6 +2909,22 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2783,6 +2944,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "iso8601" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1082f0c48f143442a1ac6122f67e360ceee130b967af4d50996e5154a45df46" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "itoa" version = "1.0.15" @@ -2869,7 +3039,7 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d463f34ca3c400fde3a054da0e0b8c6ffa21e4590922f3e18281bb5eeef4cbdc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -3072,6 +3242,12 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lyon" version = "1.0.1" @@ -3354,6 +3530,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "notify" version = "8.2.0" @@ -4211,6 +4405,61 @@ 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 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.1", + "rustls", + "socket2 0.6.0", + "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 = [ + "bytes", + "getrandom 0.3.3", + "lru-slab", + "rand 0.9.1", + "ring", + "rustc-hash 2.1.1", + "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 0.2.1", + "libc", + "once_cell", + "socket2 0.6.0", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.41" @@ -4407,6 +4656,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "resvg" version = "0.42.0" @@ -4457,16 +4744,17 @@ dependencies = [ ] [[package]] -name = "ron" -version = "0.11.0" +name = "ring" +version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db09040cc89e461f1a265139777a2bde7f8d8c67c4936f700c63ce3e2904d468" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ - "base64", - "bitflags 2.10.0", - "serde", - "serde_derive", - "unicode-ident", + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] @@ -4572,7 +4860,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.60.2", + "windows-sys 0.61.1", +] + +[[package]] +name = "rustls" +version = "0.23.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -5101,6 +5424,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 = "svg_fmt" version = "0.4.5" @@ -5150,6 +5479,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -5186,14 +5524,18 @@ dependencies = [ name = "tasks" version = "0.2.0" dependencies = [ + "base64", "chrono", "cli-clipboard", "dirs", "i18n-embed", "i18n-embed-fl", + "icalendar", "libcosmic", "open", - "ron 0.11.0", + "quick-xml", + "reqwest", + "ron", "rust-embed", "serde", "slotmap", @@ -5201,6 +5543,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -5367,6 +5710,16 @@ dependencies = [ "syn 2.0.106", ] +[[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-stream" version = "0.1.17" @@ -5378,6 +5731,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -5415,6 +5781,56 @@ dependencies = [ "winnow 0.7.10", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags 2.10.0", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "iri-string", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" @@ -5485,11 +5901,17 @@ checksum = "aac5e8971f245c3389a5a76e648bfc80803ae066a1243a75db0064d7c1129d63" dependencies = [ "fnv", "memchr", - "nom", + "nom 7.1.3", "once_cell", "petgraph", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.21.1" @@ -5631,6 +6053,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" @@ -5729,6 +6157,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -6068,6 +6505,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.8" @@ -6208,7 +6654,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -7116,6 +7562,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.2" diff --git a/Cargo.toml b/Cargo.toml index 09d4455e..df337b4c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,30 @@ [package] name = "tasks" version = "0.2.0" -edition = "2021" +edition = "2024" [dependencies] -i18n-embed-fl = "0.10.0" -rust-embed = "8" -open = "5.3.2" -dirs = "6.0.0" -cli-clipboard = "0.4.0" -slotmap = "1.0.7" -ron = "0.11.0" -thiserror = "2.0.17" -tracing = "0.1.41" +i18n-embed-fl = "*" +rust-embed = "*" +open = "*" +dirs = "*" +cli-clipboard = "*" +slotmap = "*" +ron = "*" +thiserror = "*" +tracing = "*" +quick-xml = "*" +icalendar = "*" +base64 = "*" +url = "*" + +[dependencies.reqwest] +version = "*" +default-features = false +features = ["rustls-tls", "gzip"] [dependencies.tracing-subscriber] -version = "0.3.20" +version = "*" features = ["env-filter"] [dependencies.libcosmic] @@ -28,16 +37,16 @@ version = "0.16.0" features = ["fluent-system", "desktop-requester"] [dependencies.serde] -version = "1" +version = "*" features = ["derive"] [dependencies.sqlx] -version = "0.8.6" +version = "*" features = ["sqlite"] default-features = false [dependencies.chrono] -version = "0.4.42" +version = "*" features = ["serde"] [dependencies.uuid] @@ -45,7 +54,7 @@ version = "1.18.1" features = ["v4"] [patch."https://github.com/smithay/client-toolkit.git"] -sctk = { package = "smithay-client-toolkit", version = "=0.19.2" } +sctk = { package = "smithay-client-toolkit", version = "*" } # [patch."https://github.com/pop-os/libcosmic.git"] # libcosmic = { path = "../../edfloreshz-ext/libcosmic" } diff --git a/i18n/en/tasks.ftl b/i18n/en/tasks.ftl index cfb56d71..716e3b6f 100644 --- a/i18n/en/tasks.ftl +++ b/i18n/en/tasks.ftl @@ -70,6 +70,23 @@ match-desktop = Match desktop dark = Dark light = Light +### Sync (CalDAV) +sync = Sync (CalDAV) +sync-server-url = Server URL +sync-server-url-hint = https://mail.example.com/dav/ +sync-username = Username +sync-username-hint = user@example.com +sync-password = Password +sync-password-hint = App password +sync-test-connection = Test connection +sync-now = Sync now +sync-testing = Testing connection… +sync-test-ok = Connection OK. +sync-test-fail = Connection failed: {$error} +sync-running = Syncing… +sync-done = Sync complete. Lists added: {$lists}, tasks pulled: {$pulled}, pushed: {$pushed}. +sync-fail = Sync failed: {$error} + # Menu ## File diff --git a/src/app.rs b/src/app.rs index 847ecbdf..cf64b4c2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -65,6 +65,8 @@ pub struct Tasks { modifiers: Modifiers, dialog_pages: VecDeque, dialog_text_input: widget::Id, + sync_status: String, + sync_in_progress: bool, } #[derive(Debug, Clone)] @@ -78,7 +80,7 @@ pub enum Message { impl Tasks { fn settings(&self) -> Element<'_, Message> { - widget::scrollable(widget::settings::section().title(fl!("appearance")).add( + let appearance = widget::settings::section().title(fl!("appearance")).add( widget::settings::item::item( fl!("theme"), widget::dropdown( @@ -87,7 +89,53 @@ impl Tasks { |theme| Message::Application(ApplicationAction::AppTheme(theme)), ), ), - )) + ); + + let url_input = widget::text_input(fl!("sync-server-url-hint"), &self.config.sync_server_url) + .on_input(|s| Message::Application(ApplicationAction::SetSyncServerUrl(s))); + let user_input = widget::text_input(fl!("sync-username-hint"), &self.config.sync_username) + .on_input(|s| Message::Application(ApplicationAction::SetSyncUsername(s))); + let pass_input = widget::secure_input( + fl!("sync-password-hint"), + &self.config.sync_password, + None, + true, + ) + .on_input(|s| Message::Application(ApplicationAction::SetSyncPassword(s))); + + let test_button = widget::button::standard(fl!("sync-test-connection")) + .on_press_maybe((!self.sync_in_progress).then_some( + Message::Application(ApplicationAction::TestSyncConnection), + )); + let sync_button = widget::button::suggested(fl!("sync-now")) + .on_press_maybe((!self.sync_in_progress).then_some( + Message::Application(ApplicationAction::SyncNow), + )); + + let buttons = widget::row::with_children(vec![ + test_button.into(), + widget::horizontal_space().width(cosmic::iced::Length::Fixed(8.0)).into(), + sync_button.into(), + ]); + + let mut sync_section = widget::settings::section() + .title(fl!("sync")) + .add(widget::settings::item::item(fl!("sync-server-url"), url_input)) + .add(widget::settings::item::item(fl!("sync-username"), user_input)) + .add(widget::settings::item::item(fl!("sync-password"), pass_input)) + .add(widget::settings::item::item("", buttons)); + + if !self.sync_status.is_empty() { + sync_section = sync_section.add(widget::settings::item::item( + "", + widget::text(self.sync_status.clone()), + )); + } + + widget::scrollable( + widget::column::with_children(vec![appearance.into(), sync_section.into()]) + .spacing(16), + ) .into() } @@ -405,6 +453,86 @@ impl Tasks { content::SortType::DateDesc, )))); } + ApplicationAction::SetSyncServerUrl(value) => { + if let Some(handler) = &self.config_handler { + if let Err(err) = self.config.set_sync_server_url(handler, value) { + tracing::error!("{err}"); + } + } + } + ApplicationAction::SetSyncUsername(value) => { + if let Some(handler) = &self.config_handler { + if let Err(err) = self.config.set_sync_username(handler, value) { + tracing::error!("{err}"); + } + } + } + ApplicationAction::SetSyncPassword(value) => { + if let Some(handler) = &self.config_handler { + if let Err(err) = self.config.set_sync_password(handler, value) { + tracing::error!("{err}"); + } + } + } + ApplicationAction::TestSyncConnection => { + self.sync_in_progress = true; + self.sync_status = fl!("sync-testing"); + let config = self.config.clone(); + tasks.push(cosmic::Task::perform( + async move { + crate::sync::engine::test_connection(&config) + .await + .map_err(|e| e.to_string()) + }, + |result| { + cosmic::Action::App(Message::Application( + ApplicationAction::TestSyncConnectionResult(result), + )) + }, + )); + } + ApplicationAction::TestSyncConnectionResult(result) => { + self.sync_in_progress = false; + self.sync_status = match result { + Ok(()) => fl!("sync-test-ok"), + Err(e) => fl!("sync-test-fail", error = e), + }; + } + ApplicationAction::SyncNow => { + self.sync_in_progress = true; + self.sync_status = fl!("sync-running"); + let config = self.config.clone(); + let storage = self.storage.clone(); + tasks.push(cosmic::Task::perform( + async move { + crate::sync::engine::sync(&storage, &config) + .await + .map_err(|e| e.to_string()) + }, + |result| { + cosmic::Action::App(Message::Application( + ApplicationAction::SyncResult(result), + )) + }, + )); + } + ApplicationAction::SyncResult(result) => { + self.sync_in_progress = false; + match result { + Ok(report) => { + self.sync_status = fl!( + "sync-done", + lists = report.lists_pulled, + pulled = report.tasks_pulled, + pushed = report.tasks_pushed + ); + tasks.push(self.update(Message::Tasks(TasksAction::FetchLists))); + } + Err(e) => { + self.sync_status = fl!("sync-fail", error = e); + } + } + } } } @@ -508,6 +636,8 @@ impl Application for Tasks { modifiers: Modifiers::empty(), dialog_pages: VecDeque::new(), dialog_text_input: widget::Id::unique(), + sync_status: String::new(), + sync_in_progress: false, }; let mut tasks = vec![app.update(Message::Tasks(TasksAction::FetchLists))]; diff --git a/src/app/actions.rs b/src/app/actions.rs index ab84a1c5..386d399c 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -46,6 +46,13 @@ pub enum ApplicationAction { SortByNameDesc, SortByDateAsc, SortByDateDesc, + SetSyncServerUrl(String), + SetSyncUsername(String), + SetSyncPassword(String), + TestSyncConnection, + TestSyncConnectionResult(Result<(), String>), + SyncNow, + SyncResult(Result), } #[derive(Debug, Clone)] diff --git a/src/core/config.rs b/src/core/config.rs index 397d6dd5..601c9ae0 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -12,6 +12,9 @@ pub const CONFIG_VERSION: u64 = 1; pub struct TasksConfig { pub app_theme: AppTheme, pub hide_completed: bool, + pub sync_server_url: String, + pub sync_username: String, + pub sync_password: String, } impl TasksConfig { diff --git a/src/main.rs b/src/main.rs index 03f2778e..2d296820 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod core; mod pages; mod storage; +mod sync; use core::settings; diff --git a/src/sync/caldav.rs b/src/sync/caldav.rs new file mode 100644 index 00000000..f6cea0c5 --- /dev/null +++ b/src/sync/caldav.rs @@ -0,0 +1,609 @@ +use base64::Engine as _; +use chrono::{DateTime, Utc}; +use icalendar::{Calendar as ICalendar, Component, Todo}; +use quick_xml::events::Event; +use quick_xml::Reader; +use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE}; +use reqwest::{Client, Method}; +use thiserror::Error; +use url::Url; + +use crate::storage::models::{Priority, Status, Task}; + +#[derive(Debug, Error)] +pub enum CalDavError { + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + #[error("URL parse error: {0}")] + Url(#[from] url::ParseError), + #[error("Invalid header value")] + Header, + #[error("Server returned status {0}")] + Status(u16), + #[error("XML parse error: {0}")] + Xml(String), + #[error("iCalendar parse error: {0}")] + ICal(String), + #[error("No principal discovered")] + NoPrincipal, + #[error("No calendar home discovered")] + NoCalendarHome, +} + +pub type Result = std::result::Result; + +#[derive(Debug, Clone)] +pub struct CalDavClient { + base_url: Url, + auth_header: HeaderValue, + http: Client, +} + +#[derive(Debug, Clone)] +pub struct RemoteCalendar { + pub url: Url, + pub display_name: String, +} + +#[derive(Debug, Clone)] +pub struct RemoteTodo { + pub href: Url, + pub etag: Option, + pub ical: String, +} + +impl CalDavClient { + pub fn new(base_url: &str, username: &str, password: &str) -> Result { + let base_url = Url::parse(base_url)?; + let token = base64::engine::general_purpose::STANDARD + .encode(format!("{username}:{password}")); + let auth_header = HeaderValue::from_str(&format!("Basic {token}")) + .map_err(|_| CalDavError::Header)?; + let http = Client::builder() + .user_agent("cosmic-tasks-caldav/0.1") + .build()?; + Ok(Self { + base_url, + auth_header, + http, + }) + } + + fn headers(&self, depth: Option<&str>, content_type: Option<&str>) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("authorization"), + self.auth_header.clone(), + ); + if let Some(d) = depth { + headers.insert( + HeaderName::from_static("depth"), + HeaderValue::from_str(d).map_err(|_| CalDavError::Header)?, + ); + } + if let Some(ct) = content_type { + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(ct).map_err(|_| CalDavError::Header)?, + ); + } + Ok(headers) + } + + async fn request( + &self, + method: Method, + url: Url, + headers: HeaderMap, + body: Option, + ) -> Result<(reqwest::StatusCode, HeaderMap, String)> { + let mut req = self.http.request(method, url).headers(headers); + if let Some(b) = body { + req = req.body(b); + } + let resp = req.send().await?; + let status = resp.status(); + let headers = resp.headers().clone(); + let text = resp.text().await?; + Ok((status, headers, text)) + } + + /// Verifies credentials by issuing a PROPFIND on the base URL. + pub async fn test_connection(&self) -> Result<()> { + let body = r#" + + + + +"# + .to_string(); + let headers = self.headers(Some("0"), Some("application/xml; charset=utf-8"))?; + let method = Method::from_bytes(b"PROPFIND").unwrap(); + let (status, _, _) = self + .request(method, self.base_url.clone(), headers, Some(body)) + .await?; + if status.is_success() || status.as_u16() == 207 { + Ok(()) + } else { + Err(CalDavError::Status(status.as_u16())) + } + } + + async fn discover_principal(&self) -> Result { + let body = r#" + + +"# + .to_string(); + let headers = self.headers(Some("0"), Some("application/xml; charset=utf-8"))?; + let method = Method::from_bytes(b"PROPFIND").unwrap(); + let (status, _, text) = self + .request(method, self.base_url.clone(), headers, Some(body)) + .await?; + if !(status.is_success() || status.as_u16() == 207) { + return Err(CalDavError::Status(status.as_u16())); + } + let href = first_inner_href(&text, "current-user-principal") + .ok_or(CalDavError::NoPrincipal)?; + Ok(self.base_url.join(&href)?) + } + + async fn discover_calendar_home(&self, principal: &Url) -> Result { + let body = r#" + + +"# + .to_string(); + let headers = self.headers(Some("0"), Some("application/xml; charset=utf-8"))?; + let method = Method::from_bytes(b"PROPFIND").unwrap(); + let (status, _, text) = self + .request(method, principal.clone(), headers, Some(body)) + .await?; + if !(status.is_success() || status.as_u16() == 207) { + return Err(CalDavError::Status(status.as_u16())); + } + let href = first_inner_href(&text, "calendar-home-set") + .ok_or(CalDavError::NoCalendarHome)?; + Ok(self.base_url.join(&href)?) + } + + /// Returns calendars under the user's home that advertise VTODO support. + pub async fn list_task_calendars(&self) -> Result> { + let principal = self.discover_principal().await?; + let home = self.discover_calendar_home(&principal).await?; + let body = r#" + + + + + + +"# + .to_string(); + let headers = self.headers(Some("1"), Some("application/xml; charset=utf-8"))?; + let method = Method::from_bytes(b"PROPFIND").unwrap(); + let (status, _, text) = self + .request(method, home.clone(), headers, Some(body)) + .await?; + if !(status.is_success() || status.as_u16() == 207) { + return Err(CalDavError::Status(status.as_u16())); + } + let responses = parse_multistatus(&text)?; + let mut out = vec![]; + for r in responses { + if !r.is_collection_calendar { + continue; + } + if !r.supports_vtodo { + continue; + } + let url = self.base_url.join(&r.href)?; + // Skip the home itself if it appeared in the listing. + if url == home { + continue; + } + let display_name = r.display_name.unwrap_or_else(|| { + url.path_segments() + .and_then(|s| s.filter(|x| !x.is_empty()).last().map(|x| x.to_string())) + .unwrap_or_else(|| "Calendar".to_string()) + }); + out.push(RemoteCalendar { url, display_name }); + } + Ok(out) + } + + pub async fn fetch_todos(&self, calendar: &Url) -> Result> { + let body = r#" + + + + + + + + + + +"# + .to_string(); + let headers = self.headers(Some("1"), Some("application/xml; charset=utf-8"))?; + let method = Method::from_bytes(b"REPORT").unwrap(); + let (status, _, text) = self + .request(method, calendar.clone(), headers, Some(body)) + .await?; + if !(status.is_success() || status.as_u16() == 207) { + return Err(CalDavError::Status(status.as_u16())); + } + let responses = parse_multistatus(&text)?; + let mut out = vec![]; + for r in responses { + let Some(ical) = r.calendar_data else { + continue; + }; + let href = self.base_url.join(&r.href)?; + out.push(RemoteTodo { + href, + etag: r.etag, + ical, + }); + } + Ok(out) + } + + pub async fn put_todo( + &self, + href: &Url, + ical: &str, + if_match: Option<&str>, + ) -> Result> { + let mut headers = self.headers(None, Some("text/calendar; charset=utf-8"))?; + if let Some(etag) = if_match { + headers.insert( + HeaderName::from_static("if-match"), + HeaderValue::from_str(etag).map_err(|_| CalDavError::Header)?, + ); + } else { + headers.insert( + HeaderName::from_static("if-none-match"), + HeaderValue::from_static("*"), + ); + } + let (status, resp_headers, _) = self + .request(Method::PUT, href.clone(), headers, Some(ical.to_string())) + .await?; + if !status.is_success() { + return Err(CalDavError::Status(status.as_u16())); + } + Ok(resp_headers + .get("etag") + .and_then(|v| v.to_str().ok().map(|s| s.to_string()))) + } + + #[allow(dead_code)] + pub async fn delete_todo(&self, href: &Url, if_match: Option<&str>) -> Result<()> { + let mut headers = self.headers(None, None)?; + if let Some(etag) = if_match { + headers.insert( + HeaderName::from_static("if-match"), + HeaderValue::from_str(etag).map_err(|_| CalDavError::Header)?, + ); + } + let (status, _, _) = self + .request(Method::DELETE, href.clone(), headers, None) + .await?; + if !status.is_success() && status.as_u16() != 404 { + return Err(CalDavError::Status(status.as_u16())); + } + Ok(()) + } +} + +// --- minimal XML helpers ---------------------------------------------------- + +#[derive(Default, Debug)] +struct DavResponse { + href: String, + display_name: Option, + etag: Option, + calendar_data: Option, + is_collection_calendar: bool, + supports_vtodo: bool, +} + +fn local_name(name: &[u8]) -> &[u8] { + match name.iter().rposition(|b| *b == b':') { + Some(i) => &name[i + 1..], + None => name, + } +} + +fn parse_multistatus(xml: &str) -> Result> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut out = vec![]; + let mut buf = vec![]; + let mut stack: Vec> = vec![]; + let mut current: Option = None; + let mut text_target: Option<&'static str> = None; + + loop { + match reader.read_event_into(&mut buf) { + Err(e) => return Err(CalDavError::Xml(e.to_string())), + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => { + let local = local_name(e.name().as_ref()).to_vec(); + stack.push(local.clone()); + match local.as_slice() { + b"response" => current = Some(DavResponse::default()), + b"href" => { + // Only top-level directly under is the resource href. + // Nested hrefs (inside current-user-principal etc.) are handled by callers. + text_target = Some("href"); + } + b"displayname" => text_target = Some("displayname"), + b"getetag" => text_target = Some("etag"), + b"calendar-data" => text_target = Some("caldata"), + b"collection" => { + // Inside ; combined with sibling means a calendar collection. + if let Some(c) = current.as_mut() { + // mark provisional; finalized when we also see + c.is_collection_calendar |= stack.iter().any(|n| n == b"resourcetype") + && false; // placeholder + } + } + b"calendar" => { + if let Some(c) = current.as_mut() { + if stack.iter().any(|n| n == b"resourcetype") { + c.is_collection_calendar = true; + } + } + } + _ => {} + } + } + Ok(Event::Empty(e)) => { + let local = local_name(e.name().as_ref()).to_vec(); + match local.as_slice() { + b"calendar" => { + if let Some(c) = current.as_mut() { + if stack.iter().any(|n| n == b"resourcetype") { + c.is_collection_calendar = true; + } + } + } + b"comp" => { + // inside supported-calendar-component-set + if let Some(c) = current.as_mut() { + for attr in e.attributes().flatten() { + if attr.key.as_ref() == b"name" + && attr.value.as_ref().eq_ignore_ascii_case(b"VTODO") + { + c.supports_vtodo = true; + } + } + } + } + _ => {} + } + } + Ok(Event::Text(t)) => { + if let (Some(target), Some(c)) = (text_target, current.as_mut()) { + let s = t.unescape().map_err(|e| CalDavError::Xml(e.to_string()))?; + match target { + "href" => { + // Only set the resource href on first occurrence within a response. + if c.href.is_empty() { + c.href = s.into_owned(); + } + } + "displayname" => c.display_name = Some(s.into_owned()), + "etag" => c.etag = Some(s.into_owned()), + "caldata" => c.calendar_data = Some(s.into_owned()), + _ => {} + } + } + } + Ok(Event::End(e)) => { + let local = local_name(e.name().as_ref()).to_vec(); + if !stack.is_empty() { + stack.pop(); + } + if local == b"response" { + if let Some(c) = current.take() { + out.push(c); + } + } + text_target = None; + } + _ => {} + } + buf.clear(); + } + Ok(out) +} + +/// Extract the first nested under a named element (e.g. "current-user-principal"). +fn first_inner_href(xml: &str, parent_local: &str) -> Option { + let parent_bytes = parent_local.as_bytes(); + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut buf = vec![]; + let mut depth_in_parent: i32 = 0; + let mut want_text = false; + let mut found: Option = None; + loop { + match reader.read_event_into(&mut buf) { + Err(_) => return None, + Ok(Event::Eof) => break, + Ok(Event::Start(e)) => { + let local = local_name(e.name().as_ref()).to_vec(); + if local == parent_bytes { + depth_in_parent += 1; + } else if depth_in_parent > 0 && local == b"href" { + want_text = true; + } + } + Ok(Event::Text(t)) => { + if want_text && depth_in_parent > 0 && found.is_none() { + if let Ok(s) = t.unescape() { + found = Some(s.into_owned()); + } + } + want_text = false; + } + Ok(Event::End(e)) => { + let local = local_name(e.name().as_ref()).to_vec(); + if local == parent_bytes { + depth_in_parent -= 1; + } + want_text = false; + } + _ => {} + } + buf.clear(); + } + found +} + +// --- VTODO <-> Task mapping ------------------------------------------------- + +pub fn parse_vtodo(ical: &str) -> std::result::Result { + let cal: ICalendar = ical + .parse() + .map_err(|e: String| CalDavError::ICal(e))?; + cal.components + .into_iter() + .find_map(|c| match c { + icalendar::CalendarComponent::Todo(t) => Some(t), + _ => None, + }) + .ok_or_else(|| CalDavError::ICal("no VTODO in iCalendar object".into())) +} + +pub fn vtodo_to_task(todo: &Todo, list_path: std::path::PathBuf) -> Task { + let now = Utc::now(); + let uid = todo.get_uid().unwrap_or("").to_string(); + let summary = todo.get_summary().unwrap_or("").to_string(); + let description = todo.get_description().unwrap_or("").to_string(); + + let status = match todo.property_value("STATUS") { + Some("COMPLETED") => Status::Completed, + _ => Status::NotStarted, + }; + + let priority = match todo + .property_value("PRIORITY") + .and_then(|s| s.parse::().ok()) + { + Some(0) => Priority::Low, + Some(p) if p <= 4 => Priority::High, + Some(p) if p <= 6 => Priority::Normal, + Some(_) => Priority::Low, + None => Priority::Low, + }; + + let due_date = todo + .property_value("DUE") + .and_then(parse_ical_datetime); + let completion_date = todo + .property_value("COMPLETED") + .and_then(parse_ical_datetime); + let created = todo + .property_value("CREATED") + .and_then(parse_ical_datetime) + .unwrap_or(now); + let last_modified = todo + .property_value("LAST-MODIFIED") + .and_then(parse_ical_datetime) + .unwrap_or(now); + let tags = todo + .property_value("CATEGORIES") + .map(|s| { + s.split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect() + }) + .unwrap_or_default(); + + Task { + id: uid, + path: list_path, + title: summary, + favorite: false, + today: false, + status, + priority, + tags, + notes: description, + completion_date, + due_date, + reminder_date: None, + recurrence: Default::default(), + expanded: false, + sub_tasks: vec![], + deletion_date: None, + created_date_time: created, + last_modified_date_time: last_modified, + } +} + +pub fn task_to_vtodo(task: &Task) -> String { + let mut todo = Todo::new(); + todo.uid(&task.id); + todo.summary(&task.title); + if !task.notes.is_empty() { + todo.description(&task.notes); + } + todo.add_property( + "STATUS", + match task.status { + Status::Completed => "COMPLETED", + Status::NotStarted => "NEEDS-ACTION", + }, + ); + let prio = match task.priority { + Priority::High => "1", + Priority::Normal => "5", + Priority::Low => "9", + }; + todo.add_property("PRIORITY", prio); + if let Some(due) = task.due_date { + todo.add_property("DUE", &format_ical_datetime(due)); + } + if let Some(c) = task.completion_date { + todo.add_property("COMPLETED", &format_ical_datetime(c)); + } + if !task.tags.is_empty() { + todo.add_property("CATEGORIES", &task.tags.join(",")); + } + todo.add_property("CREATED", &format_ical_datetime(task.created_date_time)); + todo.add_property( + "LAST-MODIFIED", + &format_ical_datetime(task.last_modified_date_time), + ); + + let mut cal = ICalendar::new(); + cal.push(todo.done()); + cal.to_string() +} + +fn parse_ical_datetime(s: &str) -> Option> { + // Accept basic forms: 20260101T120000Z, 20260101T120000, 20260101. + let s = s.trim(); + if let Ok(dt) = DateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ") { + return Some(dt.with_timezone(&Utc)); + } + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y%m%d") { + let naive = date.and_hms_opt(0, 0, 0)?; + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } + None +} + +fn format_ical_datetime(dt: DateTime) -> String { + dt.format("%Y%m%dT%H%M%SZ").to_string() +} diff --git a/src/sync/engine.rs b/src/sync/engine.rs new file mode 100644 index 00000000..8e07c7a0 --- /dev/null +++ b/src/sync/engine.rs @@ -0,0 +1,201 @@ +use std::collections::HashMap; + +use thiserror::Error; +use url::Url; + +use crate::core::config::TasksConfig; +use crate::storage::models::{List, Task}; +use crate::storage::LocalStorage; + +use super::caldav::{parse_vtodo, task_to_vtodo, vtodo_to_task, CalDavClient, CalDavError}; + +const REMOTE_MARKER: &str = "caldav:"; + +#[derive(Debug, Error)] +pub enum SyncError { + #[error("CalDAV error: {0}")] + CalDav(#[from] CalDavError), + #[error("Storage error: {0}")] + Storage(String), + #[error("Sync is not configured")] + NotConfigured, + #[error("Invalid remote URL: {0}")] + Url(#[from] url::ParseError), +} + +pub fn is_configured(config: &TasksConfig) -> bool { + !config.sync_server_url.trim().is_empty() + && !config.sync_username.trim().is_empty() + && !config.sync_password.is_empty() +} + +pub fn make_client(config: &TasksConfig) -> Result { + if !is_configured(config) { + return Err(SyncError::NotConfigured); + } + Ok(CalDavClient::new( + config.sync_server_url.trim(), + config.sync_username.trim(), + &config.sync_password, + )?) +} + +#[derive(Debug, Clone, Default)] +pub struct SyncReport { + pub lists_pulled: usize, + pub tasks_pulled: usize, + pub tasks_pushed: usize, +} + +/// Identify the remote URL bound to a local list, if any. +fn list_remote_url(list: &List) -> Option { + let line = list + .description + .lines() + .find(|l| l.trim().starts_with(REMOTE_MARKER))?; + let raw = line.trim().trim_start_matches(REMOTE_MARKER).trim(); + Url::parse(raw).ok() +} + +fn set_list_remote_url(list: &mut List, url: &Url) { + let url_str = url.as_str(); + let mut kept: Vec<&str> = list + .description + .lines() + .filter(|l| !l.trim().starts_with(REMOTE_MARKER)) + .collect(); + let line = format!("{REMOTE_MARKER}{url_str}"); + kept.push(&line); + list.description = kept.join("\n"); +} + +/// Bidirectional sync. v1 semantics: +/// - Discover remote VTODO calendars; create matching local lists if missing. +/// - For each linked list: pull remote VTODOs into local, push local-only tasks. +/// - Conflicts: last_modified_date_time wins (no per-side tombstones, so deletes +/// are not propagated yet). +pub async fn sync( + storage: &LocalStorage, + config: &TasksConfig, +) -> Result { + let client = make_client(config)?; + let mut report = SyncReport::default(); + + let mut local_lists = storage.lists().map_err(|e| SyncError::Storage(e.to_string()))?; + let remote_calendars = client.list_task_calendars().await?; + + // Index local lists by their bound remote URL. + let mut by_remote: HashMap = HashMap::new(); + for (i, l) in local_lists.iter().enumerate() { + if let Some(u) = list_remote_url(l) { + by_remote.insert(u.to_string(), i); + } + } + + // Ensure a local list exists for every remote calendar. + for cal in &remote_calendars { + let key = cal.url.to_string(); + if by_remote.contains_key(&key) { + continue; + } + let mut list = List::new(&cal.display_name); + set_list_remote_url(&mut list, &cal.url); + let created = storage + .create_list(&list) + .map_err(|e| SyncError::Storage(e.to_string()))?; + report.lists_pulled += 1; + by_remote.insert(key, local_lists.len()); + local_lists.push(created); + } + + // Sync each linked list. + for cal in &remote_calendars { + let Some(&idx) = by_remote.get(cal.url.as_str()) else { + continue; + }; + let list = local_lists[idx].clone(); + let local_tasks = storage + .tasks(&list) + .map_err(|e| SyncError::Storage(e.to_string()))?; + let remote_todos = client.fetch_todos(&cal.url).await?; + + let mut remote_by_uid: HashMap, String)> = HashMap::new(); + for r in remote_todos { + let todo = match parse_vtodo(&r.ical) { + Ok(t) => t, + Err(e) => { + tracing::warn!("skipping VTODO at {}: {e}", r.href); + continue; + } + }; + let uid = icalendar::Component::get_uid(&todo).unwrap_or("").to_string(); + if uid.is_empty() { + continue; + } + remote_by_uid.insert(uid, (r.href, r.etag, r.ical)); + } + + let local_by_uid: HashMap = local_tasks + .iter() + .map(|t| (t.id.clone(), t.clone())) + .collect(); + + // Pull: write/update local from remote where remote is newer or local missing. + for (uid, (_href, _etag, ical)) in &remote_by_uid { + let Ok(todo) = parse_vtodo(ical) else { continue }; + let remote_task = vtodo_to_task(&todo, list.tasks_path()); + match local_by_uid.get(uid) { + None => { + if let Err(e) = storage.create_task(&remote_task) { + tracing::warn!("create_task {uid} failed: {e}"); + } else { + report.tasks_pulled += 1; + } + } + Some(local) => { + if remote_task.last_modified_date_time > local.last_modified_date_time { + if let Err(e) = storage.update_task(&remote_task) { + tracing::warn!("update_task {uid} failed: {e}"); + } else { + report.tasks_pulled += 1; + } + } + } + } + } + + // Push: PUT local-only tasks, and locally-newer tasks. + for (uid, local) in &local_by_uid { + let ical = task_to_vtodo(local); + let target = cal.url.join(&format!("{uid}.ics"))?; + match remote_by_uid.get(uid) { + None => match client.put_todo(&target, &ical, None).await { + Ok(_) => report.tasks_pushed += 1, + Err(e) => tracing::warn!("PUT {uid} failed: {e}"), + }, + Some((href, etag, _)) => { + let remote_task = parse_vtodo(&remote_by_uid[uid].2) + .ok() + .map(|t| vtodo_to_task(&t, list.tasks_path())); + let push = remote_task + .map(|r| local.last_modified_date_time > r.last_modified_date_time) + .unwrap_or(false); + if push { + match client.put_todo(href, &ical, etag.as_deref()).await { + Ok(_) => report.tasks_pushed += 1, + Err(e) => tracing::warn!("PUT update {uid} failed: {e}"), + } + } + } + } + } + } + + Ok(report) +} + +pub async fn test_connection(config: &TasksConfig) -> Result<(), SyncError> { + let client = make_client(config)?; + client.test_connection().await?; + Ok(()) +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs new file mode 100644 index 00000000..621db5ca --- /dev/null +++ b/src/sync/mod.rs @@ -0,0 +1,2 @@ +pub mod caldav; +pub mod engine; From a92b63f6e506ec22d4e96d2e3073a7e51b61e06c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atay=20=C3=96zcan?= Date: Tue, 28 Apr 2026 03:25:58 +0200 Subject: [PATCH 2/5] fix(sync): handle CDATA-wrapped calendar-data in REPORT responses Stalwart (and others) return as CDATA so the iCal payload survives XML escaping. quick-xml fires that as Event::CData, which the multistatus parser ignored, causing fetch_todos to return zero items and sync to appear no-op. Listen for CData and accumulate text chunks across events. --- src/sync/caldav.rs | 45 +++++++++++++++++++++++++++++++++------------ 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/src/sync/caldav.rs b/src/sync/caldav.rs index f6cea0c5..bac530f6 100644 --- a/src/sync/caldav.rs +++ b/src/sync/caldav.rs @@ -317,6 +317,29 @@ fn local_name(name: &[u8]) -> &[u8] { } } +fn append_target(c: &mut DavResponse, target: &str, s: &str) { + match target { + "href" => { + if c.href.is_empty() { + c.href = s.to_string(); + } + } + "displayname" => match &mut c.display_name { + Some(existing) => existing.push_str(s), + None => c.display_name = Some(s.to_string()), + }, + "etag" => match &mut c.etag { + Some(existing) => existing.push_str(s), + None => c.etag = Some(s.to_string()), + }, + "caldata" => match &mut c.calendar_data { + Some(existing) => existing.push_str(s), + None => c.calendar_data = Some(s.to_string()), + }, + _ => {} + } +} + fn parse_multistatus(xml: &str) -> Result> { let mut reader = Reader::from_str(xml); reader.config_mut().trim_text(true); @@ -389,18 +412,16 @@ fn parse_multistatus(xml: &str) -> Result> { Ok(Event::Text(t)) => { if let (Some(target), Some(c)) = (text_target, current.as_mut()) { let s = t.unescape().map_err(|e| CalDavError::Xml(e.to_string()))?; - match target { - "href" => { - // Only set the resource href on first occurrence within a response. - if c.href.is_empty() { - c.href = s.into_owned(); - } - } - "displayname" => c.display_name = Some(s.into_owned()), - "etag" => c.etag = Some(s.into_owned()), - "caldata" => c.calendar_data = Some(s.into_owned()), - _ => {} - } + append_target(c, target, s.as_ref()); + } + } + Ok(Event::CData(t)) => { + if let (Some(target), Some(c)) = (text_target, current.as_mut()) { + let bytes = t.into_inner(); + let s = std::str::from_utf8(&bytes) + .map_err(|e| CalDavError::Xml(e.to_string()))? + .to_string(); + append_target(c, target, &s); } } Ok(Event::End(e)) => { From ae9e1d8d6c4ceb8a2cf715ded822fc73419e2220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atay=20=C3=96zcan?= Date: Tue, 28 Apr 2026 03:35:08 +0200 Subject: [PATCH 3/5] feat(sync): auto-push on edits + periodic 60s sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LocalStorage::update_task now bumps last_modified_date_time so the sync engine sees local edits as newer than remote. - Add LocalStorage::replace_task that preserves LMD; sync engine uses it on pull to avoid ping-pong. - Pages emit Output::Mutated on save-class events (add/complete/ delete/title submit/expand/sub-task ops; details: title/favorite/ priority/due-date). Keystroke-grade writes (TitleUpdate, Editor) are intentionally skipped to avoid spam — periodic sync picks them up. - App handles Mutated by dispatching SyncNow if configured and not already syncing. - Add 60s subscription emitting SyncTick to drive periodic sync. --- src/app.rs | 27 +++++++++++++++++++++++++ src/app/actions.rs | 1 + src/pages/content.rs | 47 +++++++++++++++++++++++++++++--------------- src/pages/details.rs | 8 ++++++++ src/storage/mod.rs | 16 +++++++++++++++ src/sync/engine.rs | 4 ++-- 6 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/app.rs b/src/app.rs index cf64b4c2..93e56c45 100644 --- a/src/app.rs +++ b/src/app.rs @@ -181,6 +181,9 @@ impl Tasks { } } } + content::Output::Mutated => { + self.maybe_trigger_sync(tasks); + } } } } @@ -203,10 +206,26 @@ impl Tasks { task.clone(), )))); } + details::Output::Mutated => { + self.maybe_trigger_sync(tasks); + } } } } + fn maybe_trigger_sync( + &mut self, + tasks: &mut Vec>>, + ) { + if self.sync_in_progress { + return; + } + if !crate::sync::engine::is_configured(&self.config) { + return; + } + tasks.push(self.update(Message::Application(ApplicationAction::SyncNow))); + } + fn update_dialog( &mut self, tasks: &mut Vec>>, @@ -516,6 +535,9 @@ impl Tasks { }, )); } + ApplicationAction::SyncTick => { + self.maybe_trigger_sync(tasks); + } ApplicationAction::SyncResult(result) => { self.sync_in_progress = false; match result { @@ -796,6 +818,11 @@ impl Application for Tasks { subscriptions.push(self.content.subscription().map(Message::Content)); + subscriptions.push( + cosmic::iced::time::every(std::time::Duration::from_secs(60)) + .map(|_| Message::Application(ApplicationAction::SyncTick)), + ); + Subscription::batch(subscriptions) } diff --git a/src/app/actions.rs b/src/app/actions.rs index 386d399c..81173ae1 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -52,6 +52,7 @@ pub enum ApplicationAction { TestSyncConnection, TestSyncConnectionResult(Result<(), String>), SyncNow, + SyncTick, SyncResult(Result), } diff --git a/src/pages/content.rs b/src/pages/content.rs index 9b36f430..35e21f25 100644 --- a/src/pages/content.rs +++ b/src/pages/content.rs @@ -85,6 +85,7 @@ pub enum Output { ToggleHideCompleted(models::List), Focus(widget::Id), OpenTaskDetails(models::Task), + Mutated, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -600,8 +601,9 @@ impl Content { Message::TaskExpand(default_key) => { if let Some(task) = self.tasks.get_mut(default_key) { task.expanded = !task.expanded; - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update task: {:?}", error), } } } @@ -614,6 +616,7 @@ impl Content { let id = self.tasks.insert(task); self.task_input_ids.insert(id, widget::Id::unique()); self.input.clear(); + tasks.push(Output::Mutated); } Err(error) => { tracing::error!("Failed to create task: {:?}", error); @@ -627,8 +630,9 @@ impl Content { if editing { tasks.push(Output::Focus(self.task_input_ids[id].clone())); } else if let Some(task) = self.tasks.get(id) { - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update task: {:?}", error), } } } @@ -639,6 +643,7 @@ impl Content { Ok(_) => { self.task_editing.insert(id, false); tasks.push(Output::Focus(widget::Id::new("new-task-input"))); + tasks.push(Output::Mutated); } Err(error) => tracing::error!("Failed to update task: {:?}", error), } @@ -651,8 +656,9 @@ impl Content { } Message::TaskDelete(id) => { if let Some(task) = self.tasks.remove(id) { - if let Err(error) = self.storage.delete_task(&task) { - tracing::error!("Failed to delete task: {:?}", error); + match self.storage.delete_task(&task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to delete task: {:?}", error), } } } @@ -664,8 +670,9 @@ impl Content { } else { Status::NotStarted }; - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update task: {:?}", error), } } } @@ -685,6 +692,7 @@ impl Content { .insert(sub_task_id, widget::Id::unique()); self.sub_task_editing.insert(sub_task_id, false); tasks.push(Output::Focus(self.sub_task_input_ids[sub_task_id].clone())); + tasks.push(Output::Mutated); } Err(error) => { tracing::error!("Failed to add sub-task: {:?}", error); @@ -703,8 +711,9 @@ impl Content { if editing { tasks.push(Output::Focus(self.sub_task_input_ids[id].clone())); } else if let Some(task) = self.sub_tasks.get(id) { - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update sub-task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update sub-task: {:?}", error), } } } @@ -714,6 +723,7 @@ impl Content { Ok(_) => { self.sub_task_editing.insert(id, false); tasks.push(Output::Focus(widget::Id::new("new-task-input"))); + tasks.push(Output::Mutated); } Err(error) => tracing::error!("Failed to update sub-task: {:?}", error), } @@ -725,6 +735,7 @@ impl Content { if let Err(error) = self.storage.update_task(task) { tracing::error!("Failed to update sub-task: {:?}", error); } + // No Mutated here: per-keystroke writes; periodic sync will push. } } Message::SubTaskOpenDetails(id) => { @@ -750,6 +761,7 @@ impl Content { .insert(sub_task_id, widget::Id::unique()); self.sub_task_editing.insert(sub_task_id, false); tasks.push(Output::Focus(self.sub_task_input_ids[sub_task_id].clone())); + tasks.push(Output::Mutated); } Err(error) => { tracing::error!("Failed to add sub-task: {:?}", error); @@ -765,23 +777,26 @@ impl Content { } else { Status::NotStarted }; - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update sub-task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update sub-task: {:?}", error), } } } Message::SubTaskDelete(id) => { if let Some(task) = self.sub_tasks.remove(id) { - if let Err(error) = self.storage.delete_task(&task) { - tracing::error!("Failed to delete sub-task: {:?}", error); + match self.storage.delete_task(&task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to delete sub-task: {:?}", error), } } } Message::SubTaskExpand(id) => { if let Some(task) = self.sub_tasks.get_mut(id) { task.expanded = !task.expanded; - if let Err(error) = self.storage.update_task(task) { - tracing::error!("Failed to update sub-task: {:?}", error); + match self.storage.update_task(task) { + Ok(_) => tasks.push(Output::Mutated), + Err(error) => tracing::error!("Failed to update sub-task: {:?}", error), } } } diff --git a/src/pages/details.rs b/src/pages/details.rs index 2de7ac99..a9436388 100644 --- a/src/pages/details.rs +++ b/src/pages/details.rs @@ -39,6 +39,7 @@ pub enum Message { pub enum Output { OpenCalendarDialog, RefreshTask(models::Task), + Mutated, } impl Details { @@ -71,13 +72,16 @@ impl Details { pub fn update(&mut self, message: Message) -> Vec { let mut tasks = vec![]; + let mut emit_mutated = true; match message { Message::Editor(action) => { self.text_editor_content.perform(action); self.task.notes.clone_from(&self.text_editor_content.text()); + emit_mutated = false; // keystroke-grade; periodic sync will push it } Message::SetTitle(title) => { self.task.title.clone_from(&title); + emit_mutated = false; // keystroke-grade } Message::Favorite(favorite) => { self.task.favorite = favorite; @@ -91,6 +95,7 @@ impl Details { } Message::OpenCalendarDialog => { tasks.push(Output::OpenCalendarDialog); + return tasks; } Message::SetDueDate(date) => { let tz = Utc::now().timezone(); @@ -102,6 +107,9 @@ impl Details { tracing::error!("Failed to update task: {}", e); } tasks.push(Output::RefreshTask(self.task.clone())); + if emit_mutated { + tasks.push(Output::Mutated); + } tasks } diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 14906090..e9df3f76 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -117,7 +117,23 @@ impl LocalStorage { } } + /// Local edit: bumps last_modified_date_time so the sync engine pushes it. pub fn update_task(&self, task: &Task) -> Result<(), Error> { + let path = task.file_path(); + if path.exists() { + let mut touched = task.clone(); + touched.last_modified_date_time = chrono::Utc::now(); + let content = ron::to_string(&touched)?; + std::fs::write(path, content)?; + Ok(()) + } else { + Err(Error::Tasks(TasksError::TaskNotFound)) + } + } + + /// Sync write: preserves last_modified_date_time as set by the caller. + /// Used when pulling remote state into local storage. + pub fn replace_task(&self, task: &Task) -> Result<(), Error> { let path = task.file_path(); if path.exists() { let content = ron::to_string(&task)?; diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 8e07c7a0..29dddf73 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -154,8 +154,8 @@ pub async fn sync( } Some(local) => { if remote_task.last_modified_date_time > local.last_modified_date_time { - if let Err(e) = storage.update_task(&remote_task) { - tracing::warn!("update_task {uid} failed: {e}"); + if let Err(e) = storage.replace_task(&remote_task) { + tracing::warn!("replace_task {uid} failed: {e}"); } else { report.tasks_pulled += 1; } From 82ba52cdc25fdf82cc6e7210e9d970b34f336dfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atay=20=C3=96zcan?= Date: Tue, 28 Apr 2026 03:51:14 +0200 Subject: [PATCH 4/5] fix(sync): make push actually fire; dedupe nav lists; move pw to keyring Three fixes for CalDAV sync. - parse_ical_datetime: chrono's DateTime::parse_from_str rejects a literal 'Z' as a timezone specifier, so VTODO timestamps like 20260405T170617Z always failed to parse and fell back to Utc::now(). This made every local task's last_modified equal to the most recent pull time, which then equaled (or trailed) the remote's reparsed "now" in the push comparison, so push never fired. Strip trailing Z and parse via NaiveDateTime in UTC. Tests cover Zulu / floating / date-only forms. - Nav model duplicates after sync: PopulateLists appended without clearing the segmented_button model, so each sync re-added every list. Clear the model first, then restore the previously active list by id. - Password storage: move CalDAV password from cosmic-config (plaintext on disk) to libsecret via the keyring crate. Existing config-stored passwords migrate into the keyring on first launch and are cleared from the config file. Username/server URL stay in cosmic-config. - Surface PUT failures in the Sync status line (added `failed` count) and log response bodies on non-2xx PUTs. --- Cargo.lock | 322 +++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 + i18n/en/tasks.ftl | 2 +- src/app.rs | 90 +++++++++++-- src/sync/caldav.rs | 36 ++++- src/sync/engine.rs | 45 ++++--- src/sync/mod.rs | 1 + src/sync/secret.rs | 40 ++++++ 8 files changed, 504 insertions(+), 37 deletions(-) create mode 100644 src/sync/secret.rs diff --git a/Cargo.lock b/Cargo.lock index 61cf71f1..0cc9ac06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,17 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -618,6 +629,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -770,6 +790,15 @@ dependencies = [ "wayland-client 0.31.11", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.39" @@ -820,6 +849,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "cli-clipboard" version = "0.4.0" @@ -1309,6 +1348,35 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.1", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand 2.3.0", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "derivative" version = "2.2.0" @@ -1362,6 +1430,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -2274,6 +2343,24 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -2870,6 +2957,16 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "instant" version = "0.1.13" @@ -3016,6 +3113,18 @@ dependencies = [ "mutate_once", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "dbus-secret-service", + "log", + "secret-service", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -3140,6 +3249,15 @@ dependencies = [ "zbus 5.13.2", ] +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.7" @@ -3520,6 +3638,19 @@ dependencies = [ "memoffset 0.7.1", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "nom" version = "7.1.3" @@ -3581,6 +3712,70 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4960,6 +5155,25 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.5", + "serde", + "sha2", + "zbus 4.4.0", +] + [[package]] name = "self_cell" version = "1.2.2" @@ -5531,6 +5745,7 @@ dependencies = [ "i18n-embed", "i18n-embed-fl", "icalendar", + "keyring", "libcosmic", "open", "quick-xml", @@ -7428,6 +7643,38 @@ dependencies = [ "zvariant 3.15.2", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast 0.7.2", + "async-process 2.5.0", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener 5.4.1", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.5", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + [[package]] name = "zbus" version = "5.13.2" @@ -7478,6 +7725,19 @@ dependencies = [ "zvariant_utils 1.0.1", ] +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "zvariant_utils 2.1.0", +] + [[package]] name = "zbus_macros" version = "5.13.2" @@ -7504,6 +7764,17 @@ dependencies = [ "zvariant 3.15.2", ] +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant 4.2.0", +] + [[package]] name = "zbus_names" version = "4.3.1" @@ -7567,6 +7838,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "zerotrie" @@ -7636,6 +7921,19 @@ dependencies = [ "zvariant_derive 3.15.2", ] +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + [[package]] name = "zvariant" version = "5.9.2" @@ -7664,6 +7962,19 @@ dependencies = [ "zvariant_utils 1.0.1", ] +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate 3.3.0", + "proc-macro2", + "quote", + "syn 2.0.106", + "zvariant_utils 2.1.0", +] + [[package]] name = "zvariant_derive" version = "5.9.2" @@ -7688,6 +7999,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "zvariant_utils" version = "3.3.0" diff --git a/Cargo.toml b/Cargo.toml index df337b4c..a0a04172 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,11 @@ icalendar = "*" base64 = "*" url = "*" +[dependencies.keyring] +version = "*" +default-features = false +features = ["sync-secret-service", "crypto-rust"] + [dependencies.reqwest] version = "*" default-features = false diff --git a/i18n/en/tasks.ftl b/i18n/en/tasks.ftl index 716e3b6f..7504a960 100644 --- a/i18n/en/tasks.ftl +++ b/i18n/en/tasks.ftl @@ -84,7 +84,7 @@ sync-testing = Testing connection… sync-test-ok = Connection OK. sync-test-fail = Connection failed: {$error} sync-running = Syncing… -sync-done = Sync complete. Lists added: {$lists}, tasks pulled: {$pulled}, pushed: {$pushed}. +sync-done = Sync complete. Lists added: {$lists}, tasks pulled: {$pulled}, pushed: {$pushed}, failed: {$failed}. sync-fail = Sync failed: {$error} # Menu diff --git a/src/app.rs b/src/app.rs index 93e56c45..ed051bce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -67,6 +67,7 @@ pub struct Tasks { dialog_text_input: widget::Id, sync_status: String, sync_in_progress: bool, + sync_password: String, } #[derive(Debug, Clone)] @@ -97,7 +98,7 @@ impl Tasks { .on_input(|s| Message::Application(ApplicationAction::SetSyncUsername(s))); let pass_input = widget::secure_input( fl!("sync-password-hint"), - &self.config.sync_password, + &self.sync_password, None, true, ) @@ -213,6 +214,14 @@ impl Tasks { } } + fn sync_credentials(&self) -> crate::sync::engine::SyncCredentials { + crate::sync::engine::SyncCredentials { + server_url: self.config.sync_server_url.clone(), + username: self.config.sync_username.clone(), + password: self.sync_password.clone(), + } + } + fn maybe_trigger_sync( &mut self, tasks: &mut Vec>>, @@ -220,7 +229,7 @@ impl Tasks { if self.sync_in_progress { return; } - if !crate::sync::engine::is_configured(&self.config) { + if !crate::sync::engine::is_configured(&self.sync_credentials()) { return; } tasks.push(self.update(Message::Application(ApplicationAction::SyncNow))); @@ -480,26 +489,48 @@ impl Tasks { } } ApplicationAction::SetSyncUsername(value) => { + let old = self.config.sync_username.clone(); if let Some(handler) = &self.config_handler { - if let Err(err) = self.config.set_sync_username(handler, value) { + if let Err(err) = self.config.set_sync_username(handler, value.clone()) { tracing::error!("{err}"); } } + // Move existing password under the new username so the keyring + // entry stays addressable by the current user. + if old != value && !self.sync_password.is_empty() { + if !value.is_empty() { + if let Err(e) = + crate::sync::secret::store(&value, &self.sync_password) + { + tracing::warn!("keyring store under new username: {e}"); + } + } + if !old.is_empty() { + crate::sync::secret::delete(&old); + } + } } ApplicationAction::SetSyncPassword(value) => { - if let Some(handler) = &self.config_handler { - if let Err(err) = self.config.set_sync_password(handler, value) { - tracing::error!("{err}"); - } + self.sync_password = value; + let username = self.config.sync_username.clone(); + if username.is_empty() { + // No username yet — keep in memory; will be persisted once + // the username is set. + } else if self.sync_password.is_empty() { + crate::sync::secret::delete(&username); + } else if let Err(e) = + crate::sync::secret::store(&username, &self.sync_password) + { + tracing::warn!("keyring store: {e}"); } } ApplicationAction::TestSyncConnection => { self.sync_in_progress = true; self.sync_status = fl!("sync-testing"); - let config = self.config.clone(); + let creds = self.sync_credentials(); tasks.push(cosmic::Task::perform( async move { - crate::sync::engine::test_connection(&config) + crate::sync::engine::test_connection(&creds) .await .map_err(|e| e.to_string()) }, @@ -520,11 +551,11 @@ impl Tasks { ApplicationAction::SyncNow => { self.sync_in_progress = true; self.sync_status = fl!("sync-running"); - let config = self.config.clone(); + let creds = self.sync_credentials(); let storage = self.storage.clone(); tasks.push(cosmic::Task::perform( async move { - crate::sync::engine::sync(&storage, &config) + crate::sync::engine::sync(&storage, &creds) .await .map_err(|e| e.to_string()) }, @@ -546,7 +577,8 @@ impl Tasks { "sync-done", lists = report.lists_pulled, pulled = report.tasks_pulled, - pushed = report.tasks_pushed + pushed = report.tasks_pushed, + failed = report.tasks_failed ); tasks.push(self.update(Message::Tasks(TasksAction::FetchLists))); } @@ -573,10 +605,21 @@ impl Tasks { } }, TasksAction::PopulateLists(lists) => { + let previously_active = self + .nav_model + .active_data::() + .map(|l| l.id.clone()); + self.nav_model.clear(); for list in lists { self.create_nav_item(&list); } - let Some(entity) = self.nav_model.iter().next() else { + let restore = previously_active.and_then(|id| { + self.nav_model + .iter() + .find(|e| self.nav_model.data::(*e).map(|l| l.id == id).unwrap_or(false)) + }); + let target = restore.or_else(|| self.nav_model.iter().next()); + let Some(entity) = target else { return; }; self.nav_model.activate(entity); @@ -660,8 +703,29 @@ impl Application for Tasks { dialog_text_input: widget::Id::unique(), sync_status: String::new(), sync_in_progress: false, + sync_password: String::new(), }; + // Load password from libsecret. If config still holds a legacy plaintext + // password, migrate it into the keyring and clear the config field. + let username = app.config.sync_username.clone(); + if let Some(pw) = crate::sync::secret::load(&username) { + app.sync_password = pw; + } else if !app.config.sync_password.is_empty() { + let legacy = std::mem::take(&mut app.config.sync_password); + if let Err(e) = crate::sync::secret::store(&username, &legacy) { + tracing::warn!("Failed to migrate password to keyring: {e}"); + app.sync_password = legacy; + } else { + app.sync_password = legacy; + if let Some(handler) = &app.config_handler { + if let Err(e) = app.config.set_sync_password(handler, String::new()) { + tracing::warn!("Failed to clear legacy password from config: {e}"); + } + } + } + } + let mut tasks = vec![app.update(Message::Tasks(TasksAction::FetchLists))]; if let Some(id) = app.core.main_window_id() { diff --git a/src/sync/caldav.rs b/src/sync/caldav.rs index bac530f6..ca5645b4 100644 --- a/src/sync/caldav.rs +++ b/src/sync/caldav.rs @@ -268,10 +268,11 @@ impl CalDavClient { HeaderValue::from_static("*"), ); } - let (status, resp_headers, _) = self + let (status, resp_headers, body) = self .request(Method::PUT, href.clone(), headers, Some(ical.to_string())) .await?; if !status.is_success() { + tracing::warn!("PUT {href} -> {} body: {body}", status.as_u16()); return Err(CalDavError::Status(status.as_u16())); } Ok(resp_headers @@ -610,21 +611,42 @@ pub fn task_to_vtodo(task: &Task) -> String { } fn parse_ical_datetime(s: &str) -> Option> { - // Accept basic forms: 20260101T120000Z, 20260101T120000, 20260101. + // Accept basic iCalendar forms: 20260101T120000Z, 20260101T120000, 20260101. + // chrono::DateTime::parse_from_str rejects a literal 'Z' as a timezone + // specifier, so strip it and parse as NaiveDateTime in UTC. let s = s.trim(); - if let Ok(dt) = DateTime::parse_from_str(s, "%Y%m%dT%H%M%SZ") { - return Some(dt.with_timezone(&Utc)); - } - if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s, "%Y%m%dT%H%M%S") { + let no_z = s.strip_suffix('Z').unwrap_or(s); + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(no_z, "%Y%m%dT%H%M%S") { return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); } - if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y%m%d") { + if let Ok(date) = chrono::NaiveDate::parse_from_str(no_z, "%Y%m%d") { let naive = date.and_hms_opt(0, 0, 0)?; return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); } None } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_zulu_datetime() { + let dt = parse_ical_datetime("20260405T170617Z").expect("should parse"); + assert_eq!(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-04-05T17:06:17Z"); + } + + #[test] + fn parses_floating_datetime() { + assert!(parse_ical_datetime("20260101T120000").is_some()); + } + + #[test] + fn parses_date_only() { + assert!(parse_ical_datetime("20260330").is_some()); + } +} + fn format_ical_datetime(dt: DateTime) -> String { dt.format("%Y%m%dT%H%M%SZ").to_string() } diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 29dddf73..1d8cdf86 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use thiserror::Error; use url::Url; -use crate::core::config::TasksConfig; use crate::storage::models::{List, Task}; use crate::storage::LocalStorage; @@ -23,20 +22,27 @@ pub enum SyncError { Url(#[from] url::ParseError), } -pub fn is_configured(config: &TasksConfig) -> bool { - !config.sync_server_url.trim().is_empty() - && !config.sync_username.trim().is_empty() - && !config.sync_password.is_empty() +#[derive(Debug, Clone)] +pub struct SyncCredentials { + pub server_url: String, + pub username: String, + pub password: String, } -pub fn make_client(config: &TasksConfig) -> Result { - if !is_configured(config) { +pub fn is_configured(creds: &SyncCredentials) -> bool { + !creds.server_url.trim().is_empty() + && !creds.username.trim().is_empty() + && !creds.password.is_empty() +} + +pub fn make_client(creds: &SyncCredentials) -> Result { + if !is_configured(creds) { return Err(SyncError::NotConfigured); } Ok(CalDavClient::new( - config.sync_server_url.trim(), - config.sync_username.trim(), - &config.sync_password, + creds.server_url.trim(), + creds.username.trim(), + &creds.password, )?) } @@ -45,6 +51,7 @@ pub struct SyncReport { pub lists_pulled: usize, pub tasks_pulled: usize, pub tasks_pushed: usize, + pub tasks_failed: usize, } /// Identify the remote URL bound to a local list, if any. @@ -76,9 +83,9 @@ fn set_list_remote_url(list: &mut List, url: &Url) { /// are not propagated yet). pub async fn sync( storage: &LocalStorage, - config: &TasksConfig, + creds: &SyncCredentials, ) -> Result { - let client = make_client(config)?; + let client = make_client(creds)?; let mut report = SyncReport::default(); let mut local_lists = storage.lists().map_err(|e| SyncError::Storage(e.to_string()))?; @@ -171,7 +178,10 @@ pub async fn sync( match remote_by_uid.get(uid) { None => match client.put_todo(&target, &ical, None).await { Ok(_) => report.tasks_pushed += 1, - Err(e) => tracing::warn!("PUT {uid} failed: {e}"), + Err(e) => { + tracing::warn!("PUT {uid} failed: {e}"); + report.tasks_failed += 1; + } }, Some((href, etag, _)) => { let remote_task = parse_vtodo(&remote_by_uid[uid].2) @@ -183,7 +193,10 @@ pub async fn sync( if push { match client.put_todo(href, &ical, etag.as_deref()).await { Ok(_) => report.tasks_pushed += 1, - Err(e) => tracing::warn!("PUT update {uid} failed: {e}"), + Err(e) => { + tracing::warn!("PUT update {uid} failed: {e}"); + report.tasks_failed += 1; + } } } } @@ -194,8 +207,8 @@ pub async fn sync( Ok(report) } -pub async fn test_connection(config: &TasksConfig) -> Result<(), SyncError> { - let client = make_client(config)?; +pub async fn test_connection(creds: &SyncCredentials) -> Result<(), SyncError> { + let client = make_client(creds)?; client.test_connection().await?; Ok(()) } diff --git a/src/sync/mod.rs b/src/sync/mod.rs index 621db5ca..8c5f349a 100644 --- a/src/sync/mod.rs +++ b/src/sync/mod.rs @@ -1,2 +1,3 @@ pub mod caldav; pub mod engine; +pub mod secret; diff --git a/src/sync/secret.rs b/src/sync/secret.rs new file mode 100644 index 00000000..677a004f --- /dev/null +++ b/src/sync/secret.rs @@ -0,0 +1,40 @@ +use keyring::Entry; + +const SERVICE: &str = "dev.edfloreshz.Tasks.caldav"; + +fn entry(username: &str) -> keyring::Result { + Entry::new(SERVICE, username) +} + +/// Returns Ok(None) if the entry exists but has no value, or no entry exists. +pub fn load(username: &str) -> Option { + if username.is_empty() { + return None; + } + match entry(username).and_then(|e| e.get_password()) { + Ok(s) => Some(s), + Err(keyring::Error::NoEntry) => None, + Err(e) => { + tracing::warn!("keyring load for {username}: {e}"); + None + } + } +} + +pub fn store(username: &str, password: &str) -> Result<(), String> { + if username.is_empty() { + return Err("username is empty".to_string()); + } + entry(username) + .and_then(|e| e.set_password(password)) + .map_err(|e| e.to_string()) +} + +pub fn delete(username: &str) { + if username.is_empty() { + return; + } + if let Ok(e) = entry(username) { + let _ = e.delete_credential(); + } +} From d2a5afa2b2430c3c142715f3980eae9c7a1fa3b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Atay=20=C3=96zcan?= Date: Wed, 29 Apr 2026 02:30:47 +0200 Subject: [PATCH 5/5] feat(0.3.0): polish CalDAV release Account / keyring - Move CalDAV password to the system keyring (Secret Service / cosmic-keyring); drop the legacy plaintext-password migration and the now-unused sync_password field on TasksConfig. - Replace the description-embedded "caldav:URL" marker with a proper List::remote_url field; legacy lists migrate on first sync. - Account settings panel gains a status row, helper text under each input, last-synced relative timestamp, and a destructive Sign-out button that wipes config and keyring entry. Sync triggers - Sync icon in the header bar (configured-only, disables while running). - "Sync now" entries in the View menu and per-list right-click menu. UI polish - Due-date badge on every task row ("Today" / "Tomorrow" / "Yesterday" / weekday / YYYY-MM-DD), localized. - Sort by due date (Earliest/Latest); completed tasks always sink to the bottom regardless of sort. Bug fixes - Pulled VTODOs now appear immediately in the active list. SetList short-circuited when the list id was unchanged, so post-sync the view stayed stale until the user reselected. New Message::ReloadTasks is dispatched after every successful sync. - Date dialog Complete handler called details::update directly and dropped RefreshTask/Mutated; routed through Message::Details(..) so the in-memory task and sidebar refresh and a sync is triggered. - "invalid SecondaryMap key used" panic: ReloadTasks rebuilds the slotmap, so message handlers could arrive with stale DefaultKeys. All hot-path SecondaryMap accesses now use .get() with bail-out. - Date picker stored UTC midnight, which shifted to the previous day for any UTC-negative offset. Stores local-midnight (as UTC) and emits VALUE=DATE for all-day RFC encoding so other clients show the same calendar day. - Rename / Set-Icon dialogs now correctly target the entity passed in from the nav context menu instead of the active list. - CalDAV calendar URLs without a trailing slash had Url::join() silently replace the last segment; trailing slashes are now enforced at discovery. - Removed unsafe impl Send for List (PathBuf is already Send). - Dropped the dead sqlx dependency and Error::Sqlx variant. iCalendar interop - Use icalendar::Todo::get_due() so all RFC 5545 forms (DATE, DATE-TIME UTC / floating / TZID) are accepted; textual fallback parser also accepts ISO-8601 extended forms (with separators and with offset). - Always emit DTSTAMP, some servers refuse VTODOs without it. - Use Todo::completed() for COMPLETED so it's a proper UTC date-time. Release housekeeping - 0.3.0 metainfo entry; flatpak finish-args gain --share=network and --talk-name=org.freedesktop.secrets; changed from offline-only to always. - README gets a CalDAV section; new CHANGELOG.md (Keep-a-Changelog). - Reorganized .gitignore. - About dialog reads version from CARGO_PKG_VERSION. Tests - 12 unit tests cover legacy-marker parsing, remote_url precedence, marker stripping, ISO-8601 (UTC, offset, extended), garbage rejection, and the all-day VALUE=DATE round-trip. --- .gitignore | 44 ++- CHANGELOG.md | 111 ++++++++ Cargo.lock | 227 +-------------- Cargo.toml | 7 +- README.md | 29 ++ dev.edfloreshz.Tasks.json | 2 + i18n/en/tasks.ftl | 42 ++- res/dev.edfloreshz.Tasks.metainfo.xml | 17 +- src/app.rs | 392 ++++++++++++++++++-------- src/app/actions.rs | 12 +- src/app/dialog.rs | 4 +- src/app/error.rs | 2 - src/app/menu.rs | 12 +- src/core/config.rs | 6 +- src/core/localize.rs | 2 +- src/core/settings/app.rs | 6 +- src/core/settings/error.rs | 5 +- src/core/style/segmented_control.rs | 4 +- src/pages/content.rs | 115 +++++++- src/pages/details.rs | 150 +++++----- src/storage/migration.rs | 2 +- src/storage/mod.rs | 2 +- src/storage/models/list.rs | 6 +- src/sync/caldav.rs | 217 +++++++++++--- src/sync/engine.rs | 111 ++++++-- 25 files changed, 998 insertions(+), 529 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.gitignore b/.gitignore index eff29026..360929ac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,20 +1,44 @@ -# Generated by Cargo -# will have compiled files and executables +# --- Cargo / Rust build output ---------------------------------------------- debug/ target/ - -# These are backup files generated by rustfmt +/target **/*.rs.bk - -# MSVC Windows builds of rustc generate these, which store debugging information *.pdb - -# Added by cargo - -/target +# --- Editors / IDE state ---------------------------------------------------- /.idea /.vscode +/.zed +.zed/ +.fleet/ +.devcontainer/ +*.code-workspace + +# --- Flatpak build state ---------------------------------------------------- /.flatpak-builder /flatpak_build +/build/ +/builddir/ **/.venv + +# --- Tooling artifacts ------------------------------------------------------ +# Qodana / JetBrains static analysis output +qodana.sarif.json +qodana.yaml +.qodana/ + +# Local agent / scratch directories +.ai/ +.claude/ +.aider* + +# --- OS noise --------------------------------------------------------------- +.DS_Store +Thumbs.db +*~ +*.swp +*.swo + +# --- Local secrets / overrides --------------------------------------------- +.env +.env.local diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..187e5616 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to this fork are recorded here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project +adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.3.0] — 2026-04-29 + +This release introduces CalDAV sync and a number of related quality-of-life +improvements. + +### Added + +- **CalDAV sync.** Two-way sync against any RFC-4791 server (Nextcloud, + Radicale, SOGo, Fastmail, …). Calendars that advertise `VTODO` support are + auto-discovered; a local list is created for each. +- **Account credentials in the keyring.** The CalDAV password is stored via + the system Secret Service (cosmic-keyring), never in `cosmic-config` on + disk. +- **Push on edit + 60 s background sync.** Local edits trigger a push + shortly after they happen; a tick subscription also runs a full sync + every minute. +- **Sync triggers everywhere.** + - A sync icon in the header bar that disables itself while a sync is + in progress. + - "Sync now" entry under the **View** menu. + - "Sync now" in the per-list right-click menu in the sidebar. +- **Account settings panel.** The old "Sync (CalDAV)" section is now a + proper "Account" panel: status row with icon and message ("Signed in + as user@example.com" / "Not configured" / "Syncing…" / error), helper + text under each input, a "Last synced" relative timestamp ("just now", + "5 minutes ago"), and a destructive "Sign out" button that wipes the + saved URL/username and removes the keyring entry. +- **Due-date badge on each task row.** Renders "Today", "Tomorrow", + "Yesterday", an upcoming weekday, or `YYYY-MM-DD` otherwise — using the + user's local timezone. +- **Sort by due date.** New "Due date (Earliest first)" / "(Latest first)" + entries in the Sort menu. Completed tasks always sink to the bottom + regardless of the chosen sort. +- **`List::remote_url`.** Lists carry an explicit `Option` field + binding them to a CalDAV resource. Replaces the previous practice of + stuffing `caldav:URL` into the description; legacy lists are migrated + automatically on first sync. +- **`CHANGELOG.md`** and a CalDAV section in `README.md`. + +### Changed + +- **Date picker now stores local-midnight, not UTC-midnight.** Picking + "April 28" stays "April 28" regardless of viewer timezone, both in the + UI and on the wire. +- **All-day DUE is emitted as `VALUE=DATE`.** RFC-correct encoding for an + all-day VTODO; the previous `DUE:…T000000Z` form caused other clients to + shift the displayed day. +- **Date format on the details pane** changed from `MM-DD-YYYY` to ISO + `YYYY-MM-DD`. +- **`DTSTAMP` is always emitted** on outgoing VTODOs — some servers refuse + components without one. +- **iCalendar parsing is far more permissive.** Uses + `icalendar::Todo::get_due()` so all RFC 5545 forms (`DATE`, `DATE-TIME` + UTC / floating / TZID) are accepted; falls back to a textual parse for + ISO-8601 with separators and offsets. +- **Bumped to libcosmic 1.0** flatpak permissions: `--share=network` and + `--talk-name=org.freedesktop.secrets`. +- **About dialog** now reads the version from `CARGO_PKG_VERSION`. + +### Fixed + +- **Pulled VTODOs now appear immediately in the active list.** Previously + `Message::SetList` short-circuited when the list id was unchanged, so + tasks pulled from CalDAV would land on disk but stay invisible until the + user reselected the list. A new `Message::ReloadTasks` is dispatched + after every successful sync. +- **Date dialog now persists the picked date.** The Calendar dialog's + `Complete` handler used to call `details.update(...)` directly, + discarding the resulting `RefreshTask`/`Mutated` outputs. It now routes + through the regular update path so the in-memory task copy and the + sidebar refresh, and a sync is triggered. +- **`invalid SecondaryMap key used` panic.** Rewriting the slotmap on + `SetTasks` (which `ReloadTasks` triggers) could leave message handlers + dereferencing stale `DefaultKey`s. All `task_input_ids[..]` / + `sub_task_input_ids[..]` accesses now use `.get()` and bail out when the + key is gone. +- **Rename / Set-Icon dialogs** now correctly target the entity passed in + from the nav context menu — previously they always wrote back to the + active list. +- **Calendar URLs without a trailing slash** had `Url::join("uid.ics")` + silently replace the last path segment. Trailing slashes are now + enforced when discovering calendars. +- **Stale CalDAV XML parser branch.** Removed a `b"collection"` branch + whose `&& false` placeholder made it dead code. +- **Removed `unsafe impl Send for List`.** It was unnecessary (`PathBuf` is + already `Send`) and unsound to assert manually. + +### Removed + +- **`sync_password` from `TasksConfig`.** Passwords live only in the + keyring now; the legacy plaintext-password migration block in `init` is + gone. +- **`sqlx` dependency.** Nothing in the codebase used it; the only + reference was a dead `Error::Sqlx` variant. Removing it shaves a + significant chunk off the build graph. +- **`caldav:` description marker.** Superseded by `List::remote_url`; the + marker is still read once for migration and then stripped. + +### Tests + +- New unit tests cover legacy-marker parsing, `remote_url` precedence, + marker stripping, ISO-8601 date parsing (UTC, offset, extended), garbage + rejection, and the all-day round-trip emitting `VALUE=DATE`. + +[0.3.0]: https://github.com/edfloreshz/tasks/compare/v0.2.0...v0.3.0 diff --git a/Cargo.lock b/Cargo.lock index 0cc9ac06..c93ffe98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,12 +143,6 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - [[package]] name = "almost" version = "0.2.0" @@ -478,15 +472,6 @@ dependencies = [ "syn 2.0.106", ] -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -1211,21 +1196,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" - [[package]] name = "crc32fast" version = "1.4.2" @@ -1235,15 +1205,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -1513,12 +1474,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "dotenvy" -version = "0.15.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" - [[package]] name = "downcast-rs" version = "1.2.1" @@ -1569,15 +1524,6 @@ dependencies = [ "linux-raw-sys 0.6.5", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - [[package]] name = "endi" version = "1.1.0" @@ -1812,17 +1758,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - [[package]] name = "fnv" version = "1.0.7" @@ -1978,17 +1913,6 @@ dependencies = [ "num_cpus", ] -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot 0.12.4", -] - [[package]] name = "futures-io" version = "0.3.31" @@ -2266,8 +2190,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -2277,15 +2199,6 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown 0.15.5", -] - [[package]] name = "hassle-rs" version = "0.11.0" @@ -2307,12 +2220,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" version = "0.3.9" @@ -3285,17 +3192,6 @@ dependencies = [ "redox_syscall 0.7.1", ] -[[package]] -name = "libsqlite3-sys" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linebender_resource_handle" version = "0.1.1" @@ -4161,7 +4057,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" dependencies = [ - "heck 0.4.1", + "heck", "proc-macro2", "proc-macro2-diagnostics", "quote", @@ -5486,15 +5382,6 @@ dependencies = [ "x11rb 0.13.1", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -5504,107 +5391,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "sqlx" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" -dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-sqlite", -] - -[[package]] -name = "sqlx-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" -dependencies = [ - "base64", - "bytes", - "crc", - "crossbeam-queue", - "either", - "event-listener 5.4.1", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.15.5", - "hashlink", - "indexmap", - "log", - "memchr", - "once_cell", - "percent-encoding", - "serde", - "sha2", - "smallvec", - "thiserror 2.0.18", - "tracing", - "url", -] - -[[package]] -name = "sqlx-macros" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" -dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.106", -] - -[[package]] -name = "sqlx-macros-core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" -dependencies = [ - "dotenvy", - "either", - "heck 0.5.0", - "hex", - "once_cell", - "proc-macro2", - "quote", - "serde", - "serde_json", - "sha2", - "sqlx-core", - "sqlx-sqlite", - "syn 2.0.106", - "url", -] - -[[package]] -name = "sqlx-sqlite" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" -dependencies = [ - "atoi", - "flume", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", - "serde", - "serde_urlencoded", - "sqlx-core", - "thiserror 2.0.18", - "tracing", - "url", -] - [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -5736,7 +5522,7 @@ dependencies = [ [[package]] name = "tasks" -version = "0.2.0" +version = "0.3.0" dependencies = [ "base64", "chrono", @@ -5754,8 +5540,7 @@ dependencies = [ "rust-embed", "serde", "slotmap", - "sqlx", - "thiserror 2.0.18", + "thiserror 1.0.69", "tracing", "tracing-subscriber", "url", @@ -6344,12 +6129,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index a0a04172..5d0cc580 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tasks" -version = "0.2.0" +version = "0.3.0" edition = "2024" [dependencies] @@ -45,11 +45,6 @@ features = ["fluent-system", "desktop-requester"] version = "*" features = ["derive"] -[dependencies.sqlx] -version = "*" -features = ["sqlite"] -default-features = false - [dependencies.chrono] version = "*" features = ["serde"] diff --git a/README.md b/README.md index 9d7a0199..92ca6e96 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,35 @@ +# CalDAV sync + +This fork adds two-way CalDAV sync so your task lists can stay in sync with +servers like Nextcloud, Radicale, SOGo, and Fastmail. + +Configure it from **Settings → Sync (CalDAV)**: + +- **Server URL** — the root DAV path, e.g. `https://cloud.example.com/remote.php/dav/` +- **Username** — your account name +- **Password** — an app password (recommended for Nextcloud / Fastmail). Stored + in the system keyring (Secret Service / cosmic-keyring), never on disk. + +Then hit **Test connection** and **Sync now**. Once configured: + +- Edits push automatically a moment after they happen. +- A periodic sync runs in the background every 60 seconds. +- A sync icon appears in the header bar and as a "Sync now" entry in the + **View** menu and per-list right-click menu. + +Remote calendars that support `VTODO` are auto-discovered; one local list is +created for each. Conflicts use `LAST-MODIFIED` to pick a winner. Deletes are +not yet propagated — see [CHANGELOG.md](CHANGELOG.md). + +### Flatpak permissions + +The Flatpak manifest already requests `--share=network` and the secret service. +If you build a sandboxed copy yourself, make sure those are present, otherwise +the keyring write or the HTTPS request will silently fail. + # Installation ``` git clone https://github.com/edfloreshz/tasks.git diff --git a/dev.edfloreshz.Tasks.json b/dev.edfloreshz.Tasks.json index 44420b6a..f8190fe8 100644 --- a/dev.edfloreshz.Tasks.json +++ b/dev.edfloreshz.Tasks.json @@ -7,10 +7,12 @@ "command": "tasks", "finish-args": [ "--share=ipc", + "--share=network", "--socket=wayland", "--socket=fallback-x11", "--device=dri", "--talk-name=com.system76.CosmicSettingsDaemon", + "--talk-name=org.freedesktop.secrets", "--filesystem=xdg-config/cosmic:ro" ], "build-options": { diff --git a/i18n/en/tasks.ftl b/i18n/en/tasks.ftl index 7504a960..92aac720 100644 --- a/i18n/en/tasks.ftl +++ b/i18n/en/tasks.ftl @@ -70,16 +70,43 @@ match-desktop = Match desktop dark = Dark light = Light -### Sync (CalDAV) -sync = Sync (CalDAV) +### Account (CalDAV sync) +account = Account +account-description = Sync your tasks with a CalDAV server such as Nextcloud, Radicale, SOGo or Fastmail. Credentials are stored in the system keyring. sync-server-url = Server URL -sync-server-url-hint = https://mail.example.com/dav/ +sync-server-url-hint = https://cloud.example.com/remote.php/dav/ +sync-server-url-description = The root DAV path of your account. sync-username = Username sync-username-hint = user@example.com +sync-username-description = Usually your email or login name. sync-password = Password -sync-password-hint = App password +sync-password-hint = Password or app password +sync-password-description = Tip: many providers (Nextcloud, Fastmail, iCloud) require an app-specific password rather than your main account password. sync-test-connection = Test connection sync-now = Sync now +sync-sign-out = Sign out +sync-sign-out-confirm-title = Sign out +sync-sign-out-confirm-body = Remove your CalDAV server URL and username from this device, and delete the password from the keyring? Local task lists will not be deleted. +account-status = Status +account-status-not-configured = Not configured +account-status-ready = Signed in as {$username} +account-status-syncing = Syncing… +account-status-error = Error: {$error} +account-last-sync = Last synced +account-last-sync-never = Never +account-last-sync-just-now = just now +account-last-sync-minutes = {$count -> + [one] {$count} minute ago + *[other] {$count} minutes ago +} +account-last-sync-hours = {$count -> + [one] {$count} hour ago + *[other] {$count} hours ago +} +account-last-sync-days = {$count -> + [one] {$count} day ago + *[other] {$count} days ago +} sync-testing = Testing connection… sync-test-ok = Connection OK. sync-test-fail = Connection failed: {$error} @@ -122,3 +149,10 @@ sort-name-asc = Name A-Z sort-name-desc = Name Z-A sort-date-asc = Date added (Old to New) sort-date-desc = Date added (New to Old) +sort-due-asc = Due date (Earliest first) +sort-due-desc = Due date (Latest first) + +# Due-date badges +due-today = Today +due-tomorrow = Tomorrow +due-yesterday = Yesterday diff --git a/res/dev.edfloreshz.Tasks.metainfo.xml b/res/dev.edfloreshz.Tasks.metainfo.xml index cb38be97..2071ca52 100644 --- a/res/dev.edfloreshz.Tasks.metainfo.xml +++ b/res/dev.edfloreshz.Tasks.metainfo.xml @@ -43,6 +43,21 @@ https://raw.githubusercontent.com/edfloreshz/tasks/master/res/icons/hicolor/scalable/apps/dev.edfloreshz.Tasks.svg dev.edfloreshz.Tasks.desktop + + +

CalDAV sync 🌐

+
    +
  • Two-way sync against any CalDAV server (Nextcloud, Radicale, SOGo, Fastmail, …)
  • +
  • Auto-discovers task calendars from your account
  • +
  • Pushes edits as you go and runs a periodic sync in the background
  • +
  • One-click sync from the header bar, View menu, and per-list right-click menu
  • +
  • Account credentials are stored in the system keyring (Secret Service / cosmic-keyring)
  • +
  • Accepts the full range of iCalendar date forms (UTC, floating, TZID, all-day, ISO-8601)
  • +
  • Pulled VTODOs now appear immediately in the active list — no need to reselect
  • +
  • Internet must be enabled in flatpak permissions to use sync
  • +
+
+

Powerful new features! 🚀

@@ -126,7 +141,7 @@ 360 - offline-only + always keyboard diff --git a/src/app.rs b/src/app.rs index ed051bce..8dba904b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,20 +15,20 @@ use std::{ use cli_clipboard::{ClipboardContext, ClipboardProvider}; use cosmic::{ + Application, ApplicationExt, Element, app::{self, Core}, cosmic_config::{self, Update}, cosmic_theme::{self, ThemeMode}, iced::{ + Event, Length, Subscription, keyboard::{Event as KeyEvent, Modifiers}, - Event, Subscription, }, widget::{ self, calendar::CalendarModel, - menu::{key_bind::KeyBind, Action as _}, + menu::{Action as _, key_bind::KeyBind}, segmented_button::{Entity, EntityMut, SingleSelect}, }, - Application, ApplicationExt, Element, }; use crate::{ @@ -47,7 +47,7 @@ use crate::{ content::{self, Content}, details::{self, Details}, }, - storage::{models::List, LocalStorage}, + storage::{LocalStorage, models::List}, }; pub struct Tasks { @@ -68,6 +68,8 @@ pub struct Tasks { sync_status: String, sync_in_progress: bool, sync_password: String, + sync_last_at: Option>, + sync_last_error: Option, } #[derive(Debug, Clone)] @@ -81,61 +83,148 @@ pub enum Message { impl Tasks { fn settings(&self) -> Element<'_, Message> { - let appearance = widget::settings::section().title(fl!("appearance")).add( - widget::settings::item::item( - fl!("theme"), - widget::dropdown( - &self.app_themes, - Some(self.config.app_theme.into()), - |theme| Message::Application(ApplicationAction::AppTheme(theme)), + let spacing = cosmic::theme::active().cosmic().spacing; + let appearance = + widget::settings::section() + .title(fl!("appearance")) + .add(widget::settings::item::item( + fl!("theme"), + widget::dropdown( + &self.app_themes, + Some(self.config.app_theme.into()), + |theme| Message::Application(ApplicationAction::AppTheme(theme)), + ), + )); + + let creds = self.sync_credentials(); + let configured = crate::sync::engine::is_configured(&creds); + + // --- status row ------------------------------------------------- + let (status_icon, status_text, status_class) = if self.sync_in_progress { + ( + "process-working-symbolic", + fl!("account-status-syncing"), + cosmic::style::Text::Default, + ) + } else if let Some(err) = &self.sync_last_error { + ( + "dialog-error-symbolic", + fl!("account-status-error", error = err.as_str()), + cosmic::style::Text::Color(cosmic::iced::Color::from_rgb(0.86, 0.30, 0.30)), + ) + } else if configured { + ( + "emblem-default-symbolic", + fl!( + "account-status-ready", + username = self.config.sync_username.as_str() ), - ), + cosmic::style::Text::Accent, + ) + } else { + ( + "dialog-information-symbolic", + fl!("account-status-not-configured"), + cosmic::style::Text::Default, + ) + }; + let status_row = widget::row::with_children(vec![ + icons::get_icon(status_icon, 16).into(), + widget::text(status_text).class(status_class).into(), + ]) + .align_y(cosmic::iced::Alignment::Center) + .spacing(spacing.space_xs); + + let last_sync_row = widget::settings::item::item( + fl!("account-last-sync"), + widget::text(format_relative_time(self.sync_last_at)), ); - let url_input = widget::text_input(fl!("sync-server-url-hint"), &self.config.sync_server_url) - .on_input(|s| Message::Application(ApplicationAction::SetSyncServerUrl(s))); + // --- credential inputs ----------------------------------------- + let url_input = + widget::text_input(fl!("sync-server-url-hint"), &self.config.sync_server_url) + .on_input(|s| Message::Application(ApplicationAction::SetSyncServerUrl(s))); let user_input = widget::text_input(fl!("sync-username-hint"), &self.config.sync_username) .on_input(|s| Message::Application(ApplicationAction::SetSyncUsername(s))); - let pass_input = widget::secure_input( - fl!("sync-password-hint"), - &self.sync_password, - None, - true, - ) - .on_input(|s| Message::Application(ApplicationAction::SetSyncPassword(s))); - - let test_button = widget::button::standard(fl!("sync-test-connection")) - .on_press_maybe((!self.sync_in_progress).then_some( - Message::Application(ApplicationAction::TestSyncConnection), - )); - let sync_button = widget::button::suggested(fl!("sync-now")) - .on_press_maybe((!self.sync_in_progress).then_some( - Message::Application(ApplicationAction::SyncNow), - )); - - let buttons = widget::row::with_children(vec![ + let pass_input = + widget::secure_input(fl!("sync-password-hint"), &self.sync_password, None, true) + .on_input(|s| Message::Application(ApplicationAction::SetSyncPassword(s))); + + let url_field = widget::column::with_children(vec![ + widget::text::body(fl!("sync-server-url")).into(), + url_input.into(), + widget::text::caption(fl!("sync-server-url-description")).into(), + ]) + .spacing(spacing.space_xxxs); + let user_field = widget::column::with_children(vec![ + widget::text::body(fl!("sync-username")).into(), + user_input.into(), + widget::text::caption(fl!("sync-username-description")).into(), + ]) + .spacing(spacing.space_xxxs); + let pass_field = widget::column::with_children(vec![ + widget::text::body(fl!("sync-password")).into(), + pass_input.into(), + widget::text::caption(fl!("sync-password-description")).into(), + ]) + .spacing(spacing.space_xxxs); + + // --- buttons --------------------------------------------------- + let test_button = widget::button::standard(fl!("sync-test-connection")).on_press_maybe( + (!self.sync_in_progress && configured) + .then_some(Message::Application(ApplicationAction::TestSyncConnection)), + ); + let sync_button = widget::button::suggested(fl!("sync-now")).on_press_maybe( + (!self.sync_in_progress && configured) + .then_some(Message::Application(ApplicationAction::SyncNow)), + ); + let mut button_children: Vec> = vec![ test_button.into(), - widget::horizontal_space().width(cosmic::iced::Length::Fixed(8.0)).into(), + widget::horizontal_space() + .width(cosmic::iced::Length::Fixed(8.0)) + .into(), sync_button.into(), - ]); + ]; + if configured { + button_children.push(widget::horizontal_space().width(Length::Fill).into()); + button_children.push( + widget::button::destructive(fl!("sync-sign-out")) + .on_press(Message::Application(ApplicationAction::SignOut)) + .into(), + ); + } + let buttons = + widget::row::with_children(button_children).align_y(cosmic::iced::Alignment::Center); - let mut sync_section = widget::settings::section() - .title(fl!("sync")) - .add(widget::settings::item::item(fl!("sync-server-url"), url_input)) - .add(widget::settings::item::item(fl!("sync-username"), user_input)) - .add(widget::settings::item::item(fl!("sync-password"), pass_input)) - .add(widget::settings::item::item("", buttons)); + // --- assemble -------------------------------------------------- + let mut account_section = widget::settings::section() + .title(fl!("account")) + .add( + widget::column::with_children(vec![ + widget::text::caption(fl!("account-description")).into(), + status_row.into(), + ]) + .spacing(spacing.space_xs) + .padding([ + spacing.space_xs, + spacing.space_none, + spacing.space_s, + spacing.space_none, + ]), + ) + .add(url_field) + .add(user_field) + .add(pass_field) + .add(last_sync_row) + .add(buttons); if !self.sync_status.is_empty() { - sync_section = sync_section.add(widget::settings::item::item( - "", - widget::text(self.sync_status.clone()), - )); + account_section = account_section.add(widget::text::caption(self.sync_status.clone())); } widget::scrollable( - widget::column::with_children(vec![appearance.into(), sync_section.into()]) - .spacing(16), + widget::column::with_children(vec![appearance.into(), account_section.into()]) + .spacing(spacing.space_m), ) .into() } @@ -222,10 +311,7 @@ impl Tasks { } } - fn maybe_trigger_sync( - &mut self, - tasks: &mut Vec>>, - ) { + fn maybe_trigger_sync(&mut self, tasks: &mut Vec>>) { if self.sync_in_progress { return; } @@ -283,22 +369,19 @@ impl Tasks { } } DialogPage::Rename(entity, name) => { - let data = if let Some(entity) = entity { - self.nav_model.data_mut::(entity) - } else { - self.nav_model.active_data_mut::() - }; - if let Some(list) = data { - list.name.clone_from(&name.clone()); + let target = entity.unwrap_or_else(|| self.nav_model.active()); + if let Some(list) = self.nav_model.data_mut::(target) { + list.name.clone_from(&name); let list = list.clone(); - self.nav_model - .text_set(self.nav_model.active(), name.clone()); + self.nav_model.text_set(target, name.clone()); if let Err(err) = self.storage.update_list(&list) { tracing::error!("Error updating list: {err}"); } - tasks.push(self.update(Message::Content( - content::Message::SetList(Some(list)), - ))); + if target == self.nav_model.active() { + tasks.push(self.update(Message::Content( + content::Message::SetList(Some(list)), + ))); + } } } DialogPage::Delete(entity) => { @@ -306,31 +389,30 @@ impl Tasks { .push(self.update(Message::Tasks(TasksAction::DeleteList(entity)))); } DialogPage::Icon(entity, name, _) => { - let data = if let Some(entity) = entity { - self.nav_model.data::(entity) - } else { - self.nav_model.active_data::() - }; - if let Some(list) = data { - let entity = self.nav_model.active(); - self.nav_model.text_set(entity, list.name.clone()); - self.nav_model - .icon_set(entity, crate::app::icons::get_icon(&name, 16)); - } - if let Some(list) = self.nav_model.active_data_mut::() { - list.icon = Some(name); + let target = entity.unwrap_or_else(|| self.nav_model.active()); + if let Some(list) = self.nav_model.data_mut::(target) { + list.icon = Some(name.clone()); let list = list.clone(); + self.nav_model + .icon_set(target, crate::app::icons::get_icon(&name, 16)); if let Err(err) = self.storage.update_list(&list) { tracing::error!("Error updating list: {err}"); } - tasks.push(self.update(Message::Content( - content::Message::SetList(Some(list)), - ))); + if target == self.nav_model.active() { + tasks.push(self.update(Message::Content( + content::Message::SetList(Some(list)), + ))); + } } } DialogPage::Calendar(date) => { - self.details - .update(details::Message::SetDueDate(date.selected)); + // Route through update_details so the resulting + // RefreshTask/Mutated outputs are dispatched — + // calling self.details.update directly would drop + // them and leave the task list stale on screen. + tasks.push(self.update(Message::Details( + details::Message::SetDueDate(date.selected), + ))); } DialogPage::Export(content) => { let Ok(mut clipboard) = ClipboardContext::new() else { @@ -437,6 +519,9 @@ impl Tasks { DialogAction::Open(DialogPage::Delete(Some(entity))), )))); } + NavMenuAction::SyncNow => { + tasks.push(self.update(Message::Application(ApplicationAction::SyncNow))); + } }, ApplicationAction::ToggleContextPage(context_page) => { if self.context_page == context_page { @@ -481,6 +566,16 @@ impl Tasks { content::SortType::DateDesc, )))); } + ApplicationAction::SortByDueAsc => { + tasks.push(self.update(Message::Content(content::Message::SetSort( + content::SortType::DueAsc, + )))); + } + ApplicationAction::SortByDueDesc => { + tasks.push(self.update(Message::Content(content::Message::SetSort( + content::SortType::DueDesc, + )))); + } ApplicationAction::SetSyncServerUrl(value) => { if let Some(handler) = &self.config_handler { if let Err(err) = self.config.set_sync_server_url(handler, value) { @@ -495,17 +590,21 @@ impl Tasks { tracing::error!("{err}"); } } - // Move existing password under the new username so the keyring - // entry stays addressable by the current user. - if old != value && !self.sync_password.is_empty() { - if !value.is_empty() { - if let Err(e) = - crate::sync::secret::store(&value, &self.sync_password) - { + if old != value { + // Prefer an existing keyring entry for the new username + // (e.g. user is switching back to a previously configured + // account); otherwise migrate the in-memory password + // under the new key. + if let Some(stored) = crate::sync::secret::load(&value) { + self.sync_password = stored; + } else if !self.sync_password.is_empty() && !value.is_empty() { + if let Err(e) = crate::sync::secret::store(&value, &self.sync_password) { tracing::warn!("keyring store under new username: {e}"); } + } else if value.is_empty() { + self.sync_password.clear(); } - if !old.is_empty() { + if !old.is_empty() && old != value { crate::sync::secret::delete(&old); } } @@ -518,9 +617,7 @@ impl Tasks { // the username is set. } else if self.sync_password.is_empty() { crate::sync::secret::delete(&username); - } else if let Err(e) = - crate::sync::secret::store(&username, &self.sync_password) - { + } else if let Err(e) = crate::sync::secret::store(&username, &self.sync_password) { tracing::warn!("keyring store: {e}"); } } @@ -560,9 +657,9 @@ impl Tasks { .map_err(|e| e.to_string()) }, |result| { - cosmic::Action::App(Message::Application( - ApplicationAction::SyncResult(result), - )) + cosmic::Action::App(Message::Application(ApplicationAction::SyncResult( + result, + ))) }, )); } @@ -573,6 +670,8 @@ impl Tasks { self.sync_in_progress = false; match result { Ok(report) => { + self.sync_last_at = Some(chrono::Utc::now()); + self.sync_last_error = None; self.sync_status = fl!( "sync-done", lists = report.lists_pulled, @@ -581,12 +680,35 @@ impl Tasks { failed = report.tasks_failed ); tasks.push(self.update(Message::Tasks(TasksAction::FetchLists))); + // FetchLists won't reload the active list's tasks if the + // selected list id is unchanged; force a re-read so newly + // pulled VTODOs become visible without a manual reselect. + tasks.push(self.update(Message::Content(content::Message::ReloadTasks))); } Err(e) => { + self.sync_last_error = Some(e.clone()); self.sync_status = fl!("sync-fail", error = e); } } } + ApplicationAction::SignOut => { + let username = self.config.sync_username.clone(); + if !username.is_empty() { + crate::sync::secret::delete(&username); + } + self.sync_password.clear(); + if let Some(handler) = &self.config_handler { + if let Err(err) = self.config.set_sync_server_url(handler, String::new()) { + tracing::error!("{err}"); + } + if let Err(err) = self.config.set_sync_username(handler, String::new()) { + tracing::error!("{err}"); + } + } + self.sync_status.clear(); + self.sync_last_at = None; + self.sync_last_error = None; + } } } @@ -605,18 +727,18 @@ impl Tasks { } }, TasksAction::PopulateLists(lists) => { - let previously_active = self - .nav_model - .active_data::() - .map(|l| l.id.clone()); + let previously_active = self.nav_model.active_data::().map(|l| l.id.clone()); self.nav_model.clear(); for list in lists { self.create_nav_item(&list); } let restore = previously_active.and_then(|id| { - self.nav_model - .iter() - .find(|e| self.nav_model.data::(*e).map(|l| l.id == id).unwrap_or(false)) + self.nav_model.iter().find(|e| { + self.nav_model + .data::(*e) + .map(|l| l.id == id) + .unwrap_or(false) + }) }); let target = restore.or_else(|| self.nav_model.iter().next()); let Some(entity) = target else { @@ -673,7 +795,7 @@ impl Application for Tasks { let about = widget::about::About::default() .name(fl!("tasks")) .icon(widget::icon::from_name(Self::APP_ID)) - .version("0.2.0") + .version(env!("CARGO_PKG_VERSION")) .author("Eduardo Flores") .license("GPL-3.0-only") .links([ @@ -704,26 +826,14 @@ impl Application for Tasks { sync_status: String::new(), sync_in_progress: false, sync_password: String::new(), + sync_last_at: None, + sync_last_error: None, }; - // Load password from libsecret. If config still holds a legacy plaintext - // password, migrate it into the keyring and clear the config field. + // Load CalDAV password from the system keyring (Secret Service / cosmic-keyring). let username = app.config.sync_username.clone(); if let Some(pw) = crate::sync::secret::load(&username) { app.sync_password = pw; - } else if !app.config.sync_password.is_empty() { - let legacy = std::mem::take(&mut app.config.sync_password); - if let Err(e) = crate::sync::secret::store(&username, &legacy) { - tracing::warn!("Failed to migrate password to keyring: {e}"); - app.sync_password = legacy; - } else { - app.sync_password = legacy; - if let Some(handler) = &app.config_handler { - if let Err(e) = app.config.set_sync_password(handler, String::new()) { - tracing::warn!("Failed to clear legacy password from config: {e}"); - } - } - } } let mut tasks = vec![app.update(Message::Tasks(TasksAction::FetchLists))]; @@ -772,6 +882,30 @@ impl Application for Tasks { vec![menu::menu_bar(&self.key_binds, &self.config)] } + fn header_end(&self) -> Vec> { + let creds = self.sync_credentials(); + if !crate::sync::engine::is_configured(&creds) { + return vec![]; + } + let icon = if self.sync_in_progress { + "process-working-symbolic" + } else { + "emblem-synchronizing-symbolic" + }; + let mut button = widget::button::icon(icons::get_handle(icon, 18)); + if !self.sync_in_progress { + button = button.on_press(Message::Application(ApplicationAction::SyncNow)); + } + vec![ + widget::tooltip( + button, + widget::text(fl!("sync-now")), + widget::tooltip::Position::Bottom, + ) + .into(), + ] + } + fn nav_context_menu( &self, id: widget::nav_bar::Id, @@ -794,6 +928,11 @@ impl Application for Tasks { Some(icons::get_handle("share-symbolic", 18)), NavMenuAction::Export(id), ), + cosmic::widget::menu::Item::Button( + fl!("sync-now"), + Some(icons::get_handle("emblem-synchronizing-symbolic", 14)), + NavMenuAction::SyncNow, + ), cosmic::widget::menu::Item::Button( fl!("delete"), Some(icons::get_handle("user-trash-full-symbolic", 14)), @@ -919,3 +1058,22 @@ impl Application for Tasks { self.content.view().map(Message::Content) } } + +/// Render `at` as a coarse human-friendly relative timestamp ("just now", +/// "5 minutes ago"). Used for the last-sync row in settings. +fn format_relative_time(at: Option>) -> String { + let Some(at) = at else { + return fl!("account-last-sync-never"); + }; + let secs = (chrono::Utc::now() - at).num_seconds().max(0); + let n = |s: i64| (s.max(0)) as i32; + if secs < 60 { + fl!("account-last-sync-just-now") + } else if secs < 60 * 60 { + fl!("account-last-sync-minutes", count = n(secs / 60)) + } else if secs < 60 * 60 * 24 { + fl!("account-last-sync-hours", count = n(secs / 3600)) + } else { + fl!("account-last-sync-days", count = n(secs / 86400)) + } +} diff --git a/src/app/actions.rs b/src/app/actions.rs index 81173ae1..830c4739 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1,8 +1,8 @@ use crate::{ app::{ + Message, context::ContextPage, dialog::{DialogAction, DialogPage}, - Message, }, storage::models::List, }; @@ -26,6 +26,9 @@ pub enum Action { SortByNameDesc, SortByDateAsc, SortByDateDesc, + SortByDueAsc, + SortByDueDesc, + SyncNow, } #[derive(Debug, Clone)] @@ -46,6 +49,8 @@ pub enum ApplicationAction { SortByNameDesc, SortByDateAsc, SortByDateDesc, + SortByDueAsc, + SortByDueDesc, SetSyncServerUrl(String), SetSyncUsername(String), SetSyncPassword(String), @@ -54,6 +59,7 @@ pub enum ApplicationAction { SyncNow, SyncTick, SyncResult(Result), + SignOut, } #[derive(Debug, Clone)] @@ -95,6 +101,9 @@ impl MenuAction for Action { Action::SortByNameDesc => Message::Application(ApplicationAction::SortByNameDesc), Action::SortByDateAsc => Message::Application(ApplicationAction::SortByDateAsc), Action::SortByDateDesc => Message::Application(ApplicationAction::SortByDateDesc), + Action::SortByDueAsc => Message::Application(ApplicationAction::SortByDueAsc), + Action::SortByDueDesc => Message::Application(ApplicationAction::SortByDueDesc), + Action::SyncNow => Message::Application(ApplicationAction::SyncNow), } } } @@ -105,6 +114,7 @@ pub enum NavMenuAction { SetIcon(segmented_button::Entity), Export(segmented_button::Entity), Delete(segmented_button::Entity), + SyncNow, } impl MenuAction for NavMenuAction { diff --git a/src/app/dialog.rs b/src/app/dialog.rs index e7d5bef5..c2aac1f0 100644 --- a/src/app/dialog.rs +++ b/src/app/dialog.rs @@ -1,12 +1,12 @@ use cosmic::{ iced::{ - alignment::{Horizontal, Vertical}, Length, + alignment::{Horizontal, Vertical}, }, widget::{self, calendar::CalendarModel, segmented_button}, }; -use crate::{app::actions::ApplicationAction, app::Message, fl}; +use crate::{app::Message, app::actions::ApplicationAction, fl}; #[derive(Debug, Clone)] pub enum DialogAction { diff --git a/src/app/error.rs b/src/app/error.rs index a9d6ab81..c2de3287 100644 --- a/src/app/error.rs +++ b/src/app/error.rs @@ -8,8 +8,6 @@ pub enum Error { RonSpanned(#[from] ron::error::SpannedError), #[error("Ron deserialization error: {0}")] RonDeserialization(#[from] ron::de::Error), - #[error("Sqlx error: {0}")] - Sqlx(#[from] sqlx::Error), #[error("{0}")] Tasks(#[from] TasksError), #[error("{0}")] diff --git a/src/app/menu.rs b/src/app/menu.rs index a614ba08..1bf454cc 100644 --- a/src/app/menu.rs +++ b/src/app/menu.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; use cosmic::{ - widget::menu::{items, key_bind::KeyBind, root, Item, ItemHeight, ItemWidth, MenuBar, Tree}, Element, + widget::menu::{Item, ItemHeight, ItemWidth, MenuBar, Tree, items, key_bind::KeyBind, root}, }; use crate::{ @@ -81,6 +81,12 @@ pub fn menu_bar<'a>( Action::Settings, ), Item::Divider, + Item::Button( + fl!("sync-now"), + Some(icons::get_handle("emblem-synchronizing-symbolic", 14)), + Action::SyncNow, + ), + Item::Divider, Item::CheckBox( fl!("hide-completed"), None, @@ -103,8 +109,12 @@ pub fn menu_bar<'a>( vec![ Item::Button(fl!("sort-name-asc"), None, Action::SortByNameAsc), Item::Button(fl!("sort-name-desc"), None, Action::SortByNameDesc), + Item::Divider, Item::Button(fl!("sort-date-asc"), None, Action::SortByDateAsc), Item::Button(fl!("sort-date-desc"), None, Action::SortByDateDesc), + Item::Divider, + Item::Button(fl!("sort-due-asc"), None, Action::SortByDueAsc), + Item::Button(fl!("sort-due-desc"), None, Action::SortByDueDesc), ], ), ), diff --git a/src/core/config.rs b/src/core/config.rs index 601c9ae0..fd5ac21f 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -1,6 +1,7 @@ use cosmic::{ - cosmic_config::{self, cosmic_config_derive::CosmicConfigEntry, Config, CosmicConfigEntry}, - theme, Application, + Application, + cosmic_config::{self, Config, CosmicConfigEntry, cosmic_config_derive::CosmicConfigEntry}, + theme, }; use serde::{Deserialize, Serialize}; @@ -14,7 +15,6 @@ pub struct TasksConfig { pub hide_completed: bool, pub sync_server_url: String, pub sync_username: String, - pub sync_password: String, } impl TasksConfig { diff --git a/src/core/localize.rs b/src/core/localize.rs index 7600ffcf..5e444c8a 100644 --- a/src/core/localize.rs +++ b/src/core/localize.rs @@ -3,8 +3,8 @@ use std::sync::LazyLock; use i18n_embed::{ - fluent::{fluent_language_loader, FluentLanguageLoader}, DefaultLocalizer, LanguageLoader, Localizer, + fluent::{FluentLanguageLoader, fluent_language_loader}, }; use rust_embed::RustEmbed; diff --git a/src/core/settings/app.rs b/src/core/settings/app.rs index c61fa929..98287cf8 100644 --- a/src/core/settings/app.rs +++ b/src/core/settings/app.rs @@ -1,7 +1,7 @@ use cosmic::{ + Application, app::Settings, iced::{Limits, Size}, - Application, }; use std::sync::Mutex; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -10,12 +10,12 @@ use crate::{ app::Tasks, core::{ config::TasksConfig, - icons::{IconCache, ICON_CACHE}, + icons::{ICON_CACHE, IconCache}, localize::localize, }, storage::{ - migration::{migrate_data, migrate_data_dir}, LocalStorage, + migration::{migrate_data, migrate_data_dir}, }, }; diff --git a/src/core/settings/error.rs b/src/core/settings/error.rs index 734b416a..9ffcf499 100644 --- a/src/core/settings/error.rs +++ b/src/core/settings/error.rs @@ -1,7 +1,8 @@ use cosmic::{ + Application, ApplicationExt, Core, app::Settings, - iced::{alignment::Horizontal, Color, Limits, Size}, - widget, Application, ApplicationExt, Core, + iced::{Color, Limits, Size, alignment::Horizontal}, + widget, }; use crate::{ diff --git a/src/core/style/segmented_control.rs b/src/core/style/segmented_control.rs index d053cd98..92614b1f 100644 --- a/src/core/style/segmented_control.rs +++ b/src/core/style/segmented_control.rs @@ -4,7 +4,7 @@ //! Contains stylesheet implementation for [`crate::widget::segmented_button`]. use cosmic::iced::Border; -use cosmic::iced::{border::Radius, Background}; +use cosmic::iced::{Background, border::Radius}; use cosmic::widget::segmented_button::ItemStatusAppearance; use cosmic::widget::segmented_button::{Appearance, ItemAppearance}; @@ -59,7 +59,7 @@ fn horizontal(theme: &cosmic::Theme) -> Appearance { mod horizontal { use cosmic::iced::Border; - use cosmic::iced::{border::Radius, Background}; + use cosmic::iced::{Background, border::Radius}; use cosmic::widget::segmented_button::{ItemAppearance, ItemStatusAppearance}; pub fn selection_active(theme: &cosmic::Theme) -> ItemStatusAppearance { diff --git a/src/pages/content.rs b/src/pages/content.rs index 35e21f25..67f7237a 100644 --- a/src/pages/content.rs +++ b/src/pages/content.rs @@ -1,22 +1,22 @@ use std::collections::HashMap; use cosmic::{ + Apply, Element, iced::{ - alignment::{Horizontal, Vertical}, Alignment, Length, Subscription, + alignment::{Horizontal, Vertical}, }, iced_widget::row, theme, widget::{self, menu::Action as MenuAction}, - Apply, Element, }; use slotmap::{DefaultKey, SecondaryMap, SlotMap}; use crate::{ core::{config, icons}, fl, - storage::models::{self, List, Status}, storage::LocalStorage, + storage::models::{self, List, Status}, }; pub struct Content { @@ -42,6 +42,8 @@ pub enum SortType { NameDesc, DateAsc, DateDesc, + DueAsc, + DueDesc, } #[derive(Debug, Clone)] @@ -73,6 +75,9 @@ pub enum Message { SetTasks(Vec), SetConfig(config::TasksConfig), RefreshTask(models::Task), + /// Re-read tasks for the currently active list from disk. Used after + /// background work (e.g. a CalDAV sync) mutates the on-disk state. + ReloadTasks, Empty, ContextMenuOpen(bool), @@ -197,6 +202,8 @@ impl Content { } let mut tasks_vec: Vec<_> = self.tasks.iter().collect(); + // Primary sort by user choice; completed tasks always sink to the + // bottom regardless so the active list stays focused at the top. match self.sort_type { SortType::NameAsc => { tasks_vec.sort_by(|a, b| a.1.title.to_lowercase().cmp(&b.1.title.to_lowercase())) @@ -210,7 +217,23 @@ impl Content { SortType::DateDesc => { tasks_vec.sort_by(|a, b| b.1.created_date_time.cmp(&a.1.created_date_time)) } + SortType::DueAsc => tasks_vec.sort_by(|a, b| { + // Tasks without a due date sink below those with one. + match (a.1.due_date, b.1.due_date) { + (Some(x), Some(y)) => x.cmp(&y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a.1.created_date_time.cmp(&b.1.created_date_time), + } + }), + SortType::DueDesc => tasks_vec.sort_by(|a, b| match (a.1.due_date, b.1.due_date) { + (Some(x), Some(y)) => y.cmp(&x), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => b.1.created_date_time.cmp(&a.1.created_date_time), + }), } + tasks_vec.sort_by_key(|(_, t)| t.status == Status::Completed); let filtered_tasks: Vec<_> = tasks_vec .into_iter() @@ -300,6 +323,11 @@ impl Content { None }; + let task_input_id = self + .task_input_ids + .get(id) + .cloned() + .unwrap_or_else(widget::Id::unique); let task_item_text = widget::editable_input( "", &task.title, @@ -308,16 +336,24 @@ impl Content { ) .size(13) .trailing_icon(widget::column().into()) - .id(self.task_input_ids[id].clone()) + .id(task_input_id) .on_submit(move |_| Message::TaskTitleSubmit(id)) .on_input(move |text| Message::TaskTitleUpdate(id, text)); - let row = widget::row::with_capacity(5) + let due_badge = task.due_date.map(|due| { + let label = format_due_badge(due); + widget::text(label) + .size(11) + .class(cosmic::style::Text::Accent) + }); + + let row = widget::row::with_capacity(6) .align_y(Alignment::Center) .spacing(spacing.space_xxxs) .padding([spacing.space_xxs, spacing.space_s]) .push(item_checkbox) .push(task_item_text) + .push_maybe(due_badge) .push_maybe(expand_button) .push_maybe(subtask_count) .push(more_button); @@ -409,6 +445,11 @@ impl Content { None }; + let sub_task_input_id = self + .sub_task_input_ids + .get(id) + .cloned() + .unwrap_or_else(widget::Id::unique); let task_item_text = widget::editable_input( "", &task.title, @@ -417,16 +458,23 @@ impl Content { ) .size(13) .trailing_icon(widget::column().into()) - .id(self.sub_task_input_ids[id].clone()) + .id(sub_task_input_id) .on_submit(move |_| Message::SubTaskTitleSubmit(id)) .on_input(move |text| Message::SubTaskTitleUpdate(id, text)); - let row = widget::row::with_capacity(4) + let due_badge = task.due_date.map(|due| { + widget::text(format_due_badge(due)) + .size(11) + .class(cosmic::style::Text::Accent) + }); + + let row = widget::row::with_capacity(5) .align_y(Alignment::Center) .spacing(spacing.space_xxxs) .padding([spacing.space_xxs, spacing.space_s]) .push(item_checkbox) .push(task_item_text) + .push_maybe(due_badge) .push_maybe(expand_button) .push_maybe(subtask_count) .push(more_button); @@ -577,6 +625,18 @@ impl Content { Message::SetConfig(config) => { self.config = config; } + Message::ReloadTasks => { + if let Some(list) = self.list.clone() { + match self.storage.tasks(&list) { + Ok(reloaded) => { + self.update(Message::SetTasks(reloaded)); + } + Err(error) => { + tracing::error!("Failed to reload tasks: {error:?}") + } + } + } + } Message::RefreshTask(refreshed_task) => { if let Some((id, _)) = self.tasks.iter().find(|(_, t)| t.id == refreshed_task.id) { if let Some(task) = self.tasks.get_mut(id) { @@ -626,9 +686,17 @@ impl Content { } } Message::TaskToggleTitleEditMode(id, editing) => { + if !self.tasks.contains_key(id) { + // Stale key — slotmap was repopulated (e.g. by a post-sync + // ReloadTasks) before this message reached us. Drop it + // rather than panicking on a SecondaryMap index. + return tasks; + } self.task_editing.insert(id, editing); if editing { - tasks.push(Output::Focus(self.task_input_ids[id].clone())); + if let Some(input_id) = self.task_input_ids.get(id) { + tasks.push(Output::Focus(input_id.clone())); + } } else if let Some(task) = self.tasks.get(id) { match self.storage.update_task(task) { Ok(_) => tasks.push(Output::Mutated), @@ -691,7 +759,9 @@ impl Content { self.sub_task_input_ids .insert(sub_task_id, widget::Id::unique()); self.sub_task_editing.insert(sub_task_id, false); - tasks.push(Output::Focus(self.sub_task_input_ids[sub_task_id].clone())); + if let Some(input_id) = self.sub_task_input_ids.get(sub_task_id) { + tasks.push(Output::Focus(input_id.clone())); + } tasks.push(Output::Mutated); } Err(error) => { @@ -707,9 +777,14 @@ impl Content { } } Message::SubTaskToggleTitleEditMode(id, editing) => { + if !self.sub_tasks.contains_key(id) { + return tasks; + } self.sub_task_editing.insert(id, editing); if editing { - tasks.push(Output::Focus(self.sub_task_input_ids[id].clone())); + if let Some(input_id) = self.sub_task_input_ids.get(id) { + tasks.push(Output::Focus(input_id.clone())); + } } else if let Some(task) = self.sub_tasks.get(id) { match self.storage.update_task(task) { Ok(_) => tasks.push(Output::Mutated), @@ -760,7 +835,9 @@ impl Content { self.sub_task_input_ids .insert(sub_task_id, widget::Id::unique()); self.sub_task_editing.insert(sub_task_id, false); - tasks.push(Output::Focus(self.sub_task_input_ids[sub_task_id].clone())); + if let Some(input_id) = self.sub_task_input_ids.get(sub_task_id) { + tasks.push(Output::Focus(input_id.clone())); + } tasks.push(Output::Mutated); } Err(error) => { @@ -848,3 +925,19 @@ impl Content { Subscription::none() } } + +/// Compact human-friendly badge for a due date — "Today", "Tomorrow", +/// "Yesterday", a weekday for nearby dates, and `YYYY-MM-DD` otherwise. +fn format_due_badge(due: chrono::DateTime) -> String { + use chrono::Local; + let today = Local::now().date_naive(); + let due_local = due.with_timezone(&Local).date_naive(); + let days = (due_local - today).num_days(); + match days { + 0 => fl!("due-today"), + 1 => fl!("due-tomorrow"), + -1 => fl!("due-yesterday"), + 2..=6 => due_local.format("%a").to_string(), + _ => due_local.format("%Y-%m-%d").to_string(), + } +} diff --git a/src/pages/details.rs b/src/pages/details.rs index a9436388..58d92b26 100644 --- a/src/pages/details.rs +++ b/src/pages/details.rs @@ -1,5 +1,6 @@ -use chrono::{NaiveDate, TimeZone, Utc}; +use chrono::{Local, NaiveDate, TimeZone, Utc}; use cosmic::{ + Element, iced::{Alignment, Length}, theme, widget::{ @@ -7,15 +8,14 @@ use cosmic::{ segmented_button::{self, Entity}, text_editor, }, - Element, }; use crate::{ core::icons, fl, storage::{ - models::{self, Priority}, LocalStorage, + models::{self, Priority}, }, }; @@ -98,8 +98,15 @@ impl Details { return tasks; } Message::SetDueDate(date) => { - let tz = Utc::now().timezone(); - self.task.due_date = Some(tz.from_utc_datetime(&date.into())); + // Store as local-midnight rather than UTC-midnight so the + // date the user picked stays the same date when rendered or + // exported to CalDAV — UTC-midnight gets shifted into the + // adjacent day for any non-zero local offset. + let naive = date.and_hms_opt(0, 0, 0).unwrap_or_default(); + self.task.due_date = Local + .from_local_datetime(&naive) + .single() + .map(|dt| dt.with_timezone(&Utc)); } } @@ -116,74 +123,71 @@ impl Details { pub fn view(&self) -> Element<'_, Message> { let spacing = theme::active().cosmic().spacing; - widget::settings::view_column(vec![widget::settings::section() - .title(fl!("details")) - .add( - widget::column::with_children(vec![ - widget::text::body(fl!("title")).into(), - widget::text_input(fl!("title"), &self.task.title) - .style(crate::core::style::text_input()) - .on_input(Message::SetTitle) - .size(13) - .into(), - ]) - .padding([ - spacing.space_s, - spacing.space_none, - spacing.space_s, - spacing.space_none, - ]) - .spacing(spacing.space_xxs), - ) - .add( - widget::settings::item::builder(fl!("favorite")) - .control(widget::checkbox("", self.task.favorite).on_toggle(Message::Favorite)), - ) - .add( - widget::settings::item::builder(fl!("priority")).control( - widget::segmented_control::horizontal(&self.priority_model) - .button_alignment(Alignment::Center) - .width(Length::Shrink) - .style(crate::core::style::segmented_control()) - .on_activate(Message::PriorityActivate), - ), - ) - .add( - widget::settings::item::builder(fl!("due-date")).control( - widget::button::text(if self.task.due_date.is_some() { - self.task - .due_date - .as_ref() - .unwrap() - .format("%m-%d-%Y") - .to_string() - } else { - fl!("select-date") - }) - .on_press(Message::OpenCalendarDialog), - ), - ) - .add( - widget::column::with_children(vec![ - widget::text::body(fl!("notes")).into(), - widget::text_editor(&self.text_editor_content) - .class(crate::core::style::text_editor()) - .padding(spacing.space_xxs) - .placeholder(fl!("add-notes")) - .height(100.0) - .size(13) - .on_action(Message::Editor) - .into(), - ]) - .spacing(spacing.space_xxs) - .padding([ - spacing.space_s, - spacing.space_none, - spacing.space_s, - spacing.space_none, - ]), - ) - .into()]) + widget::settings::view_column(vec![ + widget::settings::section() + .title(fl!("details")) + .add( + widget::column::with_children(vec![ + widget::text::body(fl!("title")).into(), + widget::text_input(fl!("title"), &self.task.title) + .style(crate::core::style::text_input()) + .on_input(Message::SetTitle) + .size(13) + .into(), + ]) + .padding([ + spacing.space_s, + spacing.space_none, + spacing.space_s, + spacing.space_none, + ]) + .spacing(spacing.space_xxs), + ) + .add( + widget::settings::item::builder(fl!("favorite")).control( + widget::checkbox("", self.task.favorite).on_toggle(Message::Favorite), + ), + ) + .add( + widget::settings::item::builder(fl!("priority")).control( + widget::segmented_control::horizontal(&self.priority_model) + .button_alignment(Alignment::Center) + .width(Length::Shrink) + .style(crate::core::style::segmented_control()) + .on_activate(Message::PriorityActivate), + ), + ) + .add( + widget::settings::item::builder(fl!("due-date")).control( + widget::button::text(match self.task.due_date { + Some(due) => due.with_timezone(&Local).format("%Y-%m-%d").to_string(), + None => fl!("select-date"), + }) + .on_press(Message::OpenCalendarDialog), + ), + ) + .add( + widget::column::with_children(vec![ + widget::text::body(fl!("notes")).into(), + widget::text_editor(&self.text_editor_content) + .class(crate::core::style::text_editor()) + .padding(spacing.space_xxs) + .placeholder(fl!("add-notes")) + .height(100.0) + .size(13) + .on_action(Message::Editor) + .into(), + ]) + .spacing(spacing.space_xxs) + .padding([ + spacing.space_s, + spacing.space_none, + spacing.space_s, + spacing.space_none, + ]), + ) + .into(), + ]) .padding([ spacing.space_none, spacing.space_s, diff --git a/src/storage/migration.rs b/src/storage/migration.rs index 60a4e91a..6cb6e69e 100644 --- a/src/storage/migration.rs +++ b/src/storage/migration.rs @@ -1,6 +1,6 @@ +use crate::Error; use crate::app::Tasks; use crate::storage::models::{List, Task}; -use crate::Error; use cosmic::Application; use ron::de::from_str; use ron::ser::to_string; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index e9df3f76..b601d210 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -4,9 +4,9 @@ pub mod models; use std::path::PathBuf; use crate::{ + Error, LocalStorageError, TasksError, app::markdown::Markdown, storage::models::{List, Task}, - Error, LocalStorageError, TasksError, }; #[derive(Debug, Clone)] diff --git a/src/storage/models/list.rs b/src/storage/models/list.rs index 512e5438..9f3a674d 100644 --- a/src/storage/models/list.rs +++ b/src/storage/models/list.rs @@ -16,10 +16,11 @@ pub struct List { pub icon: Option, #[serde(default)] pub hide_completed: bool, + /// CalDAV resource URL bound to this list, if any. + #[serde(default)] + pub remote_url: Option, } -unsafe impl Send for List {} - impl FromIterator for List { fn from_iter>(iter: T) -> Self { let mut list = Self::default(); @@ -46,6 +47,7 @@ impl List { description: String::new(), icon: Some("view-list-symbolic".to_string()), hide_completed: false, + remote_url: None, } } diff --git a/src/sync/caldav.rs b/src/sync/caldav.rs index ca5645b4..7626008e 100644 --- a/src/sync/caldav.rs +++ b/src/sync/caldav.rs @@ -1,9 +1,9 @@ use base64::Engine as _; -use chrono::{DateTime, Utc}; -use icalendar::{Calendar as ICalendar, Component, Todo}; -use quick_xml::events::Event; +use chrono::{DateTime, NaiveDate, Utc}; +use icalendar::{Calendar as ICalendar, CalendarDateTime, Component, DatePerhapsTime, Todo}; use quick_xml::Reader; -use reqwest::header::{HeaderMap, HeaderName, HeaderValue, CONTENT_TYPE}; +use quick_xml::events::Event; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; use reqwest::{Client, Method}; use thiserror::Error; use url::Url; @@ -55,10 +55,10 @@ pub struct RemoteTodo { impl CalDavClient { pub fn new(base_url: &str, username: &str, password: &str) -> Result { let base_url = Url::parse(base_url)?; - let token = base64::engine::general_purpose::STANDARD - .encode(format!("{username}:{password}")); - let auth_header = HeaderValue::from_str(&format!("Basic {token}")) - .map_err(|_| CalDavError::Header)?; + let token = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + let auth_header = + HeaderValue::from_str(&format!("Basic {token}")).map_err(|_| CalDavError::Header)?; let http = Client::builder() .user_agent("cosmic-tasks-caldav/0.1") .build()?; @@ -143,8 +143,8 @@ impl CalDavClient { if !(status.is_success() || status.as_u16() == 207) { return Err(CalDavError::Status(status.as_u16())); } - let href = first_inner_href(&text, "current-user-principal") - .ok_or(CalDavError::NoPrincipal)?; + let href = + first_inner_href(&text, "current-user-principal").ok_or(CalDavError::NoPrincipal)?; Ok(self.base_url.join(&href)?) } @@ -162,8 +162,8 @@ impl CalDavClient { if !(status.is_success() || status.as_u16() == 207) { return Err(CalDavError::Status(status.as_u16())); } - let href = first_inner_href(&text, "calendar-home-set") - .ok_or(CalDavError::NoCalendarHome)?; + let href = + first_inner_href(&text, "calendar-home-set").ok_or(CalDavError::NoCalendarHome)?; Ok(self.base_url.join(&href)?) } @@ -197,14 +197,24 @@ impl CalDavClient { if !r.supports_vtodo { continue; } - let url = self.base_url.join(&r.href)?; + let mut url = self.base_url.join(&r.href)?; // Skip the home itself if it appeared in the listing. if url == home { continue; } + // CalDAV collections are always directories; ensure the trailing + // slash so `Url::join("uid.ics")` appends rather than replacing + // the last segment. + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } let display_name = r.display_name.unwrap_or_else(|| { url.path_segments() - .and_then(|s| s.filter(|x| !x.is_empty()).last().map(|x| x.to_string())) + .and_then(|s| { + s.filter(|x| !x.is_empty()) + .next_back() + .map(|x| x.to_string()) + }) .unwrap_or_else(|| "Calendar".to_string()) }); out.push(RemoteCalendar { url, display_name }); @@ -367,14 +377,6 @@ fn parse_multistatus(xml: &str) -> Result> { b"displayname" => text_target = Some("displayname"), b"getetag" => text_target = Some("etag"), b"calendar-data" => text_target = Some("caldata"), - b"collection" => { - // Inside ; combined with sibling means a calendar collection. - if let Some(c) = current.as_mut() { - // mark provisional; finalized when we also see - c.is_collection_calendar |= stack.iter().any(|n| n == b"resourcetype") - && false; // placeholder - } - } b"calendar" => { if let Some(c) = current.as_mut() { if stack.iter().any(|n| n == b"resourcetype") { @@ -490,9 +492,7 @@ fn first_inner_href(xml: &str, parent_local: &str) -> Option { // --- VTODO <-> Task mapping ------------------------------------------------- pub fn parse_vtodo(ical: &str) -> std::result::Result { - let cal: ICalendar = ical - .parse() - .map_err(|e: String| CalDavError::ICal(e))?; + let cal: ICalendar = ical.parse().map_err(|e: String| CalDavError::ICal(e))?; cal.components .into_iter() .find_map(|c| match c { @@ -524,20 +524,28 @@ pub fn vtodo_to_task(todo: &Todo, list_path: std::path::PathBuf) -> Task { None => Priority::Low, }; + // DUE accepts every variant the icalendar crate understands: DATE, + // DATE-TIME UTC (`...Z`), floating DATE-TIME, and DATE-TIME with a TZID + // parameter. Falls back to a textual parse for the loose forms some + // servers emit (e.g. ISO-8601 with separators). let due_date = todo - .property_value("DUE") - .and_then(parse_ical_datetime); - let completion_date = todo - .property_value("COMPLETED") - .and_then(parse_ical_datetime); + .get_due() + .map(date_perhaps_time_to_utc) + .or_else(|| todo.property_value("DUE").and_then(parse_ical_datetime)); + let completion_date = todo.get_completed().or_else(|| { + todo.property_value("COMPLETED") + .and_then(parse_ical_datetime) + }); let created = todo .property_value("CREATED") .and_then(parse_ical_datetime) + .or_else(|| todo.property_value("DTSTAMP").and_then(parse_ical_datetime)) .unwrap_or(now); let last_modified = todo .property_value("LAST-MODIFIED") .and_then(parse_ical_datetime) - .unwrap_or(now); + .or_else(|| todo.property_value("DTSTAMP").and_then(parse_ical_datetime)) + .unwrap_or(created); let tags = todo .property_value("CATEGORIES") .map(|s| { @@ -591,38 +599,95 @@ pub fn task_to_vtodo(task: &Task) -> String { }; todo.add_property("PRIORITY", prio); if let Some(due) = task.due_date { - todo.add_property("DUE", &format_ical_datetime(due)); + // The UI only picks dates (no time-of-day), and SetDueDate stores a + // local-midnight value. Detect that and emit VALUE=DATE so other + // CalDAV clients show the same calendar day regardless of timezone. + if is_local_date_only(due) { + todo.due(due.with_timezone(&chrono::Local).date_naive()); + } else { + todo.due(due); + } } if let Some(c) = task.completion_date { - todo.add_property("COMPLETED", &format_ical_datetime(c)); + todo.completed(c); } if !task.tags.is_empty() { - todo.add_property("CATEGORIES", &task.tags.join(",")); + todo.add_property("CATEGORIES", task.tags.join(",")); } - todo.add_property("CREATED", &format_ical_datetime(task.created_date_time)); + todo.add_property("CREATED", format_ical_datetime(task.created_date_time)); todo.add_property( "LAST-MODIFIED", - &format_ical_datetime(task.last_modified_date_time), + format_ical_datetime(task.last_modified_date_time), ); + todo.add_property("DTSTAMP", format_ical_datetime(Utc::now())); let mut cal = ICalendar::new(); cal.push(todo.done()); cal.to_string() } +/// True if `dt`, viewed in the user's local timezone, falls exactly on +/// midnight — the encoding the date picker emits for an all-day due date. +fn is_local_date_only(dt: DateTime) -> bool { + use chrono::Timelike; + let local = dt.with_timezone(&chrono::Local); + local.hour() == 0 && local.minute() == 0 && local.second() == 0 && local.nanosecond() == 0 +} + +/// Reduce any iCalendar date/date-time variant into a UTC instant suitable +/// for the local Task model. Floating and TZID-bearing times are taken at +/// face value (chrono-tz is not enabled, so TZID can't be resolved). +fn date_perhaps_time_to_utc(dpt: DatePerhapsTime) -> DateTime { + match dpt { + DatePerhapsTime::Date(d) => date_at_midnight_utc(d), + DatePerhapsTime::DateTime(CalendarDateTime::Utc(dt)) => dt, + DatePerhapsTime::DateTime(CalendarDateTime::Floating(naive)) => { + DateTime::::from_naive_utc_and_offset(naive, Utc) + } + DatePerhapsTime::DateTime(CalendarDateTime::WithTimezone { date_time, .. }) => { + DateTime::::from_naive_utc_and_offset(date_time, Utc) + } + } +} + +fn date_at_midnight_utc(d: NaiveDate) -> DateTime { + DateTime::::from_naive_utc_and_offset(d.and_hms_opt(0, 0, 0).unwrap_or_default(), Utc) +} + +/// Loose textual parser used as a fallback when the icalendar crate refuses +/// the input. Accepts: +/// - `20260101T120000Z` / `20260101T120000` (basic iCal) +/// - `20260101` (DATE) +/// - `2026-01-01T12:00:00Z` / `2026-01-01T12:00:00` (extended ISO-8601) +/// - `2026-01-01` (extended date) +/// - `2026-01-01T12:00:00+02:00` (with offset) fn parse_ical_datetime(s: &str) -> Option> { - // Accept basic iCalendar forms: 20260101T120000Z, 20260101T120000, 20260101. - // chrono::DateTime::parse_from_str rejects a literal 'Z' as a timezone - // specifier, so strip it and parse as NaiveDateTime in UTC. let s = s.trim(); + if s.is_empty() { + return None; + } + + // RFC 3339 / ISO-8601 with offset. + if let Ok(dt) = DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&Utc)); + } + let no_z = s.strip_suffix('Z').unwrap_or(s); - if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(no_z, "%Y%m%dT%H%M%S") { - return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + + // Basic and extended date-time forms. + for fmt in ["%Y%m%dT%H%M%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"] { + if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(no_z, fmt) { + return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + } } - if let Ok(date) = chrono::NaiveDate::parse_from_str(no_z, "%Y%m%d") { - let naive = date.and_hms_opt(0, 0, 0)?; - return Some(DateTime::::from_naive_utc_and_offset(naive, Utc)); + + // Date-only forms. + for fmt in ["%Y%m%d", "%Y-%m-%d"] { + if let Ok(date) = NaiveDate::parse_from_str(no_z, fmt) { + return Some(date_at_midnight_utc(date)); + } } + None } @@ -633,7 +698,10 @@ mod tests { #[test] fn parses_zulu_datetime() { let dt = parse_ical_datetime("20260405T170617Z").expect("should parse"); - assert_eq!(dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), "2026-04-05T17:06:17Z"); + assert_eq!( + dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + "2026-04-05T17:06:17Z" + ); } #[test] @@ -645,6 +713,65 @@ mod tests { fn parses_date_only() { assert!(parse_ical_datetime("20260330").is_some()); } + + #[test] + fn parses_iso8601_extended_date_time() { + let dt = parse_ical_datetime("2026-04-05T17:06:17Z").expect("iso-8601 utc"); + assert_eq!( + dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + "2026-04-05T17:06:17Z" + ); + } + + #[test] + fn parses_iso8601_with_offset() { + let dt = parse_ical_datetime("2026-04-05T19:06:17+02:00").expect("iso-8601 offset"); + assert_eq!( + dt.format("%Y-%m-%dT%H:%M:%SZ").to_string(), + "2026-04-05T17:06:17Z" + ); + } + + #[test] + fn parses_iso8601_extended_date_only() { + assert!(parse_ical_datetime("2026-04-05").is_some()); + } + + #[test] + fn rejects_garbage() { + assert!(parse_ical_datetime("not a date").is_none()); + assert!(parse_ical_datetime("").is_none()); + } + + #[test] + fn date_only_round_trip_emits_value_date() { + use chrono::{Local, TimeZone}; + // Construct a local-midnight value, the same way the date picker does. + let local_midnight = Local + .from_local_datetime( + &NaiveDate::from_ymd_opt(2026, 4, 28) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(), + ) + .single() + .unwrap() + .with_timezone(&Utc); + let task = Task { + id: "abc".into(), + path: std::path::PathBuf::from("/tmp"), + title: "t".into(), + due_date: Some(local_midnight), + created_date_time: Utc::now(), + last_modified_date_time: Utc::now(), + ..Default::default() + }; + let ical = task_to_vtodo(&task); + assert!( + ical.contains("DUE;VALUE=DATE:20260428"), + "expected DUE as VALUE=DATE for local 2026-04-28; got:\n{ical}" + ); + } } fn format_ical_datetime(dt: DateTime) -> String { diff --git a/src/sync/engine.rs b/src/sync/engine.rs index 1d8cdf86..206f9ba9 100644 --- a/src/sync/engine.rs +++ b/src/sync/engine.rs @@ -3,12 +3,14 @@ use std::collections::HashMap; use thiserror::Error; use url::Url; -use crate::storage::models::{List, Task}; use crate::storage::LocalStorage; +use crate::storage::models::{List, Task}; -use super::caldav::{parse_vtodo, task_to_vtodo, vtodo_to_task, CalDavClient, CalDavError}; +use super::caldav::{CalDavClient, CalDavError, parse_vtodo, task_to_vtodo, vtodo_to_task}; -const REMOTE_MARKER: &str = "caldav:"; +/// Legacy marker used in v0.2 to embed the CalDAV URL inside the list +/// description. Kept only for read-side migration into `List::remote_url`. +const LEGACY_REMOTE_MARKER: &str = "caldav:"; #[derive(Debug, Error)] pub enum SyncError { @@ -55,24 +57,31 @@ pub struct SyncReport { } /// Identify the remote URL bound to a local list, if any. +/// +/// Reads `List::remote_url` first; falls back to the legacy `caldav:URL` +/// marker that v0.2 stored in `description`. fn list_remote_url(list: &List) -> Option { + if let Some(raw) = list.remote_url.as_deref() { + if let Ok(url) = Url::parse(raw) { + return Some(url); + } + } let line = list .description .lines() - .find(|l| l.trim().starts_with(REMOTE_MARKER))?; - let raw = line.trim().trim_start_matches(REMOTE_MARKER).trim(); + .find(|l| l.trim().starts_with(LEGACY_REMOTE_MARKER))?; + let raw = line.trim().trim_start_matches(LEGACY_REMOTE_MARKER).trim(); Url::parse(raw).ok() } fn set_list_remote_url(list: &mut List, url: &Url) { - let url_str = url.as_str(); - let mut kept: Vec<&str> = list + list.remote_url = Some(url.as_str().to_string()); + // Strip any legacy marker line from the description. + let kept: Vec<&str> = list .description .lines() - .filter(|l| !l.trim().starts_with(REMOTE_MARKER)) + .filter(|l| !l.trim().starts_with(LEGACY_REMOTE_MARKER)) .collect(); - let line = format!("{REMOTE_MARKER}{url_str}"); - kept.push(&line); list.description = kept.join("\n"); } @@ -88,15 +97,25 @@ pub async fn sync( let client = make_client(creds)?; let mut report = SyncReport::default(); - let mut local_lists = storage.lists().map_err(|e| SyncError::Storage(e.to_string()))?; + let mut local_lists = storage + .lists() + .map_err(|e| SyncError::Storage(e.to_string()))?; let remote_calendars = client.list_task_calendars().await?; - // Index local lists by their bound remote URL. + // Index local lists by their bound remote URL, migrating legacy + // description-encoded markers into `List::remote_url` on the way. let mut by_remote: HashMap = HashMap::new(); - for (i, l) in local_lists.iter().enumerate() { - if let Some(u) = list_remote_url(l) { - by_remote.insert(u.to_string(), i); + for (i, l) in local_lists.iter_mut().enumerate() { + let Some(u) = list_remote_url(l) else { + continue; + }; + if l.remote_url.as_deref() != Some(u.as_str()) { + set_list_remote_url(l, &u); + if let Err(e) = storage.update_list(l) { + tracing::warn!("migrating legacy remote_url for {}: {e}", l.id); + } } + by_remote.insert(u.to_string(), i); } // Ensure a local list exists for every remote calendar. @@ -135,7 +154,9 @@ pub async fn sync( continue; } }; - let uid = icalendar::Component::get_uid(&todo).unwrap_or("").to_string(); + let uid = icalendar::Component::get_uid(&todo) + .unwrap_or("") + .to_string(); if uid.is_empty() { continue; } @@ -149,7 +170,9 @@ pub async fn sync( // Pull: write/update local from remote where remote is newer or local missing. for (uid, (_href, _etag, ical)) in &remote_by_uid { - let Ok(todo) = parse_vtodo(ical) else { continue }; + let Ok(todo) = parse_vtodo(ical) else { + continue; + }; let remote_task = vtodo_to_task(&todo, list.tasks_path()); match local_by_uid.get(uid) { None => { @@ -212,3 +235,57 @@ pub async fn test_connection(creds: &SyncCredentials) -> Result<(), SyncError> { client.test_connection().await?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_list() -> List { + List::new("test") + } + + #[test] + fn legacy_marker_in_description_is_recognized() { + let mut list = empty_list(); + list.description = "notes\ncaldav:https://example.com/dav/cal/".into(); + let url = list_remote_url(&list).expect("legacy marker should parse"); + assert_eq!(url.as_str(), "https://example.com/dav/cal/"); + } + + #[test] + fn remote_url_field_takes_precedence() { + let mut list = empty_list(); + list.description = "caldav:https://old.example.com/".into(); + list.remote_url = Some("https://new.example.com/".into()); + let url = list_remote_url(&list).unwrap(); + assert_eq!(url.as_str(), "https://new.example.com/"); + } + + #[test] + fn set_remote_url_strips_legacy_marker() { + let mut list = empty_list(); + list.description = "first line\ncaldav:https://x/\nlast".into(); + let url = Url::parse("https://example.com/cal/").unwrap(); + set_list_remote_url(&mut list, &url); + assert_eq!(list.remote_url.as_deref(), Some("https://example.com/cal/")); + assert!(!list.description.contains("caldav:")); + assert!(list.description.contains("first line")); + assert!(list.description.contains("last")); + } + + #[test] + fn is_configured_requires_all_fields() { + let blank = SyncCredentials { + server_url: String::new(), + username: String::new(), + password: String::new(), + }; + assert!(!is_configured(&blank)); + let full = SyncCredentials { + server_url: "https://x/".into(), + username: "u".into(), + password: "p".into(), + }; + assert!(is_configured(&full)); + } +}