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 36babcb3..b8f36080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -104,6 +104,27 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[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" @@ -132,12 +153,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" @@ -300,6 +315,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" @@ -454,15 +482,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" @@ -605,6 +624,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" @@ -757,6 +785,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" @@ -793,6 +830,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.43" @@ -807,6 +868,17 @@ 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", + "zeroize", +] + [[package]] name = "cli-clipboard" version = "0.4.0" @@ -957,6 +1029,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 +1130,7 @@ dependencies = [ "iced_futures", "known-folders", "notify", - "ron 0.12.0", + "ron", "serde", "tokio", "tracing", @@ -1127,7 +1216,7 @@ dependencies = [ "csscolorparser", "dirs", "palette", - "ron 0.12.0", + "ron", "serde", "serde_json", "thiserror 2.0.18", @@ -1142,21 +1231,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" @@ -1166,15 +1240,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" @@ -1194,6 +1259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1279,6 +1345,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" @@ -1332,6 +1427,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -1352,7 +1448,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.1", ] [[package]] @@ -1414,12 +1510,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" @@ -1470,15 +1560,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" @@ -1713,17 +1794,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" @@ -1879,17 +1949,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" @@ -2002,8 +2061,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 +2074,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]] @@ -2163,8 +2226,6 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "allocator-api2", - "equivalent", "foldhash", ] @@ -2174,15 +2235,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" @@ -2204,12 +2256,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" @@ -2240,6 +2286,122 @@ 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" +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 +2493,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" @@ -2725,6 +2900,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" @@ -2765,16 +2950,32 @@ dependencies = [ ] [[package]] -name = "is-docker" -version = "0.2.0" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "is-wsl" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" @@ -2783,6 +2984,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" @@ -2846,6 +3056,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" @@ -2869,7 +3091,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]] @@ -2970,6 +3192,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" @@ -2997,17 +3228,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" @@ -3072,6 +3292,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" @@ -3344,6 +3570,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" @@ -3354,6 +3593,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" @@ -3387,6 +3644,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" @@ -3709,11 +4030,17 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" -version = "5.3.2" +version = "5.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" dependencies = [ "is-wsl", "libc", @@ -3772,7 +4099,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", @@ -4100,6 +4427,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.3" @@ -4211,6 +4549,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 +4800,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 +4888,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 +5004,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]] @@ -4637,6 +5104,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" @@ -4949,15 +5435,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" @@ -4967,107 +5444,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" @@ -5101,6 +5477,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 +5532,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" @@ -5184,23 +5575,31 @@ dependencies = [ [[package]] name = "tasks" -version = "0.2.0" +version = "0.3.0" dependencies = [ + "base64", + "chacha20poly1305", "chrono", "cli-clipboard", "dirs", "i18n-embed", "i18n-embed-fl", + "icalendar", + "keyring", "libcosmic", "open", - "ron 0.11.0", + "quick-xml", + "rand 0.9.1", + "reqwest", + "rfd", + "ron", "rust-embed", "serde", "slotmap", - "sqlx", "thiserror 2.0.18", "tracing", "tracing-subscriber", + "url", "uuid", ] @@ -5367,6 +5766,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 +5787,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 +5837,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 +5957,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 +6109,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[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" @@ -5701,12 +6195,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" @@ -5729,6 +6217,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 +6565,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 +6714,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]] @@ -6982,6 +7488,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" @@ -7032,6 +7570,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" @@ -7058,6 +7609,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" @@ -7116,6 +7678,26 @@ dependencies = [ "synstructure", ] +[[package]] +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" version = "0.2.2" @@ -7184,6 +7766,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" @@ -7212,6 +7807,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" @@ -7236,6 +7844,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 09d4455e..55f5ab11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,42 @@ [package] name = "tasks" -version = "0.2.0" -edition = "2021" +version = "0.3.0" +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 = "*" +chacha20poly1305 = "*" +rand = "*" + +[dependencies.rfd] +version = "0.16" +default-features = false +features = ["xdg-portal", "tokio"] + +[dependencies.keyring] +version = "*" +default-features = false +features = ["sync-secret-service", "crypto-rust"] + +[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 +49,11 @@ version = "0.16.0" features = ["fluent-system", "desktop-requester"] [dependencies.serde] -version = "1" +version = "*" features = ["derive"] -[dependencies.sqlx] -version = "0.8.6" -features = ["sqlite"] -default-features = false - [dependencies.chrono] -version = "0.4.42" +version = "*" features = ["serde"] [dependencies.uuid] @@ -45,7 +61,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/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 cfb56d71..f13b0c82 100644 --- a/i18n/en/tasks.ftl +++ b/i18n/en/tasks.ftl @@ -46,6 +46,10 @@ select-date = Select a date # Export Dialog export = Export +export-save-to-file = Save to file… + +# Import (file → File menu entry) +import = Import from markdown # Dialogs cancel = Cancel @@ -70,6 +74,55 @@ match-desktop = Match desktop dark = Dark light = Light +### Privacy +privacy = Privacy +encrypt-notes = Encrypt notes at rest +encrypt-notes-description = Encrypt the notes field of each task using a key stored in the system keyring. Reads always auto-detect, so toggling this on or off does not require a migration. CalDAV sync still pushes plaintext to the server (decryption happens locally before upload). + +### 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://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 = 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} +sync-running = Syncing… +sync-done = Sync complete. Lists added: {$lists}, tasks pulled: {$pulled}, pushed: {$pushed}, failed: {$failed}. +sync-fail = Sync failed: {$error} + # Menu ## File @@ -105,3 +158,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 847ecbdf..d31a7af5 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 { @@ -65,6 +65,11 @@ pub struct Tasks { modifiers: Modifiers, dialog_pages: VecDeque, dialog_text_input: widget::Id, + sync_status: String, + sync_in_progress: bool, + sync_password: String, + sync_last_at: Option>, + sync_last_error: Option, } #[derive(Debug, Clone)] @@ -78,16 +83,160 @@ pub enum Message { impl Tasks { fn settings(&self) -> Element<'_, Message> { - widget::scrollable(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 privacy = widget::settings::section() + .title(fl!("privacy")) + .add(widget::settings::item::builder(fl!("encrypt-notes")).description(fl!("encrypt-notes-description")).control( + widget::toggler(self.config.encrypt_notes) + .on_toggle(|on| Message::Application(ApplicationAction::ToggleEncryptNotes(on))), + )); + + 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)), + ); + + // --- 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 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(), + 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); + + // --- 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() { + account_section = account_section.add(widget::text::caption(self.sync_status.clone())); + } + + widget::scrollable( + widget::column::with_children(vec![ + appearance.into(), + privacy.into(), + account_section.into(), + ]) + .spacing(spacing.space_m), + ) .into() } @@ -133,6 +282,9 @@ impl Tasks { } } } + content::Output::Mutated => { + self.maybe_trigger_sync(tasks); + } } } } @@ -155,10 +307,31 @@ impl Tasks { task.clone(), )))); } + details::Output::Mutated => { + self.maybe_trigger_sync(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>>) { + if self.sync_in_progress { + return; + } + if !crate::sync::engine::is_configured(&self.sync_credentials()) { + return; + } + tasks.push(self.update(Message::Application(ApplicationAction::SyncNow))); + } + fn update_dialog( &mut self, tasks: &mut Vec>>, @@ -207,22 +380,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) => { @@ -230,33 +400,32 @@ 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) => { + DialogPage::Export(content, _filename) => { let Ok(mut clipboard) = ClipboardContext::new() else { tracing::error!("Clipboard is not available"); return; @@ -344,9 +513,10 @@ impl Tasks { match self.storage.tasks(list) { Ok(data) => { let exported_markdown = LocalStorage::export_list(list, &data); + let default_filename = default_export_filename(&list.name); tasks.push(self.update(Message::Application( ApplicationAction::Dialog(DialogAction::Open( - DialogPage::Export(exported_markdown), + DialogPage::Export(exported_markdown, default_filename), )), ))); } @@ -361,6 +531,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 { @@ -405,6 +578,215 @@ 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::ImportFromFile => { + tasks.push(cosmic::Task::perform( + pick_and_read_markdown(), + |result| { + cosmic::Action::App(Message::Application( + ApplicationAction::ImportFromFileResult(result), + )) + }, + )); + } + ApplicationAction::ImportFromFileResult(result) => match result { + Ok((filename, text)) => { + let parsed = crate::app::markdown::parse_import(&text); + let fallback = std::path::Path::new(&filename) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Imported") + .to_string(); + match self.storage.import_list(parsed, &fallback) { + Ok(list) => { + tasks.push(self.update(Message::Tasks(TasksAction::AddList(list)))); + } + Err(err) => tracing::error!("import failed: {err}"), + } + } + Err(e) if e == "cancelled" => {} + Err(e) => tracing::error!("import picker: {e}"), + }, + ApplicationAction::SaveExportToFile => { + let Some(DialogPage::Export(content, default_filename)) = + self.dialog_pages.front().cloned() + else { + return; + }; + tasks.push(cosmic::Task::perform( + pick_and_save_markdown(content, default_filename), + |result| { + cosmic::Action::App(Message::Application( + ApplicationAction::SaveExportToFileResult(result), + )) + }, + )); + } + ApplicationAction::SaveExportToFileResult(result) => match result { + Ok(_) => { + self.dialog_pages.pop_front(); + } + Err(e) if e == "cancelled" => {} + Err(e) => tracing::error!("export save: {e}"), + }, + ApplicationAction::ToggleEncryptNotes(value) => { + if let Some(handler) = &self.config_handler { + if let Err(err) = self.config.set_encrypt_notes(handler, value) { + tracing::error!("{err}"); + } + } + self.storage.set_encrypt_notes(value); + if value { + // Force the keyring entry to materialize now so the user + // gets an immediate prompt-to-unlock if needed, rather + // than the first time they edit a note's text. + if let Err(err) = crate::storage::notes_crypto::encrypt("warmup") { + tracing::warn!("notes encryption warmup failed: {err}"); + } + } + } + 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) => { + 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.clone()) { + tracing::error!("{err}"); + } + } + 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() && old != value { + crate::sync::secret::delete(&old); + } + } + } + ApplicationAction::SetSyncPassword(value) => { + 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 creds = self.sync_credentials(); + tasks.push(cosmic::Task::perform( + async move { + crate::sync::engine::test_connection(&creds) + .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 creds = self.sync_credentials(); + let storage = self.storage.clone(); + tasks.push(cosmic::Task::perform( + async move { + crate::sync::engine::sync(&storage, &creds) + .await + .map_err(|e| e.to_string()) + }, + |result| { + cosmic::Action::App(Message::Application(ApplicationAction::SyncResult( + result, + ))) + }, + )); + } + ApplicationAction::SyncTick => { + self.maybe_trigger_sync(tasks); + } + ApplicationAction::SyncResult(result) => { + 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, + pulled = report.tasks_pulled, + pushed = report.tasks_pushed, + 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; + } } } @@ -423,10 +805,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); @@ -480,7 +873,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([ @@ -508,8 +901,24 @@ 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, + sync_password: String::new(), + sync_last_at: None, + sync_last_error: None, }; + // 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; + } + + // Propagate the persisted encryption preference into the storage + // layer. The flag governs writes; reads always auto-detect, so this + // is safe whether or not the keyring entry already exists. + app.storage.set_encrypt_notes(app.config.encrypt_notes); + let mut tasks = vec![app.update(Message::Tasks(TasksAction::FetchLists))]; if let Some(id) = app.core.main_window_id() { @@ -556,6 +965,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, @@ -578,6 +1011,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)), @@ -666,6 +1104,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) } @@ -698,3 +1141,72 @@ impl Application for Tasks { self.content.view().map(Message::Content) } } + +/// Slugified default filename to seed the portal Save dialog with. +fn default_export_filename(list_name: &str) -> String { + let slug: String = list_name + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect(); + let slug = slug.trim_matches('-'); + if slug.is_empty() { + "tasks.md".to_string() + } else { + format!("{slug}.md") + } +} + +/// Open the portal file chooser and return `(filename, contents)` for the +/// picked markdown file. Uses `rfd`'s xdg-portal backend so it works +/// inside a Flatpak sandbox without needing `--filesystem=home`. The +/// `"cancelled"` sentinel signals user cancellation, which the caller +/// treats as a no-op. +async fn pick_and_read_markdown() -> Result<(String, String), String> { + let handle = rfd::AsyncFileDialog::new() + .set_title(&fl!("import")) + .add_filter("Markdown / text", &["md", "markdown", "txt"]) + .pick_file() + .await + .ok_or_else(|| "cancelled".to_string())?; + let bytes = handle.read().await; + let text = String::from_utf8(bytes).map_err(|e| e.to_string())?; + Ok((handle.file_name(), text)) +} + +/// Open the portal Save dialog and write `content` to the chosen path. +async fn pick_and_save_markdown( + content: String, + default_filename: String, +) -> Result { + let handle = rfd::AsyncFileDialog::new() + .set_title(&fl!("export-save-to-file")) + .set_file_name(&default_filename) + .add_filter("Markdown", &["md"]) + .save_file() + .await + .ok_or_else(|| "cancelled".to_string())?; + handle + .write(content.as_bytes()) + .await + .map_err(|e| e.to_string())?; + Ok(handle.path().to_path_buf()) +} + +/// 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 ab84a1c5..d3e89afc 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, }; @@ -20,12 +20,16 @@ pub enum Action { NewList, DeleteList, RenameList, + ImportList, Icon, ToggleHideCompleted(bool), SortByNameAsc, SortByNameDesc, SortByDateAsc, SortByDateDesc, + SortByDueAsc, + SortByDueDesc, + SyncNow, } #[derive(Debug, Clone)] @@ -46,6 +50,28 @@ pub enum ApplicationAction { SortByNameDesc, SortByDateAsc, SortByDateDesc, + SortByDueAsc, + SortByDueDesc, + ToggleEncryptNotes(bool), + /// Open the portal file picker to choose a markdown file, then read + + /// parse it. The result is delivered via `ImportFromFileResult`. + ImportFromFile, + /// `(filename, contents)` on success, or a short error string. The + /// "cancelled" sentinel is treated as a no-op. + ImportFromFileResult(Result<(String, String), String>), + /// Open the portal Save dialog for the currently displayed Export + /// payload and write the markdown to the chosen path. + SaveExportToFile, + SaveExportToFileResult(Result), + SetSyncServerUrl(String), + SetSyncUsername(String), + SetSyncPassword(String), + TestSyncConnection, + TestSyncConnectionResult(Result<(), String>), + SyncNow, + SyncTick, + SyncResult(Result), + SignOut, } #[derive(Debug, Clone)] @@ -71,6 +97,9 @@ impl MenuAction for Action { Action::NewList => Message::Application(ApplicationAction::Dialog(DialogAction::Open( DialogPage::New(String::new()), ))), + Action::ImportList => { + Message::Application(ApplicationAction::ImportFromFile) + } Action::Icon => Message::Application(ApplicationAction::Dialog(DialogAction::Open( DialogPage::Icon(None, String::new(), String::new()), ))), @@ -87,6 +116,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), } } } @@ -97,6 +129,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..9595c990 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 { @@ -24,7 +24,10 @@ pub enum DialogPage { Rename(Option, String), Delete(Option), Calendar(CalendarModel), - Export(String), + /// (markdown_contents, default_filename_for_save_picker). The filename + /// is what the portal Save dialog will pre-fill when the user clicks + /// "Save to file…". + Export(String, String), } impl DialogPage { @@ -181,24 +184,28 @@ impl DialogPage { ); dialog } - DialogPage::Export(contents) => { - let dialog = widget::dialog() + DialogPage::Export(contents, default_filename) => { + let preview = widget::container( + widget::scrollable(widget::text(contents)).width(Length::Fill), + ) + .height(Length::Fixed(220.0)) + .width(Length::Fill); + + let _ = text_input_id; + let _ = default_filename; // consumed by the SaveExportToFile path + + widget::dialog() .title(fl!("export")) - .control( - widget::container( - widget::scrollable(widget::text(contents)).width(Length::Fill), - ) - .height(Length::Fixed(200.0)) - .width(Length::Fill), - ) - .primary_action(widget::button::suggested(fl!("copy")).on_press_maybe(Some( + .control(preview) + .primary_action(widget::button::suggested(fl!("copy")).on_press( Message::Application(ApplicationAction::Dialog(DialogAction::Complete)), - ))) + )) .secondary_action(widget::button::standard(fl!("cancel")).on_press( Message::Application(ApplicationAction::Dialog(DialogAction::Close)), - )); - - dialog + )) + .tertiary_action(widget::button::standard(fl!("export-save-to-file")).on_press( + Message::Application(ApplicationAction::SaveExportToFile), + )) } } } 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/markdown.rs b/src/app/markdown.rs index 31e33737..f81760cb 100644 --- a/src/app/markdown.rs +++ b/src/app/markdown.rs @@ -12,48 +12,293 @@ impl Markdown for List { impl Markdown for Task { fn markdown(&self) -> String { - let mut task = format!( - "- [{}] {}\n", - if self.status == Status::Completed { - "x" - } else { - " " - }, - self.title - ); - - // Recursively format sub-tasks with proper indentation - if !self.sub_tasks.is_empty() { - task.push_str(&format_sub_tasks(&self.sub_tasks, 1)); + let mut out = String::new(); + write_task(&mut out, self, 0); + out + } +} + +fn write_task(out: &mut String, task: &Task, indent_level: usize) { + let indent = " ".repeat(indent_level); + out.push_str(&format!( + "{}- [{}] {}\n", + indent, + if task.status == Status::Completed { + "x" + } else { + " " + }, + task.title + )); + for sub in &task.sub_tasks { + write_task(out, sub, indent_level + 1); + } +} + +// --- Import ---------------------------------------------------------------- + +/// Result of parsing a markdown document into something the importer can +/// materialize. `name` is the first H1 the parser saw (or `None` if the input +/// only had bullets / lower-level headings). +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ImportedList { + pub name: Option, + pub tasks: Vec, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ImportedTask { + pub title: String, + pub completed: bool, + pub children: Vec, +} + +/// Parse a markdown document into a list-and-tasks tree. The parser is +/// intentionally permissive — it accepts both checkbox bullets (`- [ ] x`) +/// and plain bullets (`- x`, `* x`, `+ x`, `1. x`), uses the first `#` as +/// the list name, and treats every `##`/deeper heading as a top-level +/// parent task whose children are the bullets that follow it (until the +/// next heading at the same or higher level). +pub fn parse_import(input: &str) -> ImportedList { + let mut out = ImportedList::default(); + + // Each frame is (level, path-of-indices-into-out.tasks). The base frame + // and any heading-section frame both use level `-1` so the first bullet + // (level 0) doesn't pop them; deeper nesting works by frame.level >= + // bullet.level. + let mut stack: Vec<(i32, Vec)> = vec![(-1, vec![])]; + + for raw_line in input.lines() { + let line = raw_line.trim_end_matches(['\r']); + if line.trim().is_empty() { + continue; + } + + // ---- headings ---- + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix('#') { + let mut level = 1usize; + let mut rest = rest; + while let Some(r) = rest.strip_prefix('#') { + rest = r; + level += 1; + } + // Require a space after the hashes — otherwise it's not a + // heading (e.g. a URL fragment in a bullet). + if let Some(text) = rest.strip_prefix(' ') { + let text = text.trim(); + if level == 1 && out.name.is_none() { + out.name = Some(text.to_string()); + stack = vec![(-1, vec![])]; + continue; + } + // ## or deeper, or a second H1: parent task. + let parent = ImportedTask { + title: text.to_string(), + completed: false, + children: vec![], + }; + out.tasks.push(parent); + let parent_idx = out.tasks.len() - 1; + stack = vec![(-1, vec![]), (-1, vec![parent_idx])]; + continue; + } + } + + // ---- bullets ---- + let Some((indent_spaces, content, completed)) = parse_bullet(raw_line) else { + continue; + }; + let level = (indent_spaces / 2) as i32; + + while stack.len() > 1 && stack.last().map(|f| f.0).unwrap_or(-1) >= level { + stack.pop(); } - task + let new_task = ImportedTask { + title: content, + completed, + children: vec![], + }; + + let frame_path = stack.last().expect("stack always has a base").1.clone(); + let target = locate_children(&mut out.tasks, &frame_path); + target.push(new_task); + let new_idx = target.len() - 1; + + let mut new_path = frame_path; + new_path.push(new_idx); + stack.push((level, new_path)); } + + out } -// Helper function to recursively format sub-tasks with proper indentation -fn format_sub_tasks(sub_tasks: &[Task], indent_level: usize) -> String { - let mut result = String::new(); - let indent = " ".repeat(indent_level); +fn locate_children<'a>( + top: &'a mut Vec, + path: &[usize], +) -> &'a mut Vec { + if path.is_empty() { + return top; + } + let mut current = &mut top[path[0]]; + for &i in &path[1..] { + current = &mut current.children[i]; + } + &mut current.children +} + +/// Recognise a bullet line. Returns `(indent_in_spaces, content, completed)`. +/// Tabs count as 4 spaces. +fn parse_bullet(line: &str) -> Option<(usize, String, bool)> { + let mut indent = 0usize; + let mut chars = line.chars().peekable(); + while let Some(&c) = chars.peek() { + match c { + ' ' => { + indent += 1; + chars.next(); + } + '\t' => { + indent += 4; + chars.next(); + } + _ => break, + } + } + let rest: String = chars.collect(); - for sub_task in sub_tasks { - // Add the sub-task with proper indentation - result.push_str(&format!( - "{}- [{}] {}\n", - indent, - if sub_task.status == Status::Completed { - "x" - } else { - " " - }, - sub_task.title - )); - - // Recursively process nested sub-tasks if any - if !sub_task.sub_tasks.is_empty() { - result.push_str(&format_sub_tasks(&sub_task.sub_tasks, indent_level + 1)); + let after_marker = if let Some(r) = rest.strip_prefix("- ") { + r + } else if let Some(r) = rest.strip_prefix("* ") { + r + } else if let Some(r) = rest.strip_prefix("+ ") { + r + } else { + // numbered list: leading digits, then ". " + let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect(); + if digits.is_empty() { + return None; } + let after = &rest[digits.len()..]; + let after = after.strip_prefix('.').or_else(|| after.strip_prefix(')'))?; + after.strip_prefix(' ')? + }; + + // Optional checkbox. + let (completed, content) = if let Some(rest) = after_marker.strip_prefix("[ ] ") { + (false, rest.to_string()) + } else if let Some(rest) = after_marker + .strip_prefix("[x] ") + .or_else(|| after_marker.strip_prefix("[X] ")) + { + (true, rest.to_string()) + } else if after_marker == "[ ]" { + (false, String::new()) + } else if after_marker == "[x]" || after_marker == "[X]" { + (true, String::new()) + } else { + (false, after_marker.to_string()) + }; + + let content = content.trim().to_string(); + if content.is_empty() { + return None; + } + Some((indent, content, completed)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_h1_as_list_name() { + let md = "# My List\n- one\n- two\n"; + let out = parse_import(md); + assert_eq!(out.name.as_deref(), Some("My List")); + assert_eq!(out.tasks.len(), 2); + assert_eq!(out.tasks[0].title, "one"); + } + + #[test] + fn h2_becomes_parent_task() { + let md = "# Top\n## Section A\n- a1\n- a2\n## Section B\n- b1\n"; + let out = parse_import(md); + assert_eq!(out.name.as_deref(), Some("Top")); + assert_eq!(out.tasks.len(), 2); + assert_eq!(out.tasks[0].title, "Section A"); + assert_eq!(out.tasks[0].children.len(), 2); + assert_eq!(out.tasks[0].children[1].title, "a2"); + assert_eq!(out.tasks[1].title, "Section B"); + assert_eq!(out.tasks[1].children[0].title, "b1"); + } + + #[test] + fn checkbox_states_round_trip() { + let md = "- [x] done\n- [ ] todo\n- [X] also done\n"; + let out = parse_import(md); + assert_eq!(out.tasks.len(), 3); + assert!(out.tasks[0].completed); + assert!(!out.tasks[1].completed); + assert!(out.tasks[2].completed); + } + + #[test] + fn indented_bullets_become_children() { + let md = "- parent\n - child\n - grandchild\n- sibling\n"; + let out = parse_import(md); + assert_eq!(out.tasks.len(), 2); + assert_eq!(out.tasks[0].children.len(), 1); + assert_eq!(out.tasks[0].children[0].title, "child"); + assert_eq!(out.tasks[0].children[0].children[0].title, "grandchild"); + assert_eq!(out.tasks[1].title, "sibling"); + } + + #[test] + fn plain_bullets_without_checkbox_count_as_tasks() { + let md = "* foo\n+ bar\n1. baz\n"; + let out = parse_import(md); + assert_eq!(out.tasks.len(), 3); + assert_eq!(out.tasks[0].title, "foo"); + assert_eq!(out.tasks[1].title, "bar"); + assert_eq!(out.tasks[2].title, "baz"); + } + + #[test] + fn realistic_user_todo_file() { + let md = "# TODO\n\n## Corp Job\n- a\n- b\n\n## Okul\n- c\n"; + let out = parse_import(md); + assert_eq!(out.name.as_deref(), Some("TODO")); + assert_eq!(out.tasks.len(), 2); + assert_eq!(out.tasks[0].title, "Corp Job"); + assert_eq!(out.tasks[0].children.len(), 2); + assert_eq!(out.tasks[1].title, "Okul"); } - result + #[test] + fn input_without_h1_yields_no_name() { + let md = "- a\n- b\n"; + let out = parse_import(md); + assert!(out.name.is_none()); + assert_eq!(out.tasks.len(), 2); + } + + #[test] + fn export_round_trip_preserves_completion_for_subtasks() { + let mut parent = Task::default(); + parent.title = "p".into(); + parent.status = Status::NotStarted; + let mut child_done = Task::default(); + child_done.title = "c1".into(); + child_done.status = Status::Completed; + let mut child_todo = Task::default(); + child_todo.title = "c2".into(); + child_todo.status = Status::NotStarted; + parent.sub_tasks = vec![child_done, child_todo]; + let md = parent.markdown(); + assert!(md.contains("- [ ] p")); + assert!(md.contains(" - [x] c1")); + assert!(md.contains(" - [ ] c2")); + } } diff --git a/src/app/menu.rs b/src/app/menu.rs index a614ba08..f413e866 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::{ @@ -36,6 +36,11 @@ pub fn menu_bar<'a>( Some(icons::get_handle("plus-square-filled-symbolic", 14)), Action::NewList, ), + Item::Button( + fl!("import"), + Some(icons::get_handle("document-open-symbolic", 14)), + Action::ImportList, + ), Item::Divider, Item::Button( fl!("quit"), @@ -81,6 +86,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 +114,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 397d6dd5..b106d7a9 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}; @@ -12,6 +13,13 @@ 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, + /// When true, the storage layer encrypts `Task::notes` at rest using a + /// key from the system keyring. Reads always auto-detect, so flipping + /// this off is non-destructive for already-encrypted files (they get + /// re-saved as plaintext the next time they're touched). + pub encrypt_notes: bool, } 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/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/pages/content.rs b/src/pages/content.rs index 9b36f430..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), @@ -85,6 +90,7 @@ pub enum Output { ToggleHideCompleted(models::List), Focus(widget::Id), OpenTaskDetails(models::Task), + Mutated, } #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -196,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())) @@ -209,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() @@ -299,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, @@ -307,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); @@ -408,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, @@ -416,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); @@ -576,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) { @@ -600,8 +661,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 +676,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); @@ -623,12 +686,21 @@ 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) { - 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 +711,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 +724,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 +738,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), } } } @@ -684,7 +759,10 @@ 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) => { tracing::error!("Failed to add sub-task: {:?}", error); @@ -699,12 +777,18 @@ 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) { - 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 +798,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 +810,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) => { @@ -749,7 +835,10 @@ 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) => { tracing::error!("Failed to add sub-task: {:?}", error); @@ -765,23 +854,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), } } } @@ -833,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 2de7ac99..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}, }, }; @@ -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,10 +95,18 @@ impl Details { } Message::OpenCalendarDialog => { tasks.push(Output::OpenCalendarDialog); + 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)); } } @@ -102,80 +114,80 @@ impl Details { tracing::error!("Failed to update task: {}", e); } tasks.push(Output::RefreshTask(self.task.clone())); + if emit_mutated { + tasks.push(Output::Mutated); + } tasks } 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 14906090..e47f0ce4 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,17 +1,25 @@ pub mod migration; pub mod models; +pub mod notes_crypto; use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; use crate::{ - app::markdown::Markdown, - storage::models::{List, Task}, Error, LocalStorageError, TasksError, + app::markdown::{ImportedList, ImportedTask, Markdown}, + storage::models::{List, Status, Task}, }; #[derive(Debug, Clone)] pub struct LocalStorage { paths: LocalStoragePaths, + /// Whether new writes should encrypt `Task::notes`. Reads always + /// auto-detect, so this only governs the *outgoing* shape. Wrapped + /// in `Arc` so all clones in the app see the latest + /// value the moment the user toggles the setting. + encrypt_notes: Arc, } #[derive(Debug, Clone)] @@ -40,11 +48,47 @@ impl LocalStorage { } let storage = Self { paths: LocalStoragePaths { lists: lists_path }, + encrypt_notes: Arc::new(AtomicBool::new(false)), }; Ok(storage) } + pub fn set_encrypt_notes(&self, on: bool) { + self.encrypt_notes.store(on, Ordering::Relaxed); + } + + pub fn encrypt_notes_enabled(&self) -> bool { + self.encrypt_notes.load(Ordering::Relaxed) + } + + /// Decrypt `task.notes` in place. Errors are downgraded to a warning + /// and the (still-encrypted) value is left as-is — that way a missing + /// key (e.g. on a fresh machine that hasn't unlocked the keyring yet) + /// does not crash list views. + fn decrypt_notes(task: &mut Task) { + if !notes_crypto::is_encrypted(&task.notes) { + return; + } + match notes_crypto::decrypt(&task.notes) { + Ok(plain) => task.notes = plain, + Err(e) => tracing::warn!("decrypt notes for task {}: {e}", task.id), + } + } + + fn encrypt_notes_for_write(&self, task: &mut Task) { + if !self.encrypt_notes_enabled() { + return; + } + if task.notes.is_empty() { + return; + } + match notes_crypto::encrypt(&task.notes) { + Ok(ct) => task.notes = ct, + Err(e) => tracing::warn!("encrypt notes for task {}: {e}", task.id), + } + } + pub fn tasks(&self, list: &List) -> Result, Error> { let mut tasks = vec![]; let path = list.tasks_path(); @@ -57,6 +101,7 @@ impl LocalStorage { if path.is_file() { let content = std::fs::read_to_string(&path)?; let mut task: Task = ron::from_str(&content)?; + Self::decrypt_notes(&mut task); if let Some(stem) = path.file_stem() { let folder_path = path.parent().unwrap().join(stem); if folder_path.is_dir() { @@ -81,6 +126,7 @@ impl LocalStorage { if path.is_file() { let content = std::fs::read_to_string(&path)?; let mut task: Task = ron::from_str(&content)?; + Self::decrypt_notes(&mut task); if let Some(stem) = path.file_stem() { let folder_path = path.parent().unwrap().join(stem); if folder_path.is_dir() { @@ -109,7 +155,9 @@ impl LocalStorage { let path = task.file_path(); if !path.exists() { std::fs::create_dir_all(&task.path)?; - let content = ron::to_string(&task)?; + let mut to_write = task.clone(); + self.encrypt_notes_for_write(&mut to_write); + let content = ron::to_string(&to_write)?; std::fs::write(path, content)?; Ok(task.clone()) } else { @@ -117,10 +165,29 @@ 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 content = ron::to_string(&task)?; + let mut touched = task.clone(); + touched.last_modified_date_time = chrono::Utc::now(); + self.encrypt_notes_for_write(&mut touched); + 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 mut to_write = task.clone(); + self.encrypt_notes_for_write(&mut to_write); + let content = ron::to_string(&to_write)?; std::fs::write(path, content)?; Ok(()) } else { @@ -181,4 +248,53 @@ impl LocalStorage { let tasks_markdown: String = tasks.iter().map(Markdown::markdown).collect(); format!("{markdown}\n{tasks_markdown}") } + + /// Materialize a parsed markdown document as a brand-new local list with + /// its tasks (and sub-task tree) on disk. The list is created fresh so + /// it does not collide with any existing CalDAV-bound list; users can + /// later attach a remote URL via sync. Returns the persisted `List`. + pub fn import_list(&self, parsed: ImportedList, fallback_name: &str) -> Result { + let name = parsed + .name + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(fallback_name); + let list = List::new(name); + let list = self.create_list(&list)?; + let tasks_path = list.tasks_path(); + if !tasks_path.exists() { + std::fs::create_dir_all(&tasks_path)?; + } + for task in &parsed.tasks { + self.write_imported_task(task, &tasks_path)?; + } + Ok(list) + } + + fn write_imported_task( + &self, + task: &ImportedTask, + parent_dir: &std::path::Path, + ) -> Result<(), Error> { + let now = chrono::Utc::now(); + let mut model = Task::new(task.title.clone(), parent_dir.to_path_buf()); + model.status = if task.completed { + Status::Completed + } else { + Status::NotStarted + }; + if task.completed { + model.completion_date = Some(now); + } + self.create_task(&model)?; + if !task.children.is_empty() { + let sub_dir = model.sub_tasks_path(); + std::fs::create_dir_all(&sub_dir)?; + for child in &task.children { + self.write_imported_task(child, &sub_dir)?; + } + } + Ok(()) + } } 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/storage/notes_crypto.rs b/src/storage/notes_crypto.rs new file mode 100644 index 00000000..0c3ff8b7 --- /dev/null +++ b/src/storage/notes_crypto.rs @@ -0,0 +1,138 @@ +//! Optional at-rest encryption for the `Task::notes` field. +//! +//! Encrypted payloads are written as `enc:v1:` so +//! plaintext stays plaintext (the prefix is the tell) and reads can +//! auto-detect without a flag. The key is a 32-byte secret stored in the +//! system keyring under (`dev.edfloreshz.Tasks.notes`, `master`); it is +//! generated on first use and never leaves the device. +//! +//! CalDAV roundtrips deliberately see *plaintext*: the storage layer +//! decrypts on read and re-encrypts on write, so the sync engine pushes +//! the readable form to remote calendars (preserving interop with other +//! clients that share the same calendar). + +use base64::Engine as _; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, Nonce, + aead::{Aead, KeyInit}, +}; +use rand::RngCore; + +const SERVICE: &str = "dev.edfloreshz.Tasks.notes"; +const ACCOUNT: &str = "master"; +pub const PREFIX: &str = "enc:v1:"; + +#[derive(Debug, thiserror::Error)] +pub enum CryptoError { + #[error("keyring: {0}")] + Keyring(String), + #[error("key has wrong length")] + KeyLength, + #[error("base64: {0}")] + Base64(#[from] base64::DecodeError), + #[error("ciphertext too short")] + Truncated, + #[error("aead: {0}")] + Aead(String), + #[error("not utf-8: {0}")] + Utf8(#[from] std::string::FromUtf8Error), +} + +fn entry() -> Result { + keyring::Entry::new(SERVICE, ACCOUNT).map_err(|e| CryptoError::Keyring(e.to_string())) +} + +fn load_or_create_key() -> Result<[u8; 32], CryptoError> { + let entry = entry()?; + match entry.get_password() { + Ok(s) => { + let bytes = base64::engine::general_purpose::STANDARD.decode(s.trim())?; + if bytes.len() != 32 { + return Err(CryptoError::KeyLength); + } + let mut k = [0u8; 32]; + k.copy_from_slice(&bytes); + Ok(k) + } + Err(keyring::Error::NoEntry) => { + let mut k = [0u8; 32]; + rand::rng().fill_bytes(&mut k); + entry + .set_password(&base64::engine::general_purpose::STANDARD.encode(k)) + .map_err(|e| CryptoError::Keyring(e.to_string()))?; + Ok(k) + } + Err(e) => Err(CryptoError::Keyring(e.to_string())), + } +} + +/// True when `s` looks like a payload produced by [`encrypt`]. +pub fn is_encrypted(s: &str) -> bool { + s.starts_with(PREFIX) +} + +/// Encrypt `plain`. Empty strings and already-encrypted strings are +/// returned untouched so callers can apply this idempotently. +pub fn encrypt(plain: &str) -> Result { + if plain.is_empty() || is_encrypted(plain) { + return Ok(plain.to_string()); + } + let key = load_or_create_key()?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let mut nonce_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt(nonce, plain.as_bytes()) + .map_err(|e| CryptoError::Aead(e.to_string()))?; + let mut combined = Vec::with_capacity(12 + ct.len()); + combined.extend_from_slice(&nonce_bytes); + combined.extend_from_slice(&ct); + Ok(format!( + "{PREFIX}{}", + base64::engine::general_purpose::STANDARD.encode(combined) + )) +} + +/// Decrypt `text` if it looks like an encrypted payload; otherwise return +/// it unchanged. This makes reads tolerant of mixed-format storage during +/// the migration window after toggling the feature on. +pub fn decrypt(text: &str) -> Result { + if !is_encrypted(text) { + return Ok(text.to_string()); + } + let body = &text[PREFIX.len()..]; + let bytes = base64::engine::general_purpose::STANDARD.decode(body.trim())?; + if bytes.len() < 12 { + return Err(CryptoError::Truncated); + } + let key = load_or_create_key()?; + let cipher = ChaCha20Poly1305::new(Key::from_slice(&key)); + let nonce = Nonce::from_slice(&bytes[..12]); + let pt = cipher + .decrypt(nonce, &bytes[12..]) + .map_err(|e| CryptoError::Aead(e.to_string()))?; + Ok(String::from_utf8(pt)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_passes_through() { + assert_eq!(encrypt("").unwrap(), ""); + assert_eq!(decrypt("").unwrap(), ""); + } + + #[test] + fn plaintext_decrypt_is_identity() { + assert_eq!(decrypt("hello").unwrap(), "hello"); + } + + #[test] + fn is_encrypted_recognizes_prefix() { + assert!(is_encrypted("enc:v1:abc")); + assert!(!is_encrypted("plain notes")); + } +} diff --git a/src/sync/caldav.rs b/src/sync/caldav.rs new file mode 100644 index 00000000..38512d4c --- /dev/null +++ b/src/sync/caldav.rs @@ -0,0 +1,776 @@ +use base64::Engine as _; +use chrono::{DateTime, NaiveDate, Utc}; +use icalendar::{Calendar as ICalendar, CalendarDateTime, Component, DatePerhapsTime, Todo}; +use quick_xml::Reader; +use quick_xml::events::Event; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue}; +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 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(|mut s| s.rfind(|x| !x.is_empty()).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, 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 + .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 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); + 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"calendar" => { + if let Some(c) = current.as_mut() + && 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() + && 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()))?; + 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)) => { + 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, + }; + + // 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 + .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) + .or_else(|| todo.property_value("DTSTAMP").and_then(parse_ical_datetime)) + .unwrap_or(created); + 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 { + // 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.completed(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), + ); + 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> { + 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); + + // 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)); + } + } + + // 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 +} + +#[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()); + } + + #[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 { + 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..206f9ba9 --- /dev/null +++ b/src/sync/engine.rs @@ -0,0 +1,291 @@ +use std::collections::HashMap; + +use thiserror::Error; +use url::Url; + +use crate::storage::LocalStorage; +use crate::storage::models::{List, Task}; + +use super::caldav::{CalDavClient, CalDavError, parse_vtodo, task_to_vtodo, vtodo_to_task}; + +/// 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 { + #[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), +} + +#[derive(Debug, Clone)] +pub struct SyncCredentials { + pub server_url: String, + pub username: String, + pub password: String, +} + +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( + creds.server_url.trim(), + creds.username.trim(), + &creds.password, + )?) +} + +#[derive(Debug, Clone, Default)] +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. +/// +/// 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(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) { + 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(LEGACY_REMOTE_MARKER)) + .collect(); + 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, + creds: &SyncCredentials, +) -> Result { + 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 remote_calendars = client.list_task_calendars().await?; + + // 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_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. + 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.replace_task(&remote_task) { + tracing::warn!("replace_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}"); + report.tasks_failed += 1; + } + }, + 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}"); + report.tasks_failed += 1; + } + } + } + } + } + } + } + + Ok(report) +} + +pub async fn test_connection(creds: &SyncCredentials) -> Result<(), SyncError> { + let client = make_client(creds)?; + 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)); + } +} diff --git a/src/sync/mod.rs b/src/sync/mod.rs new file mode 100644 index 00000000..8c5f349a --- /dev/null +++ b/src/sync/mod.rs @@ -0,0 +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(); + } +}