commit ba530a727139c3fe053b19f41b8f5de4889c95a3 Author: Hermes Agent Date: Sun Aug 16 19:23:59 2026 +0000 working prototype diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a3bed2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target/ +*.agnd +*.agnd-shm +*.agnd-wal + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..59b0a19 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2012 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "darling" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88490bf1b990d87eaaa7ac8aa887f629a08e7359765b4911faf63c3763347d23" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e274f91c482280130e1e34e0b8d6e66776a060d7b6de7b84289ca778868c4" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + +[[package]] +name = "darling_macro" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5792fa0d41cd2325ce0ffa64f0a340eaebd4971a3a0c5e1ffd2cc488a355e" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.20", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pest" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termina", + "ratatui-termwiz", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror 2.0.20", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rogue-agenda" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "crossterm", + "ratatui", + "regex", + "rusqlite", + "tempfile", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termina" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e" +dependencies = [ + "bitflags 2.13.1", + "parking_lot", + "rustix", + "signal-hook", + "windows-sys", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "atomic", + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa5b2c3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rogue-agenda" +version = "0.1.0" +edition = "2024" +description = "A category-driven personal information manager for the terminal" +license = "MIT" + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", features = ["clock"] } +clap = { version = "4.6", features = ["derive"] } +crossterm = "0.29" +ratatui = "0.30" +regex = "1.13" +rusqlite = "0.40" + +[dev-dependencies] +tempfile = "3.27" + +[profile.release] +lto = "thin" +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50ae7bb --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Rogue Agenda contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..25d6707 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,119 @@ +# Rogue Agenda project ledger + +Last updated: 2026-08-16 + +## Product direction + +Rogue Agenda is a Rust, Ratatui, and SQLite reimagining of Lotus Agenda 2.0. +The goal is functional fidelity to its information-management ideas and visual +rhythm, not binary or document compatibility. Documents use `.agnd`; each is a +self-contained SQLite database. Every product string uses **Rogue Agenda**. + +The central principle is “capture first, organize later.” An item is stored once +and may participate in many category relationships and saved views. The TUI keeps +the original's high-information gray workspace, blue title bands, red selection, +columnar item view, and function-key strip, while adding dynamic layout, mouse +interaction, conventional shortcuts, UTF-8, and modern durability. + +## Research basis + +- Lotus Development Corporation, *Lotus Agenda User's Guide 2.0* (1989), scanned + at Internet Archive. The contents identify items, notes, hierarchical and + mutually exclusive categories, explicit/conditional assignments, sections, + live views, datebook views, alarms, numeric calculations, filters, dependencies, + bulk marking, sorting, automatic conditions/actions, import/export, printing, + backups, managers, macros, and accessories. +- Lotus Development Corporation, *Starter Applications 2.0* (1990), scanned at + Internet Archive. It documents Activities Planner, Account Manager, People + Manager, priorities, contacts, expenses, weekly tracking, and status reports. +- Tavis Ormandy's hands-on “Lotus Agenda” article and animations. These establish + the actual screen grammar: two-line blue file/view header, gray unboxed canvas, + section and column headings, dot-prefixed category values, red selection, + blue F1–F10 command tiles, and examples of natural-language extraction. +- Bob Newell's Agenda FAQ and James Fallows' contemporary account. These clarify + the distinctive behavior: single-copy items, category inheritance, negative + assignment, aliases/abbreviations, incremental structure, and named live views. +- Period reviews from the Los Angeles Times and HUMANIST archives corroborate + long notes, free-form entry, priorities, Boolean reports, and automatic filing. + +Research URLs: + +- https://archive.org/details/lotus-agenda-users-guide +- https://lock.cmpxchg8b.com/lotusagenda.html +- https://www.bobnewell.net/agenda/agfaq.txt.html +- https://www.bobnewell.net/agenda/atlantic.txt.html +- https://dhhumanist.org/archives/Virginia/v02/0410.html +- https://www.latimes.com/archives/la-xpm-1988-07-28-fi-9745-story.html + +## Implemented in this prototype + +- [x] `.agnd` SQLite documents, schema migration metadata, foreign keys, WAL, + transactions, integrity-friendly soft deletion, and automatic checkpoints. +- [x] Items with free text, note, priority, When, Done, alarm, numeric value, + recurring schedule, creation/update timestamps, manual order, and prerequisite + links. Completion advances recurring work and carries alarms and filing forward. +- [x] Categories with hierarchy, inheritance in queries, kinds (standard, date, + numeric), match phrases, mutually exclusive siblings, and many-to-many links. +- [x] Explicit assignment and automatic text-based assignment. +- [x] Explicit negative category assignments that override automatic filing. +- [x] Natural-language date extraction for common relative, weekday, and ISO forms. +- [x] Live saved views, datebook presentation, category/done/date/priority filters, + search filtering, configurable sort, and responsive columns. +- [x] Interactive creation, editing, and deletion of saved live views and category + definitions, including hierarchy, match phrases, kinds, and exclusivity. +- [x] Persistent per-view fields, headings, widths, order, numeric aggregates, + filtered multi-section layouts, collapsed sections, and view reordering. +- [x] Parsed Boolean expressions with `and`/`or`/`not`, parentheses, text/note/ + category matching, numeric comparisons, state predicates, and relative due dates. +- [x] User-authored category conditions with live match preview and deterministic + assign, exclude, priority, numeric value, When, alarm, recurrence, and Done + actions. Rules apply iteratively, respect explicit overrides, and detect + non-convergence. +- [x] Default All Items, Tasks, Upcoming, Recently Done, and Datebook views. +- [x] Agenda-style item workspace, View Manager, Category Manager, command menu, + item editor, property editor, notes, category chooser, help, and status messages. +- [x] Marking and bulk completion; Trash view, soft discard, and recovery. +- [x] Responsive wide/compact rendering and mouse row/command/view interaction. +- [x] Text and iCalendar import; view-scoped CSV, JSON, Markdown, styled HTML, + and iCalendar export with notes, categories, dates, priority, completion, and + recurring `RRULE` schedules. +- [x] Demo data, CLI help, README, MIT license, and unit/integration tests. + +## Supported with a modern interpretation + +- [x] Numeric calculations: per-item numeric values and view total/average are + supported; arbitrary category formula programs are intentionally represented + by the safer aggregate model. +- [x] Conditions/actions: category match phrases and date parsing run on item + changes; assignments are immediately re-evaluated. Shell-executing actions are + omitted by design, while deterministic filing actions are supported. +- [x] Printing: view-aware Markdown and styled HTML reports are the portable + print/preview path, alongside CSV and JSON data exports. +- [x] Backups: SQLite durability plus explicit checkpoint replaces proprietary + backup-file rotation. Ordinary filesystem backup tools work on `.agnd` files. +- [x] Clipboard/accessory capture: terminal paste into the new-item form replaces + the DOS resident accessory. +- [x] Macros: conventional keys and direct commands replace keystroke-replay macros. + +## Delayed / unsupported + +These are the few features that either depend on obsolete DOS integration or need +a larger follow-on implementation: + +- [ ] Arbitrary formula columns that reference several numeric categories. Current + view columns support `sum`, `avg`, `count`, `min`, and `max` aggregates. +- [ ] Rich-text/font markers, printer control sequences, page-layout preview, and + named printer configurations. +- [ ] Lotus Metro/List Manager/STF compatibility, DOS accessories, and Agenda macro + compatibility (explicitly outside the project's format-compatibility goal). +- [ ] Password encryption. A description field is supported by schema metadata; + weak historic password protection will not be imitated without real encryption. +- [ ] Desktop notifications while the application is closed. Due alarms are shown + prominently while Rogue Agenda is running. + +## Near-term plan + +1. Add desktop alarm integration and in-application snooze controls. +2. Add encrypted documents and secure unlock handling. +3. Add property-based tests for complex hierarchy and rule combinations. +4. Add a structured text definition-file importer for unusual legacy datasets. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a7cb733 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# Rogue Agenda + +Rogue Agenda is a modern terminal reimagining of the ideas behind Lotus Agenda. +It captures information first and lets structure emerge later: enter a plain +sentence, then use categories, dates, saved views, notes, priorities, and live +filters to see it from different angles. + +The interface deliberately recalls Agenda's dense blue-and-gray DOS workspace, +including the function-key command strip, while behaving like a current TUI: +it resizes fluidly, supports mouse selection and command clicks, understands +common keyboard conventions, and stores each document as an ordinary SQLite +database with the `.agnd` extension. + +## Features + +- Free-form items with multiline notes, priority, due dates, done dates, alarms, + numeric values, recurring schedules, and prerequisites. Completing a recurring + item creates its next dated occurrence and carries its alarm and filing forward. +- Hierarchical categories with inherited membership and explicit many-to-many + assignment and negative exclusions: an item exists once but can appear in many + contexts. +- Automatic filing from category match phrases and natural date phrases such as + `today`, `tomorrow`, `next friday`, `in 3 days`, and ISO dates. +- Live views for all items, tasks, upcoming work, completed work, and a datebook. +- Full-text search plus composable Boolean filters over text, notes, categories, + dates, done state, priority, numeric value, alarms, recurrence, and relative due + windows. +- Agenda-style View Manager and Category Manager screens with editable live + view definitions, hierarchy, category kinds, match phrases, and exclusivity. +- Persistent per-view columns, custom headings and widths, numeric aggregates, + filtered multi-section layouts, collapsed sections, and view reordering. +- User-authored category rules with a live match preview and deterministic actions + for assignments, exclusions, priority, value, dates, and completion. +- Marking and bulk completion, soft deletion with Trash recovery, sorting, and + dependency visibility. +- View-scoped CSV, JSON, Markdown, HTML, and iCalendar export, text/iCalendar + import (including standard recurrence rules), autosave, WAL journaling, and + transactional SQLite persistence. +- Responsive compact/wide layouts, keyboard navigation, mouse selection, + clickable function-key commands, and an in-app help screen. + +## Build and run + +You need a current stable Rust toolchain and SQLite development libraries. + +```sh +cargo run -- notes.agnd +``` + +If the path has no extension, Rogue Agenda appends `.agnd`. A new document is +initialized with useful categories and saved views. To explore a populated file: + +```sh +cargo run -- demo.agnd --demo +``` + +Noninteractive transfer commands are also available: + +```sh +cargo run -- notes.agnd --import inbox.txt +cargo run -- notes.agnd --export csv --output notes.csv +cargo run -- notes.agnd --export json --output notes.json +cargo run -- notes.agnd --export markdown --view Tasks --output tasks.md +cargo run -- notes.agnd --export html --view Datebook --output agenda.html +cargo run -- notes.agnd --export ics --output tasks.ics +cargo run -- imported.agnd --import tasks.ics +``` + +## Essential keys + +| Key | Action | +| --- | --- | +| Arrows or `h j k l` | Move selection / scroll | +| `Insert` or `n` | Add a free-form item | +| `F2` or `e` | Edit the selected item | +| `F3` or `c` | Assign categories | +| `F4` or `d` | Toggle done | +| `F5` | Edit the item's note | +| `F6` or `p` | Edit item properties | +| `F7` or `Space` | Mark for bulk operations | +| `F8` or `v` | View Manager | +| `F9` | Category Manager | +| `F10` or `m` | Command menu | +| `r` | Edit prerequisites for the selected item | +| `/` | Search the current view | +| `[` / `]` | Previous / next saved view | +| `Ctrl-S` | Save/checkpoint | +| `?` or `F1` | Help | +| `q` | Quit | + +Inside forms, `Tab` moves between fields, `Enter` accepts, and `Esc` cancels. +Mouse clicks select rows, switch views, and activate the bottom command strip. + +## Designing live views + +Open the View Manager with `F8`. Press `n` to create a view, `e` to edit one, +`Ctrl-Up`/`Ctrl-Down` to reorder it, or `Delete` to remove it. + +Boolean filters use `and`, `or`, `not`, and parentheses. Available predicates +include `text~rust`, `note~budget`, `category=Work`, `priority<=2`, `value>100`, +`done`, `open`, `dated`, `undated`, `due<=7d`, `overdue`, `has-note`, `alarm`, +`recurring`, and `nonrecurring`. +For example: + +```text +category=Work and open and (priority<=2 or due<=3d) +``` + +Columns use `field:width:heading:aggregate`, separated by commas. Fields are +`item`, `categories`, `when`, `priority`, `note`, `value`, `done`, `alarm`, +`recurrence`, `created`, and `updated`; aggregates are `none`, `sum`, `avg`, +`count`, `min`, or `max`. + +```text +item:55:Action:none,when:25:Due:none,value:20:Cost:sum +``` + +Sections use `heading|filter`, separated by semicolons. Add `|collapsed` to make +a section initially collapsed. + +```text +Urgent|priority=1;Upcoming|due<=7d;Someday|undated|collapsed +``` + +## Automatic category rules + +Open the Category Manager with `F9`, then create or edit a category. In addition +to simple match phrases, each category can have a Boolean rule condition and an +action. The form previews how many existing items match before saving. + +Actions use `action:value`: `assign:Category`, `exclude:Category`, `priority:1`, +`value:125.50`, `when:tomorrow at 3pm`, `alarm:tomorrow at 2pm`, +`repeat:weekdays`, or `done:true`. `assign:` with no value assigns the category +being edited. Rules are reapplied to existing information and whenever an item +changes. + +## Recurring work + +Open item properties with `F6` and set a recurrence to `daily`, `weekdays`, +`weekly`, `monthly`, `yearly`, or a rule such as `every 2 weeks`. The item must +have a When date. Marking it done preserves the completed occurrence and creates +the next open occurrence. Rogue Agenda shifts its alarm by the same interval and +copies its note, priority, numeric value, recurrence, and explicit category +assignments. iCalendar import and export maps these schedules to `RRULE`. + +## Design notes + +Rogue Agenda is not file-format compatible with Lotus Agenda and uses no Lotus +code. It reinterprets the interaction model around items, categories, views, +automatic assignment, and information sifting. See [PROJECT.md](PROJECT.md) for +the research record and detailed implementation matrix. + +## License + +MIT. See [LICENSE](LICENSE). diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..566e34f --- /dev/null +++ b/src/app.rs @@ -0,0 +1,812 @@ +use std::{collections::HashSet, path::PathBuf}; + +use anyhow::Result; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; +use ratatui::layout::Rect; + +use crate::{ + db::Database, + model::{Category, Item, ItemChanges, ViewDef}, + parser::extract_when, +}; + +#[derive(Debug, Clone)] +pub enum InputKind { + NewItem, + EditItem(i64), + Note(i64), + Search, +} + +#[derive(Debug, Clone)] +pub struct InputState { + pub kind: InputKind, + pub title: String, + pub value: String, + pub multiline: bool, +} + +#[derive(Debug, Clone)] +pub struct PropsState { + pub item_id: i64, + pub values: [String; 5], + pub field: usize, +} + +#[derive(Debug, Clone)] +pub enum FormKind { + Category(Option), + View(Option), +} + +#[derive(Debug, Clone)] +pub struct FormState { + pub kind: FormKind, + pub values: Vec, + pub field: usize, +} + +#[derive(Debug, Clone)] +pub enum Mode { + Normal, + Help, + Input(InputState), + Properties(PropsState), + Form(FormState), + Dependencies { + item_id: i64, + choices: Vec, + selected: usize, + }, + Categories, + Views, + CategoryManager, + Menu, +} + +pub struct App { + pub db: Database, + pub path: PathBuf, + pub views: Vec, + pub view_index: usize, + pub items: Vec, + pub selected: usize, + pub scroll: usize, + pub search: String, + pub marked: HashSet, + pub categories: Vec, + pub category_selected: usize, + pub view_selected: usize, + pub mode: Mode, + pub status: String, + pub should_quit: bool, + pub item_rows: Vec<(Rect, i64)>, + pub command_regions: Vec, + pub view_regions: Vec, +} + +impl App { + pub fn new(db: Database, path: PathBuf) -> Result { + let views = db.views()?; + let categories = db.categories()?; + let mut app = Self { + db, + path, + views, + view_index: 0, + items: vec![], + selected: 0, + scroll: 0, + search: String::new(), + marked: HashSet::new(), + categories, + category_selected: 0, + view_selected: 0, + mode: Mode::Normal, + status: "Ready — capture first, organize later".into(), + should_quit: false, + item_rows: vec![], + command_regions: vec![], + view_regions: vec![], + }; + app.refresh()?; + Ok(app) + } + + pub fn current_view(&self) -> &ViewDef { + &self.views[self.view_index] + } + pub fn selected_item(&self) -> Option<&Item> { + self.items.get(self.selected) + } + + pub fn refresh(&mut self) -> Result<()> { + let keep = self.selected_item().map(|i| i.id); + self.views = self.db.views()?; + self.categories = self.db.categories()?; + self.view_index = self.view_index.min(self.views.len().saturating_sub(1)); + self.items = self.db.items(&self.views[self.view_index], &self.search)?; + self.selected = keep + .and_then(|id| self.items.iter().position(|i| i.id == id)) + .unwrap_or(self.selected.min(self.items.len().saturating_sub(1))); + if self.selected < self.scroll { + self.scroll = self.selected; + } + Ok(()) + } + + fn chosen_ids(&self) -> Vec { + if self.marked.is_empty() { + self.selected_item().map(|i| vec![i.id]).unwrap_or_default() + } else { + self.marked.iter().copied().collect() + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> Result<()> { + match self.mode.clone() { + Mode::Normal => self.handle_normal(key), + Mode::Help => { + self.mode = Mode::Normal; + Ok(()) + } + Mode::Input(_) => self.handle_input(key), + Mode::Properties(_) => self.handle_properties(key), + Mode::Form(_) => self.handle_form(key), + Mode::Dependencies { .. } => self.handle_dependencies(key), + Mode::Categories => self.handle_categories(key), + Mode::Views => self.handle_views(key), + Mode::CategoryManager => self.handle_category_manager(key), + Mode::Menu => self.handle_menu(key), + } + } + + fn handle_normal(&mut self, key: KeyEvent) -> Result<()> { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { + self.db.checkpoint()?; + self.status = "Saved and checkpointed".into(); + return Ok(()); + } + match key.code { + KeyCode::Char('q') => self.should_quit = true, + KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), + KeyCode::PageUp => self.move_selection(-10), + KeyCode::PageDown => self.move_selection(10), + KeyCode::Home => self.selected = 0, + KeyCode::End => self.selected = self.items.len().saturating_sub(1), + KeyCode::Insert | KeyCode::Char('n') => { + self.open_input(InputKind::NewItem, "New item", String::new(), false) + } + KeyCode::F(2) | KeyCode::Char('e') | KeyCode::Enter => self.edit_selected(), + KeyCode::F(3) | KeyCode::Char('c') => { + if self.selected_item().is_some() { + self.category_selected = 0; + self.mode = Mode::Categories; + } + } + KeyCode::F(4) | KeyCode::Char('d') => self.toggle_done()?, + KeyCode::F(5) => self.edit_note(), + KeyCode::F(6) | KeyCode::Char('p') => self.edit_properties(), + KeyCode::F(7) | KeyCode::Char(' ') => self.toggle_mark(), + KeyCode::F(8) | KeyCode::Char('v') => { + self.view_selected = self.view_index; + self.mode = Mode::Views; + } + KeyCode::F(9) => { + self.category_selected = 0; + self.mode = Mode::CategoryManager; + } + KeyCode::F(10) | KeyCode::Char('m') => self.mode = Mode::Menu, + KeyCode::F(1) | KeyCode::Char('?') => self.mode = Mode::Help, + KeyCode::Char('r') => self.open_dependencies()?, + KeyCode::Char('/') => self.open_input( + InputKind::Search, + "Search current view", + self.search.clone(), + false, + ), + KeyCode::Char(']') => self.switch_view(1)?, + KeyCode::Char('[') => self.switch_view(-1)?, + KeyCode::Delete => self.discard_or_restore()?, + KeyCode::Esc if !self.search.is_empty() => { + self.search.clear(); + self.refresh()?; + self.status = "Search cleared".into(); + } + _ => {} + } + Ok(()) + } + + fn move_selection(&mut self, delta: isize) { + if self.items.is_empty() { + return; + } + self.selected = + (self.selected as isize + delta).clamp(0, self.items.len() as isize - 1) as usize; + } + fn switch_view(&mut self, delta: isize) -> Result<()> { + let len = self.views.len() as isize; + self.view_index = (self.view_index as isize + delta).rem_euclid(len) as usize; + self.selected = 0; + self.scroll = 0; + self.search.clear(); + self.refresh()?; + self.status = format!("View: {}", self.current_view().name); + Ok(()) + } + fn open_input(&mut self, kind: InputKind, title: &str, value: String, multiline: bool) { + self.mode = Mode::Input(InputState { + kind, + title: title.into(), + value, + multiline, + }); + } + fn edit_selected(&mut self) { + if let Some(i) = self.selected_item() { + self.open_input( + InputKind::EditItem(i.id), + "Edit item", + i.text.clone(), + false, + ); + } + } + fn edit_note(&mut self) { + if let Some(i) = self.selected_item() { + self.open_input( + InputKind::Note(i.id), + "Item note (Ctrl-S saves; Enter adds a line)", + i.note.clone(), + true, + ); + } + } + fn edit_properties(&mut self) { + if let Some(i) = self.selected_item() { + self.mode = Mode::Properties(PropsState { + item_id: i.id, + values: [ + i.priority.to_string(), + i.when_at.clone().unwrap_or_default(), + i.alarm_at.clone().unwrap_or_default(), + i.numeric_value.map(|v| v.to_string()).unwrap_or_default(), + i.recurrence.clone(), + ], + field: 0, + }); + } + } + fn toggle_mark(&mut self) { + if let Some(id) = self.selected_item().map(|i| i.id) { + if !self.marked.remove(&id) { + self.marked.insert(id); + } + self.status = format!("{} item(s) marked", self.marked.len()); + } + } + fn toggle_done(&mut self) -> Result<()> { + let ids = self.chosen_ids(); + if !ids.is_empty() { + self.db.toggle_done(&ids)?; + self.marked.clear(); + self.refresh()?; + self.status = format!("Updated {} item(s)", ids.len()); + } + Ok(()) + } + fn discard_or_restore(&mut self) -> Result<()> { + let ids = self.chosen_ids(); + if ids.is_empty() { + return Ok(()); + } + let restore = self.current_view().kind == "trash"; + self.db.discard(&ids, !restore)?; + self.marked.clear(); + self.refresh()?; + self.status = if restore { + format!("Restored {} item(s)", ids.len()) + } else { + format!("Moved {} item(s) to Trash", ids.len()) + }; + Ok(()) + } + + fn handle_input(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Input(mut input) = self.mode.clone() else { + return Ok(()); + }; + if key.code == KeyCode::Esc { + self.mode = Mode::Normal; + self.status = "Canceled".into(); + return Ok(()); + } + if input.multiline + && key.modifiers.contains(KeyModifiers::CONTROL) + && key.code == KeyCode::Char('s') + { + return self.accept_input(input); + } + match key.code { + KeyCode::Enter if input.multiline => input.value.push('\n'), + KeyCode::Enter => return self.accept_input(input), + KeyCode::Backspace => { + input.value.pop(); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + input.value.push(c) + } + _ => {} + } + self.mode = Mode::Input(input); + Ok(()) + } + fn accept_input(&mut self, input: InputState) -> Result<()> { + match input.kind { + InputKind::NewItem => { + self.db.add_item(&input.value)?; + self.status = "Item captured and automatically filed".into(); + } + InputKind::EditItem(id) => { + self.db.update_item( + id, + &ItemChanges { + text: Some(input.value), + ..Default::default() + }, + )?; + self.status = "Item updated and filing re-evaluated".into(); + } + InputKind::Note(id) => { + self.db.update_item( + id, + &ItemChanges { + note: Some(input.value), + ..Default::default() + }, + )?; + self.status = "Note saved".into(); + } + InputKind::Search => { + self.search = input.value; + self.status = if self.search.is_empty() { + "Search cleared".into() + } else { + format!("Filtering for “{}”", self.search) + }; + } + } + self.mode = Mode::Normal; + self.refresh()?; + Ok(()) + } + + fn handle_properties(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Properties(mut props) = self.mode.clone() else { + return Ok(()); + }; + match key.code { + KeyCode::Esc => { + self.mode = Mode::Normal; + return Ok(()); + } + KeyCode::Tab | KeyCode::Down => props.field = (props.field + 1) % 5, + KeyCode::BackTab | KeyCode::Up => props.field = (props.field + 4) % 5, + KeyCode::Backspace => { + props.values[props.field].pop(); + } + KeyCode::Enter => { + let priority = props.values[0].parse().unwrap_or(3); + let when_at = normalize_date(&props.values[1]); + let alarm_at = normalize_date(&props.values[2]); + let numeric_value = if props.values[3].trim().is_empty() { + None + } else { + props.values[3].parse().ok() + }; + self.db.update_item( + props.item_id, + &ItemChanges { + priority: Some(priority), + when_at: Some(when_at), + alarm_at: Some(alarm_at), + numeric_value: Some(numeric_value), + recurrence: Some(props.values[4].clone()), + ..Default::default() + }, + )?; + self.mode = Mode::Normal; + self.refresh()?; + self.status = "Properties saved".into(); + return Ok(()); + } + KeyCode::Char(c) => props.values[props.field].push(c), + _ => {} + } + self.mode = Mode::Properties(props); + Ok(()) + } + fn handle_categories(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Enter => self.mode = Mode::Normal, + KeyCode::Up | KeyCode::Char('k') => { + self.category_selected = self.category_selected.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.category_selected = + (self.category_selected + 1).min(self.categories.len().saturating_sub(1)) + } + KeyCode::Char(' ') => { + if let (Some(item), Some(cat)) = ( + self.selected_item(), + self.categories.get(self.category_selected), + ) { + self.db.toggle_assignment(item.id, cat.id)?; + self.refresh()?; + self.status = "Category assignment changed".into(); + } + } + _ => {} + } + Ok(()) + } + fn handle_views(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => self.mode = Mode::Normal, + KeyCode::Up if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(view) = self.views.get(self.view_selected) { + let id = view.id; + self.db.move_view(id, -1)?; + self.refresh()?; + self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0); + self.status = "View moved up".into(); + } + } + KeyCode::Down if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(view) = self.views.get(self.view_selected) { + let id = view.id; + self.db.move_view(id, 1)?; + self.refresh()?; + self.view_selected = self.views.iter().position(|v| v.id == id).unwrap_or(0); + self.status = "View moved down".into(); + } + } + KeyCode::Up | KeyCode::Char('k') => { + self.view_selected = self.view_selected.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.view_selected = + (self.view_selected + 1).min(self.views.len().saturating_sub(1)) + } + KeyCode::Enter => { + self.view_index = self.view_selected; + self.selected = 0; + self.scroll = 0; + self.search.clear(); + self.mode = Mode::Normal; + self.refresh()?; + self.status = format!("View: {}", self.current_view().name); + } + KeyCode::Char('n') | KeyCode::Insert => self.open_view_form(None), + KeyCode::Char('e') | KeyCode::F(2) => { + if let Some(view) = self.views.get(self.view_selected).cloned() { + self.open_view_form(Some(view)); + } + } + KeyCode::Delete => { + if let Some(view) = self.views.get(self.view_selected) { + self.db.delete_view(view.id)?; + self.view_index = 0; + self.view_selected = 0; + self.refresh()?; + self.status = "View deleted".into(); + } + } + _ => {} + } + Ok(()) + } + fn handle_category_manager(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => self.mode = Mode::Normal, + KeyCode::Up | KeyCode::Char('k') => { + self.category_selected = self.category_selected.saturating_sub(1) + } + KeyCode::Down | KeyCode::Char('j') => { + self.category_selected = + (self.category_selected + 1).min(self.categories.len().saturating_sub(1)) + } + KeyCode::Char('n') | KeyCode::Insert => self.open_category_form(None), + KeyCode::Char('e') | KeyCode::Enter | KeyCode::F(2) => { + if let Some(category) = self.categories.get(self.category_selected).cloned() { + self.open_category_form(Some(category)); + } + } + KeyCode::Delete => { + if let Some(category) = self.categories.get(self.category_selected) { + self.db.delete_category(category.id)?; + self.category_selected = self.category_selected.saturating_sub(1); + self.refresh()?; + self.status = "Category deleted; its items were preserved".into(); + } + } + _ => {} + } + Ok(()) + } + fn handle_menu(&mut self, key: KeyEvent) -> Result<()> { + self.mode = Mode::Normal; + match key.code { + KeyCode::Char('n') => { + self.open_input(InputKind::NewItem, "New item", String::new(), false) + } + KeyCode::Char('s') => { + self.db.checkpoint()?; + self.status = "Saved and checkpointed".into(); + } + KeyCode::Char('h') => self.mode = Mode::Help, + KeyCode::Char('r') => self.open_dependencies()?, + KeyCode::Char('q') => self.should_quit = true, + _ => {} + } + Ok(()) + } + + fn open_category_form(&mut self, category: Option) { + let id = category.as_ref().map(|c| c.id); + let rule = + id.and_then(|category_id| self.db.category_rules(category_id).ok()?.into_iter().next()); + let condition = rule + .as_ref() + .map(|r| r.condition_expr.clone()) + .unwrap_or_default(); + let action = rule + .map(|r| format!("{}:{}", r.action_kind, r.action_value)) + .unwrap_or_default(); + let values = if let Some(c) = category { + let parent = c + .parent_id + .and_then(|id| self.categories.iter().find(|p| p.id == id)) + .map(|p| p.name.clone()) + .unwrap_or_default(); + vec![ + c.name, + parent, + c.kind, + c.match_text, + yes_no(c.exclusive), + condition, + action, + ] + } else { + vec![ + String::new(), + String::new(), + "standard".into(), + String::new(), + "no".into(), + String::new(), + String::new(), + ] + }; + self.mode = Mode::Form(FormState { + kind: FormKind::Category(id), + values, + field: 0, + }); + } + + fn open_view_form(&mut self, view: Option) { + let id = view.as_ref().map(|v| v.id); + let values = if let Some(v) = view { + let columns = v.columns_spec(); + let sections = v.sections_spec(); + vec![ + v.name, + v.kind, + v.filter_value, + v.filter_expr, + v.sort_key, + yes_no(v.show_done), + columns, + sections, + ] + } else { + vec![ + String::new(), + "list".into(), + String::new(), + String::new(), + "manual".into(), + "yes".into(), + "item:48:Items:none,categories:27:Categories:none,when:20:When:none,priority:5:P:none".into(), + String::new(), + ] + }; + self.mode = Mode::Form(FormState { + kind: FormKind::View(id), + values, + field: 0, + }); + } + + fn handle_form(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Form(mut form) = self.mode.clone() else { + return Ok(()); + }; + match key.code { + KeyCode::Esc => { + self.mode = match form.kind { + FormKind::Category(_) => Mode::CategoryManager, + FormKind::View(_) => Mode::Views, + }; + return Ok(()); + } + KeyCode::Tab | KeyCode::Down => form.field = (form.field + 1) % form.values.len(), + KeyCode::BackTab | KeyCode::Up => { + form.field = (form.field + form.values.len() - 1) % form.values.len() + } + KeyCode::Backspace => { + form.values[form.field].pop(); + } + KeyCode::Enter => { + match form.kind { + FormKind::Category(id) => { + let parent_id = if form.values[1].trim().is_empty() { + None + } else { + self.categories + .iter() + .find(|c| c.name.eq_ignore_ascii_case(form.values[1].trim())) + .map(|c| c.id) + }; + let category_id = self.db.save_category( + id, + &form.values[0], + parent_id, + form.values[2].trim(), + &form.values[3], + parse_yes(&form.values[4]), + )?; + self.db + .save_primary_rule(category_id, &form.values[5], &form.values[6])?; + self.db.reapply_automatic_assignments()?; + self.refresh()?; + self.mode = Mode::CategoryManager; + self.status = "Category definition saved".into(); + } + FormKind::View(id) => { + let saved = self.db.save_view_design( + id, + &form.values[0], + form.values[1].trim(), + &form.values[2], + form.values[4].trim(), + parse_yes(&form.values[5]), + &form.values[3], + &form.values[6], + &form.values[7], + )?; + self.refresh()?; + self.view_selected = + self.views.iter().position(|v| v.id == saved).unwrap_or(0); + self.mode = Mode::Views; + self.status = "Live view saved".into(); + } + } + return Ok(()); + } + KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { + form.values[form.field].push(c) + } + _ => {} + } + self.mode = Mode::Form(form); + Ok(()) + } + + fn open_dependencies(&mut self) -> Result<()> { + let Some(item_id) = self.selected_item().map(|i| i.id) else { + return Ok(()); + }; + let Some(all) = self.views.iter().find(|v| v.kind == "list") else { + return Ok(()); + }; + let choices = self + .db + .items(all, "")? + .into_iter() + .filter(|i| i.id != item_id) + .collect(); + self.mode = Mode::Dependencies { + item_id, + choices, + selected: 0, + }; + Ok(()) + } + + fn handle_dependencies(&mut self, key: KeyEvent) -> Result<()> { + let Mode::Dependencies { + item_id, + choices, + mut selected, + } = self.mode.clone() + else { + return Ok(()); + }; + match key.code { + KeyCode::Esc | KeyCode::Enter => { + self.mode = Mode::Normal; + return Ok(()); + } + KeyCode::Up | KeyCode::Char('k') => selected = selected.saturating_sub(1), + KeyCode::Down | KeyCode::Char('j') => { + selected = (selected + 1).min(choices.len().saturating_sub(1)) + } + KeyCode::Char(' ') => { + if let Some(choice) = choices.get(selected) { + self.db.toggle_dependency(item_id, choice.id)?; + self.status = "Prerequisite changed".into(); + } + } + _ => {} + } + self.mode = Mode::Dependencies { + item_id, + choices, + selected, + }; + Ok(()) + } + + pub fn handle_mouse(&mut self, event: MouseEvent) -> Result<()> { + if !matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) { + return Ok(()); + } + let (x, y) = (event.column, event.row); + if let Some((_, id)) = self.item_rows.iter().find(|(r, _)| inside(*r, x, y)) { + if let Some(pos) = self.items.iter().position(|i| i.id == *id) { + self.selected = pos; + } + return Ok(()); + } + if let Some(i) = self.command_regions.iter().position(|r| inside(*r, x, y)) { + return self.handle_key(KeyEvent::new(KeyCode::F((i + 1) as u8), KeyModifiers::NONE)); + } + if let Some(i) = self.view_regions.iter().position(|r| inside(*r, x, y)) { + self.view_index = i; + self.selected = 0; + self.scroll = 0; + self.search.clear(); + self.refresh()?; + } + Ok(()) + } +} + +fn yes_no(value: bool) -> String { + if value { "yes" } else { "no" }.into() +} +fn parse_yes(value: &str) -> bool { + matches!( + value.trim().to_lowercase().as_str(), + "y" | "yes" | "true" | "1" | "on" + ) +} + +fn normalize_date(value: &str) -> Option { + let s = value.trim(); + if s.is_empty() { + None + } else if chrono::DateTime::parse_from_rfc3339(s).is_ok() { + Some(s.into()) + } else { + extract_when(&format!("on {s}")) + } +} +fn inside(r: Rect, x: u16, y: u16) -> bool { + x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..9807bed --- /dev/null +++ b/src/db.rs @@ -0,0 +1,1913 @@ +use std::{fs, path::Path}; + +use anyhow::{Context, Result, bail}; +use chrono::{DateTime, Duration, Local, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use rusqlite::{Connection, OptionalExtension, params}; + +use crate::filter; +use crate::model::{Category, CategoryRule, Item, ItemChanges, ViewColumn, ViewDef, ViewSection}; +use crate::parser::{extract_when, next_occurrence}; + +pub struct Database { + conn: Connection, +} + +impl Database { + pub fn open(path: &Path) -> Result { + let conn = + Connection::open(path).with_context(|| format!("could not open {}", path.display()))?; + conn.pragma_update(None, "foreign_keys", "ON")?; + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + let mut db = Self { conn }; + db.migrate()?; + db.seed_defaults()?; + Ok(db) + } + + fn migrate(&mut self) -> Result<()> { + self.conn.execute_batch( + "BEGIN; + CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS items ( + id INTEGER PRIMARY KEY, + text TEXT NOT NULL, + note TEXT NOT NULL DEFAULT '', + priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5), + when_at TEXT, + done_at TEXT, + alarm_at TEXT, + numeric_value REAL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + discarded INTEGER NOT NULL DEFAULT 0 CHECK(discarded IN (0,1)) + ); + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + parent_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + kind TEXT NOT NULL DEFAULT 'standard', + match_text TEXT NOT NULL DEFAULT '', + exclusive INTEGER NOT NULL DEFAULT 0 CHECK(exclusive IN (0,1)), + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS item_categories ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + assignment TEXT NOT NULL DEFAULT 'explicit', + PRIMARY KEY(item_id, category_id) + ); + CREATE TABLE IF NOT EXISTS dependencies ( + item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + prerequisite_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE, + PRIMARY KEY(item_id, prerequisite_id), + CHECK(item_id <> prerequisite_id) + ); + CREATE TABLE IF NOT EXISTS views ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + kind TEXT NOT NULL DEFAULT 'list', + filter_value TEXT NOT NULL DEFAULT '', + sort_key TEXT NOT NULL DEFAULT 'manual', + show_done INTEGER NOT NULL DEFAULT 1, + sort_order INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_items_when ON items(when_at); + CREATE INDEX IF NOT EXISTS idx_items_done ON items(done_at); + CREATE INDEX IF NOT EXISTS idx_item_categories_category ON item_categories(category_id); + COMMIT;" + )?; + let version: i64 = self + .conn + .query_row("PRAGMA user_version", [], |r| r.get(0))?; + if version < 2 { + let has_filter_expr = { + let mut stmt = self.conn.prepare("PRAGMA table_info(views)")?; + let names = stmt + .query_map([], |r| r.get::<_, String>(1))? + .collect::>>()?; + names.iter().any(|name| name == "filter_expr") + }; + if !has_filter_expr { + self.conn.execute_batch( + "ALTER TABLE views ADD COLUMN filter_expr TEXT NOT NULL DEFAULT '';", + )?; + } + } + self.conn.execute_batch( + "CREATE TABLE IF NOT EXISTS view_columns ( + id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, + field TEXT NOT NULL, heading TEXT NOT NULL, width INTEGER NOT NULL DEFAULT 20, + aggregate TEXT NOT NULL DEFAULT 'none', sort_order INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS view_sections ( + id INTEGER PRIMARY KEY, view_id INTEGER NOT NULL REFERENCES views(id) ON DELETE CASCADE, + heading TEXT NOT NULL, filter_expr TEXT NOT NULL DEFAULT '', collapsed INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0); + CREATE TABLE IF NOT EXISTS category_rules ( + id INTEGER PRIMARY KEY, category_id INTEGER NOT NULL REFERENCES categories(id) ON DELETE CASCADE, + condition_expr TEXT NOT NULL, action_kind TEXT NOT NULL, action_value TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0);", + )?; + if version < 2 { + self.conn.pragma_update(None, "user_version", 2)?; + } + if version < 3 { + let has_recurrence = { + let mut stmt = self.conn.prepare("PRAGMA table_info(items)")?; + let names = stmt + .query_map([], |r| r.get::<_, String>(1))? + .collect::>>()?; + names.iter().any(|name| name == "recurrence") + }; + if !has_recurrence { + self.conn.execute_batch( + "ALTER TABLE items ADD COLUMN recurrence TEXT NOT NULL DEFAULT '';", + )?; + } + self.conn.pragma_update(None, "user_version", 3)?; + } + Ok(()) + } + + fn seed_defaults(&mut self) -> Result<()> { + let tx = self.conn.transaction()?; + let count: i64 = tx.query_row("SELECT COUNT(*) FROM categories", [], |r| r.get(0))?; + if count == 0 { + tx.execute("INSERT INTO categories(name, match_text, sort_order) VALUES('Tasks', 'todo,to-do,task,call,meet,review,send,buy', 0)", [])?; + let tasks = tx.last_insert_rowid(); + tx.execute("INSERT INTO categories(name, parent_id, match_text, sort_order) VALUES('Calls', ?1, 'call,phone,ring', 0)", [tasks])?; + tx.execute("INSERT INTO categories(name, parent_id, match_text, sort_order) VALUES('Meetings', ?1, 'meet,meeting,appointment', 1)", [tasks])?; + tx.execute( + "INSERT INTO categories(name, match_text, sort_order) VALUES('People', '', 1)", + [], + )?; + tx.execute( + "INSERT INTO categories(name, match_text, sort_order) VALUES('Projects', '', 2)", + [], + )?; + tx.execute( + "INSERT INTO categories(name, kind, sort_order) VALUES('When', 'date', 3)", + [], + )?; + tx.execute( + "INSERT INTO categories(name, kind, sort_order) VALUES('Priority', 'numeric', 4)", + [], + )?; + } + let views: i64 = tx.query_row("SELECT COUNT(*) FROM views", [], |r| r.get(0))?; + if views == 0 { + for (name, kind, filter, sort, done, order) in [ + ("All Items", "list", "", "manual", 1, 0), + ("Tasks", "category", "Tasks", "when", 0, 1), + ("Upcoming", "upcoming", "7", "when", 0, 2), + ("Recently Done", "done", "14", "done", 1, 3), + ("Datebook", "datebook", "30", "when", 1, 4), + ("Trash", "trash", "", "updated", 1, 5), + ] { + tx.execute("INSERT INTO views(name, kind, filter_value, sort_key, show_done, sort_order) VALUES(?1,?2,?3,?4,?5,?6)", params![name,kind,filter,sort,done,order])?; + } + } + tx.execute( + "INSERT OR IGNORE INTO meta(key,value) VALUES('title','Rogue Agenda')", + [], + )?; + tx.commit()?; + Ok(()) + } + + pub fn views(&self) -> Result> { + let mut stmt = self.conn.prepare("SELECT id,name,kind,filter_value,sort_key,show_done,filter_expr FROM views ORDER BY sort_order,name")?; + let mut views = stmt + .query_map([], |r| { + Ok(ViewDef { + id: r.get(0)?, + name: r.get(1)?, + kind: r.get(2)?, + filter_value: r.get(3)?, + sort_key: r.get(4)?, + show_done: r.get::<_, i64>(5)? != 0, + filter_expr: r.get(6)?, + columns: vec![], + sections: vec![], + }) + })? + .collect::>>()?; + drop(stmt); + for view in &mut views { + view.columns = self.view_columns(view.id)?; + view.sections = self.view_sections(view.id)?; + } + Ok(views) + } + + fn view_columns(&self, view_id: i64) -> Result> { + let mut stmt=self.conn.prepare("SELECT field,heading,width,aggregate FROM view_columns WHERE view_id=?1 ORDER BY sort_order,id")?; + let columns = stmt + .query_map([view_id], |r| { + Ok(ViewColumn { + field: r.get(0)?, + heading: r.get(1)?, + width: r.get::<_, u16>(2)?, + aggregate: r.get(3)?, + }) + })? + .collect::>>()?; + if columns.is_empty() { + Ok(default_columns()) + } else { + Ok(columns) + } + } + + fn view_sections(&self, view_id: i64) -> Result> { + let mut stmt=self.conn.prepare("SELECT id,heading,filter_expr,collapsed FROM view_sections WHERE view_id=?1 ORDER BY sort_order,id")?; + Ok(stmt + .query_map([view_id], |r| { + Ok(ViewSection { + id: r.get(0)?, + heading: r.get(1)?, + filter_expr: r.get(2)?, + collapsed: r.get::<_, i64>(3)? != 0, + }) + })? + .collect::>()?) + } + + pub fn categories(&self) -> Result> { + let mut stmt = self.conn.prepare("SELECT id,name,parent_id,kind,match_text,exclusive FROM categories ORDER BY parent_id IS NOT NULL,parent_id,sort_order,name COLLATE NOCASE")?; + Ok(stmt + .query_map([], Self::category_row)? + .collect::>()?) + } + + fn category_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Category { + id: r.get(0)?, + name: r.get(1)?, + parent_id: r.get(2)?, + kind: r.get(3)?, + match_text: r.get(4)?, + exclusive: r.get::<_, i64>(5)? != 0, + }) + } + + pub fn save_category( + &self, + id: Option, + name: &str, + parent_id: Option, + kind: &str, + match_text: &str, + exclusive: bool, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + bail!("category name cannot be empty"); + } + if !matches!(kind, "standard" | "date" | "numeric") { + bail!("category kind must be standard, date, or numeric"); + } + if id.is_some() && id == parent_id { + bail!("a category cannot be its own parent"); + } + if let (Some(id), Some(parent)) = (id, parent_id) { + let cycle: bool = self.conn.query_row( + "WITH RECURSIVE descendants(id) AS ( + SELECT id FROM categories WHERE parent_id=?1 + UNION ALL SELECT c.id FROM categories c JOIN descendants d ON c.parent_id=d.id + ) SELECT EXISTS(SELECT 1 FROM descendants WHERE id=?2)", + params![id, parent], + |r| r.get(0), + )?; + if cycle { + bail!("category hierarchy would contain a cycle"); + } + } + if let Some(id) = id { + self.conn.execute( + "UPDATE categories SET name=?1,parent_id=?2,kind=?3,match_text=?4,exclusive=?5 WHERE id=?6", + params![name, parent_id, kind, match_text.trim(), exclusive as i64, id], + )?; + Ok(id) + } else { + self.conn.execute("INSERT INTO categories(name,parent_id,kind,match_text,exclusive,sort_order) VALUES(?1,?2,?3,?4,?5,(SELECT COALESCE(MAX(sort_order),0)+1 FROM categories))",params![name,parent_id,kind,match_text.trim(),exclusive as i64])?; + Ok(self.conn.last_insert_rowid()) + } + } + + pub fn delete_category(&self, id: i64) -> Result<()> { + self.conn + .execute("DELETE FROM categories WHERE id=?1", [id])?; + Ok(()) + } + + pub fn category_rules(&self, category_id: i64) -> Result> { + let mut stmt=self.conn.prepare("SELECT id,category_id,condition_expr,action_kind,action_value,enabled FROM category_rules WHERE category_id=?1 ORDER BY sort_order,id")?; + Ok(stmt + .query_map([category_id], |r| { + Ok(CategoryRule { + id: r.get(0)?, + category_id: r.get(1)?, + condition_expr: r.get(2)?, + action_kind: r.get(3)?, + action_value: r.get(4)?, + enabled: r.get::<_, i64>(5)? != 0, + }) + })? + .collect::>()?) + } + + pub fn save_primary_rule( + &self, + category_id: i64, + condition_expr: &str, + action_spec: &str, + ) -> Result<()> { + if condition_expr.trim().is_empty() && action_spec.trim().is_empty() { + self.conn.execute( + "DELETE FROM category_rules WHERE category_id=?1", + [category_id], + )?; + return Ok(()); + } + filter::parse(condition_expr).context("invalid category rule condition")?; + let (kind, value) = parse_action_spec(action_spec)?; + let tx = self.conn.unchecked_transaction()?; + tx.execute( + "DELETE FROM category_rules WHERE category_id=?1", + [category_id], + )?; + tx.execute("INSERT INTO category_rules(category_id,condition_expr,action_kind,action_value,enabled,sort_order) VALUES(?1,?2,?3,?4,1,0)",params![category_id,condition_expr.trim(),kind,value])?; + tx.commit()?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub fn save_view_design( + &mut self, + id: Option, + name: &str, + kind: &str, + filter_value: &str, + sort_key: &str, + show_done: bool, + filter_expr: &str, + columns_spec: &str, + sections_spec: &str, + ) -> Result { + let name = name.trim(); + if name.is_empty() { + bail!("view name cannot be empty"); + } + if !matches!( + kind, + "list" | "category" | "upcoming" | "done" | "datebook" | "trash" + ) { + bail!("unsupported view kind"); + } + if !matches!( + sort_key, + "manual" | "when" | "done" | "priority" | "updated" + ) { + bail!("unsupported view sort key"); + } + filter::parse(filter_expr).context("invalid view filter")?; + let columns = parse_columns_spec(columns_spec)?; + let sections = parse_sections_spec(sections_spec)?; + let tx = self.conn.transaction()?; + let view_id = if let Some(id) = id { + tx.execute("UPDATE views SET name=?1,kind=?2,filter_value=?3,sort_key=?4,show_done=?5,filter_expr=?6 WHERE id=?7",params![name,kind,filter_value.trim(),sort_key,show_done as i64,filter_expr.trim(),id])?; + id + } else { + tx.execute("INSERT INTO views(name,kind,filter_value,sort_key,show_done,sort_order,filter_expr) VALUES(?1,?2,?3,?4,?5,(SELECT COALESCE(MAX(sort_order),0)+1 FROM views),?6)",params![name,kind,filter_value.trim(),sort_key,show_done as i64,filter_expr.trim()])?; + tx.last_insert_rowid() + }; + tx.execute("DELETE FROM view_columns WHERE view_id=?1", [view_id])?; + for (order, column) in columns.iter().enumerate() { + tx.execute("INSERT INTO view_columns(view_id,field,heading,width,aggregate,sort_order) VALUES(?1,?2,?3,?4,?5,?6)",params![view_id,column.field,column.heading,column.width,column.aggregate,order as i64])?; + } + tx.execute("DELETE FROM view_sections WHERE view_id=?1", [view_id])?; + for (order, section) in sections.iter().enumerate() { + tx.execute("INSERT INTO view_sections(view_id,heading,filter_expr,collapsed,sort_order) VALUES(?1,?2,?3,?4,?5)",params![view_id,section.heading,section.filter_expr,section.collapsed as i64,order as i64])?; + } + tx.commit()?; + Ok(view_id) + } + + pub fn delete_view(&self, id: i64) -> Result<()> { + let count: i64 = self + .conn + .query_row("SELECT COUNT(*) FROM views", [], |r| r.get(0))?; + if count <= 1 { + bail!("the last view cannot be deleted"); + } + self.conn.execute("DELETE FROM views WHERE id=?1", [id])?; + Ok(()) + } + + pub fn move_view(&self, id: i64, delta: i64) -> Result<()> { + let views = self.views()?; + let Some(index) = views.iter().position(|v| v.id == id) else { + return Ok(()); + }; + let target = (index as i64 + delta).clamp(0, views.len().saturating_sub(1) as i64) as usize; + if target == index { + return Ok(()); + } + self.conn.execute( + "UPDATE views SET sort_order=?1 WHERE id=?2", + params![target as i64, views[index].id], + )?; + self.conn.execute( + "UPDATE views SET sort_order=?1 WHERE id=?2", + params![index as i64, views[target].id], + )?; + Ok(()) + } + + pub fn dependency_ids(&self, item_id: i64) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT prerequisite_id FROM dependencies WHERE item_id=?1")?; + Ok(stmt + .query_map([item_id], |r| r.get(0))? + .collect::>()?) + } + + pub fn toggle_dependency(&self, item_id: i64, prerequisite_id: i64) -> Result<()> { + if item_id == prerequisite_id { + bail!("an item cannot depend on itself"); + } + let exists: bool = self.conn.query_row( + "SELECT EXISTS(SELECT 1 FROM dependencies WHERE item_id=?1 AND prerequisite_id=?2)", + params![item_id, prerequisite_id], + |r| r.get(0), + )?; + if exists { + self.conn.execute( + "DELETE FROM dependencies WHERE item_id=?1 AND prerequisite_id=?2", + params![item_id, prerequisite_id], + )?; + } else { + let cycle: bool = self.conn.query_row( + "WITH RECURSIVE reach(id) AS ( + SELECT prerequisite_id FROM dependencies WHERE item_id=?1 + UNION SELECT d.prerequisite_id FROM dependencies d JOIN reach r ON d.item_id=r.id + ) SELECT EXISTS(SELECT 1 FROM reach WHERE id=?2)", + params![prerequisite_id, item_id], + |r| r.get(0), + )?; + if cycle { + bail!("dependency would contain a cycle"); + } + self.conn.execute( + "INSERT INTO dependencies(item_id,prerequisite_id) VALUES(?1,?2)", + params![item_id, prerequisite_id], + )?; + } + Ok(()) + } + + pub fn unmet_dependencies(&self, item_id: i64) -> Result { + Ok(self.conn.query_row("SELECT COUNT(*) FROM dependencies d JOIN items p ON p.id=d.prerequisite_id WHERE d.item_id=?1 AND p.done_at IS NULL",[item_id],|r|r.get(0))?) + } + + pub fn add_item(&mut self, text: &str) -> Result { + let text = text.trim(); + if text.is_empty() { + bail!("item text cannot be empty"); + } + let now = Local::now().to_rfc3339(); + let when_at = extract_when(text); + let tx = self.conn.transaction()?; + tx.execute("INSERT INTO items(text,when_at,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?3,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))", params![text,when_at,now])?; + let id = tx.last_insert_rowid(); + Self::auto_assign_tx(&tx, id, text)?; + tx.commit()?; + self.apply_rules_to_item(id)?; + Ok(id) + } + + fn auto_assign_tx(tx: &rusqlite::Transaction<'_>, item_id: i64, text: &str) -> Result<()> { + tx.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND assignment='automatic'", + [item_id], + )?; + let lower = text.to_lowercase(); + let mut stmt = tx.prepare("SELECT id,match_text FROM categories WHERE match_text <> ''")?; + let matches: Vec<(i64, String)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()?; + drop(stmt); + for (cat, phrases) in matches { + if phrases + .split(',') + .map(str::trim) + .filter(|p| !p.is_empty()) + .any(|p| lower.contains(&p.to_lowercase())) + { + tx.execute("INSERT OR IGNORE INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'automatic')", params![item_id,cat])?; + } + } + Ok(()) + } + + pub fn reapply_automatic_assignments(&mut self) -> Result<()> { + let tx = self.conn.transaction()?; + let items: Vec<(i64, String)> = { + let mut stmt = tx.prepare("SELECT id,text FROM items WHERE discarded=0")?; + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::>()? + }; + for (id, text) in items { + Self::auto_assign_tx(&tx, id, &text)?; + } + tx.commit()?; + self.apply_rules_to_all()?; + Ok(()) + } + + pub fn update_item(&mut self, id: i64, changes: &ItemChanges) -> Result<()> { + let tx = self.conn.transaction()?; + if let Some(text) = &changes.text { + if text.trim().is_empty() { + bail!("item text cannot be empty"); + } + tx.execute( + "UPDATE items SET text=?1,updated_at=?2 WHERE id=?3", + params![text.trim(), Local::now().to_rfc3339(), id], + )?; + Self::auto_assign_tx(&tx, id, text)?; + } + if let Some(note) = &changes.note { + tx.execute( + "UPDATE items SET note=?1,updated_at=?2 WHERE id=?3", + params![note, Local::now().to_rfc3339(), id], + )?; + } + if let Some(priority) = changes.priority { + tx.execute( + "UPDATE items SET priority=?1,updated_at=?2 WHERE id=?3", + params![priority.clamp(1, 5), Local::now().to_rfc3339(), id], + )?; + } + if let Some(when_at) = &changes.when_at { + tx.execute( + "UPDATE items SET when_at=?1,updated_at=?2 WHERE id=?3", + params![when_at, Local::now().to_rfc3339(), id], + )?; + } + if let Some(alarm_at) = &changes.alarm_at { + tx.execute( + "UPDATE items SET alarm_at=?1,updated_at=?2 WHERE id=?3", + params![alarm_at, Local::now().to_rfc3339(), id], + )?; + } + if let Some(value) = changes.numeric_value { + tx.execute( + "UPDATE items SET numeric_value=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), id], + )?; + } + if let Some(recurrence) = &changes.recurrence { + if !recurrence.trim().is_empty() { + let base = if let Some(when_at) = &changes.when_at { + when_at.clone() + } else { + tx.query_row("SELECT when_at FROM items WHERE id=?1", [id], |r| { + r.get::<_, Option>(0) + })? + }; + if let Some(base) = base { + next_occurrence(&base, recurrence)?; + } + } + tx.execute( + "UPDATE items SET recurrence=?1,updated_at=?2 WHERE id=?3", + params![recurrence.trim(), Local::now().to_rfc3339(), id], + )?; + } + tx.commit()?; + self.apply_rules_to_item(id)?; + Ok(()) + } + + fn all_rules(&self) -> Result> { + let mut stmt=self.conn.prepare("SELECT id,category_id,condition_expr,action_kind,action_value,enabled FROM category_rules WHERE enabled=1 ORDER BY sort_order,id")?; + Ok(stmt + .query_map([], |r| { + Ok(CategoryRule { + id: r.get(0)?, + category_id: r.get(1)?, + condition_expr: r.get(2)?, + action_kind: r.get(3)?, + action_value: r.get(4)?, + enabled: r.get::<_, i64>(5)? != 0, + }) + })? + .collect::>()?) + } + + pub fn apply_rules_to_all(&mut self) -> Result<()> { + let mut stmt = self + .conn + .prepare("SELECT id FROM items WHERE discarded=0")?; + let ids = stmt + .query_map([], |r| r.get(0))? + .collect::>>()?; + drop(stmt); + for id in ids { + self.apply_rules_to_item(id)?; + } + Ok(()) + } + + pub fn count_matching_filter(&self, source: &str) -> Result { + let expr = filter::parse(source)?; + let mut stmt = self + .conn + .prepare("SELECT id FROM items WHERE discarded=0")?; + let ids = stmt + .query_map([], |r| r.get(0))? + .collect::>>()?; + drop(stmt); + let mut count = 0; + for id in ids { + if let Some(item) = self.load_item(id)? + && filter::evaluate(&expr, &item) + { + count += 1; + } + } + Ok(count) + } + + fn apply_rules_to_item(&mut self, item_id: i64) -> Result<()> { + let rules = self.all_rules()?; + for _ in 0..16 { + let Some(item) = self.load_item(item_id)? else { + return Ok(()); + }; + let mut changed = false; + for rule in &rules { + let expr = filter::parse(&rule.condition_expr)?; + if filter::evaluate(&expr, &item) { + changed |= self.apply_rule(item_id, rule, &item)?; + } + } + if !changed { + return Ok(()); + } + } + bail!("category rules did not converge after 16 passes") + } + + fn apply_rule(&self, item_id: i64, rule: &CategoryRule, item: &Item) -> Result { + match rule.action_kind.as_str() { + "assign" | "exclude" => { + let target = if rule.action_value.trim().is_empty() { + Some(rule.category_id) + } else { + self.conn + .query_row( + "SELECT id FROM categories WHERE name=?1", + [rule.action_value.trim()], + |r| r.get(0), + ) + .optional()? + }; + let Some(target) = target else { + return Ok(false); + }; + let state:Option=self.conn.query_row("SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2",params![item_id,target],|r|r.get(0)).optional()?; + if rule.action_kind == "assign" { + if state.is_none() { + self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'conditional')",params![item_id,target])?; + return Ok(true); + } + } else if state.as_deref().is_none() + || matches!(state.as_deref(), Some("automatic" | "conditional")) + { + self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'excluded') ON CONFLICT(item_id,category_id) DO UPDATE SET assignment='excluded'",params![item_id,target])?; + return Ok(true); + } + Ok(false) + } + "priority" => { + let value = rule.action_value.parse::()?.clamp(1, 5); + if item.priority != value { + self.conn.execute( + "UPDATE items SET priority=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + "value" => { + let value = rule.action_value.parse::()?; + if item.numeric_value != Some(value) { + self.conn.execute( + "UPDATE items SET numeric_value=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + "when" => { + let value = extract_when(&rule.action_value) + .context("rule action has no recognizable date")?; + if item.when_at.as_deref() != Some(value.as_str()) { + self.conn.execute( + "UPDATE items SET when_at=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + "alarm" => { + let value = extract_when(&rule.action_value) + .context("rule action has no recognizable alarm date")?; + if item.alarm_at.as_deref() != Some(value.as_str()) { + self.conn.execute( + "UPDATE items SET alarm_at=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + "repeat" => { + let value = rule.action_value.trim(); + if let Some(when) = item.when_at.as_deref() { + next_occurrence(when, value)?; + } + if item.recurrence != value { + self.conn.execute( + "UPDATE items SET recurrence=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + "done" => { + let done = parse_bool(&rule.action_value); + if item.done_at.is_some() != done { + let value = if done { + Some(Local::now().to_rfc3339()) + } else { + None + }; + self.conn.execute( + "UPDATE items SET done_at=?1,updated_at=?2 WHERE id=?3", + params![value, Local::now().to_rfc3339(), item_id], + )?; + Ok(true) + } else { + Ok(false) + } + } + _ => bail!("unsupported rule action {}", rule.action_kind), + } + } + + fn load_item(&self, id: i64) -> Result> { + let mut item=self.conn.query_row("SELECT id,text,note,priority,when_at,done_at,alarm_at,numeric_value,created_at,updated_at,discarded,recurrence FROM items WHERE id=?1",[id],|r|Ok(Item{id:r.get(0)?,text:r.get(1)?,note:r.get(2)?,priority:r.get(3)?,when_at:r.get(4)?,done_at:r.get(5)?,alarm_at:r.get(6)?,numeric_value:r.get(7)?,created_at:r.get(8)?,updated_at:r.get(9)?,discarded:r.get::<_,i64>(10)?!=0,recurrence:r.get(11)?,categories:vec![]})).optional()?; + if let Some(item) = &mut item { + item.categories = self.categories_for_item(id)?; + } + Ok(item) + } + + pub fn toggle_done(&mut self, ids: &[i64]) -> Result<()> { + for id in ids { + let Some(item) = self.load_item(*id)? else { + continue; + }; + let now = Local::now().to_rfc3339(); + let next = if item.done_at.is_none() && !item.recurrence.is_empty() { + item.when_at + .as_deref() + .map(|when| next_occurrence(when, &item.recurrence)) + .transpose()? + } else { + None + }; + let tx = self.conn.transaction()?; + let mut next_id = None; + if let Some(next_when) = next { + let next_alarm = shift_alarm( + item.alarm_at.as_deref(), + item.when_at.as_deref(), + &next_when, + ); + tx.execute("INSERT INTO items(text,note,priority,when_at,alarm_at,numeric_value,recurrence,created_at,updated_at,sort_order) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?8,(SELECT COALESCE(MAX(sort_order),0)+1 FROM items))",params![&item.text,&item.note,item.priority,next_when,next_alarm,item.numeric_value,&item.recurrence,&now])?; + let created = tx.last_insert_rowid(); + Self::auto_assign_tx(&tx, created, &item.text)?; + tx.execute("INSERT OR REPLACE INTO item_categories(item_id,category_id,assignment) SELECT ?1,category_id,assignment FROM item_categories WHERE item_id=?2 AND assignment IN ('explicit','excluded')",params![created,id])?; + next_id = Some(created); + } + tx.execute("UPDATE items SET done_at=CASE WHEN done_at IS NULL THEN ?1 ELSE NULL END,updated_at=?1 WHERE id=?2",params![now,id])?; + tx.commit()?; + if let Some(next_id) = next_id { + self.apply_rules_to_item(next_id)?; + } + } + Ok(()) + } + + pub fn discard(&self, ids: &[i64], discarded: bool) -> Result<()> { + for id in ids { + self.conn.execute( + "UPDATE items SET discarded=?1,updated_at=?2 WHERE id=?3", + params![discarded as i64, Local::now().to_rfc3339(), id], + )?; + } + Ok(()) + } + + pub fn toggle_assignment(&mut self, item_id: i64, category_id: i64) -> Result<()> { + let state: Option = self + .conn + .query_row( + "SELECT assignment FROM item_categories WHERE item_id=?1 AND category_id=?2", + params![item_id, category_id], + |r| r.get(0), + ) + .optional()?; + match state.as_deref() { + Some("excluded") => { + self.conn.execute( + "DELETE FROM item_categories WHERE item_id=?1 AND category_id=?2", + params![item_id, category_id], + )?; + } + Some(_) => { + self.conn.execute("UPDATE item_categories SET assignment='excluded' WHERE item_id=?1 AND category_id=?2", params![item_id, category_id])?; + } + None => { + let exclusive: bool = self.conn.query_row( + "SELECT exclusive FROM categories WHERE id=?1", + [category_id], + |r| r.get(0), + )?; + if exclusive { + self.conn.execute("DELETE FROM item_categories WHERE item_id=?1 AND category_id IN (SELECT sibling.id FROM categories chosen JOIN categories sibling ON sibling.parent_id IS chosen.parent_id AND sibling.exclusive=1 WHERE chosen.id=?2)", params![item_id,category_id])?; + } + self.conn.execute("INSERT INTO item_categories(item_id,category_id,assignment) VALUES(?1,?2,'explicit')", params![item_id,category_id])?; + } + } + Ok(()) + } + + pub fn item_category_ids(&self, item_id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded'", + )?; + Ok(stmt + .query_map([item_id], |r| r.get(0))? + .collect::>()?) + } + + pub fn excluded_category_ids(&self, item_id: i64) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment='excluded'", + )?; + Ok(stmt + .query_map([item_id], |r| r.get(0))? + .collect::>()?) + } + + pub fn items(&self, view: &ViewDef, search: &str) -> Result> { + let mut sql = String::from( + "SELECT DISTINCT i.id,i.text,i.note,i.priority,i.when_at,i.done_at,i.alarm_at,i.numeric_value,i.created_at,i.updated_at,i.discarded,i.recurrence FROM items i", + ); + let mut values: Vec = vec![]; + if view.kind == "category" { + sql.push_str( + " JOIN item_categories ic ON ic.item_id=i.id AND ic.assignment<>'excluded'", + ); + } + sql.push_str(if view.kind == "trash" { + " WHERE i.discarded=1" + } else { + " WHERE i.discarded=0" + }); + match view.kind.as_str() { + "category" => { + sql.push_str(" AND ic.category_id IN (WITH RECURSIVE descendants(id) AS (SELECT id FROM categories WHERE name=? UNION ALL SELECT c.id FROM categories c JOIN descendants d ON c.parent_id=d.id) SELECT id FROM descendants) AND NOT EXISTS(SELECT 1 FROM item_categories ex JOIN categories ec ON ec.id=ex.category_id WHERE ex.item_id=i.id AND ex.assignment='excluded' AND ec.name=?)"); + values.extend([view.filter_value.clone(), view.filter_value.clone()]); + } + "upcoming" | "datebook" => { + let days = view.filter_value.parse::().unwrap_or(30); + sql.push_str(" AND i.when_at IS NOT NULL AND i.when_at <= ?"); + values.push((Local::now() + Duration::days(days)).to_rfc3339()); + } + "done" => { + let days = view.filter_value.parse::().unwrap_or(14); + sql.push_str(" AND i.done_at IS NOT NULL AND i.done_at >= ?"); + values.push((Local::now() - Duration::days(days)).to_rfc3339()); + } + _ => {} + } + if !view.show_done && view.kind != "done" { + sql.push_str(" AND i.done_at IS NULL"); + } + if !search.is_empty() { + sql.push_str(" AND (i.text LIKE ? OR i.note LIKE ?)"); + let like = format!("%{search}%"); + values.extend([like.clone(), like]); + } + sql.push_str(match view.sort_key.as_str() { + "when" => " ORDER BY i.when_at IS NULL,i.when_at,i.priority,i.sort_order", + "done" => " ORDER BY i.done_at DESC,i.sort_order", + "priority" => " ORDER BY i.priority,i.when_at IS NULL,i.when_at", + "updated" => " ORDER BY i.updated_at DESC", + _ => " ORDER BY i.sort_order,i.id", + }); + let mut stmt = self.conn.prepare(&sql)?; + let refs: Vec<&dyn rusqlite::ToSql> = + values.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + let rows = stmt.query_map(refs.as_slice(), |r| { + Ok(Item { + id: r.get(0)?, + text: r.get(1)?, + note: r.get(2)?, + priority: r.get(3)?, + when_at: r.get(4)?, + done_at: r.get(5)?, + alarm_at: r.get(6)?, + numeric_value: r.get(7)?, + created_at: r.get(8)?, + updated_at: r.get(9)?, + discarded: r.get::<_, i64>(10)? != 0, + recurrence: r.get(11)?, + categories: vec![], + }) + })?; + let mut items: Vec = rows.collect::>()?; + for item in &mut items { + item.categories = self.categories_for_item(item.id)?; + } + if !view.filter_expr.trim().is_empty() { + let expr = filter::parse(&view.filter_expr).context("invalid saved view filter")?; + items.retain(|item| filter::evaluate(&expr, item)); + } + Ok(items) + } + + fn categories_for_item(&self, item_id: i64) -> Result> { + let mut stmt = self.conn.prepare("WITH RECURSIVE inherited(id) AS ( + SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment<>'excluded' + UNION SELECT c.parent_id FROM categories c JOIN inherited i ON c.id=i.id WHERE c.parent_id IS NOT NULL + ) SELECT DISTINCT c.id,c.name,c.parent_id,c.kind,c.match_text,c.exclusive FROM categories c JOIN inherited i ON i.id=c.id + WHERE c.id NOT IN (SELECT category_id FROM item_categories WHERE item_id=?1 AND assignment='excluded') ORDER BY c.name")?; + Ok(stmt + .query_map([item_id], Self::category_row)? + .collect::>()?) + } + + pub fn due_alarm_count(&self) -> Result { + Ok(self.conn.query_row("SELECT COUNT(*) FROM items WHERE discarded=0 AND done_at IS NULL AND alarm_at IS NOT NULL AND alarm_at <= ?1", [Local::now().to_rfc3339()], |r| r.get(0))?) + } + + pub fn checkpoint(&self) -> Result<()> { + self.conn.execute_batch("PRAGMA wal_checkpoint(PASSIVE)")?; + Ok(()) + } + + pub fn import_path(&mut self, path: &Path) -> Result { + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("ics")) + { + return self.import_ical(path); + } + self.import_text(path) + } + + fn import_text(&mut self, path: &Path) -> Result { + let text = fs::read_to_string(path) + .with_context(|| format!("could not read {}", path.display()))?; + let mut count = 0; + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + self.add_item(line)?; + count += 1; + } + Ok(count) + } + + fn import_ical(&mut self, path: &Path) -> Result { + let source = fs::read_to_string(path) + .with_context(|| format!("could not read {}", path.display()))?; + let mut lines: Vec = vec![]; + for raw in source.lines() { + let line = raw.trim_end_matches('\r'); + if (line.starts_with(' ') || line.starts_with('\t')) && !lines.is_empty() { + lines.last_mut().unwrap().push_str(line.trim_start()); + } else { + lines.push(line.to_owned()); + } + } + let mut records = vec![]; + let mut current: Option = None; + for line in lines { + match line.as_str() { + "BEGIN:VEVENT" | "BEGIN:VTODO" => current = Some(IcalRecord::default()), + "END:VEVENT" | "END:VTODO" => { + if let Some(record) = current.take() { + records.push(record) + } + } + _ => { + if let Some(record) = &mut current + && let Some((raw_key, value)) = line.split_once(':') + { + let key = raw_key.split(';').next().unwrap_or(raw_key); + let value = ical_unescape(value); + match key { + "SUMMARY" => record.summary = value, + "DESCRIPTION" => record.note = value, + "DTSTART" | "DUE" => record.when_at = parse_ical_date(&value), + "COMPLETED" => record.done = true, + "STATUS" if value.eq_ignore_ascii_case("COMPLETED") => { + record.done = true + } + "PRIORITY" => record.priority = value.parse().ok(), + "CATEGORIES" => { + record.categories = value.split(',').map(ical_unescape).collect() + } + "RRULE" => record.recurrence = rrule_to_recurrence(&value), + _ => {} + } + } + } + } + } + let mut count = 0; + for record in records.into_iter().filter(|r| !r.summary.trim().is_empty()) { + let id = self.add_item(&record.summary)?; + self.update_item( + id, + &ItemChanges { + note: Some(record.note), + priority: record.priority.map(|p| p.clamp(1, 5)), + when_at: Some(record.when_at), + recurrence: Some(record.recurrence), + ..Default::default() + }, + )?; + for name in record + .categories + .into_iter() + .filter(|n| !n.trim().is_empty()) + { + let category_id = if let Some(category) = self + .categories()? + .into_iter() + .find(|c| c.name.eq_ignore_ascii_case(&name)) + { + category.id + } else { + self.save_category(None, &name, None, "standard", "", false)? + }; + if !self.item_category_ids(id)?.contains(&category_id) { + self.toggle_assignment(id, category_id)?; + } + } + if record.done { + self.toggle_done(&[id])?; + } + count += 1; + } + Ok(count) + } + + pub fn export(&self, format: &str, path: &Path, view_name: Option<&str>) -> Result { + let views = self.views()?; + let view = if let Some(name) = view_name { + views + .into_iter() + .find(|v| v.name.eq_ignore_ascii_case(name)) + .with_context(|| format!("view {name:?} does not exist"))? + } else { + views + .into_iter() + .find(|v| v.name == "All Items") + .context("All Items view is missing")? + }; + let items = self.items(&view, "")?; + let out = if format.eq_ignore_ascii_case("json") { + let records = items.iter().map(|i| format!(" {{\"id\":{},\"text\":{},\"note\":{},\"priority\":{},\"when\":{},\"done\":{},\"alarm\":{},\"value\":{},\"recurrence\":{},\"categories\":{}}}", i.id,json(&i.text),json(&i.note),i.priority,opt_json(i.when_at.as_deref()),opt_json(i.done_at.as_deref()),opt_json(i.alarm_at.as_deref()),i.numeric_value.map(|v|v.to_string()).unwrap_or_else(||"null".into()),json(&i.recurrence),json(&i.category_names()))).collect::>().join(",\n"); + format!("[\n{records}\n]\n") + } else if format.eq_ignore_ascii_case("csv") { + let mut s = + String::from("id,text,note,priority,when,done,alarm,value,recurrence,categories\n"); + for i in &items { + s.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{}\n", + i.id, + csv(&i.text), + csv(&i.note), + i.priority, + csv(i.when_at.as_deref().unwrap_or("")), + csv(i.done_at.as_deref().unwrap_or("")), + csv(i.alarm_at.as_deref().unwrap_or("")), + i.numeric_value.map(|v| v.to_string()).unwrap_or_default(), + csv(&i.recurrence), + csv(&i.category_names()) + )); + } + s + } else if matches!(format.to_lowercase().as_str(), "md" | "markdown") { + render_markdown(&view, &items) + } else if format.eq_ignore_ascii_case("html") { + render_html(&view, &items) + } else if matches!(format.to_lowercase().as_str(), "ics" | "ical") { + render_ical(&items) + } else { + bail!("export format must be csv, json, markdown, html, or ics"); + }; + fs::write(path, out).with_context(|| format!("could not write {}", path.display()))?; + Ok(items.len()) + } + + pub fn seed_demo(&mut self) -> Result<()> { + let exists: Option = self + .conn + .query_row("SELECT id FROM items LIMIT 1", [], |r| r.get(0)) + .optional()?; + if exists.is_some() { + return Ok(()); + } + for text in [ + "Call Sarah tomorrow at 3pm to review the Wells proposal", + "Meet with Anne next Tuesday at 9am to discuss her progress", + "Review quarterly budget in 3 days", + "An idea about information that may become useful later", + "Buy train tickets this Friday", + ] { + self.add_item(text)?; + } + Ok(()) + } +} + +#[derive(Default)] +struct IcalRecord { + summary: String, + note: String, + when_at: Option, + done: bool, + priority: Option, + categories: Vec, + recurrence: String, +} + +fn parse_ical_date(value: &str) -> Option { + if let Ok(dt) = DateTime::parse_from_rfc3339(value) { + return Some(dt.to_rfc3339()); + } + if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%SZ") { + return Some(DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339()); + } + if let Ok(dt) = NaiveDateTime::parse_from_str(value, "%Y%m%dT%H%M%S") { + return Local + .from_local_datetime(&dt) + .single() + .map(|d| d.to_rfc3339()); + } + NaiveDate::parse_from_str(value, "%Y%m%d") + .ok() + .and_then(|d| d.and_hms_opt(9, 0, 0)) + .and_then(|dt| Local.from_local_datetime(&dt).single()) + .map(|d| d.to_rfc3339()) +} +fn shift_alarm(alarm: Option<&str>, old_when: Option<&str>, new_when: &str) -> Option { + let alarm = DateTime::parse_from_rfc3339(alarm?).ok()?; + let old = DateTime::parse_from_rfc3339(old_when?).ok()?; + let new = DateTime::parse_from_rfc3339(new_when).ok()?; + Some((new + (alarm - old)).to_rfc3339()) +} +fn ical_unescape(value: &str) -> String { + value + .replace("\\n", "\n") + .replace("\\N", "\n") + .replace("\\,", ",") + .replace("\\;", ";") + .replace("\\\\", "\\") +} +fn ical_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('\n', "\\n") + .replace(',', "\\,") + .replace(';', "\\;") +} +fn recurrence_to_rrule(value: &str) -> Option { + let value = value.trim().to_lowercase(); + if value.is_empty() { + return None; + } + Some(match value.as_str() { + "daily" => "FREQ=DAILY".into(), + "weekdays" => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR".into(), + "weekly" => "FREQ=WEEKLY".into(), + "monthly" => "FREQ=MONTHLY".into(), + "yearly" | "annually" => "FREQ=YEARLY".into(), + _ => { + let re = regex::Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap(); + let c = re.captures(&value)?; + let freq = match &c[2] { + "day" | "days" => "DAILY", + "week" | "weeks" => "WEEKLY", + "month" | "months" => "MONTHLY", + _ => "YEARLY", + }; + format!("FREQ={freq};INTERVAL={}", &c[1]) + } + }) +} +fn rrule_to_recurrence(value: &str) -> String { + let upper = value.to_uppercase(); + let freq = upper + .split(';') + .find_map(|p| p.strip_prefix("FREQ=")) + .unwrap_or(""); + let interval = upper + .split(';') + .find_map(|p| p.strip_prefix("INTERVAL=")) + .and_then(|v| v.parse::().ok()) + .unwrap_or(1); + if upper.contains("BYDAY=MO,TU,WE,TH,FR") { + return "weekdays".into(); + } + if interval == 1 { + return match freq { + "DAILY" => "daily", + "WEEKLY" => "weekly", + "MONTHLY" => "monthly", + "YEARLY" => "yearly", + _ => "", + } + .into(); + } + let unit = match freq { + "DAILY" => "days", + "WEEKLY" => "weeks", + "MONTHLY" => "months", + "YEARLY" => "years", + _ => return String::new(), + }; + format!("every {interval} {unit}") +} + +fn render_ical(items: &[Item]) -> String { + let mut out = String::from( + "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Rogue Agenda//EN\r\nCALSCALE:GREGORIAN\r\n", + ); + for item in items { + out.push_str("BEGIN:VTODO\r\n"); + out.push_str(&format!("UID:rogue-{}@local\r\n", item.id)); + out.push_str(&format!( + "DTSTAMP:{}\r\n", + ical_timestamp(&item.created_at) + .unwrap_or_else(|| Utc::now().format("%Y%m%dT%H%M%SZ").to_string()) + )); + out.push_str(&format!("SUMMARY:{}\r\n", ical_escape(&item.text))); + if !item.note.is_empty() { + out.push_str(&format!("DESCRIPTION:{}\r\n", ical_escape(&item.note))); + } + if let Some(when_at) = item.when_at.as_deref().and_then(ical_timestamp) { + out.push_str(&format!("DUE:{when_at}\r\n")); + } + out.push_str(&format!("PRIORITY:{}\r\n", item.priority)); + if !item.categories.is_empty() { + out.push_str(&format!( + "CATEGORIES:{}\r\n", + item.categories + .iter() + .map(|c| ical_escape(&c.name)) + .collect::>() + .join(",") + )); + } + if let Some(rrule) = recurrence_to_rrule(&item.recurrence) { + out.push_str(&format!("RRULE:{rrule}\r\n")); + } + if let Some(done) = item.done_at.as_deref() { + out.push_str("STATUS:COMPLETED\r\n"); + if let Some(done) = ical_timestamp(done) { + out.push_str(&format!("COMPLETED:{done}\r\n")); + } + } else { + out.push_str("STATUS:NEEDS-ACTION\r\n"); + } + out.push_str("END:VTODO\r\n"); + } + out.push_str("END:VCALENDAR\r\n"); + out +} +fn ical_timestamp(value: &str) -> Option { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|d| d.with_timezone(&Utc).format("%Y%m%dT%H%M%SZ").to_string()) +} + +fn report_groups<'a>(view: &ViewDef, items: &'a [Item]) -> Vec<(String, Vec<&'a Item>)> { + if view.sections.is_empty() { + return vec![(String::new(), items.iter().collect())]; + } + view.sections + .iter() + .map(|section| { + ( + section.heading.clone(), + items + .iter() + .filter(|item| filter::matches(§ion.filter_expr, item).unwrap_or(false)) + .collect(), + ) + }) + .collect() +} +fn report_value(field: &str, item: &Item) -> String { + match field { + "item" => item.text.clone(), + "categories" => item.category_names(), + "when" => item.when_at.clone().unwrap_or_default(), + "priority" => item.priority.to_string(), + "note" => item.note.clone(), + "value" => item + .numeric_value + .map(|v| v.to_string()) + .unwrap_or_default(), + "done" => item.done_at.clone().unwrap_or_default(), + "alarm" => item.alarm_at.clone().unwrap_or_default(), + "recurrence" => item.recurrence.clone(), + "created" => item.created_at.clone(), + "updated" => item.updated_at.clone(), + _ => String::new(), + } +} +fn render_markdown(view: &ViewDef, items: &[Item]) -> String { + let mut out = format!("# Rogue Agenda — {}\n\n", view.name); + for (heading, group) in report_groups(view, items) { + if !heading.is_empty() { + out.push_str(&format!("## {}\n\n", markdown(&heading))); + } + out.push('|'); + for column in &view.columns { + out.push_str(&format!(" {} |", markdown(&column.heading))); + } + out.push('\n'); + out.push('|'); + for _ in &view.columns { + out.push_str(" --- |"); + } + out.push('\n'); + for item in group { + out.push('|'); + for column in &view.columns { + out.push_str(&format!( + " {} |", + markdown(&report_value(&column.field, item)) + )); + } + out.push('\n'); + } + out.push('\n'); + } + out +} +fn render_html(view: &ViewDef, items: &[Item]) -> String { + let mut out = format!( + "Rogue Agenda — {}

Rogue Agenda — {}

", + html(&view.name), + html(&view.name) + ); + for (heading, group) in report_groups(view, items) { + if !heading.is_empty() { + out.push_str(&format!("

{}

", html(&heading))); + } + out.push_str(""); + for column in &view.columns { + out.push_str(&format!("", html(&column.heading))); + } + out.push_str(""); + for item in group { + out.push_str(""); + for column in &view.columns { + out.push_str(&format!( + "", + html(&report_value(&column.field, item)).replace('\n', "
") + )); + } + out.push_str(""); + } + out.push_str("
{}
{}
"); + } + out.push_str("\n"); + out +} +fn markdown(value: &str) -> String { + value.replace('|', "\\|").replace('\n', "
") +} +fn html(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +fn default_columns() -> Vec { + [ + ("item", "Items", 48), + ("categories", "Categories", 27), + ("when", "When", 20), + ("priority", "P", 5), + ] + .into_iter() + .map(|(field, heading, width)| ViewColumn { + field: field.into(), + heading: heading.into(), + width, + aggregate: "none".into(), + }) + .collect() +} + +fn parse_action_spec(spec: &str) -> Result<(String, String)> { + let (kind, value) = spec.trim().split_once(':').unwrap_or((spec.trim(), "")); + let kind = kind.trim().to_lowercase(); + if !matches!( + kind.as_str(), + "assign" | "exclude" | "priority" | "value" | "when" | "alarm" | "repeat" | "done" + ) { + bail!("rule action must be assign, exclude, priority, value, when, alarm, repeat, or done") + } + if matches!( + kind.as_str(), + "priority" | "value" | "when" | "alarm" | "repeat" | "done" + ) && value.trim().is_empty() + { + bail!("rule action {kind} needs a value") + } + if kind == "repeat" { + next_occurrence("2026-01-05T09:00:00+00:00", value.trim())?; + } + Ok((kind, value.trim().into())) +} + +fn parse_bool(value: &str) -> bool { + matches!( + value.trim().to_lowercase().as_str(), + "1" | "yes" | "true" | "on" | "done" + ) +} + +fn parse_columns_spec(spec: &str) -> Result> { + if spec.trim().is_empty() { + return Ok(default_columns()); + } + let mut columns = vec![]; + for raw in spec.split(',').map(str::trim).filter(|s| !s.is_empty()) { + let parts: Vec<&str> = raw.split(':').collect(); + let field = parts[0].trim().to_lowercase(); + if !matches!( + field.as_str(), + "item" + | "categories" + | "when" + | "priority" + | "note" + | "value" + | "done" + | "alarm" + | "recurrence" + | "created" + | "updated" + ) { + bail!("unknown view column {field}"); + } + let width = parts + .get(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(20) + .clamp(4, 100); + let heading = parts + .get(2) + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| default_heading(&field).into()); + let aggregate = parts + .get(3) + .map(|s| s.trim().to_lowercase()) + .unwrap_or_else(|| "none".into()); + if !matches!( + aggregate.as_str(), + "none" | "sum" | "avg" | "count" | "min" | "max" + ) { + bail!("unsupported aggregate {aggregate}") + } + columns.push(ViewColumn { + field, + heading, + width, + aggregate, + }); + } + if columns.is_empty() { + bail!("a view needs at least one column") + } + Ok(columns) +} + +fn parse_sections_spec(spec: &str) -> Result> { + let mut sections = vec![]; + for raw in spec.split(';').map(str::trim).filter(|s| !s.is_empty()) { + let mut parts = raw.split('|'); + let heading = parts.next().unwrap_or_default().trim(); + let expr = parts.next().unwrap_or_default().trim(); + let collapsed = parts + .next() + .is_some_and(|v| v.trim().eq_ignore_ascii_case("collapsed")); + if heading.is_empty() { + bail!("section heading cannot be empty") + } + filter::parse(expr).with_context(|| format!("invalid filter for section {heading}"))?; + sections.push(ViewSection { + id: 0, + heading: heading.into(), + filter_expr: expr.into(), + collapsed, + }); + } + Ok(sections) +} + +fn default_heading(field: &str) -> &str { + match field { + "item" => "Items", + "categories" => "Categories", + "when" => "When", + "priority" => "P", + "note" => "Note", + "value" => "Value", + "done" => "Done", + "alarm" => "Alarm", + "recurrence" => "Repeats", + "created" => "Created", + "updated" => "Updated", + _ => field, + } +} + +fn json(s: &str) -> String { + format!( + "\"{}\"", + s.replace('\\', "\\\\") + .replace('\"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + ) +} +fn opt_json(s: Option<&str>) -> String { + s.map(json).unwrap_or_else(|| "null".into()) +} +fn csv(s: &str) -> String { + format!("\"{}\"", s.replace('\"', "\"\"")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn persists_items_and_auto_assigns() { + let d = tempdir().unwrap(); + let p = d.path().join("test.agnd"); + let mut db = Database::open(&p).unwrap(); + db.add_item("Call Ada tomorrow").unwrap(); + let view = db + .views() + .unwrap() + .into_iter() + .find(|v| v.name == "All Items") + .unwrap(); + let items = db.items(&view, "").unwrap(); + assert_eq!(items.len(), 1); + assert!(items[0].when_at.is_some()); + assert!(items[0].categories.iter().any(|c| c.name == "Calls")); + } + + #[test] + fn category_assignment_toggles() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("x.agnd")).unwrap(); + let item = db.add_item("Unclassified thought").unwrap(); + let cat = db + .categories() + .unwrap() + .into_iter() + .find(|c| c.name == "Projects") + .unwrap(); + db.toggle_assignment(item, cat.id).unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&cat.id)); + db.toggle_assignment(item, cat.id).unwrap(); + assert!(!db.item_category_ids(item).unwrap().contains(&cat.id)); + } + + #[test] + fn category_views_inherit_through_the_hierarchy() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("inherit.agnd")).unwrap(); + let parent = db + .save_category(None, "Research", None, "standard", "", false) + .unwrap(); + let child = db + .save_category(None, "Rust", Some(parent), "standard", "", false) + .unwrap(); + let item = db.add_item("Borrow ownership notes").unwrap(); + db.toggle_assignment(item, child).unwrap(); + let view_id = db + .save_view_design( + None, + "Research view", + "category", + "Research", + "manual", + true, + "", + "item:48,categories:27,when:20,priority:5", + "", + ) + .unwrap(); + let view = db + .views() + .unwrap() + .into_iter() + .find(|v| v.id == view_id) + .unwrap(); + assert_eq!(db.items(&view, "").unwrap().len(), 1); + } + + #[test] + fn dependency_cycles_are_rejected() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("deps.agnd")).unwrap(); + let first = db.add_item("First").unwrap(); + let second = db.add_item("Second").unwrap(); + db.toggle_dependency(second, first).unwrap(); + assert!(db.toggle_dependency(first, second).is_err()); + assert_eq!(db.unmet_dependencies(second).unwrap(), 1); + } + + #[test] + fn view_design_roundtrips_columns_sections_and_boolean_filter() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("views.agnd")).unwrap(); + let urgent = db.add_item("Urgent proposal").unwrap(); + db.update_item( + urgent, + &ItemChanges { + priority: Some(1), + numeric_value: Some(Some(125.0)), + ..Default::default() + }, + ) + .unwrap(); + db.add_item("Someday proposal").unwrap(); + let id = db + .save_view_design( + None, + "Focused", + "list", + "", + "priority", + true, + "priority<=2 and open", + "item:60:Task:none,value:20:Cost:sum", + "Urgent|priority=1;Later|priority>1|collapsed", + ) + .unwrap(); + let view = db + .views() + .unwrap() + .into_iter() + .find(|v| v.id == id) + .unwrap(); + assert_eq!(view.columns[1].heading, "Cost"); + assert_eq!(view.columns[1].aggregate, "sum"); + assert_eq!(view.sections.len(), 2); + assert!(view.sections[1].collapsed); + assert_eq!(db.items(&view, "").unwrap().len(), 1); + } + + #[test] + fn category_rules_apply_and_invalid_edits_are_atomic() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("rules.agnd")).unwrap(); + let important = db + .save_category(None, "Important", None, "standard", "", false) + .unwrap(); + db.save_primary_rule(important, "text~urgent", "assign:") + .unwrap(); + let item = db.add_item("Urgent customer request").unwrap(); + assert!(db.item_category_ids(item).unwrap().contains(&important)); + let promoter = db + .save_category(None, "Promoter", None, "standard", "", false) + .unwrap(); + db.save_primary_rule(promoter, "category=Important", "priority:1") + .unwrap(); + db.apply_rules_to_all().unwrap(); + let all = db + .views() + .unwrap() + .into_iter() + .find(|v| v.name == "All Items") + .unwrap(); + assert_eq!(db.items(&all, "").unwrap()[0].priority, 1); + assert!( + db.save_primary_rule(important, "priority nope 2", "assign:") + .is_err() + ); + assert_eq!( + db.category_rules(important).unwrap()[0].condition_expr, + "text~urgent" + ); + } + + #[test] + fn completing_a_recurring_item_creates_the_next_occurrence() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("recurring.agnd")).unwrap(); + let id = db.add_item("Weekly project review").unwrap(); + db.update_item( + id, + &ItemChanges { + note: Some("Bring the status sheet".into()), + priority: Some(2), + when_at: Some(Some("2026-09-01T14:30:00+00:00".into())), + alarm_at: Some(Some("2026-09-01T13:30:00+00:00".into())), + numeric_value: Some(Some(45.0)), + recurrence: Some("weekly".into()), + ..Default::default() + }, + ) + .unwrap(); + let projects = db + .categories() + .unwrap() + .into_iter() + .find(|c| c.name == "Projects") + .unwrap(); + db.toggle_assignment(id, projects.id).unwrap(); + + db.toggle_done(&[id]).unwrap(); + let all = db + .views() + .unwrap() + .into_iter() + .find(|v| v.name == "All Items") + .unwrap(); + let items = db.items(&all, "").unwrap(); + assert_eq!(items.len(), 2); + let completed = items.iter().find(|item| item.id == id).unwrap(); + let next = items.iter().find(|item| item.id != id).unwrap(); + assert!(completed.done_at.is_some()); + assert!(next.done_at.is_none()); + assert_eq!(next.note, "Bring the status sheet"); + assert_eq!(next.priority, 2); + assert_eq!(next.numeric_value, Some(45.0)); + assert_eq!(next.recurrence, "weekly"); + assert!( + next.when_at + .as_deref() + .unwrap() + .contains("2026-09-08T14:30:00") + ); + assert!( + next.alarm_at + .as_deref() + .unwrap() + .contains("2026-09-08T13:30:00") + ); + assert!(next.categories.iter().any(|c| c.name == "Projects")); + + db.toggle_done(&[id]).unwrap(); + assert_eq!(db.items(&all, "").unwrap().len(), 2); + } + + #[test] + fn exports_view_scoped_markdown_and_html_reports() { + let d = tempdir().unwrap(); + let mut db = Database::open(&d.path().join("report.agnd")).unwrap(); + let keep = db.add_item("Keep report").unwrap(); + db.update_item( + keep, + &ItemChanges { + priority: Some(1), + ..Default::default() + }, + ) + .unwrap(); + db.add_item("Omit report").unwrap(); + db.save_view_design( + None, + "Priority report", + "list", + "", + "priority", + true, + "priority=1", + "item:70:Action:none,priority:30:Rank:none", + "Important|priority=1", + ) + .unwrap(); + let md = d.path().join("report.md"); + db.export("markdown", &md, Some("Priority report")).unwrap(); + let md = fs::read_to_string(md).unwrap(); + assert!(md.contains("## Important")); + assert!(md.contains("Keep report")); + assert!(!md.contains("Omit report")); + let html_path = d.path().join("report.html"); + db.export("html", &html_path, Some("Priority report")) + .unwrap(); + let html = fs::read_to_string(html_path).unwrap(); + assert!(html.contains("Keep <this> report")); + assert!(!html.contains("Omit report")); + } + + #[test] + fn ical_roundtrip_preserves_planner_fields() { + let d = tempdir().unwrap(); + let mut source = Database::open(&d.path().join("source.agnd")).unwrap(); + let id = source.add_item("Prepare launch").unwrap(); + source + .update_item( + id, + &ItemChanges { + note: Some("Bring charts".into()), + priority: Some(1), + when_at: Some(Some("2026-09-01T14:30:00+00:00".into())), + ..Default::default() + }, + ) + .unwrap(); + let project = source + .categories() + .unwrap() + .into_iter() + .find(|c| c.name == "Projects") + .unwrap(); + source.toggle_assignment(id, project.id).unwrap(); + source.toggle_done(&[id]).unwrap(); + source + .update_item( + id, + &ItemChanges { + recurrence: Some("every 2 weeks".into()), + ..Default::default() + }, + ) + .unwrap(); + let path = d.path().join("tasks.ics"); + source.export("ics", &path, None).unwrap(); + let mut target = Database::open(&d.path().join("target.agnd")).unwrap(); + assert_eq!(target.import_path(&path).unwrap(), 1); + let all = target + .views() + .unwrap() + .into_iter() + .find(|v| v.name == "All Items") + .unwrap(); + let items = target.items(&all, "").unwrap(); + assert_eq!(items[0].text, "Prepare launch"); + assert_eq!(items[0].note, "Bring charts"); + assert_eq!(items[0].priority, 1); + assert!( + items[0] + .when_at + .as_deref() + .unwrap() + .contains("2026-09-01T14:30:00") + ); + assert!(items[0].done_at.is_some()); + assert_eq!(items[0].recurrence, "every 2 weeks"); + assert!(items[0].categories.iter().any(|c| c.name == "Projects")); + } +} diff --git a/src/filter.rs b/src/filter.rs new file mode 100644 index 0000000..f084a4f --- /dev/null +++ b/src/filter.rs @@ -0,0 +1,390 @@ +use anyhow::{Result, bail}; +use chrono::{DateTime, Duration, Local}; + +use crate::model::Item; + +#[derive(Debug, Clone, PartialEq)] +pub enum Expr { + True, + Predicate(Predicate), + Not(Box), + And(Box, Box), + Or(Box, Box), +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Predicate { + TextContains(String), + NoteContains(String), + Category(String), + Priority(Compare, i64), + Value(Compare, f64), + Done(bool), + HasNote, + HasDate(bool), + DueWithin(i64), + Overdue, + Alarm, + Recurring(bool), +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Compare { + Eq, + Ne, + Lt, + Le, + Gt, + Ge, +} + +pub fn parse(source: &str) -> Result { + if source.trim().is_empty() { + return Ok(Expr::True); + } + let tokens = tokenize(source)?; + let mut parser = Parser { tokens, at: 0 }; + let expr = parser.parse_or()?; + if parser.at != parser.tokens.len() { + bail!("unexpected token {:?}", parser.tokens[parser.at]); + } + Ok(expr) +} + +pub fn matches(source: &str, item: &Item) -> Result { + Ok(evaluate(&parse(source)?, item)) +} + +pub fn evaluate(expr: &Expr, item: &Item) -> bool { + match expr { + Expr::True => true, + Expr::Predicate(p) => predicate(p, item), + Expr::Not(inner) => !evaluate(inner, item), + Expr::And(a, b) => evaluate(a, item) && evaluate(b, item), + Expr::Or(a, b) => evaluate(a, item) || evaluate(b, item), + } +} + +fn predicate(p: &Predicate, item: &Item) -> bool { + match p { + Predicate::TextContains(s) => item.text.to_lowercase().contains(&s.to_lowercase()), + Predicate::NoteContains(s) => item.note.to_lowercase().contains(&s.to_lowercase()), + Predicate::Category(s) => item + .categories + .iter() + .any(|c| c.name.eq_ignore_ascii_case(s)), + Predicate::Priority(op, n) => compare_i64(item.priority, *op, *n), + Predicate::Value(op, n) => item.numeric_value.is_some_and(|v| compare_f64(v, *op, *n)), + Predicate::Done(expected) => item.done_at.is_some() == *expected, + Predicate::HasNote => !item.note.trim().is_empty(), + Predicate::HasDate(expected) => item.when_at.is_some() == *expected, + Predicate::DueWithin(days) => item + .when_at + .as_deref() + .and_then(parse_date) + .is_some_and(|d| d >= Local::now() && d <= Local::now() + Duration::days(*days)), + Predicate::Overdue => { + item.done_at.is_none() + && item + .when_at + .as_deref() + .and_then(parse_date) + .is_some_and(|d| d < Local::now()) + } + Predicate::Alarm => item.alarm_at.is_some(), + Predicate::Recurring(expected) => !item.recurrence.is_empty() == *expected, + } +} + +fn parse_date(value: &str) -> Option> { + DateTime::parse_from_rfc3339(value) + .ok() + .map(|d| d.with_timezone(&Local)) +} +fn compare_i64(a: i64, op: Compare, b: i64) -> bool { + match op { + Compare::Eq => a == b, + Compare::Ne => a != b, + Compare::Lt => a < b, + Compare::Le => a <= b, + Compare::Gt => a > b, + Compare::Ge => a >= b, + } +} +fn compare_f64(a: f64, op: Compare, b: f64) -> bool { + match op { + Compare::Eq => (a - b).abs() < f64::EPSILON, + Compare::Ne => (a - b).abs() >= f64::EPSILON, + Compare::Lt => a < b, + Compare::Le => a <= b, + Compare::Gt => a > b, + Compare::Ge => a >= b, + } +} + +#[derive(Debug, Clone, PartialEq)] +enum Token { + Word(String), + String(String), + And, + Or, + Not, + LParen, + RParen, + Op(Compare), +} + +fn tokenize(source: &str) -> Result> { + let chars: Vec = source.chars().collect(); + let mut out = vec![]; + let mut i = 0; + while i < chars.len() { + if chars[i].is_whitespace() { + i += 1; + continue; + } + match chars[i] { + '(' => { + out.push(Token::LParen); + i += 1 + } + ')' => { + out.push(Token::RParen); + i += 1 + } + '"' | '\'' => { + let quote = chars[i]; + i += 1; + let mut s = String::new(); + while i < chars.len() && chars[i] != quote { + if chars[i] == '\\' && i + 1 < chars.len() { + i += 1; + } + s.push(chars[i]); + i += 1; + } + if i == chars.len() { + bail!("unterminated quoted string") + } + i += 1; + out.push(Token::String(s)); + } + '&' if chars.get(i + 1) == Some(&'&') => { + out.push(Token::And); + i += 2 + } + '|' if chars.get(i + 1) == Some(&'|') => { + out.push(Token::Or); + i += 2 + } + '!' if chars.get(i + 1) == Some(&'=') => { + out.push(Token::Op(Compare::Ne)); + i += 2 + } + '!' => { + out.push(Token::Not); + i += 1 + } + '<' if chars.get(i + 1) == Some(&'=') => { + out.push(Token::Op(Compare::Le)); + i += 2 + } + '>' if chars.get(i + 1) == Some(&'=') => { + out.push(Token::Op(Compare::Ge)); + i += 2 + } + '<' => { + out.push(Token::Op(Compare::Lt)); + i += 1 + } + '>' => { + out.push(Token::Op(Compare::Gt)); + i += 1 + } + '=' => { + out.push(Token::Op(Compare::Eq)); + i += 1 + } + '~' => { + out.push(Token::Op(Compare::Eq)); + i += 1 + } + _ => { + let start = i; + while i < chars.len() + && !chars[i].is_whitespace() + && !"()=!<>~&|".contains(chars[i]) + { + i += 1; + } + let word: String = chars[start..i].iter().collect(); + match word.to_lowercase().as_str() { + "and" => out.push(Token::And), + "or" => out.push(Token::Or), + "not" => out.push(Token::Not), + _ => out.push(Token::Word(word)), + } + } + } + } + Ok(out) +} + +struct Parser { + tokens: Vec, + at: usize, +} +impl Parser { + fn parse_or(&mut self) -> Result { + let mut left = self.parse_and()?; + while self.take(&Token::Or) { + left = Expr::Or(Box::new(left), Box::new(self.parse_and()?)); + } + Ok(left) + } + fn parse_and(&mut self) -> Result { + let mut left = self.parse_not()?; + while self.take(&Token::And) { + left = Expr::And(Box::new(left), Box::new(self.parse_not()?)); + } + Ok(left) + } + fn parse_not(&mut self) -> Result { + if self.take(&Token::Not) { + Ok(Expr::Not(Box::new(self.parse_not()?))) + } else { + self.parse_primary() + } + } + fn parse_primary(&mut self) -> Result { + if self.take(&Token::LParen) { + let e = self.parse_or()?; + if !self.take(&Token::RParen) { + bail!("missing closing parenthesis") + } + return Ok(e); + } + self.parse_predicate().map(Expr::Predicate) + } + fn parse_predicate(&mut self) -> Result { + let field = self.word()?.to_lowercase(); + match field.as_str() { + "done" => Ok(Predicate::Done(true)), + "open" => Ok(Predicate::Done(false)), + "has-note" => Ok(Predicate::HasNote), + "dated" => Ok(Predicate::HasDate(true)), + "undated" => Ok(Predicate::HasDate(false)), + "overdue" => Ok(Predicate::Overdue), + "alarm" => Ok(Predicate::Alarm), + "recurring" => Ok(Predicate::Recurring(true)), + "nonrecurring" => Ok(Predicate::Recurring(false)), + "text" | "note" | "category" => { + if self.optional_op() == Some(Compare::Ne) { + bail!("use 'not {field}=value' for a negative predicate") + } + let value = self.word()?; + Ok(match field.as_str() { + "text" => Predicate::TextContains(value), + "note" => Predicate::NoteContains(value), + _ => Predicate::Category(value), + }) + } + "priority" => { + let op = self.required_op()?; + Ok(Predicate::Priority(op, self.word()?.parse()?)) + } + "value" => { + let op = self.required_op()?; + Ok(Predicate::Value(op, self.word()?.parse()?)) + } + "due" => { + let _ = self.optional_op(); + let raw = self.word()?.to_lowercase(); + let days = raw.strip_suffix('d').unwrap_or(&raw).parse()?; + Ok(Predicate::DueWithin(days)) + } + _ => bail!("unknown filter predicate {field}"), + } + } + fn word(&mut self) -> Result { + let token = self.tokens.get(self.at).cloned(); + match token { + Some(Token::Word(s)) | Some(Token::String(s)) => { + self.at += 1; + Ok(s) + } + _ => bail!("expected a value"), + } + } + fn required_op(&mut self) -> Result { + match self.tokens.get(self.at).cloned() { + Some(Token::Op(op)) => { + self.at += 1; + Ok(op) + } + _ => bail!("expected a comparison operator"), + } + } + fn optional_op(&mut self) -> Option { + if let Some(Token::Op(op)) = self.tokens.get(self.at).cloned() { + self.at += 1; + Some(op) + } else { + None + } + } + fn take(&mut self, expected: &Token) -> bool { + if self.tokens.get(self.at) == Some(expected) { + self.at += 1; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::Category; + fn item() -> Item { + Item { + id: 1, + text: "Call Ada about Rust".into(), + note: "budget is 42".into(), + priority: 2, + when_at: None, + done_at: None, + alarm_at: None, + numeric_value: Some(42.0), + recurrence: String::new(), + created_at: String::new(), + updated_at: String::new(), + discarded: false, + categories: vec![Category { + id: 1, + name: "Work".into(), + parent_id: None, + kind: "standard".into(), + match_text: String::new(), + exclusive: false, + }], + } + } + #[test] + fn evaluates_boolean_expressions() { + let i = item(); + assert!(matches("category=Work and (priority<=2 or done)", &i).unwrap()); + assert!(!matches("done or text='missing'", &i).unwrap()); + } + #[test] + fn supports_text_and_numeric_predicates() { + let i = item(); + assert!(matches("text~rust and note~budget and value>=40", &i).unwrap()); + } + #[test] + fn rejects_invalid_expressions() { + assert!(parse("priority nope 3").is_err()); + assert!(parse("(done or open").is_err()); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..fdb435b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,113 @@ +mod app; +mod db; +mod filter; +mod model; +mod parser; +mod ui; + +use std::{io::stdout, path::PathBuf, time::Duration}; + +use anyhow::{Context, Result}; +use app::App; +use clap::Parser; +use crossterm::{ + event::{self, Event, KeyEventKind}, + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, +}; +use db::Database; +use ratatui::{Terminal, backend::CrosstermBackend}; + +#[derive(Debug, Parser)] +#[command( + name = "rogue-agenda", + version, + about = "A category-driven personal information manager for the terminal" +)] +struct Args { + /// Rogue Agenda SQLite document (the .agnd extension is added if absent) + #[arg(default_value = "rogue.agnd")] + document: PathBuf, + /// Populate a new document with representative items + #[arg(long)] + demo: bool, + /// Import a text or iCalendar (.ics) file, then exit + #[arg(long)] + import: Option, + /// Export as csv, json, markdown, html, or ics, then exit + #[arg(long, value_name = "FORMAT")] + export: Option, + /// Destination for --export + #[arg(long)] + output: Option, + /// Saved view to use for export (defaults to All Items) + #[arg(long, requires = "export")] + view: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + let mut path = args.document; + if path.extension().is_none() { + path.set_extension("agnd"); + } + let mut db = Database::open(&path)?; + if args.demo { + db.seed_demo()?; + } + if let Some(import) = args.import { + let count = db.import_path(&import)?; + println!("Imported {count} item(s) into {}", path.display()); + return Ok(()); + } + if let Some(format) = args.export { + let output = args.output.context("--output is required with --export")?; + let count = db.export(&format, &output, args.view.as_deref())?; + println!("Exported {count} item(s) to {}", output.display()); + return Ok(()); + } + run_tui(App::new(db, path)?) +} + +fn run_tui(mut app: App) -> Result<()> { + enable_raw_mode()?; + let _guard = TerminalGuard; + let mut out = stdout(); + execute!(out, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(out); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + let result = (|| -> Result<()> { + loop { + terminal.draw(|f| ui::draw(f, &mut app))?; + if app.should_quit { + break; + } + if event::poll(Duration::from_millis(250))? { + match event::read()? { + Event::Key(k) if k.kind == KeyEventKind::Press => app.handle_key(k)?, + Event::Mouse(m) => app.handle_mouse(m)?, + Event::Resize(_, _) => {} + _ => {} + } + } + } + app.db.checkpoint()?; + Ok(()) + })(); + terminal.show_cursor()?; + if let Err(e) = result { + return Err(e.context("Rogue Agenda session ended unexpectedly")); + } + Ok(()) +} + +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!(stdout(), DisableMouseCapture, LeaveAlternateScreen); + } +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..1064774 --- /dev/null +++ b/src/model.rs @@ -0,0 +1,110 @@ +#[derive(Debug, Clone, PartialEq)] +pub struct Item { + pub id: i64, + pub text: String, + pub note: String, + pub priority: i64, + pub when_at: Option, + pub done_at: Option, + pub alarm_at: Option, + pub numeric_value: Option, + pub recurrence: String, + pub created_at: String, + pub updated_at: String, + pub discarded: bool, + pub categories: Vec, +} + +impl Item { + pub fn category_names(&self) -> String { + self.categories + .iter() + .map(|c| c.name.as_str()) + .collect::>() + .join(", ") + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct Category { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub kind: String, + pub match_text: String, + pub exclusive: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewDef { + pub id: i64, + pub name: String, + pub kind: String, + pub filter_value: String, + pub sort_key: String, + pub show_done: bool, + pub filter_expr: String, + pub columns: Vec, + pub sections: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewColumn { + pub field: String, + pub heading: String, + pub width: u16, + pub aggregate: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct ViewSection { + pub id: i64, + pub heading: String, + pub filter_expr: String, + pub collapsed: bool, +} + +impl ViewDef { + pub fn columns_spec(&self) -> String { + self.columns + .iter() + .map(|c| format!("{}:{}:{}:{}", c.field, c.width, c.heading, c.aggregate)) + .collect::>() + .join(",") + } + pub fn sections_spec(&self) -> String { + self.sections + .iter() + .map(|s| { + format!( + "{}|{}{}", + s.heading, + s.filter_expr, + if s.collapsed { "|collapsed" } else { "" } + ) + }) + .collect::>() + .join(";") + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CategoryRule { + pub id: i64, + pub category_id: i64, + pub condition_expr: String, + pub action_kind: String, + pub action_value: String, + pub enabled: bool, +} + +#[derive(Debug, Clone, Default)] +pub struct ItemChanges { + pub text: Option, + pub note: Option, + pub priority: Option, + pub when_at: Option>, + pub alarm_at: Option>, + pub numeric_value: Option>, + pub recurrence: Option, +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..4a7e1b8 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,179 @@ +use anyhow::{Result, bail}; +use chrono::{ + Datelike, Duration, Local, Months, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday, +}; +use regex::Regex; + +/// Extracts the first recognizable date/time phrase and returns local RFC 3339. +/// Deliberately favors useful, predictable planner phrases over pretending to be +/// a complete natural-language parser. +pub fn extract_when(text: &str) -> Option { + extract_when_from(text, Local::now().naive_local()) +} + +pub fn extract_when_from(text: &str, now: NaiveDateTime) -> Option { + let lower = text.to_lowercase(); + let mut date = None; + + let iso = Regex::new(r"\b(\d{4})-(\d{2})-(\d{2})\b").unwrap(); + if let Some(c) = iso.captures(&lower) { + date = NaiveDate::from_ymd_opt(c[1].parse().ok()?, c[2].parse().ok()?, c[3].parse().ok()?); + } + + if date.is_none() { + let in_days = Regex::new(r"\bin\s+(\d+)\s+days?\b").unwrap(); + if let Some(c) = in_days.captures(&lower) { + date = Some(now.date() + Duration::days(c[1].parse().ok()?)); + } else if lower.contains("day after tomorrow") { + date = Some(now.date() + Duration::days(2)); + } else if lower.contains("tomorrow") { + date = Some(now.date() + Duration::days(1)); + } else if lower.contains("today") || lower.contains("tonight") { + date = Some(now.date()); + } + } + + if date.is_none() { + let weekdays = [ + ("monday", Weekday::Mon), + ("tuesday", Weekday::Tue), + ("wednesday", Weekday::Wed), + ("thursday", Weekday::Thu), + ("friday", Weekday::Fri), + ("saturday", Weekday::Sat), + ("sunday", Weekday::Sun), + ]; + for (word, weekday) in weekdays { + if lower.contains(word) { + let mut delta = (weekday.num_days_from_monday() as i64 + - now.weekday().num_days_from_monday() as i64 + + 7) + % 7; + // Treat both "Friday" and "next Friday" as the next occurrence. + // This mirrors planner expectations and avoids surprising dates + // almost two weeks away when the weekday is still ahead. + if delta == 0 { + delta += 7; + } + date = Some(now.date() + Duration::days(delta)); + break; + } + } + } + + let date = date?; + let time_re = Regex::new(r"\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b").unwrap(); + let time24_re = Regex::new(r"\b(?:at\s+)([01]?\d|2[0-3]):([0-5]\d)\b").unwrap(); + let time = if let Some(c) = time_re.captures(&lower) { + let mut hour: u32 = c[1].parse().ok()?; + let minute: u32 = c.get(2).map_or("0", |v| v.as_str()).parse().ok()?; + if hour == 12 { + hour = 0; + } + if &c[3] == "pm" { + hour += 12; + } + NaiveTime::from_hms_opt(hour, minute, 0)? + } else if let Some(c) = time24_re.captures(&lower) { + NaiveTime::from_hms_opt(c[1].parse().ok()?, c[2].parse().ok()?, 0)? + } else { + NaiveTime::from_hms_opt(9, 0, 0)? + }; + + let local = Local.from_local_datetime(&date.and_time(time)).single()?; + Some(local.to_rfc3339()) +} + +pub fn pretty_when(value: Option<&str>) -> String { + let Some(value) = value else { + return String::new(); + }; + chrono::DateTime::parse_from_rfc3339(value) + .map(|d| d.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|_| value.to_owned()) +} + +pub fn next_occurrence(current: &str, recurrence: &str) -> Result { + let current = chrono::DateTime::parse_from_rfc3339(current)?.with_timezone(&Local); + let rule = recurrence.trim().to_lowercase(); + let next = match rule.as_str() { + "daily" => current + Duration::days(1), + "weekdays" => { + let mut next = current + Duration::days(1); + while matches!(next.weekday(), Weekday::Sat | Weekday::Sun) { + next += Duration::days(1) + } + next + } + "weekly" => current + Duration::weeks(1), + "monthly" => current + .checked_add_months(Months::new(1)) + .ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?, + "yearly" | "annually" => current + .checked_add_months(Months::new(12)) + .ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?, + _ => { + let re = Regex::new(r"^every\s+(\d+)\s+(days?|weeks?|months?|years?)$").unwrap(); + let Some(c) = re.captures(&rule) else { + bail!( + "recurrence must be daily, weekdays, weekly, monthly, yearly, or 'every N days/weeks/months/years'" + ) + }; + let n: u32 = c[1].parse()?; + match &c[2] { + "day" | "days" => current + Duration::days(n as i64), + "week" | "weeks" => current + Duration::weeks(n as i64), + "month" | "months" => current + .checked_add_months(Months::new(n)) + .ok_or_else(|| anyhow::anyhow!("monthly date is out of range"))?, + _ => current + .checked_add_months(Months::new(n.saturating_mul(12))) + .ok_or_else(|| anyhow::anyhow!("yearly date is out of range"))?, + } + } + }; + Ok(next.to_rfc3339()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn base() -> NaiveDateTime { + NaiveDate::from_ymd_opt(2026, 8, 16) + .unwrap() + .and_hms_opt(12, 0, 0) + .unwrap() + } + + #[test] + fn parses_relative_dates_and_time() { + let got = extract_when_from("Call Ada tomorrow at 3:30pm", base()).unwrap(); + assert!(got.contains("2026-08-17T15:30:00")); + } + + #[test] + fn parses_next_weekday() { + let got = extract_when_from("Review next Friday", base()).unwrap(); + assert!(got.contains("2026-08-21T09:00:00")); + } + + #[test] + fn ignores_undated_text() { + assert_eq!(extract_when_from("An evergreen idea", base()), None); + } + + #[test] + fn advances_recurring_dates() { + assert!( + next_occurrence("2026-08-14T09:00:00+00:00", "weekdays") + .unwrap() + .contains("2026-08-17T09:00:00") + ); + assert!( + next_occurrence("2026-08-16T09:00:00+00:00", "every 2 weeks") + .unwrap() + .contains("2026-08-30T09:00:00") + ); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b6b2828 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,935 @@ +use std::collections::HashMap; + +use chrono::Local; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Margin, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span, Text}, + widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Wrap}, +}; + +use crate::{ + app::{App, FormKind, Mode}, + filter, + model::{Item, ViewColumn}, + parser::pretty_when, +}; + +const BLUE: Color = Color::Rgb(0, 0, 128); +const CYAN: Color = Color::Cyan; +const CANVAS: Color = Color::Rgb(192, 192, 192); +const RED: Color = Color::Rgb(128, 0, 0); + +pub fn draw(frame: &mut Frame<'_>, app: &mut App) { + frame.render_widget( + Block::default().style(Style::default().bg(Color::Black)), + frame.area(), + ); + let height = frame.area().height; + if height < 10 || frame.area().width < 40 { + frame.render_widget( + Paragraph::new("Rogue Agenda needs at least 40×10") + .style(Style::default().fg(Color::Yellow).bg(BLUE)) + .alignment(Alignment::Center), + frame.area(), + ); + return; + } + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + Constraint::Length(2), + ]) + .split(frame.area()); + draw_header(frame, app, chunks[0]); + draw_view_tabs(frame, app, chunks[1]); + draw_items(frame, app, chunks[2]); + draw_status(frame, app, chunks[3]); + draw_commands(frame, app, chunks[4]); + draw_overlay(frame, app); +} + +fn draw_header(frame: &mut Frame<'_>, app: &App, area: Rect) { + let alarms = app.db.due_alarm_count().unwrap_or(0); + let right = format!( + "{} {}{}", + Local::now().format("%Y-%m-%d"), + Local::now().format("%H:%M"), + if alarms > 0 { + format!(" ALARM:{alarms}") + } else { + String::new() + } + ); + let width = area.width as usize; + let left1 = format!("File: {}", app.path.display()); + let pad = width.saturating_sub(left1.chars().count() + right.chars().count()); + let line1 = format!("{left1}{}{right}", " ".repeat(pad)); + let line2 = format!( + "View: {}{}", + app.current_view().name, + if app.search.is_empty() { + String::new() + } else { + format!(" /{}", app.search) + } + ); + frame.render_widget( + Paragraph::new(vec![Line::from(line1), Line::from(line2)]).style( + Style::default() + .fg(Color::White) + .bg(BLUE) + .add_modifier(Modifier::BOLD), + ), + area, + ); +} + +fn draw_view_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + app.view_regions.clear(); + let mut x = area.x; + frame.render_widget(Block::default().style(Style::default().bg(CANVAS)), area); + for (i, v) in app.views.iter().enumerate() { + let w = (v.name.chars().count() + 2).min(20) as u16; + if x + w > area.right() { + break; + } + let r = Rect::new(x, area.y, w, 1); + app.view_regions.push(r); + let st = if i == app.view_index { + Style::default() + .fg(BLUE) + .bg(Color::White) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White).bg(BLUE) + }; + frame.render_widget(Paragraph::new(format!(" {} ", v.name)).style(st), r); + x += w + 1; + } +} + +fn draw_items(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + frame.render_widget(Block::default().style(Style::default().bg(CANVAS)), area); + app.item_rows.clear(); + if app.items.is_empty() { + frame.render_widget( + Paragraph::new(if app.search.is_empty() { + " No items. Press Insert or n to capture one." + } else { + " No items match this search. Press Esc to clear it." + }) + .style(Style::default().fg(BLUE).bg(CANVAS)), + area, + ); + return; + } + if app.current_view().kind == "datebook" { + draw_datebook(frame, app, area); + return; + } + let mut columns = app.current_view().columns.clone(); + if area.width < 70 && columns.len() > 2 { + let item = columns + .iter() + .find(|c| c.field == "item") + .cloned() + .unwrap_or_else(|| columns[0].clone()); + let detail = columns + .iter() + .find(|c| c.field == "when") + .cloned() + .or_else(|| columns.iter().find(|c| c.field != item.field).cloned()); + columns = vec![item]; + if let Some(detail) = detail { + columns.push(detail) + } + } + let widths = column_widths(&columns, area.width); + let mut display: Vec = vec![]; + let sections = app.current_view().sections.clone(); + if sections.is_empty() { + display.push(DisplayLine::Header); + for index in 0..app.items.len() { + display.push(DisplayLine::Item(index)); + } + if columns.iter().any(|c| c.aggregate != "none") { + display.push(DisplayLine::Aggregate((0..app.items.len()).collect())); + } + } else { + for section in sections { + display.push(DisplayLine::Section(format!( + "{}{}", + if section.collapsed { "▸ " } else { "▾ " }, + section.heading + ))); + if section.collapsed { + continue; + } + display.push(DisplayLine::Header); + let indices = app + .items + .iter() + .enumerate() + .filter(|(_, item)| filter::matches(§ion.filter_expr, item).unwrap_or(false)) + .map(|(i, _)| i) + .collect::>(); + for index in &indices { + display.push(DisplayLine::Item(*index)); + } + if columns.iter().any(|c| c.aggregate != "none") { + display.push(DisplayLine::Aggregate(indices)); + } + } + } + let selected_line = display + .iter() + .position(|line| matches!(line,DisplayLine::Item(i) if *i==app.selected)) + .unwrap_or(0); + let visible = area.height as usize; + if selected_line < app.scroll { + app.scroll = selected_line + } + if selected_line >= app.scroll + visible { + app.scroll = selected_line + 1 - visible + } + let mut lines = vec![]; + for (shown, line) in display.iter().skip(app.scroll).take(visible).enumerate() { + match line { + DisplayLine::Section(heading) => lines.push(Line::from(Span::styled( + format!("── {heading} "), + Style::default() + .fg(BLUE) + .bg(CANVAS) + .add_modifier(Modifier::BOLD), + ))), + DisplayLine::Header => lines.push(cells_line( + columns.iter().map(|c| c.heading.clone()).collect(), + &widths, + Style::default() + .fg(BLUE) + .bg(CANVAS) + .add_modifier(Modifier::BOLD), + )), + DisplayLine::Item(index) => { + let item = &app.items[*index]; + let style = if *index == app.selected { + Style::default() + .fg(Color::White) + .bg(RED) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(if item.done_at.is_some() { + Color::DarkGray + } else { + Color::Black + }) + .bg(CANVAS) + }; + let cells = columns.iter().map(|c| column_value(c, item, app)).collect(); + lines.push(cells_line(cells, &widths, style)); + app.item_rows.push(( + Rect::new(area.x, area.y + shown as u16, area.width, 1), + item.id, + )); + } + DisplayLine::Aggregate(indices) => { + let cells = columns + .iter() + .map(|c| aggregate_value(c, indices, &app.items)) + .collect(); + lines.push(cells_line( + cells, + &widths, + Style::default() + .fg(BLUE) + .bg(CANVAS) + .add_modifier(Modifier::BOLD), + )); + } + } + } + frame.render_widget( + Paragraph::new(lines).style(Style::default().bg(CANVAS)), + area, + ); +} + +enum DisplayLine { + Section(String), + Header, + Item(usize), + Aggregate(Vec), +} + +fn column_widths(columns: &[ViewColumn], total: u16) -> Vec { + let spacing = columns.len().saturating_sub(1) as u16; + let available = total.saturating_sub(spacing).max(columns.len() as u16); + let weights = columns + .iter() + .map(|c| c.width.max(1) as u32) + .sum::() + .max(1); + let mut widths = columns + .iter() + .map(|c| ((available as u32 * c.width.max(1) as u32 / weights).max(1)) as usize) + .collect::>(); + let used = widths.iter().sum::(); + if let Some(first) = widths.first_mut() { + *first += available as usize - used; + } + widths +} +fn cells_line(values: Vec, widths: &[usize], style: Style) -> Line<'static> { + let text = values + .into_iter() + .zip(widths) + .map(|(v, w)| fit(&v, *w)) + .collect::>() + .join(" "); + Line::from(Span::styled(text, style)) +} +fn fit(value: &str, width: usize) -> String { + let mut chars = value.chars(); + let mut out = chars.by_ref().take(width).collect::(); + let count = out.chars().count(); + if chars.next().is_some() && width > 0 { + out.pop(); + out.push('…') + } else if count < width { + out.push_str(&" ".repeat(width - count)) + } + out +} +fn column_value(column: &ViewColumn, item: &Item, app: &App) -> String { + match column.field.as_str() { + "item" => { + let marker = if app.marked.contains(&item.id) { + "◆" + } else if item.done_at.is_some() { + "✓" + } else if app.db.unmet_dependencies(item.id).unwrap_or(0) > 0 { + "◦" + } else { + "•" + }; + format!("{marker} {}", item.text.replace('\n', " ")) + } + "categories" => format_categories(item.category_names()), + "when" => pretty_when(item.when_at.as_deref()), + "priority" => item.priority.to_string(), + "note" => item.note.replace('\n', " "), + "value" => item + .numeric_value + .map(|v| format!("{v:.2}")) + .unwrap_or_default(), + "done" => pretty_when(item.done_at.as_deref()), + "alarm" => pretty_when(item.alarm_at.as_deref()), + "recurrence" => item.recurrence.clone(), + "created" => pretty_when(Some(&item.created_at)), + "updated" => pretty_when(Some(&item.updated_at)), + _ => String::new(), + } +} +fn aggregate_value(column: &ViewColumn, indices: &[usize], items: &[Item]) -> String { + if column.aggregate == "none" { + return String::new(); + } + if column.aggregate == "count" { + return format!("count {}", indices.len()); + } + let values = indices + .iter() + .filter_map(|i| match column.field.as_str() { + "value" => items[*i].numeric_value, + "priority" => Some(items[*i].priority as f64), + _ => None, + }) + .collect::>(); + if values.is_empty() { + return String::new(); + } + let value = match column.aggregate.as_str() { + "sum" => values.iter().sum(), + "avg" => values.iter().sum::() / values.len() as f64, + "min" => values.iter().copied().fold(f64::INFINITY, f64::min), + "max" => values.iter().copied().fold(f64::NEG_INFINITY, f64::max), + _ => return String::new(), + }; + format!("{} {value:.2}", column.aggregate) +} + +fn draw_datebook(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let mut lines: Vec = vec![]; + let mut dates: HashMap = HashMap::new(); + for i in &app.items { + let date = pretty_when(i.when_at.as_deref()) + .chars() + .take(10) + .collect::(); + if !dates.contains_key(&date) { + lines.push(Line::from(Span::styled( + format!("── {date} ──"), + Style::default() + .fg(BLUE) + .bg(CANVAS) + .add_modifier(Modifier::BOLD), + ))); + dates.insert(date.clone(), true); + } + let selected = app.items.get(app.selected).map(|s| s.id) == Some(i.id); + let style = if selected { + Style::default().fg(Color::White).bg(RED) + } else { + Style::default().fg(Color::Black).bg(CANVAS) + }; + let y = area.y + lines.len() as u16; + app.item_rows + .push((Rect::new(area.x, y, area.width, 1), i.id)); + lines.push(Line::from(Span::styled( + format!( + " {} {}", + pretty_when(i.when_at.as_deref()) + .chars() + .skip(11) + .collect::(), + i.text + ), + style, + ))); + } + frame.render_widget( + Paragraph::new(lines).style(Style::default().bg(CANVAS)), + area, + ); +} + +fn format_categories(s: String) -> String { + if s.is_empty() { + String::new() + } else { + s.split(", ") + .map(|v| format!("·{v}")) + .collect::>() + .join(" ") + } +} + +fn draw_status(frame: &mut Frame<'_>, app: &App, area: Rect) { + let totals = app + .items + .iter() + .filter_map(|i| i.numeric_value) + .collect::>(); + let aggregate = if totals.is_empty() { + String::new() + } else { + format!( + " Σ {:.2} avg {:.2}", + totals.iter().sum::(), + totals.iter().sum::() / totals.len() as f64 + ) + }; + let right = format!("{} item(s){}", app.items.len(), aggregate); + let pad = area + .width + .saturating_sub((app.status.chars().count() + right.chars().count() + 2) as u16) + as usize; + frame.render_widget( + Paragraph::new(format!(" {}{}{right}", app.status, " ".repeat(pad))) + .style(Style::default().fg(Color::Yellow).bg(BLUE)), + area, + ); +} + +fn draw_commands(frame: &mut Frame<'_>, app: &mut App, area: Rect) { + let labels = [ + "Help", "Edit", "Choices", "Done", "Note", "Props", "Mark", "Vw Mgr", "Cat Mgr", "Menu", + ]; + app.command_regions.clear(); + let cells = Layout::default() + .direction(Direction::Horizontal) + .constraints((0..10).map(|_| Constraint::Ratio(1, 10))) + .split(area); + for (i, r) in cells.iter().enumerate() { + app.command_regions.push(*r); + frame.render_widget( + Paragraph::new(vec![ + Line::from(format!("F{}", i + 1)), + Line::from(labels[i]), + ]) + .alignment(Alignment::Center) + .style(Style::default().fg(Color::White).bg(BLUE)), + *r, + ); + } +} + +fn draw_overlay(frame: &mut Frame<'_>, app: &App) { + match &app.mode { + Mode::Normal => {} + Mode::Help => { + let text = Text::from(vec![ + Line::from(Span::styled( + "ROGUE AGENDA HELP", + Style::default().fg(CYAN).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from( + "Capture a sentence first; assign categories and shape views as patterns emerge.", + ), + Line::from(""), + Line::from("↑↓/jk move Insert/n new Enter/F2 edit F3/c categories"), + Line::from("F4/d done F5 note F6/p properties F7/Space mark"), + Line::from("F8/v views F9 categories F10/m menu / search"), + Line::from("[ ] views r prerequisites Delete trash/recover"), + Line::from("Ctrl-S save q quit"), + Line::from(""), + Line::from("Mouse: click items, view tabs, or function-key tiles."), + Line::from("Press any key to close."), + ]); + popup( + frame, + center(frame.area(), 74, 15), + "Help", + Paragraph::new(text).wrap(Wrap { trim: false }), + ); + } + Mode::Input(input) => { + let h = if input.multiline { 18 } else { 7 }; + let shown = if input.value.is_empty() { + Span::styled("Type here…", Style::default().fg(Color::DarkGray)) + } else { + Span::raw(input.value.clone()) + }; + popup( + frame, + center(frame.area(), 78, h), + &input.title, + Paragraph::new(Line::from(shown)).wrap(Wrap { trim: false }), + ); + } + Mode::Properties(p) => { + let names = [ + "Priority (1 highest)", + "When (phrase or RFC3339)", + "Alarm (phrase or RFC3339)", + "Numeric value", + "Recurrence (daily/weekly/monthly/every N days)", + ]; + let lines = names + .iter() + .enumerate() + .flat_map(|(i, n)| { + [ + Line::from(Span::styled( + *n, + Style::default().fg(if i == p.field { Color::Yellow } else { CYAN }), + )), + Line::from(Span::styled( + format!("> {}", p.values[i]), + Style::default().fg(Color::White).bg(if i == p.field { + RED + } else { + BLUE + }), + )), + ] + }) + .chain([ + Line::from(""), + Line::from("Tab fields · Enter save · Esc cancel"), + ]) + .collect::>(); + popup( + frame, + center(frame.area(), 78, 18), + "Item Properties", + Paragraph::new(lines), + ); + } + Mode::Form(form) => { + let (title, names, hint) = match form.kind { + FormKind::Category(_) => ( + "Category Definition", + vec![ + "Name", + "Parent category", + "Kind", + "Match phrases (comma separated)", + "Mutually exclusive (yes/no)", + "Rule condition", + "Rule action", + ], + "Rule: category=Work and priority<=2 → assign:Important", + ), + FormKind::View(_) => ( + "Live View Definition", + vec![ + "Name", + "Kind", + "Filter value", + "Boolean filter", + "Sort key", + "Show done (yes/no)", + "Columns (field:width:heading:aggregate, …)", + "Sections (heading|filter; …)", + ], + "Filters: category=Work and (open or priority<=2)", + ), + }; + let filter_source = match form.kind { + FormKind::Category(_) => form.values.get(5), + FormKind::View(_) => form.values.get(3), + } + .map(String::as_str) + .unwrap_or(""); + let preview = match app.db.count_matching_filter(filter_source) { + Ok(n) => format!("Preview: {n} existing item(s) match"), + Err(e) => format!("Preview error: {e}"), + }; + let lines = names + .iter() + .enumerate() + .flat_map(|(i, name)| { + [ + Line::from(Span::styled( + *name, + Style::default().fg(if i == form.field { Color::Yellow } else { CYAN }), + )), + Line::from(Span::styled( + format!("> {}", form.values[i]), + Style::default().fg(Color::White).bg(if i == form.field { + RED + } else { + BLUE + }), + )), + ] + }) + .chain([ + Line::from(""), + Line::from(hint), + Line::from(preview), + Line::from("Tab fields · Enter save · Esc back"), + ]) + .collect::>(); + popup( + frame, + center(frame.area(), 96, 24), + title, + Paragraph::new(lines), + ); + } + Mode::Dependencies { + item_id, + choices, + selected, + } => { + let assigned = app.db.dependency_ids(*item_id).unwrap_or_default(); + let items = choices + .iter() + .map(|item| { + ListItem::new(format!( + "{} {}", + if assigned.contains(&item.id) { + "[×]" + } else { + "[ ]" + }, + item.text + )) + }) + .collect::>(); + let mut state = ListState::default().with_selected(Some(*selected)); + let area = center(frame.area(), 76, (choices.len() as u16 + 4).clamp(8, 22)); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(" Prerequisites (Space toggles · Enter closes) ") + .style(Style::default().fg(Color::White).bg(BLUE)), + ) + .highlight_style( + Style::default() + .fg(Color::White) + .bg(RED) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); + } + Mode::Categories => draw_category_list( + frame, + app, + "Assign Categories (Space toggles · Enter closes)", + true, + ), + Mode::CategoryManager => draw_category_list( + frame, + app, + "Category Manager (n add · e edit · Delete discard)", + false, + ), + Mode::Views => { + let items = app + .views + .iter() + .map(|v| { + ListItem::new(format!("{:<18} {:<9} sort: {}", v.name, v.kind, v.sort_key)) + }) + .collect::>(); + let mut state = ListState::default().with_selected(Some(app.view_selected)); + let area = center(frame.area(), 64, (app.views.len() as u16 + 4).min(20)); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title( + " View Manager (n add · e edit · Ctrl-↑↓ move · Delete discard) ", + ) + .style(Style::default().fg(Color::White).bg(BLUE)), + ) + .highlight_style( + Style::default() + .fg(Color::White) + .bg(RED) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); + } + Mode::Menu => { + popup( + frame, + center(frame.area(), 46, 10), + "Rogue Agenda Menu", + Paragraph::new(vec![ + Line::from(" n New item"), + Line::from(" s Save / checkpoint"), + Line::from(" h Help"), + Line::from(" r Prerequisites"), + Line::from(" q Quit"), + Line::from(""), + Line::from(" Esc closes the menu"), + ]) + .style(Style::default().fg(Color::White)), + ); + } + } +} + +fn draw_category_list(frame: &mut Frame<'_>, app: &App, title: &str, assign: bool) { + let assigned = app + .selected_item() + .and_then(|i| app.db.item_category_ids(i.id).ok()) + .unwrap_or_default(); + let excluded = app + .selected_item() + .and_then(|i| app.db.excluded_category_ids(i.id).ok()) + .unwrap_or_default(); + let names: HashMap = app + .categories + .iter() + .map(|c| (c.id, c.name.as_str())) + .collect(); + let items = app + .categories + .iter() + .map(|c| { + let indent = if c.parent_id.is_some() { " └ " } else { "" }; + let marker = if assign && assigned.contains(&c.id) { + "[×]" + } else if assign && excluded.contains(&c.id) { + "[-]" + } else if assign { + "[ ]" + } else { + " • " + }; + let parent = c + .parent_id + .and_then(|p| names.get(&p)) + .map(|p| format!(" ({p})")) + .unwrap_or_default(); + let rule = app + .db + .category_rules(c.id) + .ok() + .and_then(|rules| rules.into_iter().next()) + .map(|r| { + format!( + " rule: {} → {}:{}", + r.condition_expr, r.action_kind, r.action_value + ) + }) + .unwrap_or_default(); + ListItem::new(format!( + "{marker} {indent}{}{} {}{}", + c.name, + parent, + if c.match_text.is_empty() { + String::new() + } else { + format!("matches: {}", c.match_text) + }, + rule + )) + }) + .collect::>(); + let mut state = ListState::default().with_selected(Some(app.category_selected)); + let area = center(frame.area(), 78, (app.categories.len() as u16 + 4).min(22)); + frame.render_widget(Clear, area); + frame.render_stateful_widget( + List::new(items) + .block( + Block::bordered() + .title(format!(" {title} ")) + .style(Style::default().fg(Color::White).bg(BLUE)), + ) + .highlight_style( + Style::default() + .fg(Color::White) + .bg(RED) + .add_modifier(Modifier::BOLD), + ), + area, + &mut state, + ); +} + +fn popup(frame: &mut Frame<'_>, area: Rect, title: &str, widget: Paragraph<'_>) { + frame.render_widget(Clear, area); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(format!(" {title} ")) + .border_style(Style::default().fg(CYAN)) + .style(Style::default().bg(BLUE)), + area, + ); + frame.render_widget( + widget.style(Style::default().fg(Color::White).bg(BLUE)), + area.inner(Margin { + horizontal: 1, + vertical: 1, + }), + ); +} +fn center(area: Rect, width: u16, height: u16) -> Rect { + let w = width.min(area.width.saturating_sub(2)); + let h = height.min(area.height.saturating_sub(2)); + Rect::new( + area.x + (area.width - w) / 2, + area.y + (area.height - h) / 2, + w, + h, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{app::App, db::Database, model::ItemChanges}; + use ratatui::{Terminal, backend::TestBackend}; + use tempfile::tempdir; + #[test] + fn renders_main_workspace() { + let d = tempdir().unwrap(); + let p = d.path().join("ui.agnd"); + let mut db = Database::open(&p).unwrap(); + db.add_item("Call Grace tomorrow").unwrap(); + let mut app = App::new(db, p).unwrap(); + let backend = TestBackend::new(100, 30); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, &mut app)).unwrap(); + let screen = term + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect::(); + assert!(screen.contains("View: All Items")); + assert!(screen.contains("Call Grace")); + assert!(screen.contains("Cat Mgr")); + } + + #[test] + fn renders_persistent_columns_sections_and_aggregates() { + let d = tempdir().unwrap(); + let p = d.path().join("designed.agnd"); + let mut db = Database::open(&p).unwrap(); + let item = db.add_item("File the urgent report").unwrap(); + db.update_item( + item, + &ItemChanges { + priority: Some(1), + numeric_value: Some(Some(75.0)), + ..Default::default() + }, + ) + .unwrap(); + let view_id = db + .save_view_design( + None, + "Dashboard", + "list", + "", + "priority", + true, + "open", + "item:70:Action:none,value:30:Cost:sum", + "Urgent work|priority=1", + ) + .unwrap(); + let mut app = App::new(db, p).unwrap(); + app.view_index = app.views.iter().position(|v| v.id == view_id).unwrap(); + app.refresh().unwrap(); + let backend = TestBackend::new(90, 24); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, &mut app)).unwrap(); + let screen = term + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect::(); + assert!(screen.contains("Urgent work")); + assert!(screen.contains("Action")); + assert!(screen.contains("Cost")); + assert!(screen.contains("sum 75.00")); + } + + #[test] + fn compact_layout_keeps_primary_fields_visible() { + let d = tempdir().unwrap(); + let p = d.path().join("compact.agnd"); + let mut db = Database::open(&p).unwrap(); + db.add_item("Compact item tomorrow").unwrap(); + let mut app = App::new(db, p).unwrap(); + let backend = TestBackend::new(50, 15); + let mut term = Terminal::new(backend).unwrap(); + term.draw(|f| draw(f, &mut app)).unwrap(); + let screen = term + .backend() + .buffer() + .content + .iter() + .map(|c| c.symbol()) + .collect::(); + assert!(screen.contains("Compact item")); + assert!(screen.contains("When")); + } +}