Compare commits
3 Commits
main
..
v0.1.0-dev.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 302164b7ed | |||
| 5e8020aa38 | |||
| 15d7f5e873 |
Generated
+421
-5
@@ -134,6 +134,15 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -429,15 +438,39 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bincode"
|
||||
version = "1.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
"bit-vec 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -456,6 +489,15 @@ version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.5.1"
|
||||
@@ -487,6 +529,17 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -702,6 +755,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core_detect"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.13.0"
|
||||
@@ -742,6 +801,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
@@ -790,6 +858,34 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
@@ -802,6 +898,15 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cursor-icon"
|
||||
version = "1.2.0"
|
||||
@@ -820,6 +925,16 @@ version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "7.0.0"
|
||||
@@ -1058,6 +1173,29 @@ dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"core_detect",
|
||||
"multiversion_no_op",
|
||||
"rustversion",
|
||||
"scopeguard",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs_io"
|
||||
version = "0.1.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1187,6 +1325,17 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -1237,6 +1386,12 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
@@ -1409,6 +1564,19 @@ dependencies = [
|
||||
"vello_common",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "globset"
|
||||
version = "0.4.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bstr",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glow"
|
||||
version = "0.17.0"
|
||||
@@ -1501,6 +1669,43 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-matcher"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9417543f4870fc8f1c8e1af870afae2431007626d9e703fce6471c468d33847"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-regex"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ce0c256c3ad82bcc07b812c15a45ec1d398122e8e15124f96695234db7112ef"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"grep-matcher",
|
||||
"log",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "grep-searcher"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72348823a0eafc4bc2e9051064f28b5b42cc100b571b3a35d67918d711efcbc6"
|
||||
dependencies = [
|
||||
"bstr",
|
||||
"encoding_rs",
|
||||
"encoding_rs_io",
|
||||
"grep-matcher",
|
||||
"log",
|
||||
"memchr",
|
||||
"memmap2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "guillotiere"
|
||||
version = "0.7.0"
|
||||
@@ -1575,6 +1780,31 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ignore"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"globset",
|
||||
"log",
|
||||
"memchr",
|
||||
"regex-automata",
|
||||
"same-file",
|
||||
"walkdir",
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
@@ -1735,6 +1965,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
@@ -1851,6 +2091,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
|
||||
|
||||
[[package]]
|
||||
name = "linked-hash-map"
|
||||
version = "0.5.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -1959,6 +2205,30 @@ dependencies = [
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion_no_op"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
@@ -1966,7 +2236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -2042,16 +2312,21 @@ dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"notescraft-calendar",
|
||||
"notescraft-engine",
|
||||
"notescraft-templates",
|
||||
"notescraft-themes",
|
||||
"notescraft-ui-egui",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-acp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2066,6 +2341,28 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-blocks"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"notescraft-markdown",
|
||||
"notescraft-regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-calendar"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"notescraft-markdown",
|
||||
"notescraft-templates",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-canvas"
|
||||
version = "0.1.0"
|
||||
@@ -2100,7 +2397,9 @@ name = "notescraft-editor"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"notescraft-blocks",
|
||||
"notescraft-markdown",
|
||||
"notescraft-syntax",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -2109,14 +2408,24 @@ dependencies = [
|
||||
name = "notescraft-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"craft-atomic",
|
||||
"craft-settings",
|
||||
"flate2",
|
||||
"grep-regex",
|
||||
"grep-searcher",
|
||||
"ignore",
|
||||
"notescraft-blocks",
|
||||
"notescraft-calendar",
|
||||
"notescraft-git",
|
||||
"notescraft-index",
|
||||
"notescraft-knowledge",
|
||||
"notescraft-lint",
|
||||
"notescraft-markdown",
|
||||
"notescraft-math",
|
||||
"notescraft-pdf",
|
||||
"notescraft-plugins",
|
||||
"notescraft-regex",
|
||||
"notescraft-syntax",
|
||||
"notescraft-templates",
|
||||
"notescraft-themes",
|
||||
"notescraft-vault",
|
||||
@@ -2157,6 +2466,24 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-knowledge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-lint"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-lottie"
|
||||
version = "0.1.0"
|
||||
@@ -2215,6 +2542,22 @@ dependencies = [
|
||||
"wat",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-regex"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-syntax"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"sha2",
|
||||
"syntect",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notescraft-templates"
|
||||
version = "0.1.0"
|
||||
@@ -2246,6 +2589,7 @@ dependencies = [
|
||||
"log",
|
||||
"notescraft-acp",
|
||||
"notescraft-backlinks",
|
||||
"notescraft-calendar",
|
||||
"notescraft-canvas",
|
||||
"notescraft-diagrams",
|
||||
"notescraft-editor",
|
||||
@@ -2254,9 +2598,12 @@ dependencies = [
|
||||
"notescraft-graph",
|
||||
"notescraft-i18n",
|
||||
"notescraft-index",
|
||||
"notescraft-knowledge",
|
||||
"notescraft-lint",
|
||||
"notescraft-lottie",
|
||||
"notescraft-markdown",
|
||||
"notescraft-math",
|
||||
"notescraft-syntax",
|
||||
"notescraft-templates",
|
||||
"notescraft-themes",
|
||||
"notescraft-vault",
|
||||
@@ -2265,6 +2612,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"unicode-bidi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3128,6 +3476,23 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[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 = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -3339,6 +3704,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
@@ -3567,6 +3943,25 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syntect"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"fancy-regex",
|
||||
"flate2",
|
||||
"fnv",
|
||||
"once_cell",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"thiserror 2.0.21",
|
||||
"walkdir",
|
||||
"yaml-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -3773,6 +4168,12 @@ dependencies = [
|
||||
"rustc-hash 2.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
@@ -3790,6 +4191,12 @@ version = "2.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-general-category"
|
||||
version = "1.1.0"
|
||||
@@ -4252,8 +4659,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -4325,7 +4732,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set",
|
||||
"bit-set 0.10.0",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -4919,6 +5326,15 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yaml-rust"
|
||||
version = "0.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85"
|
||||
dependencies = [
|
||||
"linked-hash-map",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.19.0"
|
||||
|
||||
+17
@@ -26,11 +26,17 @@ notescraft-lottie = { path = "crates/lottie" }
|
||||
notescraft-acp = { path = "crates/acp" }
|
||||
notescraft-pdf = { path = "crates/pdf" }
|
||||
notescraft-markdown = { path = "crates/markdown" }
|
||||
notescraft-syntax = { path = "crates/syntax" }
|
||||
notescraft-regex = { path = "crates/regex" }
|
||||
notescraft-index = { path = "crates/index" }
|
||||
notescraft-backlinks = { path = "crates/backlinks" }
|
||||
notescraft-blocks = { path = "crates/blocks" }
|
||||
notescraft-knowledge = { path = "crates/knowledge" }
|
||||
notescraft-lint = { path = "crates/lint" }
|
||||
notescraft-canvas = { path = "crates/canvas" }
|
||||
notescraft-graph = { path = "crates/graph" }
|
||||
notescraft-templates = { path = "crates/templates" }
|
||||
notescraft-calendar = { path = "crates/calendar" }
|
||||
notescraft-plugins = { path = "crates/plugins" }
|
||||
notescraft-themes = { path = "crates/themes" }
|
||||
notescraft-engine = { path = "crates/engine" }
|
||||
@@ -46,7 +52,10 @@ notescraft-testkit = { path = "crates/testkit" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
rfd = "0.17"
|
||||
thiserror = "2"
|
||||
# Native macOS menu bar (apps/notescraft only; owner-approved: close/minimise/zoom live in the menu bar since the title bar is gone). No default features: no GTK.
|
||||
muda = { version = "=0.21.2", default-features = false }
|
||||
egui = { version = "0.36", default-features = false }
|
||||
unicode-bidi = { version = "0.3.18", default-features = false, features = ["std", "hardcoded-data"] }
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
@@ -56,6 +65,14 @@ cpal = "0.16"
|
||||
notify = "8"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
toml = "1"
|
||||
# Content search over the files on disk before the index is ready (the ripgrep library crates; engine only; owner-approved 2026-10-18).
|
||||
ignore = "0.4"
|
||||
grep-regex = "0.1"
|
||||
grep-searcher = "0.1"
|
||||
# Syntax highlighting for fenced code; pure-Rust regex backend, no onig (owner-approved).
|
||||
syntect = { version = "5", default-features = false, features = ["parsing", "default-syntaxes", "yaml-load", "regex-fancy"] }
|
||||
# SHA-256 of downloaded grammars (pure Rust, RustCrypto; owner-approved 2026-10-18).
|
||||
sha2 = { version = "0.11", default-features = false }
|
||||
# Pure-Rust WebAssembly interpreter, same configuration as photocraft-plugins.
|
||||
wasmi = { version = "2.0.0", default-features = false, features = ["std", "validate", "stable", "portable-dispatch"] }
|
||||
wat = "1.228"
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Handover (2026-10-18)
|
||||
|
||||
Temporary notes. Read `AGENTS.md`, `SPEC.md`, `ROADMAP.md`, `docs/ARCHITECTURE.md` first; delete this file when stale.
|
||||
|
||||
## Working agreements
|
||||
- Repo `/Volumes/Storage/Projects/notescraft`, branch `develop`. Before commit: `cargo xtask ci` (long, use a long timeout). Commits end with the Co-Authored-By line. Push only when asked (`git push origin develop develop:main`).
|
||||
- After a UI change: `cargo build -p notescraft`, kill the old process, `target/debug/notescraft /tmp/gv`.
|
||||
- Snapshots: `NOTESCRAFT_CONFIG_DIR=/tmp/ncv-cfg cargo run -p notescraft-ui-egui --example snapshot -- --vault <v> --out x.png --size 1440x900 --open "note.md" [--right-tab lint]`.
|
||||
- Designs: PaperCraft (`papercraft-cli`), page "Editor" id `d2f89d953e07927f`.
|
||||
|
||||
## State
|
||||
- Everything from the last sessions is in one commit: calendar plugin, vault knowledge, editor blocks and lint (phase 14), macOS menu bar.
|
||||
- `cargo xtask ci` was green before the commit.
|
||||
- New crates: `lint` (L2), `blocks` (L1). New commands: `lint.*`, `block.*` (engine, CLI, MCP).
|
||||
|
||||
## Pending (important only)
|
||||
- Spell-check: owner decision (new dependency or in-house checker); rule slot `spelling` exists.
|
||||
- Editor: mobile bottom toolbar, inline lint fix popover, block menu by keyboard, two embeds on one line, checkboxes in embeds, embed refresh after editing the source in another tab, `^block-id` shown in rendered text.
|
||||
- Obsidian switch: no community plugins (Dataview shows as code), no sync service (use git), `%%` and tables done.
|
||||
- Release: run `release.yml` on the remote, install-test Windows MSI and Linux packages, signing, `muda` and `winresource` approvals, macOS menu clicks and Cmd-W/Cmd-Q unchecked.
|
||||
- Never run: a release-build soak test or a long GUI session. Try the app on a copy of a real vault first.
|
||||
- Other ROADMAP "Pending, by area" items still stand (assistant Accept/Reject on Preview, calendar follow-ups, phone-width shell).
|
||||
+44
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: pre-alpha** · spec approved 2026-10-13 · phase 0 done · phases 1 to 10 implemented; phase 11 (packaging, i18n migration, release) open
|
||||
|
||||
> **Last updated:** 2026-10-16 (assistant panel with ACP bridge and write gate, control channel, properties panel, slash menu, image paste/drop, PDF export, LaTeX math, Vim mode, resizable panels, whiteboard upgrade) · **Target:** Obsidian-compatible vault, native egui, see [SPEC.md](SPEC.md)
|
||||
> **Last updated:** 2026-10-19 (vault knowledge, settings sub-menus, macOS menu bar; calendar plugin: events, recurrence, quick add, filters, keyboard, reminders, ICS and Google feeds; assistant panel with ACP bridge and write gate, control channel, properties panel, slash menu, image paste/drop, PDF export, LaTeX math, Vim mode, resizable panels, whiteboard upgrade, fast open, editor blocks and lint, code blocks, grammars and formatting, editor previews) · **Target:** Obsidian-compatible vault, native egui, see [SPEC.md](SPEC.md)
|
||||
|
||||
One-page summary of where NotesCraft stands. The contract is [SPEC.md](SPEC.md); the definition of done per feature is its section 12. One PR per phase, each reviewed before merge.
|
||||
|
||||
@@ -21,22 +21,57 @@ One-page summary of where NotesCraft stands. The contract is [SPEC.md](SPEC.md);
|
||||
| 8 | Canvas, whiteboard, diagrams UI | `canvas`, renderers | done: canvas, whiteboard (`.draw`, legacy `.excalidraw`), diagrams, LaTeX math (`math` crate and renderer) |
|
||||
| 9 | Plugins | `plugins` (wasmi host) | done; plugin-tool toggles in Settings > AI assistant |
|
||||
| 10 | MCP, CLI, control channel, assistant panel | `mcp`, `notescraft-cli`, `acp` | done: MCP server, CLI (`mcp --via-control`, `export pdf`, `export whiteboard`), ACP client, write gate with pending-change review, transcripts, assistant panel |
|
||||
| 12 | Calendar plugin | `calendar`, engine `calendar.*`, calendar view, `apps/notescraft` services | done: events as notes, repeat subset, quick add, five views, filters and saved filters, keyboard model, reminders with inbox, ICS feeds, Google (view-only); not built: Apple, Outlook, CalDAV, two-way sync, background agent, non-English quick add (specs/calendar-plugin-redesign) |
|
||||
| 13 | Vault knowledge | `knowledge`, engine `knowledge.*`, Related tab, assistant sources, Settings > Knowledge | done: passage index, vocabulary expansion, link and tag signals, five commands, MCP `vault_context`, auto context, sources, Related tab, compact layout; not built: embeddings (only if recall@6 falls under 80%), non-English stemming, attachments (specs/vault-knowledge) |
|
||||
| 14 | Editor blocks and lint | `lint`, `blocks`, engine `lint.*` and `block.*`, slash menu, grip, Lint tab, Settings > Editor and lint | done: 12 lint rules (spelling slot only), one-click and safe fixes, learned style, dismissals, `fix_on_save`, slash menu with groups and preview in Source and Visual, block grip, drag, menu, Vim `]]` `[[` `gb`, Visual tables, note embeds, `%%` comments; not built: spell-check, mobile bottom toolbar, Fix popover in the editor, keyboard use of the block menu (specs/editor-blocks-and-lint) |
|
||||
| 15 | Fast open and large vaults | `vault` listing and `count_files`, engine `open_lazy`, `index_step`, `ContentSearch`, `index.*`, Explorer virtualization and navigation, large-folder warning | done: window in under a second on 10 000 notes, background indexing with a status card, search from disk while indexing, pause and resume, rename and move refused until the index is complete, virtualized Explorer (50 000 rows), note counts, filter, collapse all, reveal and follow, pinned folders, sticky folder path, warning above 5 000 files; no index cache (not needed); not built: honouring `.gitignore` in non-vault folders, jump-to-letter strip, key bindings for the Explorer commands (specs/fast-open-and-explorer) |
|
||||
| 16 | Editor previews | `editor::preview`, engine `editor.previews.*`, colour swatches, external-link arrows, tree card, Mermaid Source toggle | done: `detect_colors` (hex, `rgb`/`rgba`, `hsl`/`hsla` and the basic colour names) and `detect_tree`, both pure and bounded, swatches in body text, inline code and ten fenced languages, the right-click colour menu (Copy color, Copy as rgb, the two switches), external-link arrows, the TREE card, the Mermaid Diagram/Source toggle and the error/unsupported/too-big chips; not built: swatches and arrows in Source mode and in rows with inline math, swatches for a hex Obsidian reads as a tag (specs/editor-previews) |
|
||||
| 11 | PDF export, packaging, i18n coverage, hardening | `pdf`, `packaging/`, release | PDF export done (crate, `export.pdf`, CLI, dialog; LaTeX prints as source, `craft-print` not wired); packaging done for macOS (built and smoke-tested), Windows (MSI scripts, compile-checked only) and Linux (deb/rpm/AppImage scripts, untested here); i18n migration done (252 keys in `en.toml`, `cargo xtask i18n --strict` is part of `ci`) |
|
||||
|
||||
Also built beyond the phase list: Vim mode (`vim` crate and app-wide navigation), resizable and collapsable cards with rails, Windows/Linux custom title bar.
|
||||
|
||||
## Implemented so far
|
||||
|
||||
22 crates plus the CLI: `acp`, `backlinks`, `canvas`, `diagrams`, `editor`, `engine`, `git`, `graph`, `i18n`, `index`, `markdown`, `math`, `mcp`, `pdf`, `plugins`, `templates`, `testkit` (empty), `themes`, `ui-egui`, `vault`, `vim`, `whiteboard`; about 110 000 source lines. `cargo xtask ci` is green on `develop`. Per-crate API and numbers: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
28 crates plus the CLI: `acp`, `backlinks`, `blocks`, `calendar`, `canvas`, `diagrams`, `editor`, `engine`, `git`, `graph`, `i18n`, `index`, `knowledge`, `lint`, `lottie`, `markdown`, `math`, `mcp`, `pdf`, `plugins`, `syntax`, `templates`, `testkit` (empty), `themes`, `ui-egui`, `vault`, `vim`, `whiteboard`; about 166 000 source lines. `cargo xtask ci` is green on `develop`. Per-crate API and numbers: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Next up
|
||||
Also done on 2026-10-17: the assistant starts opencode with a lean private config (`crates/acp/src/lean.rs`: no plugins, MCP, skills or agents, unused built-in tools off; about 5 000 tokens for a first prompt instead of 60 000 or more), the in-app assistant's MCP list holds only the aliases, `search` and plugin tools, the assistant's Preview of a pending change is a full-window side-by-side diff (still without Accept/Reject, and it opens only the first pending change), mouse-wheel scrolling works in every card, many more LaTeX commands, and a configurable note padding (Settings, Appearance).
|
||||
|
||||
1. Assistant overhaul: merged (connections manager, OpenAI-compatible backend over curl, `@` mentions and chips, Add to chat from Source and Visual, new panel layout, static agent icons). Still to check by hand: dragging an Explorer item onto the composer, the motion of the popup and chips in the running app, the Source selection toolbar.
|
||||
2. Folder restructure: done for every crate (`PENDING` in `xtask/src/tree.rs` is empty); the layout is described in `docs/ARCHITECTURE.md`, the rule in `AGENTS.md`.
|
||||
3. First tagged release: the macOS `.dmg` dry run passes locally (ad-hoc signed, signature valid from the mounted image, app starts); still to do: run `release.yml` on the remote (signing secrets, the shared `craft-common-actions` workflow), then install-test the Windows MSI and the Linux packages on real machines; embed the app icon into the Windows `.exe` (needs a build dependency such as `winresource`, owner approval). The runtime window icon is set on Windows and Linux.
|
||||
4. Polish: the tab row clears the macOS traffic lights when the sidebar is collapsed to its rail; animated icons: author the set in EffectCraft instead of generating it (its Lottie export adds fields such as `ml` that the player rejects) and tune individual icons as they are noticed (`notescraft-icon-motion` skill); egui's own menus and tooltips still close without animation.
|
||||
5. Binary clipboard image paste (needs the `arboard` dependency approved), `craft-print` hook for PDF export, typeset math in PDF.
|
||||
6. Housekeeping: `craftrules` reconciliation (0.10), regex dependency decision for search, `testkit`, split `crates/markdown/src/lib.rs` (568 lines).
|
||||
## Status checklist (2026-10-17)
|
||||
|
||||
Done (crossed off):
|
||||
- [x] ~~Phases 0 to 13 (see the table)~~
|
||||
- [x] ~~Calendar plugin redesign: 15 tasks, events as notes, repeats, quick add, five views, filters, keyboard model, reminders, ICS and Google (view-only)~~
|
||||
- [x] ~~Calendar settings page rebuilt from the design (General, Sources, Display, Notifications, Accounts) and its wide rows (chips, quiet hours, add feed) laid out on their own line~~
|
||||
- [x] ~~Core plugins as cards (toggle, tools chip, Configure) and Core plugin switches that save~~
|
||||
- [x] ~~Interactive sidebar mini calendar~~
|
||||
- [x] ~~Command palette redesign (icon tiles, key caps, hover glow)~~
|
||||
- [x] ~~Settings sub-menus with arrows (Core plugins > Calendar, Appearance, AI assistant sections that scroll to their heading); Calendar is no longer a top-level entry~~
|
||||
- [x] ~~Assistant fixes: tool calls saved per update now read back as one card; a queued write tells the agent it is NOT written yet~~
|
||||
- [x] ~~Vault knowledge (RAG without vectors): crate, `knowledge.*` commands, MCP `vault_context`, auto context and sources in the assistant, Related tab, Settings > Knowledge, compact (phone-width) layouts, bottom sheet; 10 tasks in specs/vault-knowledge~~
|
||||
- [x] ~~macOS: traffic-light buttons hidden, native menu bar (File, Edit, View, Window, Help), top space reclaimed (8 pt drag strip)~~
|
||||
- [x] ~~Settings page at phone width: category pills on top, rows stack, 44 pt targets~~
|
||||
- [x] ~~Designs made in PaperCraft: slash menu, WYSIWYG blocks, lint and fixes~~
|
||||
- [x] ~~Fast open and large vaults: lazy session open, `index_step` in the frame loop, content search from disk, indexing card and states, virtualized Explorer with filter, pins and sticky path, large-folder warning; 12 tasks in specs/fast-open-and-explorer~~
|
||||
- [x] ~~Editor blocks and lint: `notescraft-lint`, `notescraft-blocks`, `lint.*` and `block.*` commands (engine, CLI, MCP), Lint tab, slash menu with preview, block grip and menu, Settings > Editor and lint, Visual tables and note embeds~~
|
||||
- [x] ~~Code blocks, grammars and formatting: `notescraft-syntax` (highlighting on a worker thread with a bounded cache, grammar fetch/validate/install, language suggestions), JSON check, format and minify in `notescraft-blocks`, the eight `[syntax]` theme colours, the rendered code card (Copy, Wrap, Valid chip, JSON offer, typo suggestion, "Add grammar"), rounded inline-code chips, the batch format and lint engine (`batch.*`, a dry run that writes nothing) and the file-by-file review view with Accept/Skip per change and Apply, the original TOML and TypeScript grammars embedded in `notescraft-syntax` and the third batch check "Add missing language labels"; 15 tasks in specs/code-blocks-and-formatting~~
|
||||
- [x] ~~Editor previews: colour swatches in text, inline code and fenced stylesheets, the right-click colour menu (Copy color, Copy as rgb, two switches), external-link arrows, the TREE card, the Mermaid Diagram/Source toggle and the fallback chips; 11 tasks in specs/editor-previews~~
|
||||
|
||||
Pending, by area:
|
||||
- **Code blocks, grammars and formatting (done, 15 of 15 tasks, specs/code-blocks-and-formatting):** `toml` and `ts` fences now highlight through two original grammars embedded in `notescraft-syntax` (`crates/syntax/grammars/*.sublime-syntax`), and the batch review has a third check, "Add missing language labels": `blocks::detect_language` names the language of an unlabeled fence conservatively (shebang, PHP, HTML, XML, JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML, on the first 4 KiB and 40 non-blank lines) and each `ChangeKind::Label` is a zero-length insert right after the fence run that always needs review (`batch.start` takes `labels`, on by default). Not built — the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. A note with 50 code blocks draws its first frame in 15 ms median (release), 87 ms in a cold process.
|
||||
- **Editor previews (done, 11 of 11 tasks, specs/editor-previews):** not built or limited — no swatches or arrows in Source mode or in rows carrying inline math, and a hex that Obsidian reads as a tag (`#cba6f7` in prose) stays a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only if the diagram parser returns an unrenderable kind (today it answers `NoHeader` for one it does not know, so those show the Error chip). Scanning 100 rows of 10 colours (1,000 hits) takes 0.05 ms median, 0.15 ms worst, in release.
|
||||
- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), audio embeds with Play/Stop through the system player, video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events.
|
||||
- **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer engine commands `note.extract` and `note.merge` built, UI open; unbuilt: hand check of the packaged app and a Windows playback run.
|
||||
- **Editor (built, see phase 14):** spell-check needs an owner decision (new dependency or in-house checker); open: mobile bottom toolbar, inline fix popover, block menu by keyboard, embed with other text on one line, checkboxes inside embeds, embed refresh after editing another tab.
|
||||
- **Editor settings:** the page has only Vim mode; more settings need editor features first (line numbers, spellcheck, font size, ...), then sub-menus for them.
|
||||
- **Community plugins:** a browse page and sub-menu.
|
||||
- **Calendar follow-ups:** Apple (EventKit or CalDAV), Outlook, two-way Google sync, a background reminder agent, non-English quick add, Windows notification and keychain, light and 4K variants of the newer screens, a colour-blind-safe palette, an index of dated tasks, "Default view", "Notes with a date property", notification status banner.
|
||||
- **Assistant:** Accept/Reject on the Preview page and choosing which pending change to preview; pending changes are lost when the app restarts; a lean launch for Claude Code, Gemini and Codex; hand checks still open (drag an Explorer item onto the composer, popup and chip motion, the Source selection toolbar).
|
||||
- **Knowledge gaps:** opening a source jumps to the passage start but cannot select it; Related, sources card and Knowledge settings differ slightly from the designs (answer text 15 pt vs 14 pt, marker weight, chip padding, uppercase section headings, card tint); a dense scorer only if recall@6 falls under 80% on a real vault; the vocabulary cache keeps stale pairs after an edit outside the app until a rebuild; no other-language stemming.
|
||||
- **Phone width:** the app shell has no bottom tab bar and there is no mobile build; only the new screens and Settings adapt.
|
||||
- **macOS menu:** clicking the items, Cmd-W and Cmd-Q were not checked live (macOS blocked the automation check), the menu bar shows the lowercase binary name until the app is bundled, no menu on Windows and Linux, the Edit menu shows no shortcut hints; the `muda` dependency (pinned 0.21.2) needs owner approval.
|
||||
- **Release:** run `release.yml` on the remote, install-test the Windows MSI and Linux packages, embed the Windows icon (`winresource` approval), first tagged release.
|
||||
- **Polish:** animated icons authored in EffectCraft, egui menus and tooltips close without animation, binary clipboard image paste (`arboard` approval), `craft-print` for PDF export, typeset math in PDF, font coverage for some math symbols.
|
||||
- **Housekeeping:** nothing from this session is committed (large uncommitted tree); `Cargo.lock` changed (muda, crossbeam-channel, keyboard-types); `craftrules` 0.10, regex dependency decision for search, `testkit`, split `crates/markdown/src/lib.rs`.
|
||||
|
||||
## Known issues and review findings
|
||||
|
||||
|
||||
@@ -205,10 +205,11 @@ Each row is a command group; acceptance criteria are in §12.
|
||||
|
||||
25. **Assistant panel (agent chat).** A docked chat panel that drives an *external* coding or chat agent the user already has (Claude, Codex, Cursor, Gemini, Copilot or a custom command). **The agent is chosen once in Settings → AI assistant, not in the panel**; the panel shows no agent switcher. NotesCraft contains **no model, API key, or network code**: it spawns the user-configured agent command over stdio and exposes the vault through the §10 MCP server. The panel shows the conversation, the active note as an attachable context chip, and **tool-call cards** (tool, target path, status `RUNNING`/`DONE`/`FAILED`, one-line summary). Agent writes go through the same engine commands as the user: they are undoable, atomic, and shown as a diff the user accepts before save when `assistant.review_writes` is on (default on). The MCP server runs in read-only mode unless the user grants write per session. Transcripts are kept in `.notescraft/chat/`, never in the vault notes. No telemetry.
|
||||
26. **Core plugins integrated with the assistant.** Calendar, Templates, Diagrams and Whiteboard ship as bundled core plugins that register ordinary engine commands. Every command a plugin registers (core or WASM) is exposed to the assistant as an MCP tool named `plugin_<id>_<command>`, gated by a per-plugin toggle in Settings → AI assistant → Plugin tools. The panel's quick-action chips (Template, Calendar, Diagram) are shortcuts to these tools.
|
||||
- **Calendar:** month grid in the sidebar; days that have a daily note are emphasised, today is filled; click opens or creates that day's note (feature 8). Agenda built from tasks with a due date (`📅 YYYY-MM-DD` or frontmatter `due`). Tools: `calendar_agenda`, `calendar_open_day`, `calendar_add_task`.
|
||||
- **Calendar:** a sidebar month grid (days that have a daily note are emphasised, today is filled; click opens or creates that day's note, feature 8) and a full Calendar tab (`View > Calendar`, `Mod-Alt-C`) with month, week, day, agenda and year views. **Events** are one note each in the calendar folder (default `Calendar/`), with frontmatter `title`, `start`, `end` (`YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`), `tz`, `repeat`, `exceptions`, `calendar`, `reminders` (minutes before), `tags`, `priority`, `status`, `link`, `note`; unknown keys and the body are kept. **Dated tasks** (`- [ ] text 📅 YYYY-MM-DD` or `due:` frontmatter) share the agenda. **Repeats:** daily, weekly (with weekdays), monthly, yearly, interval, `UNTIL` or `COUNT`, exceptions; changing a repeating event asks `this`, `following` or `all`. **Quick add** reads English phrases ("lunch tomorrow at 12:30 for 1h #food @Home every friday remind 10m") and previews them; an unreadable part is named and nothing is saved. **Filter, sort, group** by calendar, kind, status, priority, tag, range and reminder, saved by name; multi-day selection feeds a bulk bar that shows the count before it applies. **Keyboard:** `h l` day, `j k` week, `H L` month, `[ ]` year, `t` today, `gd` go to date, `gm gw gD ga gy` views, `Tab` focus an event, `e`/`Enter` open, `o` add, `v` grab (then `h j k l` move, `J K` resize, `Enter` drop, `y` copy) or select a range, `V` weeks, `x` mark, `b` bulk bar, `dd` delete, `yy` yank, `p` paste, `.` repeat, `/ n N` find, `f` free time, `s` sort, `c` calendar filter, `gn` meeting note, `gR` week in review, `i` reminders inbox, `z` zoom, `Esc` back out. **Reminders** fire while the app runs (system notification, toast, sound), respect quiet hours, can be snoozed or dismissed, and those that fired while the app was closed wait in the inbox as *missed*. **Imports** are read-only: ICS feeds (`https://`, `webcal://`) refreshed in the background with a cache that keeps showing offline, and Google Calendar (view-only, sign-in in the browser, refresh token in the system keychain, needs a configured client id). Apple, Outlook, CalDAV and two-way sync are not built. **Commands** (all `calendar.*`, so the UI, CLI `run`, MCP and the assistant share them): `agenda`, `next`, `free`, `conflicts`, `year`, `parse`, `review`, `add`, `edit`, `move`, `delete`, `note`, `bulk`, `bulk_delete`, `filters`, `filter_save`, `filter_delete`, `reminders`, `reminders_tick`, `reminder_snooze`, `reminder_dismiss`, `feeds`, `feed_add`, `feed_remove`, `sync_due`, `sync_apply`, `google_state`, `google_calendars`, `google_disconnect`, plus `month`. MCP tools are named `calendar_<command>`; the assistant's short list holds `calendar_agenda`, `calendar_add`, `calendar_edit`. The `calendar` plugin toggle (Settings, AI assistant, Plugin tools) is on until switched off; deletes need the delete flag; assistant writes become pending changes. Spec and plan: `specs/calendar-plugin-redesign/`.
|
||||
- **Templates:** picker listing the templates folder with preview; variables per feature 8. Tools: `templates_list`, `templates_insert`, `templates_create_note`.
|
||||
- **Diagrams:** fenced blocks ```` ```mermaid ```` render inline. Native Rust renderer (crate `diagrams`), no JavaScript. v1 supports a Mermaid **subset**: `flowchart`/`graph`, `sequenceDiagram`, `classDiagram` and `stateDiagram-v2` (UML), `erDiagram`, `gantt`, `pie`. Unsupported syntax shows the source with a diagnostic and is never dropped. Output is also exportable as SVG/PNG and embeds in PDF export. Tools: `diagrams_render`, `diagrams_insert`.
|
||||
- **Whiteboard (Excalidraw alternative):** freeform drawing surface stored in `.draw` files (`.excalidraw` is still readable for legacy vaults): rectangle, ellipse, diamond, arrow, line, freehand, text, image, with a hand-drawn rendering style (seeded roughness, so output is deterministic) and a clean style toggle. Read/write targets Excalidraw v2 element JSON (`type: "excalidraw"`); unknown fields are preserved. Embeds in notes as `![[board.draw]]`; `.excalidraw` stays readable and is saved back in place. Distinct from Canvas, which stays strictly JSON Canvas. Tools: `whiteboard_read`, `whiteboard_add_shapes`.
|
||||
26b. **Vault knowledge (retrieval).** Passage-level retrieval over the vault with no embedding model and no network: notes are split at headings and paragraphs (at most 1,200 characters), ranked with BM25, then boosted by the link graph, shared tags and a vocabulary learned from the user's own notes (words that co-occur), optionally recency. Commands `knowledge.{search,context,related,status,rebuild}`, MCP alias `vault_context`; the assistant panel adds the best passages to a prompt within a token budget and lists them as sources (`[n]` markers and chips); the right card has a Related tab; Settings > AI assistant > Knowledge holds the switches, sliders and excluded folders (`session.knowledge.*`). Plugin `knowledge` toggles the tools. Screens lay out down to 390 pt (sources as a bottom sheet). Spec: `specs/vault-knowledge/`.
|
||||
27. **Visual / Markdown toggle and status bar.** The editor switches between live preview and raw markdown with one control; a footer shows the git branch (if the vault is a repo, read-only), word and character counts and an approximate token count.
|
||||
|
||||
27. **Versioning with git.** A vault that is a git repository gets a Source panel (sidebar tab) with changed files grouped Staged / Changes, per-file stage, unstage and discard, a commit box, history (whole vault and per note), a coloured unified diff viewer and "restore this version". `Init repository` offers to create the repo with a `.gitignore` for `.notescraft/` and `.obsidian/workspace*`. Everything runs through the `git` command line (no libgit2; `git` must be installed, otherwise the tab explains it), never through a shell. No network operations in v1 (no push, pull, fetch); branch name and ahead/behind are shown read-only. Engine commands `git.status`, `git.diff`, `git.stage`, `git.unstage`, `git.commit`, `git.log`, `git.show`, `git.restore`, `git.init` make it available to the CLI, MCP and plugins. Optional auto-snapshot (setting `git.auto_commit`, off by default): commit all changes after N minutes of inactivity.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
//! The calendar through the CLI: `run <id> <json>` gives the engine's answer.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{empty_vault, exists, note, read_file, run};
|
||||
use notescraft_engine::{Registry, Session, register_core};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn vault(name: &str) -> std::path::PathBuf {
|
||||
let root = empty_vault(name);
|
||||
note(&root, "Calendar/lunch.md", "---\ntitle: Lunch\nstart: 2026-10-20T12:00\nend: 2026-10-20T13:00\n---\n");
|
||||
note(&root, "Calendar/clash.md", "---\ntitle: Dentist\nstart: 2026-10-20T12:30\nend: 2026-10-20T13:30\n---\n");
|
||||
note(&root, "Calendar/standup.md", "---\ntitle: Standup\nstart: 2026-10-19T09:30\nrepeat: weekly\n---\n");
|
||||
root
|
||||
}
|
||||
|
||||
fn engine(root: &std::path::Path, id: &str, params: &Value) -> Value {
|
||||
let mut registry = Registry::new();
|
||||
register_core(&mut registry).expect("register");
|
||||
let mut session = Session::open_read_only(root).expect("open");
|
||||
registry.run(id, &mut session, params).expect("engine run")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_match_the_engine_with_json() {
|
||||
let root = vault("cal-cli-read");
|
||||
for (id, params) in [
|
||||
("calendar.agenda", json!({ "from": "2026-10-19", "to": "2026-11-02", "group": "day" })),
|
||||
("calendar.free", json!({ "day": "2026-10-20", "min_minutes": 15 })),
|
||||
("calendar.conflicts", json!({ "from": "2026-10-19", "to": "2026-10-25" })),
|
||||
("calendar.year", json!({ "year": 2026 })),
|
||||
] {
|
||||
let out = run(&root, &["--json", "run", id, ¶ms.to_string()]);
|
||||
assert_eq!(out.code, 0, "{id}: {}", out.stderr);
|
||||
assert_eq!(out.result(), engine(&root, id, ¶ms), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_work_and_a_repeating_event_needs_a_scope() {
|
||||
let root = vault("cal-cli-write");
|
||||
let added = run(&root, &["--json", "run", "calendar.add", &json!({ "text": "Gym 2026-11-03 at 7:00 for 1h" }).to_string()]);
|
||||
assert_eq!(added.code, 0, "{}", added.stderr);
|
||||
assert!(exists(&root, "Calendar/2026-11-03-gym.md"));
|
||||
|
||||
let missing = run(&root, &["--json", "run", "calendar.edit", &json!({ "id": "Calendar/standup.md", "fields": { "title": "x" } }).to_string()]);
|
||||
assert_eq!(missing.code, 1);
|
||||
assert_eq!(missing.error_code(), "command_failed");
|
||||
assert!(missing.stdout.contains("this, following or all"), "{}", missing.stdout);
|
||||
assert!(read_file(&root, "Calendar/standup.md").contains("title: Standup"), "nothing changed");
|
||||
|
||||
let scoped =
|
||||
run(&root, &["--json", "run", "calendar.edit", &json!({ "id": "Calendar/standup.md", "scope": "all", "fields": { "title": "Sync" } }).to_string()]);
|
||||
assert_eq!(scoped.code, 0, "{}", scoped.stderr);
|
||||
assert!(read_file(&root, "Calendar/standup.md").contains("title: Sync"));
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Lint through the CLI: `run lint.* <json>` gives the engine's answer and fixes the note.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{empty_vault, note, read_file, run};
|
||||
use notescraft_engine::{Registry, Session, register_core};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MESSY: &str = "# A\n\n### B\n\ntext with gaps\n";
|
||||
|
||||
fn engine(root: &std::path::Path, id: &str, params: &Value) -> Value {
|
||||
let mut registry = Registry::new();
|
||||
register_core(&mut registry).expect("register");
|
||||
let mut session = Session::open_read_only(root).expect("open");
|
||||
registry.run(id, &mut session, params).expect("engine run")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_matches_the_engine_and_fix_all_safe_cleans_the_note() {
|
||||
let root = empty_vault("lint-cli");
|
||||
note(&root, "messy.md", MESSY);
|
||||
let params = json!({ "path": "messy.md" });
|
||||
let checked = run(&root, &["--json", "run", "lint.check", ¶ms.to_string()]);
|
||||
assert_eq!(checked.code, 0, "{}", checked.stderr);
|
||||
assert_eq!(checked.result(), engine(&root, "lint.check", ¶ms));
|
||||
|
||||
let fixed = run(&root, &["--json", "run", "lint.fix_all_safe", ¶ms.to_string()]);
|
||||
assert_eq!(fixed.code, 0, "{}", fixed.stderr);
|
||||
assert_eq!(read_file(&root, "messy.md"), "# A\n\n## B\n\ntext with gaps\n");
|
||||
|
||||
let stale = run(&root, &["--json", "run", "lint.fix", &json!({ "path": "messy.md", "key": "heading-increment:0000000000000000" }).to_string()]);
|
||||
assert_eq!(stale.code, 1);
|
||||
assert!(stale.stdout.contains("stale"), "{}", stale.stdout);
|
||||
}
|
||||
@@ -18,9 +18,15 @@ workspace = true
|
||||
notescraft-ui-egui = { workspace = true }
|
||||
notescraft-engine = { workspace = true }
|
||||
notescraft-themes = { workspace = true }
|
||||
notescraft-calendar = { workspace = true }
|
||||
notescraft-templates = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["wgpu", "default_fonts", "accesskit", "wayland", "x11"] }
|
||||
log = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
craft-quit = { workspace = true, features = ["egui"] }
|
||||
rfd = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! The desktop's answer to [`Services`]: curl for the network, the platform's notification tool,
|
||||
//! the system keychain and the Google sign-in.
|
||||
|
||||
use notescraft_calendar::Fetched;
|
||||
use notescraft_ui_egui::services::Services;
|
||||
|
||||
use crate::google::{Google, Http, Secrets, open_browser, system_random};
|
||||
use crate::keychain::{Op, run};
|
||||
use crate::net::{self, Response};
|
||||
use crate::notify::notify;
|
||||
|
||||
/// The keychain entry the Google refresh token lives in.
|
||||
const KEYCHAIN_SERVICE: &str = "notescraft-calendar";
|
||||
const KEYCHAIN_ACCOUNT: &str = "google";
|
||||
|
||||
/// Address prefix of a Google calendar feed.
|
||||
const GOOGLE_PREFIX: &str = "google://calendar/";
|
||||
|
||||
struct CurlHttp;
|
||||
|
||||
impl Http for CurlHttp {
|
||||
fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<Response, String> {
|
||||
net::get(url, headers)
|
||||
}
|
||||
|
||||
fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<Response, String> {
|
||||
net::post_form(url, form)
|
||||
}
|
||||
}
|
||||
|
||||
struct Keychain;
|
||||
|
||||
impl Secrets for Keychain {
|
||||
fn store(&self, secret: &str) -> Result<(), String> {
|
||||
run(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, Op::Store(secret)).map(|_| ())
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<Option<String>, String> {
|
||||
run(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, Op::Load)
|
||||
}
|
||||
|
||||
fn delete(&self) -> Result<(), String> {
|
||||
run(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT, Op::Delete).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop services.
|
||||
pub struct Desktop {
|
||||
google: Google,
|
||||
}
|
||||
|
||||
impl Desktop {
|
||||
/// Reads the Google client id from `NOTESCRAFT_GOOGLE_CLIENT_ID` (and the secret from
|
||||
/// `NOTESCRAFT_GOOGLE_CLIENT_SECRET`), set at run time or baked in at build time.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
let id = std::env::var("NOTESCRAFT_GOOGLE_CLIENT_ID").ok().or_else(|| option_env!("NOTESCRAFT_GOOGLE_CLIENT_ID").map(str::to_owned));
|
||||
let secret = std::env::var("NOTESCRAFT_GOOGLE_CLIENT_SECRET").ok().or_else(|| option_env!("NOTESCRAFT_GOOGLE_CLIENT_SECRET").map(str::to_owned));
|
||||
Self { google: Google::new(id, secret, Box::new(CurlHttp), Box::new(Keychain), Box::new(open_browser), Box::new(system_random)) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Services for Desktop {
|
||||
fn fetch(&self, url: &str, etag: Option<&str>) -> Result<Fetched, String> {
|
||||
match url.strip_prefix(GOOGLE_PREFIX) {
|
||||
Some(calendar) => self.google.fetch_events(calendar),
|
||||
None => net::fetch_feed(url, etag),
|
||||
}
|
||||
}
|
||||
|
||||
fn notify(&self, title: &str, body: &str) -> Result<(), String> {
|
||||
notify(title, body)
|
||||
}
|
||||
|
||||
fn google_configured(&self) -> bool {
|
||||
self.google.configured()
|
||||
}
|
||||
|
||||
fn google_connected(&self) -> bool {
|
||||
self.google.connected()
|
||||
}
|
||||
|
||||
fn connect_google(&self) -> Result<Vec<(String, String)>, String> {
|
||||
self.google.connect()
|
||||
}
|
||||
|
||||
fn disconnect_google(&self) -> Result<(), String> {
|
||||
self.google.disconnect()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Google Calendar, read-only: the sign-in in the browser, the keychain and the API calls.
|
||||
//!
|
||||
//! The pure parts (URLs, PKCE, JSON) live in `notescraft_calendar::google`; here is what touches
|
||||
//! the outside: a loopback listener for the browser's answer, `curl` for HTTP and the keychain.
|
||||
//! The refresh token lives only in the keychain.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notescraft_calendar::Fetched;
|
||||
use notescraft_calendar::google::{auth_url, base64url, events_url, parse_calendar_list, parse_events, parse_token, pkce_challenge};
|
||||
use notescraft_templates::{Date, DateTime};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::net::Response;
|
||||
|
||||
const TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
|
||||
const CALENDAR_LIST_URL: &str = "https://www.googleapis.com/calendar/v3/users/me/calendarList?minAccessRole=reader";
|
||||
|
||||
/// How long the sign-in waits for the browser.
|
||||
const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// Pages of events one refresh reads at most (250 events each).
|
||||
const MAX_PAGES: usize = 20;
|
||||
|
||||
/// Days of events before and after today one refresh asks for.
|
||||
const DAYS_BEFORE: i32 = 30;
|
||||
const DAYS_AFTER: i32 = 365;
|
||||
|
||||
/// Seconds an access token is trusted less than Google says, so it never expires mid-request.
|
||||
const TOKEN_MARGIN: u64 = 60;
|
||||
|
||||
/// HTTP as the sign-in needs it.
|
||||
pub trait Http: Send + Sync {
|
||||
/// GET with headers.
|
||||
fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<Response, String>;
|
||||
/// POST of a url-encoded form.
|
||||
fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<Response, String>;
|
||||
}
|
||||
|
||||
/// Where the refresh token is kept.
|
||||
pub trait Secrets: Send + Sync {
|
||||
/// Stores the secret.
|
||||
fn store(&self, secret: &str) -> Result<(), String>;
|
||||
/// Reads it, `None` when nothing is stored.
|
||||
fn load(&self) -> Result<Option<String>, String>;
|
||||
/// Removes it.
|
||||
fn delete(&self) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Opens an address in the user's browser.
|
||||
pub type Opener = dyn Fn(&str) -> Result<(), String> + Send + Sync;
|
||||
|
||||
/// Secure random bytes.
|
||||
pub type Random = dyn Fn(usize) -> Result<Vec<u8>, String> + Send + Sync;
|
||||
|
||||
/// The Google side of the desktop services.
|
||||
pub struct Google {
|
||||
/// The OAuth client id, from `NOTESCRAFT_GOOGLE_CLIENT_ID` or the build.
|
||||
pub client_id: Option<String>,
|
||||
/// The client secret some Google clients need.
|
||||
pub client_secret: Option<String>,
|
||||
/// How long the sign-in waits for the browser.
|
||||
pub timeout: Duration,
|
||||
http: Box<dyn Http>,
|
||||
secrets: Box<dyn Secrets>,
|
||||
open: Box<Opener>,
|
||||
random: Box<Random>,
|
||||
access: Mutex<Option<(String, Instant)>>,
|
||||
connected: Mutex<Option<bool>>,
|
||||
}
|
||||
|
||||
impl Google {
|
||||
/// Builds it from its parts.
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
client_id: Option<String>,
|
||||
client_secret: Option<String>,
|
||||
http: Box<dyn Http>,
|
||||
secrets: Box<dyn Secrets>,
|
||||
open: Box<Opener>,
|
||||
random: Box<Random>,
|
||||
) -> Self {
|
||||
Self { client_id, client_secret, timeout: SIGN_IN_TIMEOUT, http, secrets, open, random, access: Mutex::new(None), connected: Mutex::new(None) }
|
||||
}
|
||||
|
||||
/// Whether a client id is configured.
|
||||
#[must_use]
|
||||
pub fn configured(&self) -> bool {
|
||||
self.client_id.as_deref().is_some_and(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// Whether a refresh token is stored; the keychain is asked once.
|
||||
#[must_use]
|
||||
pub fn connected(&self) -> bool {
|
||||
let Ok(mut known) = self.connected.lock() else { return false };
|
||||
*known.get_or_insert_with(|| self.secrets.load().ok().flatten().is_some())
|
||||
}
|
||||
|
||||
fn set_connected(&self, value: bool) {
|
||||
if let Ok(mut known) = self.connected.lock() {
|
||||
*known = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Signs in and returns the account's calendars as `(id, name)`, the account's own first.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message that says what went wrong; never a token.
|
||||
pub fn connect(&self) -> Result<Vec<(String, String)>, String> {
|
||||
let client_id = self.client_id.as_deref().filter(|id| !id.is_empty()).ok_or("Google needs a client id: set NOTESCRAFT_GOOGLE_CLIENT_ID")?;
|
||||
let verifier = base64url(&(self.random)(32)?);
|
||||
let state = base64url(&(self.random)(16)?);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").map_err(|error| format!("cannot listen for the browser: {error}"))?;
|
||||
let port = listener.local_addr().map_err(|error| error.to_string())?.port();
|
||||
let redirect = format!("http://127.0.0.1:{port}/");
|
||||
(self.open)(&auth_url(client_id, &redirect, &pkce_challenge(&verifier), &state))?;
|
||||
let code = wait_for_code(&listener, &state, self.timeout)?;
|
||||
let mut form = vec![
|
||||
("code", code.as_str()),
|
||||
("client_id", client_id),
|
||||
("redirect_uri", redirect.as_str()),
|
||||
("grant_type", "authorization_code"),
|
||||
("code_verifier", verifier.as_str()),
|
||||
];
|
||||
if let Some(secret) = self.client_secret.as_deref().filter(|secret| !secret.is_empty()) {
|
||||
form.push(("client_secret", secret));
|
||||
}
|
||||
let answer = self.http.post_form(TOKEN_URL, &form)?;
|
||||
let token = parse_token(&String::from_utf8_lossy(&answer.body)).map_err(|error| error.to_string())?;
|
||||
let refresh = token.refresh_token.as_deref().ok_or("Google gave no refresh token; remove NotesCraft from your Google account's apps and try again")?;
|
||||
self.secrets.store(refresh)?;
|
||||
self.set_connected(true);
|
||||
self.remember(&token.access_token, token.expires_in);
|
||||
let list = self.http.get(CALENDAR_LIST_URL, &[("Authorization", &format!("Bearer {}", token.access_token))])?;
|
||||
if list.status != 200 {
|
||||
return Err(format!("Google answered {} for the calendar list", list.status));
|
||||
}
|
||||
let mut calendars = parse_calendar_list(&String::from_utf8_lossy(&list.body)).map_err(|error| error.to_string())?;
|
||||
calendars.sort_by_key(|calendar| !calendar.primary);
|
||||
Ok(calendars.into_iter().map(|calendar| (calendar.id, calendar.name)).collect())
|
||||
}
|
||||
|
||||
/// Forgets the account.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message from the keychain.
|
||||
pub fn disconnect(&self) -> Result<(), String> {
|
||||
self.secrets.delete()?;
|
||||
self.set_connected(false);
|
||||
if let Ok(mut access) = self.access.lock() {
|
||||
*access = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remember(&self, token: &str, expires_in: u64) {
|
||||
if let Ok(mut access) = self.access.lock() {
|
||||
*access = Some((token.to_owned(), Instant::now() + Duration::from_secs(expires_in.saturating_sub(TOKEN_MARGIN))));
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid access token, refreshed from the stored refresh token when needed.
|
||||
fn access_token(&self) -> Result<String, String> {
|
||||
if let Some((token, until)) = self.access.lock().ok().and_then(|access| access.clone())
|
||||
&& Instant::now() < until
|
||||
{
|
||||
return Ok(token);
|
||||
}
|
||||
let client_id = self.client_id.as_deref().filter(|id| !id.is_empty()).ok_or("Google needs a client id")?;
|
||||
let refresh = self.secrets.load()?.ok_or("Google is not connected")?;
|
||||
let mut form = vec![("client_id", client_id), ("refresh_token", refresh.as_str()), ("grant_type", "refresh_token")];
|
||||
if let Some(secret) = self.client_secret.as_deref().filter(|secret| !secret.is_empty()) {
|
||||
form.push(("client_secret", secret));
|
||||
}
|
||||
let answer = self.http.post_form(TOKEN_URL, &form)?;
|
||||
let token = parse_token(&String::from_utf8_lossy(&answer.body)).map_err(|error| error.to_string())?;
|
||||
self.remember(&token.access_token, token.expires_in);
|
||||
Ok(token.access_token)
|
||||
}
|
||||
|
||||
/// The events of one calendar around today, as the JSON `{"items": [...]}` the feed refresh reads.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message that says what went wrong; never a token.
|
||||
pub fn fetch_events(&self, calendar_id: &str) -> Result<Fetched, String> {
|
||||
let token = self.access_token()?;
|
||||
let (time_min, time_max) = window()?;
|
||||
let bearer = format!("Bearer {token}");
|
||||
let mut items: Vec<Value> = Vec::new();
|
||||
let mut page: Option<String> = None;
|
||||
for _ in 0..MAX_PAGES {
|
||||
let response = self.http.get(&events_url(calendar_id, &time_min, &time_max, page.as_deref()), &[("Authorization", &bearer)])?;
|
||||
if response.status == 401 {
|
||||
if let Ok(mut access) = self.access.lock() {
|
||||
*access = None;
|
||||
}
|
||||
return Err("Google no longer accepts the saved sign-in; connect again".to_owned());
|
||||
}
|
||||
if response.status != 200 {
|
||||
return Err(format!("Google answered {}", response.status));
|
||||
}
|
||||
let body: Value = serde_json::from_slice(&response.body).map_err(|_| "Google's answer is not JSON".to_owned())?;
|
||||
items.extend(body.get("items").and_then(Value::as_array).cloned().unwrap_or_default());
|
||||
page = body.get("nextPageToken").and_then(Value::as_str).map(str::to_owned);
|
||||
if page.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let text = serde_json::json!({ "items": items }).to_string();
|
||||
parse_events(&text).map_err(|error| error.to_string())?;
|
||||
Ok(Fetched::Body { text, etag: None })
|
||||
}
|
||||
}
|
||||
|
||||
/// `timeMin` and `timeMax` for today: 30 days back, a year ahead, in UTC.
|
||||
fn window() -> Result<(String, String), String> {
|
||||
let now = DateTime::now_with_offset(0).map_err(|error| error.to_string())?;
|
||||
let day = |offset: i32| -> Result<String, String> {
|
||||
let date: Date = now.date().add_days(offset).map_err(|error| error.to_string())?;
|
||||
Ok(format!("{:04}-{:02}-{:02}T00:00:00Z", date.year(), date.month(), date.day()))
|
||||
};
|
||||
Ok((day(-DAYS_BEFORE)?, day(DAYS_AFTER)?))
|
||||
}
|
||||
|
||||
fn percent_decode(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut at = 0;
|
||||
while let Some(&byte) = bytes.get(at) {
|
||||
let hex = |offset: usize| bytes.get(at + offset).and_then(|b| char::from(*b).to_digit(16));
|
||||
match (byte, hex(1), hex(2)) {
|
||||
(b'%', Some(high), Some(low)) => {
|
||||
out.push(u8::try_from(high * 16 + low).unwrap_or(b'?'));
|
||||
at += 3;
|
||||
}
|
||||
(b'+', ..) => {
|
||||
out.push(b' ');
|
||||
at += 1;
|
||||
}
|
||||
_ => {
|
||||
out.push(byte);
|
||||
at += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Waits for the browser's request to the loopback address and returns the `code` it carries.
|
||||
fn wait_for_code(listener: &TcpListener, state: &str, timeout: Duration) -> Result<String, String> {
|
||||
listener.set_nonblocking(true).map_err(|error| error.to_string())?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
stream.set_nonblocking(false).map_err(|error| error.to_string())?;
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).map_err(|error| error.to_string())?;
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
while !request.windows(4).any(|window| window == b"\r\n\r\n") && request.len() < 8192 {
|
||||
let read = stream.read(&mut buffer).unwrap_or(0);
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(buffer.get(..read).unwrap_or_default());
|
||||
}
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
let target = text.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or("");
|
||||
let query: Vec<(String, String)> = target
|
||||
.split_once('?')
|
||||
.map_or("", |(_, query)| query)
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.map(|(k, v)| (percent_decode(k), percent_decode(v)))
|
||||
.collect();
|
||||
let find = |name: &str| query.iter().find(|(key, _)| key == name).map(|(_, value)| value.clone());
|
||||
let page = "<!doctype html><meta charset=utf-8><title>NotesCraft</title><p>You can close this tab and go back to NotesCraft.";
|
||||
let _ = stream.write_all(
|
||||
format!("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()).as_bytes(),
|
||||
);
|
||||
if find("code").is_none() && find("error").is_none() {
|
||||
continue; // a favicon request or a probe, not the answer
|
||||
}
|
||||
if let Some(error) = find("error") {
|
||||
return Err(format!("Google sign-in stopped: {}", error.chars().take(60).collect::<String>()));
|
||||
}
|
||||
if find("state").as_deref() != Some(state) {
|
||||
return Err("the sign-in answer did not match the request; try again".to_owned());
|
||||
}
|
||||
return find("code").filter(|code| !code.is_empty()).ok_or_else(|| "the sign-in answer has no code".to_owned());
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if Instant::now() >= deadline {
|
||||
return Err("the sign-in timed out waiting for the browser".to_owned());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Secure random bytes from the system, where it offers `/dev/urandom`.
|
||||
pub fn system_random(count: usize) -> Result<Vec<u8>, String> {
|
||||
let mut bytes = vec![0_u8; count];
|
||||
std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut file| file.read_exact(&mut bytes))
|
||||
.map_err(|_| "this system has no secure random source for the sign-in".to_owned())?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Opens `url` in the default browser.
|
||||
pub fn open_browser(url: &str) -> Result<(), String> {
|
||||
let (program, args): (&str, Vec<&str>) = match std::env::consts::OS {
|
||||
"macos" => ("open", vec![url]),
|
||||
"windows" => ("cmd", vec!["/c", "start", "", url]),
|
||||
_ => ("xdg-open", vec![url]),
|
||||
};
|
||||
std::process::Command::new(program).args(args).spawn().map(|_| ()).map_err(|error| format!("cannot open the browser: {error}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,238 @@
|
||||
//! The sign-in and the event fetch against scripted HTTP, an in-memory keychain and a pretend browser.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use notescraft_calendar::Fetched;
|
||||
use notescraft_calendar::google::pkce_challenge;
|
||||
|
||||
use super::{Google, Http, Secrets};
|
||||
use crate::net::Response;
|
||||
|
||||
const CLIENT: &str = "client-1.apps.googleusercontent.com";
|
||||
|
||||
type Log = Arc<Mutex<Vec<String>>>;
|
||||
|
||||
struct FakeHttp {
|
||||
log: Log,
|
||||
script: Mutex<Vec<(&'static str, Response)>>,
|
||||
}
|
||||
|
||||
fn reply(status: u16, body: &str) -> Response {
|
||||
Response { status, body: body.as_bytes().to_vec(), etag: None }
|
||||
}
|
||||
|
||||
impl FakeHttp {
|
||||
fn answer(&self, line: String, url: &str) -> Result<Response, String> {
|
||||
self.log.lock().map_err(|e| e.to_string())?.push(line);
|
||||
let mut script = self.script.lock().map_err(|e| e.to_string())?;
|
||||
let at = script.iter().position(|(prefix, _)| url.starts_with(prefix)).ok_or_else(|| "unscripted request".to_owned())?;
|
||||
Ok(script.remove(at).1)
|
||||
}
|
||||
}
|
||||
|
||||
impl Http for FakeHttp {
|
||||
fn get(&self, url: &str, headers: &[(&str, &str)]) -> Result<Response, String> {
|
||||
self.answer(format!("GET {url} {headers:?}"), url)
|
||||
}
|
||||
|
||||
fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<Response, String> {
|
||||
self.answer(format!("POST {url} {form:?}"), url)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
struct Keychain(Arc<Mutex<Option<String>>>);
|
||||
|
||||
impl Secrets for Keychain {
|
||||
fn store(&self, secret: &str) -> Result<(), String> {
|
||||
*self.0.lock().map_err(|e| e.to_string())? = Some(secret.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load(&self) -> Result<Option<String>, String> {
|
||||
Ok(self.0.lock().map_err(|e| e.to_string())?.clone())
|
||||
}
|
||||
|
||||
fn delete(&self) -> Result<(), String> {
|
||||
*self.0.lock().map_err(|e| e.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn query_of(url: &str, name: &str) -> String {
|
||||
let query = url.split_once('?').map_or("", |(_, query)| query);
|
||||
query.split('&').filter_map(|pair| pair.split_once('=')).find(|(key, _)| *key == name).map(|(_, value)| super::percent_decode(value)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A pretend browser: reads the sign-in address and sends the answer `code`, `state` and `error` to its redirect.
|
||||
fn browser(answer: fn(&str) -> String) -> Box<super::Opener> {
|
||||
Box::new(move |url| {
|
||||
let redirect = query_of(url, "redirect_uri");
|
||||
let target = format!("{}{}", redirect.trim_end_matches('/'), answer(url));
|
||||
let address = redirect.trim_start_matches("http://").trim_end_matches('/').to_owned();
|
||||
std::thread::spawn(move || {
|
||||
let path = target.split_once(&address).map_or(String::new(), |(_, path)| path.to_owned());
|
||||
if let Ok(mut stream) = TcpStream::connect(&address) {
|
||||
let _ = stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: {address}\r\n\r\n").as_bytes());
|
||||
let mut sink = String::new();
|
||||
let _ = stream.read_to_string(&mut sink);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn good_answer(url: &str) -> String {
|
||||
format!("/?code=4%2Fabc&state={}", query_of(url, "state"))
|
||||
}
|
||||
|
||||
fn build(script: Vec<(&'static str, Response)>, open: Box<super::Opener>, keychain: &Keychain) -> (Google, Log) {
|
||||
let log: Log = Arc::default();
|
||||
let random: Box<super::Random> = Box::new(|count| Ok((0..count).map(|n| u8::try_from(n * 7 % 251).unwrap_or(0)).collect()));
|
||||
let mut google = Google::new(
|
||||
Some(CLIENT.to_owned()),
|
||||
None,
|
||||
Box::new(FakeHttp { log: Arc::clone(&log), script: Mutex::new(script) }),
|
||||
Box::new(keychain.clone()),
|
||||
open,
|
||||
random,
|
||||
);
|
||||
google.timeout = Duration::from_secs(5);
|
||||
(google, log)
|
||||
}
|
||||
|
||||
const TOKEN: &str = r#"{"access_token":"ya29.ACCESS","expires_in":3599,"refresh_token":"1//REFRESH"}"#;
|
||||
const LIST: &str = r#"{"items":[{"id":"team@group","summary":"Team"},{"id":"me@x.com","summary":"Me","primary":true}]}"#;
|
||||
|
||||
#[test]
|
||||
fn signing_in_stores_the_refresh_token_and_lists_the_calendars_primary_first() {
|
||||
let keychain = Keychain::default();
|
||||
let (google, log) = build(
|
||||
vec![("https://oauth2.googleapis.com/token", reply(200, TOKEN)), ("https://www.googleapis.com/calendar/v3/users/me/calendarList", reply(200, LIST))],
|
||||
browser(good_answer),
|
||||
&keychain,
|
||||
);
|
||||
assert!(google.configured() && !google.connected());
|
||||
let calendars = google.connect().unwrap();
|
||||
assert_eq!(calendars, [("me@x.com".to_owned(), "Me".to_owned()), ("team@group".to_owned(), "Team".to_owned())]);
|
||||
assert_eq!(keychain.load().unwrap().as_deref(), Some("1//REFRESH"));
|
||||
assert!(google.connected());
|
||||
let log = log.lock().unwrap().clone();
|
||||
assert!(
|
||||
log[0].starts_with("POST https://oauth2.googleapis.com/token")
|
||||
&& log[0].contains("(\"code\", \"4/abc\")")
|
||||
&& log[0].contains("(\"grant_type\", \"authorization_code\")"),
|
||||
"{}",
|
||||
log[0]
|
||||
);
|
||||
assert!(log[1].contains("Bearer ya29.ACCESS"), "{}", log[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_verifier_sent_matches_the_challenge_the_browser_saw() {
|
||||
let keychain = Keychain::default();
|
||||
let seen: Arc<Mutex<String>> = Arc::default();
|
||||
let sink = Arc::clone(&seen);
|
||||
let spy = browser(good_answer);
|
||||
let open: Box<super::Opener> = Box::new(move |url| {
|
||||
*sink.lock().map_err(|e| e.to_string())? = url.to_owned();
|
||||
spy(url)
|
||||
});
|
||||
let (google, log) =
|
||||
build(vec![("https://oauth2.googleapis.com/token", reply(200, TOKEN)), ("https://www.googleapis.com/", reply(200, LIST))], open, &keychain);
|
||||
google.connect().unwrap();
|
||||
let url = seen.lock().unwrap().clone();
|
||||
assert!(url.contains("code_challenge_method=S256") && url.contains("calendar.readonly"), "{url}");
|
||||
let challenge = query_of(&url, "code_challenge");
|
||||
let form = log.lock().unwrap()[0].clone();
|
||||
let verifier = form.split("(\"code_verifier\", \"").nth(1).and_then(|rest| rest.split('"').next()).unwrap_or_default().to_owned();
|
||||
assert_eq!(pkce_challenge(&verifier), challenge, "the verifier proves the challenge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_state_a_refusal_and_a_silent_browser_all_fail_cleanly() {
|
||||
let keychain = Keychain::default();
|
||||
let (google, _) = build(vec![], browser(|_| "/?code=x&state=forged".to_owned()), &keychain);
|
||||
assert!(google.connect().unwrap_err().contains("did not match"));
|
||||
let (google, _) = build(vec![], browser(|url| format!("/?error=access_denied&state={}", query_of(url, "state"))), &keychain);
|
||||
assert!(google.connect().unwrap_err().contains("access_denied"));
|
||||
let (mut google_quiet, _) = build(vec![], Box::new(|_| Ok(())), &keychain);
|
||||
google_quiet.timeout = Duration::from_millis(200);
|
||||
assert!(google_quiet.connect().unwrap_err().contains("timed out"));
|
||||
let (mut without_id, _) = build(vec![], browser(good_answer), &keychain);
|
||||
without_id.client_id = None;
|
||||
assert!(!without_id.configured() && without_id.connect().unwrap_err().contains("NOTESCRAFT_GOOGLE_CLIENT_ID"));
|
||||
assert_eq!(keychain.load().unwrap(), None, "a failed sign-in keeps nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_without_a_refresh_token_is_refused_and_errors_hold_no_secret() {
|
||||
let keychain = Keychain::default();
|
||||
let (google, _) = build(
|
||||
vec![("https://oauth2.googleapis.com/token", reply(200, r#"{"access_token":"ya29.ACCESS","expires_in":3599}"#))],
|
||||
browser(good_answer),
|
||||
&keychain,
|
||||
);
|
||||
let error = google.connect().unwrap_err();
|
||||
assert!(error.contains("no refresh token") && !error.contains("ya29"), "{error}");
|
||||
assert_eq!(keychain.load().unwrap(), None);
|
||||
let (google, _) =
|
||||
google_with(vec![("https://oauth2.googleapis.com/token", reply(400, r#"{"error":"invalid_grant","error_description":"Bad Request"}"#))], &keychain);
|
||||
let error = google.connect().unwrap_err();
|
||||
assert!(error.contains("Bad Request"), "{error}");
|
||||
}
|
||||
|
||||
fn google_with(script: Vec<(&'static str, Response)>, keychain: &Keychain) -> (Google, Log) {
|
||||
build(script, browser(good_answer), keychain)
|
||||
}
|
||||
|
||||
const PAGE_ONE: &str = r#"{"items":[{"id":"a","summary":"One","start":{"dateTime":"2026-10-20T09:00:00Z"}}],"nextPageToken":"p2"}"#;
|
||||
const PAGE_TWO: &str = r#"{"items":[{"id":"b","summary":"Two","start":{"date":"2026-10-21"}}]}"#;
|
||||
|
||||
#[test]
|
||||
fn events_are_fetched_page_by_page_with_a_fresh_access_token() {
|
||||
let keychain = Keychain::default();
|
||||
keychain.store("1//REFRESH").unwrap();
|
||||
let (google, log) = google_with(
|
||||
vec![
|
||||
("https://oauth2.googleapis.com/token", reply(200, r#"{"access_token":"ya29.NEW","expires_in":3599}"#)),
|
||||
("https://www.googleapis.com/calendar/v3/calendars/me%40x.com/events", reply(200, PAGE_ONE)),
|
||||
("https://www.googleapis.com/calendar/v3/calendars/me%40x.com/events", reply(200, PAGE_TWO)),
|
||||
],
|
||||
&keychain,
|
||||
);
|
||||
let Fetched::Body { text, etag } = google.fetch_events("me@x.com").unwrap() else { panic!("a body") };
|
||||
assert_eq!(etag, None);
|
||||
let (events, skipped) = notescraft_calendar::google::parse_events(&text).unwrap();
|
||||
assert_eq!((events.iter().map(|e| e.event.title.as_str()).collect::<Vec<_>>(), skipped), (vec!["One", "Two"], 0));
|
||||
let log = log.lock().unwrap().clone();
|
||||
assert!(log[0].contains("refresh_token") && log[0].contains("1//REFRESH"), "{}", log[0]);
|
||||
assert!(log[1].contains("Bearer ya29.NEW") && !log[1].contains("pageToken"), "{}", log[1]);
|
||||
assert!(log[2].contains("pageToken=p2"), "{}", log[2]);
|
||||
let again = google.fetch_events("me@x.com");
|
||||
assert!(again.is_err(), "the script is used up, so a second fetch asked the network, not the cached token twice: {again:?}");
|
||||
assert_eq!(log.len(), 3, "the access token was cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rejected_token_asks_to_connect_again_and_disconnect_forgets_everything() {
|
||||
let keychain = Keychain::default();
|
||||
keychain.store("1//REFRESH").unwrap();
|
||||
let (google, _) = google_with(
|
||||
vec![
|
||||
("https://oauth2.googleapis.com/token", reply(200, r#"{"access_token":"ya29.OLD","expires_in":3599}"#)),
|
||||
("https://www.googleapis.com/calendar/v3/calendars/c/events", reply(401, "{}")),
|
||||
],
|
||||
&keychain,
|
||||
);
|
||||
let error = google.fetch_events("c").unwrap_err();
|
||||
assert!(error.contains("connect again") && !error.contains("ya29") && !error.contains("REFRESH"), "{error}");
|
||||
assert!(google.connected());
|
||||
google.disconnect().unwrap();
|
||||
assert!(!google.connected());
|
||||
assert_eq!(keychain.load().unwrap(), None);
|
||||
assert!(google.fetch_events("c").unwrap_err().contains("not connected"));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Secrets in the system keychain through its command-line tool: `security` on macOS,
|
||||
//! `secret-tool` on Linux. Nothing secret goes into the vault, the settings or a log.
|
||||
//!
|
||||
//! macOS limit: `security` takes the secret as an argument, so it is visible to the same
|
||||
//! user's `ps` for the moment the command runs; Linux's tool reads it from stdin.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// A keychain operation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Op<'a> {
|
||||
/// Store this secret.
|
||||
Store(&'a str),
|
||||
/// Read the secret.
|
||||
Load,
|
||||
/// Remove the secret.
|
||||
Delete,
|
||||
}
|
||||
|
||||
/// The program and arguments for `op` on `os`; the secret to feed on stdin comes second.
|
||||
fn command_for(os: &str, service: &str, account: &str, op: Op<'_>) -> Option<(String, Vec<String>, Option<String>)> {
|
||||
let args = |list: &[&str]| list.iter().map(|part| (*part).to_owned()).collect::<Vec<_>>();
|
||||
match (os, op) {
|
||||
("macos", Op::Store(secret)) => Some(("security".to_owned(), args(&["add-generic-password", "-U", "-s", service, "-a", account, "-w", secret]), None)),
|
||||
("macos", Op::Load) => Some(("security".to_owned(), args(&["find-generic-password", "-s", service, "-a", account, "-w"]), None)),
|
||||
("macos", Op::Delete) => Some(("security".to_owned(), args(&["delete-generic-password", "-s", service, "-a", account]), None)),
|
||||
("linux", Op::Store(secret)) => {
|
||||
Some(("secret-tool".to_owned(), args(&["store", "--label=NotesCraft", "service", service, "account", account]), Some(secret.to_owned())))
|
||||
}
|
||||
("linux", Op::Load) => Some(("secret-tool".to_owned(), args(&["lookup", "service", service, "account", account]), None)),
|
||||
("linux", Op::Delete) => Some(("secret-tool".to_owned(), args(&["clear", "service", service, "account", account]), None)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `op`. A load that finds nothing is `Ok(None)`.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message that never contains the secret.
|
||||
pub fn run(service: &str, account: &str, op: Op<'_>) -> Result<Option<String>, String> {
|
||||
let Some((program, args, stdin)) = command_for(std::env::consts::OS, service, account, op) else {
|
||||
return Err(format!("there is no keychain tool for {}", std::env::consts::OS));
|
||||
};
|
||||
let mut child = Command::new(&program)
|
||||
.args(&args)
|
||||
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| if error.kind() == std::io::ErrorKind::NotFound { format!("{program} is not installed") } else { error.to_string() })?;
|
||||
if let (Some(secret), Some(mut pipe)) = (stdin, child.stdin.take()) {
|
||||
pipe.write_all(secret.as_bytes()).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|error| error.to_string())?;
|
||||
if output.status.success() {
|
||||
return Ok(match op {
|
||||
Op::Load => Some(String::from_utf8_lossy(&output.stdout).trim_end_matches(['\n', '\r']).to_owned()).filter(|secret| !secret.is_empty()),
|
||||
Op::Store(_) | Op::Delete => None,
|
||||
});
|
||||
}
|
||||
match op {
|
||||
Op::Load => Ok(None),
|
||||
Op::Delete if output.stdout.is_empty() => Ok(None),
|
||||
Op::Store(_) | Op::Delete => {
|
||||
let message: String = String::from_utf8_lossy(&output.stderr).lines().next().unwrap_or("the keychain tool failed").chars().take(120).collect();
|
||||
Err(match op {
|
||||
Op::Store(secret) if !secret.is_empty() => message.replace(secret, "<secret>"),
|
||||
_ => message,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Op, command_for};
|
||||
|
||||
#[test]
|
||||
fn the_right_tool_gets_the_right_arguments() {
|
||||
let (program, args, stdin) = command_for("macos", "notescraft", "google", Op::Store("s3cret")).unwrap();
|
||||
assert_eq!((program.as_str(), stdin), ("security", None));
|
||||
assert_eq!(args, ["add-generic-password", "-U", "-s", "notescraft", "-a", "google", "-w", "s3cret"]);
|
||||
assert_eq!(command_for("macos", "n", "g", Op::Load).unwrap().1, ["find-generic-password", "-s", "n", "-a", "g", "-w"]);
|
||||
let (program, args, stdin) = command_for("linux", "notescraft", "google", Op::Store("s3cret")).unwrap();
|
||||
assert_eq!(program, "secret-tool");
|
||||
assert_eq!(stdin.as_deref(), Some("s3cret"), "Linux reads the secret from stdin");
|
||||
assert!(!args.iter().any(|arg| arg.contains("s3cret")), "and keeps it out of the arguments");
|
||||
assert!(command_for("windows", "n", "g", Op::Load).is_none());
|
||||
}
|
||||
}
|
||||
+76
-16
@@ -10,13 +10,23 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod audio;
|
||||
mod desktop;
|
||||
mod google;
|
||||
mod keychain;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod menu;
|
||||
mod mixer;
|
||||
mod net;
|
||||
mod notify;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use eframe::egui;
|
||||
use notescraft_engine::Session;
|
||||
use notescraft_ui_egui::app::{NotesApp, VaultRequest};
|
||||
use notescraft_ui_egui::app::{LargeFolderGate, NotesApp, VaultRequest};
|
||||
|
||||
/// Passed to the relaunched window after the user confirmed the large-folder warning.
|
||||
const LARGE_OK_FLAG: &str = "--large-ok";
|
||||
|
||||
/// Why the app did not start.
|
||||
#[derive(Debug)]
|
||||
@@ -42,10 +52,18 @@ impl std::fmt::Display for StartError {
|
||||
/// The eframe wrapper: hands the root UI's context to [`NotesApp`].
|
||||
struct NotesCraftWindow {
|
||||
app: NotesApp,
|
||||
/// The native menu bar (macOS); `None` when it could not be built.
|
||||
#[cfg(target_os = "macos")]
|
||||
menu: Option<menu::MenuBar>,
|
||||
}
|
||||
|
||||
impl eframe::App for NotesCraftWindow {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Menu clicks first: Close/Quit become viewport commands the checks below see next frame.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(menu) = &self.menu {
|
||||
menu.poll(&mut self.app, ctx);
|
||||
}
|
||||
// Unsaved changes hold the window until the prompt is answered; the prompt
|
||||
// re-requests the close itself once it is.
|
||||
if self.app.blocked_by_close_request(ctx) {
|
||||
@@ -55,11 +73,20 @@ impl eframe::App for NotesCraftWindow {
|
||||
craft_quit::on_close_request(ctx);
|
||||
// A vault is bound to its Session, so switching starts a new instance on the chosen folder.
|
||||
let Some(request) = self.app.take_vault_request() else { return };
|
||||
let target = match request {
|
||||
VaultRequest::Open(path) => Some(path),
|
||||
VaultRequest::Pick => rfd::FileDialog::new().set_title("Open or create a vault folder").pick_folder(),
|
||||
// A folder the user confirmed in the large-folder warning is relaunched with the check skipped.
|
||||
let (target, confirmed) = match request {
|
||||
VaultRequest::Open(path) => (Some(path), false),
|
||||
VaultRequest::OpenLarge(path) => (Some(path), true),
|
||||
VaultRequest::Pick => (rfd::FileDialog::new().set_title("Open or create a vault folder").pick_folder(), false),
|
||||
};
|
||||
if target.is_some_and(|target| std::env::current_exe().and_then(|exe| std::process::Command::new(exe).arg(target).spawn()).is_ok()) {
|
||||
let relaunch = |target: std::path::PathBuf| {
|
||||
let mut command = std::process::Command::new(std::env::current_exe()?);
|
||||
if confirmed {
|
||||
command.arg(LARGE_OK_FLAG);
|
||||
}
|
||||
command.arg(target).spawn()
|
||||
};
|
||||
if target.is_some_and(|target| relaunch(target).is_ok()) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
@@ -145,8 +172,20 @@ fn remember_vault(path: &std::path::Path) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The warning to show before `path` is opened: it holds more than the allowed number of files and
|
||||
/// the user has not confirmed yet. Counting only reads, so nothing is written to the folder.
|
||||
fn large_folder_gate(path: &std::path::Path, confirmed: bool) -> Option<LargeFolderGate> {
|
||||
if confirmed {
|
||||
return None;
|
||||
}
|
||||
let counted = notescraft_engine::count_files(path, notescraft_engine::LARGE_FOLDER_FILES).ok()?;
|
||||
counted.over_limit.then(|| LargeFolderGate { path: path.to_path_buf(), files: counted.files, notes: counted.notes, over_limit: true })
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let mut args: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let large_ok = args.iter().any(|arg| arg == LARGE_OK_FLAG);
|
||||
args.retain(|arg| arg != LARGE_OK_FLAG);
|
||||
let path = match parse_launch(&args) {
|
||||
Ok(Launch::Print(text)) => {
|
||||
println!("{text}");
|
||||
@@ -162,7 +201,13 @@ fn main() -> ExitCode {
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let session = match Session::open(&path) {
|
||||
// A folder with thousands of files is confirmed before anything is written to it: the window
|
||||
// opens on an empty throw-away vault and shows the warning, and only a confirmed relaunch
|
||||
// opens the folder itself.
|
||||
let gate = large_folder_gate(&path, large_ok);
|
||||
let gate_dir = std::env::temp_dir().join(format!("notescraft-gate-{}", std::process::id()));
|
||||
let opened = if gate.is_some() { Session::open_read_only(&gate_dir) } else { Session::open_lazy(&path) };
|
||||
let session = match opened {
|
||||
Ok(session) => session,
|
||||
Err(error) => {
|
||||
eprintln!("{}", StartError::Vault(error));
|
||||
@@ -171,14 +216,20 @@ fn main() -> ExitCode {
|
||||
};
|
||||
// The control channel (SPEC 10, D9): loopback only, descriptor in the vault
|
||||
// state folder. A failure never blocks the app — the CLI just falls back.
|
||||
let control = match notescraft_engine::ControlServer::start(session.vault()) {
|
||||
Ok(handle) => Some(handle),
|
||||
Err(error) => {
|
||||
eprintln!("notescraft: the control channel did not start ({error})");
|
||||
None
|
||||
let control = if gate.is_some() {
|
||||
None
|
||||
} else {
|
||||
match notescraft_engine::ControlServer::start(session.vault()) {
|
||||
Ok(handle) => Some(handle),
|
||||
Err(error) => {
|
||||
eprintln!("notescraft: the control channel did not start ({error})");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
remember_vault(&path);
|
||||
if gate.is_none() {
|
||||
remember_vault(&path);
|
||||
}
|
||||
let Some(theme) = notescraft_themes::builtin("catppuccin-mocha") else {
|
||||
eprintln!("built-in theme catppuccin-mocha missing (broken build)");
|
||||
return ExitCode::from(1);
|
||||
@@ -195,8 +246,8 @@ fn main() -> ExitCode {
|
||||
viewport = viewport.with_icon(icon);
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
// No title bar: the content fills the window and the traffic lights float over the canvas.
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false);
|
||||
// No title bar and no traffic lights: the content fills the window (close, minimise and zoom live in the menu bar).
|
||||
viewport = viewport.with_fullsize_content_view(true).with_titlebar_shown(false).with_title_shown(false).with_titlebar_buttons_shown(false);
|
||||
}
|
||||
let options = eframe::NativeOptions { viewport, ..Default::default() };
|
||||
let result = eframe::run_native(
|
||||
@@ -204,6 +255,8 @@ fn main() -> ExitCode {
|
||||
options,
|
||||
Box::new(move |creation| {
|
||||
let mut app = NotesApp::new(session, theme);
|
||||
app.set_large_folder_gate(gate);
|
||||
app.set_services(std::sync::Arc::new(desktop::Desktop::new()));
|
||||
app.set_recent_vaults(&recent_vaults());
|
||||
app.control = control;
|
||||
if custom_titlebar {
|
||||
@@ -216,9 +269,16 @@ fn main() -> ExitCode {
|
||||
if let Some(sink) = audio::AudioSink::open() {
|
||||
app.set_sound_sink(Box::new(sink));
|
||||
}
|
||||
Ok(Box::new(NotesCraftWindow { app }) as Box<dyn eframe::App>)
|
||||
#[cfg(target_os = "macos")]
|
||||
let menu = menu::MenuBar::install(&creation.egui_ctx).map_err(|error| log::warn!("menu bar unavailable: {error}")).ok();
|
||||
Ok(Box::new(NotesCraftWindow {
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
menu,
|
||||
}) as Box<dyn eframe::App>)
|
||||
}),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&gate_dir);
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(error) => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! The native macOS menu bar: builds muda menus from the UI crate's pure [`menu_model`] and routes
|
||||
//! clicked items back to the app. Safe muda API only (`Menu::init_for_nsapp`).
|
||||
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use notescraft_ui_egui::app::{MenuEntry, MenuRole, NotesApp, Predefined, menu_model};
|
||||
|
||||
/// The installed menu bar; keep it alive for the life of the window.
|
||||
pub struct MenuBar {
|
||||
_menu: Menu,
|
||||
clicks: Receiver<String>,
|
||||
}
|
||||
|
||||
fn predefined(kind: Predefined, label: &str) -> PredefinedMenuItem {
|
||||
let label = Some(label);
|
||||
match kind {
|
||||
Predefined::About => PredefinedMenuItem::about(label, None),
|
||||
Predefined::Hide => PredefinedMenuItem::hide(label),
|
||||
Predefined::HideOthers => PredefinedMenuItem::hide_others(label),
|
||||
Predefined::ShowAll => PredefinedMenuItem::show_all(label),
|
||||
Predefined::FullScreen => PredefinedMenuItem::fullscreen(label),
|
||||
Predefined::Minimize => PredefinedMenuItem::minimize(label),
|
||||
Predefined::Zoom => PredefinedMenuItem::maximize(label),
|
||||
Predefined::BringAllToFront => PredefinedMenuItem::bring_all_to_front(label),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text on the clipboard (macOS `pbpaste`); empty when it cannot be read.
|
||||
fn clipboard_text() -> String {
|
||||
std::process::Command::new("pbpaste").output().map(|out| String::from_utf8_lossy(&out.stdout).into_owned()).unwrap_or_default()
|
||||
}
|
||||
|
||||
impl MenuBar {
|
||||
/// Installs the menu bar and wakes the event loop on every click.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns muda's error when a menu cannot be built; the app then keeps winit's default menu.
|
||||
pub fn install(ctx: &egui::Context) -> muda::Result<Self> {
|
||||
let menu = Menu::new();
|
||||
let mut roles = Vec::new();
|
||||
for model in menu_model() {
|
||||
let submenu = Submenu::new(model.title, true);
|
||||
for entry in &model.entries {
|
||||
match entry {
|
||||
MenuEntry::Separator => submenu.append(&PredefinedMenuItem::separator())?,
|
||||
MenuEntry::Predefined(kind, label) => submenu.append(&predefined(*kind, label))?,
|
||||
MenuEntry::Item(item) => {
|
||||
let accelerator = item.accelerator.and_then(|text| text.parse::<Accelerator>().ok());
|
||||
submenu.append(&MenuItem::with_id(item.id, item.label, true, accelerator))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
menu.append(&submenu)?;
|
||||
roles.push((model.role, submenu));
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
for (role, submenu) in &roles {
|
||||
match role {
|
||||
MenuRole::Window => submenu.set_as_windows_menu_for_nsapp(),
|
||||
MenuRole::Help => submenu.set_as_help_menu_for_nsapp(),
|
||||
MenuRole::App | MenuRole::Plain => {}
|
||||
}
|
||||
}
|
||||
let (sender, clicks) = channel();
|
||||
let wake = ctx.clone();
|
||||
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||
let _ = sender.send(event.id.0);
|
||||
wake.request_repaint();
|
||||
}));
|
||||
Ok(Self { _menu: menu, clicks })
|
||||
}
|
||||
|
||||
/// Runs every click since the last frame; call before the app paints.
|
||||
pub fn poll(&self, app: &mut NotesApp, ctx: &egui::Context) {
|
||||
while let Ok(id) = self.clicks.try_recv() {
|
||||
app.run_menu_item(&id, ctx, clipboard_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! HTTP through the system's `curl`: no TLS stack of our own, no new crate.
|
||||
//!
|
||||
//! Every request is described to curl on its standard input (`--config -`), so addresses,
|
||||
//! tokens and passwords never appear in the process list. A request has a time limit and a size
|
||||
//! limit, follows redirects only between http and https, and its errors never repeat the address.
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use notescraft_calendar::Fetched;
|
||||
|
||||
/// Seconds a request may take.
|
||||
const TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
/// Most bytes of an answer.
|
||||
const MAX_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// What came back.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Response {
|
||||
/// The HTTP status.
|
||||
pub status: u16,
|
||||
/// The body.
|
||||
pub body: Vec<u8>,
|
||||
/// The `ETag` header of the last hop.
|
||||
pub etag: Option<String>,
|
||||
}
|
||||
|
||||
/// A value for a curl config file: quoted, with the escapes curl reads.
|
||||
fn quote(text: &str) -> Result<String, String> {
|
||||
if text.chars().any(|c| c == '\r' || c == '\n' || c == '\0') {
|
||||
return Err("a request value has a line break".to_owned());
|
||||
}
|
||||
Ok(format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\"")))
|
||||
}
|
||||
|
||||
/// The curl config for a request.
|
||||
fn config(url: &str, headers: &[(&str, &str)], form: &[(&str, &str)], header_file: &str) -> Result<String, String> {
|
||||
let mut lines: Vec<String> = vec![format!("url = {}", quote(url)?), format!("dump-header = {}", quote(header_file)?)];
|
||||
lines.extend(["silent", "show-error", "location", "fail-early"].map(str::to_owned));
|
||||
lines.push(format!("max-time = {TIMEOUT_SECS}"));
|
||||
lines.push(format!("max-filesize = {MAX_BYTES}"));
|
||||
lines.extend(["proto = \"=https,http\"", "proto-redir = \"=https,http\"", "write-out = \"%{http_code}\""].map(str::to_owned));
|
||||
for (name, value) in headers {
|
||||
lines.push(format!("header = {}", quote(&format!("{name}: {value}"))?));
|
||||
}
|
||||
for (name, value) in form {
|
||||
lines.push(format!("data-urlencode = {}", quote(&format!("{name}={value}"))?));
|
||||
}
|
||||
Ok(lines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
/// The last `ETag` of a `-D` dump (one block per hop).
|
||||
fn etag_of(dump: &str) -> Option<String> {
|
||||
let block = dump.rsplit("\r\n\r\n").find(|block| block.to_ascii_lowercase().contains("etag:"))?;
|
||||
block.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.trim().eq_ignore_ascii_case("etag").then(|| value.trim().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn run(url: &str, headers: &[(&str, &str)], form: &[(&str, &str)]) -> Result<Response, String> {
|
||||
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let header_path = std::env::temp_dir().join(format!("notescraft-curl-{}-{unique}.hdr", std::process::id()));
|
||||
let header_file = header_path.to_string_lossy().into_owned();
|
||||
let config = config(url, headers, form, &header_file)?;
|
||||
let mut child = Command::new("curl")
|
||||
.args(["--config", "-"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| if error.kind() == std::io::ErrorKind::NotFound { "curl is not installed".to_owned() } else { error.to_string() })?;
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(config.as_bytes()).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let output = child.wait_with_output().map_err(|error| error.to_string())?;
|
||||
let dump = std::fs::read_to_string(&header_path).unwrap_or_default();
|
||||
let _ = std::fs::remove_file(&header_path);
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let line = stderr.lines().next().unwrap_or("the request failed");
|
||||
return Err(line.trim_start_matches("curl: ").chars().take(200).collect());
|
||||
}
|
||||
let (body, code) = output.stdout.split_at(output.stdout.len().saturating_sub(3));
|
||||
let status = std::str::from_utf8(code).ok().and_then(|code| code.parse::<u16>().ok()).ok_or_else(|| "no status in the answer".to_owned())?;
|
||||
Ok(Response { status, body: body.to_vec(), etag: etag_of(&dump) })
|
||||
}
|
||||
|
||||
/// GET with extra headers.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message without the address.
|
||||
pub fn get(url: &str, headers: &[(&str, &str)]) -> Result<Response, String> {
|
||||
run(url, headers, &[])
|
||||
}
|
||||
|
||||
/// POST of a url-encoded form.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message without the address.
|
||||
pub fn post_form(url: &str, form: &[(&str, &str)]) -> Result<Response, String> {
|
||||
run(url, &[], form)
|
||||
}
|
||||
|
||||
/// Fetches a calendar feed, sending the validator when there is one.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message without the address.
|
||||
pub fn fetch_feed(url: &str, etag: Option<&str>) -> Result<Fetched, String> {
|
||||
let validator;
|
||||
let mut headers: Vec<(&str, &str)> = vec![("Accept", "text/calendar, */*;q=0.1")];
|
||||
if let Some(etag) = etag {
|
||||
validator = etag.to_owned();
|
||||
headers.push(("If-None-Match", &validator));
|
||||
}
|
||||
let response = get(url, &headers)?;
|
||||
match response.status {
|
||||
304 => Ok(Fetched::NotModified),
|
||||
200..=299 => Ok(Fetched::Body { text: String::from_utf8_lossy(&response.body).into_owned(), etag: response.etag }),
|
||||
status => Err(format!("the server answered {status}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
|
||||
use super::{config, etag_of, fetch_feed, get, post_form, quote};
|
||||
|
||||
fn curl_exists() -> bool {
|
||||
std::process::Command::new("curl").arg("--version").output().is_ok_and(|out| out.status.success())
|
||||
}
|
||||
|
||||
/// Serves `replies` one connection each and returns the requests it saw.
|
||||
fn serve(replies: Vec<String>) -> (u16, std::thread::JoinHandle<Vec<String>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap_or_else(|error| panic!("{error}"));
|
||||
let port = listener.local_addr().map_or(0, |addr| addr.port());
|
||||
let handle = std::thread::spawn(move || {
|
||||
let mut seen = Vec::new();
|
||||
for reply in replies {
|
||||
let Ok((mut stream, _)) = listener.accept() else { break };
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4096];
|
||||
loop {
|
||||
let n = stream.read(&mut buffer).unwrap_or(0);
|
||||
request.extend_from_slice(&buffer[..n]);
|
||||
let text = String::from_utf8_lossy(&request).into_owned();
|
||||
let length = text
|
||||
.to_ascii_lowercase()
|
||||
.split("content-length:")
|
||||
.nth(1)
|
||||
.and_then(|rest| rest.lines().next())
|
||||
.and_then(|v| v.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
if n == 0 || text.split("\r\n\r\n").nth(1).is_some_and(|body| body.len() >= length) && text.contains("\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
seen.push(String::from_utf8_lossy(&request).into_owned());
|
||||
let _ = stream.write_all(reply.as_bytes());
|
||||
}
|
||||
seen
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
fn reply(status: &str, extra: &str, body: &str) -> String {
|
||||
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{extra}\r\n{body}", body.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_are_quoted_and_line_breaks_refused() {
|
||||
assert_eq!(quote(r#"a "b" \c"#).as_deref(), Ok(r#""a \"b\" \\c""#));
|
||||
assert!(quote("a\nb").is_err() && quote("a\0b").is_err());
|
||||
let text = config("https://a.example/x", &[("Authorization", "Bearer t")], &[("code", "a b&c")], "/tmp/h").unwrap();
|
||||
for line in
|
||||
["url = \"https://a.example/x\"", "header = \"Authorization: Bearer t\"", "data-urlencode = \"code=a b&c\"", "proto-redir = \"=https,http\""]
|
||||
{
|
||||
assert!(text.contains(line), "{line} in {text}");
|
||||
}
|
||||
assert!(config("https://a.example/\r\nevil", &[], &[], "h").is_err());
|
||||
assert_eq!(etag_of("HTTP/1.1 301\r\nLocation: x\r\n\r\nHTTP/1.1 200\r\nETag: \"v2\"\r\n\r\n").as_deref(), Some("\"v2\""));
|
||||
assert_eq!(etag_of("HTTP/1.1 200\r\n\r\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_feed_is_fetched_with_its_validator() {
|
||||
if !curl_exists() {
|
||||
return;
|
||||
}
|
||||
let (port, server) = serve(vec![reply("200 OK", "ETag: \"v1\"\r\n", "BEGIN:VCALENDAR"), reply("304 Not Modified", "", ""), reply("500 Oops", "", "x")]);
|
||||
let url = format!("http://127.0.0.1:{port}/feed.ics");
|
||||
let first = fetch_feed(&url, None).unwrap();
|
||||
assert_eq!(first, notescraft_calendar::Fetched::Body { text: "BEGIN:VCALENDAR".to_owned(), etag: Some("\"v1\"".to_owned()) });
|
||||
assert_eq!(fetch_feed(&url, Some("\"v1\"")).unwrap(), notescraft_calendar::Fetched::NotModified);
|
||||
let error = fetch_feed(&url, None).unwrap_err();
|
||||
assert_eq!(error, "the server answered 500");
|
||||
let seen = server.join().unwrap_or_default();
|
||||
assert!(seen[1].to_ascii_lowercase().contains("if-none-match: \"v1\""), "{}", seen[1]);
|
||||
assert!(!seen[0].to_ascii_lowercase().contains("if-none-match"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn requests_carry_headers_and_forms_and_errors_name_no_address() {
|
||||
if !curl_exists() {
|
||||
return;
|
||||
}
|
||||
let (port, server) = serve(vec![reply("200 OK", "", "{}"), reply("200 OK", "", "{\"ok\":true}")]);
|
||||
let got = get(&format!("http://127.0.0.1:{port}/a?token=SECRET"), &[("Authorization", "Bearer abc")]).unwrap();
|
||||
assert_eq!((got.status, got.body.as_slice()), (200, b"{}".as_slice()));
|
||||
let posted = post_form(&format!("http://127.0.0.1:{port}/token"), &[("code", "a b&c"), ("grant_type", "authorization_code")]).unwrap();
|
||||
assert_eq!(posted.body, b"{\"ok\":true}");
|
||||
let seen = server.join().unwrap_or_default();
|
||||
assert!(seen[0].to_ascii_lowercase().contains("authorization: bearer abc"));
|
||||
assert!(seen[1].starts_with("POST /token") && seen[1].contains("code=a+b%26c&grant_type=authorization_code"), "{}", seen[1]);
|
||||
let closed = get("http://127.0.0.1:1/never?token=SECRET", &[]).unwrap_err();
|
||||
assert!(!closed.contains("SECRET") && !closed.contains("127.0.0.1:1/never"), "{closed}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! System notifications through the platform's own tool: `osascript` on macOS, `notify-send` on
|
||||
//! Linux. Windows has none in this build and says so.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// An `AppleScript` string literal: quotes and backslashes escaped, line breaks flattened.
|
||||
fn applescript(text: &str) -> String {
|
||||
let flat: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
format!("\"{}\"", flat.replace('\\', "\\\\").replace('"', "\\\""))
|
||||
}
|
||||
|
||||
/// The program and arguments that show a notification on `os`, if the platform has one.
|
||||
fn command_for(os: &str, title: &str, body: &str) -> Option<(String, Vec<String>)> {
|
||||
match os {
|
||||
"macos" => {
|
||||
Some(("osascript".to_owned(), vec!["-e".to_owned(), format!("display notification {} with title {}", applescript(body), applescript(title))]))
|
||||
}
|
||||
"linux" => Some(("notify-send".to_owned(), vec!["--app-name=NotesCraft".to_owned(), "--".to_owned(), title.to_owned(), body.to_owned()])),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows a notification.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message saying the platform has no notification tool, the tool is missing, or it failed.
|
||||
pub fn notify(title: &str, body: &str) -> Result<(), String> {
|
||||
let Some((program, args)) = command_for(std::env::consts::OS, title, body) else {
|
||||
return Err(format!("system notifications are not available on {}", std::env::consts::OS));
|
||||
};
|
||||
let output = Command::new(&program)
|
||||
.args(&args)
|
||||
.output()
|
||||
.map_err(|error| if error.kind() == std::io::ErrorKind::NotFound { format!("{program} is not installed") } else { error.to_string() })?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(stderr.lines().next().unwrap_or("the notification tool failed").chars().take(200).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{applescript, command_for};
|
||||
|
||||
#[test]
|
||||
fn applescript_text_cannot_break_out_of_its_quotes() {
|
||||
assert_eq!(applescript(r#"say "hi" \ now"#), r#""say \"hi\" \\ now""#);
|
||||
assert_eq!(applescript("a\nb\tc"), "\"a b c\"");
|
||||
let (program, args) = command_for("macos", "Stand\"up", "9:30\" & (do shell script \"x\")").unwrap();
|
||||
assert_eq!(program, "osascript");
|
||||
assert!(args[1].starts_with("display notification \"9:30\\\" & (do shell script \\\"x\\\")\" with title \"Stand\\\"up\""), "{}", args[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_passes_text_as_arguments_after_a_separator() {
|
||||
let (program, args) = command_for("linux", "--urgency=critical", "body").unwrap();
|
||||
assert_eq!(program, "notify-send");
|
||||
assert_eq!(args, ["--app-name=NotesCraft", "--", "--urgency=critical", "body"]);
|
||||
assert!(command_for("windows", "t", "b").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 332 B |
@@ -0,0 +1,17 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m3 17 2 2 4-4" />
|
||||
<path d="m3 7 2 2 4-4" />
|
||||
<path d="M13 6h8" />
|
||||
<path d="M13 12h8" />
|
||||
<path d="M13 18h8" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 336 B |
@@ -36,7 +36,7 @@ mod source;
|
||||
pub(crate) mod text;
|
||||
|
||||
pub use error::{Error, Result};
|
||||
pub use mentions::link::link_mention;
|
||||
pub use mentions::link::{link_mention, link_mention_as};
|
||||
pub use mentions::linked::linked_mentions;
|
||||
pub use mentions::unlinked::{counts, unlinked_mentions};
|
||||
pub use model::{Counts, LinkedMention, MAX_MENTIONS, MAX_NOTE_BYTES, MAX_SNIPPET_CHARS, UnlinkedMention, UnlinkedReport, group_by_note};
|
||||
|
||||
@@ -26,16 +26,28 @@ pub fn link_mention(content: &str, mention: &UnlinkedMention, target_title: &str
|
||||
if target_title.is_empty() {
|
||||
return Err(Error::EmptyTitle);
|
||||
}
|
||||
let link = if mention.matched == target_title { format!("[[{target_title}]]") } else { format!("[[{target_title}|{}]]", mention.matched) };
|
||||
link_mention_as(content, mention, &link)
|
||||
}
|
||||
|
||||
/// [`link_mention`] with the link text already written (the caller picks wikilink or Markdown form).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Same as [`link_mention`], except [`Error::EmptyTitle`] for an empty `link`.
|
||||
pub fn link_mention_as(content: &str, mention: &UnlinkedMention, link: &str) -> Result<String> {
|
||||
if link.is_empty() {
|
||||
return Err(Error::EmptyTitle);
|
||||
}
|
||||
check_range(content, mention)?;
|
||||
if mention.matched.is_empty() || content.get(mention.range.start..mention.range.end) != Some(mention.matched.as_str()) {
|
||||
return Err(Error::Stale);
|
||||
}
|
||||
let link = if mention.matched == target_title { format!("[[{target_title}]]") } else { format!("[[{target_title}|{}]]", mention.matched) };
|
||||
let (head, rest) = content.split_at_checked(mention.range.start).ok_or(invalid(content, mention))?;
|
||||
let (_, tail) = rest.split_at_checked(mention.range.end - mention.range.start).ok_or(invalid(content, mention))?;
|
||||
let mut out = String::with_capacity(content.len().saturating_sub(mention.matched.len()).saturating_add(link.len()));
|
||||
out.push_str(head);
|
||||
out.push_str(&link);
|
||||
out.push_str(link);
|
||||
out.push_str(tail);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "notescraft-blocks"
|
||||
description = "top-level markdown blocks of a note and pure edits on them (move, duplicate, delete, turn into, insert)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
notescraft-markdown = { workspace = true }
|
||||
notescraft-regex = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
@@ -0,0 +1,109 @@
|
||||
//! "Turn into": change the type of a block, keeping its text.
|
||||
|
||||
use super::{Block, BlockKind, Edited, block, splice};
|
||||
use crate::{Error, Result};
|
||||
|
||||
const MAX_HEADING: u8 = 6;
|
||||
|
||||
/// Block type to convert to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Target {
|
||||
/// Plain paragraph.
|
||||
Paragraph,
|
||||
/// Heading of level 1 to 6.
|
||||
Heading(u8),
|
||||
/// Block quote.
|
||||
Quote,
|
||||
/// Unchecked task item.
|
||||
Task,
|
||||
/// Bullet item.
|
||||
Bullet,
|
||||
/// Numbered item.
|
||||
Numbered,
|
||||
/// Callout of this kind (`note`, `warning`, ...).
|
||||
Callout(String),
|
||||
}
|
||||
|
||||
struct Parts {
|
||||
indent: String,
|
||||
lines: Vec<String>,
|
||||
children: String,
|
||||
checked: bool,
|
||||
list: bool,
|
||||
}
|
||||
|
||||
fn unquote(line: &str) -> String {
|
||||
let t = line.trim_start().strip_prefix('>').unwrap_or(line);
|
||||
t.strip_prefix(' ').unwrap_or(t).to_owned()
|
||||
}
|
||||
|
||||
fn parts(text: &str, b: &Block) -> Result<Parts> {
|
||||
let src = text.get(b.range.clone()).ok_or(Error::CharBoundary { start: b.range.start, end: b.range.end })?;
|
||||
let mut p = Parts { indent: String::new(), lines: Vec::new(), children: String::new(), checked: false, list: false };
|
||||
match b.kind {
|
||||
BlockKind::Paragraph => p.lines = src.lines().map(|l| l.trim().to_owned()).collect(),
|
||||
BlockKind::Heading(_) => p.lines.push(text.get(b.text_range.clone()).unwrap_or("").trim().to_owned()),
|
||||
BlockKind::Quote => p.lines = src.lines().map(unquote).collect(),
|
||||
BlockKind::Callout => {
|
||||
let mut it = src.lines();
|
||||
let head = it.next().map(unquote).unwrap_or_default();
|
||||
let title = head.split_once(']').map_or("", |(_, t)| t.trim_start_matches(['+', '-']).trim());
|
||||
if !title.is_empty() {
|
||||
p.lines.push(title.to_owned());
|
||||
}
|
||||
p.lines.extend(it.map(unquote));
|
||||
}
|
||||
BlockKind::TaskItem { checked } => {
|
||||
p.checked = checked;
|
||||
list_parts(src, b, &mut p);
|
||||
}
|
||||
BlockKind::ListItem { .. } => list_parts(src, b, &mut p),
|
||||
_ => return Err(Error::Unsupported("block type cannot be converted")),
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
fn list_parts(src: &str, b: &Block, p: &mut Parts) {
|
||||
let (first, rest) = src.split_once('\n').unwrap_or((src, ""));
|
||||
let cut = b.text_range.start.saturating_sub(b.range.start).min(first.len());
|
||||
p.indent = first.chars().take_while(|c| c.is_whitespace()).collect();
|
||||
p.lines.push(first.get(cut..).unwrap_or("").trim().to_owned());
|
||||
if !rest.is_empty() {
|
||||
p.children = format!("\n{rest}");
|
||||
}
|
||||
p.list = true;
|
||||
}
|
||||
|
||||
/// Convert block `index` to `target` keeping its text; caret at the block end.
|
||||
///
|
||||
/// List items keep their indentation and nested children. Tables, code
|
||||
/// fences, dividers, frontmatter and embeds are refused.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::BlockIndex`], [`Error::Unsupported`] (also for a heading level outside 1..=6).
|
||||
pub fn turn_into(text: &str, index: usize, target: &Target) -> Result<Edited> {
|
||||
let b = block(text, index)?;
|
||||
let p = parts(text, &b)?;
|
||||
let joined = p.lines.join(" ");
|
||||
let item = |marker: &str| format!("{}{marker}{joined}{}", p.indent, p.children);
|
||||
let body = match target {
|
||||
Target::Paragraph => p.lines.join("\n"),
|
||||
Target::Heading(n) if (1..=MAX_HEADING).contains(n) => format!("{} {joined}", "#".repeat(usize::from(*n))),
|
||||
Target::Heading(_) => return Err(Error::Unsupported("heading level must be 1 to 6")),
|
||||
Target::Quote => p.lines.iter().map(|l| format!("> {l}").trim_end().to_owned()).collect::<Vec<_>>().join("\n"),
|
||||
Target::Task => item(if p.checked { "- [x] " } else { "- [ ] " }),
|
||||
Target::Bullet => item("- "),
|
||||
Target::Numbered => item("1. "),
|
||||
Target::Callout(kind) => {
|
||||
let rows = p.lines.iter().map(|l| format!("> {l}").trim_end().to_owned());
|
||||
std::iter::once(format!("> [!{kind}]")).chain(rows).collect::<Vec<_>>().join("\n")
|
||||
}
|
||||
};
|
||||
let body = if p.list && !matches!(target, Target::Task | Target::Bullet | Target::Numbered) && !p.children.is_empty() {
|
||||
format!("{body}{}", p.children)
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let caret = b.range.start + body.len();
|
||||
splice(text, b.range, body, caret)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Conservative language detection for unlabeled fenced code blocks.
|
||||
//!
|
||||
//! [`detect_language`] returns a fence label only when the evidence is strong;
|
||||
//! prose, one-liners and ambiguous snippets get `None`, so a batch review never
|
||||
//! offers a guess it cannot defend. Only the first [`MAX_BYTES`] bytes and the
|
||||
//! first [`MAX_LINES`] non-blank lines are examined, so a huge body costs no
|
||||
//! more than a small one.
|
||||
|
||||
/// Largest prefix of a body the detector examines, in bytes.
|
||||
const MAX_BYTES: usize = 4 * 1024;
|
||||
/// Most non-blank lines the detector examines.
|
||||
const MAX_LINES: usize = 40;
|
||||
/// Most non-blank lines the YAML shape rule examines.
|
||||
const YAML_LINES: usize = 20;
|
||||
/// Fewest `$` prompt lines the shell rule accepts.
|
||||
const MIN_PROMPT_LINES: usize = 2;
|
||||
/// Fewest shaped lines the YAML rule accepts.
|
||||
const MIN_YAML_LINES: usize = 3;
|
||||
/// Fewest `key:` lines the YAML rule accepts.
|
||||
const MIN_YAML_KEYS: usize = 2;
|
||||
|
||||
/// The fence label a code-block body is written in, `None` when the evidence is weak.
|
||||
///
|
||||
/// The rules run in order and the first match wins: shebang, PHP, HTML, XML,
|
||||
/// JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML. Anything else is
|
||||
/// `None`.
|
||||
#[must_use]
|
||||
pub fn detect_language(body: &str) -> Option<&'static str> {
|
||||
let window = head(body);
|
||||
if window.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let lines = split_lines(window);
|
||||
let seen: Vec<&str> = lines.iter().copied().filter(|line| !line.trim().is_empty()).take(MAX_LINES).collect();
|
||||
if let Some(found) = shebang(seen.first().copied()) {
|
||||
return Some(found);
|
||||
}
|
||||
let lead = window.trim_start();
|
||||
if lead.starts_with("<?php") {
|
||||
return Some("php");
|
||||
}
|
||||
if starts_html(lead) {
|
||||
return Some("html");
|
||||
}
|
||||
if lead.starts_with("<?xml") {
|
||||
return Some("xml");
|
||||
}
|
||||
if crate::looks_like_json(window) {
|
||||
return Some("json");
|
||||
}
|
||||
if is_yaml(&seen, window) {
|
||||
return Some("yaml");
|
||||
}
|
||||
if is_diff(&seen) {
|
||||
return Some("diff");
|
||||
}
|
||||
if is_prompt(&seen) {
|
||||
return Some("bash");
|
||||
}
|
||||
if is_rust(&seen) {
|
||||
return Some("rust");
|
||||
}
|
||||
if is_python(&seen) {
|
||||
return Some("python");
|
||||
}
|
||||
if is_sql(&seen, window) {
|
||||
return Some("sql");
|
||||
}
|
||||
if is_toml(&seen, window) {
|
||||
return Some("toml");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The first at most [`MAX_BYTES`] bytes of `body`, cut on a char boundary.
|
||||
fn head(body: &str) -> &str {
|
||||
let mut end = body.len().min(MAX_BYTES);
|
||||
while end > 0 && !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `window` split into lines, each without its CR.
|
||||
fn split_lines(window: &str) -> Vec<&str> {
|
||||
window.split('\n').map(|line| line.strip_suffix('\r').unwrap_or(line)).collect()
|
||||
}
|
||||
|
||||
/// The interpreter of a `#!` first line: `python`, `bash` (also `sh`/`zsh`) or `javascript`.
|
||||
fn shebang(line: Option<&str>) -> Option<&'static str> {
|
||||
let rest = line?.trim_start().strip_prefix("#!")?;
|
||||
let mut words = rest.split_whitespace();
|
||||
let first = words.next()?;
|
||||
let named = if first.rsplit('/').next() == Some("env") { words.next()? } else { first.rsplit('/').next()? };
|
||||
match named {
|
||||
"python" => Some("python"),
|
||||
"bash" | "sh" | "zsh" => Some("bash"),
|
||||
"node" => Some("javascript"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` when `lead` opens with `<!DOCTYPE html` or `<html`, whatever the case.
|
||||
fn starts_html(lead: &str) -> bool {
|
||||
let lower: String = lead.chars().take(16).flat_map(char::to_lowercase).collect();
|
||||
lower.starts_with("<!doctype html") || lower.starts_with("<html")
|
||||
}
|
||||
|
||||
/// `true` when the first lines are a `key: value` / `- item` YAML shape: every non-blank,
|
||||
/// non-comment line of the first [`YAML_LINES`] matches, at least [`MIN_YAML_KEYS`] hold a
|
||||
/// colon key, at least [`MIN_YAML_LINES`] are shaped, and no `{` or `;` appears.
|
||||
fn is_yaml(seen: &[&str], window: &str) -> bool {
|
||||
if window.contains('{') || window.contains(';') {
|
||||
return false;
|
||||
}
|
||||
let mut shaped = 0;
|
||||
let mut keys = 0;
|
||||
for line in seen.iter().take(YAML_LINES) {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('#') {
|
||||
continue; // a comment line is exempt from the shape test
|
||||
}
|
||||
if yaml_key(trimmed) {
|
||||
keys += 1;
|
||||
shaped += 1;
|
||||
} else if trimmed.starts_with("- ") || trimmed == "-" {
|
||||
shaped += 1;
|
||||
} else {
|
||||
return false; // one line outside the shape: not YAML
|
||||
}
|
||||
}
|
||||
keys >= MIN_YAML_KEYS && shaped >= MIN_YAML_LINES
|
||||
}
|
||||
|
||||
/// `true` when `line` is `key:` or `key: value` with an ASCII-word key, a colon and a space (or nothing) after it.
|
||||
fn yaml_key(line: &str) -> bool {
|
||||
let key = line.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_' || *b == b'-').count();
|
||||
if line.as_bytes().get(key) != Some(&b':') {
|
||||
return false;
|
||||
}
|
||||
let after = line.get(key + 1..).unwrap_or("");
|
||||
after.is_empty() || after.starts_with(' ')
|
||||
}
|
||||
|
||||
/// `true` for a `diff --git ` opener or a `--- a/` line with a `+++ b/` line after it.
|
||||
fn is_diff(seen: &[&str]) -> bool {
|
||||
if seen.first().is_some_and(|line| line.starts_with("diff --git ")) {
|
||||
return true;
|
||||
}
|
||||
let mut minus = false;
|
||||
seen.iter().take(MAX_LINES).any(|line| {
|
||||
if line.starts_with("--- a/") {
|
||||
minus = true;
|
||||
} else if minus && line.starts_with("+++ b/") {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
/// `true` when every examined line starts with `$ `, so the body is a transcript of shell commands.
|
||||
fn is_prompt(seen: &[&str]) -> bool {
|
||||
seen.len() >= MIN_PROMPT_LINES && seen.iter().all(|line| line.starts_with("$ "))
|
||||
}
|
||||
|
||||
/// `true` on Rust evidence: a `fn `/`pub fn `/`impl `/`use std::` line and a `let `/`->`/`::` line.
|
||||
fn is_rust(seen: &[&str]) -> bool {
|
||||
let opens = seen.iter().any(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
trimmed.starts_with("fn ") || trimmed.starts_with("pub fn ") || trimmed.starts_with("impl ") || trimmed.starts_with("use std::")
|
||||
});
|
||||
let marks = seen.iter().any(|line| line.contains("let ") || line.contains("->") || line.contains("::"));
|
||||
opens && marks
|
||||
}
|
||||
|
||||
/// `true` on Python evidence: a `def `/`import `/`from x import y` line and an indented, `class ` or `print(` line.
|
||||
fn is_python(seen: &[&str]) -> bool {
|
||||
let opens = seen.iter().any(|line| {
|
||||
let trimmed = line.trim_start();
|
||||
trimmed.starts_with("def ") || trimmed.starts_with("import ") || (trimmed.starts_with("from ") && trimmed.contains(" import "))
|
||||
});
|
||||
let marks = seen.iter().any(|line| line.starts_with(" ") || line.trim_start().starts_with("class ") || line.contains("print("));
|
||||
opens && marks
|
||||
}
|
||||
|
||||
/// `true` when the first keyword is a DML/DDL opener (case-insensitive) and the body holds a `;` or a FROM/VALUES/WHERE word.
|
||||
fn is_sql(seen: &[&str], window: &str) -> bool {
|
||||
let Some(first) = seen.first().copied().map(str::trim) else {
|
||||
return false;
|
||||
};
|
||||
let upper = first.trim().to_ascii_uppercase();
|
||||
let opens = upper.starts_with("SELECT ")
|
||||
|| upper.starts_with("INSERT INTO")
|
||||
|| upper.starts_with("CREATE TABLE")
|
||||
|| upper.starts_with("DELETE FROM")
|
||||
|| (upper.starts_with("UPDATE ") && upper.contains(" SET "));
|
||||
if !opens {
|
||||
return false;
|
||||
}
|
||||
let all_upper = window.to_ascii_uppercase();
|
||||
all_upper.contains(';') || has_word(&all_upper, "FROM") || has_word(&all_upper, "VALUES") || has_word(&all_upper, "WHERE")
|
||||
}
|
||||
|
||||
/// `true` when `word` appears in `upper` at the start or after a space or newline.
|
||||
fn has_word(upper: &str, word: &str) -> bool {
|
||||
upper.starts_with(word) || upper.contains(&format!(" {word}")) || upper.contains(&format!("\n{word}"))
|
||||
}
|
||||
|
||||
/// `true` on TOML evidence: a `[section]` line or two `key = value` lines, and no `{` or `;`.
|
||||
fn is_toml(seen: &[&str], window: &str) -> bool {
|
||||
if window.contains('{') || window.contains(';') {
|
||||
return false;
|
||||
}
|
||||
let section = seen.iter().any(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.len() > 2
|
||||
&& trimmed.starts_with('[')
|
||||
&& trimmed.ends_with(']')
|
||||
&& !trimmed.contains('"')
|
||||
&& !trimmed.contains(',')
|
||||
&& !trimmed.chars().any(char::is_control)
|
||||
});
|
||||
let assigns = seen.iter().filter(|line| toml_assign(line.trim())).count();
|
||||
section || assigns >= 2
|
||||
}
|
||||
|
||||
/// `true` when `line` is a `key = value` assignment with an ASCII-word key and a non-empty value.
|
||||
fn toml_assign(line: &str) -> bool {
|
||||
let Some(at) = line.find('=') else {
|
||||
return false;
|
||||
};
|
||||
let key = line.get(..at).unwrap_or("").trim();
|
||||
let value = line.get(at + 1..).unwrap_or("").trim();
|
||||
!key.is_empty() && !value.is_empty() && key.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
//! Plain find and replace: the byte range of every match, and a text with chosen
|
||||
//! ranges replaced.
|
||||
//!
|
||||
//! Matches never overlap and never run past [`MAX_MATCHES`]. Case folding goes through
|
||||
//! [`char::to_lowercase`] one char at a time, so a folded match keeps the byte range of
|
||||
//! the text it was found in (`İ` and `ß` included).
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// Most matches [`find_all`] returns; a text with more matches reports the first ones.
|
||||
const MAX_MATCHES: usize = 10_000;
|
||||
|
||||
/// How [`find_all`] matches: case sensitivity and whole-word boundaries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FindOptions {
|
||||
/// Upper and lower case must match as written.
|
||||
pub case_sensitive: bool,
|
||||
/// Only match when neither neighbour is a letter, a digit or `_`.
|
||||
pub whole_word: bool,
|
||||
}
|
||||
|
||||
impl Default for FindOptions {
|
||||
fn default() -> Self {
|
||||
Self { case_sensitive: true, whole_word: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The matches of a regular expression, with what is needed to expand `$1` in a replacement.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegexFind {
|
||||
/// Byte range of every match, as [`find_all`] reports them.
|
||||
pub ranges: Vec<Range<usize>>,
|
||||
regex: notescraft_regex::Regex,
|
||||
matches: Vec<notescraft_regex::Match>,
|
||||
}
|
||||
|
||||
/// Every non-empty match of the regular expression `pattern` in `text` (at most [`MAX_MATCHES`]).
|
||||
///
|
||||
/// # Errors
|
||||
/// The reason the pattern is invalid, or that the text is too large to search.
|
||||
pub fn find_regex(text: &str, pattern: &str, case_sensitive: bool) -> Result<RegexFind, String> {
|
||||
let regex = notescraft_regex::Regex::new(pattern, !case_sensitive).map_err(|error| error.to_string())?;
|
||||
let matches = regex.find_all(text, MAX_MATCHES).map_err(|error| error.to_string())?;
|
||||
Ok(RegexFind { ranges: matches.iter().map(|found| found.range.clone()).collect(), regex, matches })
|
||||
}
|
||||
|
||||
impl RegexFind {
|
||||
/// The replacement of match `index` for `template` (`$1` group, `$0` whole match, `$$` dollar).
|
||||
#[must_use]
|
||||
pub fn replacement(&self, index: usize, text: &str, template: &str) -> String {
|
||||
self.matches.get(index).map_or_else(String::new, |found| self.regex.expand(found, text, template))
|
||||
}
|
||||
|
||||
/// `text` with every match replaced.
|
||||
#[must_use]
|
||||
pub fn replace_all(&self, text: &str, template: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut at = 0;
|
||||
for (index, range) in self.ranges.iter().enumerate() {
|
||||
out.push_str(text.get(at..range.start).unwrap_or_default());
|
||||
out.push_str(&self.replacement(index, text, template));
|
||||
at = range.end;
|
||||
}
|
||||
out.push_str(text.get(at..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Every match of `needle` in `text`: byte ranges on char boundaries, non-overlapping, left to
|
||||
/// right. An empty `needle` matches nothing, and at most [`MAX_MATCHES`] ranges come back.
|
||||
#[must_use]
|
||||
pub fn find_all(text: &str, needle: &str, options: FindOptions) -> Vec<Range<usize>> {
|
||||
let wanted: Vec<char> = if options.case_sensitive { needle.chars().collect() } else { needle.chars().flat_map(char::to_lowercase).collect() };
|
||||
if wanted.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found: Vec<Range<usize>> = Vec::new();
|
||||
let mut at = 0;
|
||||
while at <= text.len() && found.len() < MAX_MATCHES {
|
||||
match match_at(text, at, &wanted, options.case_sensitive) {
|
||||
Some(end) if whole_word_ok(text, at, end, options.whole_word) => {
|
||||
found.push(at..end);
|
||||
at = end; // matches never overlap: the next one starts past this one
|
||||
}
|
||||
_ => at = next_char(text, at),
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
/// `text` with each range of `ranges` replaced by `with`, spliced from the end so the byte
|
||||
/// offsets of the ranges left stay valid. Ranges are taken in order of their start; one that is
|
||||
/// out of bounds, off a char boundary or overlapping an earlier replacement is skipped.
|
||||
#[must_use]
|
||||
pub fn replace_ranges(text: &str, ranges: &[Range<usize>], with: &str) -> String {
|
||||
let mut order: Vec<&Range<usize>> = ranges.iter().collect();
|
||||
order.sort_by_key(|range| (range.start, range.end));
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut at = 0usize;
|
||||
for range in order {
|
||||
if range.start < at || !valid(text, range) {
|
||||
continue; // invalid or overlapping an earlier replacement: the text stays
|
||||
}
|
||||
out.push_str(text.get(at..range.start).unwrap_or_default());
|
||||
out.push_str(with);
|
||||
at = range.end;
|
||||
}
|
||||
out.push_str(text.get(at..).unwrap_or_default());
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether a range can be spliced out of `text`: inside it, ordered and on char boundaries.
|
||||
fn valid(text: &str, range: &Range<usize>) -> bool {
|
||||
range.start <= range.end && range.end <= text.len() && text.is_char_boundary(range.start) && text.is_char_boundary(range.end)
|
||||
}
|
||||
|
||||
/// The end of the match of `wanted` at `at`, `None` when `text` does not hold it there. One text
|
||||
/// char can stand for several wanted chars when its lowercase folds longer (`ß` to `ss`).
|
||||
fn match_at(text: &str, at: usize, wanted: &[char], case_sensitive: bool) -> Option<usize> {
|
||||
if case_sensitive {
|
||||
let mut end = at;
|
||||
for want in wanted {
|
||||
let c = text.get(end..).and_then(|rest| rest.chars().next())?;
|
||||
if c != *want {
|
||||
return None;
|
||||
}
|
||||
end += c.len_utf8();
|
||||
}
|
||||
return Some(end);
|
||||
}
|
||||
let mut end = at;
|
||||
let mut matched = 0usize;
|
||||
while matched < wanted.len() {
|
||||
let c = text.get(end..).and_then(|rest| rest.chars().next())?;
|
||||
let mut done = false;
|
||||
for got in c.to_lowercase() {
|
||||
if got != wanted.get(matched).copied()? {
|
||||
return None;
|
||||
}
|
||||
matched += 1;
|
||||
if matched == wanted.len() {
|
||||
done = true; // the needle ran out inside this char, so the whole char is in the match
|
||||
break;
|
||||
}
|
||||
}
|
||||
end += c.len_utf8();
|
||||
if done {
|
||||
return Some(end);
|
||||
}
|
||||
}
|
||||
Some(end)
|
||||
}
|
||||
|
||||
/// Whether both neighbours of a match are word-free, so `whole_word` keeps it.
|
||||
fn whole_word_ok(text: &str, start: usize, end: usize, whole_word: bool) -> bool {
|
||||
if !whole_word {
|
||||
return true;
|
||||
}
|
||||
let before = text.get(..start).and_then(|head| head.chars().next_back());
|
||||
let after = text.get(end..).and_then(|rest| rest.chars().next());
|
||||
[before, after].into_iter().flatten().all(|c| !c.is_alphanumeric() && c != '_')
|
||||
}
|
||||
|
||||
/// The next place a match may start: the char after `at`, past the end of the text.
|
||||
fn next_char(text: &str, at: usize) -> usize {
|
||||
match text.get(at..).and_then(|rest| rest.chars().next()) {
|
||||
Some(c) => at + c.len_utf8(),
|
||||
None => text.len() + 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PLAIN: FindOptions = FindOptions { case_sensitive: true, whole_word: false };
|
||||
const FOLDED: FindOptions = FindOptions { case_sensitive: false, whole_word: false };
|
||||
const WORD: FindOptions = FindOptions { case_sensitive: true, whole_word: true };
|
||||
|
||||
/// What `find_all` matched, as text.
|
||||
fn hits<'a>(text: &'a str, needle: &str, options: FindOptions) -> Vec<&'a str> {
|
||||
find_all(text, needle, options).iter().filter_map(|range| text.get(range.clone())).collect()
|
||||
}
|
||||
|
||||
/// `(text, needle, options, expected matches)`.
|
||||
type Row = (&'static str, &'static str, FindOptions, Vec<&'static str>);
|
||||
|
||||
#[test]
|
||||
fn find_all_rows() {
|
||||
let rows: Vec<Row> = vec![
|
||||
("hello world", "world", PLAIN, vec!["world"]),
|
||||
("aaa", "aa", PLAIN, vec!["aa"]),
|
||||
("aaaa", "aa", PLAIN, vec!["aa", "aa"]),
|
||||
("abcabcabc", "abc", PLAIN, vec!["abc", "abc", "abc"]),
|
||||
("abc", "z", PLAIN, vec![]),
|
||||
("abc", "", PLAIN, vec![]),
|
||||
("", "a", PLAIN, vec![]),
|
||||
("", "", PLAIN, vec![]),
|
||||
("a b", " ", PLAIN, vec![" "]),
|
||||
("line\nline", "line", PLAIN, vec!["line", "line"]),
|
||||
("x\ty", "\t", PLAIN, vec!["\t"]),
|
||||
("aaa", "aaaa", PLAIN, vec![]),
|
||||
("foo bar foo", "foo", PLAIN, vec!["foo", "foo"]),
|
||||
("FOO foo", "foo", PLAIN, vec!["foo"]),
|
||||
("FOO foo", "foo", FOLDED, vec!["FOO", "foo"]),
|
||||
("Foo Bar", "fOO", FOLDED, vec!["Foo"]),
|
||||
("Straße", "asse", FOLDED, vec![]),
|
||||
("straße", "STRASSE", FOLDED, vec![]), // `ß` folds to itself, not to `ss`
|
||||
("ẞ", "ß", FOLDED, vec!["ẞ"]),
|
||||
("Istanbul", "istanbul", FOLDED, vec!["Istanbul"]),
|
||||
("İstanbul", "istanbul", FOLDED, vec![]), // `İ` folds to `i` plus a dot, so it is not `i`
|
||||
("ÄÖÜ", "äöü", FOLDED, vec!["ÄÖÜ"]),
|
||||
("é", "e", FOLDED, vec![]),
|
||||
("cat dog cat", "cat", WORD, vec!["cat", "cat"]),
|
||||
("cat.", "cat", WORD, vec!["cat"]),
|
||||
("(cat)", "cat", WORD, vec!["cat"]),
|
||||
("concat", "cat", WORD, vec![]),
|
||||
("cat_x", "cat", WORD, vec![]),
|
||||
("cat9", "cat", WORD, vec![]),
|
||||
("9cat", "cat", WORD, vec![]),
|
||||
("_cat_", "cat", WORD, vec![]),
|
||||
("cat, dog, cat!", "cat", WORD, vec!["cat", "cat"]),
|
||||
("concat", "cat", PLAIN, vec!["cat"]),
|
||||
("cat", "cat", WORD, vec!["cat"]),
|
||||
("CAT cat", "cat", FOLDED, vec!["CAT", "cat"]),
|
||||
("a cat CAT", "cat", WORD, vec!["cat"]),
|
||||
("a cat CAT", "cat", FindOptions { case_sensitive: false, whole_word: true }, vec!["cat", "CAT"]),
|
||||
("ß", "s", FOLDED, vec![]),
|
||||
("ß", "ss", FOLDED, vec![]),
|
||||
("ß", "ß", FOLDED, vec!["ß"]),
|
||||
("İ", "i", FOLDED, vec!["İ"]),
|
||||
("ss", "ß", FOLDED, vec![]),
|
||||
("naïve", "Ïve", FOLDED, vec!["ïve"]),
|
||||
("naïve", "ïve", FOLDED, vec!["ïve"]),
|
||||
(" spaced ", "spaced", WORD, vec!["spaced"]),
|
||||
("--flag --flag", "-", WORD, vec!["-", "-"]),
|
||||
("a\tb", "a", WORD, vec!["a"]),
|
||||
("[a]", "a", WORD, vec!["a"]),
|
||||
("🚀 rocket 🚀", "🚀", PLAIN, vec!["🚀", "🚀"]),
|
||||
("🚀 rocket 🚀", "rocket", WORD, vec!["rocket"]),
|
||||
];
|
||||
for (text, needle, options, expected) in rows {
|
||||
assert_eq!(hits(text, needle, options), expected, "find {needle:?} in {text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ranges_are_byte_ranges_on_char_boundaries() {
|
||||
let text = "İİ istanbul";
|
||||
let range = find_all(text, "istanbul", FOLDED).first().cloned().expect("one match");
|
||||
assert_eq!(range, 5..13);
|
||||
assert_eq!(text.get(range.clone()), Some("istanbul"));
|
||||
let sharp = find_all("İ", "i", FOLDED).first().cloned().expect("one match");
|
||||
assert_eq!(sharp, 0..2, "`İ` folds to two chars but keeps its own byte range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_never_overlap_and_stop_at_the_cap() {
|
||||
let text = "a".repeat(20_000);
|
||||
let found = find_all(&text, "a", PLAIN);
|
||||
assert_eq!(found.len(), MAX_MATCHES);
|
||||
assert!(found.windows(2).all(|pair| pair[0].end <= pair[1].start), "no overlaps");
|
||||
let stepped = "aaaaa".repeat(4);
|
||||
let ranges = find_all(&stepped, "aa", PLAIN);
|
||||
assert_eq!(ranges.len(), 10);
|
||||
assert_eq!(ranges.last().cloned(), Some(18..20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mib_of_text_stays_bounded() {
|
||||
let text = "needle in a haystack, ".repeat(50_000);
|
||||
assert!(text.len() > 1024 * 1024, "{} bytes", text.len());
|
||||
let began = std::time::Instant::now();
|
||||
let found = find_all(&text, "needle", PLAIN);
|
||||
assert_eq!(found.len(), MAX_MATCHES, "a megabyte of matches is capped");
|
||||
assert!(began.elapsed().as_secs_f64() < 2.0, "took {}s", began.elapsed().as_secs_f64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_ranges_splices_from_the_end() {
|
||||
let text = "one two three";
|
||||
let ranges = find_all(text, "two", PLAIN);
|
||||
assert_eq!(replace_ranges(text, &ranges, "2"), "one 2 three");
|
||||
assert_eq!(replace_ranges(text, &[], "x"), text);
|
||||
assert_eq!(replace_ranges(text, &find_all(text, "o", PLAIN), "0"), "0ne tw0 three");
|
||||
}
|
||||
|
||||
/// `replace_ranges` with a single `start..end` range.
|
||||
fn one(text: &str, start: usize, end: usize, with: &str) -> String {
|
||||
let range = start..end;
|
||||
replace_ranges(text, std::slice::from_ref(&range), with)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_ranges_skips_invalid_and_overlapping_ranges() {
|
||||
let text = "café au lait";
|
||||
let all_a = find_all(text, "a", PLAIN);
|
||||
assert_eq!(replace_ranges(text, &all_a, "A"), "cAfé Au lAit");
|
||||
assert_eq!(replace_ranges(text, &[3..5, 5..6], "-"), "caf--au lait");
|
||||
assert_eq!(one(text, 0, text.len() + 5, "x"), text, "out of bounds");
|
||||
assert_eq!(one(text, 3, 1, "x"), text, "reversed");
|
||||
assert_eq!(one(text, 4, 5, "x"), text, "off a char boundary");
|
||||
let sorted = "one two three";
|
||||
assert_eq!(replace_ranges(sorted, &[8..13, 0..3], "!"), "! two !", "ranges are sorted first");
|
||||
assert_eq!(replace_ranges(text, &[], "-"), text, "no range changes nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod regex_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn regex_matches_expand_groups_and_bad_patterns_report() {
|
||||
let text = "a-1 b-22";
|
||||
let found = find_regex(text, r"(\w)-(\d+)", true).unwrap();
|
||||
assert_eq!(found.ranges, [0..3, 4..8]);
|
||||
assert_eq!(found.replace_all(text, "$2$1"), "1a 22b");
|
||||
assert_eq!(found.replacement(1, text, "<$0>"), "<b-22>");
|
||||
assert!(find_regex(text, "(", true).is_err());
|
||||
assert_eq!(find_regex("ABC", "b", false).unwrap().ranges.first(), Some(&(1..2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Component inserts for the slash menu.
|
||||
|
||||
use super::{Edited, splice};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// A component the slash menu can insert.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Component {
|
||||
/// 2 column table with a header row and two body rows.
|
||||
Table,
|
||||
/// `> [!note]` callout.
|
||||
Callout,
|
||||
/// Fenced `calendar` block.
|
||||
Calendar,
|
||||
/// Fenced `mermaid` diagram.
|
||||
Diagram,
|
||||
/// Embed of a new `.draw` whiteboard.
|
||||
Whiteboard,
|
||||
/// Embed of the template note with this name.
|
||||
Template(String),
|
||||
}
|
||||
|
||||
/// Insert `component` at `offset`; the caret lands in the first editable place.
|
||||
///
|
||||
/// A newline is added before when `offset` is mid-line and after when text
|
||||
/// follows on the same line.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Offset`] if `offset` is past the end, [`Error::CharBoundary`] if it splits a character.
|
||||
pub fn insert_component(text: &str, offset: usize, component: &Component) -> Result<Edited> {
|
||||
if offset > text.len() {
|
||||
return Err(Error::Offset { offset, len: text.len() });
|
||||
}
|
||||
let (Some(head), Some(tail)) = (text.get(..offset), text.get(offset..)) else {
|
||||
return Err(Error::CharBoundary { start: offset, end: offset });
|
||||
};
|
||||
let (snippet, caret_in) = match component {
|
||||
Component::Table => ("| | |\n| --- | --- |\n| | |\n| | |".to_owned(), 2),
|
||||
Component::Callout => ("> [!note]\n> ".to_owned(), "> [!note]\n> ".len()),
|
||||
Component::Calendar => ("```calendar\n\n```".to_owned(), "```calendar\n".len()),
|
||||
Component::Diagram => {
|
||||
let s = "```mermaid\nflowchart TD\n A --> B\n```";
|
||||
(s.to_owned(), s.len() - "\n```".len())
|
||||
}
|
||||
Component::Whiteboard => ("![[Untitled.draw]]".to_owned(), "![[Untitled".len()),
|
||||
Component::Template(name) => (format!("![[{name}]]"), name.len() + 3),
|
||||
};
|
||||
let pre = if head.is_empty() || head.ends_with('\n') { "" } else { "\n" };
|
||||
let post = if tail.is_empty() || tail.starts_with('\n') { "" } else { "\n" };
|
||||
let caret = offset + pre.len() + caret_in;
|
||||
splice(text, offset..offset, format!("{pre}{snippet}{post}"), caret)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! JSON in code blocks: detect, validate, format and minify.
|
||||
//!
|
||||
//! Formatting re-indents the validated text instead of re-serialising it, so key order, number
|
||||
//! spelling (`1e5`, `1.0`) and string escapes stay byte-for-byte what the author wrote.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use serde::de::IgnoredAny;
|
||||
|
||||
use crate::{BlockKind, Edited, Error, Result, block, splice};
|
||||
|
||||
/// Largest JSON text that is checked or rewritten, in bytes.
|
||||
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
|
||||
/// Spaces per indent level.
|
||||
const INDENT: usize = 2;
|
||||
|
||||
/// Checks that `text` is one JSON value (the parser's nesting limit applies).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Json`] with the position, or for text over [`MAX_JSON_BYTES`].
|
||||
pub fn check_json(text: &str) -> Result<()> {
|
||||
if text.len() > MAX_JSON_BYTES {
|
||||
return Err(Error::Json { message: format!("larger than {MAX_JSON_BYTES} bytes"), line: 1, column: 1 });
|
||||
}
|
||||
serde_json::from_str::<IgnoredAny>(text).map(|_| ()).map_err(|error| Error::Json { message: error.to_string(), line: error.line(), column: error.column() })
|
||||
}
|
||||
|
||||
/// The byte range of the JSON inside a code-block body: the whole trimmed body, or the inside of
|
||||
/// a single backtick-quoted template string. `None` when neither is a JSON object or array.
|
||||
#[must_use]
|
||||
pub fn json_span(body: &str) -> Option<Range<usize>> {
|
||||
let lead = body.len() - body.trim_start().len();
|
||||
let core = body.trim();
|
||||
let (start, inner) = match core.strip_prefix('`').and_then(|rest| rest.strip_suffix('`')) {
|
||||
Some(inner) if !inner.contains('`') => (lead + 1, inner),
|
||||
_ => (lead, core),
|
||||
};
|
||||
let first = inner.chars().next()?;
|
||||
let last = inner.chars().next_back()?;
|
||||
let shaped = matches!((first, last), ('{', '}') | ('[', ']'));
|
||||
(shaped && check_json(inner).is_ok()).then(|| start..start + inner.len())
|
||||
}
|
||||
|
||||
/// `true` when the body holds JSON that [`format_json_block`] would act on.
|
||||
#[must_use]
|
||||
pub fn looks_like_json(body: &str) -> bool {
|
||||
json_span(body).is_some()
|
||||
}
|
||||
|
||||
/// Pretty-prints valid JSON with two-space indents; empty containers stay `{}` and `[]`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Json`] when `text` is not valid JSON.
|
||||
pub fn format_json(text: &str) -> Result<String> {
|
||||
check_json(text)?;
|
||||
let mut out = String::with_capacity(text.len() + text.len() / 4);
|
||||
let mut depth = 0usize;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if in_string {
|
||||
out.push(c);
|
||||
match (escaped, c) {
|
||||
(true, _) => escaped = false,
|
||||
(false, '\\') => escaped = true,
|
||||
(false, '"') => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'"' => {
|
||||
in_string = true;
|
||||
out.push(c);
|
||||
}
|
||||
'{' | '[' => {
|
||||
out.push(c);
|
||||
while chars.next_if(|next| next.is_whitespace()).is_some() {}
|
||||
if chars.next_if(|next| matches!((c, next), ('{', '}') | ('[', ']'))).is_some() {
|
||||
out.push(if c == '{' { '}' } else { ']' });
|
||||
} else {
|
||||
depth += 1;
|
||||
newline(&mut out, depth);
|
||||
}
|
||||
}
|
||||
'}' | ']' => {
|
||||
depth = depth.saturating_sub(1);
|
||||
newline(&mut out, depth);
|
||||
out.push(c);
|
||||
}
|
||||
',' => {
|
||||
out.push(c);
|
||||
newline(&mut out, depth);
|
||||
}
|
||||
':' => out.push_str(": "),
|
||||
c if c.is_whitespace() => {}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn newline(out: &mut String, depth: usize) {
|
||||
out.push('\n');
|
||||
out.extend(std::iter::repeat_n(' ', depth.saturating_mul(INDENT)));
|
||||
}
|
||||
|
||||
/// Removes every insignificant space from valid JSON.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Json`] when `text` is not valid JSON.
|
||||
pub fn minify_json(text: &str) -> Result<String> {
|
||||
check_json(text)?;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
for c in text.chars() {
|
||||
if in_string {
|
||||
match (escaped, c) {
|
||||
(true, _) => escaped = false,
|
||||
(false, '\\') => escaped = true,
|
||||
(false, '"') => in_string = false,
|
||||
_ => {}
|
||||
}
|
||||
out.push(c);
|
||||
} else if c == '"' {
|
||||
in_string = true;
|
||||
out.push(c);
|
||||
} else if !c.is_whitespace() {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `replacement` with its line endings made to match `note`: CRLF when the note uses CRLF, else as given.
|
||||
#[must_use]
|
||||
pub fn match_line_endings(note: &str, replacement: &str) -> String {
|
||||
if note.contains("\r\n") { replacement.replace("\r\n", "\n").replace('\n', "\r\n") } else { replacement.to_owned() }
|
||||
}
|
||||
|
||||
/// Formats the JSON in code block `index` as one edit.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::BlockIndex`], [`Error::Unsupported`] when the block is not a fence, or [`Error::Json`]
|
||||
/// when the body holds no valid JSON.
|
||||
pub fn format_json_block(text: &str, index: usize) -> Result<Edited> {
|
||||
rewrite(text, index, format_json)
|
||||
}
|
||||
|
||||
/// Minifies the JSON in code block `index` as one edit; errors as [`format_json_block`].
|
||||
///
|
||||
/// # Errors
|
||||
/// See [`format_json_block`].
|
||||
pub fn minify_json_block(text: &str, index: usize) -> Result<Edited> {
|
||||
rewrite(text, index, minify_json)
|
||||
}
|
||||
|
||||
fn rewrite(text: &str, index: usize, run: fn(&str) -> Result<String>) -> Result<Edited> {
|
||||
let found = block(text, index)?;
|
||||
if found.kind != BlockKind::CodeFence {
|
||||
return Err(Error::Unsupported("only code blocks hold JSON"));
|
||||
}
|
||||
let body = text.get(found.text_range.clone()).ok_or(Error::CharBoundary { start: found.text_range.start, end: found.text_range.end })?;
|
||||
let Some(span) = json_span(body) else {
|
||||
check_json(body.trim())?;
|
||||
return Err(Error::Unsupported("the code block holds no JSON object or array"));
|
||||
};
|
||||
let inner = body.get(span.clone()).ok_or(Error::CharBoundary { start: span.start, end: span.end })?;
|
||||
let new = match_line_endings(text, &run(inner)?);
|
||||
let start = found.text_range.start + span.start;
|
||||
splice(text, start..found.text_range.start + span.end, new, start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn check_accepts_valid_and_reports_position_for_invalid() {
|
||||
assert!(check_json("{\"a\": [1, 2, {\"b\": null}]}").is_ok());
|
||||
assert!(matches!(check_json("{\"a\": }"), Err(Error::Json { line: 1, .. })));
|
||||
assert!(matches!(check_json("{\"a\":\n 1,}"), Err(Error::Json { line: 2, .. })));
|
||||
assert!(check_json("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_keeps_key_order_numbers_and_escapes_byte_for_byte() {
|
||||
let src = r#"{"z":1e5,"a":[1.0,-0,"q\"uote \\ \u00e9"],"m":{"n":true,"e":{},"l":[ ]}}"#;
|
||||
let pretty = format_json(src).expect("valid");
|
||||
let expected = "{\n \"z\": 1e5,\n \"a\": [\n 1.0,\n -0,\n \"q\\\"uote \\\\ \\u00e9\"\n ],\n \"m\": {\n \"n\": true,\n \"e\": {},\n \"l\": []\n }\n}";
|
||||
assert_eq!(pretty, expected);
|
||||
assert_eq!(minify_json(&pretty).expect("valid"), src.replace("[ ]", "[]"));
|
||||
assert_eq!(format_json(&pretty).expect("valid"), pretty, "formatting is idempotent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_with_structure_characters_are_untouched() {
|
||||
let src = r#"{"k": "a, b: {c} [d] e"}"#;
|
||||
assert_eq!(minify_json(src).expect("valid"), r#"{"k":"a, b: {c} [d] e"}"#);
|
||||
assert!(format_json(src).expect("valid").contains("\"a, b: {c} [d] e\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_depth_and_size_are_errors_not_panics() {
|
||||
assert!(format_json("{\"a\":").is_err());
|
||||
assert!(format_json(&"[".repeat(5000)).is_err(), "nesting limit");
|
||||
let big = format!("[\"{}\"]", "x".repeat(MAX_JSON_BYTES));
|
||||
assert!(format_json(&big).is_err() && minify_json(&big).is_err());
|
||||
for hostile in ["\u{0}", "{\"a\":\"\u{fffd}\u{0}\"", "\u{1f600}", "[1,,]", "{,}"] {
|
||||
assert!(format_json(hostile).is_err(), "{hostile:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spans_cover_plain_bodies_and_single_template_strings() {
|
||||
assert_eq!(json_span("\n {\"a\":1}\n"), Some(3..10));
|
||||
assert_eq!(json_span("`{\"a\":1}`\n"), Some(1..8));
|
||||
assert!(looks_like_json("[1, 2]\n"));
|
||||
assert!(!looks_like_json("let x = {};\n"));
|
||||
assert!(!looks_like_json("`{\"a\":${x}}`"));
|
||||
assert!(!looks_like_json("`{\"a\":1}` + `{}`"));
|
||||
assert!(!looks_like_json("42"), "only objects and arrays are offered");
|
||||
assert!(!looks_like_json("{not json}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_edits_replace_only_the_json_and_keep_the_fence() {
|
||||
let note = "# T\n\n```json\n{\"a\":1,\"b\":[2]}\n```\n\nafter\n";
|
||||
let edited = format_json_block(note, 1).expect("formats");
|
||||
assert_eq!(edited.text, "# T\n\n```json\n{\n \"a\": 1,\n \"b\": [\n 2\n ]\n}\n```\n\nafter\n");
|
||||
let back = minify_json_block(&edited.text, 1).expect("minifies");
|
||||
assert_eq!(back.text, note);
|
||||
assert_eq!(¬e[edited.replace.0.clone()], "{\"a\":1,\"b\":[2]}", "the replacement range is the old JSON");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_notes_get_crlf_inside_the_formatted_json_too() {
|
||||
let note = "```json\r\n{\"a\":1}\r\n```\r\n";
|
||||
assert_eq!(format_json_block(note, 0).expect("formats").text, "```json\r\n{\r\n \"a\": 1\r\n}\r\n```\r\n");
|
||||
assert_eq!(match_line_endings("lf\n", "a\nb"), "a\nb");
|
||||
assert_eq!(match_line_endings("crlf\r\n", "a\nb\r\nc"), "a\r\nb\r\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn template_strings_keep_their_backticks() {
|
||||
let note = "```js\n`{\"a\":1}`\n```\n";
|
||||
assert_eq!(format_json_block(note, 0).expect("formats").text, "```js\n`{\n \"a\": 1\n}`\n```\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_input_leaves_the_caller_with_an_error() {
|
||||
assert!(matches!(format_json_block("```json\n{\"a\":\n```\n", 0), Err(Error::Json { .. })));
|
||||
assert!(matches!(format_json_block("hello\n", 0), Err(Error::Unsupported(_))));
|
||||
assert!(matches!(format_json_block("```\nplain\n```\n", 0), Err(Error::Json { .. })));
|
||||
assert!(matches!(format_json_block("```\n42\n```\n", 0), Err(Error::Unsupported(_))));
|
||||
assert!(matches!(format_json_block("x\n", 9), Err(Error::BlockIndex(9))));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Block model: the top-level blocks of a markdown note and pure edits on them.
|
||||
//!
|
||||
//! Blocks are found by parsing, never stored. Every operation returns an
|
||||
//! [`Edited`] holding the new text, the new caret and the single replacement
|
||||
//! (`replace`) that produces it, so a UI can apply it as ONE undo step.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod convert;
|
||||
mod detect;
|
||||
mod find;
|
||||
mod insert;
|
||||
mod json;
|
||||
mod ops;
|
||||
mod parse;
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
pub use convert::{Target, turn_into};
|
||||
pub use detect::detect_language;
|
||||
pub use find::{FindOptions, RegexFind, find_all, find_regex, replace_ranges};
|
||||
pub use insert::{Component, insert_component};
|
||||
pub use json::{MAX_JSON_BYTES, check_json, format_json, format_json_block, json_span, looks_like_json, match_line_endings, minify_json, minify_json_block};
|
||||
pub use ops::{Direction, delete_block, duplicate_block, move_block};
|
||||
pub use parse::parse_blocks;
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// Every failure a block operation can report.
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
/// A byte range was not on UTF-8 char boundaries.
|
||||
#[error("byte range {start}..{end} is not on char boundaries")]
|
||||
CharBoundary {
|
||||
/// Range start in bytes.
|
||||
start: usize,
|
||||
/// Range end in bytes.
|
||||
end: usize,
|
||||
},
|
||||
/// A byte offset was past the end of the text.
|
||||
#[error("offset {offset} is past the end ({len} bytes)")]
|
||||
Offset {
|
||||
/// Offending offset.
|
||||
offset: usize,
|
||||
/// Text length in bytes.
|
||||
len: usize,
|
||||
},
|
||||
/// No block has this index.
|
||||
#[error("no block at index {0}")]
|
||||
BlockIndex(usize),
|
||||
/// The block has no neighbour in that direction.
|
||||
#[error("no neighbouring block to swap with")]
|
||||
NoNeighbour,
|
||||
/// The operation does not apply to this block.
|
||||
#[error("{0}")]
|
||||
Unsupported(&'static str),
|
||||
/// The text is not valid JSON.
|
||||
#[error("invalid JSON at line {line}, column {column}: {message}")]
|
||||
Json {
|
||||
/// What the parser said.
|
||||
message: String,
|
||||
/// 1-based line of the problem.
|
||||
line: usize,
|
||||
/// 1-based column of the problem.
|
||||
column: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result alias used across the crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Kind of a top-level block.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
|
||||
pub enum BlockKind {
|
||||
/// Plain paragraph (one or more lines).
|
||||
Paragraph,
|
||||
/// ATX heading of level 1 to 6.
|
||||
Heading(u8),
|
||||
/// Block quote that is not a callout.
|
||||
Quote,
|
||||
/// Quote whose first line is `[!kind]`.
|
||||
Callout,
|
||||
/// List item with a checkbox.
|
||||
TaskItem {
|
||||
/// `[x]` instead of `[ ]`.
|
||||
checked: bool,
|
||||
},
|
||||
/// Bullet or numbered list item (with its nested children).
|
||||
ListItem {
|
||||
/// Numbered (`1.`) instead of bullet.
|
||||
ordered: bool,
|
||||
},
|
||||
/// Pipe table.
|
||||
Table,
|
||||
/// Fenced code block (also an unclosed one, up to the end of the note).
|
||||
CodeFence,
|
||||
/// Thematic break (`---`, `***`, `___`).
|
||||
Divider,
|
||||
/// YAML frontmatter at byte 0.
|
||||
Frontmatter,
|
||||
/// A line that is a single `![[embed]]`.
|
||||
Embed,
|
||||
}
|
||||
|
||||
/// One top-level block.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Block {
|
||||
/// What the block is.
|
||||
pub kind: BlockKind,
|
||||
/// Whole block in bytes, children included, trailing blank line excluded.
|
||||
pub range: Range<usize>,
|
||||
/// Editable text inside the block (after markers, fences or brackets).
|
||||
pub text_range: Range<usize>,
|
||||
}
|
||||
|
||||
/// One edit: new text, new caret and the single replacement that makes it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Edited {
|
||||
/// The whole text after the edit.
|
||||
pub text: String,
|
||||
/// Caret byte offset in [`Edited::text`].
|
||||
pub caret: usize,
|
||||
/// Byte range of the OLD text replaced, and its replacement.
|
||||
pub replace: (Range<usize>, String),
|
||||
}
|
||||
|
||||
/// Result of [`move_block`].
|
||||
pub type Moved = Edited;
|
||||
|
||||
/// Index of the block that contains `offset` (end included), if any.
|
||||
#[must_use]
|
||||
pub fn block_at(text: &str, offset: usize) -> Option<usize> {
|
||||
parse_blocks(text).iter().position(|b| b.range.start <= offset && offset <= b.range.end)
|
||||
}
|
||||
|
||||
/// Replaces `range` of `text` with `new`, checking bounds and char boundaries; `caret` is where the caret lands.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Offset`] or [`Error::CharBoundary`].
|
||||
pub fn splice(text: &str, range: Range<usize>, new: String, caret: usize) -> Result<Edited> {
|
||||
let (Some(head), Some(tail)) = (text.get(..range.start), text.get(range.end..)) else {
|
||||
return Err(Error::CharBoundary { start: range.start, end: range.end });
|
||||
};
|
||||
let mut out = String::with_capacity(head.len() + new.len() + tail.len());
|
||||
out.push_str(head);
|
||||
out.push_str(&new);
|
||||
out.push_str(tail);
|
||||
Ok(Edited { text: out, caret, replace: (range, new) })
|
||||
}
|
||||
|
||||
pub(crate) fn block(text: &str, index: usize) -> Result<Block> {
|
||||
parse_blocks(text).into_iter().nth(index).ok_or(Error::BlockIndex(index))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Move, duplicate and delete: each is one replacement.
|
||||
|
||||
use super::{BlockKind, Edited, block, parse_blocks, splice};
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Direction of [`move_block`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Direction {
|
||||
/// Towards the start of the note.
|
||||
Up,
|
||||
/// Towards the end of the note.
|
||||
Down,
|
||||
}
|
||||
|
||||
/// Swap block `index` with its neighbour, keeping the text between them as is
|
||||
/// (so list items stay tight and paragraphs keep their one blank line).
|
||||
///
|
||||
/// The caret lands at the start of the moved block.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::BlockIndex`] if `index` is out of range, [`Error::NoNeighbour`] at
|
||||
/// either end, [`Error::Unsupported`] when frontmatter is involved.
|
||||
pub fn move_block(text: &str, index: usize, dir: Direction) -> Result<Edited> {
|
||||
let blocks = parse_blocks(text);
|
||||
let (a, b) = match dir {
|
||||
Direction::Up => (index.checked_sub(1).ok_or(Error::NoNeighbour)?, index),
|
||||
Direction::Down => (index, index.checked_add(1).ok_or(Error::NoNeighbour)?),
|
||||
};
|
||||
if index >= blocks.len() {
|
||||
return Err(Error::BlockIndex(index));
|
||||
}
|
||||
let (Some(first), Some(second)) = (blocks.get(a), blocks.get(b)) else { return Err(Error::NoNeighbour) };
|
||||
if [first.kind, second.kind].contains(&BlockKind::Frontmatter) {
|
||||
return Err(Error::Unsupported("frontmatter cannot move"));
|
||||
}
|
||||
let part = |r: std::ops::Range<usize>| text.get(r).ok_or(Error::BlockIndex(index));
|
||||
let (ta, sep, tb) = (part(first.range.clone())?, part(first.range.end..second.range.start)?, part(second.range.clone())?);
|
||||
let new = format!("{tb}{sep}{ta}");
|
||||
let caret = match dir {
|
||||
Direction::Up => first.range.start,
|
||||
Direction::Down => first.range.start + tb.len() + sep.len(),
|
||||
};
|
||||
splice(text, first.range.start..second.range.end, new, caret)
|
||||
}
|
||||
|
||||
/// Copy block `index` right below itself; the caret lands at the copy.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::BlockIndex`] if `index` is out of range, [`Error::Unsupported`] for frontmatter.
|
||||
pub fn duplicate_block(text: &str, index: usize) -> Result<Edited> {
|
||||
let b = block(text, index)?;
|
||||
if b.kind == BlockKind::Frontmatter {
|
||||
return Err(Error::Unsupported("frontmatter cannot be duplicated"));
|
||||
}
|
||||
let body = text.get(b.range.clone()).ok_or(Error::BlockIndex(index))?;
|
||||
let sep = if matches!(b.kind, BlockKind::ListItem { .. } | BlockKind::TaskItem { .. }) { "\n" } else { "\n\n" };
|
||||
splice(text, b.range.end..b.range.end, format!("{sep}{body}"), b.range.end + sep.len())
|
||||
}
|
||||
|
||||
/// Remove block `index` and one separator.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::BlockIndex`] if `index` is out of range.
|
||||
pub fn delete_block(text: &str, index: usize) -> Result<Edited> {
|
||||
let blocks = parse_blocks(text);
|
||||
let b = blocks.get(index).ok_or(Error::BlockIndex(index))?;
|
||||
let range = match (blocks.get(index + 1), index.checked_sub(1).and_then(|p| blocks.get(p))) {
|
||||
(Some(next), _) => b.range.start..next.range.start,
|
||||
(None, Some(prev)) => prev.range.end..b.range.end,
|
||||
(None, None) => b.range.clone(),
|
||||
};
|
||||
let caret = range.start.min(b.range.start);
|
||||
splice(text, range, String::new(), caret)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Line-based block scanner. Linear time; no recursion.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use notescraft_markdown::yaml::block_range;
|
||||
|
||||
use super::{Block, BlockKind};
|
||||
|
||||
const MAX_HEADING: usize = 6;
|
||||
const MIN_FENCE: usize = 3;
|
||||
const TAB_WIDTH: usize = 4;
|
||||
const CODE_INDENT: usize = 4;
|
||||
|
||||
struct Lines<'a> {
|
||||
text: &'a str,
|
||||
spans: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl Lines<'_> {
|
||||
fn s(&self, i: usize) -> &str {
|
||||
self.spans.get(i).and_then(|r| self.text.get(r.clone())).unwrap_or("")
|
||||
}
|
||||
fn start(&self, i: usize) -> usize {
|
||||
self.spans.get(i).map_or(self.text.len(), |r| r.start)
|
||||
}
|
||||
fn end(&self, i: usize) -> usize {
|
||||
self.spans.get(i).map_or(self.text.len(), |r| r.end)
|
||||
}
|
||||
fn blank(&self, i: usize) -> bool {
|
||||
self.s(i).trim().is_empty()
|
||||
}
|
||||
fn next_nonblank(&self, mut i: usize) -> usize {
|
||||
while i < self.spans.len() && self.blank(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
}
|
||||
|
||||
fn split(text: &str) -> Lines<'_> {
|
||||
let mut spans = Vec::new();
|
||||
let mut pos = 0;
|
||||
for raw in text.split_inclusive('\n') {
|
||||
let end = pos + raw.len();
|
||||
let body = raw.strip_suffix('\n').unwrap_or(raw);
|
||||
let body = body.strip_suffix('\r').unwrap_or(body);
|
||||
spans.push(pos..pos + body.len());
|
||||
pos = end;
|
||||
}
|
||||
Lines { text, spans }
|
||||
}
|
||||
|
||||
fn indent(s: &str) -> usize {
|
||||
s.chars().take_while(|c| [' ', '\t'].contains(c)).map(|c| if c == '\t' { TAB_WIDTH } else { 1 }).sum()
|
||||
}
|
||||
|
||||
fn lead_bytes(s: &str) -> usize {
|
||||
s.len() - s.trim_start().len()
|
||||
}
|
||||
|
||||
fn fence_open(t: &str) -> Option<(char, usize)> {
|
||||
let c = t.chars().next().filter(|c| ['`', '~'].contains(c))?;
|
||||
let n = t.chars().take_while(|x| *x == c).count();
|
||||
(n >= MIN_FENCE).then_some((c, n))
|
||||
}
|
||||
|
||||
fn fence_close(t: &str, c: char, n: usize) -> bool {
|
||||
let t = t.trim();
|
||||
t.chars().count() >= n && t.chars().all(|x| x == c)
|
||||
}
|
||||
|
||||
fn heading_level(t: &str) -> Option<usize> {
|
||||
let n = t.chars().take_while(|c| *c == '#').count();
|
||||
let rest = t.get(n..)?;
|
||||
((1..=MAX_HEADING).contains(&n) && (rest.is_empty() || rest.starts_with(' '))).then_some(n)
|
||||
}
|
||||
|
||||
fn is_divider(t: &str) -> bool {
|
||||
let t = t.trim();
|
||||
let Some(c) = t.chars().next().filter(|c| ['-', '*', '_'].contains(c)) else { return false };
|
||||
t.chars().all(|x| x == c || x == ' ') && t.chars().filter(|x| *x == c).count() >= MIN_FENCE
|
||||
}
|
||||
|
||||
/// `(ordered, marker bytes incl. the following space)`.
|
||||
fn list_marker(t: &str) -> Option<(bool, usize)> {
|
||||
let c = t.chars().next()?;
|
||||
if ['-', '*', '+'].contains(&c) {
|
||||
return t.get(1..).filter(|r| r.starts_with(' ')).map(|_| (false, 2));
|
||||
}
|
||||
let digits = t.chars().take_while(char::is_ascii_digit).count();
|
||||
if digits == 0 || digits > 9 {
|
||||
return None;
|
||||
}
|
||||
let rest = t.get(digits..)?;
|
||||
(rest.starts_with(". ") || rest.starts_with(") ")).then_some((true, digits + 2))
|
||||
}
|
||||
|
||||
fn interrupts(l: &Lines, i: usize) -> bool {
|
||||
let s = l.s(i);
|
||||
let t = s.trim_start();
|
||||
if indent(s) >= CODE_INDENT {
|
||||
return false;
|
||||
}
|
||||
fence_open(t).is_some()
|
||||
|| heading_level(t).is_some()
|
||||
|| t.starts_with('>')
|
||||
|| is_divider(t)
|
||||
|| list_marker(t).is_some_and(|(ordered, _)| !ordered || t.starts_with("1."))
|
||||
}
|
||||
|
||||
fn task(rest: &str) -> Option<bool> {
|
||||
let checked = match rest.get(..3)? {
|
||||
"[ ]" => false,
|
||||
"[x]" | "[X]" => true,
|
||||
_ => return None,
|
||||
};
|
||||
rest.get(3..).is_some_and(|r| r.is_empty() || r.starts_with(' ')).then_some(checked)
|
||||
}
|
||||
|
||||
fn block_at_line(ls: &Lines, i: usize) -> (Block, usize) {
|
||||
let count = ls.spans.len();
|
||||
let line = ls.s(i);
|
||||
let tr = line.trim_start();
|
||||
let lead = lead_bytes(line);
|
||||
let start = ls.start(i);
|
||||
let mk = |kind, last: usize, text_start: usize, next: usize| {
|
||||
let range = start..ls.end(last);
|
||||
let ts = text_start.min(range.end);
|
||||
(Block { kind, text_range: ts..range.end, range }, next)
|
||||
};
|
||||
if indent(line) < CODE_INDENT {
|
||||
if let Some((fch, len)) = fence_open(tr) {
|
||||
let mut j = i + 1;
|
||||
while j < count && !fence_close(ls.s(j), fch, len) {
|
||||
j += 1;
|
||||
}
|
||||
let (last, next) = if j < count { (j, j + 1) } else { (count - 1, count) };
|
||||
let inner_end = if j < count { ls.start(j) } else { ls.end(last) };
|
||||
let inner_start = ls.start(i + 1).min(inner_end);
|
||||
let range = start..ls.end(last);
|
||||
return (Block { kind: BlockKind::CodeFence, text_range: inner_start..inner_end, range }, next);
|
||||
}
|
||||
if let Some(level) = heading_level(tr) {
|
||||
let sp = usize::from(tr.len() > level);
|
||||
return mk(BlockKind::Heading(u8::try_from(level).unwrap_or(1)), i, start + lead + level + sp, i + 1);
|
||||
}
|
||||
if is_divider(tr) {
|
||||
let (mut b, next) = mk(BlockKind::Divider, i, start, i + 1);
|
||||
b.text_range = start..start;
|
||||
return (b, next);
|
||||
}
|
||||
if tr.starts_with('>') {
|
||||
let mut j = i + 1;
|
||||
while j < count && ls.s(j).trim_start().starts_with('>') {
|
||||
j += 1;
|
||||
}
|
||||
let after = tr.get(1..).unwrap_or("");
|
||||
let callout = after.trim_start().starts_with("[!");
|
||||
let kind = if callout { BlockKind::Callout } else { BlockKind::Quote };
|
||||
return mk(kind, j - 1, start + lead + 1 + usize::from(after.starts_with(' ')), j);
|
||||
}
|
||||
if tr.starts_with('|') {
|
||||
let mut j = i + 1;
|
||||
while j < count && ls.s(j).trim_start().starts_with('|') {
|
||||
j += 1;
|
||||
}
|
||||
return mk(BlockKind::Table, j - 1, start, j);
|
||||
}
|
||||
}
|
||||
if let Some((ordered, mlen)) = list_marker(tr) {
|
||||
let base = indent(line);
|
||||
let mut last = i;
|
||||
let mut j = i + 1;
|
||||
while j < count {
|
||||
let k = if ls.blank(j) { ls.next_nonblank(j) } else { j };
|
||||
if k >= count || indent(ls.s(k)) <= base {
|
||||
break;
|
||||
}
|
||||
last = k;
|
||||
j = k + 1;
|
||||
}
|
||||
let rest = tr.get(mlen..).unwrap_or("");
|
||||
let (kind, skip) = match task(rest) {
|
||||
Some(checked) => (BlockKind::TaskItem { checked }, 3 + usize::from(rest.len() > 3)),
|
||||
None => (BlockKind::ListItem { ordered }, 0),
|
||||
};
|
||||
return mk(kind, last, start + lead + mlen + skip, last + 1);
|
||||
}
|
||||
let mut j = i + 1;
|
||||
while j < count && !ls.blank(j) && !interrupts(ls, j) && !ls.s(j).trim_start().starts_with('|') {
|
||||
j += 1;
|
||||
}
|
||||
let (mut b, next) = mk(BlockKind::Paragraph, j - 1, start, j);
|
||||
let single = j == i + 1;
|
||||
if single && tr.starts_with("![[") && tr.ends_with("]]") && tr.matches("![[").count() == 1 {
|
||||
b.kind = BlockKind::Embed;
|
||||
b.text_range = start + lead + 3..b.range.end.saturating_sub(2).max(start + lead + 3);
|
||||
}
|
||||
(b, next)
|
||||
}
|
||||
|
||||
/// Parse the top-level blocks of `text`, in order.
|
||||
///
|
||||
/// A list item with all its nested lines is one block; a fenced code block is
|
||||
/// one block whatever it contains (an unclosed fence runs to the end).
|
||||
#[must_use]
|
||||
pub fn parse_blocks(text: &str) -> Vec<Block> {
|
||||
let l = split(text);
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
if let Some(r) = block_range(text) {
|
||||
let last = l.spans.iter().rposition(|s| s.start < r.end).unwrap_or(0);
|
||||
let inner = l.start(1).min(l.start(last))..l.start(last);
|
||||
out.push(Block { kind: BlockKind::Frontmatter, text_range: inner, range: r });
|
||||
i = last + 1;
|
||||
}
|
||||
while i < l.spans.len() {
|
||||
if l.blank(i) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let (b, next) = block_at_line(&l, i);
|
||||
out.push(b);
|
||||
i = next.max(i + 1);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Block parsing, block operations and component inserts.
|
||||
|
||||
use notescraft_blocks::{
|
||||
BlockKind as K, Component, Direction, Target, block_at, delete_block, duplicate_block, insert_component, move_block, parse_blocks, turn_into,
|
||||
};
|
||||
|
||||
fn kinds(text: &str) -> Vec<K> {
|
||||
parse_blocks(text).into_iter().map(|b| b.kind).collect()
|
||||
}
|
||||
|
||||
fn slice(text: &str, i: usize) -> &str {
|
||||
&text[parse_blocks(text)[i].range.clone()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_kind_is_parsed() {
|
||||
let text = "---\ntitle: x\n---\n\n# Head\n\npara\nline two\n\n> quote\n\n> [!note] T\n> body\n\n- [ ] todo\n- [x] done\n- item\n1. one\n\n| a | b |\n| - | - |\n\n```rust\nfn x() {}\n```\n\n---\n\n![[note]]\n";
|
||||
assert_eq!(
|
||||
kinds(text),
|
||||
vec![
|
||||
K::Frontmatter,
|
||||
K::Heading(1),
|
||||
K::Paragraph,
|
||||
K::Quote,
|
||||
K::Callout,
|
||||
K::TaskItem { checked: false },
|
||||
K::TaskItem { checked: true },
|
||||
K::ListItem { ordered: false },
|
||||
K::ListItem { ordered: true },
|
||||
K::Table,
|
||||
K::CodeFence,
|
||||
K::Divider,
|
||||
K::Embed
|
||||
]
|
||||
);
|
||||
assert_eq!(slice(text, 2), "para\nline two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_with_slash_hash_and_blank_lines_is_one_block() {
|
||||
let text = "intro\n\n```\n# not heading\n/ not slash\n\n- not list\n```\n\nafter";
|
||||
assert_eq!(kinds(text), vec![K::Paragraph, K::CodeFence, K::Paragraph]);
|
||||
let b = &parse_blocks(text)[1];
|
||||
assert_eq!(&text[b.text_range.clone()], "# not heading\n/ not slash\n\n- not list\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_fence_runs_to_end() {
|
||||
let text = "```\nabc\n\ndef";
|
||||
assert_eq!(kinds(text), vec![K::CodeFence]);
|
||||
assert_eq!(parse_blocks(text)[0].range, 0..text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_item_keeps_children() {
|
||||
let text = "- a\n - b\n\n more\n- c";
|
||||
assert_eq!(parse_blocks(text).len(), 2);
|
||||
assert_eq!(slice(text, 0), "- a\n - b\n\n more");
|
||||
assert_eq!(slice(text, 1), "- c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_ranges_skip_markers() {
|
||||
let text = "## Hi\n\n- [x] done\n\n> [!tip] t";
|
||||
let bs = parse_blocks(text);
|
||||
assert_eq!(&text[bs[0].text_range.clone()], "Hi");
|
||||
assert_eq!(&text[bs[1].text_range.clone()], "done");
|
||||
assert_eq!(&text[bs[2].text_range.clone()], "[!tip] t");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_at_finds_block() {
|
||||
let text = "one\n\ntwo";
|
||||
assert_eq!(block_at(text, 0), Some(0));
|
||||
assert_eq!(block_at(text, 3), Some(0));
|
||||
assert_eq!(block_at(text, 4), None);
|
||||
assert_eq!(block_at(text, 5), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_round_trip_is_identity_and_single_replace() {
|
||||
let text = "# T\n\npara\n\n- a\n- b\n child\n\n```\ncode\n```\n\n> q";
|
||||
let n = parse_blocks(text).len();
|
||||
for i in 0..n - 1 {
|
||||
let down = move_block(text, i, Direction::Down).unwrap();
|
||||
let (range, new) = &down.replace;
|
||||
let mut applied = text.to_owned();
|
||||
applied.replace_range(range.clone(), new);
|
||||
assert_eq!(applied, down.text, "replace reproduces text");
|
||||
assert_ne!(down.text, text);
|
||||
let back = move_block(&down.text, i + 1, Direction::Up).unwrap();
|
||||
assert_eq!(back.text, text, "round trip at {i}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_keeps_tight_list_and_blank_separator() {
|
||||
let text = "a\n\nb\nc\n\nd";
|
||||
let up = move_block(text, 1, Direction::Up).unwrap();
|
||||
assert_eq!(up.text, "b\nc\n\na\n\nd");
|
||||
assert_eq!(up.caret, 0);
|
||||
let down = move_block(text, 0, Direction::Down).unwrap();
|
||||
assert_eq!(&down.text[down.caret..=down.caret], "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_edges_and_frontmatter_error() {
|
||||
assert!(move_block("a\n\nb", 0, Direction::Up).is_err());
|
||||
assert!(move_block("a\n\nb", 1, Direction::Down).is_err());
|
||||
assert!(move_block("a", 5, Direction::Down).is_err());
|
||||
assert!(move_block("---\na: 1\n---\n\nbody", 1, Direction::Up).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_and_delete() {
|
||||
let d = duplicate_block("a\n\nb", 0).unwrap();
|
||||
assert_eq!(d.text, "a\n\na\n\nb");
|
||||
assert_eq!(d.caret, 3);
|
||||
let d = duplicate_block("- a\n- b", 0).unwrap();
|
||||
assert_eq!(d.text, "- a\n- a\n- b");
|
||||
assert_eq!(delete_block("a\n\nb\n\nc", 1).unwrap().text, "a\n\nc");
|
||||
assert_eq!(delete_block("a\n\nb", 1).unwrap().text, "a");
|
||||
assert_eq!(delete_block("a", 0).unwrap().text, "");
|
||||
assert!(delete_block("a", 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_into_keeps_text() {
|
||||
let p = turn_into("hello world", 0, &Target::Heading(2)).unwrap();
|
||||
assert_eq!(p.text, "## hello world");
|
||||
let q = turn_into(&p.text, 0, &Target::Quote).unwrap();
|
||||
assert_eq!(q.text, "> hello world");
|
||||
let t = turn_into(&q.text, 0, &Target::Task).unwrap();
|
||||
assert_eq!(t.text, "- [ ] hello world");
|
||||
let c = turn_into(&t.text, 0, &Target::Callout("warning".into())).unwrap();
|
||||
assert_eq!(c.text, "> [!warning]\n> hello world");
|
||||
let back = turn_into(&c.text, 0, &Target::Paragraph).unwrap();
|
||||
assert_eq!(back.text, "hello world");
|
||||
assert_eq!(back.caret, back.text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_into_list_keeps_indent_and_children() {
|
||||
let t = turn_into(" - item\n child", 0, &Target::Task).unwrap();
|
||||
assert_eq!(t.text, " - [ ] item\n child");
|
||||
let p = turn_into("- [x] item\n child", 0, &Target::Paragraph).unwrap();
|
||||
assert_eq!(p.text, "item\n child");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_into_refuses_unsupported() {
|
||||
assert!(turn_into("```\nx\n```", 0, &Target::Paragraph).is_err());
|
||||
assert!(turn_into("a", 0, &Target::Heading(7)).is_err());
|
||||
assert!(turn_into("a", 3, &Target::Quote).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn components() {
|
||||
let t = insert_component("ab", 2, &Component::Table).unwrap();
|
||||
assert!(t.text.starts_with("ab\n| | |\n| --- | --- |"));
|
||||
assert_eq!(&t.text[..t.caret], "ab\n| ");
|
||||
assert_eq!(kinds(&t.text), vec![K::Paragraph, K::Table]);
|
||||
let c = insert_component("", 0, &Component::Callout).unwrap();
|
||||
assert_eq!(c.text, "> [!note]\n> ");
|
||||
assert_eq!(c.caret, c.text.len());
|
||||
for comp in [Component::Calendar, Component::Diagram, Component::Whiteboard, Component::Template("Daily".into())] {
|
||||
let e = insert_component("x\n\ny", 3, &comp).unwrap();
|
||||
assert!(e.text.is_char_boundary(e.caret));
|
||||
assert!(e.text.ends_with("\ny"));
|
||||
}
|
||||
let d = insert_component("", 0, &Component::Diagram).unwrap();
|
||||
assert_eq!(kinds(&d.text), vec![K::CodeFence]);
|
||||
assert!(d.text[..d.caret].ends_with("A --> B"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_never_panic() {
|
||||
assert!(insert_component("é", 1, &Component::Table).is_err());
|
||||
assert!(insert_component("a", 9, &Component::Table).is_err());
|
||||
for text in ["", "\n", "é\n\n日本語\n- é\n 日", "```", "~~~\n", "---", "---\n", "> ", "|", "- [", "- [x", "![[", "![[]]", "#", "\r\n\r\n- a\r\n b\r\n"]
|
||||
{
|
||||
let n = parse_blocks(text).len();
|
||||
for i in 0..=n {
|
||||
let _ = move_block(text, i, Direction::Up);
|
||||
let _ = move_block(text, i, Direction::Down);
|
||||
let _ = duplicate_block(text, i);
|
||||
let _ = delete_block(text, i);
|
||||
let _ = turn_into(text, i, &Target::Heading(3));
|
||||
}
|
||||
for o in 0..=text.len() + 1 {
|
||||
let _ = block_at(text, o);
|
||||
let _ = insert_component(text, o, &Component::Callout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hundred_thousand_lines() {
|
||||
let text: String = (0..100_000).map(|i| if i % 3 == 0 { format!("- item {i}\n child\n") } else { format!("line {i}\n\n") }).collect();
|
||||
let bs = parse_blocks(&text);
|
||||
assert!(bs.len() > 50_000);
|
||||
let blanks = "\n".repeat(100_000);
|
||||
let t = format!("- a\n{blanks}x");
|
||||
assert_eq!(parse_blocks(&t).len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Table-driven tests of [`notescraft_blocks::detect_language`]: every rule positive, near-miss
|
||||
//! negatives, CRLF input, empty and hostile bodies, and the 4 KiB / 40 line bounds.
|
||||
|
||||
use notescraft_blocks::detect_language;
|
||||
|
||||
/// (body, expected label) rows: a rule positive or a near miss that must stay `None`.
|
||||
#[test]
|
||||
fn the_table() {
|
||||
let rows: Vec<(&str, Option<&str>)> = vec![
|
||||
// shebang
|
||||
("#!/usr/bin/env python\nprint('hi')\n", Some("python")),
|
||||
("#!/usr/bin/python\nimport os\n", Some("python")),
|
||||
("#!/usr/bin/env bash\necho hi\n", Some("bash")),
|
||||
("#!/bin/bash\necho hi\n", Some("bash")),
|
||||
("#!/bin/sh\necho hi\n", Some("bash")),
|
||||
("#!/usr/bin/env zsh\necho hi\n", Some("bash")),
|
||||
("#!/usr/bin/env node\nconsole.log(1)\n", Some("javascript")),
|
||||
("#!/usr/bin/env ruby\nputs 'no'\n", None),
|
||||
("# not a shebang\n", None),
|
||||
// php, html, xml by opener
|
||||
("<?php\necho 1;\n", Some("php")),
|
||||
("<?PHP\necho 1;\n", None),
|
||||
("<!DOCTYPE html>\n<html><body></body></html>\n", Some("html")),
|
||||
("<!doctype html>\n<p>hi</p>\n", Some("html")),
|
||||
("<HTML>\n</HTML>\n", Some("html")),
|
||||
("<div>no label for fragments</div>\n", None),
|
||||
("<?xml version=\"1.0\"?>\n<a/>\n", Some("xml")),
|
||||
// json
|
||||
("{\"a\": 1}\n", Some("json")),
|
||||
("[1, 2, 3]\n", Some("json")),
|
||||
("`{\"a\":1}`\n", Some("json")),
|
||||
("{\"a\": 1}\r\n", Some("json")),
|
||||
("{a: 1}\nb: 2\n", None),
|
||||
("42\n", None),
|
||||
// yaml
|
||||
("name: NotesCraft\nversion: 1.0\nitems:\n - one\n - two\n", Some("yaml")),
|
||||
("title: a\n# a comment\n- one\nkey: value\n", Some("yaml")),
|
||||
("a: {x}\nb: 1\nc: 2\n", None),
|
||||
("a: b\nc: d\n", None),
|
||||
("x: 1; y\nz: 2\nw: 3\n", None),
|
||||
// diff
|
||||
("diff --git a/x b/x\nindex 1..2 100644\n", Some("diff")),
|
||||
("--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new\n", Some("diff")),
|
||||
("+++ b/x\n--- a/x\n", None),
|
||||
("- removed\n+ added\n", None),
|
||||
// shell transcripts
|
||||
("$ cargo build\n$ cargo test\n", Some("bash")),
|
||||
("$ ls\nfile1 file2\n$ pwd\n/home\n", None),
|
||||
("$ make\n", None),
|
||||
// rust
|
||||
("fn main() {\n let x = 1;\n}\n", Some("rust")),
|
||||
("impl Foo {\n fn bar(&self) -> u32 {\n 0\n }\n}\n", Some("rust")),
|
||||
("use std::collections::HashMap;\n", Some("rust")),
|
||||
("pub fn id(x: u32) -> u32 {\n x\n}\n", Some("rust")),
|
||||
("let x = 1;\n", None),
|
||||
("fn main() {}\n", None),
|
||||
("use foo::bar;\n", None),
|
||||
// python
|
||||
("def add(a, b):\n return a + b\n", Some("python")),
|
||||
("import os\nprint(os.getcwd())\n", Some("python")),
|
||||
("from os import path\nprint(path)\n", Some("python")),
|
||||
("class A:\n def x(self):\n pass\n", Some("python")),
|
||||
("def f(): pass\n", None),
|
||||
("import os\n", None),
|
||||
("from x y\n", None),
|
||||
// sql
|
||||
("SELECT * FROM users;\n", Some("sql")),
|
||||
("select name\nfrom t\nwhere id = 1\n", Some("sql")),
|
||||
("INSERT INTO t VALUES (1);\n", Some("sql")),
|
||||
("CREATE TABLE t (id INT);\n", Some("sql")),
|
||||
("DELETE FROM t WHERE id = 1;\n", Some("sql")),
|
||||
("UPDATE t SET a = 1 WHERE b = 2;\n", Some("sql")),
|
||||
("UPDATE t SET a = 1\n", None),
|
||||
("SELECT 1\n", None),
|
||||
("insert into\n", None),
|
||||
// toml
|
||||
("[package]\nname = \"x\"\n", Some("toml")),
|
||||
("a = 1\nb = 2\n", Some("toml")),
|
||||
("a = 1\n", None),
|
||||
("a = 1\nb = 2;\n", None),
|
||||
("[\"a\",]\n", None),
|
||||
("[section]\n", Some("toml")),
|
||||
// prose, lists, one-liners, hostile
|
||||
("The plan is simple: we ship it.\nAnother line here: no code.\nMostly prose without any shape at all.\n", None),
|
||||
("Note: this\nWarning: that\n", None),
|
||||
("- one\n- two\n- three\n", None),
|
||||
("x = 1", None),
|
||||
("a: b", None),
|
||||
("just a sentence\n", None),
|
||||
("1 + 1\n2 + 2\n", None),
|
||||
("hello: world\nfoo: bar\n", None),
|
||||
("\u{0}\u{0}\u{fffd}\n", None),
|
||||
("caf\u{e9}: no\n", None),
|
||||
// crlf versions of the keyword rules
|
||||
("fn main() {\r\n let x = 1;\r\n}\r\n", Some("rust")),
|
||||
("def add(a, b):\r\n return a + b\r\n", Some("python")),
|
||||
("$ cargo build\r\n$ cargo test\r\n", Some("bash")),
|
||||
("[package]\r\nname = \"x\"\r\n", Some("toml")),
|
||||
];
|
||||
for (body, expected) in rows {
|
||||
assert_eq!(detect_language(body), expected, "body {body:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_blank_bodies_are_none() {
|
||||
assert_eq!(detect_language(""), None);
|
||||
assert_eq!(detect_language(" \n\n \n"), None);
|
||||
assert_eq!(detect_language("\r\n\r\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_first_4_kib_and_40_non_blank_lines_are_examined() {
|
||||
// 1500 `$ x` lines (6000 bytes) push the bad line past the 4 KiB window.
|
||||
let beyond_bytes = format!("{}not a prompt\n", "$ x\n".repeat(1500));
|
||||
assert_eq!(detect_language(&beyond_bytes), Some("bash"));
|
||||
// 40 prompt lines: the 41st is never examined.
|
||||
let beyond_lines = format!("{}nope\n", "$ x\n".repeat(40));
|
||||
assert_eq!(detect_language(&beyond_lines), Some("bash"));
|
||||
// 39 prompt lines plus the bad one: the rule fails.
|
||||
let within = format!("{}nope\n", "$ x\n".repeat(39));
|
||||
assert_eq!(detect_language(&within), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mib_body_stays_bounded_in_time() {
|
||||
let began = std::time::Instant::now();
|
||||
let big = "x".repeat(1024 * 1024);
|
||||
assert_eq!(detect_language(&big), None);
|
||||
let mut pythonish = String::from("def add(a, b):\n return a + b\n");
|
||||
pythonish.push_str(&"x".repeat(1024 * 1024));
|
||||
assert_eq!(detect_language(&pythonish), Some("python"));
|
||||
let mut multibyte = String::from("fn main() {\n let x = \"\u{e9}\";\n}\n");
|
||||
multibyte.push_str(&"\u{e9}".repeat(700 * 1024));
|
||||
assert_eq!(detect_language(&multibyte), Some("rust"));
|
||||
assert!(began.elapsed().as_secs_f64() < 1.0, "took {}s", began.elapsed().as_secs_f64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bytes_never_panic_and_stay_none() {
|
||||
for hostile in ["\u{0}", "\u{0}\n\u{0}", "\u{7f}\u{8}", "\u{fffd}".repeat(5000).as_str(), "#!/usr/bin/env\u{0}python\n", "<<??php", "[]", "{}", "[\u{0}]"] {
|
||||
let _ = detect_language(hostile);
|
||||
}
|
||||
assert_eq!(detect_language("[\u{0}]"), None);
|
||||
assert_eq!(detect_language("#!/usr/bin/env python\u{0}"), None);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! Hostile input for the JSON half of the blocks crate: random alphabet soup, deep nesting,
|
||||
//! half a million escaped quotes and absurd numbers.
|
||||
//!
|
||||
//! Invariant everywhere: nothing panics, the size and nesting caps are errors, and for any input
|
||||
//! `format_json` accepts, formatting is idempotent and minifying loses nothing.
|
||||
|
||||
use notescraft_blocks::{Edited, check_json, format_json, format_json_block, json_span, looks_like_json, minify_json, minify_json_block};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Strings in the random sweep.
|
||||
const RANDOM_CASES: usize = 3000;
|
||||
/// Longest random string, in chars.
|
||||
const RANDOM_MAX: usize = 120;
|
||||
/// Bytes of the escaped-quote monster.
|
||||
const QUOTE_PAIR: &str = "\\\"";
|
||||
|
||||
/// xorshift64: deterministic, tiny, no dependency.
|
||||
struct Rng(u64);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 7;
|
||||
x ^= x << 17;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
|
||||
/// Bytes from the JSON alphabet plus NUL and a few multi-byte shapes.
|
||||
fn text(&mut self, len: usize) -> String {
|
||||
const ALPHABET: &[char] = &[
|
||||
'{', '}', '[', ']', '"', ',', ':', ' ', '\n', '\t', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'e', 'E', '.', '+', '-', 't', 'f', 'n', 'u',
|
||||
'l', '\0', '\u{7f}', 'é', '😀', '\u{2028}', '\u{feff}',
|
||||
];
|
||||
(0..len)
|
||||
.map(|_| {
|
||||
let at = u64::try_from(ALPHABET.len()).unwrap_or(u64::MAX);
|
||||
let at = usize::try_from(self.next() % at).unwrap_or(0);
|
||||
*ALPHABET.get(at).unwrap_or(&'?')
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// One note with the hostile text as the body of a `json` code block.
|
||||
fn note(body: &str) -> String {
|
||||
format!("# n\n\n```json\n{body}\n```\n\nafter\n")
|
||||
}
|
||||
|
||||
/// The edit must be exactly one splice of the old note, and the caret must be inside the new text.
|
||||
fn assert_edit_is_consistent(old: &str, edited: &Edited) {
|
||||
let (range, new) = &edited.replace;
|
||||
let head = old.get(..range.start).unwrap_or_default();
|
||||
let tail = old.get(range.end..).unwrap_or_default();
|
||||
let rebuilt = format!("{head}{new}{tail}");
|
||||
assert_eq!(rebuilt, edited.text, "the edit is one splice of the old text");
|
||||
assert!(edited.caret <= edited.text.len(), "the caret is past the text");
|
||||
assert!(edited.text.is_char_boundary(edited.caret), "the caret is not on a char boundary");
|
||||
assert!(edited.text.contains("after\n"), "the rest of the note survives");
|
||||
}
|
||||
|
||||
/// Whatever the input, formatting must be idempotent and minifying must lose nothing.
|
||||
fn assert_format_properties(text: &str) {
|
||||
let Ok(formatted) = format_json(text) else { return };
|
||||
let minified = minify_json(text).unwrap_or_default();
|
||||
assert_eq!(minify_json(&formatted).unwrap_or_default(), minified, "minify(format(x)) == minify(x) for {text:.40}");
|
||||
assert_eq!(format_json(&formatted).unwrap_or_default(), formatted, "format is idempotent for {text:.40}");
|
||||
assert_eq!(serde_json::from_str::<Value>(text).ok(), serde_json::from_str::<Value>(&formatted).ok(), "same value for {text:.40}");
|
||||
assert!(check_json(&formatted).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_alphabet_soup_never_panics() {
|
||||
let mut rng = Rng(0x1064_5dca_9e37_79b9);
|
||||
for _ in 0..RANDOM_CASES {
|
||||
let cap = u64::try_from(RANDOM_MAX).unwrap_or(u64::MAX).saturating_add(1);
|
||||
let len = usize::try_from(rng.next() % cap).unwrap_or(0);
|
||||
let text = rng.text(len);
|
||||
let _ = check_json(&text);
|
||||
let _ = looks_like_json(&text);
|
||||
if let Some(span) = json_span(&text) {
|
||||
assert!(span.start <= span.end && span.end <= text.len(), "span out of the body: {text:.40}");
|
||||
assert!(text.is_char_boundary(span.start) && text.is_char_boundary(span.end), "span off a char boundary");
|
||||
let inner = text.get(span.clone()).unwrap_or_default();
|
||||
assert!(check_json(inner).is_ok(), "the span must hold valid JSON");
|
||||
assert!(matches!((inner.chars().next(), inner.chars().next_back()), (Some('{'), Some('}')) | (Some('['), Some(']'))));
|
||||
assert!(looks_like_json(&text));
|
||||
}
|
||||
assert_format_properties(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"a":{"a":...1...}}` `depth` levels deep.
|
||||
fn nest(depth: usize) -> String {
|
||||
format!("{}1{}", "{\"a\":".repeat(depth), "}".repeat(depth))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_nesting_is_handled_without_a_stack_overflow() {
|
||||
let deep_ok = nest(100);
|
||||
assert!(check_json(&deep_ok).is_ok(), "100 levels is valid JSON");
|
||||
assert_format_properties(&deep_ok);
|
||||
|
||||
// 129 levels: valid JSON, and nothing overflows. See the ignored BUG test below: the rustdoc
|
||||
// claims serde_json's limit of 128 applies, but `IgnoredAny` skips values iteratively.
|
||||
let at_129 = nest(129);
|
||||
assert!(check_json(&at_129).is_ok(), "129 levels parses");
|
||||
assert!(check_json(&format!("{}0{}", "[".repeat(129), "]".repeat(129))).is_ok());
|
||||
assert_format_properties(&at_129);
|
||||
|
||||
// Deep but small: under the byte cap, so the parser sees it and survives it.
|
||||
let deep = format!("{}0{}", "[".repeat(200_000), "]".repeat(200_000));
|
||||
assert!(deep.len() <= notescraft_blocks::MAX_JSON_BYTES, "{} bytes", deep.len());
|
||||
assert!(check_json(&deep).is_ok(), "no stack overflow on deep nesting");
|
||||
assert!(minify_json(&deep).is_ok());
|
||||
|
||||
// Unbalanced nesting is an error, at any depth.
|
||||
for (open, close) in [("{", ""), ("", "}"), ("[[", "]]"), ("{\"a\":", ""), ("{\"a\": 1,", "")] {
|
||||
let junk = format!("{}{}", open.repeat(200), close);
|
||||
assert!(check_json(&junk).is_err(), "{junk:.20}");
|
||||
assert!(format_json(&junk).is_err(), "{junk:.20}");
|
||||
assert!(minify_json(&junk).is_err(), "{junk:.20}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "BUG: check_json's rustdoc says the parser's nesting limit applies, but 129 levels is accepted (IgnoredAny skips iteratively)"]
|
||||
fn nesting_over_128_is_refused() {
|
||||
let too_deep = nest(129);
|
||||
assert!(check_json(&too_deep).is_err(), "129 levels should be over serde_json's limit of 128");
|
||||
assert!(format_json(&too_deep).is_err());
|
||||
assert!(minify_json(&too_deep).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "BUG: format_json expands quadratically with depth: 2000 levels (6 KB in) already make a 4 MB string, so 1 MiB of nesting would try to build ~275 GB"]
|
||||
fn formatting_deep_json_stays_bounded() {
|
||||
let text = nest(2_000);
|
||||
let formatted = format_json(&text).unwrap_or_default();
|
||||
assert!(formatted.len() <= notescraft_blocks::MAX_JSON_BYTES, "{} bytes out of {} in", formatted.len(), text.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_string_with_half_a_million_escaped_quotes_survives() {
|
||||
let text = format!("[{}]{}]", "\"", "").to_owned() + "E_PAIR.repeat(500_000) + "\"]";
|
||||
assert!(text.len() > 1_000_000 && text.len() < notescraft_blocks::MAX_JSON_BYTES, "{} bytes", text.len());
|
||||
assert!(check_json(&text).is_ok(), "a monster string is still valid JSON");
|
||||
assert_format_properties(&text);
|
||||
let formatted = format_json(&text).unwrap_or_default();
|
||||
assert_eq!(formatted.matches(QUOTE_PAIR).count(), 500_000, "no escape is lost");
|
||||
assert_eq!(minify_json(&formatted).unwrap_or_default(), minify_json(&text).unwrap_or_default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn number_spelling_survives_formatting_byte_for_byte() {
|
||||
for number in ["1e999999999", "-0", "0.1e-5", "1E5", "-0.0e0", "123456789012345678901234567890", "0", "-1.7976931348623157e308", "1e-999999"] {
|
||||
let text = format!("{{\"a\":{number},\"b\":[{number}]}}");
|
||||
let formatted = format_json(&text).unwrap_or_default();
|
||||
assert_eq!(formatted.matches(number).count(), 2, "{number} must survive byte for byte: {formatted}");
|
||||
assert_eq!(minify_json(&formatted).unwrap_or_default(), minify_json(&text).unwrap_or_default());
|
||||
assert_eq!(format_json(&formatted).unwrap_or_default(), formatted.clone());
|
||||
assert_eq!(serde_json::from_str::<Value>(&text).ok(), serde_json::from_str::<Value>(&formatted).ok(), "{number}");
|
||||
}
|
||||
assert_format_properties(r#"{"deep":[[[[{"x":1e999999999}]]]],"neg":[-0,-0.1e-5]}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_size_cap_is_an_error_and_huge_bodies_do_not_panic() {
|
||||
let over = format!("[\"{}\"]", "x".repeat(notescraft_blocks::MAX_JSON_BYTES));
|
||||
assert!(check_json(&over).is_err(), "over the size cap");
|
||||
assert!(format_json(&over).is_err() && minify_json(&over).is_err());
|
||||
assert!(!looks_like_json(&over), "an oversized block holds no offerable JSON");
|
||||
assert_eq!(json_span(&over), None);
|
||||
let under = format!("[\"{}\"]", "x".repeat(100_000));
|
||||
assert!(check_json(&under).is_ok());
|
||||
assert_format_properties(&under);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_edits_on_hostile_bodies_never_panic_and_stay_consistent() {
|
||||
let mut rng = Rng(0x5851_f42d_4c95_7f2d);
|
||||
for case in 0..400 {
|
||||
let len = usize::try_from(rng.next() % 80).unwrap_or(0);
|
||||
let text = note(&rng.text(len));
|
||||
let formatted = format_json_block(&text, 0);
|
||||
let minified = minify_json_block(&text, 0);
|
||||
if let Ok(edited) = &formatted {
|
||||
assert_edit_is_consistent(&text, edited);
|
||||
}
|
||||
if let Ok(edited) = &minified {
|
||||
assert_edit_is_consistent(&text, edited);
|
||||
}
|
||||
if let (Ok(f), Ok(m)) = (&formatted, &minified) {
|
||||
assert_eq!(f.text, m.text, "case {case}: formatting and minifying differ");
|
||||
}
|
||||
}
|
||||
// Absurd block indices are errors, never panics; only the fence (index 1) holds JSON.
|
||||
for index in [0usize, 2, 3, 9, 10_000, usize::MAX] {
|
||||
let text = note("{\"a\":1}");
|
||||
assert!(format_json_block(&text, index).is_err(), "index {index}");
|
||||
assert!(minify_json_block(&text, index).is_err(), "index {index}");
|
||||
}
|
||||
// Hostile shapes inside a fence: backticks, new lines, nul, fences of their own.
|
||||
let bodies: Vec<String> = vec![
|
||||
"`{\"a\":1}`".to_owned(),
|
||||
"`{\"a\":${x}}`".to_owned(),
|
||||
"`{}` + `{}`".to_owned(),
|
||||
"{\"a\":\"```\"}".to_owned(),
|
||||
"\u{0}{\"a\":1}".to_owned(),
|
||||
"{\"a\":\"\\u0000\\uD83D\\uDE00\"}".to_owned(),
|
||||
"```".to_owned(),
|
||||
"{\"a\":1}\n{\"b\":2}".to_owned(),
|
||||
"\u{feff}{\"a\":1}\u{2028}".to_owned(),
|
||||
"\t{\"a\" : 1}\t".to_owned(),
|
||||
"[".repeat(500),
|
||||
format!("[\"{}\"]", "x".repeat(50_000)),
|
||||
];
|
||||
for body in &bodies {
|
||||
let text = note(body);
|
||||
let before = json_span(body).and_then(|span| serde_json::from_str::<Value>(body.get(span).unwrap_or_default()).ok());
|
||||
for index in [0usize, 1, usize::MAX] {
|
||||
if let Ok(edited) = format_json_block(&text, index) {
|
||||
assert_edit_is_consistent(&text, &edited);
|
||||
let after_body = json_of(&edited.text);
|
||||
if let (Some(span), Some(want)) = (json_span(after_body), before.clone()) {
|
||||
let got = after_body.get(span).and_then(|inner| serde_json::from_str::<Value>(inner).ok());
|
||||
assert_eq!(got, Some(want), "formatting keeps the value of {body:.20}");
|
||||
}
|
||||
}
|
||||
if let Ok(edited) = minify_json_block(&text, index) {
|
||||
assert_edit_is_consistent(&text, &edited);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of the first fenced block in `text`.
|
||||
fn json_of(text: &str) -> &str {
|
||||
text.split("```").nth(1).unwrap_or_default()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "notescraft-calendar"
|
||||
description = "calendar events: model, recurrence, queries (pure logic, no I/O)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
notescraft-markdown = { workspace = true }
|
||||
notescraft-templates = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Crate error type.
|
||||
|
||||
/// Everything that can go wrong in `notescraft-calendar`.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// A date or time that is not `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM`, or is impossible.
|
||||
#[error("invalid time: {0}")]
|
||||
InvalidTime(String),
|
||||
/// A repeat rule outside the supported subset, or with impossible values.
|
||||
#[error("invalid repeat rule: {0}")]
|
||||
InvalidRule(String),
|
||||
/// A repeat rule that would produce, or scan, more than the caps allow.
|
||||
#[error("repeat rule expands past the cap of {0}")]
|
||||
TooMany(usize),
|
||||
/// A quick-add phrase with a part that cannot be read.
|
||||
#[error("cannot read `{part}`: {reason}")]
|
||||
QuickAdd {
|
||||
/// The words that failed, as typed.
|
||||
part: String,
|
||||
/// What is wrong with them.
|
||||
reason: String,
|
||||
},
|
||||
/// An iCalendar file that cannot be read at all.
|
||||
#[error("invalid calendar file: {0}")]
|
||||
Ics(String),
|
||||
/// The text has no frontmatter block, so it is not an event file.
|
||||
#[error("not an event file: no frontmatter block")]
|
||||
NoFrontmatter,
|
||||
/// A field is missing, has the wrong type or is out of bounds.
|
||||
#[error("field `{field}`: {reason}")]
|
||||
Field {
|
||||
/// The frontmatter key.
|
||||
field: &'static str,
|
||||
/// What is wrong with it.
|
||||
reason: String,
|
||||
},
|
||||
/// The file is larger than the event file cap.
|
||||
#[error("event file is {0} bytes, the most allowed is {MAX_EVENT_BYTES}")]
|
||||
TooLarge(usize),
|
||||
/// The frontmatter editor refused a write.
|
||||
#[error(transparent)]
|
||||
Markdown(#[from] notescraft_markdown::Error),
|
||||
}
|
||||
|
||||
/// Most bytes of one event file this crate reads or writes.
|
||||
pub const MAX_EVENT_BYTES: usize = 1024 * 1024;
|
||||
|
||||
/// Crate result alias.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Importing calendars: the `.ics` reader and the feeds that keep a cache of it.
|
||||
|
||||
pub mod google;
|
||||
mod ics;
|
||||
mod sync;
|
||||
|
||||
pub use ics::{IcsEvent, IcsImport, MAX_ICS_BYTES, parse_ics};
|
||||
pub use sync::{CachedFeed, DEFAULT_REFRESH, Feed, FeedStatus, Fetch, Fetched, due, log_line, redact, refresh};
|
||||
@@ -0,0 +1,350 @@
|
||||
//! Google Calendar, read-only: the pure parts. Sign-in URL with PKCE, token and calendar-list
|
||||
//! JSON, and events JSON turned into the same [`IcsEvent`]s a feed gives.
|
||||
//!
|
||||
//! Nothing here touches the network, a browser or the keychain; the app does that and passes
|
||||
//! the JSON text in.
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::ics::IcsEvent;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{Event, Reminder, Status};
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// The only scope asked for: reading calendars.
|
||||
pub const SCOPE: &str = "https://www.googleapis.com/auth/calendar.readonly";
|
||||
|
||||
/// Largest reply read.
|
||||
pub const MAX_JSON_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
/// Most events one reply may hold.
|
||||
const MAX_EVENTS: usize = 10_000;
|
||||
|
||||
/// Most calendars one account may list.
|
||||
const MAX_CALENDARS: usize = 500;
|
||||
|
||||
/// Longest reminder, minutes (four weeks, Google's own limit).
|
||||
const MAX_REMINDER: u64 = 40_320;
|
||||
|
||||
const SHA_K: [u32; 64] = [
|
||||
0x428a_2f98,
|
||||
0x7137_4491,
|
||||
0xb5c0_fbcf,
|
||||
0xe9b5_dba5,
|
||||
0x3956_c25b,
|
||||
0x59f1_11f1,
|
||||
0x923f_82a4,
|
||||
0xab1c_5ed5,
|
||||
0xd807_aa98,
|
||||
0x1283_5b01,
|
||||
0x2431_85be,
|
||||
0x550c_7dc3,
|
||||
0x72be_5d74,
|
||||
0x80de_b1fe,
|
||||
0x9bdc_06a7,
|
||||
0xc19b_f174,
|
||||
0xe49b_69c1,
|
||||
0xefbe_4786,
|
||||
0x0fc1_9dc6,
|
||||
0x240c_a1cc,
|
||||
0x2de9_2c6f,
|
||||
0x4a74_84aa,
|
||||
0x5cb0_a9dc,
|
||||
0x76f9_88da,
|
||||
0x983e_5152,
|
||||
0xa831_c66d,
|
||||
0xb003_27c8,
|
||||
0xbf59_7fc7,
|
||||
0xc6e0_0bf3,
|
||||
0xd5a7_9147,
|
||||
0x06ca_6351,
|
||||
0x1429_2967,
|
||||
0x27b7_0a85,
|
||||
0x2e1b_2138,
|
||||
0x4d2c_6dfc,
|
||||
0x5338_0d13,
|
||||
0x650a_7354,
|
||||
0x766a_0abb,
|
||||
0x81c2_c92e,
|
||||
0x9272_2c85,
|
||||
0xa2bf_e8a1,
|
||||
0xa81a_664b,
|
||||
0xc24b_8b70,
|
||||
0xc76c_51a3,
|
||||
0xd192_e819,
|
||||
0xd699_0624,
|
||||
0xf40e_3585,
|
||||
0x106a_a070,
|
||||
0x19a4_c116,
|
||||
0x1e37_6c08,
|
||||
0x2748_774c,
|
||||
0x34b0_bcb5,
|
||||
0x391c_0cb3,
|
||||
0x4ed8_aa4a,
|
||||
0x5b9c_ca4f,
|
||||
0x682e_6ff3,
|
||||
0x748f_82ee,
|
||||
0x78a5_636f,
|
||||
0x84c8_7814,
|
||||
0x8cc7_0208,
|
||||
0x90be_fffa,
|
||||
0xa450_6ceb,
|
||||
0xbef9_a3f7,
|
||||
0xc671_78f2,
|
||||
];
|
||||
|
||||
/// SHA-256 of `data`.
|
||||
#[must_use]
|
||||
pub fn sha256(data: &[u8]) -> [u8; 32] {
|
||||
let mut state: [u32; 8] = [0x6a09_e667, 0xbb67_ae85, 0x3c6e_f372, 0xa54f_f53a, 0x510e_527f, 0x9b05_688c, 0x1f83_d9ab, 0x5be0_cd19];
|
||||
let mut message = data.to_vec();
|
||||
message.push(0x80);
|
||||
while message.len() % 64 != 56 {
|
||||
message.push(0);
|
||||
}
|
||||
message.extend_from_slice(&(u64::try_from(data.len()).unwrap_or(u64::MAX).wrapping_mul(8)).to_be_bytes());
|
||||
let (blocks, _) = message.as_chunks::<64>();
|
||||
for block in blocks {
|
||||
let mut w = [0_u32; 64];
|
||||
for (slot, word) in w.iter_mut().zip(block.as_chunks::<4>().0) {
|
||||
*slot = u32::from_be_bytes(*word);
|
||||
}
|
||||
for i in 16..64 {
|
||||
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
|
||||
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16].wrapping_add(s0).wrapping_add(w[i - 7]).wrapping_add(s1);
|
||||
}
|
||||
let mut v = state;
|
||||
for i in 0..64 {
|
||||
let s1 = v[4].rotate_right(6) ^ v[4].rotate_right(11) ^ v[4].rotate_right(25);
|
||||
let choose = (v[4] & v[5]) ^ (!v[4] & v[6]);
|
||||
let t1 = v[7].wrapping_add(s1).wrapping_add(choose).wrapping_add(SHA_K[i]).wrapping_add(w[i]);
|
||||
let s0 = v[0].rotate_right(2) ^ v[0].rotate_right(13) ^ v[0].rotate_right(22);
|
||||
let majority = (v[0] & v[1]) ^ (v[0] & v[2]) ^ (v[1] & v[2]);
|
||||
let t2 = s0.wrapping_add(majority);
|
||||
v = [t1.wrapping_add(t2), v[0], v[1], v[2], v[3].wrapping_add(t1), v[4], v[5], v[6]];
|
||||
}
|
||||
for (slot, add) in state.iter_mut().zip(v) {
|
||||
*slot = slot.wrapping_add(add);
|
||||
}
|
||||
}
|
||||
let mut out = [0_u8; 32];
|
||||
for (chunk, word) in out.as_chunks_mut::<4>().0.iter_mut().zip(state) {
|
||||
*chunk = word.to_be_bytes();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Base64 with the URL alphabet and no padding.
|
||||
#[must_use]
|
||||
pub fn base64url(data: &[u8]) -> String {
|
||||
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
||||
for chunk in data.chunks(3) {
|
||||
let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk.get(1).copied().unwrap_or(0)) << 8) | u32::from(chunk.get(2).copied().unwrap_or(0));
|
||||
for shift in [18, 12, 6, 0].iter().take(chunk.len() + 1) {
|
||||
out.push(char::from(ALPHABET[usize::try_from((n >> shift) & 63).unwrap_or(0)]));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The PKCE challenge of a verifier: `base64url(sha256(verifier))`.
|
||||
#[must_use]
|
||||
pub fn pkce_challenge(verifier: &str) -> String {
|
||||
base64url(&sha256(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
/// Percent-encodes everything but unreserved characters.
|
||||
#[must_use]
|
||||
pub fn percent_encode(text: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for byte in text.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
|
||||
out.push(char::from(byte));
|
||||
} else {
|
||||
let _ = write!(out, "%{byte:02X}");
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The address the user signs in at; the answer comes back to `redirect_uri` on the loopback.
|
||||
#[must_use]
|
||||
pub fn auth_url(client_id: &str, redirect_uri: &str, challenge: &str, state: &str) -> String {
|
||||
format!(
|
||||
"https://accounts.google.com/o/oauth2/v2/auth?client_id={}&redirect_uri={}&response_type=code&scope={}&access_type=offline&prompt=consent&code_challenge={}&code_challenge_method=S256&state={}",
|
||||
percent_encode(client_id),
|
||||
percent_encode(redirect_uri),
|
||||
percent_encode(SCOPE),
|
||||
percent_encode(challenge),
|
||||
percent_encode(state)
|
||||
)
|
||||
}
|
||||
|
||||
/// A token answer.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Token {
|
||||
/// Bearer token for API calls.
|
||||
pub access_token: String,
|
||||
/// Seconds it lasts.
|
||||
pub expires_in: u64,
|
||||
/// Kept in the keychain; Google sends it only on the first sign-in.
|
||||
pub refresh_token: Option<String>,
|
||||
}
|
||||
|
||||
fn json(text: &str) -> Result<Value> {
|
||||
if text.len() > MAX_JSON_BYTES {
|
||||
return Err(Error::Ics(format!("the answer is {} bytes, the most allowed is {MAX_JSON_BYTES}", text.len())));
|
||||
}
|
||||
serde_json::from_str(text).map_err(|_| Error::Ics("the answer is not JSON".to_owned()))
|
||||
}
|
||||
|
||||
/// Reads a token answer; an `error` field becomes the error text.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] when the JSON is unreadable or Google refused.
|
||||
pub fn parse_token(text: &str) -> Result<Token> {
|
||||
let value = json(text)?;
|
||||
if let Some(error) = value.get("error").and_then(Value::as_str) {
|
||||
let why = value.get("error_description").and_then(Value::as_str).unwrap_or(error);
|
||||
return Err(Error::Ics(format!("Google refused: {}", why.chars().take(120).collect::<String>())));
|
||||
}
|
||||
let access_token = value
|
||||
.get("access_token")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| Error::Ics("no access token in the answer".to_owned()))?;
|
||||
Ok(Token {
|
||||
access_token: access_token.to_owned(),
|
||||
expires_in: value.get("expires_in").and_then(Value::as_u64).unwrap_or(3600),
|
||||
refresh_token: value.get("refresh_token").and_then(Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// One calendar of the account.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GoogleCalendar {
|
||||
/// Id to ask events for.
|
||||
pub id: String,
|
||||
/// Name shown.
|
||||
pub name: String,
|
||||
/// The account's own calendar.
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
/// Reads the calendar list.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] when the JSON is unreadable.
|
||||
pub fn parse_calendar_list(text: &str) -> Result<Vec<GoogleCalendar>> {
|
||||
let value = json(text)?;
|
||||
let items = value.get("items").and_then(Value::as_array).ok_or_else(|| Error::Ics("no calendars in the answer".to_owned()))?;
|
||||
Ok(items
|
||||
.iter()
|
||||
.take(MAX_CALENDARS)
|
||||
.filter_map(|item| {
|
||||
let id = item.get("id").and_then(Value::as_str)?;
|
||||
let name = item.get("summaryOverride").or_else(|| item.get("summary")).and_then(Value::as_str).unwrap_or(id);
|
||||
Some(GoogleCalendar {
|
||||
id: id.to_owned(),
|
||||
name: name.chars().take(80).collect(),
|
||||
primary: item.get("primary").and_then(Value::as_bool).unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The address of a page of events, expanded to single occurrences.
|
||||
#[must_use]
|
||||
pub fn events_url(calendar_id: &str, time_min: &str, time_max: &str, page_token: Option<&str>) -> String {
|
||||
let mut url = format!(
|
||||
"https://www.googleapis.com/calendar/v3/calendars/{}/events?singleEvents=true&orderBy=startTime&maxResults=250&timeMin={}&timeMax={}",
|
||||
percent_encode(calendar_id),
|
||||
percent_encode(time_min),
|
||||
percent_encode(time_max)
|
||||
);
|
||||
if let Some(token) = page_token {
|
||||
url.push_str("&pageToken=");
|
||||
url.push_str(&percent_encode(token));
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// `2026-10-20T09:30:00+01:00` as the wall-clock `2026-10-20T09:30`, or `2026-10-20` as is.
|
||||
fn time_of(part: &Value) -> Option<EventTime> {
|
||||
let text = part.get("dateTime").or_else(|| part.get("date"))?.as_str()?;
|
||||
text.get(..16).unwrap_or(text).parse().ok()
|
||||
}
|
||||
|
||||
/// Reads an events answer (one page or several joined as `{"items": [...]}`).
|
||||
///
|
||||
/// An unreadable event is skipped, not fatal; the count of skipped ones comes back too.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] when the JSON is unreadable or has no `items`.
|
||||
pub fn parse_events(text: &str) -> Result<(Vec<IcsEvent>, usize)> {
|
||||
let value = json(text)?;
|
||||
let items = value.get("items").and_then(Value::as_array).ok_or_else(|| Error::Ics("no events in the answer".to_owned()))?;
|
||||
if items.len() > MAX_EVENTS {
|
||||
return Err(Error::Ics(format!("more than {MAX_EVENTS} events")));
|
||||
}
|
||||
let (mut events, mut skipped) = (Vec::new(), 0);
|
||||
for item in items {
|
||||
match event_of(item) {
|
||||
Some(event) => events.push(event),
|
||||
None => skipped += 1,
|
||||
}
|
||||
}
|
||||
Ok((events, skipped))
|
||||
}
|
||||
|
||||
fn event_of(item: &Value) -> Option<IcsEvent> {
|
||||
let start = time_of(item.get("start")?)?;
|
||||
let mut end = item.get("end").and_then(time_of);
|
||||
if let (EventTime::Day(from), Some(EventTime::Day(to))) = (start, end) {
|
||||
let last = to.add_days(-1).ok()?;
|
||||
end = (last > from).then_some(EventTime::Day(last));
|
||||
}
|
||||
if end.is_some_and(|end| end < start) {
|
||||
end = None;
|
||||
}
|
||||
let title = item.get("summary").and_then(Value::as_str).filter(|title| !title.trim().is_empty()).unwrap_or("(no title)");
|
||||
let status = match item.get("status").and_then(Value::as_str) {
|
||||
Some("cancelled") => Some(Status::Cancelled),
|
||||
Some("tentative") => Some(Status::Tentative),
|
||||
Some("confirmed") => Some(Status::Confirmed),
|
||||
_ => None,
|
||||
};
|
||||
let reminders = item
|
||||
.get("reminders")
|
||||
.and_then(|reminders| reminders.get("overrides"))
|
||||
.and_then(Value::as_array)
|
||||
.map(|list| {
|
||||
list.iter()
|
||||
.filter_map(|entry| entry.get("minutes").and_then(Value::as_u64))
|
||||
.filter(|minutes| *minutes <= MAX_REMINDER)
|
||||
.filter_map(|minutes| u32::try_from(minutes).ok())
|
||||
.map(|minutes_before| Reminder { minutes_before })
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let event = Event {
|
||||
title: title.chars().take(500).collect(),
|
||||
start,
|
||||
end,
|
||||
tz: item.get("start").and_then(|part| part.get("timeZone")).and_then(Value::as_str).map(str::to_owned),
|
||||
repeat: None,
|
||||
exceptions: Vec::new(),
|
||||
calendar: None,
|
||||
reminders,
|
||||
tags: Vec::new(),
|
||||
priority: None,
|
||||
status,
|
||||
link: item.get("hangoutLink").or_else(|| item.get("htmlLink")).and_then(Value::as_str).map(str::to_owned),
|
||||
note: None,
|
||||
};
|
||||
Some(IcsEvent { uid: item.get("id").and_then(Value::as_str).map(str::to_owned), event, unsupported_rule: None })
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Read-only iCalendar (`.ics`) import.
|
||||
//!
|
||||
//! Reads `VEVENT`s (and the `VALARM` reminders inside them) from a feed.
|
||||
//! Times keep their wall-clock value: a `TZID` or a trailing `Z` becomes the
|
||||
//! event's `tz` label and no zone conversion happens. Repeat rules outside the
|
||||
//! supported subset import as a single event with the raw rule in
|
||||
//! [`IcsEvent::unsupported_rule`], so the UI can show it read-only. Feeds are
|
||||
//! untrusted: size, line length, nesting and event count are all capped, and a
|
||||
//! broken event is skipped with a note instead of failing the file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{Event, Priority, Reminder, Status};
|
||||
use crate::recur::Rule;
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Largest feed read.
|
||||
pub const MAX_ICS_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
/// Longest unfolded line.
|
||||
const MAX_LINE_BYTES: usize = 8 * 1024;
|
||||
|
||||
/// Most lines in a feed.
|
||||
const MAX_LINES: usize = 200_000;
|
||||
|
||||
/// Deepest `BEGIN` nesting.
|
||||
const MAX_DEPTH: usize = 8;
|
||||
|
||||
/// Most events imported from one feed.
|
||||
const MAX_EVENTS: usize = 10_000;
|
||||
|
||||
/// Most skip notes kept.
|
||||
const MAX_SKIPPED: usize = 100;
|
||||
|
||||
/// One imported event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IcsEvent {
|
||||
/// The feed's `UID`, when it has one.
|
||||
pub uid: Option<String>,
|
||||
/// The event; `repeat` is set only when the rule is in the supported subset.
|
||||
pub event: Event,
|
||||
/// The raw `RRULE` when it is outside the subset.
|
||||
pub unsupported_rule: Option<String>,
|
||||
}
|
||||
|
||||
/// The result of reading a feed.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct IcsImport {
|
||||
/// The events that were read.
|
||||
pub events: Vec<IcsEvent>,
|
||||
/// Why events were skipped, one note each (capped).
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
struct Prop {
|
||||
name: String,
|
||||
params: BTreeMap<String, String>,
|
||||
value: String,
|
||||
}
|
||||
|
||||
/// Reads the events of an `.ics` feed.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] when the text is over [`MAX_ICS_BYTES`], is not a
|
||||
/// `VCALENDAR`, or breaks a line, depth or event cap.
|
||||
pub fn parse_ics(text: &str) -> Result<IcsImport> {
|
||||
if text.len() > MAX_ICS_BYTES {
|
||||
return Err(Error::Ics(format!("{} bytes, the most allowed is {MAX_ICS_BYTES}", text.len())));
|
||||
}
|
||||
let lines = unfold(text)?;
|
||||
if lines.first().map(|line| line.trim().to_ascii_uppercase()).as_deref() != Some("BEGIN:VCALENDAR") {
|
||||
return Err(Error::Ics("does not start with BEGIN:VCALENDAR".to_owned()));
|
||||
}
|
||||
let mut import = IcsImport::default();
|
||||
let mut depth = 0_usize;
|
||||
let mut event: Option<Vec<Prop>> = None;
|
||||
let mut alarm: Option<Vec<Prop>> = None;
|
||||
for line in &lines {
|
||||
let Some(prop) = parse_prop(line) else { continue };
|
||||
match (prop.name.as_str(), prop.value.to_ascii_uppercase().as_str()) {
|
||||
("BEGIN", component) => {
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(Error::Ics(format!("components nest deeper than {MAX_DEPTH}")));
|
||||
}
|
||||
match (component, depth) {
|
||||
("VEVENT", 2) => event = Some(Vec::new()),
|
||||
("VALARM", 3) if event.is_some() => alarm = Some(Vec::new()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
("END", component) => {
|
||||
depth = depth.saturating_sub(1);
|
||||
match component {
|
||||
"VALARM" => {
|
||||
if let (Some(props), Some(parent)) = (alarm.take(), event.as_mut()) {
|
||||
parent.extend(props.into_iter().filter(|p| p.name == "TRIGGER").map(|mut p| {
|
||||
"X-ALARM-TRIGGER".clone_into(&mut p.name);
|
||||
p
|
||||
}));
|
||||
}
|
||||
}
|
||||
"VEVENT" => {
|
||||
if let Some(props) = event.take() {
|
||||
finish(&props, &mut import)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(props) = alarm.as_mut().or(event.as_mut()).filter(|_| depth <= 3) {
|
||||
props.push(prop);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(import)
|
||||
}
|
||||
|
||||
fn skip(import: &mut IcsImport, note: String) {
|
||||
if import.skipped.len() < MAX_SKIPPED {
|
||||
import.skipped.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(props: &[Prop], import: &mut IcsImport) -> Result<()> {
|
||||
if import.events.len() >= MAX_EVENTS {
|
||||
return Err(Error::Ics(format!("more than {MAX_EVENTS} events")));
|
||||
}
|
||||
let uid = props.iter().find(|p| p.name == "UID").map(|p| unescape(&p.value));
|
||||
match build(props) {
|
||||
Ok((event, unsupported_rule)) => import.events.push(IcsEvent { uid, event, unsupported_rule }),
|
||||
Err(reason) => skip(import, format!("{}: {reason}", uid.as_deref().unwrap_or("event without UID"))),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Joins folded lines and strips line ends.
|
||||
fn unfold(text: &str) -> Result<Vec<String>> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for (count, raw) in text.split('\n').enumerate() {
|
||||
if count >= MAX_LINES {
|
||||
return Err(Error::Ics(format!("more than {MAX_LINES} lines")));
|
||||
}
|
||||
let raw = raw.strip_suffix('\r').unwrap_or(raw);
|
||||
match (raw.strip_prefix([' ', '\t']), lines.last_mut()) {
|
||||
(Some(rest), Some(last)) => last.push_str(rest),
|
||||
_ if raw.is_empty() => {}
|
||||
_ => lines.push(raw.to_owned()),
|
||||
}
|
||||
if lines.last().is_some_and(|last| last.len() > MAX_LINE_BYTES) {
|
||||
return Err(Error::Ics(format!("a line is longer than {MAX_LINE_BYTES} bytes")));
|
||||
}
|
||||
}
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn parse_prop(line: &str) -> Option<Prop> {
|
||||
let mut quoted = false;
|
||||
let colon = line.char_indices().find(|(_, c)| {
|
||||
quoted ^= *c == '"';
|
||||
*c == ':' && !quoted
|
||||
})?;
|
||||
let (head, value) = (line.get(..colon.0)?, line.get(colon.0 + 1..)?);
|
||||
let mut parts = head.split(';');
|
||||
let name = parts.next()?.trim().to_ascii_uppercase();
|
||||
let params = parts.filter_map(|p| p.split_once('=')).map(|(k, v)| (k.trim().to_ascii_uppercase(), v.trim().trim_matches('"').to_owned())).collect();
|
||||
Some(Prop { name, params, value: value.to_owned() })
|
||||
}
|
||||
|
||||
fn unescape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut chars = text.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n' | 'N') => out.push('\n'),
|
||||
Some(other) => out.push(other),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build(props: &[Prop]) -> std::result::Result<(Event, Option<String>), String> {
|
||||
let first = |name: &str| props.iter().find(|p| p.name == name);
|
||||
let start_prop = first("DTSTART").ok_or("no DTSTART")?;
|
||||
let start = time(start_prop)?;
|
||||
let mut end = first("DTEND").map(time).transpose()?;
|
||||
if let (EventTime::Day(from), Some(EventTime::Day(to))) = (start, end) {
|
||||
// An all-day DTEND is the day after the last one.
|
||||
let last = to.add_days(-1).map_err(|e| e.to_string())?;
|
||||
end = (last > from).then_some(EventTime::Day(last));
|
||||
}
|
||||
if end.is_some_and(|end| end < start) {
|
||||
end = None;
|
||||
}
|
||||
let title = first("SUMMARY").map(|p| unescape(&p.value)).filter(|t| !t.trim().is_empty()).unwrap_or_else(|| "(no title)".to_owned());
|
||||
let tz = start_prop.params.get("TZID").cloned().or_else(|| start_prop.value.ends_with('Z').then(|| "UTC".to_owned()));
|
||||
let (repeat, unsupported) = match first("RRULE") {
|
||||
Some(rrule) => match rule(&rrule.value) {
|
||||
Some(parsed) => (Some(parsed.to_string()), None),
|
||||
None => (None, Some(rrule.value.clone())),
|
||||
},
|
||||
None => (None, None),
|
||||
};
|
||||
let exceptions = props
|
||||
.iter()
|
||||
.filter(|p| p.name == "EXDATE")
|
||||
.flat_map(|p| p.value.split(',').map(move |v| time_text(v.trim(), p.params.get("VALUE").is_some_and(|v| v == "DATE"))))
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
let tags = first("CATEGORIES").map(|p| p.value.split(',').map(|t| unescape(t).trim().to_owned()).filter(|t| !t.is_empty()).collect()).unwrap_or_default();
|
||||
let reminders =
|
||||
props.iter().filter(|p| p.name == "X-ALARM-TRIGGER").filter_map(|p| trigger(&p.value)).map(|minutes_before| Reminder { minutes_before }).collect();
|
||||
let status = first("STATUS").and_then(|p| match p.value.to_ascii_uppercase().as_str() {
|
||||
"CONFIRMED" => Some(Status::Confirmed),
|
||||
"TENTATIVE" => Some(Status::Tentative),
|
||||
"CANCELLED" => Some(Status::Cancelled),
|
||||
_ => None,
|
||||
});
|
||||
let priority = first("PRIORITY").and_then(|p| p.value.trim().parse::<u8>().ok()).and_then(|n| match n {
|
||||
1..=4 => Some(Priority::High),
|
||||
5 => Some(Priority::Medium),
|
||||
6..=9 => Some(Priority::Low),
|
||||
_ => None,
|
||||
});
|
||||
let event = Event {
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
tz,
|
||||
repeat,
|
||||
exceptions,
|
||||
calendar: None,
|
||||
reminders,
|
||||
tags,
|
||||
priority,
|
||||
status,
|
||||
link: first("URL").map(|p| p.value.clone()),
|
||||
note: None,
|
||||
};
|
||||
Ok((event, unsupported))
|
||||
}
|
||||
|
||||
fn time(prop: &Prop) -> std::result::Result<EventTime, String> {
|
||||
time_text(prop.value.trim(), prop.params.get("VALUE").is_some_and(|v| v == "DATE"))
|
||||
}
|
||||
|
||||
/// `20261017` or `20261017T093000[Z]`.
|
||||
fn time_text(text: &str, date_only: bool) -> std::result::Result<EventTime, String> {
|
||||
let bad = || format!("cannot read time `{}`", text.chars().take(24).collect::<String>());
|
||||
let digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit());
|
||||
let (day, clock) = match text.split_once('T') {
|
||||
Some((day, clock)) => (day, Some(clock.trim_end_matches('Z'))),
|
||||
None => (text, None),
|
||||
};
|
||||
if day.len() != 8 || !digits(day) || (date_only && clock.is_some()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let iso = format!("{}-{}-{}", &day[..4], &day[4..6], &day[6..]);
|
||||
match clock {
|
||||
None => iso.parse().map_err(|_| bad()),
|
||||
Some(clock) if clock.len() == 6 && digits(clock) => format!("{iso}T{}:{}", &clock[..2], &clock[2..4]).parse().map_err(|_| bad()),
|
||||
Some(_) => Err(bad()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule when it stays inside the supported subset.
|
||||
fn rule(text: &str) -> Option<Rule> {
|
||||
let mut parts = Vec::new();
|
||||
for part in text.split(';') {
|
||||
let (key, value) = part.split_once('=')?;
|
||||
match key.to_ascii_uppercase().as_str() {
|
||||
"FREQ" | "INTERVAL" | "COUNT" => parts.push(part.to_owned()),
|
||||
"UNTIL" => {
|
||||
let day = time_text(value, false).ok()?.date();
|
||||
parts.push(format!("UNTIL={}", EventTime::Day(day)));
|
||||
}
|
||||
"BYDAY" if value.split(',').all(|d| d.len() == 2) => parts.push(part.to_owned()),
|
||||
"WKST" if value.eq_ignore_ascii_case("MO") => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
parts.join(";").parse().ok()
|
||||
}
|
||||
|
||||
/// Minutes before the start for triggers like `-PT15M`, `-PT1H30M`, `-P1D`.
|
||||
fn trigger(text: &str) -> Option<u32> {
|
||||
let rest = text.trim().strip_prefix('-')?.strip_prefix('P')?;
|
||||
let (days, clock) = rest.split_once('T').map_or((rest, ""), |(d, c)| (d, c));
|
||||
let mut minutes = 0_u32;
|
||||
for (part, per) in [(days, 1440_u32), (clock, 0)] {
|
||||
if per == 0 {
|
||||
let mut number = String::new();
|
||||
for c in part.chars() {
|
||||
if c.is_ascii_digit() {
|
||||
number.push(c);
|
||||
continue;
|
||||
}
|
||||
let value: u32 = number.parse().ok()?;
|
||||
number.clear();
|
||||
minutes = minutes.checked_add(match c {
|
||||
'H' => value.checked_mul(60)?,
|
||||
'M' => value,
|
||||
'S' => 0,
|
||||
_ => return None,
|
||||
})?;
|
||||
}
|
||||
if !number.is_empty() {
|
||||
return None;
|
||||
}
|
||||
} else if let Some(count) = part.strip_suffix('D') {
|
||||
minutes = minutes.checked_add(count.parse::<u32>().ok()?.checked_mul(per)?)?;
|
||||
} else if let Some(count) = part.strip_suffix('W') {
|
||||
minutes = minutes.checked_add(count.parse::<u32>().ok()?.checked_mul(7 * per)?)?;
|
||||
} else if !part.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(minutes)
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
//! ICS feeds: what to fetch and when, and what to keep when the network is gone.
|
||||
//!
|
||||
//! Pure: the actual fetching is a [`Fetch`] the caller supplies (a thread in the app,
|
||||
//! a fixture in tests). A feed keeps its last good events in a [`CachedFeed`]; a
|
||||
//! failed refresh adds an error to the status and keeps the events, so the calendar
|
||||
//! still shows them offline. Nothing here writes a URL into a status, a log line or
|
||||
//! an error: a private feed address is a credential.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ics::{IcsEvent, parse_ics};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::remind::epoch_minutes;
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Longest feed address.
|
||||
const MAX_URL_BYTES: usize = 2048;
|
||||
|
||||
/// Longest feed name.
|
||||
const MAX_NAME_CHARS: usize = 80;
|
||||
|
||||
/// Shortest and longest refresh interval, minutes.
|
||||
const MIN_REFRESH: u32 = 5;
|
||||
const MAX_REFRESH: u32 = 7 * 24 * 60;
|
||||
|
||||
/// Refresh interval a new feed gets, minutes.
|
||||
pub const DEFAULT_REFRESH: u32 = 60;
|
||||
|
||||
/// After a failure a feed is tried again after this many minutes at most.
|
||||
const RETRY_MINUTES: i64 = 15;
|
||||
|
||||
/// Address prefix of a Google calendar feed.
|
||||
pub const GOOGLE_PREFIX: &str = "google://calendar/";
|
||||
|
||||
/// Longest error text kept.
|
||||
const MAX_ERROR_CHARS: usize = 200;
|
||||
|
||||
/// One subscribed feed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Feed {
|
||||
/// Short stable id, from the name.
|
||||
pub id: String,
|
||||
/// Name shown as the calendar of its events.
|
||||
pub name: String,
|
||||
/// `https://` address of the `.ics` file.
|
||||
pub url: String,
|
||||
/// Minutes between refreshes.
|
||||
pub refresh_minutes: u32,
|
||||
}
|
||||
|
||||
/// How the last refreshes went.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct FeedStatus {
|
||||
/// When a refresh last tried, `YYYY-MM-DDTHH:MM`.
|
||||
pub attempted_at: Option<String>,
|
||||
/// When a refresh last succeeded.
|
||||
pub fetched_at: Option<String>,
|
||||
/// The server's validator, for `If-None-Match`.
|
||||
pub etag: Option<String>,
|
||||
/// Why the last refresh failed; none when it worked.
|
||||
pub error: Option<String>,
|
||||
/// Events read.
|
||||
pub events: usize,
|
||||
/// Events skipped for being unreadable.
|
||||
pub skipped: usize,
|
||||
}
|
||||
|
||||
/// A feed's events and status as stored on disk.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CachedFeed {
|
||||
/// The events of the last good refresh.
|
||||
pub events: Vec<IcsEvent>,
|
||||
/// How the refreshes went.
|
||||
pub status: FeedStatus,
|
||||
}
|
||||
|
||||
/// What a fetch returned.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Fetched {
|
||||
/// The server says the feed has not changed.
|
||||
NotModified,
|
||||
/// The feed text and its validator.
|
||||
Body {
|
||||
/// The `.ics` text.
|
||||
text: String,
|
||||
/// The `ETag`, when the server sent one.
|
||||
etag: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Something that fetches a feed address.
|
||||
pub trait Fetch {
|
||||
/// Fetches `url`, sending `etag` as `If-None-Match` when given.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message saying what went wrong; it may name the address, which is removed before it is kept.
|
||||
fn fetch(&mut self, url: &str, etag: Option<&str>) -> std::result::Result<Fetched, String>;
|
||||
}
|
||||
|
||||
impl Feed {
|
||||
/// A feed for `url`, which must be `https://`, `http://` or `webcal://` (read as `https://`).
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] naming what is wrong with the name, address or interval.
|
||||
pub fn new(name: &str, url: &str, refresh_minutes: Option<u32>) -> Result<Self> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(Error::Ics(format!("a feed name is 1 to {MAX_NAME_CHARS} characters")));
|
||||
}
|
||||
let url = url.trim();
|
||||
if url.len() > MAX_URL_BYTES || url.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||
return Err(Error::Ics("the feed address is empty-looking, has spaces or is too long".to_owned()));
|
||||
}
|
||||
let url = match url.split_once("://") {
|
||||
Some(("https" | "http", rest)) if !rest.is_empty() => url.to_owned(),
|
||||
Some(("webcal", rest)) if !rest.is_empty() => format!("https://{rest}"),
|
||||
_ => return Err(Error::Ics("a feed address starts with https://, http:// or webcal://".to_owned())),
|
||||
};
|
||||
let refresh_minutes = refresh_minutes.unwrap_or(DEFAULT_REFRESH);
|
||||
if !(MIN_REFRESH..=MAX_REFRESH).contains(&refresh_minutes) {
|
||||
return Err(Error::Ics(format!("the refresh interval is {MIN_REFRESH} to {MAX_REFRESH} minutes")));
|
||||
}
|
||||
Ok(Self { id: slug(name), name: name.to_owned(), url, refresh_minutes })
|
||||
}
|
||||
}
|
||||
|
||||
impl Feed {
|
||||
/// A feed of one Google calendar; its address is `google://calendar/<id>`, which holds no secret.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Ics`] naming what is wrong with the name or id.
|
||||
pub fn google(name: &str, calendar_id: &str, refresh_minutes: Option<u32>) -> Result<Self> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(Error::Ics(format!("a feed name is 1 to {MAX_NAME_CHARS} characters")));
|
||||
}
|
||||
if calendar_id.is_empty() || calendar_id.len() > 512 || calendar_id.chars().any(|c| c.is_control() || c.is_whitespace()) {
|
||||
return Err(Error::Ics("a Google calendar id has no spaces and is at most 512 bytes".to_owned()));
|
||||
}
|
||||
Ok(Self {
|
||||
id: format!("google-{}", slug(name)),
|
||||
name: name.to_owned(),
|
||||
url: format!("{GOOGLE_PREFIX}{calendar_id}"),
|
||||
refresh_minutes: refresh_minutes.unwrap_or(DEFAULT_REFRESH).clamp(MIN_REFRESH, MAX_REFRESH),
|
||||
})
|
||||
}
|
||||
|
||||
/// The Google calendar id, when this feed reads one.
|
||||
#[must_use]
|
||||
pub fn google_calendar(&self) -> Option<&str> {
|
||||
self.url.strip_prefix(GOOGLE_PREFIX)
|
||||
}
|
||||
}
|
||||
|
||||
fn slug(name: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in name.chars().flat_map(char::to_lowercase) {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
out.push(c);
|
||||
} else if !out.ends_with('-') && !out.is_empty() {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
let out = out.trim_end_matches('-');
|
||||
if out.is_empty() { "feed".to_owned() } else { out.chars().take(40).collect() }
|
||||
}
|
||||
|
||||
fn minutes_of(text: Option<&str>) -> Option<i64> {
|
||||
text?.parse::<EventTime>().ok().and_then(epoch_minutes)
|
||||
}
|
||||
|
||||
/// The feeds to refresh at `now` (minutes since the epoch): never fetched, past their
|
||||
/// interval, or failing and past the retry wait. `force` returns them all.
|
||||
#[must_use]
|
||||
pub fn due<'a>(feeds: &'a [Feed], statuses: &BTreeMap<String, FeedStatus>, now: i64, force: bool) -> Vec<&'a Feed> {
|
||||
feeds
|
||||
.iter()
|
||||
.filter(|feed| {
|
||||
if force {
|
||||
return true;
|
||||
}
|
||||
let Some(status) = statuses.get(&feed.id) else { return true };
|
||||
let Some(last) = minutes_of(status.attempted_at.as_deref()).or_else(|| minutes_of(status.fetched_at.as_deref())) else { return true };
|
||||
let wait = if status.error.is_some() { i64::from(feed.refresh_minutes).min(RETRY_MINUTES) } else { i64::from(feed.refresh_minutes) };
|
||||
now.saturating_sub(last) >= wait
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `text` with the feed address, its query and any `user:password@` removed, cut short.
|
||||
#[must_use]
|
||||
pub fn redact(text: &str, url: &str) -> String {
|
||||
let mut out = text.replace(url, "<feed>");
|
||||
if let Some((_, rest)) = url.split_once("://") {
|
||||
let host_and_path = rest.split(['?', '#']).next().unwrap_or(rest);
|
||||
out = out.replace(host_and_path, "<feed>");
|
||||
if let Some((_, query)) = url.split_once('?') {
|
||||
for part in query.split(['&', ';']) {
|
||||
let value = part.split_once('=').map_or(part, |(_, value)| value);
|
||||
for piece in [part, value] {
|
||||
if piece.len() > 3 {
|
||||
out = out.replace(piece, "<redacted>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((userinfo, _)) = host_and_path.split_once('@') {
|
||||
for piece in [userinfo, userinfo.split_once(':').map_or(userinfo, |(_, password)| password)] {
|
||||
if piece.len() > 2 {
|
||||
out = out.replace(piece, "<redacted>");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.chars().take(MAX_ERROR_CHARS).collect()
|
||||
}
|
||||
|
||||
/// One JSON log line for a refresh: when, which feed (by name), how it went. No address, no event text.
|
||||
#[must_use]
|
||||
pub fn log_line(feed: &Feed, status: &FeedStatus, now: EventTime) -> String {
|
||||
serde_json::json!({
|
||||
"at": now.to_string(),
|
||||
"feed": feed.name,
|
||||
"ok": status.error.is_none(),
|
||||
"events": status.events,
|
||||
"skipped": status.skipped,
|
||||
"error": status.error,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Refreshes `feed` through `fetcher`. A failure keeps the events of `previous` and records why.
|
||||
pub fn refresh(feed: &Feed, previous: Option<&CachedFeed>, fetcher: &mut dyn Fetch, now: EventTime) -> CachedFeed {
|
||||
let stamp = now.to_string();
|
||||
let mut cached = previous.cloned().unwrap_or_default();
|
||||
cached.status.attempted_at = Some(stamp.clone());
|
||||
let etag = cached.status.etag.clone();
|
||||
let failure = match fetcher.fetch(&feed.url, etag.as_deref()) {
|
||||
Ok(Fetched::NotModified) => {
|
||||
cached.status.fetched_at = Some(stamp);
|
||||
cached.status.error = None;
|
||||
return cached;
|
||||
}
|
||||
Ok(Fetched::Body { text, etag }) => match parse_body(feed, &text) {
|
||||
Ok(import) => {
|
||||
cached.status = FeedStatus {
|
||||
attempted_at: cached.status.attempted_at.clone(),
|
||||
fetched_at: Some(stamp),
|
||||
etag,
|
||||
error: None,
|
||||
events: import.events.len(),
|
||||
skipped: import.skipped.len(),
|
||||
};
|
||||
cached.events = import.events;
|
||||
return cached;
|
||||
}
|
||||
Err(error) => error.to_string(),
|
||||
},
|
||||
Err(message) => message,
|
||||
};
|
||||
cached.status.error = Some(redact(&failure, &feed.url));
|
||||
cached
|
||||
}
|
||||
|
||||
/// Reads a fetched body: Google JSON for a Google feed, `.ics` for any other.
|
||||
fn parse_body(feed: &Feed, text: &str) -> Result<crate::feed::ics::IcsImport> {
|
||||
if feed.google_calendar().is_none() {
|
||||
return parse_ics(text);
|
||||
}
|
||||
let (events, skipped) = super::google::parse_events(text)?;
|
||||
Ok(crate::feed::ics::IcsImport { events, skipped: vec![String::from("an event could not be read"); skipped.min(100)] })
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! One event per Markdown file: the event lives in the note's frontmatter.
|
||||
//!
|
||||
//! Reading uses the frontmatter subset parser of `notescraft-markdown`;
|
||||
//! writing goes through its single-entry editors, so keys this crate does not
|
||||
//! know, comments and the note body survive a rewrite byte for byte.
|
||||
|
||||
use notescraft_markdown::yaml::{self, Value};
|
||||
use notescraft_markdown::{PropValue, YamlValueText, remove_key, set_key};
|
||||
|
||||
use crate::error::{Error, MAX_EVENT_BYTES, Result};
|
||||
use crate::model::{Event, Priority, Reminder, Status};
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Most items one list field (`exceptions`, `reminders`, `tags`) may hold.
|
||||
pub const MAX_ITEMS: usize = 1000;
|
||||
|
||||
/// Most bytes of one text field.
|
||||
const MAX_FIELD_BYTES: usize = 4096;
|
||||
|
||||
/// Reads the event in the frontmatter of `src`.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::TooLarge`] over [`MAX_EVENT_BYTES`], [`Error::NoFrontmatter`]
|
||||
/// without a `---` block at byte 0, [`Error::Field`] for a missing `title` or
|
||||
/// `start` or any field of the wrong type, size or value.
|
||||
pub fn parse_event(src: &str) -> Result<Event> {
|
||||
if src.len() > MAX_EVENT_BYTES {
|
||||
return Err(Error::TooLarge(src.len()));
|
||||
}
|
||||
let front = yaml::parse(src).ok_or(Error::NoFrontmatter)?;
|
||||
let get = |key: &str| front.get(key);
|
||||
Ok(Event {
|
||||
title: text(get("title"), "title")?.ok_or_else(|| missing("title"))?,
|
||||
start: time(get("start"), "start")?.ok_or_else(|| missing("start"))?,
|
||||
end: time(get("end"), "end")?,
|
||||
tz: text(get("tz"), "tz")?,
|
||||
repeat: text(get("repeat"), "repeat")?,
|
||||
exceptions: times(get("exceptions"), "exceptions")?,
|
||||
calendar: text(get("calendar"), "calendar")?,
|
||||
reminders: reminders(get("reminders"))?,
|
||||
tags: texts(get("tags"), "tags")?,
|
||||
priority: text(get("priority"), "priority")?.map(|word| priority(&word)).transpose()?,
|
||||
status: text(get("status"), "status")?.map(|word| status(&word)).transpose()?,
|
||||
link: text(get("link"), "link")?,
|
||||
note: text(get("note"), "note")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Writes `event` into the frontmatter of `existing`, or into a new file when
|
||||
/// `existing` is `None`. Returns the whole new file text.
|
||||
///
|
||||
/// Fields the event leaves empty are removed; keys that are not event fields
|
||||
/// and the note body are kept.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::Field`] when a field is out of bounds, [`Error::TooLarge`] when
|
||||
/// the result passes [`MAX_EVENT_BYTES`], [`Error::Markdown`] when the editor
|
||||
/// refuses a key or value.
|
||||
pub fn write_event(existing: Option<&str>, event: &Event) -> Result<String> {
|
||||
check(event)?;
|
||||
let mut out = existing.unwrap_or("").to_owned();
|
||||
let mut put = |key: &str, value: Option<String>| -> Result<()> {
|
||||
out = match value {
|
||||
Some(value) => set_key(&out, key, &YamlValueText::new(value))?,
|
||||
None => remove_key(&out, key)?,
|
||||
};
|
||||
Ok(())
|
||||
};
|
||||
put("title", Some(quote(&event.title)))?;
|
||||
put("start", Some(event.start.to_string()))?;
|
||||
put("end", event.end.map(|time| time.to_string()))?;
|
||||
put("tz", event.tz.as_deref().map(quote))?;
|
||||
put("repeat", event.repeat.as_deref().map(quote))?;
|
||||
put("exceptions", list(event.exceptions.iter().map(ToString::to_string)))?;
|
||||
put("calendar", event.calendar.as_deref().map(quote))?;
|
||||
put("reminders", list(event.reminders.iter().map(|r| r.minutes_before.to_string())))?;
|
||||
put("tags", (!event.tags.is_empty()).then(|| PropValue::List(event.tags.clone()).render()))?;
|
||||
put("priority", event.priority.map(|p| priority_word(p).to_owned()))?;
|
||||
put("status", event.status.map(|s| status_word(s).to_owned()))?;
|
||||
put("link", event.link.as_deref().map(quote))?;
|
||||
put("note", event.note.as_deref().map(quote))?;
|
||||
if out.len() > MAX_EVENT_BYTES {
|
||||
return Err(Error::TooLarge(out.len()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn missing(field: &'static str) -> Error {
|
||||
Error::Field { field, reason: "missing".to_owned() }
|
||||
}
|
||||
|
||||
fn bad(field: &'static str, reason: impl Into<String>) -> Error {
|
||||
Error::Field { field, reason: reason.into() }
|
||||
}
|
||||
|
||||
/// Always-quoted YAML text, so a title like `2026` or `true` stays text.
|
||||
fn quote(text: &str) -> String {
|
||||
PropValue::Text(text.to_owned()).render()
|
||||
}
|
||||
|
||||
/// `[a, b]`, or `None` for an empty list so the key is removed.
|
||||
fn list(items: impl Iterator<Item = String>) -> Option<String> {
|
||||
let items: Vec<String> = items.collect();
|
||||
(!items.is_empty()).then(|| format!("[{}]", items.join(", ")))
|
||||
}
|
||||
|
||||
fn check(event: &Event) -> Result<()> {
|
||||
let field = |name: &'static str, value: &str| {
|
||||
if value.len() > MAX_FIELD_BYTES {
|
||||
return Err(bad(name, format!("{} bytes, the most allowed is {MAX_FIELD_BYTES}", value.len())));
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if event.title.trim().is_empty() {
|
||||
return Err(bad("title", "must not be empty"));
|
||||
}
|
||||
field("title", &event.title)?;
|
||||
for (name, value) in [("tz", &event.tz), ("repeat", &event.repeat), ("calendar", &event.calendar), ("link", &event.link), ("note", &event.note)] {
|
||||
if let Some(value) = value {
|
||||
field(name, value)?;
|
||||
}
|
||||
}
|
||||
for tag in &event.tags {
|
||||
field("tags", tag)?;
|
||||
}
|
||||
for (name, len) in [("exceptions", event.exceptions.len()), ("reminders", event.reminders.len()), ("tags", event.tags.len())] {
|
||||
if len > MAX_ITEMS {
|
||||
return Err(bad(name, format!("{len} items, the most allowed is {MAX_ITEMS}")));
|
||||
}
|
||||
}
|
||||
if event.end.is_some_and(|end| end < event.start) {
|
||||
return Err(bad("end", "is before `start`"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The text of a scalar value. Numbers and booleans are refused: titles and
|
||||
/// names are text, and the writer always quotes them. The parser keeps plain
|
||||
/// scalars with a colon (`https://x/y`, `10:30`) as `Raw`; those are text when
|
||||
/// they sit on one line and do not start like an anchor, tag, block or map.
|
||||
fn scalar(value: &Value, field: &'static str) -> Result<String> {
|
||||
let text = match value {
|
||||
Value::String(text) | Value::Date(text) => text.clone(),
|
||||
Value::Raw(text) if !text.contains('\n') && !text.starts_with(['&', '*', '!', '|', '>', '{', '[']) => text.clone(),
|
||||
_ => return Err(bad(field, "must be text")),
|
||||
};
|
||||
if text.len() > MAX_FIELD_BYTES {
|
||||
return Err(bad(field, format!("{} bytes, the most allowed is {MAX_FIELD_BYTES}", text.len())));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
fn text(value: Option<&Value>, field: &'static str) -> Result<Option<String>> {
|
||||
value.map(|value| scalar(value, field)).transpose()
|
||||
}
|
||||
|
||||
fn items<'a>(value: Option<&'a Value>, field: &'static str) -> Result<&'a [Value]> {
|
||||
let Some(value) = value else { return Ok(&[]) };
|
||||
let list = value.as_list().ok_or_else(|| bad(field, "must be a list"))?;
|
||||
if list.len() > MAX_ITEMS {
|
||||
return Err(bad(field, format!("{} items, the most allowed is {MAX_ITEMS}", list.len())));
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn texts(value: Option<&Value>, field: &'static str) -> Result<Vec<String>> {
|
||||
items(value, field)?.iter().map(|item| scalar(item, field)).collect()
|
||||
}
|
||||
|
||||
fn parse_time(text: &str, field: &'static str) -> Result<EventTime> {
|
||||
text.parse().map_err(|error: Error| bad(field, error.to_string()))
|
||||
}
|
||||
|
||||
fn time(value: Option<&Value>, field: &'static str) -> Result<Option<EventTime>> {
|
||||
text(value, field)?.map(|text| parse_time(&text, field)).transpose()
|
||||
}
|
||||
|
||||
fn times(value: Option<&Value>, field: &'static str) -> Result<Vec<EventTime>> {
|
||||
texts(value, field)?.iter().map(|text| parse_time(text, field)).collect()
|
||||
}
|
||||
|
||||
fn reminders(value: Option<&Value>) -> Result<Vec<Reminder>> {
|
||||
items(value, "reminders")?
|
||||
.iter()
|
||||
.map(|item| match item {
|
||||
Value::Number(minutes) if minutes.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(minutes) => {
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // checked integral and in range just above
|
||||
Ok(Reminder { minutes_before: *minutes as u32 })
|
||||
}
|
||||
_ => Err(bad("reminders", "each item must be whole minutes, 0 or more")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
const fn priority_word(priority: Priority) -> &'static str {
|
||||
match priority {
|
||||
Priority::Low => "low",
|
||||
Priority::Medium => "medium",
|
||||
Priority::High => "high",
|
||||
}
|
||||
}
|
||||
|
||||
fn priority(word: &str) -> Result<Priority> {
|
||||
[Priority::Low, Priority::Medium, Priority::High]
|
||||
.into_iter()
|
||||
.find(|candidate| priority_word(*candidate) == word)
|
||||
.ok_or_else(|| bad("priority", "must be low, medium or high"))
|
||||
}
|
||||
|
||||
const fn status_word(status: Status) -> &'static str {
|
||||
match status {
|
||||
Status::Confirmed => "confirmed",
|
||||
Status::Tentative => "tentative",
|
||||
Status::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
fn status(word: &str) -> Result<Status> {
|
||||
[Status::Confirmed, Status::Tentative, Status::Cancelled]
|
||||
.into_iter()
|
||||
.find(|candidate| status_word(*candidate) == word)
|
||||
.ok_or_else(|| bad("status", "must be confirmed, tentative or cancelled"))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Day views: conflicts, free time and the year heat map.
|
||||
|
||||
use notescraft_templates::Date;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::item::Item;
|
||||
use crate::time::EventTime;
|
||||
|
||||
const MINUTES_PER_DAY: u32 = 24 * 60;
|
||||
|
||||
/// Most items one conflict search compares (the work is quadratic).
|
||||
const MAX_CONFLICT_ITEMS: usize = 5000;
|
||||
|
||||
/// A stretch of one day, in minutes after midnight.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub struct Slot {
|
||||
/// First minute.
|
||||
pub start: u32,
|
||||
/// Minute after the last one.
|
||||
pub end: u32,
|
||||
}
|
||||
|
||||
/// Pairs of items (by index) whose times overlap. Whole-day items, items
|
||||
/// without an end and cancelled items never conflict. Examines at most the
|
||||
/// first [`MAX_CONFLICT_ITEMS`] items.
|
||||
#[must_use]
|
||||
pub fn conflicts(items: &[Item]) -> Vec<(usize, usize)> {
|
||||
let mut timed: Vec<(usize, EventTime, EventTime)> = items
|
||||
.iter()
|
||||
.take(MAX_CONFLICT_ITEMS)
|
||||
.enumerate()
|
||||
.filter(|(_, item)| item.status != Some(crate::Status::Cancelled))
|
||||
.filter_map(|(index, item)| match (item.start, item.end) {
|
||||
(start @ EventTime::Minute(_), Some(end @ EventTime::Minute(_))) if end > start => Some((index, start, end)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
timed.sort_by_key(|(_, start, _)| *start);
|
||||
let mut pairs = Vec::new();
|
||||
for (position, (a, _, a_end)) in timed.iter().enumerate() {
|
||||
for (b, b_start, _) in timed.iter().skip(position + 1) {
|
||||
if b_start >= a_end {
|
||||
break;
|
||||
}
|
||||
pairs.push((*a.min(b), *a.max(b)));
|
||||
}
|
||||
}
|
||||
pairs.sort_unstable();
|
||||
pairs
|
||||
}
|
||||
|
||||
/// The free stretches of `day` between `day_start` and `day_end` (minutes
|
||||
/// after midnight) that last at least `min_minutes`. Timed, non-cancelled
|
||||
/// items busy the day; whole-day items do not.
|
||||
#[must_use]
|
||||
pub fn free_slots(items: &[Item], day: Date, day_start: u32, day_end: u32, min_minutes: u32) -> Vec<Slot> {
|
||||
let (open, close) = (day_start.min(MINUTES_PER_DAY), day_end.min(MINUTES_PER_DAY));
|
||||
let mut busy: Vec<(u32, u32)> = items
|
||||
.iter()
|
||||
.filter(|item| item.status != Some(crate::Status::Cancelled))
|
||||
.filter_map(|item| {
|
||||
let (EventTime::Minute(start), end) = (item.start, item.end) else { return None };
|
||||
let begin = match start.date().cmp(&day) {
|
||||
std::cmp::Ordering::Less => 0,
|
||||
std::cmp::Ordering::Equal => minute_of(start),
|
||||
std::cmp::Ordering::Greater => return None,
|
||||
};
|
||||
let finish = match end {
|
||||
Some(EventTime::Minute(end)) if end.date() > day => MINUTES_PER_DAY,
|
||||
Some(EventTime::Minute(end)) if end.date() == day => minute_of(end),
|
||||
Some(EventTime::Minute(_)) => return None,
|
||||
_ => begin,
|
||||
};
|
||||
(finish > begin).then_some((begin, finish))
|
||||
})
|
||||
.collect();
|
||||
busy.sort_unstable();
|
||||
let mut slots = Vec::new();
|
||||
let mut cursor = open;
|
||||
for (begin, finish) in busy {
|
||||
if begin > cursor && begin.min(close) > cursor && begin.min(close) - cursor >= min_minutes {
|
||||
slots.push(Slot { start: cursor, end: begin.min(close) });
|
||||
}
|
||||
cursor = cursor.max(finish);
|
||||
}
|
||||
if close > cursor && close - cursor >= min_minutes {
|
||||
slots.push(Slot { start: cursor, end: close });
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
fn minute_of(at: notescraft_templates::DateTime) -> u32 {
|
||||
u32::from(at.hour()) * 60 + u32::from(at.minute())
|
||||
}
|
||||
|
||||
/// Items per day of `year`, index 0 = 1 January. A multi-day item counts on
|
||||
/// every day it touches.
|
||||
#[must_use]
|
||||
pub fn year_counts(items: &[Item], year: i32) -> Vec<u32> {
|
||||
let Ok(first) = Date::new(year, 1, 1) else { return Vec::new() };
|
||||
let length: i64 = if Date::is_leap_year(year) { 366 } else { 365 };
|
||||
let mut counts = vec![0_u32; if length == 366 { 366 } else { 365 }];
|
||||
let base = i64::from(first.to_days_since_epoch());
|
||||
for item in items {
|
||||
let from = i64::from(item.start.date().to_days_since_epoch()) - base;
|
||||
let to = i64::from(item.last_date().to_days_since_epoch()) - base;
|
||||
for day in from.max(0)..=to.min(length - 1) {
|
||||
if let Some(slot) = usize::try_from(day).ok().and_then(|index| counts.get_mut(index)) {
|
||||
*slot = slot.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// The heat level for a day's item count: 0 none, 1 to 3 as the count grows.
|
||||
#[must_use]
|
||||
pub const fn heat_level(count: u32) -> u8 {
|
||||
match count {
|
||||
0 => 0,
|
||||
1 => 1,
|
||||
2..=3 => 2,
|
||||
_ => 3,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
//! [`Item`]: one line of the agenda, an event occurrence or a dated task.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::model::{Priority, Status};
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// What an agenda item is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Kind {
|
||||
/// A calendar event.
|
||||
Event,
|
||||
/// A task with a due date.
|
||||
Task,
|
||||
}
|
||||
|
||||
/// One agenda line. Repeating events are expanded to one item per occurrence
|
||||
/// before the queries here see them.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
/// Stable id, the vault path of the note or event file.
|
||||
pub id: String,
|
||||
/// Event or task.
|
||||
pub kind: Kind,
|
||||
/// Title shown in every view.
|
||||
pub title: String,
|
||||
/// When it starts, or is due.
|
||||
pub start: EventTime,
|
||||
/// When it ends, if it has a length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<EventTime>,
|
||||
/// Calendar name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub calendar: Option<String>,
|
||||
/// Tags without the `#`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
/// Priority.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<Priority>,
|
||||
/// Status; none counts as confirmed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<Status>,
|
||||
/// Whether it has at least one reminder.
|
||||
#[serde(default)]
|
||||
pub has_reminder: bool,
|
||||
/// Minutes before the start each reminder fires.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reminders: Vec<u32>,
|
||||
/// Vault path of the linked note.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
/// When it was created.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created: Option<EventTime>,
|
||||
/// Whether a task is done.
|
||||
#[serde(default)]
|
||||
pub done: bool,
|
||||
/// Whether this is one occurrence of a repeating event, so changes need a scope.
|
||||
#[serde(default)]
|
||||
pub repeats: bool,
|
||||
/// The zone the event's times are in, when it names one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tz: Option<String>,
|
||||
/// The feed an imported, read-only item comes from; none for the vault's own items.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<String>,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// The last day it touches: the end's day, else the start's.
|
||||
#[must_use]
|
||||
pub fn last_date(&self) -> notescraft_templates::Date {
|
||||
self.end.map_or(self.start.date(), EventTime::date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Calendar events: the model, recurrence, queries and importers.
|
||||
//!
|
||||
//! Layer L3, no I/O. Dates come from `notescraft-templates`.
|
||||
//!
|
||||
//! * [`EventTime`] — a whole day or a day with a time, written as ISO text.
|
||||
//! * [`parse_event`] / [`write_event`] — one event per Markdown file, in its frontmatter.
|
||||
//! * [`parse_quick`] — "lunch with Ana tomorrow at 12:30 for 1h" to an [`Event`].
|
||||
//! * [`parse_ics`] — read-only import of `.ics` feeds.
|
||||
//! * [`tick`], [`catch_up`], [`inbox`] — reminders with an injected clock, snoozing and quiet hours.
|
||||
//! * [`Feed`], [`refresh`], [`due`] — ICS feeds with a cache that survives going offline.
|
||||
//! * [`Rule`] — the repeat rule subset (daily, weekly, monthly, yearly) and its expansion.
|
||||
//! * [`Item`], [`Query`], [`run_query`] — one agenda of events and dated tasks: filter, sort, group.
|
||||
//! * [`conflicts`], [`free_slots`], [`year_counts`], [`week_review`] — what the day, year and review views show.
|
||||
//! * [`Event`], [`Reminder`], [`Calendar`] — the data the calendar commands move around.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod error;
|
||||
mod feed;
|
||||
mod file;
|
||||
mod free;
|
||||
mod item;
|
||||
mod model;
|
||||
mod query;
|
||||
mod quickadd;
|
||||
mod recur;
|
||||
mod remind;
|
||||
mod review;
|
||||
mod time;
|
||||
|
||||
pub use error::{Error, MAX_EVENT_BYTES, Result};
|
||||
pub use feed::google;
|
||||
pub use feed::{CachedFeed, DEFAULT_REFRESH, Feed, FeedStatus, Fetch, Fetched, IcsEvent, IcsImport, MAX_ICS_BYTES, due, log_line, parse_ics, redact, refresh};
|
||||
pub use file::{MAX_ITEMS, parse_event, write_event};
|
||||
pub use free::{Slot, conflicts, free_slots, heat_level, year_counts};
|
||||
pub use item::{Item, Kind};
|
||||
pub use model::{Calendar, Event, Priority, Reminder, Status};
|
||||
pub use query::{Filter, Group, GroupKey, Query, SavedFilter, SortKey, run_query};
|
||||
pub use quickadd::parse_quick;
|
||||
pub use recur::{Freq, MAX_OCCURRENCES, Rule, Split};
|
||||
pub use remind::{Alert, Inbox, Notifier, ReminderSettings, ReminderState, Tick, alerts_for, catch_up, deliver, dismiss, epoch_minutes, inbox, snooze, tick};
|
||||
pub use review::{Review, week_review};
|
||||
pub use time::EventTime;
|
||||
@@ -0,0 +1,123 @@
|
||||
//! The event data model.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// How important an event or task is.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Priority {
|
||||
/// Can slip.
|
||||
Low,
|
||||
/// The default.
|
||||
Medium,
|
||||
/// Do not miss.
|
||||
High,
|
||||
}
|
||||
|
||||
/// Whether an event is on.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Status {
|
||||
/// Happening.
|
||||
Confirmed,
|
||||
/// Maybe.
|
||||
Tentative,
|
||||
/// Called off.
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// A reminder: how many minutes before the start it fires.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct Reminder {
|
||||
/// Minutes before the start.
|
||||
pub minutes_before: u32,
|
||||
}
|
||||
|
||||
/// A named calendar events belong to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Calendar {
|
||||
/// Display name, also the key events use in `calendar`.
|
||||
pub name: String,
|
||||
/// Colour as `#rrggbb`, when set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<String>,
|
||||
}
|
||||
|
||||
/// One event. A start without a time is an all-day event.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Event {
|
||||
/// Title shown in every view.
|
||||
pub title: String,
|
||||
/// When it starts.
|
||||
pub start: EventTime,
|
||||
/// When it ends; none means it has no length.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub end: Option<EventTime>,
|
||||
/// IANA zone name, when different from the system zone.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tz: Option<String>,
|
||||
/// Repeat rule text; parsed by the recurrence module.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub repeat: Option<String>,
|
||||
/// Occurrences of a repeating event that are skipped.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub exceptions: Vec<EventTime>,
|
||||
/// Name of the calendar it belongs to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub calendar: Option<String>,
|
||||
/// Reminders, earliest first is not required.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub reminders: Vec<Reminder>,
|
||||
/// Tags without the `#`.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tags: Vec<String>,
|
||||
/// Priority, when set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub priority: Option<Priority>,
|
||||
/// Status, when set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<Status>,
|
||||
/// A link such as a meeting URL.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub link: Option<String>,
|
||||
/// Vault path of the linked note.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Calendar, Event, Priority, Reminder, Status};
|
||||
|
||||
#[test]
|
||||
fn a_full_event_round_trips_through_json() {
|
||||
let event = Event {
|
||||
title: "Standup".to_owned(),
|
||||
start: "2026-10-19T09:30".parse().unwrap(),
|
||||
end: Some("2026-10-19T09:45".parse().unwrap()),
|
||||
tz: Some("Europe/Lisbon".to_owned()),
|
||||
repeat: Some("weekly".to_owned()),
|
||||
exceptions: vec!["2026-10-26T09:30".parse().unwrap()],
|
||||
calendar: Some("Work".to_owned()),
|
||||
reminders: vec![Reminder { minutes_before: 10 }],
|
||||
tags: vec!["team".to_owned()],
|
||||
priority: Some(Priority::High),
|
||||
status: Some(Status::Confirmed),
|
||||
link: Some("https://example.com/call".to_owned()),
|
||||
note: Some("meetings/standup.md".to_owned()),
|
||||
};
|
||||
let json = serde_json::to_string(&event).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Event>(&json).unwrap(), event);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_may_be_missing_and_bad_times_are_refused() {
|
||||
let event: Event = serde_json::from_str(r#"{"title":"Lunch","start":"2026-10-19"}"#).unwrap();
|
||||
assert!(event.start.is_all_day() && event.end.is_none() && event.reminders.is_empty());
|
||||
assert!(serde_json::from_str::<Event>(r#"{"title":"x","start":"2026-02-30"}"#).is_err());
|
||||
let calendar: Calendar = serde_json::from_str(r#"{"name":"Work"}"#).unwrap();
|
||||
assert_eq!(calendar.color, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Filter, sort and group the agenda; saved filters.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
use notescraft_templates::Weekday;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::item::{Item, Kind};
|
||||
use crate::model::{Priority, Status};
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Which items to keep. Every empty field keeps everything; a filled field
|
||||
/// keeps the items that match it, and all filled fields must match.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Filter {
|
||||
/// Calendar names.
|
||||
pub calendars: Vec<String>,
|
||||
/// Events, tasks.
|
||||
pub kinds: Vec<Kind>,
|
||||
/// Statuses; an item without one counts as confirmed.
|
||||
pub statuses: Vec<Status>,
|
||||
/// Priorities; an item without one never matches.
|
||||
pub priorities: Vec<Priority>,
|
||||
/// Tags: matches when the item has any of them (case-insensitive).
|
||||
pub tags: Vec<String>,
|
||||
/// First day of the range (only the day counts); an item matches when it touches the range.
|
||||
pub from: Option<EventTime>,
|
||||
/// Last day of the range, inclusive.
|
||||
pub to: Option<EventTime>,
|
||||
/// `Some(true)` keeps items with a reminder, `Some(false)` those without.
|
||||
pub has_reminder: Option<bool>,
|
||||
}
|
||||
|
||||
/// What to order by.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SortKey {
|
||||
/// Start time.
|
||||
#[default]
|
||||
Date,
|
||||
/// Most important first.
|
||||
Priority,
|
||||
/// Title, ignoring case.
|
||||
Title,
|
||||
/// Items with a linked note first, by note path.
|
||||
LinkedNote,
|
||||
/// Creation time.
|
||||
Created,
|
||||
}
|
||||
|
||||
/// How to bucket the sorted items.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GroupKey {
|
||||
/// One group with every item.
|
||||
#[default]
|
||||
None,
|
||||
/// By day of the start.
|
||||
Day,
|
||||
/// By week, labelled with the first day of the week.
|
||||
Week,
|
||||
/// By calendar name.
|
||||
Calendar,
|
||||
/// By tag; an item with several tags is in several groups.
|
||||
Tag,
|
||||
}
|
||||
|
||||
/// Filter, sort and group together.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Query {
|
||||
/// What to keep.
|
||||
pub filter: Filter,
|
||||
/// What to order by.
|
||||
pub sort: SortKey,
|
||||
/// Reverse the order; items missing the sort field stay last.
|
||||
pub descending: bool,
|
||||
/// How to bucket.
|
||||
pub group: GroupKey,
|
||||
}
|
||||
|
||||
/// A named query the user keeps.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SavedFilter {
|
||||
/// Name shown in the list.
|
||||
pub name: String,
|
||||
/// The query.
|
||||
pub query: Query,
|
||||
}
|
||||
|
||||
/// One bucket of the result.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Group {
|
||||
/// Heading: a day, a week start, a calendar or a tag; empty for no grouping.
|
||||
pub label: String,
|
||||
/// The items, in sort order.
|
||||
pub items: Vec<Item>,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Whether `item` passes.
|
||||
#[must_use]
|
||||
pub fn matches(&self, item: &Item) -> bool {
|
||||
let tag_hit = |tag: &String| item.tags.iter().any(|own| own.eq_ignore_ascii_case(tag));
|
||||
(self.calendars.is_empty() || item.calendar.as_ref().is_some_and(|calendar| self.calendars.contains(calendar)))
|
||||
&& (self.kinds.is_empty() || self.kinds.contains(&item.kind))
|
||||
&& (self.statuses.is_empty() || self.statuses.contains(&item.status.unwrap_or(Status::Confirmed)))
|
||||
&& (self.priorities.is_empty() || item.priority.is_some_and(|priority| self.priorities.contains(&priority)))
|
||||
&& (self.tags.is_empty() || self.tags.iter().any(tag_hit))
|
||||
&& self.from.is_none_or(|from| item.last_date() >= from.date())
|
||||
&& self.to.is_none_or(|to| item.start.date() <= to.date())
|
||||
&& self.has_reminder.is_none_or(|wanted| item.has_reminder == wanted)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `query` over `items`: filter, then a stable sort, then group.
|
||||
/// `week_start` only matters for [`GroupKey::Week`].
|
||||
#[must_use]
|
||||
pub fn run_query(items: &[Item], query: &Query, week_start: Weekday) -> Vec<Group> {
|
||||
let mut kept: Vec<&Item> = items.iter().filter(|item| query.filter.matches(item)).collect();
|
||||
kept.sort_by(|a, b| compare(a, b, query.sort, query.descending));
|
||||
let mut groups: Vec<Group> = Vec::new();
|
||||
for item in kept {
|
||||
for label in labels(item, query.group, week_start) {
|
||||
match groups.iter_mut().find(|group| group.label == label) {
|
||||
Some(group) => group.items.push(item.clone()),
|
||||
None => groups.push(Group { label, items: vec![item.clone()] }),
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(query.group, GroupKey::Calendar | GroupKey::Tag) {
|
||||
groups.sort_by(|a, b| a.label.cmp(&b.label));
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Orders by `key`; a missing field sorts last whichever way it goes, and ties
|
||||
/// fall back to start time, then title.
|
||||
fn compare(a: &Item, b: &Item, key: SortKey, descending: bool) -> Ordering {
|
||||
let by_key = match key {
|
||||
SortKey::Date => Some(a.start.cmp(&b.start)).map(|o| flip(o, descending)),
|
||||
SortKey::Title => Some(flip(a.title.to_lowercase().cmp(&b.title.to_lowercase()), descending)),
|
||||
SortKey::Priority => optional(a.priority, b.priority, descending, true),
|
||||
SortKey::LinkedNote => optional(a.note.as_deref(), b.note.as_deref(), descending, false),
|
||||
SortKey::Created => optional(a.created, b.created, descending, false),
|
||||
};
|
||||
by_key.unwrap_or(Ordering::Equal).then_with(|| a.start.cmp(&b.start)).then_with(|| a.title.cmp(&b.title))
|
||||
}
|
||||
|
||||
fn flip(order: Ordering, descending: bool) -> Ordering {
|
||||
if descending { order.reverse() } else { order }
|
||||
}
|
||||
|
||||
/// Compares two optional values with the missing one last. `high_first` makes
|
||||
/// the larger value sort first when not descending (priority).
|
||||
fn optional<T: Ord>(a: Option<T>, b: Option<T>, descending: bool, high_first: bool) -> Option<Ordering> {
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => Some(flip(a.cmp(&b), descending != high_first)),
|
||||
(Some(_), None) => Some(Ordering::Less),
|
||||
(None, Some(_)) => Some(Ordering::Greater),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn labels(item: &Item, group: GroupKey, week_start: Weekday) -> Vec<String> {
|
||||
match group {
|
||||
GroupKey::None => vec![String::new()],
|
||||
GroupKey::Day => vec![EventTime::Day(item.start.date()).to_string()],
|
||||
GroupKey::Week => {
|
||||
let date = item.start.date();
|
||||
let back = i32::from((date.weekday().index() + 7 - week_start.index()) % 7);
|
||||
vec![EventTime::Day(date.add_days(-back).unwrap_or(date)).to_string()]
|
||||
}
|
||||
GroupKey::Calendar => vec![item.calendar.clone().unwrap_or_default()],
|
||||
GroupKey::Tag if item.tags.is_empty() => vec![String::new()],
|
||||
GroupKey::Tag => item.tags.clone(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
//! Quick add: an English phrase becomes an event.
|
||||
//!
|
||||
//! Recognised, in any order: a day (`today`, `tomorrow`, `friday`,
|
||||
//! `next friday`, `2026-10-25`, `oct 25`, `25 oct`, optionally after `on`), a
|
||||
//! time (`at 9`, `3pm`, `14:30`, `noon`), a range (`9-10:30`, `from 9 to 10`),
|
||||
//! a length (`for 90 min`, `for 2 hours`), a repeat (`every day`,
|
||||
//! `every 2 weeks`, `every monday`, `every weekday`, `daily`), a reminder
|
||||
//! (`remind 10 min`), `#tag` and `@calendar`. What is left is the title.
|
||||
//! A part that looks like one of these but is wrong is an error naming it;
|
||||
//! nothing is guessed.
|
||||
|
||||
use notescraft_templates::{Date, DateTime, Weekday};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::model::{Event, Reminder};
|
||||
use crate::recur::{Freq, Rule};
|
||||
use crate::time::EventTime;
|
||||
|
||||
mod clock;
|
||||
|
||||
use clock::{add_minutes, clock, clock_inheriting, clock_shaped, duration};
|
||||
|
||||
/// Longest phrase read.
|
||||
const MAX_PHRASE_BYTES: usize = 500;
|
||||
|
||||
/// Most words in a phrase.
|
||||
const MAX_WORDS: usize = 100;
|
||||
|
||||
const MONTHS: [&str; 12] = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
||||
const WEEKDAYS: [(&str, &str, Weekday); 7] = [
|
||||
("sunday", "sun", Weekday::Sunday),
|
||||
("monday", "mon", Weekday::Monday),
|
||||
("tuesday", "tue", Weekday::Tuesday),
|
||||
("wednesday", "wed", Weekday::Wednesday),
|
||||
("thursday", "thu", Weekday::Thursday),
|
||||
("friday", "fri", Weekday::Friday),
|
||||
("saturday", "sat", Weekday::Saturday),
|
||||
];
|
||||
|
||||
/// Reads `text` into an event, with `today` as the base for relative days.
|
||||
///
|
||||
/// No day means today. A time with no length leaves the event without an end.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::QuickAdd`] naming the part that cannot be read, or when no title
|
||||
/// is left.
|
||||
pub fn parse_quick(text: &str, today: Date) -> Result<Event> {
|
||||
if text.len() > MAX_PHRASE_BYTES {
|
||||
return Err(bad("", format!("the phrase is {} bytes, the most allowed is {MAX_PHRASE_BYTES}", text.len())));
|
||||
}
|
||||
let words: Vec<&str> = text.split_whitespace().collect();
|
||||
if words.len() > MAX_WORDS {
|
||||
return Err(bad("", format!("{} words, the most allowed is {MAX_WORDS}", words.len())));
|
||||
}
|
||||
let mut phrase = Phrase {
|
||||
words,
|
||||
at: 0,
|
||||
today,
|
||||
day: None,
|
||||
clock: None,
|
||||
end_clock: None,
|
||||
minutes: None,
|
||||
rule: None,
|
||||
reminders: vec![],
|
||||
tags: vec![],
|
||||
calendar: None,
|
||||
title: vec![],
|
||||
};
|
||||
while phrase.at < phrase.words.len() {
|
||||
phrase.step()?;
|
||||
}
|
||||
phrase.finish()
|
||||
}
|
||||
|
||||
fn bad(part: &str, reason: impl Into<String>) -> Error {
|
||||
Error::QuickAdd { part: part.chars().take(40).collect(), reason: reason.into() }
|
||||
}
|
||||
|
||||
struct Phrase<'a> {
|
||||
words: Vec<&'a str>,
|
||||
at: usize,
|
||||
today: Date,
|
||||
day: Option<Date>,
|
||||
clock: Option<(u8, u8)>,
|
||||
end_clock: Option<(u8, u8)>,
|
||||
minutes: Option<u32>,
|
||||
rule: Option<Rule>,
|
||||
reminders: Vec<Reminder>,
|
||||
tags: Vec<String>,
|
||||
calendar: Option<String>,
|
||||
title: Vec<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> Phrase<'a> {
|
||||
fn word(&self, offset: usize) -> Option<&'a str> {
|
||||
self.words.get(self.at + offset).copied()
|
||||
}
|
||||
|
||||
fn lower(&self, offset: usize) -> String {
|
||||
self.word(offset).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn step(&mut self) -> Result<()> {
|
||||
let (raw, word) = (self.words[self.at], self.lower(0));
|
||||
let used = if let Some(tag) = raw.strip_prefix('#') {
|
||||
self.tags.push(name(tag, raw)?);
|
||||
1
|
||||
} else if let Some(calendar) = raw.strip_prefix('@') {
|
||||
self.calendar = Some(name(calendar, raw)?);
|
||||
1
|
||||
} else if let Some(used) = self.day_words(&word)? {
|
||||
used
|
||||
} else if let Some(used) = self.time_words(&word)? {
|
||||
used
|
||||
} else if let Some(used) = self.length_words(&word)? {
|
||||
used
|
||||
} else if let Some(used) = self.repeat_words(&word)? {
|
||||
used
|
||||
} else if let Some(used) = self.remind_words(&word)? {
|
||||
used
|
||||
} else {
|
||||
self.title.push(raw);
|
||||
1
|
||||
};
|
||||
self.at += used;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_day(&mut self, day: Date, part: &str) -> Result<()> {
|
||||
if self.day.replace(day).is_some() {
|
||||
return Err(bad(part, "a second day"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Day words; returns how many words were used.
|
||||
fn day_words(&mut self, word: &str) -> Result<Option<usize>> {
|
||||
let (mut used, mut first) = (0, word.to_owned());
|
||||
if word == "on" && self.day_ahead(&self.lower(1)) {
|
||||
(used, first) = (1, self.lower(1));
|
||||
}
|
||||
if !(self.day_ahead(&first) || used == 0 && first == "next" && weekday_of(&self.lower(1)).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let part = self.words[self.at..(self.at + used + 2).min(self.words.len())].join(" ");
|
||||
let day = match first.as_str() {
|
||||
"today" => self.today,
|
||||
"tomorrow" => shift(self.today, 1)?,
|
||||
"next" => {
|
||||
used += 1;
|
||||
next_weekday(self.today, weekday_of(&self.lower(used)), true)?
|
||||
}
|
||||
w if weekday_of(w).is_some() => next_weekday(self.today, weekday_of(w), false)?,
|
||||
w if iso_shaped(w) => match w.parse::<EventTime>() {
|
||||
Ok(EventTime::Day(date)) => date,
|
||||
_ => return Err(bad(w, "not a real date; write YYYY-MM-DD")),
|
||||
},
|
||||
w => {
|
||||
let (month, day_number) = match (month_of(w), number_of(&self.lower(used + 1))) {
|
||||
(Some(month), Some(day)) => (month, day),
|
||||
_ => (month_of(&self.lower(used + 1)).ok_or_else(|| bad(w, "unreadable date"))?, number_of(w).ok_or_else(|| bad(w, "unreadable date"))?),
|
||||
};
|
||||
used += 1;
|
||||
let in_year = |year| Date::new(year, month, u8::try_from(day_number).unwrap_or(0));
|
||||
match in_year(self.today.year()) {
|
||||
Ok(date) if date >= self.today => date,
|
||||
_ => in_year(self.today.year() + 1).map_err(|_| bad(&part, "not a real date"))?,
|
||||
}
|
||||
}
|
||||
};
|
||||
self.set_day(day, &part)?;
|
||||
Ok(Some(used + 1))
|
||||
}
|
||||
|
||||
/// Whether `word` starts a day expression (not `on`, not `next`).
|
||||
fn day_ahead(&self, word: &str) -> bool {
|
||||
matches!(word, "today" | "tomorrow")
|
||||
|| weekday_of(word).is_some()
|
||||
|| iso_shaped(word)
|
||||
|| (month_of(word).is_some() && number_of(&self.lower(1 + usize::from(self.lower(0) == "on"))).is_some())
|
||||
|| (number_of(word).is_some() && month_of(&self.lower(1 + usize::from(self.lower(0) == "on"))).is_some())
|
||||
}
|
||||
|
||||
fn time_words(&mut self, word: &str) -> Result<Option<usize>> {
|
||||
let (explicit, token) = match word {
|
||||
"at" => match self.word(1) {
|
||||
Some(next) => (true, next.to_ascii_lowercase()),
|
||||
None => return Err(bad("at", "a time must follow")),
|
||||
},
|
||||
"from" if self.lower(2) == "to" && clock_shaped(&self.lower(1), true) => {
|
||||
let start = clock(&self.lower(1), true)?;
|
||||
let end = clock(&self.lower(3), true).map_err(|_| bad(&self.lower(3), "unreadable end time"))?;
|
||||
return self.set_clock(start, Some(end), "from").map(|()| Some(4));
|
||||
}
|
||||
other if clock_shaped(other, false) => (false, other.to_owned()),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
let used = 1 + usize::from(explicit);
|
||||
if let Some((from, to)) = token.split_once('-').filter(|_| clock_shaped(&token, true)) {
|
||||
let end = clock(to, true)?;
|
||||
let start = clock_inheriting(from, to, end)?;
|
||||
return self.set_clock(start, Some(end), &token).map(|()| Some(used));
|
||||
}
|
||||
self.set_clock(clock(&token, explicit)?, None, &token).map(|()| Some(used))
|
||||
}
|
||||
|
||||
fn set_clock(&mut self, start: (u8, u8), end: Option<(u8, u8)>, part: &str) -> Result<()> {
|
||||
if self.clock.replace(start).is_some() {
|
||||
return Err(bad(part, "a second time"));
|
||||
}
|
||||
self.end_clock = end;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn length_words(&mut self, word: &str) -> Result<Option<usize>> {
|
||||
if word != "for" {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(first) = self.word(1) else { return Err(bad("for", "a length must follow, like 1h or 90 min")) };
|
||||
let (minutes, used) = duration(first, self.word(2))?;
|
||||
if self.minutes.replace(minutes).is_some() {
|
||||
return Err(bad(first, "a second length"));
|
||||
}
|
||||
Ok(Some(1 + used))
|
||||
}
|
||||
|
||||
fn repeat_words(&mut self, word: &str) -> Result<Option<usize>> {
|
||||
let (rule, used) = match word {
|
||||
"daily" | "weekly" | "monthly" | "yearly" => (word.parse::<Rule>()?, 1),
|
||||
"every" => self.every()?,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
if self.rule.replace(rule).is_some() {
|
||||
return Err(bad(word, "a second repeat"));
|
||||
}
|
||||
Ok(Some(used))
|
||||
}
|
||||
|
||||
fn every(&self) -> Result<(Rule, usize)> {
|
||||
let part = self.words[self.at..(self.at + 3).min(self.words.len())].join(" ");
|
||||
let (interval, offset) = number_of(&self.lower(1)).map_or((1, 1), |n| (n, 2));
|
||||
let unit = self.lower(offset);
|
||||
let freq = match unit.trim_end_matches('s') {
|
||||
"day" => Some(Freq::Daily),
|
||||
"week" => Some(Freq::Weekly),
|
||||
"month" => Some(Freq::Monthly),
|
||||
"year" => Some(Freq::Yearly),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(freq) = freq {
|
||||
let rule = Rule { interval, ..Rule::every(freq) };
|
||||
return rule.validate().map(|()| (rule, offset + 1)).map_err(|_| bad(&part, "the interval must be from 1 to 1000"));
|
||||
}
|
||||
if interval == 1 && offset == 1 {
|
||||
let days: Vec<Weekday> = match unit.as_str() {
|
||||
"weekday" | "weekdays" => vec![Weekday::Monday, Weekday::Tuesday, Weekday::Wednesday, Weekday::Thursday, Weekday::Friday],
|
||||
other => other.split(',').map(weekday_of).collect::<Option<Vec<_>>>().unwrap_or_default(),
|
||||
};
|
||||
if !days.is_empty() {
|
||||
return Ok((Rule { weekdays: days, ..Rule::every(Freq::Weekly) }, 2));
|
||||
}
|
||||
}
|
||||
Err(bad(&part, "write every day, every 2 weeks, every monday, every weekday"))
|
||||
}
|
||||
|
||||
fn remind_words(&mut self, word: &str) -> Result<Option<usize>> {
|
||||
if word != "remind" {
|
||||
return Ok(None);
|
||||
}
|
||||
let skip = usize::from(self.lower(1) == "me");
|
||||
let Some(first) = self.word(1 + skip) else { return Err(bad("remind", "a time must follow, like 10 min")) };
|
||||
let (minutes, used) = duration(first, self.word(2 + skip))?;
|
||||
self.reminders.push(Reminder { minutes_before: minutes });
|
||||
let before = usize::from(self.lower(1 + skip + used) == "before");
|
||||
Ok(Some(1 + skip + used + before))
|
||||
}
|
||||
|
||||
fn finish(self) -> Result<Event> {
|
||||
if self.title.is_empty() {
|
||||
return Err(bad("", "no title left; write what the event is"));
|
||||
}
|
||||
let day = self.day.unwrap_or(self.today);
|
||||
let (start, end) = match self.clock {
|
||||
None if self.minutes.is_some() => return Err(bad("for", "a length needs a time")),
|
||||
None => (EventTime::Day(day), None),
|
||||
Some((hour, minute)) => {
|
||||
let start = DateTime::new(day, hour, minute, 0).map_err(|e| bad("", e.to_string()))?;
|
||||
let end = match (self.end_clock, self.minutes) {
|
||||
(Some((h, m)), _) => Some(add_minutes(DateTime::new(day, h, m, 0).map_err(|e| bad("", e.to_string()))?, 0)?),
|
||||
(None, Some(minutes)) => Some(add_minutes(start, minutes)?),
|
||||
(None, None) => None,
|
||||
};
|
||||
if end.is_some_and(|end| end <= start) {
|
||||
return Err(bad("", "the end must be after the start"));
|
||||
}
|
||||
(EventTime::Minute(start), end.map(EventTime::Minute))
|
||||
}
|
||||
};
|
||||
Ok(Event {
|
||||
title: self.title.join(" "),
|
||||
start,
|
||||
end,
|
||||
tz: None,
|
||||
repeat: self.rule.map(|rule| rule.to_string()),
|
||||
exceptions: vec![],
|
||||
calendar: self.calendar,
|
||||
reminders: self.reminders,
|
||||
tags: self.tags,
|
||||
priority: None,
|
||||
status: None,
|
||||
link: None,
|
||||
note: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn name(text: &str, raw: &str) -> Result<String> {
|
||||
let valid = !text.is_empty() && text.len() <= 64 && text.chars().all(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '/'));
|
||||
valid.then(|| text.to_owned()).ok_or_else(|| bad(raw, "use letters, digits, - _ or /"))
|
||||
}
|
||||
|
||||
fn shift(day: Date, days: i32) -> Result<Date> {
|
||||
day.add_days(days).map_err(|e| bad("", e.to_string()))
|
||||
}
|
||||
|
||||
fn weekday_of(word: &str) -> Option<Weekday> {
|
||||
WEEKDAYS
|
||||
.iter()
|
||||
.find(|(long, short, _)| {
|
||||
word == *long || word == *short || (*short == "tue" && word == "tues") || (*short == "thu" && (word == "thur" || word == "thurs"))
|
||||
})
|
||||
.map(|(.., day)| *day)
|
||||
}
|
||||
|
||||
fn month_of(word: &str) -> Option<u8> {
|
||||
let word = word.trim_end_matches('.');
|
||||
let index = MONTHS.iter().position(|short| word == *short || (word.len() > 3 && word.starts_with(short) && FULL_MONTHS.contains(&word)))?;
|
||||
u8::try_from(index + 1).ok()
|
||||
}
|
||||
|
||||
const FULL_MONTHS: [&str; 12] = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"];
|
||||
|
||||
/// A day number with an optional `st`/`nd`/`rd`/`th`.
|
||||
fn number_of(word: &str) -> Option<u32> {
|
||||
let digits = word.trim_end_matches("st").trim_end_matches("nd").trim_end_matches("rd").trim_end_matches("th");
|
||||
(!digits.is_empty() && digits.len() <= 4 && digits.bytes().all(|b| b.is_ascii_digit())).then(|| digits.parse().ok()).flatten()
|
||||
}
|
||||
|
||||
fn iso_shaped(word: &str) -> bool {
|
||||
word.len() == 10 && word.bytes().enumerate().all(|(i, b)| if i == 4 || i == 7 { b == b'-' } else { b.is_ascii_digit() })
|
||||
}
|
||||
|
||||
fn next_weekday(today: Date, wanted: Option<Weekday>, strictly_after: bool) -> Result<Date> {
|
||||
let wanted = wanted.ok_or_else(|| bad("next", "a weekday must follow"))?;
|
||||
let ahead = i32::from((wanted.index() + 7 - today.weekday().index()) % 7);
|
||||
shift(today, if ahead == 0 && strictly_after { 7 } else { ahead })
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Clock, range and length words of the quick-add phrase.
|
||||
|
||||
use notescraft_templates::DateTime;
|
||||
|
||||
use super::{bad, iso_shaped, shift};
|
||||
use crate::error::Result;
|
||||
|
||||
/// Whether `word` looks like a clock: `H:MM`, `Ham`, `H:MMpm`, `noon`,
|
||||
/// `midnight`, or (when `bare_ok`) a plain hour; with `-` it is a range.
|
||||
pub(super) fn clock_shaped(word: &str, bare_ok: bool) -> bool {
|
||||
if matches!(word, "noon" | "midnight") {
|
||||
return true;
|
||||
}
|
||||
if let Some((from, to)) = word.split_once('-') {
|
||||
return clock_shaped(from, true) && clock_shaped(to, true) && !iso_shaped(word);
|
||||
}
|
||||
let core = word.trim_end_matches("am").trim_end_matches("pm");
|
||||
let marked = core.len() != word.len();
|
||||
!core.is_empty()
|
||||
&& core.bytes().all(|b| b.is_ascii_digit() || b == b':')
|
||||
&& core.bytes().any(|b| b.is_ascii_digit())
|
||||
&& (marked || core.contains(':') || bare_ok)
|
||||
}
|
||||
|
||||
/// Reads one clock. `explicit` allows a plain hour (`at 9`).
|
||||
pub(super) fn clock(word: &str, explicit: bool) -> Result<(u8, u8)> {
|
||||
let fail = |reason: &str| bad(word, reason.to_owned());
|
||||
match word {
|
||||
"noon" => return Ok((12, 0)),
|
||||
"midnight" => return Ok((0, 0)),
|
||||
_ => {}
|
||||
}
|
||||
let (core, meridiem) = match (word.strip_suffix("am"), word.strip_suffix("pm")) {
|
||||
(Some(core), _) => (core, Some(false)),
|
||||
(_, Some(core)) => (core, Some(true)),
|
||||
_ => (word, None),
|
||||
};
|
||||
let (hour, minute) = match core.split_once(':') {
|
||||
Some((h, m)) if m.len() == 2 => (h, m),
|
||||
Some(_) => return Err(fail("minutes need two digits, like 9:05")),
|
||||
None if meridiem.is_some() || explicit => (core, "00"),
|
||||
None => return Err(fail("write the time like 9:30, 3pm or at 9")),
|
||||
};
|
||||
let (Ok(mut hour), Ok(minute)) = (hour.parse::<u8>(), minute.parse::<u8>()) else { return Err(fail("not a time")) };
|
||||
if minute > 59 {
|
||||
return Err(fail("minutes go from 00 to 59"));
|
||||
}
|
||||
match meridiem {
|
||||
Some(pm) => {
|
||||
if !(1..=12).contains(&hour) {
|
||||
return Err(fail("with am or pm the hour goes from 1 to 12"));
|
||||
}
|
||||
hour = hour % 12 + if pm { 12 } else { 0 };
|
||||
}
|
||||
None if hour > 23 => return Err(fail("hours go from 0 to 23")),
|
||||
None => {}
|
||||
}
|
||||
Ok((hour, minute))
|
||||
}
|
||||
|
||||
/// The start of a range like `9-10am`: a start with no am or pm takes the end's.
|
||||
pub(super) fn clock_inheriting(from: &str, to: &str, end: (u8, u8)) -> Result<(u8, u8)> {
|
||||
let marker = if to.ends_with("pm") {
|
||||
"pm"
|
||||
} else if to.ends_with("am") {
|
||||
"am"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let has_own = from.ends_with("am") || from.ends_with("pm");
|
||||
let start = if has_own || marker.is_empty() { clock(from, true)? } else { clock(&format!("{from}{marker}"), true)? };
|
||||
// `11-1pm` means 11am to 1pm.
|
||||
if start >= end && !has_own && marker == "pm" { clock(&format!("{from}am"), true) } else { Ok(start) }
|
||||
}
|
||||
|
||||
/// Minutes from `5`, `5m`, `90min`, `1h`, `2 hours` (the unit may be the next word).
|
||||
/// Returns the minutes and how many words were used.
|
||||
pub(super) fn duration(first: &str, second: Option<&str>) -> Result<(u32, usize)> {
|
||||
let first_lower = first.to_ascii_lowercase();
|
||||
let split = first_lower.find(|c: char| !c.is_ascii_digit()).unwrap_or(first_lower.len());
|
||||
let (digits, glued) = first_lower.split_at(split);
|
||||
let amount: u32 = digits.parse().map_err(|_| bad(first, "write a length like 1h, 90 min or 2 hours"))?;
|
||||
let (unit, used) = if glued.is_empty() { (second.unwrap_or("").to_ascii_lowercase(), 2) } else { (glued.to_owned(), 1) };
|
||||
let per = match unit.as_str() {
|
||||
"m" | "min" | "mins" | "minute" | "minutes" => 1,
|
||||
"h" | "hr" | "hrs" | "hour" | "hours" => 60,
|
||||
"d" | "day" | "days" => 1440,
|
||||
_ => return Err(bad(format!("{first} {}", second.unwrap_or("")).trim(), "write a length like 1h, 90 min or 2 hours")),
|
||||
};
|
||||
match amount.checked_mul(per).filter(|minutes| (1..=14 * 1440).contains(minutes)) {
|
||||
Some(minutes) => Ok((minutes, used)),
|
||||
None => Err(bad(first, "a length goes from 1 minute to 14 days")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn add_minutes(start: DateTime, minutes: u32) -> Result<DateTime> {
|
||||
let total = u32::from(start.hour()) * 60 + u32::from(start.minute()) + minutes;
|
||||
let date = shift(start.date(), i32::try_from(total / 1440).map_err(|_| bad("", "too long"))?)?;
|
||||
DateTime::new(date, u8::try_from(total % 1440 / 60).unwrap_or(0), u8::try_from(total % 60).unwrap_or(0), 0).map_err(|e| bad("", e.to_string()))
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
//! Repeat rules: a subset of RFC 5545 (`FREQ`, `INTERVAL`, `BYDAY` for weekly,
|
||||
//! `UNTIL`, `COUNT`) and their expansion into occurrences.
|
||||
//!
|
||||
//! Occurrences are wall-clock times in the event's own zone, so a daylight
|
||||
//! saving change never moves "every Monday at 09:30". The text form is
|
||||
//! `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=2026-12-31`; the shorthands
|
||||
//! `daily`, `weekly`, `monthly` and `yearly` also parse.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use notescraft_templates::{Date, DateTime, Weekday};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Most occurrences one expansion returns.
|
||||
pub const MAX_OCCURRENCES: usize = 10_000;
|
||||
|
||||
/// Most periods one expansion walks.
|
||||
const MAX_STEPS: usize = 200_000;
|
||||
|
||||
/// Largest `INTERVAL`.
|
||||
const MAX_INTERVAL: u32 = 1000;
|
||||
|
||||
/// Largest `COUNT`.
|
||||
const MAX_COUNT: u32 = 100_000;
|
||||
|
||||
/// Longest rule text parsed.
|
||||
const MAX_RULE_BYTES: usize = 256;
|
||||
|
||||
/// How often a series repeats.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Freq {
|
||||
/// Every `interval` days.
|
||||
Daily,
|
||||
/// Every `interval` weeks.
|
||||
Weekly,
|
||||
/// Every `interval` months, on the start's day of the month.
|
||||
Monthly,
|
||||
/// Every `interval` years, on the start's month and day.
|
||||
Yearly,
|
||||
}
|
||||
|
||||
/// A repeat rule.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Rule {
|
||||
/// How often it repeats.
|
||||
pub freq: Freq,
|
||||
/// Every how many periods, `1..=1000`.
|
||||
pub interval: u32,
|
||||
/// Weekly only: the weekdays it falls on; empty means the start's weekday.
|
||||
pub weekdays: Vec<Weekday>,
|
||||
/// Last day an occurrence may fall on, inclusive. Not with `count`.
|
||||
pub until: Option<Date>,
|
||||
/// Number of occurrences in all, counted from the start. Not with `until`.
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
/// A series cut in two at a day (the "this and following" edit).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Split {
|
||||
/// The rule of the part before the cut; it keeps the original start.
|
||||
pub head: Rule,
|
||||
/// The first occurrence on or after the cut: the start of the second part.
|
||||
pub tail_start: EventTime,
|
||||
/// The rule of the second part.
|
||||
pub tail: Rule,
|
||||
}
|
||||
|
||||
impl Rule {
|
||||
/// A plain rule of `freq` every period, with no end.
|
||||
#[must_use]
|
||||
pub const fn every(freq: Freq) -> Self {
|
||||
Self { freq, interval: 1, weekdays: Vec::new(), until: None, count: None }
|
||||
}
|
||||
|
||||
/// Checks the interval and count bounds the text form enforces.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::InvalidRule`] when `interval` or `count` is out of range.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !(1..=MAX_INTERVAL).contains(&self.interval) || self.count.is_some_and(|count| !(1..=MAX_COUNT).contains(&count)) {
|
||||
return Err(bad("INTERVAL or COUNT out of range"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The occurrences from `from` to `to` (inclusive days) of a series that
|
||||
/// starts at `start`, without the days in `skip`, oldest first.
|
||||
///
|
||||
/// The `count` of the rule is counted from `start` and skipped days still
|
||||
/// count, as in RFC 5545.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::TooMany`] past [`MAX_OCCURRENCES`] results or the walk cap.
|
||||
pub fn occurrences(&self, start: EventTime, skip: &[EventTime], from: Date, to: Date) -> Result<Vec<EventTime>> {
|
||||
let first = start.date();
|
||||
let last = self.until.map_or(to, |until| until.min(to));
|
||||
let mut out = Vec::new();
|
||||
let (mut produced, mut period) = (0_u32, self.first_period(first, from));
|
||||
for _ in 0..MAX_STEPS {
|
||||
let Some(dates) = self.period(first, period) else { return Ok(out) };
|
||||
if dates.starts_after(last) {
|
||||
return Ok(out);
|
||||
}
|
||||
for date in dates.days {
|
||||
if self.count.is_some_and(|count| produced >= count) || date > last {
|
||||
return Ok(out);
|
||||
}
|
||||
produced = produced.saturating_add(1);
|
||||
if date < from || skip.iter().any(|skipped| skipped.date() == date) {
|
||||
continue;
|
||||
}
|
||||
if out.len() >= MAX_OCCURRENCES {
|
||||
return Err(Error::TooMany(MAX_OCCURRENCES));
|
||||
}
|
||||
out.push(at_time_of(start, date)?);
|
||||
}
|
||||
period += 1;
|
||||
}
|
||||
Err(Error::TooMany(MAX_STEPS))
|
||||
}
|
||||
|
||||
/// Cuts the series at day `at`: the head ends before it, the tail starts
|
||||
/// at the first occurrence on or after it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::InvalidRule`] when nothing falls before `at` (edit the whole
|
||||
/// series instead) or nothing falls on or after it.
|
||||
pub fn split(&self, start: EventTime, at: Date) -> Result<Split> {
|
||||
let before = match at.add_days(-1) {
|
||||
Ok(day) if day >= start.date() => self.occurrences(start, &[], start.date(), day)?.len(),
|
||||
_ => 0,
|
||||
};
|
||||
if before == 0 {
|
||||
return Err(Error::InvalidRule("no occurrence before the cut; edit all of the series".to_owned()));
|
||||
}
|
||||
let tail_start = self.first_from(start, at)?.ok_or_else(|| Error::InvalidRule("no occurrence on or after the cut".to_owned()))?;
|
||||
let mut head = self.clone();
|
||||
let mut tail = self.clone();
|
||||
if let Some(count) = self.count {
|
||||
let before = u32::try_from(before).unwrap_or(u32::MAX);
|
||||
head.count = Some(before);
|
||||
tail.count = Some(count.saturating_sub(before).max(1));
|
||||
} else {
|
||||
head.until = Some(at.add_days(-1).map_err(|error| date_error(&error))?);
|
||||
}
|
||||
Ok(Split { head, tail_start, tail })
|
||||
}
|
||||
|
||||
/// The first occurrence on or after `at`, looking ahead in windows that
|
||||
/// grow until the rule ends or the calendar does.
|
||||
fn first_from(&self, start: EventTime, at: Date) -> Result<Option<EventTime>> {
|
||||
let horizon = self.until.unwrap_or(Date::new(9999, 12, 31).map_err(|error| date_error(&error))?);
|
||||
let mut span = 400_i32;
|
||||
let mut from = at;
|
||||
while from <= horizon {
|
||||
let to = from.add_days(span).map_or(horizon, |day| day.min(horizon));
|
||||
if let Some(found) = self.occurrences(start, &[], from, to)?.first() {
|
||||
return Ok(Some(*found));
|
||||
}
|
||||
let Ok(next) = to.add_days(1) else { break };
|
||||
(from, span) = (next, span.saturating_mul(4));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The period to start walking at: 0 when a `count` forces a walk from the
|
||||
/// start, else a jump close to `from` (never past it).
|
||||
fn first_period(&self, first: Date, from: Date) -> u32 {
|
||||
if self.count.is_some() || from <= first {
|
||||
return 0;
|
||||
}
|
||||
let gap = i64::from(from.to_days_since_epoch()) - i64::from(first.to_days_since_epoch());
|
||||
let unit = match self.freq {
|
||||
Freq::Daily => 1,
|
||||
Freq::Weekly => 7,
|
||||
Freq::Monthly | Freq::Yearly => return 0,
|
||||
};
|
||||
let periods = gap / (unit * i64::from(self.interval));
|
||||
u32::try_from(periods.saturating_sub(1).max(0)).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The days of period number `index`, or `None` past the calendar.
|
||||
fn period(&self, first: Date, index: u32) -> Option<Period> {
|
||||
let step = i64::from(index) * i64::from(self.interval);
|
||||
match self.freq {
|
||||
Freq::Daily => {
|
||||
let day = first.add_days(i32::try_from(step).ok()?).ok()?;
|
||||
Some(Period { begins: day, days: vec![day] })
|
||||
}
|
||||
Freq::Weekly => {
|
||||
let monday = first.add_days(-monday_offset(first.weekday())).ok()?;
|
||||
let begins = monday.add_days(i32::try_from(step.checked_mul(7)?).ok()?).ok()?;
|
||||
let mut offsets: Vec<i32> =
|
||||
if self.weekdays.is_empty() { vec![monday_offset(first.weekday())] } else { self.weekdays.iter().map(|d| monday_offset(*d)).collect() };
|
||||
offsets.sort_unstable();
|
||||
offsets.dedup();
|
||||
let days = offsets.into_iter().filter_map(|offset| begins.add_days(offset).ok()).filter(|day| *day >= first).collect();
|
||||
Some(Period { begins, days })
|
||||
}
|
||||
Freq::Monthly => {
|
||||
let total = i64::from(first.year()) * 12 + i64::from(first.month() - 1) + step;
|
||||
let (year, month) = (i32::try_from(total.div_euclid(12)).ok()?, u8::try_from(total.rem_euclid(12) + 1).ok()?);
|
||||
Some(Period { begins: Date::new(year, month, 1).ok()?, days: Date::new(year, month, first.day()).into_iter().collect() })
|
||||
}
|
||||
Freq::Yearly => {
|
||||
let year = i32::try_from(i64::from(first.year()) + step).ok()?;
|
||||
Some(Period { begins: Date::new(year, 1, 1).ok()?, days: Date::new(year, first.month(), first.day()).into_iter().collect() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One period of a series: where it begins and the occurrence days inside it.
|
||||
struct Period {
|
||||
begins: Date,
|
||||
days: Vec<Date>,
|
||||
}
|
||||
|
||||
impl Period {
|
||||
fn starts_after(&self, last: Date) -> bool {
|
||||
self.begins > last
|
||||
}
|
||||
}
|
||||
|
||||
fn monday_offset(weekday: Weekday) -> i32 {
|
||||
i32::from((weekday.index() + 6) % 7)
|
||||
}
|
||||
|
||||
fn date_error(error: ¬escraft_templates::Error) -> Error {
|
||||
Error::InvalidRule(error.to_string())
|
||||
}
|
||||
|
||||
/// `date` at the time of day of `start`.
|
||||
fn at_time_of(start: EventTime, date: Date) -> Result<EventTime> {
|
||||
match start {
|
||||
EventTime::Day(_) => Ok(EventTime::Day(date)),
|
||||
EventTime::Minute(at) => DateTime::new(date, at.hour(), at.minute(), 0).map(EventTime::Minute).map_err(|error| date_error(&error)),
|
||||
}
|
||||
}
|
||||
|
||||
const DAY_CODES: [(&str, Weekday); 7] = [
|
||||
("MO", Weekday::Monday),
|
||||
("TU", Weekday::Tuesday),
|
||||
("WE", Weekday::Wednesday),
|
||||
("TH", Weekday::Thursday),
|
||||
("FR", Weekday::Friday),
|
||||
("SA", Weekday::Saturday),
|
||||
("SU", Weekday::Sunday),
|
||||
];
|
||||
|
||||
fn bad(reason: impl Into<String>) -> Error {
|
||||
Error::InvalidRule(reason.into())
|
||||
}
|
||||
|
||||
impl FromStr for Rule {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(text: &str) -> Result<Self> {
|
||||
if text.len() > MAX_RULE_BYTES {
|
||||
return Err(bad(format!("{} bytes, the most allowed is {MAX_RULE_BYTES}", text.len())));
|
||||
}
|
||||
let shorthand = match text.trim().to_ascii_lowercase().as_str() {
|
||||
"daily" => Some(Freq::Daily),
|
||||
"weekly" => Some(Freq::Weekly),
|
||||
"monthly" => Some(Freq::Monthly),
|
||||
"yearly" => Some(Freq::Yearly),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(freq) = shorthand {
|
||||
return Ok(Self::every(freq));
|
||||
}
|
||||
let mut rule = Self::every(Freq::Daily);
|
||||
let mut freq = None;
|
||||
for part in text.split(';') {
|
||||
let (key, value) = part.split_once('=').ok_or_else(|| bad(format!("`{part}` is not KEY=VALUE")))?;
|
||||
match key.trim().to_ascii_uppercase().as_str() {
|
||||
"FREQ" => freq = Some(parse_freq(value)?),
|
||||
"INTERVAL" => rule.interval = number(value, 1, MAX_INTERVAL, "INTERVAL")?,
|
||||
"COUNT" => rule.count = Some(number(value, 1, MAX_COUNT, "COUNT")?),
|
||||
"UNTIL" => rule.until = Some(parse_until(value)?),
|
||||
"BYDAY" => rule.weekdays = parse_days(value)?,
|
||||
other => return Err(bad(format!("`{other}` is not supported"))),
|
||||
}
|
||||
}
|
||||
rule.freq = freq.ok_or_else(|| bad("FREQ is missing"))?;
|
||||
if rule.until.is_some() && rule.count.is_some() {
|
||||
return Err(bad("UNTIL and COUNT cannot be used together"));
|
||||
}
|
||||
if !rule.weekdays.is_empty() && rule.freq != Freq::Weekly {
|
||||
return Err(bad("BYDAY is supported for weekly rules only"));
|
||||
}
|
||||
Ok(rule)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_freq(value: &str) -> Result<Freq> {
|
||||
match value.trim().to_ascii_uppercase().as_str() {
|
||||
"DAILY" => Ok(Freq::Daily),
|
||||
"WEEKLY" => Ok(Freq::Weekly),
|
||||
"MONTHLY" => Ok(Freq::Monthly),
|
||||
"YEARLY" => Ok(Freq::Yearly),
|
||||
other => Err(bad(format!("FREQ `{other}` is not supported"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn number(value: &str, min: u32, max: u32, name: &str) -> Result<u32> {
|
||||
value.trim().parse::<u32>().ok().filter(|n| (min..=max).contains(n)).ok_or_else(|| bad(format!("{name} must be a whole number from {min} to {max}")))
|
||||
}
|
||||
|
||||
fn parse_until(value: &str) -> Result<Date> {
|
||||
match value.trim().parse::<EventTime>().map_err(|e| bad(e.to_string()))? {
|
||||
EventTime::Day(date) => Ok(date),
|
||||
EventTime::Minute(at) => Ok(at.date()),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_days(value: &str) -> Result<Vec<Weekday>> {
|
||||
let days = value
|
||||
.split(',')
|
||||
.map(|code| {
|
||||
DAY_CODES
|
||||
.iter()
|
||||
.find(|(name, _)| name.eq_ignore_ascii_case(code.trim()))
|
||||
.map(|(_, day)| *day)
|
||||
.ok_or_else(|| bad(format!("BYDAY `{}` is not a weekday", code.trim())))
|
||||
})
|
||||
.collect::<Result<Vec<Weekday>>>()?;
|
||||
if days.is_empty() || days.len() > 7 {
|
||||
return Err(bad("BYDAY needs 1 to 7 weekdays"));
|
||||
}
|
||||
Ok(days)
|
||||
}
|
||||
|
||||
impl fmt::Display for Rule {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let freq = match self.freq {
|
||||
Freq::Daily => "DAILY",
|
||||
Freq::Weekly => "WEEKLY",
|
||||
Freq::Monthly => "MONTHLY",
|
||||
Freq::Yearly => "YEARLY",
|
||||
};
|
||||
write!(f, "FREQ={freq}")?;
|
||||
if self.interval != 1 {
|
||||
write!(f, ";INTERVAL={}", self.interval)?;
|
||||
}
|
||||
if !self.weekdays.is_empty() {
|
||||
let codes: Vec<&str> = self.weekdays.iter().filter_map(|day| DAY_CODES.iter().find(|(_, d)| d == day).map(|(name, _)| *name)).collect();
|
||||
write!(f, ";BYDAY={}", codes.join(","))?;
|
||||
}
|
||||
if let Some(until) = self.until {
|
||||
write!(f, ";UNTIL={}", EventTime::Day(until))?;
|
||||
}
|
||||
if let Some(count) = self.count {
|
||||
write!(f, ";COUNT={count}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
//! Reminders: which alerts are due, snoozing, quiet hours and the inbox.
|
||||
//!
|
||||
//! Everything takes the clock as an argument (minutes since 1970-01-01, in the
|
||||
//! user's own time), so the same code runs in the app, in tests and in the CLI.
|
||||
//! An alert is `(event, occurrence start, minutes before)`; its key is those three
|
||||
//! as text, which is all the state needs to remember.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::item::{Item, Kind};
|
||||
use crate::model::Status;
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// Minutes in a day.
|
||||
const DAY: i64 = 24 * 60;
|
||||
|
||||
/// How far ahead the inbox lists upcoming alerts, minutes.
|
||||
const UPCOMING_WINDOW: i64 = DAY;
|
||||
|
||||
/// How long after its start an event still counts as happening, for the inbox, minutes.
|
||||
const STILL_ON: i64 = 120;
|
||||
|
||||
/// Most keys one state remembers per set, so a long-lived file stays small.
|
||||
const MAX_KEYS: usize = 5000;
|
||||
|
||||
/// What the user set for reminders.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ReminderSettings {
|
||||
/// Quiet hours as minutes after midnight, `(from, to)`; they may wrap past midnight.
|
||||
pub quiet: Option<(u32, u32)>,
|
||||
/// Minutes before the start that apply to events with no reminder of their own.
|
||||
pub default_minutes: Vec<u32>,
|
||||
}
|
||||
|
||||
/// One reminder of one occurrence.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Alert {
|
||||
/// `id|start|minutes_before`, the identity the state uses.
|
||||
pub key: String,
|
||||
/// The event file.
|
||||
pub item_id: String,
|
||||
/// The title.
|
||||
pub title: String,
|
||||
/// The start of the occurrence.
|
||||
pub start: EventTime,
|
||||
/// How long before the start it fires.
|
||||
pub minutes_before: u32,
|
||||
/// When it fires, minutes since 1970-01-01.
|
||||
pub fire_at: i64,
|
||||
}
|
||||
|
||||
/// What the reminders remember between runs.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ReminderState {
|
||||
/// The clock of the last tick, minutes since 1970-01-01.
|
||||
pub last_seen: Option<i64>,
|
||||
/// Alerts already shown or counted as missed.
|
||||
pub fired: BTreeSet<String>,
|
||||
/// Alerts the user cleared.
|
||||
pub dismissed: BTreeSet<String>,
|
||||
/// Alerts that fired while the app was closed.
|
||||
pub missed: BTreeSet<String>,
|
||||
/// Alerts put off, with the minute they come back.
|
||||
pub snoozed: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
/// What one tick produced.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Tick {
|
||||
/// Alerts to show now, with sound.
|
||||
pub notify: Vec<Alert>,
|
||||
/// Alerts that fell in quiet hours: in the inbox, but silent.
|
||||
pub quiet: Vec<Alert>,
|
||||
}
|
||||
|
||||
/// The inbox, by what the user should do about each alert.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Inbox {
|
||||
/// Fired and not cleared.
|
||||
pub due: Vec<Alert>,
|
||||
/// Coming up in the next day.
|
||||
pub upcoming: Vec<Alert>,
|
||||
/// Put off, with when they return.
|
||||
pub snoozed: Vec<(Alert, i64)>,
|
||||
/// Fired while the app was closed.
|
||||
pub missed: Vec<Alert>,
|
||||
}
|
||||
|
||||
/// Something that shows an alert to the user (a system notification, in the app).
|
||||
pub trait Notifier {
|
||||
/// Shows `alert`.
|
||||
///
|
||||
/// # Errors
|
||||
/// A message saying why it could not be shown.
|
||||
fn notify(&mut self, alert: &Alert) -> Result<(), String>;
|
||||
}
|
||||
|
||||
/// Minutes since 1970-01-01 of `time` (midnight for a whole day).
|
||||
#[must_use]
|
||||
pub fn epoch_minutes(time: EventTime) -> Option<i64> {
|
||||
let (day, minute) = match time {
|
||||
EventTime::Day(date) => (date, 0),
|
||||
EventTime::Minute(at) => (at.date(), i64::from(at.hour()) * 60 + i64::from(at.minute())),
|
||||
};
|
||||
Some(i64::from(day.to_days_since_epoch()) * DAY + minute)
|
||||
}
|
||||
|
||||
/// Every alert of the timed events in `items`. An event with no reminder of its own
|
||||
/// uses the default ones; whole-day events, tasks and cancelled events have none.
|
||||
#[must_use]
|
||||
pub fn alerts_for(items: &[Item], settings: &ReminderSettings) -> Vec<Alert> {
|
||||
let mut alerts = Vec::new();
|
||||
for item in items.iter().filter(|item| item.kind == Kind::Event && item.status != Some(Status::Cancelled)) {
|
||||
let EventTime::Minute(_) = item.start else { continue };
|
||||
let Some(start) = epoch_minutes(item.start) else { continue };
|
||||
let minutes = if item.reminders.is_empty() { &settings.default_minutes } else { &item.reminders };
|
||||
for &before in minutes {
|
||||
alerts.push(Alert {
|
||||
key: format!("{}|{}|{before}", item.id, item.start),
|
||||
item_id: item.id.clone(),
|
||||
title: item.title.clone(),
|
||||
start: item.start,
|
||||
minutes_before: before,
|
||||
fire_at: start - i64::from(before),
|
||||
});
|
||||
}
|
||||
}
|
||||
alerts.sort_by(|a, b| a.fire_at.cmp(&b.fire_at).then_with(|| a.key.cmp(&b.key)));
|
||||
alerts
|
||||
}
|
||||
|
||||
fn in_quiet(now: i64, quiet: Option<(u32, u32)>) -> bool {
|
||||
let Some((from, to)) = quiet else { return false };
|
||||
let minute = u32::try_from(now.rem_euclid(DAY)).unwrap_or(0);
|
||||
if from <= to { (from..to).contains(&minute) } else { minute >= from || minute < to }
|
||||
}
|
||||
|
||||
fn trim(set: &mut BTreeSet<String>) {
|
||||
while set.len() > MAX_KEYS {
|
||||
let Some(first) = set.iter().next().cloned() else { break };
|
||||
set.remove(&first);
|
||||
}
|
||||
}
|
||||
|
||||
/// At start-up: alerts that fell between the last run and `now` were missed. They are
|
||||
/// remembered as missed, never notified, and the clock moves to `now`.
|
||||
pub fn catch_up(items: &[Item], now: i64, settings: &ReminderSettings, state: &mut ReminderState) -> Vec<Alert> {
|
||||
let Some(last) = state.last_seen else {
|
||||
state.last_seen = Some(now);
|
||||
return Vec::new();
|
||||
};
|
||||
let missed: Vec<Alert> = alerts_for(items, settings)
|
||||
.into_iter()
|
||||
.filter(|alert| alert.fire_at > last && alert.fire_at <= now && !state.fired.contains(&alert.key) && !state.dismissed.contains(&alert.key))
|
||||
.collect();
|
||||
for alert in &missed {
|
||||
state.fired.insert(alert.key.clone());
|
||||
state.missed.insert(alert.key.clone());
|
||||
}
|
||||
state.last_seen = Some(now);
|
||||
trim(&mut state.fired);
|
||||
trim(&mut state.missed);
|
||||
missed
|
||||
}
|
||||
|
||||
/// Advances the clock to `now`: alerts that came due since the last tick, and snoozed
|
||||
/// ones that came back. Alerts in quiet hours go to `quiet`, not `notify`.
|
||||
pub fn tick(items: &[Item], now: i64, settings: &ReminderSettings, state: &mut ReminderState) -> Tick {
|
||||
let last = state.last_seen.unwrap_or(now);
|
||||
let mut out = Tick::default();
|
||||
let alerts = alerts_for(items, settings);
|
||||
let quiet_now = in_quiet(now, settings.quiet);
|
||||
let place = |alert: Alert, out: &mut Tick| if quiet_now { out.quiet.push(alert) } else { out.notify.push(alert) };
|
||||
let newly: Vec<&Alert> = alerts
|
||||
.iter()
|
||||
.filter(|alert| alert.fire_at > last && alert.fire_at <= now && !state.fired.contains(&alert.key) && !state.dismissed.contains(&alert.key))
|
||||
.collect();
|
||||
for alert in newly {
|
||||
state.fired.insert(alert.key.clone());
|
||||
place(alert.clone(), &mut out);
|
||||
}
|
||||
let back: Vec<String> = state.snoozed.iter().filter(|(_, until)| **until <= now).map(|(key, _)| key.clone()).collect();
|
||||
for key in back {
|
||||
state.snoozed.remove(&key);
|
||||
if let Some(alert) = alerts.iter().find(|alert| alert.key == key).filter(|alert| !state.dismissed.contains(&alert.key)) {
|
||||
place(alert.clone(), &mut out);
|
||||
}
|
||||
}
|
||||
state.last_seen = Some(now);
|
||||
trim(&mut state.fired);
|
||||
out
|
||||
}
|
||||
|
||||
/// Puts `key` off for `minutes`. A snoozed alert leaves the due and missed lists.
|
||||
pub fn snooze(state: &mut ReminderState, key: &str, now: i64, minutes: u32) {
|
||||
state.missed.remove(key);
|
||||
state.snoozed.insert(key.to_owned(), now.saturating_add(i64::from(minutes)));
|
||||
}
|
||||
|
||||
/// Clears `key` for good.
|
||||
pub fn dismiss(state: &mut ReminderState, key: &str) {
|
||||
state.missed.remove(key);
|
||||
state.snoozed.remove(key);
|
||||
state.dismissed.insert(key.to_owned());
|
||||
trim(&mut state.dismissed);
|
||||
}
|
||||
|
||||
/// The inbox at `now`.
|
||||
#[must_use]
|
||||
pub fn inbox(items: &[Item], now: i64, settings: &ReminderSettings, state: &ReminderState) -> Inbox {
|
||||
let mut inbox = Inbox::default();
|
||||
for alert in alerts_for(items, settings) {
|
||||
if state.dismissed.contains(&alert.key) {
|
||||
continue;
|
||||
}
|
||||
let start = epoch_minutes(alert.start).unwrap_or(alert.fire_at);
|
||||
if let Some(until) = state.snoozed.get(&alert.key) {
|
||||
inbox.snoozed.push((alert, *until));
|
||||
} else if state.missed.contains(&alert.key) {
|
||||
inbox.missed.push(alert);
|
||||
} else if state.fired.contains(&alert.key) && alert.fire_at <= now && start + STILL_ON >= now {
|
||||
inbox.due.push(alert);
|
||||
} else if alert.fire_at > now && alert.fire_at <= now + UPCOMING_WINDOW {
|
||||
inbox.upcoming.push(alert);
|
||||
}
|
||||
}
|
||||
inbox
|
||||
}
|
||||
|
||||
/// Hands every alert of `tick` to `notifier`; returns the messages of the ones that failed.
|
||||
pub fn deliver(notifier: &mut dyn Notifier, tick: &Tick) -> Vec<String> {
|
||||
tick.notify.iter().filter_map(|alert| notifier.notify(alert).err()).collect()
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! The week in review.
|
||||
|
||||
use notescraft_templates::Date;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::item::{Item, Kind};
|
||||
use crate::model::Status;
|
||||
use crate::time::EventTime;
|
||||
|
||||
/// What happened in one week.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub struct Review {
|
||||
/// First day of the week.
|
||||
pub week_start: String,
|
||||
/// Events that were on (not cancelled), oldest first.
|
||||
pub events: Vec<Item>,
|
||||
/// Tasks marked done.
|
||||
pub tasks_done: Vec<Item>,
|
||||
/// Tasks still open.
|
||||
pub tasks_open: Vec<Item>,
|
||||
/// Minutes of timed events.
|
||||
pub event_minutes: u32,
|
||||
/// Linked notes of the week's items, without repeats.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Summarises the seven days from `week_start`.
|
||||
#[must_use]
|
||||
pub fn week_review(items: &[Item], week_start: Date) -> Review {
|
||||
let week_end = week_start.add_days(6).unwrap_or(week_start);
|
||||
let mut week: Vec<&Item> = items.iter().filter(|item| item.last_date() >= week_start && item.start.date() <= week_end).collect();
|
||||
week.sort_by_key(|item| item.start);
|
||||
let mut review = Review { week_start: EventTime::Day(week_start).to_string(), ..Review::default() };
|
||||
for item in week {
|
||||
match (item.kind, item.done, item.status) {
|
||||
(Kind::Event, _, Some(Status::Cancelled)) => continue,
|
||||
(Kind::Event, ..) => {
|
||||
review.event_minutes = review.event_minutes.saturating_add(minutes(item));
|
||||
review.events.push(item.clone());
|
||||
}
|
||||
(Kind::Task, true, _) => review.tasks_done.push(item.clone()),
|
||||
(Kind::Task, false, _) => review.tasks_open.push(item.clone()),
|
||||
}
|
||||
if let Some(note) = item.note.as_ref().filter(|note| !review.notes.contains(note)) {
|
||||
review.notes.push(note.clone());
|
||||
}
|
||||
}
|
||||
review
|
||||
}
|
||||
|
||||
/// Length of a timed item in minutes; 0 for whole-day or open-ended ones.
|
||||
fn minutes(item: &Item) -> u32 {
|
||||
let (EventTime::Minute(start), Some(EventTime::Minute(end))) = (item.start, item.end) else { return 0 };
|
||||
let day = |at: notescraft_templates::DateTime| i64::from(at.date().to_days_since_epoch()) * 1440 + i64::from(at.hour()) * 60 + i64::from(at.minute());
|
||||
u32::try_from((day(end) - day(start)).max(0)).unwrap_or(u32::MAX)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! [`EventTime`]: a whole day or a day with a time.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use notescraft_templates::{Date, DateTime};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// When something starts or ends: a whole day (`2026-10-17`) or a minute
|
||||
/// (`2026-10-17T09:30`). Serialises as that text.
|
||||
///
|
||||
/// Times order by day, then by clock; a whole day sorts before any time of
|
||||
/// that day.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum EventTime {
|
||||
/// A whole day.
|
||||
Day(Date),
|
||||
/// A day with hour and minute.
|
||||
Minute(DateTime),
|
||||
}
|
||||
|
||||
impl EventTime {
|
||||
/// The day this time falls on.
|
||||
#[must_use]
|
||||
pub const fn date(self) -> Date {
|
||||
match self {
|
||||
Self::Day(date) => date,
|
||||
Self::Minute(at) => at.date(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is a whole day.
|
||||
#[must_use]
|
||||
pub const fn is_all_day(self) -> bool {
|
||||
matches!(self, Self::Day(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl EventTime {
|
||||
/// Day number, then minutes into the day (none for a whole day).
|
||||
fn key(self) -> (i32, Option<u16>) {
|
||||
match self {
|
||||
Self::Day(date) => (date.to_days_since_epoch(), None),
|
||||
Self::Minute(at) => (at.date().to_days_since_epoch(), Some(u16::from(at.hour()) * 60 + u16::from(at.minute()))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for EventTime {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.key().cmp(&other.key())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for EventTime {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for EventTime {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let date = self.date();
|
||||
write!(f, "{:04}-{:02}-{:02}", date.year(), date.month(), date.day())?;
|
||||
if let Self::Minute(at) = self {
|
||||
write!(f, "T{:02}:{:02}", at.hour(), at.minute())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for EventTime {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(text: &str) -> Result<Self> {
|
||||
let bad = || Error::InvalidTime(text.chars().take(32).collect());
|
||||
let (day, clock) = match text.split_once('T') {
|
||||
Some((day, clock)) => (day, Some(clock)),
|
||||
None => (text, None),
|
||||
};
|
||||
let mut parts = day.split('-');
|
||||
let mut number = |width: usize| -> Result<u32> {
|
||||
let part = parts.next().ok_or_else(bad)?;
|
||||
if part.len() != width || !part.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(bad());
|
||||
}
|
||||
part.parse().map_err(|_| bad())
|
||||
};
|
||||
let (year, month, dom) = (number(4)?, number(2)?, number(2)?);
|
||||
if parts.next().is_some() {
|
||||
return Err(bad());
|
||||
}
|
||||
let date = Date::new(i32::try_from(year).map_err(|_| bad())?, u8::try_from(month).map_err(|_| bad())?, u8::try_from(dom).map_err(|_| bad())?)
|
||||
.map_err(|_| bad())?;
|
||||
let Some(clock) = clock else { return Ok(Self::Day(date)) };
|
||||
let (hour, minute) = clock.split_once(':').ok_or_else(bad)?;
|
||||
let digits = |s: &str| s.len() == 2 && s.bytes().all(|b| b.is_ascii_digit());
|
||||
if !digits(hour) || !digits(minute) {
|
||||
return Err(bad());
|
||||
}
|
||||
let hour = hour.parse::<u8>().map_err(|_| bad())?;
|
||||
let minute = minute.parse::<u8>().map_err(|_| bad())?;
|
||||
DateTime::new(date, hour, minute, 0).map(Self::Minute).map_err(|_| bad())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for EventTime {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
serializer.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for EventTime {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
let text = String::deserialize(deserializer)?;
|
||||
text.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EventTime;
|
||||
|
||||
#[test]
|
||||
fn day_and_minute_round_trip_as_text() {
|
||||
for text in ["2026-10-17", "2026-10-17T09:30", "2024-02-29T23:59"] {
|
||||
let time: EventTime = text.parse().unwrap();
|
||||
assert_eq!(time.to_string(), text);
|
||||
}
|
||||
assert!("2026-10-17".parse::<EventTime>().unwrap().is_all_day());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_day_sorts_before_the_times_of_that_day() {
|
||||
let mut times: Vec<EventTime> =
|
||||
["2026-10-21", "2026-10-20T12:00", "2026-10-20", "2026-10-20T00:00", "2026-10-19T23:59"].iter().map(|t| t.parse().unwrap()).collect();
|
||||
times.sort();
|
||||
let sorted: Vec<String> = times.iter().map(ToString::to_string).collect();
|
||||
assert_eq!(sorted, ["2026-10-19T23:59", "2026-10-20", "2026-10-20T00:00", "2026-10-20T12:00", "2026-10-21"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn impossible_or_malformed_text_is_an_error() {
|
||||
for text in ["", "2026-13-01", "2026-02-30", "2026-10-17T24:00", "2026-10-17T9:30", "26-10-17", "2026-10-17T", "2026-10-17-01", "2026-10-17"] {
|
||||
assert!(text.parse::<EventTime>().is_err(), "{text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Event file round trips and hostile input.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use notescraft_calendar::{Error, Event, MAX_EVENT_BYTES, MAX_ITEMS, Priority, Reminder, Status, parse_event, write_event};
|
||||
|
||||
fn full() -> Event {
|
||||
Event {
|
||||
title: "Standup: day #1 'quoted' \"too\"".to_owned(),
|
||||
start: "2026-10-19T09:30".parse().unwrap(),
|
||||
end: Some("2026-10-19T09:45".parse().unwrap()),
|
||||
tz: Some("Europe/Lisbon".to_owned()),
|
||||
repeat: Some("weekly".to_owned()),
|
||||
exceptions: vec!["2026-10-26T09:30".parse().unwrap()],
|
||||
calendar: Some("Work".to_owned()),
|
||||
reminders: vec![Reminder { minutes_before: 10 }, Reminder { minutes_before: 0 }],
|
||||
tags: vec!["team".to_owned(), "a, b".to_owned()],
|
||||
priority: Some(Priority::High),
|
||||
status: Some(Status::Tentative),
|
||||
link: Some("https://example.com/call?a=1#x".to_owned()),
|
||||
note: Some("meetings/standup.md".to_owned()),
|
||||
}
|
||||
}
|
||||
|
||||
fn minimal(title: &str) -> Event {
|
||||
Event {
|
||||
title: title.to_owned(),
|
||||
start: "2026-10-19".parse().unwrap(),
|
||||
end: None,
|
||||
tz: None,
|
||||
repeat: None,
|
||||
exceptions: vec![],
|
||||
calendar: None,
|
||||
reminders: vec![],
|
||||
tags: vec![],
|
||||
priority: None,
|
||||
status: None,
|
||||
link: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_event_round_trips() {
|
||||
let text = write_event(None, &full()).unwrap();
|
||||
assert!(text.starts_with("---\n"));
|
||||
assert_eq!(parse_event(&text).unwrap(), full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn awkward_titles_stay_text() {
|
||||
for title in ["2026", "true", "null", "a: b", "# not a comment", " padded ", "line\nbreak", "tab\there", "ünï 日本 🎉", "'", "\"", "[x]", "- y"] {
|
||||
let text = write_event(None, &minimal(title)).unwrap();
|
||||
assert_eq!(parse_event(&text).unwrap().title, title, "{title:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewrite_keeps_unknown_keys_comments_and_body() {
|
||||
let original = "---\n# keep me\naliases: [a]\ntitle: Old\nstart: 2026-01-01\nmystery: {x: 1}\n---\n\nBody text.\n";
|
||||
let out = write_event(Some(original), &minimal("New")).unwrap();
|
||||
assert!(out.contains("# keep me\n") && out.contains("aliases: [a]\n") && out.contains("mystery: {x: 1}\n"));
|
||||
assert!(out.ends_with("---\n\nBody text.\n"));
|
||||
let event = parse_event(&out).unwrap();
|
||||
assert_eq!((event.title.as_str(), event.start.to_string().as_str()), ("New", "2026-10-19"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emptied_fields_are_removed() {
|
||||
let with = write_event(None, &full()).unwrap();
|
||||
let without = write_event(Some(&with), &minimal("Standup")).unwrap();
|
||||
for key in ["end:", "tz:", "repeat:", "exceptions:", "reminders:", "tags:", "priority:", "status:", "link:", "note:", "calendar:"] {
|
||||
assert!(!without.contains(key), "{key} left in\n{without}");
|
||||
}
|
||||
assert_eq!(parse_event(&without).unwrap(), minimal("Standup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_wrong_fields_are_errors_not_fixes() {
|
||||
assert!(matches!(parse_event("no frontmatter"), Err(Error::NoFrontmatter)));
|
||||
assert!(matches!(parse_event("---\nstart: 2026-10-19\n---\n"), Err(Error::Field { field: "title", .. })));
|
||||
assert!(matches!(parse_event("---\ntitle: x\n---\n"), Err(Error::Field { field: "start", .. })));
|
||||
let cases = [
|
||||
("start: 2026-02-30", "start"),
|
||||
("start: 2026-13-01", "start"),
|
||||
("start: 99999-01-01", "start"),
|
||||
("start: [2026-10-19]", "start"),
|
||||
("start: 12", "start"),
|
||||
("start: 2026-10-19\nend: tomorrow", "end"),
|
||||
("start: 2026-10-19\npriority: urgent", "priority"),
|
||||
("start: 2026-10-19\nstatus: maybe", "status"),
|
||||
("start: 2026-10-19\nreminders: [-5]", "reminders"),
|
||||
("start: 2026-10-19\nreminders: [1.5]", "reminders"),
|
||||
("start: 2026-10-19\nreminders: [99999999999]", "reminders"),
|
||||
("start: 2026-10-19\nreminders: soon", "reminders"),
|
||||
("start: 2026-10-19\ntags: [[a]]", "tags"),
|
||||
("start: 2026-10-19\ntags: one", "tags"),
|
||||
("start: 2026-10-19\nexceptions: [nope]", "exceptions"),
|
||||
];
|
||||
for (body, field) in cases {
|
||||
let result = parse_event(&format!("---\ntitle: x\n{body}\n---\n"));
|
||||
assert!(matches!(&result, Err(Error::Field { field: f, .. }) if *f == field), "{body}: {result:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_are_refused() {
|
||||
let huge = format!("---\ntitle: x\nstart: 2026-10-19\nnote: {}\n---\n", "a".repeat(MAX_EVENT_BYTES));
|
||||
assert!(matches!(parse_event(&huge), Err(Error::TooLarge(_))));
|
||||
let long_title = format!("---\ntitle: {}\nstart: 2026-10-19\n---\n", "t".repeat(5000));
|
||||
assert!(parse_event(&long_title).is_err());
|
||||
let many = (0..=MAX_ITEMS).map(|i| format!("t{i}")).collect::<Vec<_>>().join(", ");
|
||||
assert!(parse_event(&format!("---\ntitle: x\nstart: 2026-10-19\ntags: [{many}]\n---\n")).is_err());
|
||||
let mut event = minimal("x");
|
||||
event.title = "t".repeat(5000);
|
||||
assert!(write_event(None, &event).is_err());
|
||||
event = minimal("x");
|
||||
event.tags = vec!["t".to_owned(); MAX_ITEMS + 1];
|
||||
assert!(write_event(None, &event).is_err());
|
||||
assert!(write_event(None, &minimal(" ")).is_err());
|
||||
let mut backwards = minimal("x");
|
||||
backwards.end = Some("2026-10-18".parse().unwrap());
|
||||
assert!(write_event(None, &backwards).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
let junk = [
|
||||
"",
|
||||
"---",
|
||||
"---\n",
|
||||
"---\n---\n",
|
||||
"---\n:\n---\n",
|
||||
"---\ntitle:\n---\n",
|
||||
"---\n\u{0}\u{1}\n---\n",
|
||||
"---\ntitle: [\nstart: {\n---\n",
|
||||
"---\ntitle: &a *a\nstart: !!x 1\n---\n",
|
||||
];
|
||||
for text in junk {
|
||||
let _ = parse_event(text);
|
||||
let _ = write_event(Some(text), &minimal("x"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! Google Calendar, read-only: hashing, sign-in URL, JSON parsing and refreshing a Google feed.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use notescraft_calendar::google::{auth_url, base64url, events_url, parse_calendar_list, parse_events, parse_token, percent_encode, pkce_challenge, sha256};
|
||||
use notescraft_calendar::{Feed, Fetch, Fetched, Status, refresh};
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().fold(String::new(), |mut out, b| {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(out, "{b:02x}");
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_the_standard_vectors() {
|
||||
assert_eq!(hex(&sha256(b"")), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
|
||||
assert_eq!(hex(&sha256(b"abc")), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
assert_eq!(hex(&sha256(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")), "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1");
|
||||
assert_eq!(hex(&sha256(&vec![b'a'; 1_000_000])), "cdc76e5c9914fb9281a1c7e284d73e67f1809a48a497200e046d39ccc7112cd0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64url_and_pkce_match_the_rfcs() {
|
||||
for (input, expected) in [("", ""), ("f", "Zg"), ("fo", "Zm8"), ("foo", "Zm9v"), ("foob", "Zm9vYg"), ("fooba", "Zm9vYmE"), ("foobar", "Zm9vYmFy")] {
|
||||
assert_eq!(base64url(input.as_bytes()), expected, "{input:?}");
|
||||
}
|
||||
assert_eq!(base64url(&[0xfb, 0xff, 0xfe]), "-__-", "the URL alphabet, not + and /");
|
||||
assert_eq!(pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sign_in_address_asks_for_read_only_access_and_a_loopback_answer() {
|
||||
let url = auth_url("id 1.apps", "http://127.0.0.1:5555/", "CHAL", "st&te");
|
||||
for part in [
|
||||
"client_id=id%201.apps",
|
||||
"redirect_uri=http%3A%2F%2F127.0.0.1%3A5555%2F",
|
||||
"scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcalendar.readonly",
|
||||
"code_challenge=CHAL",
|
||||
"code_challenge_method=S256",
|
||||
"state=st%26te",
|
||||
"access_type=offline",
|
||||
"response_type=code",
|
||||
] {
|
||||
assert!(url.contains(part), "{part} in {url}");
|
||||
}
|
||||
assert!(url.starts_with("https://accounts.google.com/"));
|
||||
assert!(!url.contains("calendar%20") && !url.contains(".events"), "no write scope");
|
||||
assert_eq!(percent_encode("a b/é~"), "a%20b%2F%C3%A9~");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tokens_calendars_and_errors_parse() {
|
||||
let token = parse_token(r#"{"access_token":"ya29.x","expires_in":3599,"refresh_token":"1//r","token_type":"Bearer"}"#).unwrap();
|
||||
assert_eq!((token.access_token.as_str(), token.expires_in, token.refresh_token.as_deref()), ("ya29.x", 3599, Some("1//r")));
|
||||
assert_eq!(parse_token(r#"{"access_token":"t"}"#).unwrap().refresh_token, None);
|
||||
for bad in [r#"{"error":"invalid_grant","error_description":"Bad Request"}"#, "{}", "nope", r#"{"access_token":""}"#] {
|
||||
assert!(parse_token(bad).is_err(), "{bad}");
|
||||
}
|
||||
let message = parse_token(r#"{"error":"invalid_grant","error_description":"Token has been revoked"}"#).unwrap_err().to_string();
|
||||
assert!(message.contains("revoked"), "{message}");
|
||||
|
||||
let list = parse_calendar_list(
|
||||
r#"{"items":[{"id":"me@x.com","summary":"Me","primary":true},{"id":"c2","summary":"Team","summaryOverride":"Work"},{"summary":"no id"}]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(list.iter().map(|c| (c.id.as_str(), c.name.as_str(), c.primary)).collect::<Vec<_>>(), [("me@x.com", "Me", true), ("c2", "Work", false)]);
|
||||
assert!(parse_calendar_list(r#"{"nothing":1}"#).is_err());
|
||||
}
|
||||
|
||||
const EVENTS: &str = r#"{"items":[
|
||||
{"id":"e1","status":"confirmed","summary":"Standup","start":{"dateTime":"2026-10-20T09:30:00+01:00","timeZone":"Europe/Lisbon"},"end":{"dateTime":"2026-10-20T09:45:00+01:00"},"hangoutLink":"https://meet.example/x","reminders":{"useDefault":false,"overrides":[{"method":"popup","minutes":10},{"method":"email","minutes":99999999}]}},
|
||||
{"id":"e2","summary":"Holiday","start":{"date":"2026-12-24"},"end":{"date":"2026-12-27"}},
|
||||
{"id":"e3","summary":"One day","start":{"date":"2026-10-10"},"end":{"date":"2026-10-11"}},
|
||||
{"id":"e4","status":"cancelled","start":{"dateTime":"2026-10-21T10:00:00Z"}},
|
||||
{"id":"e5","status":"tentative","summary":"Maybe","start":{"dateTime":"2026-10-22T10:00:00Z"},"htmlLink":"https://calendar.example/e5"},
|
||||
{"id":"bad","summary":"Broken","start":{"dateTime":"yesterday"}},
|
||||
{"id":"none","summary":"No start"}
|
||||
]}"#;
|
||||
|
||||
#[test]
|
||||
fn events_become_read_only_events_and_broken_ones_are_counted() {
|
||||
let (events, skipped) = parse_events(EVENTS).unwrap();
|
||||
assert_eq!((events.len(), skipped), (5, 2));
|
||||
let standup = &events[0];
|
||||
assert_eq!(standup.uid.as_deref(), Some("e1"));
|
||||
let e = &standup.event;
|
||||
assert_eq!(
|
||||
(e.start.to_string().as_str(), e.end.unwrap().to_string().as_str(), e.tz.as_deref()),
|
||||
("2026-10-20T09:30", "2026-10-20T09:45", Some("Europe/Lisbon"))
|
||||
);
|
||||
assert_eq!(
|
||||
(e.link.as_deref(), e.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>()),
|
||||
(Some("https://meet.example/x"), vec![10]),
|
||||
"an absurd reminder is dropped"
|
||||
);
|
||||
let holiday = &events[1].event;
|
||||
assert_eq!((holiday.start.to_string().as_str(), holiday.end.unwrap().to_string().as_str()), ("2026-12-24", "2026-12-26"));
|
||||
assert!(events[2].event.end.is_none(), "a one-day all-day event has no end");
|
||||
assert_eq!((events[3].event.status, events[3].event.title.as_str()), (Some(Status::Cancelled), "(no title)"));
|
||||
assert_eq!((events[4].event.status, events[4].event.link.as_deref()), (Some(Status::Tentative), Some("https://calendar.example/e5")));
|
||||
for bad in ["", "nope", "{}", r#"{"items":"x"}"#] {
|
||||
assert!(parse_events(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(parse_events(&format!(r#"{{"items":[{}]}}"#, vec![r#"{"start":{"date":"2026-01-01"}}"#; 10_001].join(","))).is_err(), "too many events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_of_events_are_asked_for_in_order() {
|
||||
let first = events_url("a@b.com", "2026-10-01T00:00:00Z", "2027-10-01T00:00:00Z", None);
|
||||
assert!(first.starts_with("https://www.googleapis.com/calendar/v3/calendars/a%40b.com/events?singleEvents=true"), "{first}");
|
||||
assert!(first.contains("timeMin=2026-10-01T00%3A00%3A00Z") && !first.contains("pageToken"));
|
||||
assert!(events_url("c", "a", "b", Some("tok/en")).ends_with("&pageToken=tok%2Fen"));
|
||||
}
|
||||
|
||||
struct Reply(Option<Result<Fetched, String>>);
|
||||
|
||||
impl Fetch for Reply {
|
||||
fn fetch(&mut self, _url: &str, _etag: Option<&str>) -> Result<Fetched, String> {
|
||||
self.0.take().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_google_feed_holds_no_secret_and_refreshes_from_json() {
|
||||
let feed = Feed::google("Work", "me@x.com", None).unwrap();
|
||||
assert_eq!((feed.id.as_str(), feed.url.as_str(), feed.google_calendar()), ("google-work", "google://calendar/me@x.com", Some("me@x.com")));
|
||||
assert!(Feed::new("x", "https://a.example/x.ics", None).unwrap().google_calendar().is_none());
|
||||
for (name, id) in [("", "c"), ("x", ""), ("x", "a b")] {
|
||||
assert!(Feed::google(name, id, None).is_err(), "{name:?} {id:?}");
|
||||
}
|
||||
let cached = refresh(&feed, None, &mut Reply(Some(Ok(Fetched::Body { text: EVENTS.to_owned(), etag: None }))), "2026-10-20T09:00".parse().unwrap());
|
||||
assert_eq!((cached.events.len(), cached.status.skipped, cached.status.error.clone()), (5, 2, None));
|
||||
let broken = refresh(
|
||||
&feed,
|
||||
Some(&cached),
|
||||
&mut Reply(Some(Ok(Fetched::Body { text: "BEGIN:VCALENDAR".to_owned(), etag: None }))),
|
||||
"2026-10-20T10:00".parse().unwrap(),
|
||||
);
|
||||
assert_eq!(broken.events.len(), 5, "a bad answer keeps the cache");
|
||||
assert!(broken.status.error.is_some());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! The iCalendar reader: real-looking feeds and hostile ones.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use notescraft_calendar::{MAX_ICS_BYTES, Priority, Status, parse_ics};
|
||||
|
||||
const FEED: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//y//EN\r\n\
|
||||
BEGIN:VTIMEZONE\r\nTZID:Europe/Lisbon\r\nBEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n\
|
||||
BEGIN:VEVENT\r\nUID:a@x\r\nDTSTART;TZID=Europe/Lisbon:20261019T093000\r\nDTEND;TZID=Europe/Lisbon:20261019T094500\r\n\
|
||||
SUMMARY:Stand\\, up\\; daily\r\nRRULE:FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=20261231T000000Z\r\nEXDATE;TZID=Europe/Lisbon:20261021T093000\r\n\
|
||||
CATEGORIES:team,Q4\r\nPRIORITY:1\r\nSTATUS:TENTATIVE\r\nURL:https://example.com/call\r\n\
|
||||
BEGIN:VALARM\r\nACTION:DISPLAY\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\nBEGIN:VALARM\r\nTRIGGER:-P1DT2H\r\nEND:VALARM\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:b@x\r\nDTSTART;VALUE=DATE:20261224\r\nDTEND;VALUE=DATE:20261227\r\nSUMMARY:Holiday\r\n\
|
||||
DESCRIPTION:folded\r\n text\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:c@x\r\nDTSTART:20261001T080000Z\r\nSUMMARY:Monthly\r\nRRULE:FREQ=MONTHLY;BYMONTHDAY=-1\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:d@x\r\nDTSTART;VALUE=DATE:20261010\r\nDTEND;VALUE=DATE:20261011\r\nSUMMARY:One day\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:bad@x\r\nDTSTART:2026-10-10\r\nSUMMARY:Broken\r\nEND:VEVENT\r\n\
|
||||
BEGIN:VEVENT\r\nUID:none@x\r\nSUMMARY:No start\r\nEND:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
|
||||
#[test]
|
||||
fn a_feed_imports() {
|
||||
let import = parse_ics(FEED).unwrap();
|
||||
assert_eq!(import.events.len(), 4);
|
||||
assert_eq!(import.skipped.len(), 2, "{:?}", import.skipped);
|
||||
assert!(import.skipped.iter().all(|s| s.contains("bad@x") || s.contains("none@x")));
|
||||
|
||||
let a = &import.events[0];
|
||||
assert_eq!(a.uid.as_deref(), Some("a@x"));
|
||||
let e = &a.event;
|
||||
assert_eq!(e.title, "Stand, up; daily");
|
||||
assert_eq!((e.start.to_string().as_str(), e.end.unwrap().to_string().as_str()), ("2026-10-19T09:30", "2026-10-19T09:45"));
|
||||
assert_eq!(e.tz.as_deref(), Some("Europe/Lisbon"));
|
||||
assert_eq!(e.repeat.as_deref(), Some("FREQ=WEEKLY;BYDAY=MO,WE;UNTIL=2026-12-31"));
|
||||
assert_eq!(e.exceptions[0].to_string(), "2026-10-21T09:30");
|
||||
assert_eq!((e.tags.as_slice(), e.priority, e.status), (["team".to_owned(), "Q4".to_owned()].as_slice(), Some(Priority::High), Some(Status::Tentative)));
|
||||
assert_eq!(e.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>(), [15, 1560]);
|
||||
assert_eq!(e.link.as_deref(), Some("https://example.com/call"));
|
||||
assert!(a.unsupported_rule.is_none());
|
||||
|
||||
let holiday = &import.events[1].event;
|
||||
assert_eq!((holiday.start.to_string().as_str(), holiday.end.unwrap().to_string().as_str()), ("2026-12-24", "2026-12-26"));
|
||||
|
||||
let monthly = &import.events[2];
|
||||
assert_eq!(monthly.unsupported_rule.as_deref(), Some("FREQ=MONTHLY;BYMONTHDAY=-1"));
|
||||
assert!(monthly.event.repeat.is_none());
|
||||
assert_eq!(monthly.event.tz.as_deref(), Some("UTC"));
|
||||
|
||||
assert!(import.events[3].event.end.is_none(), "a one-day all-day event has no end");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lf_only_and_folded_lines_work() {
|
||||
let text = "BEGIN:VCALENDAR\nBEGIN:VEVENT\nDTSTART:20261001T0900\n 00\nSUMMARY:Long\n title\nEND:VEVENT\nEND:VCALENDAR";
|
||||
let import = parse_ics(text).unwrap();
|
||||
assert_eq!(import.events[0].event.title, "Long title");
|
||||
assert_eq!(import.events[0].event.start.to_string(), "2026-10-01T09:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_file_problems_are_errors() {
|
||||
for text in ["", "hello", "BEGIN:VEVENT\nEND:VEVENT", " \n"] {
|
||||
assert!(parse_ics(text).is_err(), "{text:?}");
|
||||
}
|
||||
assert!(parse_ics(&format!("BEGIN:VCALENDAR\nX-BIG:{}\nEND:VCALENDAR", "a".repeat(MAX_ICS_BYTES))).is_err(), "too large");
|
||||
assert!(parse_ics(&format!("BEGIN:VCALENDAR\nX-LONG:{}\nEND:VCALENDAR", "a".repeat(9000))).is_err(), "line too long");
|
||||
let deep = format!("BEGIN:VCALENDAR\n{}{}END:VCALENDAR", "BEGIN:X\n".repeat(20), "END:X\n".repeat(20));
|
||||
assert!(parse_ics(&deep).is_err(), "too deep");
|
||||
let many = format!("BEGIN:VCALENDAR\n{}END:VCALENDAR", "BEGIN:VEVENT\nDTSTART:20261001\nEND:VEVENT\n".repeat(10_001));
|
||||
assert!(parse_ics(&many).is_err(), "too many events");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_properties_never_panic() {
|
||||
let junk = [
|
||||
"DTSTART:",
|
||||
"DTSTART:ABC",
|
||||
"DTSTART:99999999",
|
||||
"DTSTART:20261301",
|
||||
"DTSTART:20261001T256000",
|
||||
"DTSTART;VALUE=DATE:20261001T090000",
|
||||
"DTSTART:20261001\nDTEND:19990101",
|
||||
"DTSTART:20261001\nRRULE:FREQ=DAILY;COUNT=99999999999999",
|
||||
"DTSTART:20261001\nRRULE:;;;=",
|
||||
"DTSTART:20261001\nRRULE:FREQ=WEEKLY;BYDAY=1MO",
|
||||
"DTSTART:20261001\nEXDATE:zzz",
|
||||
"DTSTART:20261001\nBEGIN:VALARM\nTRIGGER:-P99999999999D\nEND:VALARM",
|
||||
"DTSTART:20261001\nBEGIN:VALARM\nTRIGGER:-PT\nEND:VALARM",
|
||||
"DTSTART:20261001\nSUMMARY:\\",
|
||||
"DTSTART:20261001\nPRIORITY:999",
|
||||
":",
|
||||
";;;:::",
|
||||
"\u{0}\u{1}",
|
||||
];
|
||||
for body in junk {
|
||||
let text = format!("BEGIN:VCALENDAR\nBEGIN:VEVENT\n{body}\nEND:VEVENT\nEND:VCALENDAR");
|
||||
let _ = parse_ics(&text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Agenda queries, day views and the week review.
|
||||
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation, clippy::assert_is_empty)]
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use notescraft_calendar::{
|
||||
Filter, GroupKey, Item, Kind, Priority, Query, SortKey, Status, conflicts, free_slots, heat_level, run_query, week_review, year_counts,
|
||||
};
|
||||
use notescraft_templates::Weekday;
|
||||
|
||||
fn t(text: &str) -> notescraft_calendar::EventTime {
|
||||
text.parse().unwrap()
|
||||
}
|
||||
|
||||
fn item(id: &str, start: &str) -> Item {
|
||||
Item {
|
||||
id: id.to_owned(),
|
||||
kind: Kind::Event,
|
||||
title: id.to_owned(),
|
||||
start: t(start),
|
||||
end: None,
|
||||
calendar: None,
|
||||
tags: vec![],
|
||||
priority: None,
|
||||
status: None,
|
||||
has_reminder: false,
|
||||
reminders: vec![],
|
||||
note: None,
|
||||
created: None,
|
||||
done: false,
|
||||
repeats: false,
|
||||
source: None,
|
||||
tz: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ids(groups: &[notescraft_calendar::Group]) -> Vec<Vec<String>> {
|
||||
groups.iter().map(|g| g.items.iter().map(|i| i.id.clone()).collect()).collect()
|
||||
}
|
||||
|
||||
fn sample() -> Vec<Item> {
|
||||
let mut a = item("a", "2026-10-20T09:00");
|
||||
a.calendar = Some("Work".to_owned());
|
||||
a.tags = vec!["team".to_owned(), "Q4".to_owned()];
|
||||
a.priority = Some(Priority::High);
|
||||
a.has_reminder = true;
|
||||
a.note = Some("n/a.md".to_owned());
|
||||
a.created = Some(t("2026-01-02"));
|
||||
let mut b = item("B", "2026-10-19");
|
||||
b.calendar = Some("Home".to_owned());
|
||||
b.priority = Some(Priority::Low);
|
||||
b.status = Some(Status::Tentative);
|
||||
b.kind = Kind::Task;
|
||||
let mut c = item("c", "2026-10-27T08:00");
|
||||
c.calendar = Some("Work".to_owned());
|
||||
c.priority = Some(Priority::Medium);
|
||||
c.note = Some("n/0.md".to_owned());
|
||||
c.created = Some(t("2025-12-01"));
|
||||
vec![a, b, c]
|
||||
}
|
||||
|
||||
fn q(sort: SortKey, descending: bool, group: GroupKey) -> Query {
|
||||
Query { filter: Filter::default(), sort, descending, group }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_filter_field() {
|
||||
let items = sample();
|
||||
let run = |filter: Filter| ids(&run_query(&items, &Query { filter, ..Query::default() }, Weekday::Monday)).concat();
|
||||
assert_eq!(run(Filter::default()), ["B", "a", "c"]);
|
||||
assert_eq!(run(Filter { calendars: vec!["Work".into()], ..Filter::default() }), ["a", "c"]);
|
||||
assert_eq!(run(Filter { kinds: vec![Kind::Task], ..Filter::default() }), ["B"]);
|
||||
assert_eq!(run(Filter { statuses: vec![Status::Confirmed], ..Filter::default() }), ["a", "c"]);
|
||||
assert_eq!(run(Filter { priorities: vec![Priority::High, Priority::Low], ..Filter::default() }), ["B", "a"]);
|
||||
assert_eq!(run(Filter { tags: vec!["q4".into()], ..Filter::default() }), ["a"]);
|
||||
assert_eq!(run(Filter { has_reminder: Some(true), ..Filter::default() }), ["a"]);
|
||||
assert_eq!(run(Filter { has_reminder: Some(false), ..Filter::default() }), ["B", "c"]);
|
||||
let range = Filter { from: Some(t("2026-10-20")), to: Some(t("2026-10-26")), ..Filter::default() };
|
||||
assert_eq!(run(range), ["a"]);
|
||||
let both = Filter { calendars: vec!["Work".into()], priorities: vec![Priority::Medium], ..Filter::default() };
|
||||
assert_eq!(run(both), ["c"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_sort_key_both_ways_with_missing_last() {
|
||||
let items = sample();
|
||||
let order = |sort, desc| ids(&run_query(&items, &q(sort, desc, GroupKey::None), Weekday::Monday)).concat();
|
||||
assert_eq!(order(SortKey::Date, false), ["B", "a", "c"]);
|
||||
assert_eq!(order(SortKey::Date, true), ["c", "a", "B"]);
|
||||
assert_eq!(order(SortKey::Priority, false), ["a", "c", "B"]);
|
||||
assert_eq!(order(SortKey::Priority, true), ["B", "c", "a"]);
|
||||
assert_eq!(order(SortKey::Title, false), ["a", "B", "c"]);
|
||||
assert_eq!(order(SortKey::LinkedNote, false), ["c", "a", "B"]);
|
||||
assert_eq!(order(SortKey::LinkedNote, true), ["a", "c", "B"]);
|
||||
assert_eq!(order(SortKey::Created, false), ["c", "a", "B"]);
|
||||
assert_eq!(order(SortKey::Created, true), ["a", "c", "B"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_by_day_week_calendar_and_tag() {
|
||||
let items = sample();
|
||||
let groups = |key, start| run_query(&items, &q(SortKey::Date, false, key), start);
|
||||
let day = groups(GroupKey::Day, Weekday::Monday);
|
||||
assert_eq!(day.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-19", "2026-10-20", "2026-10-27"]);
|
||||
let week = groups(GroupKey::Week, Weekday::Monday);
|
||||
assert_eq!(week.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-19", "2026-10-26"]);
|
||||
assert_eq!(ids(&week), [vec!["B", "a"], vec!["c"]]);
|
||||
let sunday = groups(GroupKey::Week, Weekday::Sunday);
|
||||
assert_eq!(sunday.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-18", "2026-10-25"]);
|
||||
let calendar = groups(GroupKey::Calendar, Weekday::Monday);
|
||||
assert_eq!(calendar.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["Home", "Work"]);
|
||||
let tag = groups(GroupKey::Tag, Weekday::Monday);
|
||||
assert_eq!(tag.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["", "Q4", "team"]);
|
||||
assert_eq!(groups(GroupKey::None, Weekday::Monday).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_query_survives_json() {
|
||||
let saved = notescraft_calendar::SavedFilter {
|
||||
name: "Work".into(),
|
||||
query: Query {
|
||||
filter: Filter { calendars: vec!["Work".into()], from: Some(t("2026-01-01")), ..Filter::default() },
|
||||
sort: SortKey::Priority,
|
||||
descending: true,
|
||||
group: GroupKey::Tag,
|
||||
},
|
||||
};
|
||||
let json = serde_json::to_string(&saved).unwrap();
|
||||
assert_eq!(serde_json::from_str::<notescraft_calendar::SavedFilter>(&json).unwrap(), saved);
|
||||
assert_eq!(serde_json::from_str::<Query>("{}").unwrap(), Query::default());
|
||||
}
|
||||
|
||||
fn span(id: &str, start: &str, end: &str) -> Item {
|
||||
let mut i = item(id, start);
|
||||
i.end = Some(t(end));
|
||||
i
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlaps_are_conflicts_touching_and_all_day_are_not() {
|
||||
let items = [
|
||||
span("a", "2026-10-20T09:00", "2026-10-20T10:00"),
|
||||
span("b", "2026-10-20T09:30", "2026-10-20T11:00"),
|
||||
span("c", "2026-10-20T10:00", "2026-10-20T10:30"),
|
||||
span("d", "2026-10-20T10:45", "2026-10-20T10:50"),
|
||||
span("e", "2026-10-20", "2026-10-21"),
|
||||
item("f", "2026-10-20T09:15"),
|
||||
];
|
||||
assert_eq!(conflicts(&items), [(0, 1), (1, 2), (1, 3)]);
|
||||
let mut cancelled = items.clone();
|
||||
cancelled[1].status = Some(Status::Cancelled);
|
||||
assert!(conflicts(&cancelled).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_time_between_busy_stretches() {
|
||||
let day = t("2026-10-20").date();
|
||||
let items = [
|
||||
span("a", "2026-10-20T09:00", "2026-10-20T10:00"),
|
||||
span("b", "2026-10-20T09:30", "2026-10-20T11:00"),
|
||||
span("night", "2026-10-19T22:00", "2026-10-20T08:30"),
|
||||
span("other day", "2026-10-21T09:00", "2026-10-21T10:00"),
|
||||
span("all day", "2026-10-20", "2026-10-20"),
|
||||
];
|
||||
let slots: Vec<(u32, u32)> = free_slots(&items, day, 8 * 60, 18 * 60, 30).iter().map(|s| (s.start, s.end)).collect();
|
||||
assert_eq!(slots, [(510, 540), (660, 1080)]);
|
||||
let long: Vec<(u32, u32)> = free_slots(&items, day, 8 * 60, 18 * 60, 60).iter().map(|s| (s.start, s.end)).collect();
|
||||
assert_eq!(long, [(660, 1080)]);
|
||||
assert!(free_slots(&[], day, 600, 600, 1).is_empty());
|
||||
assert_eq!(free_slots(&[], day, 0, 99_999, 1).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn year_counts_and_heat() {
|
||||
let items = [item("a", "2026-01-01"), span("b", "2026-12-30", "2027-01-02"), item("c", "2025-06-01")];
|
||||
let counts = year_counts(&items, 2026);
|
||||
assert_eq!(counts.len(), 365);
|
||||
assert_eq!((counts[0], counts[363], counts[364]), (1, 1, 1));
|
||||
assert_eq!(year_counts(&[], 2024).len(), 366);
|
||||
assert_eq!([0, 1, 2, 3, 4, 40].map(heat_level), [0, 1, 2, 2, 3, 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_week_in_review() {
|
||||
let mut done = item("done", "2026-10-21");
|
||||
done.kind = Kind::Task;
|
||||
done.done = true;
|
||||
done.note = Some("x.md".into());
|
||||
let mut open = item("open", "2026-10-22");
|
||||
open.kind = Kind::Task;
|
||||
let mut meeting = span("m", "2026-10-20T09:00", "2026-10-20T10:30");
|
||||
meeting.note = Some("x.md".into());
|
||||
let mut dropped = item("dropped", "2026-10-23T09:00");
|
||||
dropped.status = Some(Status::Cancelled);
|
||||
let outside = item("outside", "2026-10-27");
|
||||
let review = week_review(&[outside, dropped, open, done, meeting], t("2026-10-19").date());
|
||||
assert_eq!(review.week_start, "2026-10-19");
|
||||
assert_eq!(review.events.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(), ["m"]);
|
||||
assert_eq!((review.tasks_done.len(), review.tasks_open.len(), review.event_minutes), (1, 1, 90));
|
||||
assert_eq!(review.notes, ["x.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark: run with --ignored --release for the real number"]
|
||||
fn ten_thousand_items_filter_and_sort_fast() {
|
||||
let items: Vec<Item> = (0..10_000_u32)
|
||||
.map(|n| {
|
||||
let mut i = item(&format!("e{n}"), &format!("2026-{:02}-{:02}T{:02}:00", 1 + n % 12, 1 + n % 28, n % 24));
|
||||
i.calendar = Some(format!("c{}", n % 7));
|
||||
i.tags = vec![format!("t{}", n % 13)];
|
||||
i.priority = [Some(Priority::Low), None, Some(Priority::High)][(n % 3) as usize];
|
||||
i
|
||||
})
|
||||
.collect();
|
||||
let query = Query {
|
||||
filter: Filter { tags: vec!["t3".into(), "t5".into()], ..Filter::default() },
|
||||
sort: SortKey::Priority,
|
||||
descending: false,
|
||||
group: GroupKey::Calendar,
|
||||
};
|
||||
let started = Instant::now();
|
||||
let groups = run_query(&items, &query, Weekday::Monday);
|
||||
let took = started.elapsed();
|
||||
assert!(!groups.is_empty());
|
||||
assert!(took.as_millis() < 100, "took {took:?}");
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Quick-add phrases. Today is Saturday 2026-10-17.
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use notescraft_calendar::{Error, Event, parse_quick};
|
||||
use notescraft_templates::Date;
|
||||
|
||||
fn today() -> Date {
|
||||
Date::new(2026, 10, 17).unwrap()
|
||||
}
|
||||
|
||||
fn ok(text: &str) -> Event {
|
||||
parse_quick(text, today()).unwrap_or_else(|e| panic!("{text:?}: {e}"))
|
||||
}
|
||||
|
||||
fn when(text: &str) -> (String, String) {
|
||||
let event = ok(text);
|
||||
(event.start.to_string(), event.end.map(|e| e.to_string()).unwrap_or_default())
|
||||
}
|
||||
|
||||
fn failing_part(text: &str) -> String {
|
||||
match parse_quick(text, today()) {
|
||||
Err(Error::QuickAdd { part, .. }) => part,
|
||||
other => panic!("{text:?} should fail, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_title_is_an_all_day_event_today() {
|
||||
let event = ok("Buy milk");
|
||||
assert_eq!((event.title.as_str(), event.start.to_string().as_str()), ("Buy milk", "2026-10-17"));
|
||||
assert!(event.end.is_none() && event.repeat.is_none() && event.reminders.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days() {
|
||||
assert_eq!(when("Call tomorrow").0, "2026-10-18");
|
||||
assert_eq!(when("Call today").0, "2026-10-17");
|
||||
assert_eq!(when("Call monday").0, "2026-10-19");
|
||||
assert_eq!(when("Call saturday").0, "2026-10-17");
|
||||
assert_eq!(when("Call next saturday").0, "2026-10-24");
|
||||
assert_eq!(when("Call on Friday").0, "2026-10-23");
|
||||
assert_eq!(when("Call 2026-12-25").0, "2026-12-25");
|
||||
assert_eq!(when("Call oct 25").0, "2026-10-25");
|
||||
assert_eq!(when("Call 25th October").0, "2026-10-25");
|
||||
assert_eq!(when("Call on march 3").0, "2027-03-03");
|
||||
assert_eq!(when("Call 3 jan").0, "2027-01-03");
|
||||
assert_eq!(ok("Call on").title, "Call on");
|
||||
assert_eq!(ok("May the force").title, "May the force");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn times_ranges_and_lengths() {
|
||||
assert_eq!(when("Lunch tomorrow at 12:30"), ("2026-10-18T12:30".into(), String::new()));
|
||||
assert_eq!(when("Lunch at 9").0, "2026-10-17T09:00");
|
||||
assert_eq!(when("Lunch 3pm").0, "2026-10-17T15:00");
|
||||
assert_eq!(when("Lunch 12am").0, "2026-10-17T00:00");
|
||||
assert_eq!(when("Lunch noon").0, "2026-10-17T12:00");
|
||||
assert_eq!(when("Lunch 14:30").0, "2026-10-17T14:30");
|
||||
assert_eq!(when("Lunch at 9 for 1h"), ("2026-10-17T09:00".into(), "2026-10-17T10:00".into()));
|
||||
assert_eq!(when("Lunch at 9 for 90 min").1, "2026-10-17T10:30");
|
||||
assert_eq!(when("Lunch at 23:30 for 2 hours").1, "2026-10-18T01:30");
|
||||
assert_eq!(when("Meet 9-10:30"), ("2026-10-17T09:00".into(), "2026-10-17T10:30".into()));
|
||||
assert_eq!(when("Meet 9-10am").0, "2026-10-17T09:00");
|
||||
assert_eq!(when("Meet 11-1pm"), ("2026-10-17T11:00".into(), "2026-10-17T13:00".into()));
|
||||
assert_eq!(when("Meet from 9 to 10"), ("2026-10-17T09:00".into(), "2026-10-17T10:00".into()));
|
||||
assert_eq!(ok("Lunch tomorrow at 12:30 for 1h").title, "Lunch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeats_reminders_tags_and_calendars() {
|
||||
assert_eq!(ok("Standup every weekday at 9:30").repeat.unwrap(), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
|
||||
assert_eq!(ok("Gym every mon,wed").repeat.unwrap(), "FREQ=WEEKLY;BYDAY=MO,WE");
|
||||
assert_eq!(ok("Trash every 2 weeks").repeat.unwrap(), "FREQ=WEEKLY;INTERVAL=2");
|
||||
assert_eq!(ok("Water plants every day").repeat.unwrap(), "FREQ=DAILY");
|
||||
assert_eq!(ok("Rent monthly").repeat.unwrap(), "FREQ=MONTHLY");
|
||||
assert_eq!(ok("Review every friday").repeat.unwrap(), "FREQ=WEEKLY;BYDAY=FR");
|
||||
let event = ok("Dentist friday 3pm remind 15 min before remind me 1 hour #health @Personal");
|
||||
assert_eq!(event.reminders.iter().map(|r| r.minutes_before).collect::<Vec<_>>(), [15, 60]);
|
||||
assert_eq!((event.tags, event.calendar.as_deref(), event.title.as_str()), (vec!["health".to_owned()], Some("Personal"), "Dentist"));
|
||||
assert_eq!(ok("Dentist remind 10m").reminders[0].minutes_before, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_parts_are_named_and_nothing_is_saved() {
|
||||
assert_eq!(failing_part("Lunch at 25:00"), "25:00");
|
||||
assert_eq!(failing_part("Lunch at 9:5"), "9:5");
|
||||
assert_eq!(failing_part("Lunch 13pm"), "13pm");
|
||||
assert_eq!(failing_part("Lunch at noonish"), "noonish");
|
||||
assert_eq!(failing_part("Lunch at"), "at");
|
||||
assert_eq!(failing_part("Lunch 2026-02-30"), "2026-02-30");
|
||||
assert_eq!(failing_part("Lunch for banana"), "banana");
|
||||
assert_eq!(failing_part("Lunch at 9 for 5 parsecs"), "5 parsecs");
|
||||
assert_eq!(failing_part("Lunch every blue moon"), "every blue moon");
|
||||
assert_eq!(failing_part("Lunch every 0 days"), "every 0 days");
|
||||
assert_eq!(failing_part("Lunch tomorrow friday"), "friday");
|
||||
assert_eq!(failing_part("Lunch at 9 at 10"), "10");
|
||||
assert_eq!(failing_part("Lunch daily weekly"), "weekly");
|
||||
assert_eq!(failing_part("Lunch for 1h"), "for");
|
||||
assert_eq!(failing_part("Lunch 10-9"), "");
|
||||
assert_eq!(failing_part("Lunch #"), "#");
|
||||
assert_eq!(failing_part("tomorrow at 9"), "");
|
||||
assert_eq!(failing_part(""), "");
|
||||
assert_eq!(failing_part("Lunch for 99999 days"), "99999");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_input_is_an_error_not_a_panic() {
|
||||
for text in [
|
||||
"x ".repeat(300).as_str(),
|
||||
&"word ".repeat(150),
|
||||
"日本語 を 食べる 明日",
|
||||
"Lunch at 09:00",
|
||||
"💥 at 💥",
|
||||
"Lunch every 4294967296 days",
|
||||
"a\u{0}b at 9",
|
||||
"-",
|
||||
"9-",
|
||||
"-9",
|
||||
"at -",
|
||||
"from to",
|
||||
"remind",
|
||||
"remind me",
|
||||
"next",
|
||||
"on on on",
|
||||
] {
|
||||
let _ = parse_quick(text, today());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Recurrence tables, series splits and a randomised bounds check.
|
||||
#![allow(clippy::unwrap_used, clippy::format_push_string, clippy::cast_possible_truncation, clippy::assert_is_empty)]
|
||||
|
||||
use notescraft_calendar::{Error, EventTime, MAX_OCCURRENCES, Rule};
|
||||
use notescraft_templates::Date;
|
||||
|
||||
fn t(text: &str) -> EventTime {
|
||||
text.parse().unwrap()
|
||||
}
|
||||
|
||||
fn d(text: &str) -> Date {
|
||||
t(text).date()
|
||||
}
|
||||
|
||||
fn days(rule: &str, start: &str, skip: &[&str], from: &str, to: &str) -> Vec<String> {
|
||||
let skip: Vec<EventTime> = skip.iter().map(|s| t(s)).collect();
|
||||
let rule: Rule = rule.parse().unwrap();
|
||||
rule.occurrences(t(start), &skip, d(from), d(to)).unwrap().iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_rules() {
|
||||
assert_eq!(days("daily", "2026-10-30", &[], "2026-10-01", "2026-11-02"), ["2026-10-30", "2026-10-31", "2026-11-01", "2026-11-02"]);
|
||||
assert_eq!(
|
||||
days("FREQ=DAILY;INTERVAL=3;COUNT=3", "2026-01-01T08:00", &[], "2026-01-01", "2026-12-31"),
|
||||
["2026-01-01T08:00", "2026-01-04T08:00", "2026-01-07T08:00"]
|
||||
);
|
||||
assert_eq!(days("weekly", "2026-10-19", &[], "2026-10-01", "2026-11-10"), ["2026-10-19", "2026-10-26", "2026-11-02", "2026-11-09"]);
|
||||
assert_eq!(days("FREQ=YEARLY;COUNT=2", "2026-03-04", &[], "2000-01-01", "2100-01-01"), ["2026-03-04", "2027-03-04"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_by_day_and_interval() {
|
||||
// Monday start: the first week keeps only days from the start on.
|
||||
assert_eq!(
|
||||
days("FREQ=WEEKLY;BYDAY=MO,WE,FR;COUNT=5", "2026-10-21", &[], "2026-01-01", "2026-12-31"),
|
||||
["2026-10-21", "2026-10-23", "2026-10-26", "2026-10-28", "2026-10-30"]
|
||||
);
|
||||
assert_eq!(
|
||||
days("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,SU", "2026-10-18", &[], "2026-10-01", "2026-11-15"),
|
||||
["2026-10-18", "2026-10-27", "2026-11-01", "2026-11-10", "2026-11-15"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn month_ends_leap_days_and_missing_days() {
|
||||
// The 31st skips months without one.
|
||||
assert_eq!(days("FREQ=MONTHLY;COUNT=4", "2026-01-31", &[], "2026-01-01", "2027-12-31"), ["2026-01-31", "2026-03-31", "2026-05-31", "2026-07-31"]);
|
||||
// A leap day only comes back in leap years.
|
||||
assert_eq!(days("FREQ=YEARLY;COUNT=3", "2024-02-29", &[], "2024-01-01", "2100-01-01"), ["2024-02-29", "2028-02-29", "2032-02-29"]);
|
||||
// Monthly across a year end.
|
||||
assert_eq!(days("FREQ=MONTHLY;INTERVAL=2", "2026-11-15", &[], "2026-11-01", "2027-03-31"), ["2026-11-15", "2027-01-15", "2027-03-15"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_clock_time_survives_a_daylight_saving_change() {
|
||||
// Europe/Lisbon moved its clocks on 2026-10-25 and 2026-03-29: the time of day stays.
|
||||
let out = days("weekly", "2026-10-19T09:30", &[], "2026-10-19", "2026-11-02");
|
||||
assert_eq!(out, ["2026-10-19T09:30", "2026-10-26T09:30", "2026-11-02T09:30"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_days_still_count_and_are_left_out() {
|
||||
let out = days("FREQ=DAILY;COUNT=3", "2026-10-01", &["2026-10-02"], "2026-10-01", "2026-12-31");
|
||||
assert_eq!(out, ["2026-10-01", "2026-10-03"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn until_is_inclusive_and_the_window_clips() {
|
||||
assert_eq!(days("FREQ=DAILY;UNTIL=2026-10-03", "2026-10-01", &[], "2026-10-02", "2026-12-31"), ["2026-10-02", "2026-10-03"]);
|
||||
assert_eq!(days("FREQ=DAILY;UNTIL=2026-10-03", "2026-10-10", &[], "2026-10-01", "2026-12-31"), Vec::<String>::new());
|
||||
assert_eq!(days("daily", "2026-10-10", &[], "2026-10-01", "2026-10-09"), Vec::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_far_window_jumps_instead_of_walking() {
|
||||
let out = days("daily", "1970-01-01", &[], "2026-10-17", "2026-10-18");
|
||||
assert_eq!(out, ["2026-10-17", "2026-10-18"]);
|
||||
let weekly = days("FREQ=WEEKLY;INTERVAL=3;BYDAY=MO,TH", "2000-01-03", &[], "2026-10-01", "2026-10-31");
|
||||
assert!(weekly.iter().all(|s| s.as_str() >= "2026-10-01" && s.as_str() <= "2026-10-31") && !weekly.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_results_is_an_error() {
|
||||
let rule: Rule = "daily".parse().unwrap();
|
||||
assert!(matches!(rule.occurrences(t("2000-01-01"), &[], d("2000-01-01"), d("2100-01-01")), Err(Error::TooMany(MAX_OCCURRENCES))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_rules_are_refused() {
|
||||
for text in [
|
||||
"",
|
||||
"hourly",
|
||||
"FREQ=DAILY;FOO=1",
|
||||
"FREQ=DAILY;COUNT=0",
|
||||
"FREQ=DAILY;INTERVAL=0",
|
||||
"FREQ=DAILY;INTERVAL=1001",
|
||||
"FREQ=DAILY;COUNT=1;UNTIL=2026-01-01",
|
||||
"FREQ=MONTHLY;BYDAY=MO",
|
||||
"FREQ=WEEKLY;BYDAY=XX",
|
||||
"FREQ=WEEKLY;BYDAY=",
|
||||
"INTERVAL=2",
|
||||
"FREQ",
|
||||
"FREQ=DAILY;UNTIL=2026-13-01",
|
||||
"FREQ=DAILY;COUNT=-1",
|
||||
&"x".repeat(300),
|
||||
] {
|
||||
assert!(text.parse::<Rule>().is_err(), "{text:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_form_round_trips() {
|
||||
for text in ["FREQ=DAILY", "FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;UNTIL=2026-12-31", "FREQ=MONTHLY;COUNT=12", "FREQ=YEARLY"] {
|
||||
assert_eq!(text.parse::<Rule>().unwrap().to_string(), text);
|
||||
}
|
||||
assert_eq!("Weekly".parse::<Rule>().unwrap().to_string(), "FREQ=WEEKLY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_series_adds_up_to_the_original() {
|
||||
let from = d("2026-01-01");
|
||||
let to = d("2027-12-31");
|
||||
for (rule, start, at) in [
|
||||
("FREQ=WEEKLY;BYDAY=MO,WE", "2026-01-05T10:00", "2026-06-10"),
|
||||
("FREQ=DAILY;COUNT=60", "2026-02-01", "2026-03-01"),
|
||||
("FREQ=MONTHLY;UNTIL=2027-06-30", "2026-01-31", "2026-08-15"),
|
||||
("FREQ=YEARLY", "2024-02-29", "2030-01-01"),
|
||||
("FREQ=WEEKLY;INTERVAL=2", "2026-01-05", "2026-01-19"),
|
||||
] {
|
||||
let rule: Rule = rule.parse().unwrap();
|
||||
let (start, at) = (t(start), d(at));
|
||||
let whole = rule.occurrences(start, &[], from, to).unwrap();
|
||||
let split = rule.split(start, at).unwrap();
|
||||
let mut joined = split.head.occurrences(start, &[], from, to).unwrap();
|
||||
joined.extend(split.tail.occurrences(split.tail_start, &[], from, to).unwrap());
|
||||
assert_eq!(joined, whole, "{rule}");
|
||||
assert!(split.tail_start.date() >= at && joined.iter().all(|o| *o != split.tail_start || o.date() >= at));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_needs_a_part_on_each_side() {
|
||||
let rule: Rule = "daily".parse().unwrap();
|
||||
assert!(rule.split(t("2026-10-10"), d("2026-10-10")).is_err(), "nothing before");
|
||||
assert!(rule.split(t("2026-10-10"), d("2026-10-01")).is_err(), "cut before the start");
|
||||
let capped: Rule = "FREQ=DAILY;COUNT=2".parse().unwrap();
|
||||
assert!(capped.split(t("2026-10-10"), d("2026-11-01")).is_err(), "nothing after");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_rules_stay_inside_their_bounds() {
|
||||
let mut seed = 0x9E37_79B9_7F4A_7C15_u64;
|
||||
let mut next = |modulo: u64| {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(seed >> 33) % modulo
|
||||
};
|
||||
for _ in 0..500 {
|
||||
let freq = ["DAILY", "WEEKLY", "MONTHLY", "YEARLY"][next(4) as usize];
|
||||
let mut text = format!("FREQ={freq};INTERVAL={}", 1 + next(5));
|
||||
match next(3) {
|
||||
0 => text.push_str(&format!(";COUNT={}", 1 + next(40))),
|
||||
1 => text.push_str(&format!(";UNTIL=20{:02}-{:02}-{:02}", 26 + next(5), 1 + next(12), 1 + next(28))),
|
||||
_ => {}
|
||||
}
|
||||
let rule: Rule = text.parse().unwrap();
|
||||
let start = t(&format!("20{:02}-{:02}-{:02}T{:02}:{:02}", 20 + next(10), 1 + next(12), 1 + next(28), next(24), next(60)));
|
||||
let from = start.date().add_days(i32::try_from(next(800)).unwrap() - 200).unwrap();
|
||||
let to = from.add_days(i32::try_from(next(900)).unwrap()).unwrap();
|
||||
let out = rule.occurrences(start, &[], from, to).unwrap();
|
||||
assert!(out.len() <= MAX_OCCURRENCES, "{text}");
|
||||
assert!(out.windows(2).all(|w| w[0] < w[1]), "{text} not strictly increasing");
|
||||
assert!(out.iter().all(|o| o.date() >= from && o.date() <= to && o.date() >= start.date()), "{text} outside window");
|
||||
if let Some(count) = rule.count {
|
||||
assert!(out.len() <= count as usize, "{text}");
|
||||
}
|
||||
if let Some(until) = rule.until {
|
||||
assert!(out.iter().all(|o| o.date() <= until), "{text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
//! Reminders with an injected clock.
|
||||
#![allow(clippy::unwrap_used, clippy::assert_is_empty)]
|
||||
|
||||
use notescraft_calendar::{
|
||||
Alert, Item, Kind, Notifier, ReminderSettings, ReminderState, Status, alerts_for, catch_up, deliver, dismiss, epoch_minutes, inbox, snooze, tick,
|
||||
};
|
||||
|
||||
fn at(text: &str) -> i64 {
|
||||
epoch_minutes(text.parse().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn event(id: &str, start: &str, reminders: &[u32]) -> Item {
|
||||
Item {
|
||||
id: id.to_owned(),
|
||||
kind: Kind::Event,
|
||||
title: id.to_owned(),
|
||||
start: start.parse().unwrap(),
|
||||
end: None,
|
||||
calendar: None,
|
||||
tags: vec![],
|
||||
priority: None,
|
||||
status: None,
|
||||
has_reminder: !reminders.is_empty(),
|
||||
reminders: reminders.to_vec(),
|
||||
note: None,
|
||||
created: None,
|
||||
done: false,
|
||||
repeats: false,
|
||||
source: None,
|
||||
tz: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn keys(alerts: &[Alert]) -> Vec<&str> {
|
||||
alerts.iter().map(|alert| alert.item_id.as_str()).collect()
|
||||
}
|
||||
|
||||
fn fresh(now: &str) -> ReminderState {
|
||||
ReminderState { last_seen: Some(at(now)), ..ReminderState::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alert_fires_once_at_its_time() {
|
||||
let items = [event("standup", "2026-10-20T09:30", &[10])];
|
||||
let settings = ReminderSettings::default();
|
||||
let mut state = fresh("2026-10-20T09:00");
|
||||
assert!(tick(&items, at("2026-10-20T09:19"), &settings, &mut state).notify.is_empty(), "not yet");
|
||||
let fired = tick(&items, at("2026-10-20T09:20"), &settings, &mut state);
|
||||
assert_eq!(keys(&fired.notify), ["standup"]);
|
||||
assert_eq!(fired.notify[0].minutes_before, 10);
|
||||
assert!(tick(&items, at("2026-10-20T09:21"), &settings, &mut state).notify.is_empty(), "never twice");
|
||||
assert!(tick(&items, at("2026-10-20T10:00"), &settings, &mut state).notify.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn several_reminders_and_defaults() {
|
||||
let items = [event("a", "2026-10-20T10:00", &[60, 5]), event("b", "2026-10-20T10:00", &[])];
|
||||
let settings = ReminderSettings { default_minutes: vec![15], ..ReminderSettings::default() };
|
||||
let all = alerts_for(&items, &settings);
|
||||
assert_eq!(all.iter().map(|alert| (alert.item_id.as_str(), alert.minutes_before)).collect::<Vec<_>>(), [("a", 60), ("b", 15), ("a", 5)]);
|
||||
let none = alerts_for(&items, &ReminderSettings::default());
|
||||
assert_eq!(none.len(), 2, "no default means an event with no reminder stays quiet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_day_tasks_and_cancelled_events_never_alert() {
|
||||
let mut task = event("task", "2026-10-20T09:00", &[10]);
|
||||
task.kind = Kind::Task;
|
||||
let mut cancelled = event("cancelled", "2026-10-20T09:00", &[10]);
|
||||
cancelled.status = Some(Status::Cancelled);
|
||||
let all_day = event("all-day", "2026-10-20", &[10]);
|
||||
assert!(alerts_for(&[task, cancelled, all_day], &ReminderSettings::default()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snoozing_brings_it_back_once_and_dismissing_ends_it() {
|
||||
let items = [event("standup", "2026-10-20T09:30", &[10])];
|
||||
let settings = ReminderSettings::default();
|
||||
let mut state = fresh("2026-10-20T09:00");
|
||||
let alert = tick(&items, at("2026-10-20T09:20"), &settings, &mut state).notify.remove(0);
|
||||
snooze(&mut state, &alert.key, at("2026-10-20T09:20"), 5);
|
||||
assert!(tick(&items, at("2026-10-20T09:24"), &settings, &mut state).notify.is_empty());
|
||||
assert_eq!(inbox(&items, at("2026-10-20T09:24"), &settings, &state).snoozed.len(), 1);
|
||||
let back = tick(&items, at("2026-10-20T09:25"), &settings, &mut state);
|
||||
assert_eq!(keys(&back.notify), ["standup"]);
|
||||
assert!(tick(&items, at("2026-10-20T09:26"), &settings, &mut state).notify.is_empty(), "it comes back once");
|
||||
snooze(&mut state, &alert.key, at("2026-10-20T09:26"), 5);
|
||||
dismiss(&mut state, &alert.key);
|
||||
assert!(tick(&items, at("2026-10-20T09:40"), &settings, &mut state).notify.is_empty(), "dismissed means gone");
|
||||
assert_eq!(inbox(&items, at("2026-10-20T09:40"), &settings, &state), notescraft_calendar::Inbox::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quiet_hours_keep_the_inbox_but_not_the_sound() {
|
||||
let items = [event("late", "2026-10-20T23:30", &[10]), event("early", "2026-10-21T06:30", &[10])];
|
||||
let settings = ReminderSettings { quiet: Some((22 * 60, 7 * 60)), ..ReminderSettings::default() };
|
||||
let mut state = fresh("2026-10-20T23:00");
|
||||
let first = tick(&items, at("2026-10-20T23:20"), &settings, &mut state);
|
||||
assert!(first.notify.is_empty());
|
||||
assert_eq!(keys(&first.quiet), ["late"]);
|
||||
let second = tick(&items, at("2026-10-21T06:20"), &settings, &mut state);
|
||||
assert_eq!(keys(&second.quiet), ["early"], "the window wraps past midnight");
|
||||
let shown = inbox(&items, at("2026-10-21T06:25"), &settings, &state);
|
||||
assert_eq!(keys(&shown.due), ["early"], "a quiet alert is still waiting in the inbox");
|
||||
let day = ReminderSettings { quiet: Some((9 * 60, 17 * 60)), ..ReminderSettings::default() };
|
||||
let mut state = fresh("2026-10-21T08:00");
|
||||
assert_eq!(
|
||||
keys(&tick(&[event("noon", "2026-10-21T12:10", &[10])], at("2026-10-21T12:00"), &day, &mut state).quiet),
|
||||
["noon"],
|
||||
"a daytime window works too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_fired_while_the_app_was_closed_is_missed_not_notified() {
|
||||
let items = [event("one", "2026-10-20T09:30", &[10]), event("two", "2026-10-20T20:00", &[10]), event("old", "2026-10-19T09:30", &[10])];
|
||||
let settings = ReminderSettings::default();
|
||||
let mut state = fresh("2026-10-20T09:00");
|
||||
let missed = catch_up(&items, at("2026-10-20T12:00"), &settings, &mut state);
|
||||
assert_eq!(keys(&missed), ["one"], "only what fell between the last run and now");
|
||||
assert_eq!(state.last_seen, Some(at("2026-10-20T12:00")));
|
||||
assert!(tick(&items, at("2026-10-20T12:05"), &settings, &mut state).notify.is_empty(), "a missed alert is not re-notified as new");
|
||||
let shown = inbox(&items, at("2026-10-20T12:05"), &settings, &state);
|
||||
assert_eq!((keys(&shown.missed), keys(&shown.upcoming)), (vec!["one"], vec!["two"]));
|
||||
assert!(shown.due.is_empty());
|
||||
let mut empty = ReminderState::default();
|
||||
assert!(catch_up(&items, at("2026-10-20T12:00"), &settings, &mut empty).is_empty(), "a first run has nothing to miss");
|
||||
assert_eq!(empty.last_seen, Some(at("2026-10-20T12:00")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_survives_json_and_the_clock_math_is_right() {
|
||||
let items = [event("standup", "2026-10-20T09:30", &[10])];
|
||||
let settings = ReminderSettings::default();
|
||||
let mut state = fresh("2026-10-20T09:00");
|
||||
tick(&items, at("2026-10-20T09:20"), &settings, &mut state);
|
||||
snooze(&mut state, "x|y|1", 5, 5);
|
||||
let again: ReminderState = serde_json::from_str(&serde_json::to_string(&state).unwrap()).unwrap();
|
||||
assert_eq!(again, state);
|
||||
assert_eq!(at("1970-01-02T00:01") - at("1970-01-01T00:00"), 1441);
|
||||
assert_eq!(at("2027-01-01") - at("2026-12-31"), 1440);
|
||||
assert_eq!(serde_json::from_str::<ReminderState>("{}").unwrap(), ReminderState::default());
|
||||
}
|
||||
|
||||
struct Recorder {
|
||||
seen: Vec<String>,
|
||||
fail: bool,
|
||||
}
|
||||
|
||||
impl Notifier for Recorder {
|
||||
fn notify(&mut self, alert: &Alert) -> Result<(), String> {
|
||||
if self.fail {
|
||||
return Err(format!("no permission for {}", alert.item_id));
|
||||
}
|
||||
self.seen.push(alert.title.clone());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delivery_reports_what_the_notifier_refused() {
|
||||
let items = [event("a", "2026-10-20T09:30", &[10]), event("b", "2026-10-20T09:30", &[10])];
|
||||
let mut state = fresh("2026-10-20T09:00");
|
||||
let due = tick(&items, at("2026-10-20T09:20"), &ReminderSettings::default(), &mut state);
|
||||
let mut ok = Recorder { seen: vec![], fail: false };
|
||||
assert!(deliver(&mut ok, &due).is_empty());
|
||||
assert_eq!(ok.seen, ["a", "b"]);
|
||||
let mut refused = Recorder { seen: vec![], fail: true };
|
||||
assert_eq!(deliver(&mut refused, &due).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_state_never_panics() {
|
||||
let mut state = ReminderState { last_seen: Some(i64::MAX), ..ReminderState::default() };
|
||||
state.snoozed.insert("k".to_owned(), i64::MIN);
|
||||
let items = [event("a", "2026-10-20T09:30", &[u32::MAX])];
|
||||
let settings = ReminderSettings { quiet: Some((u32::MAX, 0)), default_minutes: vec![u32::MAX] };
|
||||
let _ = tick(&items, 0, &settings, &mut state);
|
||||
let _ = tick(&items, i64::MAX, &settings, &mut state);
|
||||
let _ = catch_up(&items, i64::MIN, &settings, &mut state);
|
||||
let _ = inbox(&items, i64::MAX, &settings, &state);
|
||||
snooze(&mut state, "k", i64::MAX, u32::MAX);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Feeds: addresses, schedule, offline cache and what never gets logged.
|
||||
#![allow(clippy::unwrap_used, clippy::assert_is_empty, clippy::unnecessary_wraps)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use notescraft_calendar::{CachedFeed, Feed, FeedStatus, Fetch, Fetched, due, epoch_minutes, log_line, redact, refresh};
|
||||
|
||||
const FEED: &str = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:a@x\r\nDTSTART:20261020T093000\r\nSUMMARY:Secret meeting\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:b@x\r\nDTSTART:20261021\r\nSUMMARY:Holiday\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
const SECRET: &str = "TOKEN1234SECRET";
|
||||
|
||||
struct Script(Vec<Result<Fetched, String>>, Vec<Option<String>>);
|
||||
|
||||
impl Fetch for Script {
|
||||
fn fetch(&mut self, _url: &str, etag: Option<&str>) -> Result<Fetched, String> {
|
||||
self.1.push(etag.map(str::to_owned));
|
||||
self.0.remove(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn feed() -> Feed {
|
||||
Feed::new("Team calendar", &format!("webcal://calendar.example.com/team.ics?token={SECRET}&x=1"), Some(30)).unwrap()
|
||||
}
|
||||
|
||||
fn t(text: &str) -> notescraft_calendar::EventTime {
|
||||
text.parse().unwrap()
|
||||
}
|
||||
|
||||
fn body(etag: Option<&str>) -> Result<Fetched, String> {
|
||||
Ok(Fetched::Body { text: FEED.to_owned(), etag: etag.map(str::to_owned) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_names_and_intervals_are_checked() {
|
||||
let ok = feed();
|
||||
assert_eq!((ok.id.as_str(), ok.refresh_minutes), ("team-calendar", 30));
|
||||
assert!(ok.url.starts_with("https://calendar.example.com"), "webcal is read as https");
|
||||
assert_eq!(Feed::new("x", "https://a.example/x.ics", None).unwrap().refresh_minutes, 60);
|
||||
for (name, url, refresh) in [
|
||||
("", "https://a.example/x.ics", None),
|
||||
("x", "ftp://a.example/x.ics", None),
|
||||
("x", "javascript:alert(1)", None),
|
||||
("x", "https://", None),
|
||||
("x", "https://a.example/a b.ics", None),
|
||||
("x", "https://a.example/x.ics\n", Some(1)),
|
||||
("x", "https://a.example/x.ics", Some(1)),
|
||||
("x", "https://a.example/x.ics", Some(99_999_999)),
|
||||
] {
|
||||
assert!(Feed::new(name, url, refresh).is_err(), "{name:?} {url:?} {refresh:?}");
|
||||
}
|
||||
assert!(Feed::new("x", &format!("https://a.example/{}", "a".repeat(3000)), None).is_err());
|
||||
assert_eq!(Feed::new("日本 語!!", "https://a.example/x.ics", None).unwrap().id, "feed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feeds_are_due_on_their_schedule_and_retried_sooner_after_a_failure() {
|
||||
let feed = feed();
|
||||
let at = |text: &str| epoch_minutes(t(text)).unwrap();
|
||||
let mut statuses = BTreeMap::new();
|
||||
assert_eq!(due(std::slice::from_ref(&feed), &statuses, at("2026-10-20T09:00"), false).len(), 1, "never fetched");
|
||||
statuses.insert(
|
||||
feed.id.clone(),
|
||||
FeedStatus { attempted_at: Some("2026-10-20T09:00".into()), fetched_at: Some("2026-10-20T09:00".into()), ..FeedStatus::default() },
|
||||
);
|
||||
assert!(due(std::slice::from_ref(&feed), &statuses, at("2026-10-20T09:29"), false).is_empty());
|
||||
assert_eq!(due(std::slice::from_ref(&feed), &statuses, at("2026-10-20T09:30"), false).len(), 1);
|
||||
assert_eq!(due(std::slice::from_ref(&feed), &statuses, at("2026-10-20T09:01"), true).len(), 1, "force ignores the schedule");
|
||||
statuses.get_mut(&feed.id).unwrap().error = Some("offline".into());
|
||||
assert_eq!(due(std::slice::from_ref(&feed), &statuses, at("2026-10-20T09:15"), false).len(), 1, "a failing feed is retried after 15 minutes");
|
||||
statuses.insert(feed.id.clone(), FeedStatus { attempted_at: Some("not a time".into()), ..FeedStatus::default() });
|
||||
assert_eq!(due(&[feed], &statuses, 0, false).len(), 1, "a damaged status counts as never fetched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refresh_fills_the_cache_and_remembers_the_validator() {
|
||||
let feed = feed();
|
||||
let mut script = Script(vec![body(Some("v1")), Ok(Fetched::NotModified)], vec![]);
|
||||
let first = refresh(&feed, None, &mut script, t("2026-10-20T09:00"));
|
||||
assert_eq!((first.events.len(), first.status.events, first.status.etag.as_deref(), first.status.error.clone()), (2, 2, Some("v1"), None));
|
||||
assert_eq!(first.status.fetched_at.as_deref(), Some("2026-10-20T09:00"));
|
||||
let second = refresh(&feed, Some(&first), &mut script, t("2026-10-20T10:00"));
|
||||
assert_eq!(script.1, [None, Some("v1".to_owned())], "the validator is sent back");
|
||||
assert_eq!(
|
||||
(second.events.len(), second.status.fetched_at.as_deref(), second.status.error.clone()),
|
||||
(2, Some("2026-10-20T10:00"), None),
|
||||
"not modified keeps the events"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_keeps_the_last_events_and_names_no_address() {
|
||||
let feed = feed();
|
||||
let first = refresh(&feed, None, &mut Script(vec![body(None)], vec![]), t("2026-10-20T09:00"));
|
||||
let failed = refresh(&feed, Some(&first), &mut Script(vec![Err(format!("connect failed: {}", feed.url))], vec![]), t("2026-10-20T10:00"));
|
||||
assert_eq!(failed.events.len(), 2, "cached events survive going offline");
|
||||
assert_eq!(failed.status.fetched_at.as_deref(), Some("2026-10-20T09:00"), "the last good fetch stays");
|
||||
assert_eq!(failed.status.attempted_at.as_deref(), Some("2026-10-20T10:00"));
|
||||
let error = failed.status.error.clone().unwrap();
|
||||
assert!(error.contains("connect failed") && !error.contains(SECRET) && !error.contains("calendar.example.com"), "{error}");
|
||||
let broken =
|
||||
refresh(&feed, Some(&first), &mut Script(vec![Ok(Fetched::Body { text: "not a calendar".into(), etag: None })], vec![]), t("2026-10-20T11:00"));
|
||||
assert_eq!(broken.events.len(), 2, "a bad file does not wipe the cache");
|
||||
assert!(broken.status.error.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logs_and_caches_carry_neither_the_address_nor_event_text() {
|
||||
let feed = feed();
|
||||
let ok = refresh(&feed, None, &mut Script(vec![body(Some("e"))], vec![]), t("2026-10-20T09:00"));
|
||||
let failed = refresh(&feed, Some(&ok), &mut Script(vec![Err(format!("timeout talking to {}", feed.url))], vec![]), t("2026-10-20T10:00"));
|
||||
for status in [&ok.status, &failed.status] {
|
||||
let line = log_line(&feed, status, t("2026-10-20T10:00"));
|
||||
assert!(!line.contains(SECRET) && !line.contains("example.com") && !line.contains("Secret meeting"), "{line}");
|
||||
}
|
||||
let stored = serde_json::to_string(&failed.status).unwrap();
|
||||
assert!(!stored.contains(SECRET), "{stored}");
|
||||
let roundtrip: CachedFeed = serde_json::from_str(&serde_json::to_string(&failed).unwrap()).unwrap();
|
||||
assert_eq!(roundtrip, failed);
|
||||
assert_eq!(serde_json::from_str::<CachedFeed>("{}").unwrap(), CachedFeed::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_removes_userinfo_query_and_host() {
|
||||
let url = "https://bob:hunter2@calendar.example.com/p/cal.ics?key=ABCDEF&u=bob";
|
||||
let text = format!("GET {url} failed; user bob:hunter2 key ABCDEF");
|
||||
let clean = redact(&text, url);
|
||||
for secret in ["hunter2", "ABCDEF", "calendar.example.com"] {
|
||||
assert!(!clean.contains(secret), "{secret} in {clean}");
|
||||
}
|
||||
assert!(redact(&"x".repeat(1000), url).chars().count() <= 200);
|
||||
}
|
||||
@@ -14,8 +14,10 @@ path = "src/lib.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
notescraft-blocks = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
notescraft-markdown = { workspace = true }
|
||||
notescraft-syntax = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ pub struct EditorColors {
|
||||
pub accent: Color32,
|
||||
/// Text selection rectangle (passed through for consistency).
|
||||
pub selection: Color32,
|
||||
/// Token colours inside fenced code, in `notescraft_syntax::TokenKind::ALL` order.
|
||||
pub syntax: [Color32; 8],
|
||||
}
|
||||
|
||||
impl EditorColors {
|
||||
@@ -51,6 +53,16 @@ impl EditorColors {
|
||||
highlight: Color32::from_rgba_unmultiplied(0x50, 0x4D, 0x2E, 0xFF),
|
||||
accent: Color32::from_rgb(0x89, 0xB4, 0xFA),
|
||||
selection: Color32::from_rgb(0x36, 0x3A, 0x4F),
|
||||
syntax: [
|
||||
Color32::from_rgb(0xCB, 0xA6, 0xF7),
|
||||
Color32::from_rgb(0xA6, 0xE3, 0xA1),
|
||||
Color32::from_rgb(0xFA, 0xB3, 0x87),
|
||||
Color32::from_rgb(0x7F, 0x84, 0x9C),
|
||||
Color32::from_rgb(0xF9, 0xE2, 0xAF),
|
||||
Color32::from_rgb(0x89, 0xB4, 0xFA),
|
||||
Color32::from_rgb(0x94, 0xE2, 0xD5),
|
||||
Color32::from_rgb(0x89, 0xDC, 0xEB),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ pub struct EditorResponse {
|
||||
pub cursor_line_col: (usize, usize),
|
||||
/// The caret's rectangle on screen, for anchoring popups.
|
||||
pub caret_rect: Option<egui::Rect>,
|
||||
/// The laid-out text and its top-left corner on screen, for hosts that paint over the text.
|
||||
pub galley: Option<(std::sync::Arc<egui::Galley>, egui::Pos2)>,
|
||||
}
|
||||
|
||||
/// The `TextEdit` id [`show_editor`] uses, so a host can drive its state
|
||||
@@ -191,7 +193,7 @@ pub fn show_modal_editor(
|
||||
if changed {
|
||||
state.version = state.version.wrapping_add(1);
|
||||
}
|
||||
EditorResponse { changed, link_clicked, cursor_line_col, caret_rect }
|
||||
EditorResponse { changed, link_clicked, cursor_line_col, caret_rect, galley: Some((out.galley.clone(), out.galley_pos)) }
|
||||
}
|
||||
|
||||
/// Paint a block spanning the next laid-out character, or a space at line end.
|
||||
@@ -236,6 +238,14 @@ fn handle_event(state: &mut EditorState, sel: &mut (usize, usize), event: &mut E
|
||||
}
|
||||
false // Tab is ours, always
|
||||
}
|
||||
Event::Key { key: Key::Backspace, pressed: true, modifiers, .. } if !modifiers.command && !modifiers.alt => {
|
||||
// Backspace inside an empty pair (`()`, `[]`, `""`) removes both halves.
|
||||
if let Some(edit) = crate::pairs::on_backspace(&state.text, sel.0..sel.1) {
|
||||
*edited |= apply_edit(state, sel, &edit);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
Event::Key { key, pressed: true, modifiers, .. } if matches!(key, Key::B | Key::I | Key::K) => {
|
||||
if !(modifiers.command) {
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Syntax colours inside fenced code in source mode.
|
||||
//!
|
||||
//! The fence label picks the grammar; the shared highlight cache parses off the UI thread, so a
|
||||
//! block that is not ready yet is painted as plain code and gains colour on a later frame.
|
||||
//! Source mode has no notion of the visible window, so big fences stay plain
|
||||
//! ([`MAX_FENCE_BYTES`], [`MAX_FENCE_LINES`]); rendered mode colours them window by window.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use notescraft_syntax::global;
|
||||
|
||||
use crate::highlight::Sty;
|
||||
|
||||
/// Largest fence body that is coloured in source mode, in bytes.
|
||||
pub const MAX_FENCE_BYTES: usize = 256 * 1024;
|
||||
/// Most lines of a fence body that are coloured in source mode.
|
||||
pub const MAX_FENCE_LINES: usize = 2000;
|
||||
|
||||
/// Style pieces covering the whole `fence` range, in order: token pieces in their colour and
|
||||
/// everything else as plain code. A fence with no usable grammar yields one plain piece.
|
||||
pub fn fence_assigns(text: &str, fence: &Range<usize>) -> Vec<(Range<usize>, Sty)> {
|
||||
let plain = || vec![(fence.clone(), Sty::Code)];
|
||||
let Some(tokens) = tokens(text, fence) else { return plain() };
|
||||
let mut out = Vec::with_capacity(tokens.len().saturating_mul(2).saturating_add(1));
|
||||
let mut pos = fence.start;
|
||||
for (range, kind) in tokens {
|
||||
if range.start < pos || range.end > fence.end || range.start >= range.end {
|
||||
continue;
|
||||
}
|
||||
if range.start > pos {
|
||||
out.push((pos..range.start, Sty::Code));
|
||||
}
|
||||
pos = range.end;
|
||||
out.push((range, Sty::Syntax(kind)));
|
||||
}
|
||||
if pos < fence.end {
|
||||
out.push((pos..fence.end, Sty::Code));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Absolute token ranges of a fence, sorted and non-overlapping; `None` while not ready or when plain.
|
||||
fn tokens(text: &str, fence: &Range<usize>) -> Option<Vec<(Range<usize>, notescraft_syntax::TokenKind)>> {
|
||||
let block = text.get(fence.clone())?;
|
||||
let (opener, rest) = block.split_once('\n')?;
|
||||
let marker = opener.trim_start().chars().next().filter(|c| matches!(c, '`' | '~'))?;
|
||||
let label = opener.trim_start().trim_start_matches(marker).split_whitespace().next()?;
|
||||
let body_start = fence.start.checked_add(opener.len())?.checked_add(1)?;
|
||||
let body = body_without_closer(rest, marker);
|
||||
if body.len() > MAX_FENCE_BYTES || body.bytes().filter(|b| *b == b'\n').count() > MAX_FENCE_LINES {
|
||||
return None;
|
||||
}
|
||||
let lines = body.split_inclusive('\n').count();
|
||||
let found = global().get(label, body, 0..lines)?;
|
||||
let mut out = Vec::new();
|
||||
let mut offset = body_start;
|
||||
for (index, line) in body.split_inclusive('\n').enumerate() {
|
||||
let spans = found.first_line.checked_add(0).and_then(|first| index.checked_sub(first)).and_then(|at| found.lines.get(at));
|
||||
for span in spans.into_iter().flatten() {
|
||||
out.push((offset.saturating_add(span.start)..offset.saturating_add(span.end), span.kind));
|
||||
}
|
||||
offset = offset.saturating_add(line.len());
|
||||
}
|
||||
(!out.is_empty()).then_some(out)
|
||||
}
|
||||
|
||||
/// `rest` (everything after the opening line) without a trailing closing-fence line.
|
||||
fn body_without_closer(rest: &str, marker: char) -> &str {
|
||||
let last_start = rest.trim_end_matches(['\n', '\r']).rfind('\n').map_or(0, |at| at + 1);
|
||||
let last = rest.get(last_start..).unwrap_or_default().trim();
|
||||
let closes = last.len() >= 3 && last.chars().all(|c| c == marker);
|
||||
if closes { rest.get(..last_start).unwrap_or(rest) } else { rest }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn wait_for_colour(text: &str, fence: &Range<usize>) -> Vec<(Range<usize>, Sty)> {
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let pieces = fence_assigns(text, fence);
|
||||
if pieces.iter().any(|(_, sty)| matches!(sty, Sty::Syntax(_))) || Instant::now() > until {
|
||||
return pieces;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_labelled_fence_gets_token_pieces_that_tile_the_whole_fence() {
|
||||
let text = "intro\n\n```rust\nfn main() { let n = 42; }\n```\n\nafter\n";
|
||||
let fences = crate::inline::code_fences(text);
|
||||
let fence = fences.first().expect("one fence").clone();
|
||||
let pieces = wait_for_colour(text, &fence);
|
||||
assert!(pieces.iter().any(|(_, sty)| matches!(sty, Sty::Syntax(_))), "{pieces:?}");
|
||||
let mut pos = fence.start;
|
||||
for (range, _) in &pieces {
|
||||
assert_eq!(range.start, pos, "pieces tile the fence without gaps or overlap");
|
||||
pos = range.end;
|
||||
}
|
||||
assert_eq!(pos, fence.end);
|
||||
let first_token = pieces.iter().find(|(_, sty)| matches!(sty, Sty::Syntax(_))).expect("token");
|
||||
assert!(text[first_token.0.clone()].starts_with("fn"), "{:?}", &text[first_token.0.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlabelled_unknown_unclosed_and_huge_fences_stay_plain_or_cover_the_text() {
|
||||
for text in ["```\nfn main() {}\n```\n", "```nosuchlang\nfn main() {}\n```\n", "```", "```rust"] {
|
||||
let fences = crate::inline::code_fences(text);
|
||||
for fence in fences {
|
||||
let pieces = fence_assigns(text, &fence);
|
||||
assert_eq!(pieces.first().map(|(r, _)| r.start), Some(fence.start), "{text:?}");
|
||||
assert_eq!(pieces.last().map(|(r, _)| r.end), Some(fence.end), "{text:?}");
|
||||
}
|
||||
}
|
||||
let big = format!("```rust\n{}\n```\n", "let a = 1;\n".repeat(MAX_FENCE_LINES + 5));
|
||||
let fence = crate::inline::code_fences(&big).first().expect("fence").clone();
|
||||
assert_eq!(fence_assigns(&big, &fence), vec![(fence, Sty::Code)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_fence_colours_to_the_end_of_the_text() {
|
||||
let text = "```rust\nlet s = \"x\";\n";
|
||||
let fence = crate::inline::code_fences(text).first().expect("fence").clone();
|
||||
let pieces = wait_for_colour(text, &fence);
|
||||
assert!(pieces.iter().any(|(_, sty)| matches!(sty, Sty::Syntax(_))));
|
||||
assert_eq!(pieces.last().map(|(r, _)| r.end), Some(text.len()));
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ struct Assign {
|
||||
|
||||
/// Style buckets used by the styler.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum Sty {
|
||||
pub(crate) enum Sty {
|
||||
/// Body text.
|
||||
Plain,
|
||||
/// Frontmatter, comments, block ids.
|
||||
@@ -47,6 +47,8 @@ enum Sty {
|
||||
TagColor,
|
||||
/// Callout header and task marker.
|
||||
Accent,
|
||||
/// A token inside a fenced code block: monospace in the theme's token colour on the code background.
|
||||
Syntax(notescraft_syntax::TokenKind),
|
||||
}
|
||||
|
||||
fn heading_size(base: f32, level: u8) -> f32 {
|
||||
@@ -59,7 +61,7 @@ fn heading_size(base: f32, level: u8) -> f32 {
|
||||
}
|
||||
|
||||
fn text_format(sty: Sty, colors: &EditorColors, base: f32) -> TextFormat {
|
||||
use Sty::{Accent, Code, Dim, Heading, Highlighted, Link, LinkUnresolved, Muted, Plain, TagColor};
|
||||
use Sty::{Accent, Code, Dim, Heading, Highlighted, Link, LinkUnresolved, Muted, Plain, Syntax, TagColor};
|
||||
let prop = FontId::proportional(base);
|
||||
let mono = FontId::monospace(base);
|
||||
match sty {
|
||||
@@ -73,6 +75,7 @@ fn text_format(sty: Sty, colors: &EditorColors, base: f32) -> TextFormat {
|
||||
LinkUnresolved => fmt(prop, colors.link_unresolved, Color32::TRANSPARENT),
|
||||
TagColor => fmt(prop, colors.tag, Color32::TRANSPARENT),
|
||||
Accent => fmt(prop, colors.accent, Color32::TRANSPARENT),
|
||||
Syntax(kind) => fmt(mono, colors.syntax.get(kind.index()).copied().unwrap_or(colors.code), colors.code_background),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +141,9 @@ fn frontmatter(text: &str) -> Vec<Assign> {
|
||||
fn own_scans(text: &str) -> Vec<Assign> {
|
||||
let mut out: Vec<Assign> = Vec::new();
|
||||
let fences = crate::inline::code_fences(text);
|
||||
out.extend(fences.iter().cloned().map(|r| Assign { range: r, prio: PRI_FENCE, sty: Sty::Code }));
|
||||
for fence in &fences {
|
||||
out.extend(crate::fence_syntax::fence_assigns(text, fence).into_iter().map(|(range, sty)| Assign { range, prio: PRI_FENCE, sty }));
|
||||
}
|
||||
let mut skip: Vec<std::ops::Range<usize>> = Vec::new();
|
||||
let fm = notescraft_markdown::yaml::block_range(text);
|
||||
skip.extend(fm);
|
||||
@@ -184,9 +189,9 @@ fn item_assign(item: ¬escraft_markdown::Item, resolver: &dyn Fn(&str) -> bool
|
||||
ItemKind::Comment => (PRI_ITEM_COMMENT, Sty::Muted),
|
||||
ItemKind::Math { .. } => (PRI_ITEM_MATH, Sty::Code),
|
||||
ItemKind::Highlight => (PRI_ITEM_OTHER, Sty::Highlighted),
|
||||
ItemKind::Callout { .. } | ItemKind::TaskItem { .. } => (PRI_ITEM_OTHER, Sty::Accent),
|
||||
ItemKind::Callout { .. } | ItemKind::TaskItem { .. } | ItemKind::Footnote { .. } | ItemKind::InlineFootnote => (PRI_ITEM_OTHER, Sty::Accent),
|
||||
ItemKind::Tag { .. } => (PRI_ITEM_OTHER, Sty::TagColor),
|
||||
ItemKind::BlockId { .. } => (PRI_ITEM_OTHER, Sty::Muted),
|
||||
ItemKind::BlockId { .. } | ItemKind::FootnoteDef { .. } => (PRI_ITEM_OTHER, Sty::Muted),
|
||||
ItemKind::WikiLink { target, .. } | ItemKind::Embed { target, .. } => (PRI_ITEM_OTHER, if resolver(target) { Sty::Link } else { Sty::LinkUnresolved }),
|
||||
ItemKind::Link { dest, internal, .. } => (PRI_ITEM_OTHER, if *internal || resolver(dest) { Sty::Link } else { Sty::LinkUnresolved }),
|
||||
};
|
||||
|
||||
@@ -257,7 +257,7 @@ abc",
|
||||
assert_eq!(spans.len(), 2);
|
||||
assert_eq!(text_range(text, &spans[0]), "`x y`");
|
||||
assert_eq!(text_range(text, &spans[1]), "```c```");
|
||||
assert_eq!(inline_code("`unterminated", &[]), []);
|
||||
assert_eq!(inline_code("`unterminated", &[]), Vec::<Region>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+36
-22
@@ -7,6 +7,8 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::pairs;
|
||||
|
||||
/// Tab produces four spaces.
|
||||
pub const TAB: &str = " ";
|
||||
|
||||
@@ -123,17 +125,30 @@ pub fn wrap_selection(text: &str, sel: std::ops::Range<usize>, open: &str, close
|
||||
Some(Edit { range: start..end, insert, sel: inner })
|
||||
}
|
||||
|
||||
/// Auto-close pairs and skip-over. `typed` is the character being inserted;
|
||||
/// the selection must be empty (else `None` and the caller lets `TextEdit` act).
|
||||
/// Auto-close pairs, wrap the selection and skip over. `typed` is the
|
||||
/// character being inserted.
|
||||
///
|
||||
/// With a selection, an opener (`(`, `[`, `{`, `"`, `` ` ``) wraps it and keeps
|
||||
/// it selected; `*` (bold is `Cmd-B`/`wrap_selection`) and `'` never wrap.
|
||||
/// With the caret empty an opener inserts its pair and leaves the caret
|
||||
/// between, except where a suppression rule applies: `(`, `[` and `{` do not
|
||||
/// pair when the next character is a word character, `"` and `` ` `` do not
|
||||
/// pair when the previous character is one (apostrophes and quotes in words
|
||||
/// type normally), and `'` never pairs. Typing a closer that matches the
|
||||
/// character at the caret steps over it instead of inserting.
|
||||
#[must_use]
|
||||
pub fn auto_close(text: &str, sel: std::ops::Range<usize>, typed: char) -> Option<Edit> {
|
||||
let (start, end) = norm_sel(text, &sel);
|
||||
if start != end || !text.is_char_boundary(start) {
|
||||
return None;
|
||||
if typed == '\'' {
|
||||
return None; // apostrophes (`don't`, `it's`) never pair
|
||||
}
|
||||
if start != end {
|
||||
let close = pairs::closer(typed)?;
|
||||
return wrap_selection(text, start..end, &typed.to_string(), &close.to_string());
|
||||
}
|
||||
let at_cursor = text.get(start..).and_then(|t| t.chars().next());
|
||||
// Skip over an existing closer.
|
||||
if start > 0 && at_cursor == Some(typed) && matches!(typed, ')' | ']' | '"' | '*' | '`') {
|
||||
if start > 0 && at_cursor == Some(typed) && matches!(typed, ')' | ']' | '}' | '"' | '*' | '`') {
|
||||
let step = typed.len_utf8();
|
||||
return Some(Edit { range: start..start, insert: String::new(), sel: (start + step)..(start + step) });
|
||||
}
|
||||
@@ -146,31 +161,30 @@ pub fn auto_close(text: &str, sel: std::ops::Range<usize>, typed: char) -> Optio
|
||||
}
|
||||
return None;
|
||||
}
|
||||
let (open, close) = pair(typed)?;
|
||||
if at_cursor.is_some_and(char::is_alphanumeric) && typed == '"' {
|
||||
return None; // apostrophe-like quoting inside a word
|
||||
let close = pairs::closer(typed)?;
|
||||
let prev = text.get(..start).and_then(|t| t.chars().next_back());
|
||||
if matches!(typed, '(' | '[' | '{') && at_cursor.is_some_and(is_word) {
|
||||
return None; // mid-word openers do not pair
|
||||
}
|
||||
let insert = format!("{open}{close}");
|
||||
let half = open.len_utf8();
|
||||
if matches!(typed, '"' | '`') && prev.is_some_and(is_word) {
|
||||
return None; // quote right after a word does not pair
|
||||
}
|
||||
let insert = format!("{typed}{close}");
|
||||
let half = typed.len_utf8();
|
||||
Some(Edit { range: start..start, insert, sel: (start + half)..(start + half) })
|
||||
}
|
||||
|
||||
/// The auto-close pair for `typed`, if any. `[` closes with `]`, which also
|
||||
/// turns `#[[]]` (`[` typed right before the cursor `]` of a previous pair)
|
||||
/// into the `[[ ]]` wikilink shape naturally.
|
||||
const fn pair(typed: char) -> Option<(char, char)> {
|
||||
match typed {
|
||||
'(' => Some(('(', ')')),
|
||||
'[' => Some(('[', ']')),
|
||||
'"' => Some(('"', '"')),
|
||||
'`' => Some(('`', '`')),
|
||||
_ => None,
|
||||
}
|
||||
/// `true` for word characters (letters, digits, `_`) used by the pairing
|
||||
/// suppression rules.
|
||||
fn is_word(c: char) -> bool {
|
||||
c == '_' || c.is_alphanumeric()
|
||||
}
|
||||
|
||||
// -- shared helpers --------------------------------------------------------
|
||||
|
||||
fn norm_sel(text: &str, sel: &std::ops::Range<usize>) -> (usize, usize) {
|
||||
/// Normalize a selection: floor each end to a char boundary, clamp to the
|
||||
/// text length and order them. Shared with [`crate::pairs::on_backspace`].
|
||||
pub(crate) fn norm_sel(text: &str, sel: &std::ops::Range<usize>) -> (usize, usize) {
|
||||
let floor = |p: usize| {
|
||||
if text.is_char_boundary(p) { usize::min(p, text.len()) } else { prev_boundary(text, p) }
|
||||
};
|
||||
|
||||
@@ -12,32 +12,21 @@
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub use notescraft_blocks as blocks;
|
||||
pub mod colors;
|
||||
pub mod completion;
|
||||
pub mod editor;
|
||||
pub mod highlight;
|
||||
pub mod keys;
|
||||
pub mod marks;
|
||||
pub mod pairs;
|
||||
pub mod preview;
|
||||
pub mod stats;
|
||||
|
||||
mod fence_syntax;
|
||||
mod inline;
|
||||
|
||||
use thiserror::Error as ThisError;
|
||||
|
||||
/// Every failure the editor crate can report.
|
||||
#[derive(Debug, ThisError)]
|
||||
pub enum Error {
|
||||
/// A byte range was not on UTF-8 char boundaries.
|
||||
#[error("byte range {start}..{end} is not on char boundaries")]
|
||||
CharBoundary {
|
||||
/// Range start in bytes.
|
||||
start: usize,
|
||||
/// Range end in bytes.
|
||||
end: usize,
|
||||
},
|
||||
}
|
||||
|
||||
/// Result alias used across the crate.
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
pub use notescraft_blocks::{Error, Result};
|
||||
|
||||
/// Maximum source size the styler parses; larger documents get plain styling.
|
||||
pub const MAX_HIGHLIGHT_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//! Inline marks (bold, italic, ...) as pure toggles on a text selection.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// An inline mark the toolbar can toggle.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Mark {
|
||||
/// `**text**`
|
||||
Bold,
|
||||
/// `*text*`
|
||||
Italic,
|
||||
/// `~~text~~`
|
||||
Strike,
|
||||
/// `` `text` ``
|
||||
Code,
|
||||
/// `[text](url)`
|
||||
Link,
|
||||
/// `==text==`
|
||||
Highlight,
|
||||
}
|
||||
|
||||
impl Mark {
|
||||
/// Every mark, in toolbar order.
|
||||
pub const ALL: [Self; 6] = [Self::Bold, Self::Italic, Self::Strike, Self::Code, Self::Link, Self::Highlight];
|
||||
|
||||
fn marker(self) -> &'static str {
|
||||
match self {
|
||||
Self::Bold => "**",
|
||||
Self::Italic => "*",
|
||||
Self::Strike => "~~",
|
||||
Self::Code => "`",
|
||||
Self::Highlight => "==",
|
||||
Self::Link => "[",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of [`toggle_mark`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Toggled {
|
||||
/// Whole text after the toggle.
|
||||
pub text: String,
|
||||
/// Selection after the toggle (collapsed when a link waits for its url).
|
||||
pub sel: Range<usize>,
|
||||
/// Byte range of the OLD text replaced, and its replacement: one edit.
|
||||
pub replace: (Range<usize>, String),
|
||||
}
|
||||
|
||||
/// The marker bytes around a wrapped selection.
|
||||
struct Wrap {
|
||||
open: Range<usize>,
|
||||
close: Range<usize>,
|
||||
}
|
||||
|
||||
fn check(text: &str, sel: &Range<usize>) -> Result<()> {
|
||||
let ok = sel.start <= sel.end && text.is_char_boundary(sel.start) && text.is_char_boundary(sel.end) && sel.end <= text.len();
|
||||
if ok { Ok(()) } else { Err(Error::CharBoundary { start: sel.start, end: sel.end }) }
|
||||
}
|
||||
|
||||
fn same_line(s: &str) -> bool {
|
||||
!s.contains('\n')
|
||||
}
|
||||
|
||||
fn link_wrap(text: &str, sel: &Range<usize>) -> Option<Wrap> {
|
||||
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
|
||||
let picked = text.get(sel.clone())?;
|
||||
// selection includes the whole `[text](url)`
|
||||
if let Some(p) = picked.rfind("](").filter(|_| picked.starts_with('[') && picked.ends_with(')')) {
|
||||
return Some(Wrap { open: sel.start..sel.start + 1, close: sel.start + p..sel.end });
|
||||
}
|
||||
// selection is the text between `[` and `](url)`
|
||||
if before.ends_with('[') && after.starts_with("](") {
|
||||
let end = after.find(')').filter(|e| same_line(after.get(..*e).unwrap_or("")))?;
|
||||
return Some(Wrap { open: sel.start - 1..sel.start, close: sel.end..sel.end + end + 1 });
|
||||
}
|
||||
// caret or selection inside the url
|
||||
let p = before.rfind("](").filter(|p| same_line(before.get(*p..).unwrap_or("")) && !before.get(*p..).unwrap_or("").contains(')'))?;
|
||||
let lb = before.get(..p)?.rfind('[').filter(|b| same_line(before.get(*b..p).unwrap_or("")))?;
|
||||
let end = after.find(')').filter(|e| same_line(after.get(..*e).unwrap_or("")))?;
|
||||
Some(Wrap { open: lb..lb + 1, close: p..sel.end + end + 1 })
|
||||
}
|
||||
|
||||
fn star_wrap(text: &str, sel: &Range<usize>, mark: Mark) -> Option<Wrap> {
|
||||
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
|
||||
let picked = text.get(sel.clone())?;
|
||||
let run = |it: &mut dyn Iterator<Item = char>| it.take_while(|c| *c == '*').count();
|
||||
let (a, b) = (run(&mut before.chars().rev()), run(&mut after.chars()));
|
||||
let k = a.min(b);
|
||||
let (lead, trail) = (run(&mut picked.chars()), run(&mut picked.chars().rev()));
|
||||
let (k_in, inside) = if k > 0 { (k, true) } else { (lead.min(trail), false) };
|
||||
if !inside && picked.len() <= lead + trail {
|
||||
return None;
|
||||
}
|
||||
let (active, take) = match mark {
|
||||
Mark::Bold => (k_in >= 2, 2),
|
||||
_ => (k_in % 2 == 1, 1),
|
||||
};
|
||||
if !active {
|
||||
return None;
|
||||
}
|
||||
Some(if inside {
|
||||
Wrap { open: sel.start - take..sel.start, close: sel.end..sel.end + take }
|
||||
} else {
|
||||
Wrap { open: sel.start..sel.start + take, close: sel.end - take..sel.end }
|
||||
})
|
||||
}
|
||||
|
||||
fn find_wrap(text: &str, sel: &Range<usize>, mark: Mark) -> Option<Wrap> {
|
||||
match mark {
|
||||
Mark::Link => link_wrap(text, sel),
|
||||
Mark::Bold | Mark::Italic => star_wrap(text, sel, mark),
|
||||
_ => {
|
||||
let m = mark.marker();
|
||||
let (before, after) = (text.get(..sel.start)?, text.get(sel.end..)?);
|
||||
let picked = text.get(sel.clone())?;
|
||||
if before.ends_with(m) && after.starts_with(m) {
|
||||
return Some(Wrap { open: sel.start - m.len()..sel.start, close: sel.end..sel.end + m.len() });
|
||||
}
|
||||
let ok = picked.len() >= 2 * m.len() && picked.starts_with(m) && picked.ends_with(m);
|
||||
ok.then(|| Wrap { open: sel.start..sel.start + m.len(), close: sel.end - m.len()..sel.end })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks active around `sel` (wrapped inside or including the markers).
|
||||
///
|
||||
/// Out-of-range or non char boundary selections report nothing.
|
||||
#[must_use]
|
||||
pub fn active_marks(text: &str, sel: Range<usize>) -> Vec<Mark> {
|
||||
if check(text, &sel).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
Mark::ALL.into_iter().filter(|m| find_wrap(text, &sel, *m).is_some()).collect()
|
||||
}
|
||||
|
||||
/// Wrap `sel` in `mark`, or unwrap it when it is already wrapped.
|
||||
///
|
||||
/// A link wraps as `[text]()` with the selection collapsed in the url.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::CharBoundary`] if `sel` is out of range or splits a character.
|
||||
pub fn toggle_mark(text: &str, sel: Range<usize>, mark: Mark) -> Result<Toggled> {
|
||||
check(text, &sel)?;
|
||||
let inner_of = |r: Range<usize>| text.get(r).unwrap_or("");
|
||||
if let Some(w) = find_wrap(text, &sel, mark) {
|
||||
let inner = inner_of(w.open.end..w.close.start).to_owned();
|
||||
let range = w.open.start..w.close.end;
|
||||
let sel = range.start..range.start + inner.len();
|
||||
return finish(text, range, inner, sel);
|
||||
}
|
||||
let inner = inner_of(sel.clone());
|
||||
let m = mark.marker();
|
||||
if mark == Mark::Link {
|
||||
let at = sel.start + 1 + inner.len() + 2;
|
||||
return finish(text, sel.clone(), format!("[{inner}]()"), at..at);
|
||||
}
|
||||
let new_sel = sel.start + m.len()..sel.end + m.len();
|
||||
finish(text, sel.clone(), format!("{m}{inner}{m}"), new_sel)
|
||||
}
|
||||
|
||||
fn finish(text: &str, range: Range<usize>, new: String, sel: Range<usize>) -> Result<Toggled> {
|
||||
let e = crate::blocks::splice(text, range, new, sel.end)?;
|
||||
Ok(Toggled { text: e.text, sel, replace: e.replace })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Auto-pair characters: the opener/closer table shared with
|
||||
//! [`crate::keys::auto_close`] and the Backspace behaviour for an empty pair.
|
||||
//!
|
||||
//! All byte offsets are on char boundaries; positions not on a boundary are
|
||||
//! floored to one before use (see [`crate::keys`]). No function here panics
|
||||
//! on hostile input: bad positions are normalized, and anything that is not
|
||||
//! exactly the shape described returns `None`.
|
||||
|
||||
use crate::keys::{Edit, norm_sel};
|
||||
|
||||
/// The closer auto-paired with `open` as an opener, if any.
|
||||
///
|
||||
/// `*`, `_`, `~`, `=` (emphasis markers) and `'` (apostrophes) are
|
||||
/// deliberately absent: emphasis wraps through [`crate::keys::wrap_selection`]
|
||||
/// (only the `**` bold completion lives in [`crate::keys::auto_close`]) and
|
||||
/// `'` never pairs so `don't` and `it's` type normally.
|
||||
#[must_use]
|
||||
pub const fn closer(open: char) -> Option<char> {
|
||||
match open {
|
||||
'(' => Some(')'),
|
||||
'[' => Some(']'),
|
||||
'{' => Some('}'),
|
||||
'"' => Some('"'),
|
||||
'`' => Some('`'),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Backspace between an empty pair (`()`, `[]`, `{}`, two quotes or two
|
||||
/// backticks) deletes both characters and puts the caret where the opener
|
||||
/// was.
|
||||
///
|
||||
/// Any other position returns `None` and the caller lets `TextEdit` do its
|
||||
/// normal delete: a non-empty selection, no pair around the caret, or `''`
|
||||
/// and `**` (not auto-pairs, so their halves are deleted one by one).
|
||||
#[must_use]
|
||||
pub fn on_backspace(text: &str, sel: std::ops::Range<usize>) -> Option<Edit> {
|
||||
let (start, end) = norm_sel(text, &sel);
|
||||
if start != end || !text.is_char_boundary(start) {
|
||||
return None;
|
||||
}
|
||||
let prev = text.get(..start).and_then(|t| t.chars().next_back())?;
|
||||
let next = text.get(start..).and_then(|t| t.chars().next())?;
|
||||
if closer(prev) != Some(next) {
|
||||
return None; // not an empty pair: prev is no opener or next is not its closer
|
||||
}
|
||||
let from = start.checked_sub(prev.len_utf8())?;
|
||||
let to = start.checked_add(next.len_utf8())?;
|
||||
Some(Edit { range: from..to, insert: String::new(), sel: from..from })
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
//! Hex and functional colour scanning for one line of note text.
|
||||
|
||||
use super::{Detected, is_word_byte};
|
||||
|
||||
/// Scans one `line` (which starts at byte `base` of the full text) for hex and
|
||||
/// functional colour literals, appending them to `out` in scan order.
|
||||
pub(super) fn scan_line(line: &str, base: usize, out: &mut Vec<Detected>) {
|
||||
let bytes = line.as_bytes();
|
||||
let mut at = 0usize;
|
||||
while let Some(byte) = bytes.get(at) {
|
||||
at = match *byte {
|
||||
b'#' => hex(line, base, at, out),
|
||||
b'r' | b'R' | b'h' | b'H' => functional(line, base, at, out),
|
||||
_ => at + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Hex literal at `hash`: one of `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`.
|
||||
///
|
||||
/// Digits are ASCII hex. A word character (`[A-Za-z0-9_-]`) right before the `#` or right
|
||||
/// after the digits disqualifies the candidate (`note#abc`, `#abcdefg`, `#fff_`): such text is
|
||||
/// an anchor, an issue number or a longer word, not a colour. Returns the index scanning
|
||||
/// continues from.
|
||||
fn hex(line: &str, base: usize, hash: usize, out: &mut Vec<Detected>) -> usize {
|
||||
let bytes = line.as_bytes();
|
||||
let prev = hash.checked_sub(1).and_then(|at| bytes.get(at).copied());
|
||||
let prev_word = prev.is_some_and(is_word_byte);
|
||||
let mut len = 0usize;
|
||||
let mut value: u64 = 0;
|
||||
let mut end = hash + 1;
|
||||
while len < 9 {
|
||||
match bytes.get(end).copied().and_then(hex_digit) {
|
||||
Some(digit) => {
|
||||
value = value * 16 + u64::from(digit);
|
||||
len += 1;
|
||||
end += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !matches!(len, 3 | 4 | 6 | 8) || prev_word || bytes.get(end).copied().is_some_and(is_word_byte) {
|
||||
return hash + 1;
|
||||
}
|
||||
let (rgb, alpha) = match len {
|
||||
3 => ([nibble(value, 8), nibble(value, 4), nibble(value, 0)], 255),
|
||||
4 => ([nibble(value, 12), nibble(value, 8), nibble(value, 4)], nibble(value, 0)),
|
||||
6 => ([octet(value, 16), octet(value, 8), octet(value, 0)], 255),
|
||||
8 => ([octet(value, 24), octet(value, 16), octet(value, 8)], octet(value, 0)),
|
||||
_ => return hash + 1,
|
||||
};
|
||||
out.push(Detected { range: base + hash..base + end, rgb, alpha });
|
||||
end
|
||||
}
|
||||
|
||||
/// Numeric value of one ASCII hex digit.
|
||||
fn hex_digit(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Short-form digit at `shift * 4` bits, doubled (`a` expands to `0xaa`).
|
||||
fn nibble(value: u64, shift: u32) -> u8 {
|
||||
u8::try_from(((value >> shift) & 0xF) * 17).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Byte at `shift` bits of a long-form literal.
|
||||
fn octet(value: u64, shift: u32) -> u8 {
|
||||
u8::try_from((value >> shift) & 0xFF).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Colour-space kind of a functional literal.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind {
|
||||
Rgb,
|
||||
Hsl,
|
||||
}
|
||||
|
||||
/// Unit suffix allowed after a numeric argument.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Unit {
|
||||
None,
|
||||
Percent,
|
||||
Degrees,
|
||||
}
|
||||
|
||||
/// One parsed functional argument: a number plus its unit.
|
||||
struct Arg {
|
||||
value: f64,
|
||||
unit: Unit,
|
||||
}
|
||||
|
||||
/// Functional literal at `start`: `rgb(`, `rgba(`, `hsl(` or `hsla(`
|
||||
/// (case-insensitive). Malformed or out-of-range candidates produce nothing.
|
||||
fn functional(line: &str, base: usize, start: usize, out: &mut Vec<Detected>) -> usize {
|
||||
let bytes = line.as_bytes();
|
||||
if start.checked_sub(1).and_then(|at| bytes.get(at).copied()).is_some_and(is_word_byte) {
|
||||
return start + 1;
|
||||
}
|
||||
let Some((kind, open)) = kind_at(line, start) else {
|
||||
return start + 1;
|
||||
};
|
||||
let Some((args, alpha, close)) = parse_args(line, open) else {
|
||||
return start + 1;
|
||||
};
|
||||
let Some((rgb, alpha)) = build(kind, &args, alpha.as_ref()) else {
|
||||
return start + 1;
|
||||
};
|
||||
out.push(Detected { range: base + start..base + close, rgb, alpha });
|
||||
close
|
||||
}
|
||||
|
||||
/// Matches a function name and its `(` at `start`; returns the kind and the
|
||||
/// index just after the `(`. Longest names are tried first so `rgba(` is not
|
||||
/// read as `rgb` plus a stray `a`.
|
||||
fn kind_at(line: &str, start: usize) -> Option<(Kind, usize)> {
|
||||
const NAMES: [(&str, Kind); 4] = [("rgba", Kind::Rgb), ("hsla", Kind::Hsl), ("rgb", Kind::Rgb), ("hsl", Kind::Hsl)];
|
||||
for (name, kind) in NAMES {
|
||||
let open = start + name.len();
|
||||
let named = line.get(start..open).is_some_and(|head| head.eq_ignore_ascii_case(name)) && line.as_bytes().get(open) == Some(&b'(');
|
||||
if named {
|
||||
return Some((kind, open + 1));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parses the argument list starting just after `(`. Returns the positional
|
||||
/// arguments, the `/`-separated alpha (if any) and the index just after the
|
||||
/// closing `)`. Comma and space both separate; a stray, double or trailing
|
||||
/// comma is malformed.
|
||||
fn parse_args(line: &str, start: usize) -> Option<(Vec<Arg>, Option<Arg>, usize)> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut at = start;
|
||||
let mut args: Vec<Arg> = Vec::new();
|
||||
let mut alpha: Option<Arg> = None;
|
||||
loop {
|
||||
at = skip_spaces(bytes, at);
|
||||
match bytes.get(at).copied() {
|
||||
Some(b')') => return Some((args, alpha, at + 1)),
|
||||
Some(b',') => {
|
||||
at = skip_spaces(bytes, at + 1);
|
||||
if matches!(bytes.get(at), Some(b',' | b')' | b'/')) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(b'/') => {
|
||||
if args.len() != 3 || alpha.is_some() {
|
||||
return None;
|
||||
}
|
||||
let after = skip_spaces(bytes, at + 1);
|
||||
let (value, next) = parse_arg(line, after)?;
|
||||
alpha = Some(value);
|
||||
at = skip_spaces(bytes, next);
|
||||
if bytes.get(at) != Some(&b')') {
|
||||
return None;
|
||||
}
|
||||
return Some((args, alpha, at + 1));
|
||||
}
|
||||
_ => {
|
||||
if args.len() == 4 {
|
||||
return None;
|
||||
}
|
||||
let (value, next) = parse_arg(line, at)?;
|
||||
args.push(value);
|
||||
at = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips spaces and tabs from `at`, returning the next index.
|
||||
fn skip_spaces(bytes: &[u8], mut at: usize) -> usize {
|
||||
while matches!(bytes.get(at), Some(b' ' | b'\t')) {
|
||||
at += 1;
|
||||
}
|
||||
at
|
||||
}
|
||||
|
||||
/// Parses one numeric argument with an optional `%` or `deg` unit.
|
||||
fn parse_arg(line: &str, start: usize) -> Option<(Arg, usize)> {
|
||||
let bytes = line.as_bytes();
|
||||
let mut end = start;
|
||||
while matches!(bytes.get(end), Some(b'0'..=b'9' | b'.' | b'+' | b'-' | b'e' | b'E')) {
|
||||
end += 1;
|
||||
}
|
||||
if end == start {
|
||||
return None;
|
||||
}
|
||||
let value: f64 = line.get(start..end)?.parse().ok()?;
|
||||
if !value.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let unit = match bytes.get(end).copied() {
|
||||
Some(b'%') => {
|
||||
end += 1;
|
||||
Unit::Percent
|
||||
}
|
||||
Some(byte) if byte.is_ascii_alphabetic() => {
|
||||
let tail = line.get(end..end + 3)?;
|
||||
if tail.eq_ignore_ascii_case("deg") {
|
||||
end += 3;
|
||||
Unit::Degrees
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
_ => Unit::None,
|
||||
};
|
||||
Some((Arg { value, unit }, end))
|
||||
}
|
||||
|
||||
/// Validates the arguments and converts them to 8-bit channels plus alpha;
|
||||
/// `None` when anything is malformed or out of range.
|
||||
fn build(kind: Kind, args: &[Arg], alpha: Option<&Arg>) -> Option<([u8; 3], u8)> {
|
||||
let alpha = match alpha.or_else(|| args.get(3)) {
|
||||
Some(arg) => alpha_byte(arg)?,
|
||||
None => 255,
|
||||
};
|
||||
let rgb = match kind {
|
||||
Kind::Rgb => [channel_byte(args.first()?)?, channel_byte(args.get(1)?)?, channel_byte(args.get(2)?)?],
|
||||
Kind::Hsl => {
|
||||
let hue = hue_value(args.first()?)?;
|
||||
let sat = percent_value(args.get(1)?)?;
|
||||
let light = percent_value(args.get(2)?)?;
|
||||
hue_to_rgb(hue, sat, light)
|
||||
}
|
||||
};
|
||||
Some((rgb, alpha))
|
||||
}
|
||||
|
||||
/// One `rgb` channel: bare 0-255 or a percentage of 255.
|
||||
fn channel_byte(arg: &Arg) -> Option<u8> {
|
||||
match arg.unit {
|
||||
Unit::None => ranged(arg.value, 255.0).map(to_byte),
|
||||
Unit::Percent => ranged(arg.value, 100.0).map(|v| to_byte(v / 100.0 * 255.0)),
|
||||
Unit::Degrees => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Saturation or lightness: a percentage 0-100, scaled to 0-1.
|
||||
fn percent_value(arg: &Arg) -> Option<f64> {
|
||||
if arg.unit == Unit::Percent { ranged(arg.value, 100.0).map(|v| v / 100.0) } else { None }
|
||||
}
|
||||
|
||||
/// Hue: any finite number, with or without the `deg` unit.
|
||||
fn hue_value(arg: &Arg) -> Option<f64> {
|
||||
matches!(arg.unit, Unit::None | Unit::Degrees).then_some(arg.value)
|
||||
}
|
||||
|
||||
/// Alpha: bare 0-1 or a percentage, scaled to 0-255.
|
||||
fn alpha_byte(arg: &Arg) -> Option<u8> {
|
||||
match arg.unit {
|
||||
Unit::None => ranged(arg.value, 1.0).map(|v| to_byte(v * 255.0)),
|
||||
Unit::Percent => ranged(arg.value, 100.0).map(|v| to_byte(v / 100.0 * 255.0)),
|
||||
Unit::Degrees => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some(value)` when it lies in `0.0..=max`.
|
||||
fn ranged(value: f64, max: f64) -> Option<f64> {
|
||||
(0.0..=max).contains(&value).then_some(value)
|
||||
}
|
||||
|
||||
/// Rounds a 0-255 channel, clamping float error.
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped to 0..=255, and the cast saturates
|
||||
fn to_byte(value: f64) -> u8 {
|
||||
value.round().clamp(0.0, 255.0) as u8
|
||||
}
|
||||
|
||||
/// CSS hue-to-channel step: `n` is 0 for red, 8 for green and 4 for blue.
|
||||
fn hue_channel(hue: f64, sat: f64, light: f64, n: f64) -> f64 {
|
||||
let k = (n + hue / 30.0) % 12.0;
|
||||
let chroma = sat * light.min(1.0 - light);
|
||||
light - chroma * (-1.0_f64).max((k - 3.0).min(9.0 - k).min(1.0))
|
||||
}
|
||||
|
||||
/// Converts HSL (hue in degrees, saturation and lightness 0-1) to 8-bit RGB
|
||||
/// with CSS rounding. The hue wraps, so `400deg` is `40deg`.
|
||||
fn hue_to_rgb(hue: f64, sat: f64, light: f64) -> [u8; 3] {
|
||||
let hue = hue.rem_euclid(360.0);
|
||||
[to_byte(hue_channel(hue, sat, light, 0.0) * 255.0), to_byte(hue_channel(hue, sat, light, 8.0) * 255.0), to_byte(hue_channel(hue, sat, light, 4.0) * 255.0)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{DEFAULT_LIMITS, Detected, detect_colors};
|
||||
|
||||
fn one(text: &str) -> Option<Detected> {
|
||||
detect_colors(text, false, false, DEFAULT_LIMITS).into_iter().next()
|
||||
}
|
||||
|
||||
fn rgba(text: &str) -> Option<([u8; 3], u8)> {
|
||||
one(text).map(|hit| (hit.rgb, hit.alpha))
|
||||
}
|
||||
|
||||
fn span(text: &str, literal: &str) -> std::ops::Range<usize> {
|
||||
let start = text.find(literal).unwrap_or(usize::MAX);
|
||||
start..start.saturating_add(literal.len())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_forms() {
|
||||
let rows: &[(&str, [u8; 3], u8)] = &[
|
||||
("#fff", [255, 255, 255], 255),
|
||||
("#abc", [170, 187, 204], 255),
|
||||
("#f00", [255, 0, 0], 255),
|
||||
("#1234", [17, 34, 51], 68),
|
||||
("#abcd", [170, 187, 204], 221),
|
||||
("#000000", [0, 0, 0], 255),
|
||||
("#ff0000", [255, 0, 0], 255),
|
||||
("#ABCDEF", [171, 205, 239], 255),
|
||||
("#deadbeef", [222, 173, 190], 239),
|
||||
("#ff000080", [255, 0, 0], 128),
|
||||
("#00000000", [0, 0, 0], 0),
|
||||
];
|
||||
for (text, rgb, alpha) in rows {
|
||||
assert_eq!(rgba(text), Some((*rgb, *alpha)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_adjacency_needs_clean_edges() {
|
||||
let colors: &[&str] = &["#fff.", "(#fff)", "\"#fff\"", "color: #fff;", "é#fff", "#fffé", "##fff", " #fff ", "#fff\r"];
|
||||
for text in colors {
|
||||
assert_eq!(rgba(text), Some(([255, 255, 255], 255)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_rejected() {
|
||||
let rejected: &[&str] = &[
|
||||
"",
|
||||
"#",
|
||||
"#f",
|
||||
"#ff",
|
||||
"#ffffg",
|
||||
"#12345",
|
||||
"#1234567",
|
||||
"#123456789",
|
||||
"#ggg",
|
||||
"#fffg",
|
||||
"x#fff",
|
||||
"a#fff",
|
||||
"f#fff",
|
||||
"#abcdefg",
|
||||
"note#123",
|
||||
"x#abcdef",
|
||||
"a#abcdef",
|
||||
"word#abc123x",
|
||||
"_#fff",
|
||||
"-#fff",
|
||||
"#fff_",
|
||||
"#fff-x",
|
||||
"no colors here",
|
||||
];
|
||||
for text in rejected {
|
||||
assert_eq!(rgba(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_ranges() {
|
||||
for (text, literal) in [("color: #ff0000;", "#ff0000"), ("é #abc x", "#abc")] {
|
||||
assert_eq!(one(text).map(|h| h.range), Some(span(text, literal)), "{text}");
|
||||
}
|
||||
assert_eq!(rgba("color: #ffaa00;"), Some(([255, 170, 0], 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_bytes_stay_on_char_boundaries() {
|
||||
for text in ["\u{0}#fff\u{1}", "😀#abc", "e\u{301}#fff", "#fff\u{2028}"] {
|
||||
let hit = one(text);
|
||||
let checked = hit.is_some_and(|h| text.get(h.range).is_some());
|
||||
assert!(checked, "{text:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! Pure preview scanners for the editor: colour swatches and tree fences.
|
||||
//!
|
||||
//! [`detect_colors`] finds hex and functional (`rgb`/`rgba`/`hsl`/`hsla`)
|
||||
//! colour literals in note text plus, when asked, the basic named CSS colours.
|
||||
//! [`detect_tree`] recognises fenced folder-structure blocks. Both are linear,
|
||||
//! bounded and free of UI types, so `ui-egui` only draws.
|
||||
|
||||
mod color;
|
||||
mod named;
|
||||
mod tree;
|
||||
|
||||
pub use named::NAMED_COLORS;
|
||||
pub use tree::{TreeRow, detect_tree};
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
/// One colour found in note text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Detected {
|
||||
/// Byte range of the literal, always on `char` boundaries of the text.
|
||||
pub range: Range<usize>,
|
||||
/// Red, green and blue channels, each 0-255.
|
||||
pub rgb: [u8; 3],
|
||||
/// Opacity 0-255, where 255 is fully opaque.
|
||||
pub alpha: u8,
|
||||
}
|
||||
|
||||
/// Bounds that keep detection linear and quick on hostile input.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Detection stops after this many colours in one note.
|
||||
pub max_colors: usize,
|
||||
/// Lines longer than this many bytes are not scanned at all.
|
||||
pub max_line_bytes: usize,
|
||||
}
|
||||
|
||||
/// Default bounds: 2,000 colours per note, lines up to 4 KiB.
|
||||
pub const DEFAULT_LIMITS: Limits = Limits { max_colors: 2000, max_line_bytes: 4096 };
|
||||
|
||||
/// Finds colour literals in `text`, sorted by position and never overlapping.
|
||||
///
|
||||
/// Scanned forms: hex (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, digits are
|
||||
/// ASCII hex) and functional `rgb`/`rgba`/`hsl`/`hsla` (case-insensitive, 3 or
|
||||
/// 4 arguments, comma or space separated, optional `/ alpha`). A hex literal
|
||||
/// must not touch a word character (`[A-Za-z0-9_-]`): a non-hex word character
|
||||
/// before the `#` disqualifies it, a hex-digit neighbour before a short form is
|
||||
/// tolerated, and a long form ignores what follows its last digit. Functional
|
||||
/// arguments out of range (such as `256` or `101%`) or malformed give nothing.
|
||||
///
|
||||
/// The basic named CSS colours match whole words, case-insensitively, only
|
||||
/// when `named` is on and `in_code` says the text is inline code.
|
||||
///
|
||||
/// Lines longer than [`Limits::max_line_bytes`] are skipped and scanning stops
|
||||
/// after [`Limits::max_colors`] hits. Ranges are byte ranges on `char`
|
||||
/// boundaries of `text`.
|
||||
#[must_use]
|
||||
pub fn detect_colors(text: &str, named: bool, in_code: bool, limits: Limits) -> Vec<Detected> {
|
||||
let mut found = Vec::new();
|
||||
let mut base = 0usize;
|
||||
for line in text.split('\n') {
|
||||
if found.len() >= limits.max_colors {
|
||||
break;
|
||||
}
|
||||
if line.len() <= limits.max_line_bytes {
|
||||
color::scan_line(line, base, &mut found);
|
||||
if named && in_code {
|
||||
named::scan_line(line, base, &mut found);
|
||||
}
|
||||
}
|
||||
base = base.saturating_add(line.len()).saturating_add(1);
|
||||
}
|
||||
found.sort_by_key(|hit| (hit.range.start, hit.range.end));
|
||||
if found.len() > limits.max_colors {
|
||||
found.truncate(limits.max_colors);
|
||||
}
|
||||
let mut hits = Vec::with_capacity(found.len());
|
||||
let mut end = 0usize;
|
||||
for hit in found {
|
||||
if hit.range.start >= end {
|
||||
end = hit.range.end;
|
||||
hits.push(hit);
|
||||
}
|
||||
}
|
||||
hits
|
||||
}
|
||||
|
||||
/// ASCII characters that continue a word: alphanumerics, `_` and `-`.
|
||||
fn is_word_byte(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lines_scan_sorted_and_in_order() {
|
||||
let text = "red #fff\nhsl(0, 100%, 50%) #000";
|
||||
let hits = detect_colors(text, false, false, DEFAULT_LIMITS);
|
||||
assert_eq!(hits.len(), 3);
|
||||
for (hit, literal) in hits.iter().zip(["#fff", "hsl(0, 100%, 50%)", "#000"]) {
|
||||
let start = text.find(literal).unwrap_or(usize::MAX);
|
||||
assert_eq!(hit.range, start..start.saturating_add(literal.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_lines_scan() {
|
||||
let text = "#fff\r\n#0f0\r\n";
|
||||
let hits = detect_colors(text, false, false, DEFAULT_LIMITS);
|
||||
assert_eq!(hits.len(), 2);
|
||||
for (hit, literal) in hits.iter().zip(["#fff", "#0f0"]) {
|
||||
let start = text.find(literal).unwrap_or(usize::MAX);
|
||||
assert_eq!(hit.range, start..start.saturating_add(literal.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_text_yields_char_boundary_ranges() {
|
||||
let text = "é #fff 😀 hsl(0, 100%, 50%)";
|
||||
let hits = detect_colors(text, false, false, DEFAULT_LIMITS);
|
||||
assert_eq!(hits.len(), 2);
|
||||
for (hit, literal) in hits.iter().zip(["#fff", "hsl(0, 100%, 50%)"]) {
|
||||
let start = text.find(literal).unwrap_or(usize::MAX);
|
||||
assert_eq!(hit.range, start..start.saturating_add(literal.len()));
|
||||
assert!(text.get(hit.range.clone()).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_hits_sort_before_hex_in_the_same_line() {
|
||||
let text = "red #fff";
|
||||
let hits = detect_colors(text, true, true, DEFAULT_LIMITS);
|
||||
assert_eq!(hits.len(), 2);
|
||||
let red = text.find("red").unwrap_or(usize::MAX);
|
||||
assert_eq!(hits.first().map(|h| h.range.clone()), Some(red..red.saturating_add("red".len())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lines_skipped() {
|
||||
let tiny = Limits { max_colors: 10, max_line_bytes: 3 };
|
||||
assert_eq!(detect_colors("#fff", false, false, tiny), Vec::new());
|
||||
let long_line = format!("{}#fff", "x".repeat(5000));
|
||||
assert_eq!(detect_colors(&long_line, false, false, DEFAULT_LIMITS), Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanning_stops_at_max_colors() {
|
||||
let text = "#fff\n".repeat(3000);
|
||||
let hits = detect_colors(&text, false, false, DEFAULT_LIMITS);
|
||||
assert_eq!(hits.len(), DEFAULT_LIMITS.max_colors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_sizes_finish_quickly() {
|
||||
let started = std::time::Instant::now();
|
||||
let one_line = format!("{}#fff", "a".repeat(1024 * 1024));
|
||||
assert_eq!(detect_colors(&one_line, false, false, DEFAULT_LIMITS), Vec::new());
|
||||
let hashes = "#\n".repeat(100_000);
|
||||
assert_eq!(detect_colors(&hashes, false, false, DEFAULT_LIMITS), Vec::new());
|
||||
let one_hash_line = "#".repeat(100_000);
|
||||
assert_eq!(detect_colors(&one_hash_line, false, false, DEFAULT_LIMITS), Vec::new());
|
||||
let elapsed = started.elapsed();
|
||||
assert!(elapsed.as_secs() < 1, "took {elapsed:?}");
|
||||
}
|
||||
|
||||
fn rgba(text: &str) -> Option<([u8; 3], u8)> {
|
||||
detect_colors(text, false, false, DEFAULT_LIMITS).into_iter().next().map(|hit| (hit.rgb, hit.alpha))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_functional() {
|
||||
let rows: &[(&str, [u8; 3], u8)] = &[
|
||||
("rgb(255, 0, 0)", [255, 0, 0], 255),
|
||||
("RGB(1,2,3)", [1, 2, 3], 255),
|
||||
("rgba(0, 0, 255, 0.5)", [0, 0, 255], 128),
|
||||
("rgba(1,2,3)", [1, 2, 3], 255),
|
||||
("rgb(0 0 0)", [0, 0, 0], 255),
|
||||
("rgb( 1 , 2 , 3 )", [1, 2, 3], 255),
|
||||
("rgb(100%, 0%, 0%)", [255, 0, 0], 255),
|
||||
("rgb(50%,50%,50%)", [128, 128, 128], 255),
|
||||
("rgb(127.5, 0, 0)", [128, 0, 0], 255),
|
||||
("rgb(1,2,3,0.25)", [1, 2, 3], 64),
|
||||
("rgb(1 2 3 / 50%)", [1, 2, 3], 128),
|
||||
("rgb(1,2,3/0.5)", [1, 2, 3], 128),
|
||||
("rgb(0,0,0)x", [0, 0, 0], 255),
|
||||
];
|
||||
for (text, rgb, alpha) in rows {
|
||||
assert_eq!(rgba(text), Some((*rgb, *alpha)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb_rejected() {
|
||||
let rejected: &[&str] = &[
|
||||
"rgb(256,0,0)",
|
||||
"rgb(-1,0,0)",
|
||||
"rgb(101%,0%,0%)",
|
||||
"rgb(1,2)",
|
||||
"rgb(1,2,3,4,5)",
|
||||
"rgb(1,2,3,)",
|
||||
"rgb(1,,2,3)",
|
||||
"rgb()",
|
||||
"rgb(1,2,3",
|
||||
"xrgb(1,2,3)",
|
||||
"argb(1,2,3)",
|
||||
"rgb(1deg,2,3)",
|
||||
"rgb (1,2,3)",
|
||||
"rgb(1,2,3/0.6/0.7)",
|
||||
"rgb(1.5.5,2,3)",
|
||||
"rgb(a,2,3)",
|
||||
];
|
||||
for text in rejected {
|
||||
assert_eq!(rgba(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsl_functional() {
|
||||
let rows: &[(&str, [u8; 3], u8)] = &[
|
||||
("hsl(0, 100%, 50%)", [255, 0, 0], 255),
|
||||
("HSL(120deg, 100%, 50%)", [0, 255, 0], 255),
|
||||
("hsl(240, 100%, 50%)", [0, 0, 255], 255),
|
||||
("hsl(-30, 100%, 50%)", [255, 0, 128], 255),
|
||||
("hsl(400, 100%, 50%)", [255, 170, 0], 255),
|
||||
("hsl(120, 50%, 50%)", [64, 191, 64], 255),
|
||||
("hsla(0, 100%, 50%, 0.5)", [255, 0, 0], 128),
|
||||
("hsl(0 100% 50% / 25%)", [255, 0, 0], 64),
|
||||
("hsl(0,100%,50%)deg", [255, 0, 0], 255),
|
||||
];
|
||||
for (text, rgb, alpha) in rows {
|
||||
assert_eq!(rgba(text), Some((*rgb, *alpha)), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsl_rejected() {
|
||||
let rejected: &[&str] = &[
|
||||
"hsl(1e999, 100%, 50%)",
|
||||
"hsl(120, 100, 50%)",
|
||||
"hsl(0.5turn, 100%, 50%)",
|
||||
"hsl(0, 100%, 50%, 120)",
|
||||
"hsl(120, 50%, 50)",
|
||||
"xhsl(0,100%,50%)",
|
||||
"hsl(0,101%,50%)",
|
||||
];
|
||||
for text in rejected {
|
||||
assert_eq!(rgba(text), None, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn functional_range_covers_parenthesis() {
|
||||
let text = "x rgb(0, 0, 0) y";
|
||||
let start = text.find("rgb(0, 0, 0)").unwrap_or(usize::MAX);
|
||||
let want = start..start.saturating_add("rgb(0, 0, 0)".len());
|
||||
let got = detect_colors(text, false, false, DEFAULT_LIMITS).into_iter().next().map(|hit| hit.range);
|
||||
assert_eq!(got, Some(want));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Named CSS colour detection: the basic 16 plus `orange`, whole words only.
|
||||
|
||||
use super::{Detected, is_word_byte};
|
||||
|
||||
/// The basic CSS colour names (plus `orange`) with their exact CSS values.
|
||||
pub const NAMED_COLORS: &[(&str, [u8; 3])] = &[
|
||||
("aqua", [0x00, 0xff, 0xff]),
|
||||
("black", [0x00, 0x00, 0x00]),
|
||||
("blue", [0x00, 0x00, 0xff]),
|
||||
("fuchsia", [0xff, 0x00, 0xff]),
|
||||
("gray", [0x80, 0x80, 0x80]),
|
||||
("green", [0x00, 0x80, 0x00]),
|
||||
("grey", [0x80, 0x80, 0x80]),
|
||||
("lime", [0x00, 0xff, 0x00]),
|
||||
("maroon", [0x80, 0x00, 0x00]),
|
||||
("navy", [0x00, 0x00, 0x80]),
|
||||
("olive", [0x80, 0x80, 0x00]),
|
||||
("orange", [0xff, 0xa5, 0x00]),
|
||||
("purple", [0x80, 0x00, 0x80]),
|
||||
("red", [0xff, 0x00, 0x00]),
|
||||
("silver", [0xc0, 0xc0, 0xc0]),
|
||||
("teal", [0x00, 0x80, 0x80]),
|
||||
("white", [0xff, 0xff, 0xff]),
|
||||
("yellow", [0xff, 0xff, 0x00]),
|
||||
];
|
||||
|
||||
/// Scans one `line` (which starts at byte `base` of the full text) for named
|
||||
/// colours, appending whole-word matches to `out`. The caller gates this on the
|
||||
/// named-colours setting and the inline-code context; `reddish`, `red-ish` and
|
||||
/// `bored` never match because `-`, `_` and alphanumerics continue a word.
|
||||
pub(super) fn scan_line(line: &str, base: usize, out: &mut Vec<Detected>) {
|
||||
let bytes = line.as_bytes();
|
||||
let mut at = 0usize;
|
||||
while let Some(byte) = bytes.get(at) {
|
||||
if !is_word_byte(*byte) {
|
||||
at += 1;
|
||||
continue;
|
||||
}
|
||||
let start = at;
|
||||
while bytes.get(at).copied().is_some_and(is_word_byte) {
|
||||
at += 1;
|
||||
}
|
||||
let word = line.get(start..at).unwrap_or("");
|
||||
let hit = NAMED_COLORS.iter().find(|(name, _)| word.eq_ignore_ascii_case(name));
|
||||
if let Some((_, rgb)) = hit {
|
||||
out.push(Detected { range: base + start..base + at, rgb: *rgb, alpha: 255 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::{DEFAULT_LIMITS, detect_colors};
|
||||
use super::NAMED_COLORS;
|
||||
|
||||
fn hits(text: &str, named: bool, in_code: bool) -> Vec<(std::ops::Range<usize>, [u8; 3])> {
|
||||
detect_colors(text, named, in_code, DEFAULT_LIMITS).into_iter().map(|hit| (hit.range, hit.rgb)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_names_match_with_exact_css_values() {
|
||||
for (name, rgb) in NAMED_COLORS {
|
||||
assert_eq!(hits(name, true, true), vec![(0..name.len(), *rgb)], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_case_insensitive_whole_words() {
|
||||
let rows: &[(&str, [u8; 3])] = &[
|
||||
("RED", [255, 0, 0]),
|
||||
("Blue", [0, 0, 255]),
|
||||
("fUCHsia", [255, 0, 255]),
|
||||
("the color red.", [255, 0, 0]),
|
||||
("(gray)", [128, 128, 128]),
|
||||
("red and blue", [255, 0, 0]),
|
||||
];
|
||||
for (text, rgb) in rows {
|
||||
let found = hits(text, true, true);
|
||||
assert_eq!(found.first().map(|(_, channels)| *channels), Some(*rgb), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_neighbours_do_not_match() {
|
||||
let rejected: &[&str] = &["reddish", "red-ish", "red_ish", "bored", "red1", "1red", "redred", "read", "shred", "grayish"];
|
||||
for text in rejected {
|
||||
assert_eq!(hits(text, true, true), Vec::new(), "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_needs_both_switches() {
|
||||
assert_eq!(hits("red", true, false), Vec::new());
|
||||
assert_eq!(hits("red", false, true), Vec::new());
|
||||
assert_eq!(hits("red", false, false), Vec::new());
|
||||
assert_eq!(hits("red", true, true).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_offsets_in_multibyte_text() {
|
||||
let text = "é red 😀 gray";
|
||||
let found = hits(text, true, true);
|
||||
assert_eq!(found.len(), 2);
|
||||
let red = text.find("red").unwrap_or(usize::MAX);
|
||||
assert_eq!(found.first().map(|(range, _)| range.clone()), Some(red..red.saturating_add("red".len())));
|
||||
for (range, _) in &found {
|
||||
assert!(text.is_char_boundary(range.start) && text.is_char_boundary(range.end));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
//! Fenced folder-structure (tree) detection.
|
||||
|
||||
/// One row of a detected folder tree.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TreeRow {
|
||||
/// Guide glyphs and the branch connector before the name, no trailing
|
||||
/// spaces (`├──`, `│ └──`, `|--`, `'--`).
|
||||
pub prefix: String,
|
||||
/// Row name, trailing spaces removed.
|
||||
pub name: String,
|
||||
/// True for folders: the name ends with `/` or the next row nests deeper.
|
||||
pub folder: bool,
|
||||
}
|
||||
|
||||
/// Largest fence body scanned, in bytes.
|
||||
const MAX_BODY_BYTES: usize = 64 * 1024;
|
||||
/// Largest fence body scanned, in lines.
|
||||
const MAX_BODY_LINES: usize = 2000;
|
||||
|
||||
/// Table corners and joints that never appear in a folder tree.
|
||||
const TABLE_CHARS: [char; 6] = ['┌', '┐', '┘', '┬', '┴', '┼'];
|
||||
/// Glyphs that must not appear inside a row name (box drawing and pipe).
|
||||
const NAME_REJECT: [char; 11] = ['┌', '┐', '┘', '┬', '┴', '┼', '│', '├', '└', '─', '|'];
|
||||
|
||||
/// Recognises a folder-structure fence: `label` must be empty, `text` or
|
||||
/// `tree` (case-insensitive).
|
||||
///
|
||||
/// The body needs at least two rows whose prefix is a glyph run of `│ ├ └ ─ |`
|
||||
/// (or `'` and `` ` ``) followed by dashes — `├── name`, `└── name`,
|
||||
/// `|-- name`, `'-- name`, `` `-- name `` — plus an optional first root line
|
||||
/// without glyphs. Box-drawing tables, ASCII art without names and bodies over
|
||||
/// 64 KiB or 2,000 lines are rejected with `None`. The copy text stays with
|
||||
/// the caller; the rows never include it.
|
||||
#[must_use]
|
||||
pub fn detect_tree(label: &str, body: &str) -> Option<Vec<TreeRow>> {
|
||||
let label = label.trim();
|
||||
if !label.is_empty() && !label.eq_ignore_ascii_case("text") && !label.eq_ignore_ascii_case("tree") {
|
||||
return None;
|
||||
}
|
||||
if body.len() > MAX_BODY_BYTES {
|
||||
return None;
|
||||
}
|
||||
let content = body.strip_suffix('\n').unwrap_or(body);
|
||||
let lines: Vec<&str> = content.split('\n').collect();
|
||||
if lines.len() > MAX_BODY_LINES {
|
||||
return None;
|
||||
}
|
||||
let mut rows: Vec<(String, String)> = Vec::new();
|
||||
for line in lines {
|
||||
let line = line.trim_end();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if line.contains(TABLE_CHARS) || line.ends_with('│') || line.ends_with('|') {
|
||||
return None;
|
||||
}
|
||||
let first = line.chars().next();
|
||||
if is_prefix_char(first) {
|
||||
let (prefix, name) = parse_row(line)?;
|
||||
rows.push((prefix, name));
|
||||
} else if rows.is_empty() {
|
||||
if !name_clean(line) {
|
||||
return None;
|
||||
}
|
||||
rows.push((String::new(), line.to_string()));
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if rows.iter().filter(|(prefix, _)| qualifies(prefix)).count() < 2 {
|
||||
return None;
|
||||
}
|
||||
let depths: Vec<usize> = rows.iter().map(|(prefix, _)| prefix.chars().count()).collect();
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for (index, (prefix, name)) in rows.iter().enumerate() {
|
||||
let mine = depths.get(index).copied().unwrap_or(0);
|
||||
let folder = name.ends_with('/') || depths.get(index + 1).is_some_and(|next| *next > mine);
|
||||
out.push(TreeRow { prefix: prefix.clone(), name: name.clone(), folder });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Characters that may start a tree row prefix: guides and connectors.
|
||||
fn is_prefix_char(first: Option<char>) -> bool {
|
||||
matches!(first, Some('│' | '├' | '└' | '─' | '|' | '\'' | '`' | ' '))
|
||||
}
|
||||
|
||||
/// A prefix carries a tree glyph when it holds a box-drawing character or an
|
||||
/// ASCII connector (`|--`, `'--`, `` `-- ``); plain dashes do not count.
|
||||
fn qualifies(prefix: &str) -> bool {
|
||||
prefix.contains(['│', '├', '└', '─', '|']) || prefix.contains("'--") || prefix.contains("`--")
|
||||
}
|
||||
|
||||
/// Splits a row into its guide `prefix` (dashes included) and `name`.
|
||||
fn parse_row(line: &str) -> Option<(String, String)> {
|
||||
let mut at = 0usize;
|
||||
while let Some(c) = char_at(line, at) {
|
||||
match c {
|
||||
'│' | ' ' | '|' | '\'' | '`' | '├' | '└' => at += c.len_utf8(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let dash_start = at;
|
||||
while let Some(c) = char_at(line, at) {
|
||||
match c {
|
||||
'─' | '-' => at += c.len_utf8(),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if at == dash_start {
|
||||
return None;
|
||||
}
|
||||
let prefix = line.get(0..at).unwrap_or("").to_string();
|
||||
let mut name_start = at;
|
||||
while char_at(line, name_start) == Some(' ') {
|
||||
name_start += 1;
|
||||
}
|
||||
if name_start == at {
|
||||
return None;
|
||||
}
|
||||
let name = line.get(name_start..).unwrap_or("").trim_end();
|
||||
if name.is_empty() || !name_clean(name) {
|
||||
return None;
|
||||
}
|
||||
Some((prefix, name.to_string()))
|
||||
}
|
||||
|
||||
/// True when the name holds no drawing or pipe characters.
|
||||
fn name_clean(name: &str) -> bool {
|
||||
!name.contains(NAME_REJECT)
|
||||
}
|
||||
|
||||
/// Character at byte `at`, if any.
|
||||
fn char_at(text: &str, at: usize) -> Option<char> {
|
||||
text.get(at..).and_then(|rest| rest.chars().next())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{MAX_BODY_BYTES, TreeRow, detect_tree};
|
||||
|
||||
fn row(prefix: &str, name: &str, folder: bool) -> TreeRow {
|
||||
TreeRow { prefix: prefix.into(), name: name.into(), folder }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_tree_with_root() {
|
||||
let body = "root\n├── a\n│ └── b\n└── c\n";
|
||||
let want = vec![row("", "root", true), row("├──", "a", true), row("│ └──", "b", false), row("└──", "c", false)];
|
||||
assert_eq!(detect_tree("", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_tree() {
|
||||
let body = "root\n|-- a\n| `-- b\n'-- c\n";
|
||||
let want = vec![row("", "root", true), row("|--", "a", true), row("| `--", "b", false), row("'--", "c", false)];
|
||||
assert_eq!(detect_tree("text", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backtick_and_single_dash_connectors() {
|
||||
let body = "`-- a\n|- b\n├- c\n";
|
||||
let want = vec![row("`--", "a", false), row("|-", "b", false), row("├-", "c", false)];
|
||||
assert_eq!(detect_tree("", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_without_root_and_with_blank_lines() {
|
||||
let body = "├── a\n\n└── b\n";
|
||||
let want = vec![row("├──", "a", false), row("└──", "b", false)];
|
||||
assert_eq!(detect_tree("tree", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_rules() {
|
||||
let body = "├── a\n└── b\n";
|
||||
for label in ["", "text", "tree", "TEXT", " Tree "] {
|
||||
assert!(detect_tree(label, body).is_some(), "label {label}");
|
||||
}
|
||||
for label in ["rust", "mermaid", "texty", "ascii-tree"] {
|
||||
assert_eq!(detect_tree(label, body), None, "label {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_by_slash_and_depth() {
|
||||
let body = "root\n├── src/\n│ ├── a\n└── b\n";
|
||||
let want = vec![row("", "root", true), row("├──", "src/", true), row("│ ├──", "a", false), row("└──", "b", false)];
|
||||
assert_eq!(detect_tree("", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_tables_art_and_prose() {
|
||||
let rejected: &[&str] = &[
|
||||
"┌───┬───┐\n│ a │ b │\n└───┴───┘\n",
|
||||
"│ a │ b │\n│ c │ d │\n",
|
||||
"├───┤\n└───┘\n",
|
||||
"├── a│\n└── b│\n",
|
||||
"├── a |\n└── b |\n",
|
||||
"├── a│b\n└── c\n",
|
||||
"- a\n- b\n",
|
||||
"├──\n└── x\n",
|
||||
"├──x\n└── y\n",
|
||||
"root\n├── a\nplain\n└── b\n",
|
||||
"only one line",
|
||||
"├── a\n│\n└── b\n",
|
||||
];
|
||||
for body in rejected {
|
||||
assert_eq!(detect_tree("", body), None, "{body}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_body() {
|
||||
let body = "root\r\n├── a\r\n└── b\r\n";
|
||||
let want = vec![row("", "root", true), row("├──", "a", false), row("└──", "b", false)];
|
||||
assert_eq!(detect_tree("", body), Some(want));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_bodies_rejected() {
|
||||
let too_many_lines = "├── x\n".repeat(2001);
|
||||
assert_eq!(detect_tree("", &too_many_lines), None);
|
||||
let just_enough = "├── x\n".repeat(2000);
|
||||
let rows = detect_tree("", &just_enough).map(|rows| rows.len());
|
||||
assert_eq!(rows, Some(2000));
|
||||
let mut huge = String::new();
|
||||
while huge.len() <= MAX_BODY_BYTES {
|
||||
huge.push_str("├── x\n");
|
||||
}
|
||||
assert_eq!(detect_tree("", &huge), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Hostile fence inputs for `layout_job`: random fence soup, thousands of fences, huge bodies and
|
||||
//! labels, unclosed fences and mismatched fence lengths never panic, never lose a byte and stay fast.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use notescraft_editor::colors::EditorColors;
|
||||
use notescraft_editor::highlight::layout_job;
|
||||
|
||||
/// Pieces a fence-heavy note is built from; CRLF, wide chars and bare ticks included.
|
||||
const PIECES: [&str; 13] = ["```", "~~~", "rust", "json", "\n", "`", "é", "{", "}", "\"", " ", "\r\n", " "];
|
||||
|
||||
/// How many random documents the sweep builds.
|
||||
const CASES: u32 = 3000;
|
||||
|
||||
/// No call may take longer than this.
|
||||
const BUDGET: Duration = Duration::from_secs(3);
|
||||
|
||||
fn resolver(_target: &str) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Tiny deterministic xorshift generator (no rand dependency).
|
||||
struct Rng(u32);
|
||||
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u32 {
|
||||
let mut x = self.0;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
self.0 = x;
|
||||
x
|
||||
}
|
||||
}
|
||||
|
||||
/// Layout one document and assert the invariants: same text, contiguous sections, full coverage,
|
||||
/// no section past the end and no empty-range section between two others.
|
||||
fn check(name: &str, text: &str, started: Instant) {
|
||||
let colors = EditorColors::fallback_dark();
|
||||
let job = layout_job(text, &colors, 14.0, 400.0, &resolver);
|
||||
assert_eq!(job.text, text, "{name}: the job must carry the source");
|
||||
let mut next = 0usize;
|
||||
for section in &job.sections {
|
||||
let (start, end) = (section.byte_range.start.0, section.byte_range.end.0);
|
||||
assert!(start <= end, "{name}: section range {start}..{end} is reversed");
|
||||
assert!(end <= text.len(), "{name}: section ends at {end}, past the text");
|
||||
assert_eq!(start, next, "{name}: gap or overlap at {next}");
|
||||
assert!(text.is_char_boundary(start) && text.is_char_boundary(end), "{name}: range {start}..{end} splits a char");
|
||||
next = end;
|
||||
}
|
||||
assert_eq!(next, text.len(), "{name}: coverage incomplete");
|
||||
let elapsed = started.elapsed();
|
||||
assert!(elapsed < BUDGET, "{name}: layout took {elapsed:?}, over the {BUDGET:?} budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_fence_soup_keeps_the_text_and_the_time() {
|
||||
let started = Instant::now();
|
||||
let mut rng = Rng(0xF3C7_5EED);
|
||||
for case in 0..CASES {
|
||||
let pieces = rng.next() as usize % 40 + 1;
|
||||
let mut text = String::new();
|
||||
for _ in 0..pieces {
|
||||
let pick = (rng.next() % u32::try_from(PIECES.len()).unwrap_or(1)) as usize;
|
||||
if let Some(piece) = PIECES.get(pick) {
|
||||
text.push_str(piece);
|
||||
}
|
||||
}
|
||||
check(&format!("case {case}"), &text, started);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn five_thousand_fences_in_a_row() {
|
||||
let started = Instant::now();
|
||||
let mut text = String::new();
|
||||
for n in 0..5000 {
|
||||
text.push_str("```rust\nfn f");
|
||||
text.push_str(&n.to_string());
|
||||
text.push_str("() {}\n```\n");
|
||||
}
|
||||
check("5000 fences", &text, started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_three_hundred_kilobyte_fence_body() {
|
||||
let started = Instant::now();
|
||||
let mut text = String::from("```json\n{\"a\":[");
|
||||
text.push_str(&"1,".repeat(160_000));
|
||||
text.push_str("1]}\n```\n");
|
||||
assert!(text.len() > 300 * 1024, "the fixture must be over 300 KB");
|
||||
check("300 KB body", &text, started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_ten_kilobyte_fence_label() {
|
||||
let started = Instant::now();
|
||||
let text = format!("```{}\nbody\n```\n", "r".repeat(10 * 1024));
|
||||
check("10 KB label", &text, started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unclosed_fence_at_eof() {
|
||||
let started = Instant::now();
|
||||
for text in ["```rust\nfn f() {}", "```", "````rust\n```\n````", "~~~json\n{", "\n```\n", "```\r\n"] {
|
||||
check("unclosed", text, started);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_and_nested_fence_lengths() {
|
||||
let started = Instant::now();
|
||||
for text in [
|
||||
"````rust\n```\ninner\n```\n````\n",
|
||||
"```rust\n````\n```\n",
|
||||
"`````\n`````\n",
|
||||
"``````json\n```\n``````\n",
|
||||
"~~~rust\n```json\n{x}\n```\n~~~\n",
|
||||
"``` \n```\n",
|
||||
"```json\r\n{\"a\":1}\r\n```\r\n",
|
||||
] {
|
||||
check("mismatched", text, started);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crlf_documents_with_mixed_fences() {
|
||||
let started = Instant::now();
|
||||
let mut rng = Rng(0x0BAD_C0DE);
|
||||
for case in 0..200 {
|
||||
let pieces = rng.next() as usize % 30 + 1;
|
||||
let mut text = String::new();
|
||||
for _ in 0..pieces {
|
||||
let pick = (rng.next() % u32::try_from(PIECES.len()).unwrap_or(1)) as usize;
|
||||
if let Some(piece) = PIECES.get(pick) {
|
||||
text.push_str(piece);
|
||||
}
|
||||
}
|
||||
check(&format!("crlf case {case}"), &text, started);
|
||||
}
|
||||
check("lone cr", "# T\r```rust\rfn f() {}\r```\r", started);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn documents_of_every_size_around_the_fence_cap() {
|
||||
let started = Instant::now();
|
||||
for len in [0_usize, 1, 2, 3, 7, 15, 16, 17, 63, 64, 65, 1023, 1024, 4095] {
|
||||
let mut text = "```rust\n".repeat(len);
|
||||
text.push('x');
|
||||
check(&format!("{len} fences"), &text, started);
|
||||
}
|
||||
}
|
||||
@@ -116,3 +116,44 @@ fn never_panics_on_degenerate_input() {
|
||||
assert_eq!(job.text, text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_tokens_take_the_theme_syntax_colours_and_cover_the_text() {
|
||||
use std::time::{Duration, Instant};
|
||||
// Token colours unlike every other colour, so a match means "painted as a token".
|
||||
let colors = EditorColors { syntax: [0u8, 1, 2, 3, 4, 5, 6, 7].map(|n| egui::Color32::from_rgb(1, 2, 200 + n)), ..EditorColors::fallback_dark() };
|
||||
let text = "# T\n\n```rust\nfn main() { let s = \"x\"; } // c\n```\n\nafter `code` here\n";
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
let job = loop {
|
||||
let job = layout_job(text, &colors, 14.0, 400.0, &resolver);
|
||||
if job.sections.iter().any(|s| colors.syntax.contains(&s.format.color)) || Instant::now() > until {
|
||||
break job;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
assert_eq!(job.text, text, "the job covers the whole text");
|
||||
let mut end = 0;
|
||||
for section in &job.sections {
|
||||
assert_eq!(section.byte_range.start.0, end, "sections are contiguous");
|
||||
end = section.byte_range.end.0;
|
||||
}
|
||||
assert_eq!(end, text.len());
|
||||
let coloured: Vec<&str> =
|
||||
job.sections.iter().filter(|s| colors.syntax.contains(&s.format.color)).map(|s| &text[s.byte_range.start.0..s.byte_range.end.0]).collect();
|
||||
assert!(coloured.contains(&"fn"), "{coloured:?}");
|
||||
assert!(coloured.iter().any(|t| t.contains('"')), "{coloured:?}");
|
||||
assert!(
|
||||
job.sections.iter().all(|s| s.format.background != egui::Color32::TRANSPARENT || !text[s.byte_range.start.0..s.byte_range.end.0].contains("fn")),
|
||||
"tokens keep the code background"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_fences_do_not_panic() {
|
||||
let colors = EditorColors::fallback_dark();
|
||||
let long = format!("```rust\n{}\n```\n", "x".repeat(50_000));
|
||||
for text in ["```rust\n", "```rust\n`\n```json\n{", "```\u{0}rust\n\u{0}\n```", long.as_str(), "~~~py\nprint('é')\n~~~\n```js\n"] {
|
||||
let job = layout_job(text, &colors, 14.0, 400.0, &resolver);
|
||||
assert_eq!(job.text, text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Inline mark toggles.
|
||||
|
||||
use notescraft_editor::marks::{Mark, active_marks, toggle_mark};
|
||||
|
||||
#[test]
|
||||
fn wrap_and_unwrap_each_mark() {
|
||||
for (mark, wrapped) in
|
||||
[(Mark::Bold, "a **b** c"), (Mark::Italic, "a *b* c"), (Mark::Strike, "a ~~b~~ c"), (Mark::Code, "a `b` c"), (Mark::Highlight, "a ==b== c")]
|
||||
{
|
||||
let on = toggle_mark("a b c", 2..3, mark).unwrap();
|
||||
assert_eq!(on.text, wrapped);
|
||||
assert_eq!(&on.text[on.sel.clone()], "b");
|
||||
assert!(active_marks(&on.text, on.sel.clone()).contains(&mark));
|
||||
let off = toggle_mark(&on.text, on.sel.clone(), mark).unwrap();
|
||||
assert_eq!(off.text, "a b c", "{mark:?}");
|
||||
assert_eq!(&off.text[off.sel.clone()], "b");
|
||||
let mut applied = "a b c".to_owned();
|
||||
applied.replace_range(on.replace.0.clone(), &on.replace.1);
|
||||
assert_eq!(applied, on.text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unwrap_when_selection_includes_markers() {
|
||||
let off = toggle_mark("a **b** c", 2..7, Mark::Bold).unwrap();
|
||||
assert_eq!(off.text, "a b c");
|
||||
assert_eq!(active_marks("a **b** c", 2..7), vec![Mark::Bold]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_and_italic_nest() {
|
||||
let b = toggle_mark("x", 0..1, Mark::Bold).unwrap();
|
||||
assert_eq!(active_marks(&b.text, b.sel.clone()), vec![Mark::Bold]);
|
||||
let bi = toggle_mark(&b.text, b.sel.clone(), Mark::Italic).unwrap();
|
||||
assert_eq!(bi.text, "***x***");
|
||||
assert_eq!(active_marks(&bi.text, bi.sel.clone()), vec![Mark::Bold, Mark::Italic]);
|
||||
let only_b = toggle_mark(&bi.text, bi.sel.clone(), Mark::Italic).unwrap();
|
||||
assert_eq!(only_b.text, "**x**");
|
||||
let only_i = toggle_mark(&bi.text, bi.sel.clone(), Mark::Bold).unwrap();
|
||||
assert_eq!(only_i.text, "*x*");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_wraps_with_caret_in_url_and_toggles_back() {
|
||||
let on = toggle_mark("see site now", 4..8, Mark::Link).unwrap();
|
||||
assert_eq!(on.text, "see [site]() now");
|
||||
assert_eq!(on.sel, 11..11);
|
||||
assert!(active_marks(&on.text, on.sel.clone()).contains(&Mark::Link));
|
||||
let off = toggle_mark(&on.text, on.sel.clone(), Mark::Link).unwrap();
|
||||
assert_eq!(off.text, "see site now");
|
||||
let off2 = toggle_mark("see [site](http://x) now", 4..20, Mark::Link).unwrap();
|
||||
assert_eq!(off2.text, "see site now");
|
||||
let off3 = toggle_mark("see [site](http://x) now", 5..9, Mark::Link).unwrap();
|
||||
assert_eq!(off3.text, "see site now");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_inserts_pair_and_toggles_back() {
|
||||
let on = toggle_mark("ab", 1..1, Mark::Bold).unwrap();
|
||||
assert_eq!(on.text, "a****b");
|
||||
assert_eq!(on.sel, 3..3);
|
||||
assert_eq!(toggle_mark(&on.text, on.sel, Mark::Bold).unwrap().text, "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_and_hostile() {
|
||||
let t = "é日本語 ok";
|
||||
let on = toggle_mark(t, 0..2, Mark::Strike).unwrap();
|
||||
assert_eq!(on.text, "~~é~~日本語 ok");
|
||||
assert!(toggle_mark(t, 1..2, Mark::Bold).is_err());
|
||||
assert!(toggle_mark(t, 0..99, Mark::Bold).is_err());
|
||||
assert!(toggle_mark(t, std::ops::Range { start: 3, end: 1 }, Mark::Bold).is_err());
|
||||
assert_eq!(active_marks(t, 1..2), vec![]);
|
||||
for text in ["", "*", "**", "***", "[", "](", "[]()", "`", "====", "~~"] {
|
||||
for s in 0..=text.len() {
|
||||
for e in s..=text.len() {
|
||||
for m in Mark::ALL {
|
||||
let _ = active_marks(text, s..e);
|
||||
let _ = toggle_mark(text, s..e, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let big = "word ".repeat(100_000);
|
||||
let on = toggle_mark(&big, 5..9, Mark::Italic).unwrap();
|
||||
assert_eq!(toggle_mark(&on.text, on.sel, Mark::Italic).unwrap().text, big);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Tables for auto-pairing: pair and suppress rules, wrap the selection,
|
||||
//! step over closers, and Backspace inside an empty pair. Positions come
|
||||
//! from `find`/`len`, never hand-counted; `|` marks the caret and `‹…›` a
|
||||
//! selection in every row, so byte offsets are never written by hand.
|
||||
|
||||
use notescraft_editor::keys::{self, Edit};
|
||||
use notescraft_editor::pairs;
|
||||
|
||||
fn applied(text: &str, edit: &Edit) -> String {
|
||||
let mut out = String::from(text);
|
||||
out.replace_range(edit.range.clone(), &edit.insert);
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a marked string into (text, selection): `|` = empty caret, `‹…›` =
|
||||
/// selection. Offsets come from `find`/`len`, never hand-counted.
|
||||
fn parse(marked: &str) -> (String, std::ops::Range<usize>) {
|
||||
if let Some((before, mid, after)) = marked.split_once('‹').and_then(|(b, rest)| rest.split_once('›').map(|(mid, after)| (b, mid, after))) {
|
||||
let start = before.len();
|
||||
return (format!("{before}{mid}{after}"), start..start + mid.len());
|
||||
}
|
||||
let at = marked.find('|').unwrap_or(marked.len());
|
||||
(marked.replace('|', ""), at..at)
|
||||
}
|
||||
|
||||
/// One `auto_close` expectation: the edit's applied text and new selection
|
||||
/// must match the marked expectation, or both must be `None`.
|
||||
fn check_close(marked: &str, typed: char, expected: Option<&str>) {
|
||||
let (text, sel) = parse(marked);
|
||||
let edit = keys::auto_close(&text, sel, typed);
|
||||
assert_eq!(edit.is_some(), expected.is_some(), "input {marked:?} + {typed}");
|
||||
if let (Some(edit), Some(exp)) = (edit, expected) {
|
||||
let (want, want_sel) = parse(exp);
|
||||
assert_eq!(applied(&text, &edit), want, "input {marked:?} + {typed}");
|
||||
assert_eq!(edit.sel, want_sel, "input {marked:?} + {typed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One `on_backspace` expectation in the same marked format.
|
||||
fn check_backspace(marked: &str, expected: Option<&str>) {
|
||||
let (text, sel) = parse(marked);
|
||||
let edit = pairs::on_backspace(&text, sel);
|
||||
assert_eq!(edit.is_some(), expected.is_some(), "input {marked:?}");
|
||||
if let (Some(edit), Some(exp)) = (edit, expected) {
|
||||
let (want, want_sel) = parse(exp);
|
||||
assert_eq!(applied(&text, &edit), want, "input {marked:?}");
|
||||
assert_eq!(edit.sel, want_sel, "input {marked:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pair_openers_and_suppression_rules() {
|
||||
let rows: &[(&str, char, Option<&str>)] = &[
|
||||
("|", '(', Some("(|)")),
|
||||
("|", '[', Some("[|]")),
|
||||
("|", '{', Some("{|}")),
|
||||
("|", '"', Some("\"|\"")),
|
||||
("|", '`', Some("`|`")),
|
||||
("|", '\'', None),
|
||||
("ab|c", '(', None),
|
||||
("ab|c", '[', None),
|
||||
("ab|c", '{', None),
|
||||
("don|t", '"', None),
|
||||
("it|s", '\'', None),
|
||||
("a |b", '"', Some("a \"|\"b")),
|
||||
("a| b", '(', Some("a(|) b")),
|
||||
("a| b", '[', Some("a[|] b")),
|
||||
("a| b", '{', Some("a{|} b")),
|
||||
("a(|)", ')', Some("a()|")),
|
||||
("a[|]", ']', Some("a[]|")),
|
||||
("a{|}", '}', Some("a{}|")),
|
||||
("a\"|\"", '"', Some("a\"\"|")),
|
||||
("a`|`", '`', Some("a``|")),
|
||||
("a*|*", '*', Some("a**|")),
|
||||
("a|", '*', None),
|
||||
("a*|", '*', Some("a**|**")),
|
||||
("(|)", '*', None),
|
||||
("(|)", '\'', None),
|
||||
("[|", '[', Some("[[|]")),
|
||||
("x[|]", '[', Some("x[[|]]")),
|
||||
("é|", '(', Some("é(|)")),
|
||||
("éx|", '(', Some("éx(|)")),
|
||||
("a😀|", '{', Some("a😀{|}")),
|
||||
("é|x", '"', None),
|
||||
("don|", '`', None),
|
||||
("a |", '"', Some("a \"|\"")),
|
||||
("word|", '"', None),
|
||||
("foo_|", '"', None),
|
||||
("word_|", '(', Some("word_(|)")),
|
||||
("|word", '(', None),
|
||||
("don\"|\"", '"', Some("don\"\"|")),
|
||||
];
|
||||
for (marked, typed, expected) in rows {
|
||||
check_close(marked, *typed, *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_wraps_and_stays_selected() {
|
||||
let rows: &[(&str, char, Option<&str>)] = &[
|
||||
("say ‹hi›", '[', Some("say [‹hi›]")),
|
||||
("say ‹hi›", '(', Some("say (‹hi›)")),
|
||||
("say ‹hi›", '{', Some("say {‹hi›}")),
|
||||
("say ‹hi›", '"', Some("say \"‹hi›\"")),
|
||||
("say ‹hi›", '`', Some("say `‹hi›`")),
|
||||
("say ‹hi›", '*', None),
|
||||
("say ‹hi›", '_', None),
|
||||
("say ‹hi›", '~', None),
|
||||
("say ‹hi›", '=', None),
|
||||
("say ‹hi›", '\'', None),
|
||||
("‹é😀›", '(', Some("(‹é😀›)")),
|
||||
("a‹bc›d", '[', Some("a[‹bc›]d")),
|
||||
];
|
||||
for (marked, typed, expected) in rows {
|
||||
check_close(marked, *typed, *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_selection_range_is_the_inner_text() {
|
||||
let text = "say hi";
|
||||
let sel = text.find("hi").unwrap()..text.len();
|
||||
let edit = keys::auto_close(text, sel, '[').unwrap();
|
||||
let new_text = applied(text, &edit);
|
||||
assert_eq!(new_text, "say [hi]");
|
||||
let open = new_text.find('[').unwrap();
|
||||
let close = new_text.find(']').unwrap();
|
||||
assert_eq!(edit.sel, open + 1..close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_multibyte_selection_keeps_char_boundaries() {
|
||||
let text = "é😀x";
|
||||
let sel = text.find('😀').unwrap()..text.find('x').unwrap();
|
||||
let edit = keys::auto_close(text, sel, '(').unwrap();
|
||||
let new_text = applied(text, &edit);
|
||||
assert_eq!(new_text, "é(😀)x");
|
||||
let open = new_text.find('(').unwrap();
|
||||
let close = new_text.find(')').unwrap();
|
||||
assert_eq!(edit.sel, open + 1..close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_deletes_empty_pairs() {
|
||||
let rows: &[(&str, Option<&str>)] = &[
|
||||
("(|)", Some("|")),
|
||||
("[|]", Some("|")),
|
||||
("{|}", Some("|")),
|
||||
("\"|\"", Some("|")),
|
||||
("`|`", Some("|")),
|
||||
("a(|)b", Some("a|b")),
|
||||
("a[|]b", Some("a|b")),
|
||||
("a{|}b", Some("a|b")),
|
||||
("(\"|\")", Some("(|)")),
|
||||
("x[[|]]", Some("x[|]")),
|
||||
("é(|)", Some("é|")),
|
||||
("😀(|)", Some("😀|")),
|
||||
("'|'", None),
|
||||
("**|**", None),
|
||||
("(|a", None),
|
||||
("a(|", None),
|
||||
("ab|", None),
|
||||
];
|
||||
for (marked, expected) in rows {
|
||||
check_backspace(marked, *expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backspace_ignores_selections_and_hostile_positions() {
|
||||
let text = "()";
|
||||
// A selection is deleted by `TextEdit`, not by the pair rule.
|
||||
assert_eq!(pairs::on_backspace(text, 0..text.len()), None);
|
||||
// Inverted out-of-bounds selection normalizes to a real selection.
|
||||
let inverted = usize::from(9u8)..usize::from(0u8);
|
||||
assert_eq!(pairs::on_backspace(text, inverted), None);
|
||||
// Caret past the end clamps: no pair around it.
|
||||
assert_eq!(pairs::on_backspace(text, 9..9), None);
|
||||
// A caret splitting `é` floors to a boundary and finds no pair.
|
||||
let hostile = "é()";
|
||||
assert_eq!(pairs::on_backspace(hostile, 1..1), None);
|
||||
assert_eq!(pairs::on_backspace(hostile, 2..2), None);
|
||||
// One character before the pair: single delete, not a pair delete.
|
||||
assert_eq!(pairs::on_backspace("a()", 1..1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_caret_lands_on_char_boundary() {
|
||||
let text = "a😀b";
|
||||
let at = text.find('b').unwrap();
|
||||
let edit = keys::auto_close(text, at..at, '"').unwrap();
|
||||
let new_text = applied(text, &edit);
|
||||
assert_eq!(new_text, "a😀\"\"b");
|
||||
let q = new_text.find('"').unwrap();
|
||||
assert_eq!(edit.sel, q + 1..q + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closer_table() {
|
||||
assert_eq!(pairs::closer('('), Some(')'));
|
||||
assert_eq!(pairs::closer('['), Some(']'));
|
||||
assert_eq!(pairs::closer('{'), Some('}'));
|
||||
assert_eq!(pairs::closer('"'), Some('"'));
|
||||
assert_eq!(pairs::closer('`'), Some('`'));
|
||||
assert_eq!(pairs::closer('\''), None);
|
||||
assert_eq!(pairs::closer('*'), None);
|
||||
assert_eq!(pairs::closer('_'), None);
|
||||
assert_eq!(pairs::closer('~'), None);
|
||||
assert_eq!(pairs::closer('='), None);
|
||||
assert_eq!(pairs::closer(')'), None);
|
||||
}
|
||||
@@ -11,16 +11,26 @@ publish = false
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
craft-atomic = { workspace = true }
|
||||
craft-settings = { workspace = true }
|
||||
notescraft-vault = { workspace = true }
|
||||
notescraft-git = { workspace = true }
|
||||
notescraft-markdown = { workspace = true }
|
||||
notescraft-pdf = { workspace = true }
|
||||
notescraft-index = { workspace = true }
|
||||
notescraft-knowledge = { workspace = true }
|
||||
notescraft-blocks = { workspace = true }
|
||||
notescraft-lint = { workspace = true }
|
||||
notescraft-math = { workspace = true }
|
||||
notescraft-templates = { workspace = true }
|
||||
notescraft-calendar = { workspace = true }
|
||||
notescraft-themes = { workspace = true }
|
||||
notescraft-plugins = { workspace = true }
|
||||
notescraft-syntax = { workspace = true }
|
||||
notescraft-regex = { workspace = true }
|
||||
grep-regex = { workspace = true }
|
||||
grep-searcher = { workspace = true }
|
||||
ignore = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@@ -33,7 +33,9 @@ pub fn run_as_assistant(session: &mut Session, registry: &Registry<Session>, id:
|
||||
Decision::Allow => registry.run(id, session, params),
|
||||
Decision::Queue(pending) => {
|
||||
let pending_id = session.queue_pending(pending);
|
||||
Ok(json!({ "queued": true, "pending_id": pending_id, "message": "waiting for the user to review" }))
|
||||
Ok(
|
||||
json!({ "queued": true, "pending_id": pending_id, "message": "NOT written yet: the change waits for the user to accept it in NotesCraft. Do not tell the user it is done" }),
|
||||
)
|
||||
}
|
||||
Decision::Deny(reason) => Err(Error::Command { id: id.to_owned(), source: CommandError::new("assistant_denied", reason) }),
|
||||
}
|
||||
@@ -141,7 +143,7 @@ mod tests {
|
||||
let reply = run_as_assistant(&mut writer.session, ®istry, "note.write", &json!({ "path": "alpha.md", "content": "replaced" }))
|
||||
.expect("a reviewed write answers instead of running");
|
||||
assert_eq!(reply.get("queued").and_then(Value::as_bool), Some(true));
|
||||
assert_eq!(reply.get("message").and_then(Value::as_str), Some("waiting for the user to review"));
|
||||
assert_eq!(reply.get("message").and_then(Value::as_str).map(|m| m.starts_with("NOT written yet")), Some(true));
|
||||
assert_eq!(reply.get("pending_id").and_then(Value::as_u64), Some(1), "the first pending id is 1");
|
||||
assert_eq!(writer.session.pending_changes().len(), 1);
|
||||
assert_eq!(writer.session.read_note("alpha.md").expect("read back"), "# Alpha\n\nfirst line\n", "the queued write did not run");
|
||||
|
||||
@@ -18,7 +18,25 @@ use crate::session::Session;
|
||||
|
||||
/// The commands that delete or rename vault content: they need
|
||||
/// `assistant.allow_delete` on top of `assistant.allow_write`.
|
||||
pub const DELETE_COMMANDS: &[&str] = &["note.delete", "folder.delete"];
|
||||
pub const DELETE_COMMANDS: &[&str] = &["note.delete", "note.merge", "folder.delete", "calendar.delete", "calendar.bulk_delete"];
|
||||
|
||||
/// The core plugin a command belongs to, for the per-plugin tool toggle of
|
||||
/// Settings, AI assistant, Plugin tools. Core plugins are on until the user
|
||||
/// switches them off.
|
||||
#[must_use]
|
||||
pub fn core_plugin_of(command_id: &str) -> Option<&'static str> {
|
||||
if command_id.starts_with("knowledge.") {
|
||||
return Some("knowledge");
|
||||
}
|
||||
command_id.starts_with("calendar.").then_some("calendar")
|
||||
}
|
||||
|
||||
/// Whether the user switched the core plugin behind `command_id` off for the assistant.
|
||||
#[must_use]
|
||||
pub fn core_plugin_off(session: &Session, command_id: &str) -> Option<&'static str> {
|
||||
let plugin = core_plugin_of(command_id)?;
|
||||
(!session.core_plugin_enabled(plugin) || session.assistant_settings().plugin_tools.get(plugin).copied() == Some(false)).then_some(plugin)
|
||||
}
|
||||
|
||||
/// The write-class commands. Anything not here and not on
|
||||
/// [`READ_COMMANDS`] is treated as a write as well; the table exists so the
|
||||
@@ -28,6 +46,8 @@ pub const WRITE_COMMANDS: &[&str] = &[
|
||||
"note.write",
|
||||
"note.duplicate",
|
||||
"note.rename",
|
||||
"note.extract",
|
||||
"note.merge",
|
||||
"note.property_set",
|
||||
"note.property_remove",
|
||||
"note.property_rename",
|
||||
@@ -42,6 +62,24 @@ pub const WRITE_COMMANDS: &[&str] = &[
|
||||
"template.insert",
|
||||
"template.create_note",
|
||||
"daily.open",
|
||||
"calendar.add",
|
||||
"calendar.edit",
|
||||
"calendar.move",
|
||||
"calendar.delete",
|
||||
"calendar.note",
|
||||
"calendar.bulk",
|
||||
"calendar.bulk_delete",
|
||||
"calendar.reminders_tick",
|
||||
"calendar.reminder_snooze",
|
||||
"calendar.reminder_dismiss",
|
||||
"calendar.feed_add",
|
||||
"calendar.feed_remove",
|
||||
"calendar.sync_due",
|
||||
"calendar.sync_apply",
|
||||
"calendar.google_calendars",
|
||||
"calendar.google_disconnect",
|
||||
"calendar.filter_save",
|
||||
"calendar.filter_delete",
|
||||
"bookmarks.add",
|
||||
"bookmarks.remove",
|
||||
"git.init",
|
||||
@@ -50,6 +88,21 @@ pub const WRITE_COMMANDS: &[&str] = &[
|
||||
"git.unstage",
|
||||
"slash.canvas",
|
||||
"slash.template",
|
||||
"lint.fix",
|
||||
"lint.fix_all_safe",
|
||||
"lint.dismiss",
|
||||
"lint.state_clear",
|
||||
"block.move",
|
||||
"block.duplicate",
|
||||
"block.delete",
|
||||
"block.turn_into",
|
||||
"json.format_block",
|
||||
"json.minify_block",
|
||||
"batch.start",
|
||||
"batch.step",
|
||||
"batch.cancel",
|
||||
"batch.decide",
|
||||
"batch.apply",
|
||||
];
|
||||
|
||||
/// The explicit read allow-list; everything else is treated as a write.
|
||||
@@ -63,11 +116,40 @@ pub const READ_COMMANDS: &[&str] = &[
|
||||
"links.unresolved",
|
||||
"tags.list",
|
||||
"search",
|
||||
"switcher.search",
|
||||
"note.random",
|
||||
"plugins.list",
|
||||
"template.list",
|
||||
"theme.list",
|
||||
"theme.get",
|
||||
"calendar.month",
|
||||
"calendar.agenda",
|
||||
"calendar.next",
|
||||
"calendar.free",
|
||||
"calendar.conflicts",
|
||||
"calendar.year",
|
||||
"calendar.parse",
|
||||
"calendar.review",
|
||||
"calendar.filters",
|
||||
"calendar.reminders",
|
||||
"calendar.feeds",
|
||||
"calendar.google_state",
|
||||
"knowledge.search",
|
||||
"knowledge.context",
|
||||
"knowledge.related",
|
||||
"knowledge.status",
|
||||
"knowledge.rebuild",
|
||||
"index.pause",
|
||||
"index.resume",
|
||||
"batch.status",
|
||||
"batch.proposals",
|
||||
"batch.proposal",
|
||||
"block.list",
|
||||
"grammar.info",
|
||||
"lint.check",
|
||||
"lint.rules",
|
||||
"lint.state",
|
||||
"lint.style",
|
||||
"math.render",
|
||||
"git.log",
|
||||
"git.status",
|
||||
@@ -165,6 +247,9 @@ impl AssistantGate {
|
||||
if command_id.starts_with("plugin.") {
|
||||
return check_plugin_tool(session, command_id);
|
||||
}
|
||||
if let Some(plugin) = core_plugin_off(session, command_id) {
|
||||
return Decision::Deny(format!("plugin tools for `{plugin}` are off"));
|
||||
}
|
||||
if read_allowed(command_id) {
|
||||
return Decision::Allow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! The `batch.*` commands: start a scope, read it into proposals in steps, list
|
||||
//! and decide the changes, then apply the accepted ones.
|
||||
//!
|
||||
//! Starting and applying are writes (the gate queues them); stepping reads
|
||||
//! notes only, so it works in a read-only session too. Applying writes each
|
||||
//! note through [`Session::lint_commit`](crate::session::Session::lint_commit),
|
||||
//! one edit per note, and skips a note whose text changed since the check.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::batch::replace::{checks_param, replace_param};
|
||||
use crate::batch::state::BatchState;
|
||||
use crate::batch::{Checks, Decision, Proposal, Status, apply_changes, hash_text, propose_note};
|
||||
use crate::commands::{command_error, flag_param, json, text_param, writable};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Largest note a batch reads, in bytes; larger notes are reported, not checked.
|
||||
const MAX_NOTE_BYTES: usize = 2 * 1024 * 1024;
|
||||
/// Most proposals `batch.proposals` lists in one answer.
|
||||
const MAX_LIST: usize = 5000;
|
||||
/// Default milliseconds of reading per `batch.step`.
|
||||
const DEFAULT_BUDGET_MS: i64 = 8;
|
||||
/// Smallest `budget_ms` a step accepts.
|
||||
const MIN_BUDGET_MS: i64 = 1;
|
||||
/// Largest `budget_ms` a step accepts.
|
||||
const MAX_BUDGET_MS: i64 = 1000;
|
||||
|
||||
/// The `batch.*` commands of the registry.
|
||||
pub(crate) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
vec![
|
||||
CommandSpec::new(
|
||||
"batch.start",
|
||||
"Start batch",
|
||||
"Queues a note, folder or the whole vault for format, lint and replace review; nothing is written",
|
||||
writable,
|
||||
start,
|
||||
)
|
||||
.with_param(ParamSpec::required("scope", ParamKind::String, "`note`, `folder` or `vault`"))
|
||||
.with_param(ParamSpec::optional("path", ParamKind::String, "the note, or the folder (required for `note` and `folder`)"))
|
||||
.with_param(ParamSpec::optional("json", ParamKind::Bool, "format JSON in code blocks, on by default"))
|
||||
.with_param(ParamSpec::optional("lint", ParamKind::Bool, "turn lint fixes into changes, on by default"))
|
||||
.with_param(ParamSpec::optional("labels", ParamKind::Bool, "label unlabeled code blocks whose language is detected, on by default"))
|
||||
.with_param(ParamSpec::optional(
|
||||
"replace_find",
|
||||
ParamKind::String,
|
||||
"plain text to look for; with it and no other check named, the other three default to off",
|
||||
))
|
||||
.with_param(ParamSpec::optional("replace_with", ParamKind::String, "what every match becomes, the match itself by default"))
|
||||
.with_param(ParamSpec::optional("replace_case", ParamKind::Bool, "the find text must match the case, on by default"))
|
||||
.with_param(ParamSpec::optional("replace_word", ParamKind::Bool, "only match whole words, off by default")),
|
||||
CommandSpec::new("batch.step", "Batch step", "Reads queued notes into proposals, within a time budget", |_| true, step)
|
||||
.with_param(ParamSpec::optional("budget_ms", ParamKind::Integer, "1 to 1000 milliseconds of reading, 8 by default")),
|
||||
CommandSpec::new("batch.cancel", "Cancel batch", "Empties the queue and keeps the proposals read so far", |_| true, cancel),
|
||||
CommandSpec::new("batch.status", "Batch status", "How far the batch has got", |_| true, status),
|
||||
CommandSpec::new("batch.proposals", "Batch proposals", "The proposals read so far, optionally filtered by status", |_| true, proposals)
|
||||
.with_param(ParamSpec::optional("status", ParamKind::String, "`proposed`, `needs_review`, `not_edited` or `clean`")),
|
||||
CommandSpec::new("batch.proposal", "Batch proposal", "One note's proposal with every change", |_| true, proposal).with_param(ParamSpec::required(
|
||||
"path",
|
||||
ParamKind::String,
|
||||
"note path",
|
||||
)),
|
||||
CommandSpec::new("batch.decide", "Decide change", "Accepts or skips one change, or every change of a note", |_| true, decide)
|
||||
.with_param(ParamSpec::optional("path", ParamKind::String, "note path; absent with `all` decides every proposed note"))
|
||||
.with_param(ParamSpec::optional("change_id", ParamKind::Integer, "one change of the note, from `batch.proposal`"))
|
||||
.with_param(ParamSpec::required("decision", ParamKind::String, "`accept`, `skip` or `pending`"))
|
||||
.with_param(ParamSpec::optional("all", ParamKind::Bool, "with no `path`: accept every change of every proposed note")),
|
||||
CommandSpec::new("batch.apply", "Apply batch", "Writes the accepted changes, one edit per note", writable, apply),
|
||||
]
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CommandError {
|
||||
CommandError::new("invalid_params", message)
|
||||
}
|
||||
|
||||
/// The wire name of a proposal status.
|
||||
fn status_name(status: Status) -> &'static str {
|
||||
match status {
|
||||
Status::Proposed => "proposed",
|
||||
Status::NeedsReview => "needs_review",
|
||||
Status::NotEdited => "not_edited",
|
||||
Status::Clean => "clean",
|
||||
}
|
||||
}
|
||||
|
||||
/// The status a `batch.proposals` filter names.
|
||||
fn parse_status(name: &str) -> Option<Status> {
|
||||
match name {
|
||||
"proposed" => Some(Status::Proposed),
|
||||
"needs_review" => Some(Status::NeedsReview),
|
||||
"not_edited" => Some(Status::NotEdited),
|
||||
"clean" => Some(Status::Clean),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a vault path sits in a reserved folder the batch never queues.
|
||||
fn reserved(path: &str) -> bool {
|
||||
path.starts_with(".obsidian/") || path.starts_with(".notescraft/")
|
||||
}
|
||||
|
||||
/// The progress object every batch command answers with.
|
||||
fn progress(session: &Session) -> Value {
|
||||
let batch = &session.batch;
|
||||
let count = |status| batch.proposals.iter().filter(|proposal| proposal.status == status).count();
|
||||
json!({
|
||||
"total": batch.total,
|
||||
"done": batch.proposals.len(),
|
||||
"finished": batch.queue.is_empty(),
|
||||
"running": batch.running,
|
||||
"cancelled": batch.cancelled,
|
||||
"proposed": count(Status::Proposed),
|
||||
"needs_review": count(Status::NeedsReview),
|
||||
"not_edited": count(Status::NotEdited),
|
||||
"clean": count(Status::Clean),
|
||||
"accepted": batch.proposals.iter().flat_map(|proposal| proposal.changes.iter()).filter(|change| change.decision == Decision::Accept).count(),
|
||||
})
|
||||
}
|
||||
|
||||
/// `batch.start`: builds the queue from the indexed notes, replacing any
|
||||
/// previous batch. Nothing is written.
|
||||
fn start(session: &mut Session, params: &Value) -> CommandResult {
|
||||
session.require_full_index().map_err(|error| command_error(&error))?;
|
||||
let scope = text_param(params, "scope")?;
|
||||
let replace = replace_param(params)?;
|
||||
let checks = checks_param(params, replace);
|
||||
let path = params.get("path").and_then(Value::as_str).map(str::to_owned);
|
||||
let notes = session.index.notes();
|
||||
let mut queue: Vec<String> = Vec::new();
|
||||
match scope.as_str() {
|
||||
"vault" => queue.extend(notes.iter().filter(|note| !reserved(note)).map(|note| (*note).to_owned())),
|
||||
"folder" => {
|
||||
let folder = path.ok_or_else(|| invalid("`path` is required for scope `folder`"))?;
|
||||
let folder = session.file_path(&folder).map_err(|error| command_error(&error))?;
|
||||
let prefix = format!("{folder}/");
|
||||
queue.extend(notes.iter().filter(|note| note.starts_with(&prefix) && !reserved(note)).map(|note| (*note).to_owned()));
|
||||
}
|
||||
"note" => {
|
||||
let note = path.ok_or_else(|| invalid("`path` is required for scope `note`"))?;
|
||||
let note = session.note_path(¬e).map_err(|error| command_error(&error))?;
|
||||
if reserved(¬e) || !notes.iter().any(|indexed| *indexed == note) {
|
||||
return Err(CommandError::new("not_found", format!("`{note}` is not a note of the vault")));
|
||||
}
|
||||
queue.push(note);
|
||||
}
|
||||
other => return Err(invalid(format!("`scope` is `note`, `folder` or `vault`, not `{other}`"))),
|
||||
}
|
||||
queue.reverse(); // `pop` reads the next note in index order
|
||||
let total = queue.len();
|
||||
session.batch = BatchState {
|
||||
queue,
|
||||
total,
|
||||
titles: if checks.lint { session.lint_titles() } else { Vec::new() },
|
||||
checks,
|
||||
proposals: Vec::new(),
|
||||
running: true,
|
||||
cancelled: false,
|
||||
};
|
||||
json(json!({ "total": total }))
|
||||
}
|
||||
|
||||
/// The step budget, 1 to 1000 milliseconds, 8 by default.
|
||||
fn budget_param(params: &Value) -> Result<Duration, CommandError> {
|
||||
let millis = params.get("budget_ms").and_then(Value::as_i64).unwrap_or(DEFAULT_BUDGET_MS);
|
||||
if !(MIN_BUDGET_MS..=MAX_BUDGET_MS).contains(&millis) {
|
||||
return Err(invalid(format!("`budget_ms` must be {MIN_BUDGET_MS} to {MAX_BUDGET_MS} milliseconds")));
|
||||
}
|
||||
Ok(Duration::from_millis(u64::try_from(millis).unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// `batch.step`: reads queued notes into proposals until the budget is spent,
|
||||
/// at least one per call.
|
||||
fn step(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let budget = budget_param(params)?;
|
||||
let checks = session.batch.checks.clone();
|
||||
let started = Instant::now();
|
||||
while let Some(path) = session.batch.queue.pop() {
|
||||
let proposal = read_note(session, &path, &checks);
|
||||
session.batch.proposals.push(proposal);
|
||||
if started.elapsed() >= budget {
|
||||
break;
|
||||
}
|
||||
}
|
||||
json(progress(session))
|
||||
}
|
||||
|
||||
/// Reads one queued note into a proposal, or into a `NotEdited` report when the
|
||||
/// note cannot be read or checked.
|
||||
fn read_note(session: &mut Session, path: &str, checks: &Checks) -> Proposal {
|
||||
let refused =
|
||||
|reason: &str| Proposal { path: path.to_owned(), content_hash: 0, status: Status::NotEdited, reason: Some(reason.to_owned()), changes: Vec::new() };
|
||||
let Ok(text) = session.lint_text(path) else {
|
||||
return refused("cannot read the note");
|
||||
};
|
||||
if text.len() > MAX_NOTE_BYTES {
|
||||
return refused("the note is over 2 MiB");
|
||||
}
|
||||
let issues = if checks.lint {
|
||||
if !session.config.lint.enabled {
|
||||
return refused("lint is off");
|
||||
}
|
||||
let mut titles = std::mem::take(&mut session.batch.titles);
|
||||
let checked = session.lint_check_with(path, &mut titles);
|
||||
session.batch.titles = titles;
|
||||
match checked {
|
||||
Ok((_, issues)) => issues,
|
||||
Err(_) => return refused("lint failed"),
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
propose_note(path, &text, &issues, checks.clone())
|
||||
}
|
||||
|
||||
/// `batch.cancel`: empties the queue, keeps the proposals read.
|
||||
fn cancel(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
session.batch.queue.clear();
|
||||
session.batch.cancelled = true;
|
||||
session.batch.running = false;
|
||||
json(progress(session))
|
||||
}
|
||||
|
||||
/// `batch.status`: the progress object, changed by nothing.
|
||||
fn status(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
json(progress(session))
|
||||
}
|
||||
|
||||
/// `batch.proposals`: the proposals read so far, optionally by status.
|
||||
fn proposals(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let status = match params.get("status").and_then(Value::as_str) {
|
||||
None => None,
|
||||
Some(name) => {
|
||||
Some(parse_status(name).ok_or_else(|| invalid(format!("`status` is `proposed`, `needs_review`, `not_edited` or `clean`, not `{name}`")))?)
|
||||
}
|
||||
};
|
||||
let matched: Vec<&Proposal> = session.batch.proposals.iter().filter(|proposal| status.is_none_or(|wanted| proposal.status == wanted)).collect();
|
||||
let truncated = matched.len() > MAX_LIST;
|
||||
let entries: Vec<Value> = matched
|
||||
.iter()
|
||||
.take(MAX_LIST)
|
||||
.map(|proposal| json!({ "path": proposal.path, "status": status_name(proposal.status), "reason": proposal.reason, "changes": proposal.changes.len() }))
|
||||
.collect();
|
||||
json(json!({ "proposals": entries, "truncated": truncated }))
|
||||
}
|
||||
|
||||
/// `batch.proposal`: one note's proposal with every change.
|
||||
fn proposal(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let path = text_param(params, "path")?;
|
||||
let found = session
|
||||
.batch
|
||||
.proposals
|
||||
.iter()
|
||||
.find(|proposal| proposal.path == path)
|
||||
.ok_or_else(|| CommandError::new("not_found", format!("`{path}` has no proposal yet")))?;
|
||||
let proposal = found.clone();
|
||||
let mut value = serde_json::to_value(&proposal).map_err(|error| CommandError::new("not_serialisable", error.to_string()))?;
|
||||
// Lines are only meaningful against the text that was checked.
|
||||
if let (Ok(text), Some(changes)) = (session.lint_text(&path), value.get_mut("changes").and_then(Value::as_array_mut))
|
||||
&& hash_text(&text) == proposal.content_hash
|
||||
{
|
||||
for (change, entry) in proposal.changes.iter().zip(changes) {
|
||||
let line = text.get(..change.range.start).map(|head| head.bytes().filter(|byte| *byte == b'\n').count().saturating_add(1));
|
||||
if let (Some(line), Some(map)) = (line, entry.as_object_mut()) {
|
||||
map.insert("line".to_owned(), json!(line));
|
||||
}
|
||||
}
|
||||
}
|
||||
json(value)
|
||||
}
|
||||
|
||||
/// The `change_id` parameter, an optional non-negative integer.
|
||||
fn change_id_param(params: &Value) -> Result<Option<usize>, CommandError> {
|
||||
match params.get("change_id").filter(|raw| !raw.is_null()) {
|
||||
None => Ok(None),
|
||||
Some(raw) => raw.as_i64().and_then(|id| usize::try_from(id).ok()).map(Some).ok_or_else(|| invalid("`change_id` must be an integer from 0 up")),
|
||||
}
|
||||
}
|
||||
|
||||
/// `batch.decide`: one change of a note, every change of a note, or (with
|
||||
/// `all` and no `path`) every change of every proposed note.
|
||||
fn decide(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let decision = match text_param(params, "decision")?.as_str() {
|
||||
"accept" => Decision::Accept,
|
||||
"skip" => Decision::Skip,
|
||||
"pending" => Decision::Pending,
|
||||
other => return Err(invalid(format!("`decision` is `accept`, `skip` or `pending`, not `{other}`"))),
|
||||
};
|
||||
let change_id = change_id_param(params)?;
|
||||
match params.get("path").and_then(Value::as_str) {
|
||||
Some(path) => decide_note(session, path, change_id, decision),
|
||||
None => decide_all(session, params, change_id, decision),
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides one change (or all of them when `change_id` is absent) of one note.
|
||||
fn decide_note(session: &mut Session, path: &str, change_id: Option<usize>, decision: Decision) -> CommandResult {
|
||||
let Some(proposal) = session.batch.proposals.iter_mut().find(|proposal| proposal.path == path) else {
|
||||
return Err(CommandError::new("not_found", format!("`{path}` has no proposal yet")));
|
||||
};
|
||||
let decided = if let Some(id) = change_id {
|
||||
let Some(change) = proposal.changes.iter_mut().find(|change| change.id == id) else {
|
||||
return Err(CommandError::new("not_found", format!("`{path}` has no change `{id}`")));
|
||||
};
|
||||
change.decision = decision;
|
||||
1_usize
|
||||
} else {
|
||||
let decided = proposal.changes.len();
|
||||
for change in &mut proposal.changes {
|
||||
change.decision = decision;
|
||||
}
|
||||
decided
|
||||
};
|
||||
json(json!({ "path": path, "decided": decided }))
|
||||
}
|
||||
|
||||
/// Accepts every change of every proposed note (only `accept`; needs `all`).
|
||||
fn decide_all(session: &mut Session, params: &Value, change_id: Option<usize>, decision: Decision) -> CommandResult {
|
||||
if change_id.is_some() {
|
||||
return Err(invalid("`change_id` needs a `path`"));
|
||||
}
|
||||
if !flag_param(params, "all", false) {
|
||||
return Err(invalid("`path` is required, unless `all` is true"));
|
||||
}
|
||||
if decision != Decision::Accept {
|
||||
return Err(invalid("`all` only accepts: a whole vault is reviewed change by change otherwise"));
|
||||
}
|
||||
let mut decided = 0_usize;
|
||||
for proposal in &mut session.batch.proposals {
|
||||
if proposal.status == Status::Proposed {
|
||||
for change in &mut proposal.changes {
|
||||
change.decision = decision;
|
||||
decided = decided.saturating_add(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
json(json!({ "decided": decided }))
|
||||
}
|
||||
|
||||
/// `batch.apply`: writes the accepted changes of every proposal, one edit per
|
||||
/// note, skipping notes that changed since the check.
|
||||
fn apply(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
let mut written = 0_usize;
|
||||
let mut skipped: Vec<Value> = Vec::new();
|
||||
let mut failed: Vec<Value> = Vec::new();
|
||||
let count = session.batch.proposals.len();
|
||||
for at in 0..count {
|
||||
let Some((path, content_hash)) = session.batch.proposals.get(at).map(|proposal| (proposal.path.clone(), proposal.content_hash)) else {
|
||||
continue;
|
||||
};
|
||||
let accepted = session.batch.proposals.get(at).is_some_and(|proposal| proposal.changes.iter().any(|change| change.decision == Decision::Accept));
|
||||
if !accepted {
|
||||
continue;
|
||||
}
|
||||
let text = match session.lint_text(&path) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
failed.push(json!({ "path": path, "error": error.to_string() }));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if hash_text(&text) != content_hash {
|
||||
skipped.push(json!({ "path": path, "reason": "changed since the check" }));
|
||||
continue;
|
||||
}
|
||||
let Some(new) = session.batch.proposals.get(at).map(|proposal| apply_changes(&text, &proposal.changes).0) else {
|
||||
continue;
|
||||
};
|
||||
if new == text {
|
||||
continue; // nothing applied: the accepted changes were stale or overlapping
|
||||
}
|
||||
match session.lint_commit(&path, &new) {
|
||||
Ok(_) => {
|
||||
written = written.saturating_add(1);
|
||||
if let Some(proposal) = session.batch.proposals.get_mut(at) {
|
||||
proposal.status = Status::Clean;
|
||||
proposal.changes.clear();
|
||||
}
|
||||
}
|
||||
Err(error) => failed.push(json!({ "path": path, "error": error.to_string() })),
|
||||
}
|
||||
}
|
||||
session.batch.running = false;
|
||||
json(json!({ "written": written, "skipped": skipped, "failed": failed }))
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! The `labels` batch check: naming the language of unlabeled fenced code blocks.
|
||||
|
||||
use notescraft_blocks::{BlockKind, detect_language, parse_blocks};
|
||||
|
||||
use crate::batch::propose::change;
|
||||
use crate::batch::types::{Change, ChangeKind};
|
||||
|
||||
/// The first word after the fence run of a fence's opening line, `None` when the fence is unlabeled.
|
||||
pub(crate) fn fence_label(open: &str) -> Option<&str> {
|
||||
let c = open.chars().next()?;
|
||||
let run = open.chars().take_while(|x| *x == c).count();
|
||||
if run < 3 || !matches!(c, '`' | '~') {
|
||||
return None;
|
||||
}
|
||||
open.get(run..).and_then(|rest| rest.split_whitespace().next())
|
||||
}
|
||||
|
||||
/// One `Label` change per unlabeled fence block whose language [`detect_language`] names, skipping
|
||||
/// the blocks a JSON change already covers (an unlabeled JSON block keeps its combined change). The
|
||||
/// change is a zero-length insertion right after the opening fence run and is never safe: the
|
||||
/// language is a guess the user must approve.
|
||||
pub(crate) fn label_changes(text: &str, json_blocks: &[usize], out: &mut Vec<Change>) {
|
||||
for block in parse_blocks(text) {
|
||||
if block.kind != BlockKind::CodeFence || json_blocks.contains(&block.range.start) {
|
||||
continue;
|
||||
}
|
||||
let Some(open) = text.get(block.range.start..block.text_range.start) else { continue };
|
||||
if fence_label(open).is_some() {
|
||||
continue; // already labeled
|
||||
}
|
||||
let Some(body) = text.get(block.text_range.clone()) else { continue };
|
||||
let Some(language) = detect_language(body) else { continue };
|
||||
let trimmed = open.trim_start();
|
||||
let run = trimmed.chars().take_while(|c| matches!(c, '`' | '~')).count();
|
||||
let Some(at) = (block.range.start + (open.len() - trimmed.len())).checked_add(run) else { continue };
|
||||
out.push(change(ChangeKind::Label, at..at, "", language.to_owned(), false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Batch review: turning one note's text into a reviewable list of changes.
|
||||
//!
|
||||
//! Pure functions only: [`propose_note`] reads a note (plus the lint issues
|
||||
//! found in it) and returns a [`Proposal`] of non-overlapping changes (JSON
|
||||
//! formatting, lint fixes, language labels and plain find-and-replace); the
|
||||
//! user flips each [`Decision`]; [`apply_changes`] splices the accepted ones
|
||||
//! back into the text. Nothing here touches files.
|
||||
//!
|
||||
//! The `batch.*` commands (queueing a scope, stepping through it, deciding and
|
||||
//! applying) live in [`crate::batch::commands`] on top of this module and of
|
||||
//! [`crate::batch::state`], the per-session [`BatchState`].
|
||||
|
||||
mod commands;
|
||||
mod labels;
|
||||
mod propose;
|
||||
mod replace;
|
||||
mod state;
|
||||
mod types;
|
||||
|
||||
pub(crate) use commands::specs;
|
||||
pub(crate) use state::BatchState;
|
||||
|
||||
pub use propose::{apply_changes, hash_text, propose_note};
|
||||
pub use types::{Change, ChangeKind, Checks, Decision, Proposal, ReplaceSpec, Status};
|
||||
@@ -0,0 +1,388 @@
|
||||
//! Turning one note's text into reviewable changes and applying the accepted ones.
|
||||
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::ops::Range;
|
||||
|
||||
use notescraft_blocks::{Block, BlockKind, check_json, format_json, json_span, parse_blocks};
|
||||
use notescraft_lint::Issue;
|
||||
|
||||
use crate::batch::labels::{fence_label, label_changes};
|
||||
use crate::batch::replace::replace_changes;
|
||||
use crate::batch::types::{Change, ChangeKind, Checks, Decision, Proposal, Status};
|
||||
|
||||
/// Stable hash of a note's text; a proposal keeps the hash of the text it was read from, so a
|
||||
/// later mismatch means the note was edited in between.
|
||||
#[must_use]
|
||||
pub fn hash_text(text: &str) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
text.hash(&mut hasher);
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Turns one note's `text` (plus the lint issues found in it) into a [`Proposal`]: JSON blocks that
|
||||
/// would be reformatted and lint fixes, one [`Change`] each, sorted by range, free of overlaps and
|
||||
/// `Pending`. Fixes with several edits and stale or invalid edits are skipped, not errors: the
|
||||
/// batch stays a list of things to review.
|
||||
///
|
||||
/// `checks` picks what runs: [`Checks::json`] adds JSON formatting changes, [`Checks::lint`]
|
||||
/// turns one-edit fixes into changes, [`Checks::labels`] inserts a language label into unlabeled
|
||||
/// code blocks whose language [`detect_language`] names and [`Checks::replace`] adds one change
|
||||
/// per match of a plain find string (never safe, so the note needs review).
|
||||
#[must_use]
|
||||
pub fn propose_note(path: &str, text: &str, issues: &[Issue], checks: Checks) -> Proposal {
|
||||
let Checks { json, lint, labels, replace } = checks;
|
||||
let mut changes = Vec::new();
|
||||
let mut json_blocks: Vec<usize> = Vec::new();
|
||||
if json {
|
||||
json_changes(text, &mut json_blocks, &mut changes);
|
||||
}
|
||||
if lint {
|
||||
lint_changes(text, issues, &mut changes);
|
||||
}
|
||||
if labels {
|
||||
label_changes(text, &json_blocks, &mut changes);
|
||||
}
|
||||
if let Some(spec) = replace.as_ref() {
|
||||
replace_changes(text, spec, &mut changes);
|
||||
}
|
||||
changes.sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.range.end.cmp(&b.range.end)));
|
||||
let mut kept = Vec::new();
|
||||
let mut end = 0;
|
||||
for change in changes {
|
||||
if change.range.start < end {
|
||||
continue; // overlaps an earlier kept change: the first one wins
|
||||
}
|
||||
end = change.range.end;
|
||||
kept.push(change);
|
||||
}
|
||||
let status = if kept.is_empty() {
|
||||
Status::Clean
|
||||
} else if kept.iter().any(|c| !c.safe) {
|
||||
Status::NeedsReview
|
||||
} else {
|
||||
Status::Proposed
|
||||
};
|
||||
let changes = kept
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(id, mut c)| {
|
||||
c.id = id;
|
||||
c
|
||||
})
|
||||
.collect();
|
||||
Proposal { path: path.to_owned(), content_hash: hash_text(text), status, reason: None, changes }
|
||||
}
|
||||
|
||||
/// Applies the changes whose decision is `Accept`, from the highest start to the lowest (spliced
|
||||
/// ascending, which is the same thing: no change shifts another's range). Returns the new text,
|
||||
/// the ids applied and the ids skipped. `Accept` changes are skipped when their range is out of
|
||||
/// bounds, off a `char` boundary, no longer holds `before`, or overlaps another accepted change
|
||||
/// already taken; `Pending` and `Skip` changes are neither applied nor reported as skipped.
|
||||
#[must_use]
|
||||
pub fn apply_changes(text: &str, changes: &[Change]) -> (String, Vec<usize>, Vec<usize>) {
|
||||
let mut valid = Vec::new();
|
||||
let mut skipped = Vec::new();
|
||||
for change in changes.iter().filter(|c| c.decision == Decision::Accept) {
|
||||
let ok = change.range.start <= change.range.end
|
||||
&& change.range.end <= text.len()
|
||||
&& text.is_char_boundary(change.range.start)
|
||||
&& text.is_char_boundary(change.range.end)
|
||||
&& text.get(change.range.start..change.range.end) == Some(change.before.as_str());
|
||||
if ok {
|
||||
valid.push(change);
|
||||
} else {
|
||||
skipped.push(change.id);
|
||||
}
|
||||
}
|
||||
valid.sort_by(|a, b| a.range.start.cmp(&b.range.start).then(a.range.end.cmp(&b.range.end)));
|
||||
let mut applied = Vec::new();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut at = 0;
|
||||
let mut end = 0;
|
||||
for change in valid {
|
||||
if change.range.start < end {
|
||||
skipped.push(change.id); // overlaps a change already applied
|
||||
continue;
|
||||
}
|
||||
if let Some(seg) = text.get(at..change.range.start) {
|
||||
out.push_str(seg);
|
||||
}
|
||||
out.push_str(&change.after);
|
||||
at = change.range.end;
|
||||
end = change.range.end;
|
||||
applied.push(change.id);
|
||||
}
|
||||
if let Some(rest) = text.get(at..) {
|
||||
out.push_str(rest);
|
||||
}
|
||||
applied.sort_unstable();
|
||||
skipped.sort_unstable();
|
||||
(out, applied, skipped)
|
||||
}
|
||||
|
||||
/// One fresh, `Pending` change of `kind` at `range`.
|
||||
pub(crate) fn change(kind: ChangeKind, range: Range<usize>, before: &str, after: String, safe: bool) -> Change {
|
||||
Change { id: 0, kind, range, before: before.to_owned(), after, rule: None, safe, decision: Decision::Pending }
|
||||
}
|
||||
|
||||
/// The JSON formatting change for one fence block, `None` when the block needs none.
|
||||
fn json_change(text: &str, block: &Block) -> Option<Change> {
|
||||
if block.kind != BlockKind::CodeFence {
|
||||
return None;
|
||||
}
|
||||
let open = text.get(block.range.start..block.text_range.start)?;
|
||||
let body = text.get(block.text_range.clone())?;
|
||||
let at = block.text_range.start;
|
||||
match fence_label(open) {
|
||||
Some(label) if label.eq_ignore_ascii_case("json") => labeled_json(body, at),
|
||||
// A labeled block of another language (js, rust, ...) is left alone even when it holds JSON.
|
||||
Some(_) => None,
|
||||
None => unlabeled_json(body, at),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `json`-labeled fence: the core is the whole trimmed body; safe to reformat.
|
||||
fn labeled_json(body: &str, at: usize) -> Option<Change> {
|
||||
let lead = body.len() - body.trim_start().len();
|
||||
let core = body.trim();
|
||||
if check_json(core).is_err() {
|
||||
return None; // invalid JSON: no change, nothing to review
|
||||
}
|
||||
let formatted = format_json(core).ok()?;
|
||||
if formatted == core {
|
||||
return None;
|
||||
}
|
||||
Some(change(ChangeKind::Json, at + lead..at + lead + core.len(), core, formatted, true))
|
||||
}
|
||||
|
||||
/// An unlabeled fence: only a body whose [`json_span`] is a JSON value is offered, and it needs
|
||||
/// review, because the block's language is a guess.
|
||||
fn unlabeled_json(body: &str, at: usize) -> Option<Change> {
|
||||
let span = json_span(body)?;
|
||||
let inner = body.get(span.clone())?;
|
||||
let formatted = format_json(inner).ok()?;
|
||||
if formatted == inner {
|
||||
return None;
|
||||
}
|
||||
Some(change(ChangeKind::Json, at + span.start..at + span.end, inner, formatted, false))
|
||||
}
|
||||
|
||||
/// Every JSON formatting change of the note's fence blocks; `json_blocks` collects the byte
|
||||
/// start of each block a change was proposed for, so the label check can leave those alone.
|
||||
fn json_changes(text: &str, json_blocks: &mut Vec<usize>, out: &mut Vec<Change>) {
|
||||
for block in parse_blocks(text) {
|
||||
if let Some(one) = json_change(text, &block) {
|
||||
json_blocks.push(block.range.start);
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One change per lint issue whose fix has exactly one edit. Fixes with several edits are skipped
|
||||
/// on purpose: the batch reviews one contiguous replacement at a time, and a multi-edit fix cannot
|
||||
/// be accepted one edit at a time. Edits that no longer match the text are skipped as well.
|
||||
fn lint_changes(text: &str, issues: &[Issue], out: &mut Vec<Change>) {
|
||||
for issue in issues {
|
||||
let Some(fix) = &issue.fix else { continue };
|
||||
let [edit] = fix.edits.as_slice() else { continue };
|
||||
let range = edit.range.start..edit.range.end;
|
||||
let Some(before) = text.get(range.clone()) else { continue };
|
||||
let mut one = change(ChangeKind::Lint, range, before, edit.text.clone(), fix.safe);
|
||||
one.rule = Some(issue.rule.clone());
|
||||
out.push(one);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use notescraft_lint::{Edit, Fix, Range as LintRange, Severity};
|
||||
|
||||
/// An issue whose fix is one edit replacing `start..end` with `replacement`.
|
||||
fn lint_issue(rule: &str, start: usize, end: usize, replacement: &str, safe: bool) -> Issue {
|
||||
Issue {
|
||||
rule: rule.to_owned(),
|
||||
severity: Severity::Info,
|
||||
range: LintRange { start, end },
|
||||
line: 1,
|
||||
message: String::new(),
|
||||
fix: Some(Fix { label: String::new(), edits: vec![Edit { range: LintRange { start, end }, text: replacement.to_owned() }], safe }),
|
||||
extra_fixes: Vec::new(),
|
||||
hash: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labeled_json_formats_and_is_safe() {
|
||||
let note = "```json\n{\"a\":1,\"b\":2}\n```\n";
|
||||
let proposal = propose_note("n.md", note, &[], Checks { json: true, lint: false, labels: false, replace: None });
|
||||
assert_eq!(proposal.status, Status::Proposed);
|
||||
assert_eq!(proposal.reason, None);
|
||||
assert_eq!(proposal.content_hash, hash_text(note));
|
||||
let c = proposal.changes.first().expect("one change");
|
||||
let start = note.find("{\"a\":1,\"b\":2}").expect("core in note");
|
||||
assert_eq!(c.id, 0);
|
||||
assert_eq!(c.kind, ChangeKind::Json);
|
||||
assert_eq!(c.range, start..start + "{\"a\":1,\"b\":2}".len());
|
||||
assert_eq!(c.before, "{\"a\":1,\"b\":2}");
|
||||
assert_eq!(c.after, "{\n \"a\": 1,\n \"b\": 2\n}");
|
||||
assert!(c.safe);
|
||||
assert_eq!(c.rule, None);
|
||||
assert_eq!(c.decision, Decision::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_yields_no_change_and_other_labels_are_left_alone() {
|
||||
for note in ["```json\n{\"a\": }\n```\n", "```js\n{\"a\":1}\n```\n", "```json\n{\n \"a\": 1\n}\n```\n"] {
|
||||
let proposal = propose_note("n.md", note, &[], Checks { json: true, lint: false, labels: false, replace: None });
|
||||
assert_eq!(proposal.changes, Vec::new(), "{note:?}");
|
||||
assert_eq!(proposal.status, Status::Clean, "{note:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlabeled_json_needs_review() {
|
||||
let note = "~~~\n{\"a\":1}\n~~~\n";
|
||||
let proposal = propose_note("n.md", note, &[], Checks { json: true, lint: false, labels: false, replace: None });
|
||||
assert_eq!(proposal.status, Status::NeedsReview);
|
||||
let c = proposal.changes.first().expect("one change");
|
||||
assert_eq!(c.kind, ChangeKind::Json);
|
||||
assert!(!c.safe);
|
||||
let start = note.find("{\"a\":1}").expect("core in note");
|
||||
assert_eq!(c.range, start..start + "{\"a\":1}".len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lint_issue_with_one_edit_becomes_a_lint_change() {
|
||||
let note = "a b\n";
|
||||
let start = note.find(" ").expect("two spaces");
|
||||
let issue = lint_issue("double-space", start, start + 2, " ", true);
|
||||
let proposal = propose_note("n.md", note, std::slice::from_ref(&issue), Checks { json: false, lint: true, labels: false, replace: None });
|
||||
assert_eq!(proposal.status, Status::Proposed);
|
||||
let c = proposal.changes.first().expect("one change");
|
||||
assert_eq!(c.kind, ChangeKind::Lint);
|
||||
assert_eq!(c.rule, Some("double-space".to_owned()));
|
||||
assert_eq!((c.before.as_str(), c.after.as_str()), (" ", " "));
|
||||
assert!(c.safe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_edit_and_stale_fixes_are_skipped() {
|
||||
let note = "a b\n";
|
||||
let start = note.find(" ").expect("two spaces");
|
||||
let mut multi = lint_issue("multi", start, start + 2, " ", true);
|
||||
if let Some(fix) = &mut multi.fix {
|
||||
fix.edits.push(Edit { range: LintRange { start: start + 2, end: start + 3 }, text: String::new() });
|
||||
}
|
||||
let stale = lint_issue("stale", note.len() + 5, note.len() + 7, " ", true);
|
||||
let proposal = propose_note("n.md", note, &[multi, stale], Checks { json: false, lint: true, labels: false, replace: None });
|
||||
assert_eq!(proposal.changes, Vec::new());
|
||||
assert_eq!(proposal.status, Status::Clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_change_is_dropped_and_ids_stay_sequential() {
|
||||
let note = "```json\n{\"a\":1,\"b\":2}\n```\n\n```json\n{\"c\":3}\n```\n";
|
||||
let inside = note.find("\"b\":2").expect("inside the first core");
|
||||
let issue = lint_issue("inside", inside, inside + 3, "x", false);
|
||||
let proposal = propose_note("n.md", note, std::slice::from_ref(&issue), Checks { json: true, lint: true, labels: false, replace: None });
|
||||
assert_eq!(proposal.changes.len(), 2, "the lint change inside the JSON core is dropped");
|
||||
assert_eq!(proposal.changes.iter().map(|c| c.id).collect::<Vec<_>>(), vec![0, 1]);
|
||||
assert_eq!(proposal.changes.iter().map(|c| c.kind).collect::<Vec<_>>(), vec![ChangeKind::Json, ChangeKind::Json]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_note_is_clean() {
|
||||
let proposal = propose_note("n.md", "# Title\n\nplain note\n", &[], Checks { json: true, lint: true, labels: false, replace: None });
|
||||
assert_eq!(proposal.status, Status::Clean);
|
||||
assert_eq!(proposal.changes, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_changes_accepts_and_skips() {
|
||||
let note = "```json\n{\"a\":1}\n```\n";
|
||||
let mut proposal = propose_note("n.md", note, &[], Checks { json: true, lint: false, labels: false, replace: None });
|
||||
for c in &mut proposal.changes {
|
||||
c.decision = Decision::Accept;
|
||||
}
|
||||
let (new, applied, skipped) = apply_changes(note, &proposal.changes);
|
||||
assert_eq!(applied, vec![0]);
|
||||
assert_eq!(skipped, Vec::<usize>::new());
|
||||
assert_eq!(new, "```json\n{\n \"a\": 1\n}\n```\n");
|
||||
|
||||
for c in &mut proposal.changes {
|
||||
c.decision = Decision::Skip;
|
||||
}
|
||||
let (new, applied, skipped) = apply_changes(note, &proposal.changes);
|
||||
assert_eq!(new, note);
|
||||
assert_eq!((applied, skipped), (Vec::new(), Vec::new()), "skipped decisions are not reported");
|
||||
|
||||
for c in &mut proposal.changes {
|
||||
c.decision = Decision::Pending;
|
||||
}
|
||||
let (new, applied, skipped) = apply_changes(note, &proposal.changes);
|
||||
assert_eq!((new, applied, skipped), (note.to_owned(), Vec::new(), Vec::new()), "pending decisions are not applied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_changes_skips_stale_and_overlapping() {
|
||||
let note = "one two\n";
|
||||
let at = note.find(" ").expect("two spaces");
|
||||
let stale = change(ChangeKind::Lint, at..at + 3, " ", " ".to_owned(), true); // `before` does not match
|
||||
let accepted = |mut c: Change, decision: Decision| {
|
||||
c.decision = decision;
|
||||
c
|
||||
};
|
||||
let (new, applied, skipped) = apply_changes(note, &[accepted(stale.clone(), Decision::Accept)]);
|
||||
assert_eq!((new, applied, skipped), (note.to_owned(), Vec::new(), vec![stale.id]));
|
||||
|
||||
let first = change(ChangeKind::Lint, at..at + 2, " ", " ".to_owned(), true);
|
||||
let second = change(ChangeKind::Lint, at + 1..at + 3, " t", "x".to_owned(), true);
|
||||
let (new, applied, skipped) = apply_changes(note, &[accepted(first.clone(), Decision::Accept), accepted(second.clone(), Decision::Accept)]);
|
||||
assert_eq!(applied, vec![first.id]);
|
||||
assert_eq!(skipped, vec![second.id]);
|
||||
assert_eq!(new, "one two\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_changes_handles_multibyte() {
|
||||
let note = "caf\u{e9} x\n";
|
||||
let at = note.find("\u{e9}").expect("e-acute");
|
||||
let mut ok = change(ChangeKind::Lint, at..at + 2, "\u{e9}", "e".to_owned(), true);
|
||||
ok.decision = Decision::Accept;
|
||||
let (new, applied, skipped) = apply_changes(note, std::slice::from_ref(&ok));
|
||||
assert_eq!((new, applied, skipped), ("cafe x\n".to_owned(), vec![ok.id], Vec::new()));
|
||||
|
||||
let mut mid = change(ChangeKind::Lint, at + 1..at + 2, "junk", "e".to_owned(), true); // splits the two-byte char
|
||||
mid.decision = Decision::Accept;
|
||||
let (new, applied, skipped) = apply_changes(note, std::slice::from_ref(&mid));
|
||||
assert_eq!((new, applied, skipped), (note.to_owned(), Vec::new(), vec![mid.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mib_note_with_5000_changes_stays_fast() {
|
||||
let mut note = String::new();
|
||||
let mut issues = Vec::new();
|
||||
for _ in 0..5000 {
|
||||
let start = note.len();
|
||||
note.push_str(&"x".repeat(210));
|
||||
note.push_str(" word\n");
|
||||
let at = note.rfind(" word\n").expect("the fresh pair of spaces");
|
||||
issues.push(lint_issue("double-space", at, at + 2, " ", true));
|
||||
debug_assert_eq!(start + 210, at);
|
||||
}
|
||||
debug_assert!(note.len() >= 1024 * 1024);
|
||||
let began = std::time::Instant::now();
|
||||
let mut proposal = propose_note("big.md", ¬e, &issues, Checks { json: true, lint: true, labels: false, replace: None });
|
||||
assert_eq!(proposal.changes.len(), 5000);
|
||||
for c in &mut proposal.changes {
|
||||
c.decision = Decision::Accept;
|
||||
}
|
||||
let (new, applied, skipped) = apply_changes(¬e, &proposal.changes);
|
||||
assert_eq!(applied.len(), 5000);
|
||||
assert_eq!(skipped, Vec::<usize>::new());
|
||||
assert!(new.len() < note.len());
|
||||
assert!(began.elapsed().as_secs_f64() < 2.0, "took {}s", began.elapsed().as_secs_f64());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! The `replace` batch check: one change per match of a plain find string.
|
||||
|
||||
use notescraft_blocks::{FindOptions, find_all};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::batch::propose::change;
|
||||
use crate::batch::types::{Change, ChangeKind, Checks, ReplaceSpec};
|
||||
use crate::commands::flag_param;
|
||||
use crate::error::CommandError;
|
||||
|
||||
/// Largest find string a replace check takes, in bytes.
|
||||
pub(crate) const MAX_FIND_BYTES: usize = 500;
|
||||
/// Most changes one note gets from a replace check; the rest wait for a narrower find.
|
||||
const MAX_CHANGES: usize = 2000;
|
||||
|
||||
/// The replace check the `batch.start` params ask for, `None` when `replace_find` is absent or
|
||||
/// empty. The registry has already refused a param of the wrong type.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`CommandError`] `invalid_params` when `replace_find` is over [`MAX_FIND_BYTES`].
|
||||
pub(crate) fn replace_param(params: &Value) -> Result<Option<ReplaceSpec>, CommandError> {
|
||||
let find = params.get("replace_find").and_then(Value::as_str).unwrap_or_default();
|
||||
if find.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if find.len() > MAX_FIND_BYTES {
|
||||
return Err(CommandError::new("invalid_params", format!("`replace_find` is at most {MAX_FIND_BYTES} bytes")));
|
||||
}
|
||||
Ok(Some(ReplaceSpec {
|
||||
find: find.to_owned(),
|
||||
with: params.get("replace_with").and_then(Value::as_str).unwrap_or_default().to_owned(),
|
||||
case_sensitive: flag_param(params, "replace_case", true),
|
||||
whole_word: flag_param(params, "replace_word", false),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The checks a batch starts with. The format and lint checks are on by default, unless the run
|
||||
/// asks for a replace and names no other check: a replace run then changes matches and nothing else.
|
||||
pub(crate) fn checks_param(params: &Value, replace: Option<ReplaceSpec>) -> Checks {
|
||||
let named = ["json", "lint", "labels"].iter().any(|name| params.get(*name).is_some_and(|value| !value.is_null()));
|
||||
let default = replace.is_none() || named;
|
||||
Checks { json: flag_param(params, "json", default), lint: flag_param(params, "lint", default), labels: flag_param(params, "labels", default), replace }
|
||||
}
|
||||
|
||||
/// One `Replace` change per match of `spec.find`, everywhere in the note, the frontmatter block
|
||||
/// included. A change is never safe: replacing text always needs a human look. Nothing is
|
||||
/// proposed when the find string is empty or over [`MAX_FIND_BYTES`].
|
||||
pub(crate) fn replace_changes(text: &str, spec: &ReplaceSpec, out: &mut Vec<Change>) {
|
||||
if spec.find.is_empty() || spec.find.len() > MAX_FIND_BYTES {
|
||||
return;
|
||||
}
|
||||
let options = FindOptions { case_sensitive: spec.case_sensitive, whole_word: spec.whole_word };
|
||||
for range in find_all(text, &spec.find, options).into_iter().take(MAX_CHANGES) {
|
||||
let Some(before) = text.get(range.clone()) else { continue };
|
||||
out.push(change(ChangeKind::Replace, range, before, spec.with.clone(), false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::batch::propose::propose_note;
|
||||
use crate::batch::types::{Checks, Status};
|
||||
|
||||
/// A replace check with the given switches, nothing else.
|
||||
fn checks(find: &str, with: &str, case_sensitive: bool, whole_word: bool) -> Checks {
|
||||
Checks {
|
||||
json: false,
|
||||
lint: false,
|
||||
labels: false,
|
||||
replace: Some(ReplaceSpec { find: find.to_owned(), with: with.to_owned(), case_sensitive, whole_word }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_match_becomes_one_change_that_needs_review() {
|
||||
let note = "---\ntitle: tea\n---\n\ntea time\n";
|
||||
let proposal = propose_note("n.md", note, &[], checks("tea", "coffee", true, false));
|
||||
assert_eq!(proposal.status, Status::NeedsReview);
|
||||
assert_eq!(proposal.changes.len(), 2, "the frontmatter match is offered too");
|
||||
for change in &proposal.changes {
|
||||
assert_eq!(change.kind, ChangeKind::Replace);
|
||||
assert_eq!(change.before, "tea");
|
||||
assert_eq!(change.after, "coffee");
|
||||
assert_eq!(change.rule, None);
|
||||
assert!(!change.safe);
|
||||
}
|
||||
let front = note.find("tea").expect("the first match");
|
||||
assert_eq!(proposal.changes.first().map(|c| c.range.clone()), Some(front..front + 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_or_huge_find_proposes_nothing() {
|
||||
let empty = propose_note("n.md", "tea\n", &[], checks("", "x", true, false));
|
||||
assert_eq!(empty.changes, Vec::new());
|
||||
assert_eq!(empty.status, Status::Clean);
|
||||
let huge = "a".repeat(MAX_FIND_BYTES + 1);
|
||||
let long = propose_note("n.md", &format!("{huge}\n"), &[], checks(&huge, "x", true, false));
|
||||
assert_eq!(long.changes, Vec::new());
|
||||
let exact = "a".repeat(MAX_FIND_BYTES);
|
||||
let edge = propose_note("n.md", &exact, &[], checks(&exact, "x", true, false));
|
||||
assert_eq!(edge.changes.len(), 1, "500 bytes is still accepted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_note_never_gets_more_than_the_cap() {
|
||||
let note = "x ".repeat(3000);
|
||||
let proposal = propose_note("n.md", ¬e, &[], checks("x", "y", true, false));
|
||||
assert_eq!(proposal.changes.len(), MAX_CHANGES);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! The state one format/lint batch keeps between its commands.
|
||||
|
||||
use crate::batch::{Checks, Proposal};
|
||||
|
||||
/// The batch state of a session: the notes still to read, the proposals
|
||||
/// already read and the checks the batch runs with.
|
||||
///
|
||||
/// [`BatchState::queue`] is reversed: [`Vec::pop`] takes the last element, so
|
||||
/// the batch reads the notes in the order the index lists them.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BatchState {
|
||||
/// Paths still to read, reversed so `pop` returns the next one.
|
||||
pub(crate) queue: Vec<String>,
|
||||
/// Notes queued when the batch started.
|
||||
pub(crate) total: usize,
|
||||
/// Which checks the batch runs on each note.
|
||||
pub(crate) checks: Checks,
|
||||
/// One proposal per read note, in reading order.
|
||||
pub(crate) proposals: Vec<Proposal>,
|
||||
/// Whether a batch is open (started, not cancelled or applied).
|
||||
pub(crate) running: bool,
|
||||
/// Whether the user cancelled the batch; the proposals read stay.
|
||||
pub(crate) cancelled: bool,
|
||||
/// `(path, title)` of every note, built once per batch for lint's link rules.
|
||||
pub(crate) titles: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Default for BatchState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
queue: Vec::new(),
|
||||
total: 0,
|
||||
checks: Checks { json: false, lint: false, labels: false, replace: None },
|
||||
proposals: Vec::new(),
|
||||
running: false,
|
||||
cancelled: false,
|
||||
titles: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Types of a review batch: one note, its proposed changes and the user's decisions.
|
||||
|
||||
use std::ops::Range;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Which check produced a change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum ChangeKind {
|
||||
/// Pretty-printing of JSON in a code block.
|
||||
Json,
|
||||
/// A lint rule's fix.
|
||||
Lint,
|
||||
/// A language label inserted after the opening fence of an unlabeled code block.
|
||||
Label,
|
||||
/// One match of a plain find string, replaced by what the user asked for.
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// The user's decision on a change.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Decision {
|
||||
/// Not decided yet.
|
||||
Pending,
|
||||
/// Apply it.
|
||||
Accept,
|
||||
/// Leave the text as it is.
|
||||
Skip,
|
||||
}
|
||||
|
||||
/// How a note's proposal ended.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum Status {
|
||||
/// Changes proposed, all safe.
|
||||
Proposed,
|
||||
/// At least one change needs a human look (not safe).
|
||||
NeedsReview,
|
||||
/// The note changed after it was read, so nothing was applied.
|
||||
NotEdited,
|
||||
/// Nothing to change.
|
||||
Clean,
|
||||
}
|
||||
|
||||
/// One reviewable change: replace `before` at `range` with `after`.
|
||||
///
|
||||
/// `range` is a byte range of the text that was read.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Change {
|
||||
/// Position in the batch, `0..` in final order.
|
||||
pub id: usize,
|
||||
/// Where the change comes from.
|
||||
pub kind: ChangeKind,
|
||||
/// Byte range of the old text.
|
||||
pub range: Range<usize>,
|
||||
/// The old text, as read.
|
||||
pub before: String,
|
||||
/// The replacement.
|
||||
pub after: String,
|
||||
/// Lint rule id, when the change comes from lint.
|
||||
pub rule: Option<String>,
|
||||
/// True when the change cannot alter meaning.
|
||||
pub safe: bool,
|
||||
/// The user's decision.
|
||||
pub decision: Decision,
|
||||
}
|
||||
|
||||
/// All changes proposed for one note.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Proposal {
|
||||
/// Vault-relative path of the note.
|
||||
pub path: String,
|
||||
/// [`crate::batch::hash_text`] of the text the changes were read from.
|
||||
pub content_hash: u64,
|
||||
/// How the proposal ended.
|
||||
pub status: Status,
|
||||
/// Why the status is what it is.
|
||||
pub reason: Option<String>,
|
||||
/// The changes, sorted by range and without overlaps.
|
||||
pub changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// What a replace check looks for and what it writes instead.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ReplaceSpec {
|
||||
/// The plain text to look for; never empty, at most 500 bytes.
|
||||
pub find: String,
|
||||
/// What every match becomes (may be empty).
|
||||
pub with: String,
|
||||
/// Upper and lower case must match as written.
|
||||
pub case_sensitive: bool,
|
||||
/// Only match when neither neighbour is a letter, a digit or `_`.
|
||||
pub whole_word: bool,
|
||||
}
|
||||
|
||||
/// Which checks run in a batch.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Checks {
|
||||
/// Format JSON in code blocks.
|
||||
pub json: bool,
|
||||
/// Turn lint fixes into changes.
|
||||
pub lint: bool,
|
||||
/// Label unlabeled code blocks whose language is detected.
|
||||
pub labels: bool,
|
||||
/// Turn every match of a plain find string into a change, when asked for.
|
||||
pub replace: Option<ReplaceSpec>,
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The `block.*` commands: list the blocks of a note and move, duplicate, delete or retype one; plus
|
||||
//! `json.format_block` and `json.minify_block` for the JSON inside a code block.
|
||||
//!
|
||||
//! The pure edits live in `notescraft-blocks`. Each write is one replacement, so an open buffer takes it
|
||||
//! as one undo step; the assistant gate queues the writes like any other edit.
|
||||
|
||||
use notescraft_blocks::{Direction, Edited, Target, delete_block, duplicate_block, format_json_block, minify_json_block, move_block, parse_blocks, turn_into};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::commands::{command_error, json, text_param, writable};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Largest heading level a `turn_into` target may name.
|
||||
const MAX_HEADING: u8 = 6;
|
||||
|
||||
pub(crate) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let path = || ParamSpec::required("path", ParamKind::String, "note path");
|
||||
let index = || ParamSpec::required("index", ParamKind::Integer, "block index from `block.list`, from 0");
|
||||
vec![
|
||||
CommandSpec::new("block.list", "List blocks", "The top-level blocks of a note: kind and byte range", |_| true, list).with_param(path()),
|
||||
CommandSpec::new("block.move", "Move block", "Swaps a block with its neighbour as one undoable edit", writable, move_it)
|
||||
.with_param(path())
|
||||
.with_param(index())
|
||||
.with_param(ParamSpec::required("direction", ParamKind::String, "`up` or `down`")),
|
||||
CommandSpec::new("block.duplicate", "Duplicate block", "Copies a block right below itself", writable, duplicate).with_param(path()).with_param(index()),
|
||||
CommandSpec::new("block.delete", "Delete block", "Removes a block", writable, delete).with_param(path()).with_param(index()),
|
||||
CommandSpec::new("block.turn_into", "Turn block into", "Changes the type of a block and keeps its text", writable, turn)
|
||||
.with_param(path())
|
||||
.with_param(index())
|
||||
.with_param(ParamSpec::required(
|
||||
"target",
|
||||
ParamKind::String,
|
||||
"`paragraph`, `heading1` to `heading6`, `quote`, `task`, `bullet`, `numbered` or `callout:<kind>`",
|
||||
)),
|
||||
CommandSpec::new("json.format_block", "Format JSON block", "Pretty-prints the JSON in a code block as one undoable edit", writable, format_json)
|
||||
.with_param(path())
|
||||
.with_param(index()),
|
||||
CommandSpec::new(
|
||||
"json.minify_block",
|
||||
"Minify JSON block",
|
||||
"Removes the spaces of the JSON in a code block as one undoable edit",
|
||||
writable,
|
||||
minify_json,
|
||||
)
|
||||
.with_param(path())
|
||||
.with_param(index()),
|
||||
]
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CommandError {
|
||||
CommandError::new("invalid_params", message)
|
||||
}
|
||||
|
||||
fn index_param(params: &Value) -> Result<usize, CommandError> {
|
||||
params.get("index").and_then(Value::as_u64).and_then(|index| usize::try_from(index).ok()).ok_or_else(|| invalid("`index` must be an integer from 0 up"))
|
||||
}
|
||||
|
||||
fn target_param(params: &Value) -> Result<Target, CommandError> {
|
||||
let name = text_param(params, "target")?;
|
||||
if let Some(kind) = name.strip_prefix("callout:") {
|
||||
return Ok(Target::Callout(kind.to_owned()));
|
||||
}
|
||||
if let Some(level) = name.strip_prefix("heading") {
|
||||
let level: u8 = level.parse().map_err(|_| invalid("`heading` needs a level, e.g. `heading2`"))?;
|
||||
if !(1..=MAX_HEADING).contains(&level) {
|
||||
return Err(invalid(format!("heading level must be 1 to {MAX_HEADING}")));
|
||||
}
|
||||
return Ok(Target::Heading(level));
|
||||
}
|
||||
match name.as_str() {
|
||||
"paragraph" => Ok(Target::Paragraph),
|
||||
"quote" => Ok(Target::Quote),
|
||||
"task" => Ok(Target::Task),
|
||||
"bullet" => Ok(Target::Bullet),
|
||||
"numbered" => Ok(Target::Numbered),
|
||||
other => Err(invalid(format!("unknown target `{other}`"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn note(session: &Session, params: &Value) -> Result<String, CommandError> {
|
||||
session.note_path(&text_param(params, "path")?).map_err(|error| command_error(&error))
|
||||
}
|
||||
|
||||
fn list(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let path = note(session, params)?;
|
||||
let text = session.lint_text(&path).map_err(|error| command_error(&error))?;
|
||||
let blocks = parse_blocks(&text);
|
||||
json(json!({ "path": path, "blocks": blocks }))
|
||||
}
|
||||
|
||||
/// Runs one pure edit on the note and writes the result as one edit.
|
||||
fn edit(session: &mut Session, params: &Value, run: impl FnOnce(&str, usize) -> notescraft_blocks::Result<Edited>) -> CommandResult {
|
||||
let path = note(session, params)?;
|
||||
let index = index_param(params)?;
|
||||
let text = session.lint_text(&path).map_err(|error| command_error(&error))?;
|
||||
let edited = run(&text, index).map_err(|error| CommandError::new("invalid_params", error.to_string()))?;
|
||||
let changed = session.lint_commit(&path, &edited.text).map_err(|error| command_error(&error))?;
|
||||
json(json!({ "path": path, "changed": changed, "caret": edited.caret }))
|
||||
}
|
||||
|
||||
fn move_it(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let direction = match text_param(params, "direction")?.as_str() {
|
||||
"up" => Direction::Up,
|
||||
"down" => Direction::Down,
|
||||
other => return Err(invalid(format!("`direction` is `up` or `down`, not `{other}`"))),
|
||||
};
|
||||
edit(session, params, |text, index| move_block(text, index, direction))
|
||||
}
|
||||
|
||||
fn duplicate(session: &mut Session, params: &Value) -> CommandResult {
|
||||
edit(session, params, duplicate_block)
|
||||
}
|
||||
|
||||
fn delete(session: &mut Session, params: &Value) -> CommandResult {
|
||||
edit(session, params, delete_block)
|
||||
}
|
||||
|
||||
fn format_json(session: &mut Session, params: &Value) -> CommandResult {
|
||||
edit(session, params, format_json_block)
|
||||
}
|
||||
|
||||
fn minify_json(session: &mut Session, params: &Value) -> CommandResult {
|
||||
edit(session, params, minify_json_block)
|
||||
}
|
||||
|
||||
fn turn(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let target = target_param(params)?;
|
||||
edit(session, params, |text, index| turn_into(text, index, &target))
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//! Bulk changes: one action over many event files, previewed before it is applied.
|
||||
|
||||
use notescraft_calendar::{Event, EventTime};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::store::{invalid, load_event, save_event};
|
||||
use crate::commands::{command_error, flag_param, json, writable};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Most events one bulk call may touch.
|
||||
const MAX_BULK: usize = 500;
|
||||
|
||||
/// Largest day shift of a bulk move.
|
||||
const MAX_SHIFT_DAYS: i64 = 3660;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let ids = || ParamSpec::required("ids", ParamKind::Array, "vault paths of the event files");
|
||||
let confirm = || ParamSpec::optional("confirm", ParamKind::Bool, "false (default) only counts what would change; true applies it");
|
||||
vec![
|
||||
CommandSpec::new(
|
||||
"calendar.bulk",
|
||||
"Bulk change events",
|
||||
"Moves, tags, untags or re-files many events; counts first unless confirm is true",
|
||||
writable,
|
||||
bulk,
|
||||
)
|
||||
.with_param(ids())
|
||||
.with_param(ParamSpec::required("action", ParamKind::String, "move, tag, untag or calendar"))
|
||||
.with_param(ParamSpec::optional("days", ParamKind::Integer, "move: days to shift, negative goes back"))
|
||||
.with_param(ParamSpec::optional("tag", ParamKind::String, "tag, untag: the tag without #"))
|
||||
.with_param(ParamSpec::optional("calendar", ParamKind::String, "calendar: the calendar name"))
|
||||
.with_param(confirm()),
|
||||
CommandSpec::new("calendar.bulk_delete", "Bulk delete events", "Deletes many events; counts first unless confirm is true", writable, bulk_delete)
|
||||
.with_param(ids())
|
||||
.with_param(confirm()),
|
||||
]
|
||||
}
|
||||
|
||||
/// The distinct event files named by `ids`, read and parsed (all or nothing).
|
||||
fn events(session: &Session, params: &Value) -> Result<Vec<(String, String, Event)>, CommandError> {
|
||||
let list = params.get("ids").and_then(Value::as_array).ok_or_else(|| invalid("`ids` must be a list"))?;
|
||||
if list.is_empty() || list.len() > MAX_BULK {
|
||||
return Err(invalid(format!("`ids` must hold 1 to {MAX_BULK} paths")));
|
||||
}
|
||||
let mut seen = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for value in list {
|
||||
let id = value.as_str().ok_or_else(|| invalid("`ids` must hold text"))?.trim().to_owned();
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let (text, event) = load_event(session, &id)?;
|
||||
seen.push(id.clone());
|
||||
out.push((id, text, event));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn changed(event: &Event, params: &Value) -> Result<Event, CommandError> {
|
||||
let mut event = event.clone();
|
||||
let text = |name: &str| params.get(name).and_then(Value::as_str).map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
match params.get("action").and_then(Value::as_str) {
|
||||
Some("move") => {
|
||||
let days = params
|
||||
.get("days")
|
||||
.and_then(Value::as_i64)
|
||||
.filter(|d| d.abs() <= MAX_SHIFT_DAYS)
|
||||
.ok_or_else(|| invalid(format!("`days` must be a whole number within {MAX_SHIFT_DAYS}")))?;
|
||||
let days = i32::try_from(days).map_err(|_| invalid("`days` is out of range"))?;
|
||||
let shift = |time: EventTime| -> Result<EventTime, CommandError> {
|
||||
let date = time.date().add_days(days).map_err(|error| invalid(error.to_string()))?;
|
||||
Ok(match time {
|
||||
EventTime::Day(_) => EventTime::Day(date),
|
||||
EventTime::Minute(at) => {
|
||||
EventTime::Minute(notescraft_templates::DateTime::new(date, at.hour(), at.minute(), 0).map_err(|error| invalid(error.to_string()))?)
|
||||
}
|
||||
})
|
||||
};
|
||||
event.start = shift(event.start)?;
|
||||
event.end = event.end.map(shift).transpose()?;
|
||||
event.exceptions = event.exceptions.into_iter().map(shift).collect::<Result<_, _>>()?;
|
||||
}
|
||||
Some("tag") => {
|
||||
let tag = text("tag").ok_or_else(|| invalid("`tag` is needed"))?;
|
||||
if !event.tags.contains(&tag) {
|
||||
event.tags.push(tag);
|
||||
}
|
||||
}
|
||||
Some("untag") => {
|
||||
let tag = text("tag").ok_or_else(|| invalid("`tag` is needed"))?;
|
||||
event.tags.retain(|own| *own != tag);
|
||||
}
|
||||
Some("calendar") => event.calendar = Some(text("calendar").ok_or_else(|| invalid("`calendar` is needed"))?),
|
||||
_ => return Err(invalid("`action` must be move, tag, untag or calendar")),
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
fn bulk(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let targets = events(session, params)?;
|
||||
let updated: Vec<Event> = targets.iter().map(|(_, _, event)| changed(event, params)).collect::<Result<_, _>>()?;
|
||||
let paths: Vec<&str> = targets.iter().map(|(path, ..)| path.as_str()).collect();
|
||||
if !flag_param(params, "confirm", false) {
|
||||
return json(json!({ "applied": false, "affected": paths.len(), "paths": paths }));
|
||||
}
|
||||
for ((path, text, _), event) in targets.iter().zip(&updated) {
|
||||
save_event(session, path, text, event)?;
|
||||
}
|
||||
json(json!({ "applied": true, "affected": paths.len(), "paths": paths }))
|
||||
}
|
||||
|
||||
fn bulk_delete(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let targets = events(session, params)?;
|
||||
let paths: Vec<&str> = targets.iter().map(|(path, ..)| path.as_str()).collect();
|
||||
if !flag_param(params, "confirm", false) {
|
||||
return json(json!({ "applied": false, "affected": paths.len(), "paths": paths }));
|
||||
}
|
||||
for path in &paths {
|
||||
session.delete_note(path).map_err(|error| command_error(&error))?;
|
||||
}
|
||||
json(json!({ "applied": true, "affected": paths.len(), "paths": paths }))
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The local clock without a date crate: the system's offset from UTC, read once.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// The offset of local time from UTC, minutes east of Greenwich.
|
||||
///
|
||||
/// Read from `date +%z` on first use (macOS, Linux); 0 where that is not available.
|
||||
#[must_use]
|
||||
pub fn local_utc_offset_minutes() -> i32 {
|
||||
static OFFSET: OnceLock<i32> = OnceLock::new();
|
||||
*OFFSET.get_or_init(|| {
|
||||
std::process::Command::new("date")
|
||||
.arg("+%z")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|out| String::from_utf8(out.stdout).ok())
|
||||
.and_then(|text| parse_offset(text.trim()))
|
||||
.unwrap_or(0)
|
||||
})
|
||||
}
|
||||
|
||||
/// `+0530` or `-0800` as minutes.
|
||||
fn parse_offset(text: &str) -> Option<i32> {
|
||||
let (sign, digits) = match text.split_at_checked(1)? {
|
||||
("+", digits) => (1, digits),
|
||||
("-", digits) => (-1, digits),
|
||||
_ => return None,
|
||||
};
|
||||
if digits.len() != 4 || !digits.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let (hours, minutes) = (digits.get(..2)?.parse::<i32>().ok()?, digits.get(2..)?.parse::<i32>().ok()?);
|
||||
(hours <= 14 && minutes < 60).then_some(sign * (hours * 60 + minutes))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_offset;
|
||||
|
||||
#[test]
|
||||
fn offsets_parse_and_junk_does_not() {
|
||||
assert_eq!(parse_offset("+0530"), Some(330));
|
||||
assert_eq!(parse_offset("-0800"), Some(-480));
|
||||
assert_eq!(parse_offset("+0000"), Some(0));
|
||||
for bad in ["", "0530", "+530", "+2500", "+0560", "+05a0", "é+0530", "++0530"] {
|
||||
assert_eq!(parse_offset(bad), None, "{bad:?}");
|
||||
}
|
||||
assert!(super::local_utc_offset_minutes().abs() <= 14 * 60);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! The calendar write commands: add, edit, move, delete and meeting notes.
|
||||
|
||||
use notescraft_calendar::{Event, EventTime, parse_quick};
|
||||
use notescraft_templates::DateTime;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::series::{self, Outcome, target};
|
||||
use super::store::{create_event, invalid, load_event, save_event, time_param};
|
||||
use crate::commands::{command_error, json, text_param, writable};
|
||||
use crate::daily::optional_integer;
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Folder meeting notes are created in.
|
||||
const DEFAULT_NOTES_FOLDER: &str = "Meetings";
|
||||
|
||||
/// Longest meeting-note file name stem.
|
||||
const MAX_NOTE_NAME: usize = 80;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let id = || ParamSpec::required("id", ParamKind::String, "vault path of the event file, from the agenda");
|
||||
let scope = || ParamSpec::optional("scope", ParamKind::String, "for a repeating event: this, following or all");
|
||||
let occurrence =
|
||||
|| ParamSpec::optional("occurrence", ParamKind::String, "for scope this or following: the start of the occurrence, as listed by the agenda");
|
||||
vec![
|
||||
CommandSpec::new("calendar.add", "Add event", "Creates an event from a quick-add phrase or from fields", writable, add)
|
||||
.with_param(ParamSpec::optional("text", ParamKind::String, "a phrase like 'lunch tomorrow at 12:30 for 1h'"))
|
||||
.with_param(ParamSpec::optional("event", ParamKind::Object, "the event fields: title, start, end, repeat, calendar, tags, reminders, ..."))
|
||||
.with_param(ParamSpec::optional("utc_offset_minutes", ParamKind::Integer, "UTC offset for 'today', minutes east of Greenwich, 0 by default")),
|
||||
CommandSpec::new("calendar.edit", "Edit event", "Changes fields of an event, or of part of a repeating series", writable, edit)
|
||||
.with_param(id())
|
||||
.with_param(ParamSpec::required("fields", ParamKind::Object, "the fields to change; null clears one"))
|
||||
.with_param(scope())
|
||||
.with_param(occurrence()),
|
||||
CommandSpec::new("calendar.move", "Move event", "Moves an event to a new start, keeping its length", writable, move_event)
|
||||
.with_param(id())
|
||||
.with_param(ParamSpec::required("start", ParamKind::String, "the new start, YYYY-MM-DD or YYYY-MM-DDTHH:MM"))
|
||||
.with_param(scope())
|
||||
.with_param(occurrence()),
|
||||
CommandSpec::new("calendar.delete", "Delete event", "Deletes an event, or part of a repeating series", writable, delete)
|
||||
.with_param(id())
|
||||
.with_param(scope())
|
||||
.with_param(occurrence()),
|
||||
CommandSpec::new("calendar.note", "Meeting note", "Creates, or finds, the note linked to an event", writable, note)
|
||||
.with_param(id())
|
||||
.with_param(ParamSpec::optional("folder", ParamKind::String, "folder for the note, Meetings by default"))
|
||||
.with_param(ParamSpec::optional("utc_offset_minutes", ParamKind::Integer, "UTC offset for template dates, 0 by default")),
|
||||
]
|
||||
}
|
||||
|
||||
fn today(params: &Value) -> Result<notescraft_templates::Date, CommandError> {
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
DateTime::now_with_offset(offset).map(DateTime::date).map_err(|error| invalid(error.to_string()))
|
||||
}
|
||||
|
||||
fn add(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let event: Event = match (params.get("text").and_then(Value::as_str), params.get("event")) {
|
||||
(Some(text), None) => parse_quick(text, today(params)?).map_err(|error| CommandError::new("unreadable", error.to_string()))?,
|
||||
(None, Some(fields)) => super::store::event_from(fields)?,
|
||||
_ => return Err(invalid("give exactly one of `text` and `event`")),
|
||||
};
|
||||
let path = create_event(session, &event)?;
|
||||
json(json!({ "path": path, "event": event }))
|
||||
}
|
||||
|
||||
/// The target of an edit, move or delete: path, file text, event, scope and occurrence.
|
||||
fn resolve(session: &Session, params: &Value) -> Result<(String, String, Event, series::Scope, Option<EventTime>), CommandError> {
|
||||
let id = text_param(params, "id")?;
|
||||
let (text, event) = load_event(session, &id)?;
|
||||
let (scope, occurrence) = target(params, &event)?;
|
||||
Ok((id.trim().to_owned(), text, event, scope, occurrence))
|
||||
}
|
||||
|
||||
fn report(outcome: &Outcome) -> CommandResult {
|
||||
json(outcome)
|
||||
}
|
||||
|
||||
fn edit(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let (path, text, event, scope, occurrence) = resolve(session, params)?;
|
||||
let fields = params.get("fields").ok_or_else(|| invalid("`fields` is needed"))?;
|
||||
report(&series::edit(session, &path, &text, &event, scope, occurrence, fields)?)
|
||||
}
|
||||
|
||||
fn move_event(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let (path, text, event, scope, occurrence) = resolve(session, params)?;
|
||||
let start = time_param(params, "start")?.ok_or_else(|| invalid("`start` is needed"))?;
|
||||
let fields = json!({ "start": start.to_string() });
|
||||
report(&series::edit(session, &path, &text, &event, scope, occurrence, &fields)?)
|
||||
}
|
||||
|
||||
fn delete(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let (path, text, event, scope, occurrence) = resolve(session, params)?;
|
||||
report(&series::delete(session, &path, &text, &event, scope, occurrence)?)
|
||||
}
|
||||
|
||||
/// The meeting note of an event: the linked one when it exists, else a new one.
|
||||
fn note(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let id = text_param(params, "id")?;
|
||||
let (text, event) = load_event(session, &id)?;
|
||||
if let Some(existing) = event.note.as_deref().filter(|path| session.read_note(path).is_ok()) {
|
||||
return json(json!({ "path": existing, "created": false }));
|
||||
}
|
||||
let folder_name = params.get("folder").and_then(Value::as_str).unwrap_or(DEFAULT_NOTES_FOLDER).trim_matches('/').to_owned();
|
||||
let path = format!("{folder_name}/{} {}.md", EventTime::Day(event.start.date()), file_name(&event.title));
|
||||
let link = format!("[[{}]]", id.trim().trim_end_matches(".md"));
|
||||
let template = format!("{}/Meeting.md", session.config().templates_folder.trim_matches('/'));
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
if session.read_note(&template).is_ok() {
|
||||
session.create_from_template(&template, &path, offset).map_err(|error| command_error(&error))?;
|
||||
let body = session.read_note(&path).map_err(|error| command_error(&error))?;
|
||||
session.write_note(&path, &format!("{}\nEvent: {link}\n", body.trim_end())).map_err(|error| command_error(&error))?;
|
||||
} else {
|
||||
let body = format!("# {}\n\n- When: {}\n- Event: {link}\n\n## Notes\n\n", event.title, event.start);
|
||||
session.create_note(&path, &body).map_err(|error| command_error(&error))?;
|
||||
}
|
||||
let mut linked = event;
|
||||
linked.note = Some(path.clone());
|
||||
save_event(session, id.trim(), &text, &linked)?;
|
||||
json(json!({ "path": path, "created": true }))
|
||||
}
|
||||
|
||||
/// A title as a file name: path separators and other trouble characters become spaces.
|
||||
fn file_name(title: &str) -> String {
|
||||
let cleaned: String = title.chars().map(|c| if c.is_control() || "/\\:*?\"<>|#^[]".contains(c) { ' ' } else { c }).collect();
|
||||
let cleaned = cleaned.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let cleaned: String = cleaned.chars().take(MAX_NOTE_NAME).collect();
|
||||
if cleaned.is_empty() { "Meeting".to_owned() } else { cleaned }
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
//! ICS feeds in the vault: the subscription list, the per-feed cache and the sync log.
|
||||
//!
|
||||
//! Fetching is not done here. `calendar.sync_due` says which feeds to fetch (with their
|
||||
//! address, so it is not an assistant-visible read), whoever fetches calls
|
||||
//! `calendar.sync_apply` with the answer, and a failure keeps the cached events. Reads
|
||||
//! (`calendar.feeds`) never show an address.
|
||||
|
||||
use notescraft_calendar::{CachedFeed, EventTime, Feed, FeedStatus, Fetch, Fetched, Item, due, epoch_minutes, log_line, refresh};
|
||||
use notescraft_templates::{Date, DateTime};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::load::{Loaded, expand_event};
|
||||
use super::store::invalid;
|
||||
use crate::commands::{command_error, flag_param, json, text_param, writable};
|
||||
use crate::daily::optional_integer;
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
pub(super) const FEEDS_PATH: &str = ".notescraft/calendar-feeds.json";
|
||||
const CACHE_DIR: &str = ".notescraft/calendar-cache";
|
||||
const LOG_PATH: &str = ".notescraft/calendar-sync.log";
|
||||
|
||||
/// Most feeds a vault subscribes to.
|
||||
const MAX_FEEDS: usize = 50;
|
||||
|
||||
/// Largest feeds, cache or log file read.
|
||||
const MAX_FILE_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Most lines the sync log keeps.
|
||||
const MAX_LOG_LINES: usize = 200;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let now = || ParamSpec::optional("now", ParamKind::String, "the clock as YYYY-MM-DDTHH:MM, the real clock by default");
|
||||
let offset = || ParamSpec::optional("utc_offset_minutes", ParamKind::Integer, "UTC offset of the real clock, minutes east of Greenwich, 0 by default");
|
||||
vec![
|
||||
CommandSpec::new("calendar.feeds", "Calendar feeds", "The subscribed ICS feeds and how their last refresh went (never their address)", |_| true, list),
|
||||
CommandSpec::new("calendar.feed_add", "Add calendar feed", "Subscribes to an ICS feed by address", writable, add)
|
||||
.with_param(ParamSpec::required("name", ParamKind::String, "the name its events carry as their calendar"))
|
||||
.with_param(ParamSpec::required("url", ParamKind::String, "https://, http:// or webcal:// address of the .ics file"))
|
||||
.with_param(ParamSpec::optional("refresh_minutes", ParamKind::Integer, "minutes between refreshes, 60 by default")),
|
||||
CommandSpec::new("calendar.feed_remove", "Remove calendar feed", "Unsubscribes from a feed and forgets its events", writable, remove)
|
||||
.with_param(ParamSpec::required("id", ParamKind::String, "the feed id")),
|
||||
CommandSpec::new("calendar.sync_due", "Feeds to refresh", "The feeds due for a refresh, with the address to fetch", writable, sync_due)
|
||||
.with_param(now())
|
||||
.with_param(offset())
|
||||
.with_param(ParamSpec::optional("force", ParamKind::Bool, "every feed, whatever its schedule")),
|
||||
CommandSpec::new("calendar.sync_apply", "Apply a feed fetch", "Stores the answer of a fetch; a failure keeps the cached events", writable, sync_apply)
|
||||
.with_param(ParamSpec::required("id", ParamKind::String, "the feed id"))
|
||||
.with_param(ParamSpec::optional("text", ParamKind::String, "the .ics text that was fetched"))
|
||||
.with_param(ParamSpec::optional("etag", ParamKind::String, "the ETag of the answer"))
|
||||
.with_param(ParamSpec::optional("not_modified", ParamKind::Bool, "the server said nothing changed"))
|
||||
.with_param(ParamSpec::optional("error", ParamKind::String, "why the fetch failed"))
|
||||
.with_param(now())
|
||||
.with_param(offset()),
|
||||
]
|
||||
}
|
||||
|
||||
pub(super) fn read_json<T: serde::de::DeserializeOwned + Default>(session: &Session, path: &str) -> T {
|
||||
match session.vault().read(path) {
|
||||
Ok(bytes) if bytes.len() <= MAX_FILE_BYTES => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
_ => T::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn write_json<T: serde::Serialize>(session: &Session, path: &str, value: &T) -> Result<(), CommandError> {
|
||||
let bytes = serde_json::to_vec(value).map_err(|error| invalid(error.to_string()))?;
|
||||
session.vault().write_private(path, &bytes).map_err(|error| command_error(&error.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The subscribed feeds.
|
||||
pub(super) fn feeds(session: &Session) -> Vec<Feed> {
|
||||
read_json(session, FEEDS_PATH)
|
||||
}
|
||||
|
||||
pub(super) fn cache_path(id: &str) -> String {
|
||||
format!("{CACHE_DIR}/{id}.json")
|
||||
}
|
||||
|
||||
/// The cache of one feed.
|
||||
pub(super) fn cached(session: &Session, id: &str) -> CachedFeed {
|
||||
read_json(session, &cache_path(id))
|
||||
}
|
||||
|
||||
/// Every feed's name with how its last refresh went, for the offline indicator.
|
||||
#[must_use]
|
||||
pub fn feed_statuses(session: &Session) -> Vec<(String, FeedStatus)> {
|
||||
feeds(session).into_iter().map(|feed| (feed.name.clone(), cached(session, &feed.id).status)).collect()
|
||||
}
|
||||
|
||||
/// A feed with how its last refresh went.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeedInfo {
|
||||
/// The feed id.
|
||||
pub id: String,
|
||||
/// The name shown.
|
||||
pub name: String,
|
||||
/// The Google calendar id it follows, if it follows one.
|
||||
pub google_calendar: Option<String>,
|
||||
/// How the last refresh went.
|
||||
pub status: FeedStatus,
|
||||
}
|
||||
|
||||
/// Every feed with how its last refresh went.
|
||||
#[must_use]
|
||||
pub fn feed_list(session: &Session) -> Vec<FeedInfo> {
|
||||
feeds(session)
|
||||
.into_iter()
|
||||
.map(|feed| FeedInfo {
|
||||
google_calendar: feed.google_calendar().map(str::to_owned),
|
||||
status: cached(session, &feed.id).status,
|
||||
id: feed.id,
|
||||
name: feed.name,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The last lines of the sync log, newest last.
|
||||
#[must_use]
|
||||
pub fn sync_log(session: &Session, count: usize) -> Vec<String> {
|
||||
let text = session
|
||||
.vault()
|
||||
.read(LOG_PATH)
|
||||
.ok()
|
||||
.filter(|bytes| bytes.len() <= MAX_FILE_BYTES)
|
||||
.map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
|
||||
.unwrap_or_default();
|
||||
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
|
||||
let skip = lines.len().saturating_sub(count);
|
||||
lines.into_iter().skip(skip).collect()
|
||||
}
|
||||
|
||||
/// The items of the feeds' cached events that touch `from..=to`, read-only.
|
||||
pub(super) fn feed_items(session: &Session, from: Date, to: Date, loaded: &mut Loaded) {
|
||||
for feed in feeds(session) {
|
||||
let cache = cached(session, &feed.id);
|
||||
for (number, imported) in cache.events.iter().enumerate() {
|
||||
let mut event = imported.event.clone();
|
||||
event.calendar.get_or_insert_with(|| feed.name.clone());
|
||||
let id = format!("feed:{}:{}", feed.id, imported.uid.clone().unwrap_or_else(|| number.to_string()));
|
||||
match expand_event(&id, &event, from, to) {
|
||||
Ok(items) => loaded.items.extend(items.into_iter().map(|item| Item { source: Some(feed.id.clone()), repeats: false, ..item })),
|
||||
Err(reason) => loaded.skipped.push(format!("{}: {reason}", feed.name)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clock(params: &Value) -> Result<EventTime, CommandError> {
|
||||
if let Some(text) = params.get("now").and_then(Value::as_str) {
|
||||
return text.parse().map_err(|error: notescraft_calendar::Error| invalid(format!("`now`: {error}")));
|
||||
}
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
DateTime::now_with_offset(offset).map(EventTime::Minute).map_err(|error| invalid(error.to_string()))
|
||||
}
|
||||
|
||||
fn list(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
let rows: Vec<Value> = feeds(session)
|
||||
.iter()
|
||||
.map(|feed| json!({ "id": feed.id, "name": feed.name, "refresh_minutes": feed.refresh_minutes, "status": cached(session, &feed.id).status }))
|
||||
.collect();
|
||||
json(json!({ "count": rows.len(), "feeds": rows }))
|
||||
}
|
||||
|
||||
fn add(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let name = text_param(params, "name")?;
|
||||
let url = text_param(params, "url")?;
|
||||
let refresh = params.get("refresh_minutes").and_then(Value::as_u64).map(|n| u32::try_from(n).unwrap_or(u32::MAX));
|
||||
let mut feed = Feed::new(&name, &url, refresh).map_err(|error| invalid(error.to_string()))?;
|
||||
let mut all = feeds(session);
|
||||
if all.len() >= MAX_FEEDS {
|
||||
return Err(invalid(format!("at most {MAX_FEEDS} feeds")));
|
||||
}
|
||||
let base = feed.id.clone();
|
||||
let mut number = 1;
|
||||
while all.iter().any(|own| own.id == feed.id) {
|
||||
number += 1;
|
||||
feed.id = format!("{base}-{number}");
|
||||
}
|
||||
all.push(feed.clone());
|
||||
write_json(session, FEEDS_PATH, &all)?;
|
||||
json(json!({ "id": feed.id, "name": feed.name }))
|
||||
}
|
||||
|
||||
fn remove(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let id = text_param(params, "id")?;
|
||||
let mut all = feeds(session);
|
||||
let before = all.len();
|
||||
all.retain(|feed| feed.id != id);
|
||||
if all.len() == before {
|
||||
return Err(CommandError::new("not_found", format!("no feed `{id}`")));
|
||||
}
|
||||
write_json(session, FEEDS_PATH, &all)?;
|
||||
write_json(session, &cache_path(&id), &CachedFeed::default())?;
|
||||
json(json!({ "removed": id }))
|
||||
}
|
||||
|
||||
fn sync_due(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let now = clock(params)?;
|
||||
let minutes = epoch_minutes(now).ok_or_else(|| invalid("`now` is out of range"))?;
|
||||
let all = feeds(session);
|
||||
let statuses = all.iter().map(|feed| (feed.id.clone(), cached(session, &feed.id).status)).collect();
|
||||
let rows: Vec<Value> = due(&all, &statuses, minutes, flag_param(params, "force", false))
|
||||
.into_iter()
|
||||
.map(|feed| json!({ "id": feed.id, "name": feed.name, "url": feed.url, "etag": cached(session, &feed.id).status.etag }))
|
||||
.collect();
|
||||
json(json!({ "count": rows.len(), "feeds": rows }))
|
||||
}
|
||||
|
||||
/// One answer, handed to `refresh` as if it had just been fetched.
|
||||
struct Answer(Option<Result<Fetched, String>>);
|
||||
|
||||
impl Fetch for Answer {
|
||||
fn fetch(&mut self, _url: &str, _etag: Option<&str>) -> Result<Fetched, String> {
|
||||
self.0.take().unwrap_or_else(|| Err("no answer".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_apply(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let id = text_param(params, "id")?;
|
||||
let feed = feeds(session).into_iter().find(|feed| feed.id == id).ok_or_else(|| CommandError::new("not_found", format!("no feed `{id}`")))?;
|
||||
let now = clock(params)?;
|
||||
let answer = match (params.get("error").and_then(Value::as_str), params.get("text").and_then(Value::as_str), flag_param(params, "not_modified", false)) {
|
||||
(Some(error), _, _) => Err(error.to_owned()),
|
||||
(None, _, true) => Ok(Fetched::NotModified),
|
||||
(None, Some(text), false) => Ok(Fetched::Body { text: text.to_owned(), etag: params.get("etag").and_then(Value::as_str).map(str::to_owned) }),
|
||||
_ => return Err(invalid("give `text`, `not_modified` or `error`")),
|
||||
};
|
||||
let previous = cached(session, &feed.id);
|
||||
let updated = refresh(&feed, Some(&previous), &mut Answer(Some(answer)), now);
|
||||
write_json(session, &cache_path(&feed.id), &updated)?;
|
||||
append_log(session, &log_line(&feed, &updated.status, now))?;
|
||||
json(json!({ "id": feed.id, "events": updated.status.events, "skipped": updated.status.skipped, "error": updated.status.error }))
|
||||
}
|
||||
|
||||
fn append_log(session: &Session, line: &str) -> Result<(), CommandError> {
|
||||
let mut lines: Vec<String> = match session.vault().read(LOG_PATH) {
|
||||
Ok(bytes) if bytes.len() <= MAX_FILE_BYTES => String::from_utf8_lossy(&bytes).lines().map(str::to_owned).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
lines.push(line.to_owned());
|
||||
let keep = lines.len().saturating_sub(MAX_LOG_LINES);
|
||||
let text = lines.split_off(keep).join("\n") + "\n";
|
||||
session.vault().write_private(LOG_PATH, text.as_bytes()).map_err(|error| command_error(&error.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Saved filters: named queries kept in `NotesCraft`'s private vault storage.
|
||||
|
||||
use notescraft_calendar::SavedFilter;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::store::invalid;
|
||||
use crate::commands::{command_error, json, text_param, writable};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Where the filters are kept.
|
||||
const FILTERS_PATH: &str = ".notescraft/calendar-filters.json";
|
||||
|
||||
/// Most saved filters.
|
||||
const MAX_FILTERS: usize = 100;
|
||||
|
||||
/// Longest filter name.
|
||||
const MAX_NAME_CHARS: usize = 80;
|
||||
|
||||
/// Largest filters file read.
|
||||
const MAX_FILTERS_BYTES: usize = 256 * 1024;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
vec![
|
||||
CommandSpec::new("calendar.filters", "Saved calendar filters", "Lists the saved filters", |_| true, list),
|
||||
CommandSpec::new(
|
||||
"calendar.filter_save",
|
||||
"Save calendar filter",
|
||||
"Saves a filter, sort and grouping under a name; the same name replaces it",
|
||||
writable,
|
||||
save,
|
||||
)
|
||||
.with_param(ParamSpec::required("name", ParamKind::String, "the name"))
|
||||
.with_param(ParamSpec::required("query", ParamKind::Object, "filter, sort, descending and group, as for calendar.agenda")),
|
||||
CommandSpec::new("calendar.filter_delete", "Delete calendar filter", "Deletes a saved filter", writable, delete).with_param(ParamSpec::required(
|
||||
"name",
|
||||
ParamKind::String,
|
||||
"the name",
|
||||
)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The saved filters of the vault, empty when there are none or the file is unreadable.
|
||||
#[must_use]
|
||||
pub fn saved_filters(session: &Session) -> Vec<SavedFilter> {
|
||||
match session.vault().read(FILTERS_PATH) {
|
||||
Ok(bytes) if bytes.len() <= MAX_FILTERS_BYTES => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(session: &Session, filters: &[SavedFilter]) -> Result<(), CommandError> {
|
||||
let bytes = serde_json::to_vec_pretty(filters).map_err(|error| invalid(error.to_string()))?;
|
||||
session.vault().write_private(FILTERS_PATH, &bytes).map_err(|error| command_error(&error.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn list(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
json(json!({ "filters": saved_filters(session) }))
|
||||
}
|
||||
|
||||
fn save(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let name = text_param(params, "name")?.trim().to_owned();
|
||||
if name.is_empty() || name.chars().count() > MAX_NAME_CHARS {
|
||||
return Err(invalid(format!("the name must be 1 to {MAX_NAME_CHARS} characters")));
|
||||
}
|
||||
let query = serde_json::from_value(params.get("query").cloned().unwrap_or(Value::Null)).map_err(|error| invalid(format!("bad query: {error}")))?;
|
||||
let mut filters = saved_filters(session);
|
||||
let entry = SavedFilter { name: name.clone(), query };
|
||||
match filters.iter().position(|filter| filter.name == name) {
|
||||
Some(position) => {
|
||||
if let Some(slot) = filters.get_mut(position) {
|
||||
*slot = entry;
|
||||
}
|
||||
}
|
||||
None if filters.len() >= MAX_FILTERS => return Err(invalid(format!("at most {MAX_FILTERS} saved filters"))),
|
||||
None => filters.push(entry),
|
||||
}
|
||||
write(session, &filters)?;
|
||||
json(json!({ "saved": name, "count": filters.len() }))
|
||||
}
|
||||
|
||||
fn delete(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let name = text_param(params, "name")?;
|
||||
let mut filters = saved_filters(session);
|
||||
let before = filters.len();
|
||||
filters.retain(|filter| filter.name != name.trim());
|
||||
if filters.len() == before {
|
||||
return Err(CommandError::new("not_found", format!("no saved filter named `{name}`")));
|
||||
}
|
||||
write(session, &filters)?;
|
||||
json(json!({ "deleted": name.trim(), "count": filters.len() }))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The Google account side in the vault: which calendars were picked.
|
||||
//!
|
||||
//! Signing in, the token and the keychain are the app's business; here is only the list of
|
||||
//! calendars (ids and names, no secret) and the feeds that follow from it, one per enabled calendar.
|
||||
|
||||
use notescraft_calendar::Feed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::feeds::{FEEDS_PATH, cache_path, feeds, read_json, write_json};
|
||||
use super::store::invalid;
|
||||
use crate::commands::{json, writable};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
const GOOGLE_PATH: &str = ".notescraft/calendar-google.json";
|
||||
|
||||
/// Most calendars one account lists.
|
||||
const MAX_CALENDARS: usize = 500;
|
||||
|
||||
/// Most calendars that may be on at once, so a big account cannot flood the sync.
|
||||
const MAX_ENABLED: usize = 30;
|
||||
|
||||
/// One calendar of the Google account.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct GoogleCalendarState {
|
||||
/// The id Google knows it by.
|
||||
pub id: String,
|
||||
/// The name shown.
|
||||
pub name: String,
|
||||
/// Whether it is shown in the calendar.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct Saved {
|
||||
calendars: Vec<GoogleCalendarState>,
|
||||
}
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
vec![
|
||||
CommandSpec::new(
|
||||
"calendar.google_state",
|
||||
"Google calendars",
|
||||
"The calendars of the connected Google account and which are shown",
|
||||
|_| true,
|
||||
state_command,
|
||||
),
|
||||
CommandSpec::new(
|
||||
"calendar.google_calendars",
|
||||
"Pick Google calendars",
|
||||
"Stores the account's calendars and follows only the enabled ones",
|
||||
writable,
|
||||
calendars_command,
|
||||
)
|
||||
.with_param(ParamSpec::required("calendars", ParamKind::Array, "list of { id, name, enabled }")),
|
||||
CommandSpec::new(
|
||||
"calendar.google_disconnect",
|
||||
"Forget Google calendars",
|
||||
"Drops the Google calendars and their cached events",
|
||||
writable,
|
||||
disconnect_command,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The stored Google calendars.
|
||||
#[must_use]
|
||||
pub fn google_state(session: &Session) -> Vec<GoogleCalendarState> {
|
||||
read_json::<Saved>(session, GOOGLE_PATH).calendars
|
||||
}
|
||||
|
||||
fn state_command(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
json(json!({ "calendars": google_state(session) }))
|
||||
}
|
||||
|
||||
/// Makes the feeds match `calendars`: one per enabled calendar, none of the others.
|
||||
fn follow(session: &Session, calendars: &[GoogleCalendarState]) -> Result<(), CommandError> {
|
||||
let mut all = feeds(session);
|
||||
let stale: Vec<String> = all
|
||||
.iter()
|
||||
.filter(|feed| feed.google_calendar().is_some_and(|id| !calendars.iter().any(|own| own.enabled && own.id == id)))
|
||||
.map(|feed| feed.id.clone())
|
||||
.collect();
|
||||
all.retain(|feed| !stale.contains(&feed.id));
|
||||
for calendar in calendars.iter().filter(|own| own.enabled) {
|
||||
if all.iter().any(|feed| feed.google_calendar() == Some(calendar.id.as_str())) {
|
||||
continue;
|
||||
}
|
||||
let mut feed = Feed::google(&calendar.name, &calendar.id, None).map_err(|error| invalid(error.to_string()))?;
|
||||
let base = feed.id.clone();
|
||||
let mut number = 1;
|
||||
while all.iter().any(|own| own.id == feed.id) {
|
||||
number += 1;
|
||||
feed.id = format!("{base}-{number}");
|
||||
}
|
||||
all.push(feed);
|
||||
}
|
||||
write_json(session, FEEDS_PATH, &all)?;
|
||||
for id in stale {
|
||||
write_json(session, &cache_path(&id), ¬escraft_calendar::CachedFeed::default())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn calendars_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let list = params.get("calendars").and_then(Value::as_array).ok_or_else(|| invalid("`calendars` must be a list"))?;
|
||||
if list.len() > MAX_CALENDARS {
|
||||
return Err(invalid(format!("at most {MAX_CALENDARS} calendars")));
|
||||
}
|
||||
let previous = google_state(session);
|
||||
let mut calendars: Vec<GoogleCalendarState> = Vec::new();
|
||||
for entry in list {
|
||||
let id = entry.get("id").and_then(Value::as_str).filter(|id| !id.is_empty()).ok_or_else(|| invalid("each calendar needs an `id`"))?;
|
||||
let name = entry.get("name").and_then(Value::as_str).unwrap_or(id);
|
||||
let enabled = entry.get("enabled").and_then(Value::as_bool).unwrap_or_else(|| previous.iter().find(|own| own.id == id).is_some_and(|own| own.enabled));
|
||||
if calendars.iter().any(|own| own.id == id) {
|
||||
continue;
|
||||
}
|
||||
calendars.push(GoogleCalendarState { id: id.to_owned(), name: name.chars().take(80).collect(), enabled });
|
||||
}
|
||||
if calendars.iter().filter(|calendar| calendar.enabled).count() > MAX_ENABLED {
|
||||
return Err(invalid(format!("at most {MAX_ENABLED} calendars can be on at once")));
|
||||
}
|
||||
follow(session, &calendars)?;
|
||||
write_json(session, GOOGLE_PATH, &Saved { calendars: calendars.clone() })?;
|
||||
json(json!({ "count": calendars.len(), "enabled": calendars.iter().filter(|calendar| calendar.enabled).count() }))
|
||||
}
|
||||
|
||||
fn disconnect_command(session: &mut Session, _params: &Value) -> CommandResult {
|
||||
follow(session, &[])?;
|
||||
write_json(session, GOOGLE_PATH, &Saved::default())?;
|
||||
json(json!({ "disconnected": true }))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Loading the agenda: event files expanded into occurrences, plus dated tasks.
|
||||
|
||||
use notescraft_calendar::{Event, EventTime, Item, Kind, Rule, parse_event};
|
||||
use notescraft_templates::{Date, DateTime};
|
||||
|
||||
use super::tasks;
|
||||
use crate::session::Session;
|
||||
|
||||
/// Most event files read in one pass.
|
||||
const MAX_EVENT_FILES: usize = 10_000;
|
||||
|
||||
/// Most notes scanned for dated tasks in one pass.
|
||||
const MAX_TASK_NOTES: usize = 20_000;
|
||||
|
||||
/// Most skip notes returned.
|
||||
const MAX_SKIPPED: usize = 50;
|
||||
|
||||
/// The items of a window and the files that had to be skipped.
|
||||
pub(super) struct Loaded {
|
||||
pub items: Vec<Item>,
|
||||
pub skipped: Vec<String>,
|
||||
}
|
||||
|
||||
/// Every event occurrence and dated task that touches `from..=to`, oldest first.
|
||||
///
|
||||
/// ponytail: reads every note on each call to find tasks; an index of dated
|
||||
/// tasks per note replaces this when vaults pass a few thousand notes.
|
||||
pub(super) fn load(session: &Session, from: Date, to: Date) -> Loaded {
|
||||
let folder = format!("{}/", session.config().calendar_folder.trim_matches('/'));
|
||||
let mut loaded = Loaded { items: Vec::new(), skipped: Vec::new() };
|
||||
let paths: Vec<String> = session.index().notes().into_iter().map(str::to_owned).collect();
|
||||
let (mut events, mut scanned) = (0_usize, 0_usize);
|
||||
let (show_tasks, show_done) = (session.config().calendar.tasks, session.config().calendar.show_completed);
|
||||
for path in &paths {
|
||||
let in_calendar = path.starts_with(&folder);
|
||||
if !in_calendar && !show_tasks {
|
||||
continue;
|
||||
}
|
||||
if in_calendar {
|
||||
events += 1;
|
||||
} else {
|
||||
scanned += 1;
|
||||
}
|
||||
if events > MAX_EVENT_FILES || scanned > MAX_TASK_NOTES {
|
||||
skip(&mut loaded, format!("{path}: over the scan cap, not read"));
|
||||
continue;
|
||||
}
|
||||
let Ok(text) = session.read_text(path) else { continue };
|
||||
if in_calendar {
|
||||
match parse_event(&text) {
|
||||
Ok(event) => match expand_event(path, &event, from, to) {
|
||||
Ok(items) => loaded.items.extend(items),
|
||||
Err(reason) => skip(&mut loaded, format!("{path}: {reason}")),
|
||||
},
|
||||
Err(error) => skip(&mut loaded, format!("{path}: {error}")),
|
||||
}
|
||||
} else if show_tasks {
|
||||
loaded.items.extend(tasks::scan(path, &text).into_iter().filter(|item| touches(item, from, to) && (show_done || !item.done)));
|
||||
}
|
||||
}
|
||||
super::feeds::feed_items(session, from, to, &mut loaded);
|
||||
loaded.items.sort_by(|a, b| a.start.cmp(&b.start).then_with(|| a.id.cmp(&b.id)));
|
||||
loaded
|
||||
}
|
||||
|
||||
fn skip(loaded: &mut Loaded, note: String) {
|
||||
if loaded.skipped.len() < MAX_SKIPPED {
|
||||
loaded.skipped.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
fn touches(item: &Item, from: Date, to: Date) -> bool {
|
||||
item.last_date() >= from && item.start.date() <= to
|
||||
}
|
||||
|
||||
/// One item for a plain event, one per occurrence for a repeating one.
|
||||
pub(super) fn expand_event(path: &str, event: &Event, from: Date, to: Date) -> Result<Vec<Item>, String> {
|
||||
let base = item_of(path, event);
|
||||
let Some(rule) = event.repeat.as_deref() else {
|
||||
return Ok(Some(base).filter(|item| touches(item, from, to)).into_iter().collect());
|
||||
};
|
||||
let rule: Rule = rule.parse().map_err(|error: notescraft_calendar::Error| error.to_string())?;
|
||||
let occurrences = rule.occurrences(event.start, &event.exceptions, from, to).map_err(|error| error.to_string())?;
|
||||
Ok(occurrences.into_iter().map(|start| Item { start, end: event.end.and_then(|end| shifted_end(event.start, end, start)), ..base.clone() }).collect())
|
||||
}
|
||||
|
||||
fn item_of(path: &str, event: &Event) -> Item {
|
||||
Item {
|
||||
id: path.to_owned(),
|
||||
kind: Kind::Event,
|
||||
title: event.title.clone(),
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
calendar: event.calendar.clone(),
|
||||
tags: event.tags.clone(),
|
||||
priority: event.priority,
|
||||
status: event.status,
|
||||
has_reminder: !event.reminders.is_empty(),
|
||||
reminders: event.reminders.iter().map(|reminder| reminder.minutes_before).collect(),
|
||||
note: event.note.clone(),
|
||||
created: None,
|
||||
done: false,
|
||||
repeats: event.repeat.is_some(),
|
||||
source: None,
|
||||
tz: event.tz.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of an occurrence that starts at `new`, keeping the original length.
|
||||
pub(super) fn shifted_end(start: EventTime, end: EventTime, new: EventTime) -> Option<EventTime> {
|
||||
match (start, end, new) {
|
||||
(EventTime::Day(a), EventTime::Day(b), EventTime::Day(n)) => {
|
||||
let days = b.to_days_since_epoch().checked_sub(a.to_days_since_epoch())?;
|
||||
n.add_days(days).ok().map(EventTime::Day)
|
||||
}
|
||||
(EventTime::Minute(a), EventTime::Minute(b), EventTime::Minute(n)) => {
|
||||
let length = minutes(b) - minutes(a);
|
||||
from_minutes(minutes(n).checked_add(length)?).map(EventTime::Minute)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn minutes(at: DateTime) -> i64 {
|
||||
i64::from(at.date().to_days_since_epoch()) * 1440 + i64::from(at.hour()) * 60 + i64::from(at.minute())
|
||||
}
|
||||
|
||||
fn from_minutes(total: i64) -> Option<DateTime> {
|
||||
let date = Date::from_days_since_epoch(i32::try_from(total.div_euclid(1440)).ok()?).ok()?;
|
||||
let in_day = total.rem_euclid(1440);
|
||||
DateTime::new(date, u8::try_from(in_day / 60).ok()?, u8::try_from(in_day % 60).ok()?, 0).ok()
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//! The calendar commands (SPEC 26): agenda, free time, conflicts, year,
|
||||
//! quick-add preview and week review, over one-note-per-event files in
|
||||
//! `SessionConfig::calendar_folder` and the dated tasks of the other notes.
|
||||
|
||||
mod bulk;
|
||||
mod clock;
|
||||
pub use clock::local_utc_offset_minutes;
|
||||
mod events;
|
||||
mod feeds;
|
||||
mod google;
|
||||
pub use feeds::{FeedInfo, feed_list, feed_statuses, sync_log};
|
||||
pub use google::{GoogleCalendarState, google_state};
|
||||
mod filters;
|
||||
pub use filters::saved_filters;
|
||||
mod load;
|
||||
mod read;
|
||||
mod reminders;
|
||||
mod series;
|
||||
mod store;
|
||||
mod tasks;
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::spec::CommandSpec;
|
||||
|
||||
/// The calendar commands: read-only ones first, then the writes.
|
||||
pub(crate) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let mut specs = read::specs();
|
||||
specs.extend(events::specs());
|
||||
specs.extend(bulk::specs());
|
||||
specs.extend(filters::specs());
|
||||
specs.extend(reminders::specs());
|
||||
specs.extend(feeds::specs());
|
||||
specs.extend(google::specs());
|
||||
specs
|
||||
}
|
||||
|
||||
/// The events and dated tasks that touch `from..=to`, oldest first, for views that
|
||||
/// paint them without going through the command registry. The second value lists
|
||||
/// the files that had to be skipped.
|
||||
#[must_use]
|
||||
pub fn agenda_items(session: &Session, from: notescraft_templates::Date, to: notescraft_templates::Date) -> (Vec<notescraft_calendar::Item>, Vec<String>) {
|
||||
let loaded = load::load(session, from, to);
|
||||
(loaded.items, loaded.skipped)
|
||||
}
|
||||
|
||||
/// The reminders inbox at `now_offset` minutes east of UTC, and the snooze choices.
|
||||
#[must_use]
|
||||
pub fn reminder_inbox(session: &Session, utc_offset_minutes: i32) -> (notescraft_calendar::Inbox, Vec<u32>) {
|
||||
reminders::inbox_at(session, utc_offset_minutes)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! The read-only calendar commands.
|
||||
|
||||
use notescraft_calendar::{Query, conflicts, free_slots, heat_level, parse_quick, run_query, week_review, year_counts};
|
||||
use notescraft_templates::{Date, DateTime, Weekday};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::load::load;
|
||||
use crate::daily::{optional_integer, parse_date};
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Longest window one agenda, conflict or review call may cover, in days.
|
||||
const MAX_WINDOW_DAYS: i32 = 3660;
|
||||
|
||||
/// Days an agenda covers when `to` is not given.
|
||||
const DEFAULT_AGENDA_DAYS: i32 = 30;
|
||||
|
||||
/// How far ahead `calendar.next` looks, in days.
|
||||
const NEXT_LOOKAHEAD_DAYS: i32 = 365;
|
||||
|
||||
/// Most items `calendar.next` returns.
|
||||
const MAX_NEXT: i64 = 50;
|
||||
|
||||
const OFFSET_HELP: &str = "UTC offset for 'today', minutes east of Greenwich, 0 by default";
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let offset = || ParamSpec::optional("utc_offset_minutes", ParamKind::Integer, OFFSET_HELP);
|
||||
let day = |name: &'static str, help: &'static str| ParamSpec::optional(name, ParamKind::String, help);
|
||||
vec![
|
||||
CommandSpec::new("calendar.agenda", "Calendar agenda", "Events and dated tasks in a date range, filtered, sorted and grouped", |_| true, agenda)
|
||||
.with_param(day("from", "first day as YYYY-MM-DD, today by default"))
|
||||
.with_param(day("to", "last day as YYYY-MM-DD, 30 days after `from` by default"))
|
||||
.with_param(ParamSpec::optional("filter", ParamKind::Object, "calendars, kinds, statuses, priorities, tags, from, to, has_reminder"))
|
||||
.with_param(ParamSpec::optional("sort", ParamKind::String, "date, priority, title, linked_note or created"))
|
||||
.with_param(ParamSpec::optional("descending", ParamKind::Bool, "reverse the sort order"))
|
||||
.with_param(ParamSpec::optional("group", ParamKind::String, "none, day, week, calendar or tag"))
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.next", "Next up", "The next upcoming events and dated tasks", |_| true, next)
|
||||
.with_param(ParamSpec::optional("limit", ParamKind::Integer, "how many, 1 by default, at most 50"))
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.free", "Free time", "The free stretches of a day between two times", |_| true, free)
|
||||
.with_param(day("day", "the day as YYYY-MM-DD, today by default"))
|
||||
.with_param(ParamSpec::optional("start_minute", ParamKind::Integer, "start of the working day, minutes after midnight, 540 by default"))
|
||||
.with_param(ParamSpec::optional("end_minute", ParamKind::Integer, "end of the working day, minutes after midnight, 1080 by default"))
|
||||
.with_param(ParamSpec::optional("min_minutes", ParamKind::Integer, "shortest stretch to list, 30 by default"))
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.conflicts", "Calendar conflicts", "Pairs of timed events that overlap in a date range", |_| true, conflicts_command)
|
||||
.with_param(day("from", "first day as YYYY-MM-DD, today by default"))
|
||||
.with_param(day("to", "last day as YYYY-MM-DD, 30 days after `from` by default"))
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.year", "Calendar year", "Items per day of a year, with a heat level from 0 to 3", |_| true, year)
|
||||
.with_param(ParamSpec::required("year", ParamKind::Integer, "the year, for example 2026")),
|
||||
CommandSpec::new(
|
||||
"calendar.parse",
|
||||
"Parse quick add",
|
||||
"Reads a phrase like 'lunch tomorrow at 12:30 for 1h' into an event without saving it",
|
||||
|_| true,
|
||||
parse,
|
||||
)
|
||||
.with_param(ParamSpec::required("text", ParamKind::String, "the phrase"))
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.review", "Week in review", "Events, done and open tasks and linked notes of one week", |_| true, review)
|
||||
.with_param(day("week", "any day of the week as YYYY-MM-DD, today by default"))
|
||||
.with_param(offset()),
|
||||
]
|
||||
}
|
||||
|
||||
fn invalid(message: impl Into<String>) -> CommandError {
|
||||
CommandError::new("invalid_params", message)
|
||||
}
|
||||
|
||||
fn today(params: &Value) -> Result<Date, CommandError> {
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
DateTime::now_with_offset(offset).map(DateTime::date).map_err(|error| invalid(error.to_string()))
|
||||
}
|
||||
|
||||
fn date_param(params: &Value, name: &str, default: Date) -> Result<Date, CommandError> {
|
||||
match params.get(name).and_then(Value::as_str) {
|
||||
Some(text) => parse_date(text),
|
||||
None => Ok(default),
|
||||
}
|
||||
}
|
||||
|
||||
fn add_days(day: Date, days: i32) -> Result<Date, CommandError> {
|
||||
day.add_days(days).map_err(|error| invalid(error.to_string()))
|
||||
}
|
||||
|
||||
/// `from..=to` from the params, `to` defaulting to 30 days after `from`.
|
||||
fn window(params: &Value) -> Result<(Date, Date), CommandError> {
|
||||
let from = date_param(params, "from", today(params)?)?;
|
||||
let to = date_param(params, "to", add_days(from, DEFAULT_AGENDA_DAYS)?)?;
|
||||
if to < from {
|
||||
return Err(invalid("`to` is before `from`"));
|
||||
}
|
||||
if to.to_days_since_epoch() - from.to_days_since_epoch() > MAX_WINDOW_DAYS {
|
||||
return Err(invalid(format!("the range may cover at most {MAX_WINDOW_DAYS} days")));
|
||||
}
|
||||
Ok((from, to))
|
||||
}
|
||||
|
||||
fn agenda(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let (from, to) = window(params)?;
|
||||
let mut query = serde_json::Map::new();
|
||||
for (key, target) in [("filter", "filter"), ("sort", "sort"), ("descending", "descending"), ("group", "group")] {
|
||||
if let Some(value) = params.get(key).filter(|value| !value.is_null()) {
|
||||
query.insert(target.to_owned(), value.clone());
|
||||
}
|
||||
}
|
||||
let query: Query = serde_json::from_value(Value::Object(query)).map_err(|error| invalid(format!("bad filter, sort or group: {error}")))?;
|
||||
let loaded = load(session, from, to);
|
||||
let groups = run_query(&loaded.items, &query, session.config().week_start);
|
||||
let count: usize = groups.iter().map(|group| group.items.len()).sum();
|
||||
crate::commands::json(json!({ "from": from_text(from), "to": from_text(to), "count": count, "groups": groups, "skipped": loaded.skipped }))
|
||||
}
|
||||
|
||||
fn from_text(day: Date) -> String {
|
||||
notescraft_calendar::EventTime::Day(day).to_string()
|
||||
}
|
||||
|
||||
fn next(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let limit = params
|
||||
.get("limit")
|
||||
.map_or(Some(1), Value::as_i64)
|
||||
.filter(|n| (1..=MAX_NEXT).contains(n))
|
||||
.ok_or_else(|| invalid(format!("`limit` must be from 1 to {MAX_NEXT}")))?;
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
let now = DateTime::now_with_offset(offset).map_err(|error| invalid(error.to_string()))?;
|
||||
let from = now.date();
|
||||
let loaded = load(session, from, add_days(from, NEXT_LOOKAHEAD_DAYS)?);
|
||||
let upcoming: Vec<_> = loaded
|
||||
.items
|
||||
.into_iter()
|
||||
.filter(|item| {
|
||||
!item.done
|
||||
&& match item.start {
|
||||
notescraft_calendar::EventTime::Day(_) => true,
|
||||
notescraft_calendar::EventTime::Minute(at) => at >= now,
|
||||
}
|
||||
})
|
||||
.take(usize::try_from(limit).unwrap_or(1))
|
||||
.collect();
|
||||
crate::commands::json(json!({ "count": upcoming.len(), "items": upcoming, "skipped": loaded.skipped }))
|
||||
}
|
||||
|
||||
fn free(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let day = date_param(params, "day", today(params)?)?;
|
||||
let minute = |name: &str, default: i32| {
|
||||
optional_integer(params, name, default).and_then(|n| u32::try_from(n).map_err(|_| invalid(format!("`{name}` must be 0 or more"))))
|
||||
};
|
||||
let (start, end, least) = (minute("start_minute", 540)?, minute("end_minute", 1080)?, minute("min_minutes", 30)?);
|
||||
if end > 1440 || start >= end {
|
||||
return Err(invalid("`start_minute` must be before `end_minute`, which is at most 1440"));
|
||||
}
|
||||
let loaded = load(session, add_days(day, -1)?, day);
|
||||
let slots = free_slots(&loaded.items, day, start, end, least);
|
||||
crate::commands::json(json!({ "day": from_text(day), "slots": slots }))
|
||||
}
|
||||
|
||||
fn conflicts_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let (from, to) = window(params)?;
|
||||
let loaded = load(session, from, to);
|
||||
let pairs: Vec<Value> =
|
||||
conflicts(&loaded.items).into_iter().filter_map(|(a, b)| Some(json!({ "a": loaded.items.get(a)?, "b": loaded.items.get(b)? }))).collect();
|
||||
crate::commands::json(json!({ "count": pairs.len(), "conflicts": pairs, "skipped": loaded.skipped }))
|
||||
}
|
||||
|
||||
fn year(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let year = optional_integer(params, "year", 0)?;
|
||||
let first = Date::new(year, 1, 1).map_err(|error| invalid(error.to_string()))?;
|
||||
let last = Date::new(year, 12, 31).map_err(|error| invalid(error.to_string()))?;
|
||||
let loaded = load(session, first, last);
|
||||
let counts = year_counts(&loaded.items, year);
|
||||
let levels: Vec<u8> = counts.iter().map(|count| heat_level(*count)).collect();
|
||||
crate::commands::json(json!({ "year": year, "counts": counts, "levels": levels, "skipped": loaded.skipped }))
|
||||
}
|
||||
|
||||
fn parse(_session: &mut Session, params: &Value) -> CommandResult {
|
||||
let text = crate::commands::text_param(params, "text")?;
|
||||
let event = parse_quick(&text, today(params)?).map_err(|error| CommandError::new("unreadable", error.to_string()))?;
|
||||
crate::commands::json(json!({ "event": event }))
|
||||
}
|
||||
|
||||
fn review(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let any_day = date_param(params, "week", today(params)?)?;
|
||||
let week_start: Weekday = session.config().week_start;
|
||||
let back = i32::from((any_day.weekday().index() + 7 - week_start.index()) % 7);
|
||||
let first = add_days(any_day, -back)?;
|
||||
let loaded = load(session, first, add_days(first, 6)?);
|
||||
crate::commands::json(json!({ "review": week_review(&loaded.items, first), "skipped": loaded.skipped }))
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! The reminder commands: the inbox, the scheduler tick, snooze and dismiss.
|
||||
//!
|
||||
//! The scheduler's memory lives in `.notescraft/calendar-reminders.json`. Every command takes
|
||||
//! an optional `now` (`YYYY-MM-DDTHH:MM`) so tests and scripts need no real clock.
|
||||
|
||||
use notescraft_calendar::{EventTime, Item, ReminderSettings, ReminderState, alerts_for, catch_up, dismiss, epoch_minutes, inbox, snooze, tick};
|
||||
use notescraft_templates::{Date, DateTime};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::load::load;
|
||||
use super::store::invalid;
|
||||
use crate::commands::{command_error, flag_param, json, text_param, writable};
|
||||
use crate::daily::optional_integer;
|
||||
use crate::error::CommandError;
|
||||
use crate::params::{ParamKind, ParamSpec};
|
||||
use crate::session::Session;
|
||||
use crate::spec::{CommandResult, CommandSpec};
|
||||
|
||||
/// Where the scheduler remembers what it showed.
|
||||
const STATE_PATH: &str = ".notescraft/calendar-reminders.json";
|
||||
|
||||
/// Largest state file read.
|
||||
const MAX_STATE_BYTES: usize = 512 * 1024;
|
||||
|
||||
/// Days before and after `now` whose events the scheduler looks at.
|
||||
const LOOK_BEHIND: i32 = 1;
|
||||
const LOOK_AHEAD: i32 = 2;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec<Session>> {
|
||||
let now = || ParamSpec::optional("now", ParamKind::String, "the clock as YYYY-MM-DDTHH:MM, the real clock by default");
|
||||
let offset = || ParamSpec::optional("utc_offset_minutes", ParamKind::Integer, "UTC offset of the real clock, minutes east of Greenwich, 0 by default");
|
||||
let key = || ParamSpec::required("key", ParamKind::String, "the alert key from the inbox");
|
||||
vec![
|
||||
CommandSpec::new("calendar.reminders", "Reminders inbox", "Reminders that are due, coming up, snoozed or missed", |_| true, inbox_command)
|
||||
.with_param(now())
|
||||
.with_param(offset()),
|
||||
CommandSpec::new(
|
||||
"calendar.reminders_tick",
|
||||
"Check reminders",
|
||||
"Shows what came due since the last check; with catch_up, counts what fell while closed as missed",
|
||||
writable,
|
||||
tick_command,
|
||||
)
|
||||
.with_param(now())
|
||||
.with_param(offset())
|
||||
.with_param(ParamSpec::optional("catch_up", ParamKind::Bool, "true at start-up")),
|
||||
CommandSpec::new("calendar.reminder_snooze", "Snooze reminder", "Puts a reminder off", writable, snooze_command)
|
||||
.with_param(key())
|
||||
.with_param(ParamSpec::optional("minutes", ParamKind::Integer, "how long; the first snooze choice by default"))
|
||||
.with_param(now())
|
||||
.with_param(offset()),
|
||||
CommandSpec::new("calendar.reminder_dismiss", "Dismiss reminder", "Clears a reminder for good", writable, dismiss_command).with_param(key()),
|
||||
]
|
||||
}
|
||||
|
||||
fn clock(params: &Value) -> Result<i64, CommandError> {
|
||||
if let Some(text) = params.get("now").and_then(Value::as_str) {
|
||||
let time: EventTime = text.parse().map_err(|error: notescraft_calendar::Error| invalid(format!("`now`: {error}")))?;
|
||||
return epoch_minutes(time).ok_or_else(|| invalid("`now` is out of range"));
|
||||
}
|
||||
let offset = optional_integer(params, "utc_offset_minutes", 0)?;
|
||||
let now = DateTime::now_with_offset(offset).map_err(|error| invalid(error.to_string()))?;
|
||||
epoch_minutes(EventTime::Minute(now)).ok_or_else(|| invalid("the clock is out of range"))
|
||||
}
|
||||
|
||||
fn settings(session: &Session) -> ReminderSettings {
|
||||
let config = &session.config().reminders;
|
||||
ReminderSettings { quiet: config.quiet(), default_minutes: config.default_minutes.clone() }
|
||||
}
|
||||
|
||||
fn read_state(session: &Session) -> ReminderState {
|
||||
match session.vault().read(STATE_PATH) {
|
||||
Ok(bytes) if bytes.len() <= MAX_STATE_BYTES => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
_ => ReminderState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_state(session: &Session, state: &ReminderState) -> Result<(), CommandError> {
|
||||
let bytes = serde_json::to_vec(state).map_err(|error| invalid(error.to_string()))?;
|
||||
session.vault().write_private(STATE_PATH, &bytes).map_err(|error| command_error(&error.into()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The events around `now`, expanded.
|
||||
fn items_around(session: &Session, now: i64) -> Vec<Item> {
|
||||
let day = now.div_euclid(24 * 60);
|
||||
let at = |offset: i32| i32::try_from(day).ok().and_then(|day| Date::from_days_since_epoch(day.saturating_add(offset)).ok());
|
||||
match (at(-LOOK_BEHIND), at(LOOK_AHEAD)) {
|
||||
(Some(from), Some(to)) => load(session, from, to).items,
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn inbox_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let now = clock(params)?;
|
||||
let items = items_around(session, now);
|
||||
let shown = inbox(&items, now, &settings(session), &read_state(session));
|
||||
json(json!({ "inbox": shown, "snooze_minutes": session.config().reminders.snooze_minutes }))
|
||||
}
|
||||
|
||||
fn tick_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let now = clock(params)?;
|
||||
let items = items_around(session, now);
|
||||
let settings = settings(session);
|
||||
let mut state = read_state(session);
|
||||
let missed = if flag_param(params, "catch_up", false) { catch_up(&items, now, &settings, &mut state) } else { Vec::new() };
|
||||
let due = tick(&items, now, &settings, &mut state);
|
||||
write_state(session, &state)?;
|
||||
json(json!({ "notify": due.notify, "quiet": due.quiet, "missed": missed }))
|
||||
}
|
||||
|
||||
fn snooze_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let key = text_param(params, "key")?;
|
||||
let now = clock(params)?;
|
||||
let default = session.config().reminders.snooze_minutes.first().copied().unwrap_or(5);
|
||||
let minutes = match params.get("minutes") {
|
||||
None | Some(Value::Null) => default,
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.and_then(|minutes| u32::try_from(minutes).ok())
|
||||
.filter(|minutes| (1..=7 * 24 * 60).contains(minutes))
|
||||
.ok_or_else(|| invalid("`minutes` must be from 1 to 10080"))?,
|
||||
};
|
||||
let items = items_around(session, now);
|
||||
if !alerts_for(&items, &settings(session)).iter().any(|alert| alert.key == key) {
|
||||
return Err(CommandError::new("not_found", format!("no reminder `{key}` in the next days")));
|
||||
}
|
||||
let mut state = read_state(session);
|
||||
snooze(&mut state, &key, now, minutes);
|
||||
write_state(session, &state)?;
|
||||
json(json!({ "snoozed": key, "minutes": minutes }))
|
||||
}
|
||||
|
||||
fn dismiss_command(session: &mut Session, params: &Value) -> CommandResult {
|
||||
let key = text_param(params, "key")?;
|
||||
let mut state = read_state(session);
|
||||
dismiss(&mut state, &key);
|
||||
write_state(session, &state)?;
|
||||
json(json!({ "dismissed": key }))
|
||||
}
|
||||
|
||||
/// The inbox for the real clock at `utc_offset_minutes`, for views that paint it directly.
|
||||
pub(super) fn inbox_at(session: &Session, utc_offset_minutes: i32) -> (notescraft_calendar::Inbox, Vec<u32>) {
|
||||
let snooze_minutes = session.config().reminders.snooze_minutes.clone();
|
||||
let Some(now) = DateTime::now_with_offset(utc_offset_minutes).ok().and_then(|now| epoch_minutes(EventTime::Minute(now))) else {
|
||||
return (notescraft_calendar::Inbox::default(), snooze_minutes);
|
||||
};
|
||||
let items = items_around(session, now);
|
||||
(inbox(&items, now, &settings(session), &read_state(session)), snooze_minutes)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Editing and deleting one event file, or part of a repeating series.
|
||||
|
||||
use notescraft_calendar::{Event, EventTime, Rule};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::load::shifted_end;
|
||||
use super::store::{apply_fields, create_event, invalid, save_event};
|
||||
use crate::commands::command_error;
|
||||
use crate::error::CommandError;
|
||||
use crate::session::Session;
|
||||
|
||||
/// Which part of a repeating series a change reaches.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum Scope {
|
||||
/// Only the chosen occurrence.
|
||||
This,
|
||||
/// The chosen occurrence and every later one.
|
||||
Following,
|
||||
/// The whole series.
|
||||
All,
|
||||
}
|
||||
|
||||
/// What a change did to the vault.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
pub(super) struct Outcome {
|
||||
pub changed: Vec<String>,
|
||||
pub created: Vec<String>,
|
||||
pub deleted: Vec<String>,
|
||||
}
|
||||
|
||||
/// The scope and, when it needs one, the occurrence the params choose.
|
||||
///
|
||||
/// A plain event ignores both. A repeating event must name a scope, and
|
||||
/// `this` / `following` must name an `occurrence` that really exists.
|
||||
pub(super) fn target(params: &Value, event: &Event) -> Result<(Scope, Option<EventTime>), CommandError> {
|
||||
let Some(rule) = &event.repeat else { return Ok((Scope::All, None)) };
|
||||
let scope = match params.get("scope").and_then(Value::as_str) {
|
||||
Some("this") => Scope::This,
|
||||
Some("following") => Scope::Following,
|
||||
Some("all") => Scope::All,
|
||||
Some(other) => return Err(invalid(format!("scope `{other}` is not one of this, following, all"))),
|
||||
None => return Err(invalid("this event repeats: say which part with `scope`: this, following or all")),
|
||||
};
|
||||
if scope == Scope::All {
|
||||
return Ok((scope, None));
|
||||
}
|
||||
let wanted = super::store::time_param(params, "occurrence")?.ok_or_else(|| invalid("`occurrence` is needed with scope this or following"))?;
|
||||
let rule: Rule = rule.parse().map_err(|error: notescraft_calendar::Error| invalid(error.to_string()))?;
|
||||
let day = wanted.date();
|
||||
let found = rule.occurrences(event.start, &event.exceptions, day, day).map_err(|error| invalid(error.to_string()))?;
|
||||
let occurrence = found.first().copied().ok_or_else(|| invalid(format!("{} is not an occurrence of this series", EventTime::Day(day))))?;
|
||||
Ok((scope, Some(occurrence)))
|
||||
}
|
||||
|
||||
fn occurrence_of(occurrence: Option<EventTime>) -> Result<EventTime, CommandError> {
|
||||
occurrence.ok_or_else(|| invalid("`occurrence` is needed"))
|
||||
}
|
||||
|
||||
fn series_rule(event: &Event) -> Result<Rule, CommandError> {
|
||||
event.repeat.as_deref().unwrap_or_default().parse().map_err(|error: notescraft_calendar::Error| invalid(error.to_string()))
|
||||
}
|
||||
|
||||
/// The pieces of a series cut at `at`: the head keeps the file, the tail starts a new one.
|
||||
fn cut(event: &Event, at: EventTime) -> Result<Option<(Event, Event)>, CommandError> {
|
||||
let rule = series_rule(event)?;
|
||||
let split = match rule.split(event.start, at.date()) {
|
||||
Ok(split) => split,
|
||||
Err(_) if at.date() == event.start.date() => return Ok(None),
|
||||
Err(error) => return Err(invalid(error.to_string())),
|
||||
};
|
||||
let day = at.date();
|
||||
let head =
|
||||
Event { repeat: Some(split.head.to_string()), exceptions: event.exceptions.iter().copied().filter(|e| e.date() < day).collect(), ..event.clone() };
|
||||
let tail = Event {
|
||||
start: split.tail_start,
|
||||
end: event.end.and_then(|end| shifted_end(event.start, end, split.tail_start)),
|
||||
repeat: Some(split.tail.to_string()),
|
||||
exceptions: event.exceptions.iter().copied().filter(|e| e.date() >= day).collect(),
|
||||
..event.clone()
|
||||
};
|
||||
Ok(Some((head, tail)))
|
||||
}
|
||||
|
||||
/// Applies `fields` to the chosen part of the series stored in `path`.
|
||||
pub(super) fn edit(
|
||||
session: &mut Session,
|
||||
path: &str,
|
||||
text: &str,
|
||||
event: &Event,
|
||||
scope: Scope,
|
||||
occurrence: Option<EventTime>,
|
||||
fields: &Value,
|
||||
) -> Result<Outcome, CommandError> {
|
||||
let mut outcome = Outcome::default();
|
||||
match scope {
|
||||
Scope::All => {
|
||||
save_event(session, path, text, &apply_fields(event, fields)?)?;
|
||||
outcome.changed.push(path.to_owned());
|
||||
}
|
||||
Scope::This => {
|
||||
let at = occurrence_of(occurrence)?;
|
||||
let standalone =
|
||||
Event { start: at, end: event.end.and_then(|end| shifted_end(event.start, end, at)), repeat: None, exceptions: Vec::new(), ..event.clone() };
|
||||
let standalone = apply_fields(&standalone, fields)?;
|
||||
outcome.created.push(create_event(session, &standalone)?);
|
||||
let mut series = event.clone();
|
||||
series.exceptions.push(at);
|
||||
save_event(session, path, text, &series)?;
|
||||
outcome.changed.push(path.to_owned());
|
||||
}
|
||||
Scope::Following => {
|
||||
let at = occurrence_of(occurrence)?;
|
||||
let Some((head, tail)) = cut(event, at)? else { return edit(session, path, text, event, Scope::All, None, fields) };
|
||||
let tail = apply_fields(&tail, fields)?;
|
||||
outcome.created.push(create_event(session, &tail)?);
|
||||
save_event(session, path, text, &head)?;
|
||||
outcome.changed.push(path.to_owned());
|
||||
}
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Deletes the chosen part of the series stored in `path`.
|
||||
pub(super) fn delete(
|
||||
session: &mut Session,
|
||||
path: &str,
|
||||
text: &str,
|
||||
event: &Event,
|
||||
scope: Scope,
|
||||
occurrence: Option<EventTime>,
|
||||
) -> Result<Outcome, CommandError> {
|
||||
let mut outcome = Outcome::default();
|
||||
match scope {
|
||||
Scope::All => {
|
||||
session.delete_note(path).map_err(|error| command_error(&error))?;
|
||||
outcome.deleted.push(path.to_owned());
|
||||
}
|
||||
Scope::This => {
|
||||
let at = occurrence_of(occurrence)?;
|
||||
let mut series = event.clone();
|
||||
series.exceptions.push(at);
|
||||
save_event(session, path, text, &series)?;
|
||||
outcome.changed.push(path.to_owned());
|
||||
}
|
||||
Scope::Following => {
|
||||
let at = occurrence_of(occurrence)?;
|
||||
match cut(event, at)? {
|
||||
Some((head, _)) => {
|
||||
save_event(session, path, text, &head)?;
|
||||
outcome.changed.push(path.to_owned());
|
||||
}
|
||||
None => return delete(session, path, text, event, Scope::All, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Reading and writing event files, shared by the write commands.
|
||||
|
||||
use notescraft_calendar::{Event, EventTime, parse_event, write_event};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::load::shifted_end;
|
||||
use crate::commands::command_error;
|
||||
use crate::error::CommandError;
|
||||
use crate::session::Session;
|
||||
|
||||
/// Every key of an event file; `fields` may name only these.
|
||||
pub(super) const FIELDS: [&str; 13] =
|
||||
["title", "start", "end", "tz", "repeat", "exceptions", "calendar", "reminders", "tags", "priority", "status", "link", "note"];
|
||||
|
||||
/// Most numbered tries to find a free file name.
|
||||
const MAX_NAME_TRIES: u32 = 200;
|
||||
|
||||
/// Longest slug in a file name.
|
||||
const MAX_SLUG: usize = 40;
|
||||
|
||||
pub(super) fn invalid(message: impl Into<String>) -> CommandError {
|
||||
CommandError::new("invalid_params", message)
|
||||
}
|
||||
|
||||
pub(super) fn folder(session: &Session) -> String {
|
||||
session.config().calendar_folder.trim_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// Reads the event file `id`, which must be a note inside the calendar folder.
|
||||
pub(super) fn load_event(session: &Session, id: &str) -> Result<(String, Event), CommandError> {
|
||||
let path = id.trim().to_owned();
|
||||
if !path.starts_with(&format!("{}/", folder(session))) || !std::path::Path::new(&path).extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")) {
|
||||
return Err(invalid(format!("`{id}` is not an event file under `{}/`", folder(session))));
|
||||
}
|
||||
let text = session.read_note(&path).map_err(|error| command_error(&error))?;
|
||||
let event = parse_event(&text).map_err(|error| invalid(format!("{path}: {error}")))?;
|
||||
Ok((text, event))
|
||||
}
|
||||
|
||||
/// Writes `event` over the existing file `path`, keeping unknown keys and the body.
|
||||
pub(super) fn save_event(session: &mut Session, path: &str, existing: &str, event: &Event) -> Result<(), CommandError> {
|
||||
let text = write_event(Some(existing), event).map_err(|error| invalid(error.to_string()))?;
|
||||
session.write_note(path, &text).map_err(|error| command_error(&error))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a new event file named after the event and returns its path.
|
||||
pub(super) fn create_event(session: &mut Session, event: &Event) -> Result<String, CommandError> {
|
||||
let text = write_event(None, event).map_err(|error| invalid(error.to_string()))?;
|
||||
let stem = format!("{}-{}", EventTime::Day(event.start.date()), slug(&event.title));
|
||||
let folder = folder(session);
|
||||
for attempt in 1..=MAX_NAME_TRIES {
|
||||
let name = if attempt == 1 { stem.clone() } else { format!("{stem}-{attempt}") };
|
||||
let path = format!("{folder}/{name}.md");
|
||||
if session.index().notes().contains(&path.as_str()) {
|
||||
continue;
|
||||
}
|
||||
return match session.create_note(&path, &text) {
|
||||
Ok(()) => Ok(path),
|
||||
Err(crate::error::Error::Exists(_)) => continue,
|
||||
Err(error) => Err(command_error(&error)),
|
||||
};
|
||||
}
|
||||
Err(invalid("too many events share this name; rename one"))
|
||||
}
|
||||
|
||||
fn slug(title: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for c in title.chars().flat_map(char::to_lowercase) {
|
||||
match c {
|
||||
c if c.is_ascii_alphanumeric() => out.push(c),
|
||||
_ if !out.ends_with('-') && !out.is_empty() => out.push('-'),
|
||||
_ => {}
|
||||
}
|
||||
if out.len() >= MAX_SLUG {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let out = out.trim_end_matches('-');
|
||||
if out.is_empty() { "event".to_owned() } else { out.to_owned() }
|
||||
}
|
||||
|
||||
/// An event from a complete JSON object (the `event` param of `calendar.add`).
|
||||
pub(super) fn event_from(fields: &Value) -> Result<Event, CommandError> {
|
||||
let Some(map) = fields.as_object() else { return Err(invalid("`event` must be an object")) };
|
||||
if let Some(unknown) = map.keys().find(|key| !FIELDS.contains(&key.as_str())) {
|
||||
return Err(invalid(format!("`{unknown}` is not an event field; use {}", FIELDS.join(", "))));
|
||||
}
|
||||
let event: Event = serde_json::from_value(fields.clone()).map_err(|error| invalid(format!("bad event: {error}")))?;
|
||||
if let Some(rule) = &event.repeat {
|
||||
rule.parse::<notescraft_calendar::Rule>().map_err(|error| invalid(error.to_string()))?;
|
||||
}
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
/// Applies `fields` (a partial event; `null` clears a field) to `event`.
|
||||
///
|
||||
/// Moving `start` without naming `end` moves the end too, keeping the length.
|
||||
pub(super) fn apply_fields(event: &Event, fields: &Value) -> Result<Event, CommandError> {
|
||||
let Some(map) = fields.as_object() else { return Err(invalid("`fields` must be an object")) };
|
||||
if let Some(unknown) = map.keys().find(|key| !FIELDS.contains(&key.as_str())) {
|
||||
return Err(invalid(format!("`{unknown}` is not an event field; use {}", FIELDS.join(", "))));
|
||||
}
|
||||
let mut value = serde_json::to_value(event).map_err(|error| invalid(error.to_string()))?;
|
||||
let Some(target) = value.as_object_mut() else { return Err(invalid("event is not an object")) };
|
||||
for (key, new) in map {
|
||||
if new.is_null() {
|
||||
target.remove(key);
|
||||
} else {
|
||||
target.insert(key.clone(), new.clone());
|
||||
}
|
||||
}
|
||||
let mut merged: Event = serde_json::from_value(value).map_err(|error| invalid(format!("bad field: {error}")))?;
|
||||
if map.contains_key("start") && !map.contains_key("end") {
|
||||
merged.end = event.end.and_then(|end| shifted_end(event.start, end, merged.start));
|
||||
}
|
||||
if let Some(rule) = &merged.repeat {
|
||||
rule.parse::<notescraft_calendar::Rule>().map_err(|error| invalid(error.to_string()))?;
|
||||
}
|
||||
Ok(merged)
|
||||
}
|
||||
|
||||
/// `EventTime` for a `YYYY-MM-DD` or `YYYY-MM-DDTHH:MM` param.
|
||||
pub(super) fn time_param(params: &Value, name: &str) -> Result<Option<EventTime>, CommandError> {
|
||||
match params.get(name) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(text)) => text.parse().map(Some).map_err(|error: notescraft_calendar::Error| invalid(format!("`{name}`: {error}"))),
|
||||
Some(_) => Err(invalid(format!("`{name}` must be text like 2026-10-20 or 2026-10-20T09:30"))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Dated tasks: `- [ ] text 📅 2026-10-20` lines and a `due:` frontmatter key.
|
||||
|
||||
use notescraft_calendar::{EventTime, Item, Kind};
|
||||
use notescraft_markdown::yaml::{self, Value};
|
||||
|
||||
/// The due-date marker Obsidian's Tasks plugin writes.
|
||||
const DUE_MARKER: char = '📅';
|
||||
|
||||
/// Longest task title kept.
|
||||
const MAX_TITLE_CHARS: usize = 200;
|
||||
|
||||
/// Most tasks one note contributes.
|
||||
const MAX_TASKS_PER_NOTE: usize = 1000;
|
||||
|
||||
/// The dated tasks of one note.
|
||||
pub(super) fn scan(path: &str, text: &str) -> Vec<Item> {
|
||||
let mut items = Vec::new();
|
||||
if let Some(due) = frontmatter_due(text) {
|
||||
items.push(task(path.to_owned(), stem(path), due, false, path));
|
||||
}
|
||||
if !text.contains(DUE_MARKER) {
|
||||
return items;
|
||||
}
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
if items.len() >= MAX_TASKS_PER_NOTE {
|
||||
break;
|
||||
}
|
||||
if let Some((title, due, done)) = parse_line(line) {
|
||||
items.push(task(format!("{path}:{}", number + 1), title, due, done, path));
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn task(id: String, title: String, due: EventTime, done: bool, note: &str) -> Item {
|
||||
Item {
|
||||
id,
|
||||
kind: Kind::Task,
|
||||
title,
|
||||
start: due,
|
||||
end: None,
|
||||
calendar: None,
|
||||
tags: Vec::new(),
|
||||
priority: None,
|
||||
status: None,
|
||||
has_reminder: false,
|
||||
reminders: Vec::new(),
|
||||
note: Some(note.to_owned()),
|
||||
created: None,
|
||||
done,
|
||||
repeats: false,
|
||||
source: None,
|
||||
tz: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn stem(path: &str) -> String {
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
name.strip_suffix(".md").unwrap_or(name).to_owned()
|
||||
}
|
||||
|
||||
fn frontmatter_due(text: &str) -> Option<EventTime> {
|
||||
let front = yaml::parse(text)?;
|
||||
match front.get("due")? {
|
||||
Value::Date(value) | Value::String(value) => value.parse().ok().map(|time: EventTime| EventTime::Day(time.date())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `(title, due, done)` of a task line that carries a due date.
|
||||
fn parse_line(line: &str) -> Option<(String, EventTime, bool)> {
|
||||
let rest = line.trim_start().strip_prefix(['-', '*', '+'])?.trim_start();
|
||||
let (done, rest) =
|
||||
if let Some(rest) = rest.strip_prefix("[ ]") { (false, rest) } else { (true, rest.strip_prefix("[x]").or_else(|| rest.strip_prefix("[X]"))?) };
|
||||
let marker = rest.find(DUE_MARKER)?;
|
||||
let (before, after) = rest.split_at(marker);
|
||||
let after = after.get(DUE_MARKER.len_utf8()..)?.trim_start();
|
||||
let date_text = after.get(..10)?;
|
||||
let due = date_text.parse::<EventTime>().ok().filter(|time| time.is_all_day())?;
|
||||
let tail = after.get(10..).unwrap_or("");
|
||||
let title: String = format!("{} {}", before.trim(), tail.trim()).trim().chars().take(MAX_TITLE_CHARS).collect();
|
||||
(!title.is_empty()).then_some((title, due, done))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::scan;
|
||||
|
||||
#[test]
|
||||
fn dated_task_lines_and_due_frontmatter() {
|
||||
let text = "---\ndue: 2026-10-30\n---\n- [ ] Pay rent 📅 2026-10-20 #home\n- [x] Done thing 📅 2026-10-21\n- [ ] No date\n* [ ] 📅 2026-13-01 bad\ntext 📅 2026-10-22\n";
|
||||
let items = scan("Notes/Plan.md", text);
|
||||
let rows: Vec<(String, String, bool)> = items.iter().map(|i| (i.id.clone(), i.start.to_string(), i.done)).collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
[
|
||||
("Notes/Plan.md".into(), "2026-10-30".into(), false),
|
||||
("Notes/Plan.md:4".into(), "2026-10-20".into(), false),
|
||||
("Notes/Plan.md:5".into(), "2026-10-21".into(), true)
|
||||
]
|
||||
);
|
||||
assert_eq!(items[0].title, "Plan");
|
||||
assert_eq!(items[1].title, "Pay rent #home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_never_panics() {
|
||||
for text in ["", "📅", "- [ ] 📅", "- [ ] 📅 2026", "- [x]📅 2026-10-20", "- [ ] é📅 2026-10-2é", "---\ndue: [x]\n---\n"] {
|
||||
let _ = scan("a.md", text);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user