├── .github └── workflows │ └── build_and_test.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── src ├── edit.rs ├── index.rs ├── lib.rs ├── main.rs ├── note.rs └── storage.rs └── tests ├── index_test.rs ├── note_test.rs ├── snapshots ├── note_test__editing_an_existing_daily_alters_the_same_file.snap ├── note_test__opening_two_notes_with_the_same_name_prevents_clobbering.snap ├── note_test__opening_two_notes_with_the_same_name_prevents_clobbering_even_if_collision_exists_on_disk.snap ├── note_test__writes_dailies_to_notes_directory.snap ├── note_test__writes_notes_to_notes_directory.snap └── note_test__writes_notes_to_notes_directory_even_if_inode_changes.snap └── testutil └── mod.rs /.github/workflows/build_and_test.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Build and Test 4 | jobs: 5 | build_and_test: 6 | name: Build and Test 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions-rs/toolchain@v1 11 | with: 12 | toolchain: stable 13 | 14 | - name: Clippy 15 | uses: actions-rs/cargo@v1 16 | with: 17 | command: clippy 18 | args: --tests -- -Dwarnings 19 | 20 | - name: Check Formatting 21 | uses: actions-rs/cargo@v1 22 | with: 23 | command: fmt 24 | args: --check 25 | 26 | - name: Unit Tests 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: test 30 | args: --release 31 | 32 | - name: Check Build 33 | uses: actions-rs/cargo@v1 34 | with: 35 | command: build 36 | args: --bins --release 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.1.0] - 2025-02-09 9 | 10 | ### Changed 11 | 12 | - Make the offset in `quicknotes daily` a vararg, like `quicknotes new`'s title. 13 | You can now do `quicknotes daily 2 days ago`, rather than 14 | `quicknotes daily "2 days ago"`. 15 | 16 | 17 | ## [1.0.2] - 2025-01-15 18 | 19 | ### Changed 20 | 21 | - Bump MSRV to 1.83 (https://github.com/ollien/quicknotes/pull/2; thanks @paulpr0!) 22 | 23 | ## [1.0.1] - 2025-01-13 24 | 25 | ### Fixed 26 | 27 | - Updated `nucleo_picker` from alpha version. 28 | - Update patch versions of all other dependencies. 29 | 30 | ## [1.0.0] - 2025-01-11 31 | 32 | Initial project release 33 | 34 | [1.1.0]: https://github.com/ollien/quicknotes/compare/v1.0.2...v1.1.0 35 | [1.0.2]: https://github.com/ollien/quicknotes/compare/v1.0.1...v1.0.2 36 | [1.0.1]: https://github.com/ollien/quicknotes/compare/v1.0.0...v1.0.1 37 | [1.0.0]: https://github.com/ollien/quicknotes/releases/tag/v1.0.0 38 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "ahash" 7 | version = "0.8.11" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" 10 | dependencies = [ 11 | "cfg-if", 12 | "once_cell", 13 | "version_check", 14 | "zerocopy", 15 | ] 16 | 17 | [[package]] 18 | name = "aho-corasick" 19 | version = "1.1.3" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" 22 | dependencies = [ 23 | "memchr", 24 | ] 25 | 26 | [[package]] 27 | name = "android-tzdata" 28 | version = "0.1.1" 29 | source = "registry+https://github.com/rust-lang/crates.io-index" 30 | checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" 31 | 32 | [[package]] 33 | name = "android_system_properties" 34 | version = "0.1.5" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" 37 | dependencies = [ 38 | "libc", 39 | ] 40 | 41 | [[package]] 42 | name = "anstream" 43 | version = "0.6.18" 44 | source = "registry+https://github.com/rust-lang/crates.io-index" 45 | checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" 46 | dependencies = [ 47 | "anstyle", 48 | "anstyle-parse", 49 | "anstyle-query", 50 | "anstyle-wincon", 51 | "colorchoice", 52 | "is_terminal_polyfill", 53 | "utf8parse", 54 | ] 55 | 56 | [[package]] 57 | name = "anstyle" 58 | version = "1.0.10" 59 | source = "registry+https://github.com/rust-lang/crates.io-index" 60 | checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" 61 | 62 | [[package]] 63 | name = "anstyle-parse" 64 | version = "0.2.6" 65 | source = "registry+https://github.com/rust-lang/crates.io-index" 66 | checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" 67 | dependencies = [ 68 | "utf8parse", 69 | ] 70 | 71 | [[package]] 72 | name = "anstyle-query" 73 | version = "1.1.2" 74 | source = "registry+https://github.com/rust-lang/crates.io-index" 75 | checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" 76 | dependencies = [ 77 | "windows-sys 0.59.0", 78 | ] 79 | 80 | [[package]] 81 | name = "anstyle-wincon" 82 | version = "3.0.7" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" 85 | dependencies = [ 86 | "anstyle", 87 | "once_cell", 88 | "windows-sys 0.59.0", 89 | ] 90 | 91 | [[package]] 92 | name = "anyhow" 93 | version = "1.0.95" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" 96 | 97 | [[package]] 98 | name = "autocfg" 99 | version = "1.4.0" 100 | source = "registry+https://github.com/rust-lang/crates.io-index" 101 | checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" 102 | 103 | [[package]] 104 | name = "bitflags" 105 | version = "2.7.0" 106 | source = "registry+https://github.com/rust-lang/crates.io-index" 107 | checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" 108 | 109 | [[package]] 110 | name = "block-buffer" 111 | version = "0.10.4" 112 | source = "registry+https://github.com/rust-lang/crates.io-index" 113 | checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" 114 | dependencies = [ 115 | "generic-array", 116 | ] 117 | 118 | [[package]] 119 | name = "bumpalo" 120 | version = "3.16.0" 121 | source = "registry+https://github.com/rust-lang/crates.io-index" 122 | checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" 123 | 124 | [[package]] 125 | name = "cc" 126 | version = "1.2.9" 127 | source = "registry+https://github.com/rust-lang/crates.io-index" 128 | checksum = "c8293772165d9345bdaaa39b45b2109591e63fe5e6fbc23c6ff930a048aa310b" 129 | dependencies = [ 130 | "shlex", 131 | ] 132 | 133 | [[package]] 134 | name = "cfg-if" 135 | version = "1.0.0" 136 | source = "registry+https://github.com/rust-lang/crates.io-index" 137 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 138 | 139 | [[package]] 140 | name = "chrono" 141 | version = "0.4.39" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" 144 | dependencies = [ 145 | "android-tzdata", 146 | "iana-time-zone", 147 | "js-sys", 148 | "num-traits", 149 | "wasm-bindgen", 150 | "windows-targets 0.52.6", 151 | ] 152 | 153 | [[package]] 154 | name = "chrono-english" 155 | version = "0.1.7" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "f73d909da7eb4a7d88c679c3f5a1bc09d965754e0adb2e7627426cef96a00d6f" 158 | dependencies = [ 159 | "chrono", 160 | "scanlex", 161 | ] 162 | 163 | [[package]] 164 | name = "clap" 165 | version = "4.5.26" 166 | source = "registry+https://github.com/rust-lang/crates.io-index" 167 | checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783" 168 | dependencies = [ 169 | "clap_builder", 170 | "clap_derive", 171 | ] 172 | 173 | [[package]] 174 | name = "clap_builder" 175 | version = "4.5.26" 176 | source = "registry+https://github.com/rust-lang/crates.io-index" 177 | checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121" 178 | dependencies = [ 179 | "anstream", 180 | "anstyle", 181 | "clap_lex", 182 | "strsim", 183 | ] 184 | 185 | [[package]] 186 | name = "clap_derive" 187 | version = "4.5.24" 188 | source = "registry+https://github.com/rust-lang/crates.io-index" 189 | checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" 190 | dependencies = [ 191 | "heck", 192 | "proc-macro2", 193 | "quote", 194 | "syn", 195 | ] 196 | 197 | [[package]] 198 | name = "clap_lex" 199 | version = "0.7.4" 200 | source = "registry+https://github.com/rust-lang/crates.io-index" 201 | checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" 202 | 203 | [[package]] 204 | name = "colorchoice" 205 | version = "1.0.3" 206 | source = "registry+https://github.com/rust-lang/crates.io-index" 207 | checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" 208 | 209 | [[package]] 210 | name = "colored" 211 | version = "2.2.0" 212 | source = "registry+https://github.com/rust-lang/crates.io-index" 213 | checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" 214 | dependencies = [ 215 | "lazy_static", 216 | "windows-sys 0.59.0", 217 | ] 218 | 219 | [[package]] 220 | name = "console" 221 | version = "0.15.10" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" 224 | dependencies = [ 225 | "encode_unicode", 226 | "libc", 227 | "once_cell", 228 | "windows-sys 0.59.0", 229 | ] 230 | 231 | [[package]] 232 | name = "core-foundation-sys" 233 | version = "0.8.7" 234 | source = "registry+https://github.com/rust-lang/crates.io-index" 235 | checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" 236 | 237 | [[package]] 238 | name = "cpufeatures" 239 | version = "0.2.16" 240 | source = "registry+https://github.com/rust-lang/crates.io-index" 241 | checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" 242 | dependencies = [ 243 | "libc", 244 | ] 245 | 246 | [[package]] 247 | name = "crossbeam-deque" 248 | version = "0.8.6" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" 251 | dependencies = [ 252 | "crossbeam-epoch", 253 | "crossbeam-utils", 254 | ] 255 | 256 | [[package]] 257 | name = "crossbeam-epoch" 258 | version = "0.9.18" 259 | source = "registry+https://github.com/rust-lang/crates.io-index" 260 | checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" 261 | dependencies = [ 262 | "crossbeam-utils", 263 | ] 264 | 265 | [[package]] 266 | name = "crossbeam-utils" 267 | version = "0.8.21" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 270 | 271 | [[package]] 272 | name = "crossterm" 273 | version = "0.28.1" 274 | source = "registry+https://github.com/rust-lang/crates.io-index" 275 | checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" 276 | dependencies = [ 277 | "bitflags", 278 | "crossterm_winapi", 279 | "filedescriptor", 280 | "mio", 281 | "parking_lot", 282 | "rustix", 283 | "signal-hook", 284 | "signal-hook-mio", 285 | "winapi", 286 | ] 287 | 288 | [[package]] 289 | name = "crossterm_winapi" 290 | version = "0.9.1" 291 | source = "registry+https://github.com/rust-lang/crates.io-index" 292 | checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" 293 | dependencies = [ 294 | "winapi", 295 | ] 296 | 297 | [[package]] 298 | name = "crypto-common" 299 | version = "0.1.6" 300 | source = "registry+https://github.com/rust-lang/crates.io-index" 301 | checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" 302 | dependencies = [ 303 | "generic-array", 304 | "typenum", 305 | ] 306 | 307 | [[package]] 308 | name = "digest" 309 | version = "0.10.7" 310 | source = "registry+https://github.com/rust-lang/crates.io-index" 311 | checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" 312 | dependencies = [ 313 | "block-buffer", 314 | "crypto-common", 315 | ] 316 | 317 | [[package]] 318 | name = "directories" 319 | version = "5.0.1" 320 | source = "registry+https://github.com/rust-lang/crates.io-index" 321 | checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" 322 | dependencies = [ 323 | "dirs-sys", 324 | ] 325 | 326 | [[package]] 327 | name = "dirs-sys" 328 | version = "0.4.1" 329 | source = "registry+https://github.com/rust-lang/crates.io-index" 330 | checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" 331 | dependencies = [ 332 | "libc", 333 | "option-ext", 334 | "redox_users", 335 | "windows-sys 0.48.0", 336 | ] 337 | 338 | [[package]] 339 | name = "either" 340 | version = "1.13.0" 341 | source = "registry+https://github.com/rust-lang/crates.io-index" 342 | checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" 343 | 344 | [[package]] 345 | name = "encode_unicode" 346 | version = "1.0.0" 347 | source = "registry+https://github.com/rust-lang/crates.io-index" 348 | checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" 349 | 350 | [[package]] 351 | name = "equivalent" 352 | version = "1.0.1" 353 | source = "registry+https://github.com/rust-lang/crates.io-index" 354 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 355 | 356 | [[package]] 357 | name = "errno" 358 | version = "0.3.10" 359 | source = "registry+https://github.com/rust-lang/crates.io-index" 360 | checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" 361 | dependencies = [ 362 | "libc", 363 | "windows-sys 0.59.0", 364 | ] 365 | 366 | [[package]] 367 | name = "fallible-iterator" 368 | version = "0.3.0" 369 | source = "registry+https://github.com/rust-lang/crates.io-index" 370 | checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" 371 | 372 | [[package]] 373 | name = "fallible-streaming-iterator" 374 | version = "0.1.9" 375 | source = "registry+https://github.com/rust-lang/crates.io-index" 376 | checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" 377 | 378 | [[package]] 379 | name = "fastrand" 380 | version = "2.3.0" 381 | source = "registry+https://github.com/rust-lang/crates.io-index" 382 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" 383 | 384 | [[package]] 385 | name = "filedescriptor" 386 | version = "0.8.2" 387 | source = "registry+https://github.com/rust-lang/crates.io-index" 388 | checksum = "7199d965852c3bac31f779ef99cbb4537f80e952e2d6aa0ffeb30cce00f4f46e" 389 | dependencies = [ 390 | "libc", 391 | "thiserror 1.0.69", 392 | "winapi", 393 | ] 394 | 395 | [[package]] 396 | name = "generic-array" 397 | version = "0.14.7" 398 | source = "registry+https://github.com/rust-lang/crates.io-index" 399 | checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" 400 | dependencies = [ 401 | "typenum", 402 | "version_check", 403 | ] 404 | 405 | [[package]] 406 | name = "getrandom" 407 | version = "0.2.15" 408 | source = "registry+https://github.com/rust-lang/crates.io-index" 409 | checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" 410 | dependencies = [ 411 | "cfg-if", 412 | "libc", 413 | "wasi", 414 | ] 415 | 416 | [[package]] 417 | name = "hashbrown" 418 | version = "0.14.5" 419 | source = "registry+https://github.com/rust-lang/crates.io-index" 420 | checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" 421 | dependencies = [ 422 | "ahash", 423 | ] 424 | 425 | [[package]] 426 | name = "hashbrown" 427 | version = "0.15.2" 428 | source = "registry+https://github.com/rust-lang/crates.io-index" 429 | checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" 430 | 431 | [[package]] 432 | name = "hashlink" 433 | version = "0.9.1" 434 | source = "registry+https://github.com/rust-lang/crates.io-index" 435 | checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" 436 | dependencies = [ 437 | "hashbrown 0.14.5", 438 | ] 439 | 440 | [[package]] 441 | name = "heck" 442 | version = "0.5.0" 443 | source = "registry+https://github.com/rust-lang/crates.io-index" 444 | checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" 445 | 446 | [[package]] 447 | name = "iana-time-zone" 448 | version = "0.1.61" 449 | source = "registry+https://github.com/rust-lang/crates.io-index" 450 | checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" 451 | dependencies = [ 452 | "android_system_properties", 453 | "core-foundation-sys", 454 | "iana-time-zone-haiku", 455 | "js-sys", 456 | "wasm-bindgen", 457 | "windows-core", 458 | ] 459 | 460 | [[package]] 461 | name = "iana-time-zone-haiku" 462 | version = "0.1.2" 463 | source = "registry+https://github.com/rust-lang/crates.io-index" 464 | checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" 465 | dependencies = [ 466 | "cc", 467 | ] 468 | 469 | [[package]] 470 | name = "indexmap" 471 | version = "2.7.0" 472 | source = "registry+https://github.com/rust-lang/crates.io-index" 473 | checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" 474 | dependencies = [ 475 | "equivalent", 476 | "hashbrown 0.15.2", 477 | ] 478 | 479 | [[package]] 480 | name = "insta" 481 | version = "1.42.0" 482 | source = "registry+https://github.com/rust-lang/crates.io-index" 483 | checksum = "6513e4067e16e69ed1db5ab56048ed65db32d10ba5fc1217f5393f8f17d8b5a5" 484 | dependencies = [ 485 | "console", 486 | "linked-hash-map", 487 | "once_cell", 488 | "similar", 489 | ] 490 | 491 | [[package]] 492 | name = "is_terminal_polyfill" 493 | version = "1.70.1" 494 | source = "registry+https://github.com/rust-lang/crates.io-index" 495 | checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" 496 | 497 | [[package]] 498 | name = "itertools" 499 | version = "0.13.0" 500 | source = "registry+https://github.com/rust-lang/crates.io-index" 501 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" 502 | dependencies = [ 503 | "either", 504 | ] 505 | 506 | [[package]] 507 | name = "js-sys" 508 | version = "0.3.77" 509 | source = "registry+https://github.com/rust-lang/crates.io-index" 510 | checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" 511 | dependencies = [ 512 | "once_cell", 513 | "wasm-bindgen", 514 | ] 515 | 516 | [[package]] 517 | name = "lazy_static" 518 | version = "1.5.0" 519 | source = "registry+https://github.com/rust-lang/crates.io-index" 520 | checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" 521 | 522 | [[package]] 523 | name = "libc" 524 | version = "0.2.169" 525 | source = "registry+https://github.com/rust-lang/crates.io-index" 526 | checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" 527 | 528 | [[package]] 529 | name = "libredox" 530 | version = "0.1.3" 531 | source = "registry+https://github.com/rust-lang/crates.io-index" 532 | checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" 533 | dependencies = [ 534 | "bitflags", 535 | "libc", 536 | ] 537 | 538 | [[package]] 539 | name = "libsqlite3-sys" 540 | version = "0.30.1" 541 | source = "registry+https://github.com/rust-lang/crates.io-index" 542 | checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" 543 | dependencies = [ 544 | "cc", 545 | "pkg-config", 546 | "vcpkg", 547 | ] 548 | 549 | [[package]] 550 | name = "linked-hash-map" 551 | version = "0.5.6" 552 | source = "registry+https://github.com/rust-lang/crates.io-index" 553 | checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" 554 | 555 | [[package]] 556 | name = "linux-raw-sys" 557 | version = "0.4.15" 558 | source = "registry+https://github.com/rust-lang/crates.io-index" 559 | checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" 560 | 561 | [[package]] 562 | name = "lock_api" 563 | version = "0.4.12" 564 | source = "registry+https://github.com/rust-lang/crates.io-index" 565 | checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" 566 | dependencies = [ 567 | "autocfg", 568 | "scopeguard", 569 | ] 570 | 571 | [[package]] 572 | name = "log" 573 | version = "0.4.22" 574 | source = "registry+https://github.com/rust-lang/crates.io-index" 575 | checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" 576 | 577 | [[package]] 578 | name = "memchr" 579 | version = "2.7.4" 580 | source = "registry+https://github.com/rust-lang/crates.io-index" 581 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 582 | 583 | [[package]] 584 | name = "mio" 585 | version = "1.0.3" 586 | source = "registry+https://github.com/rust-lang/crates.io-index" 587 | checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" 588 | dependencies = [ 589 | "libc", 590 | "log", 591 | "wasi", 592 | "windows-sys 0.52.0", 593 | ] 594 | 595 | [[package]] 596 | name = "nucleo" 597 | version = "0.5.0" 598 | source = "registry+https://github.com/rust-lang/crates.io-index" 599 | checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" 600 | dependencies = [ 601 | "nucleo-matcher", 602 | "parking_lot", 603 | "rayon", 604 | ] 605 | 606 | [[package]] 607 | name = "nucleo-matcher" 608 | version = "0.3.1" 609 | source = "registry+https://github.com/rust-lang/crates.io-index" 610 | checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" 611 | dependencies = [ 612 | "memchr", 613 | "unicode-segmentation", 614 | ] 615 | 616 | [[package]] 617 | name = "nucleo-picker" 618 | version = "0.7.0" 619 | source = "registry+https://github.com/rust-lang/crates.io-index" 620 | checksum = "24cf7ad2e101658755dce7dffbb15f40cf98587ef7be9a356995ab6f09597c85" 621 | dependencies = [ 622 | "crossterm", 623 | "memchr", 624 | "nucleo", 625 | "parking_lot", 626 | "unicode-segmentation", 627 | "unicode-width 0.2.0", 628 | ] 629 | 630 | [[package]] 631 | name = "num-traits" 632 | version = "0.2.19" 633 | source = "registry+https://github.com/rust-lang/crates.io-index" 634 | checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" 635 | dependencies = [ 636 | "autocfg", 637 | ] 638 | 639 | [[package]] 640 | name = "once_cell" 641 | version = "1.20.2" 642 | source = "registry+https://github.com/rust-lang/crates.io-index" 643 | checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" 644 | 645 | [[package]] 646 | name = "option-ext" 647 | version = "0.2.0" 648 | source = "registry+https://github.com/rust-lang/crates.io-index" 649 | checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 650 | 651 | [[package]] 652 | name = "parking_lot" 653 | version = "0.12.3" 654 | source = "registry+https://github.com/rust-lang/crates.io-index" 655 | checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" 656 | dependencies = [ 657 | "lock_api", 658 | "parking_lot_core", 659 | ] 660 | 661 | [[package]] 662 | name = "parking_lot_core" 663 | version = "0.9.10" 664 | source = "registry+https://github.com/rust-lang/crates.io-index" 665 | checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" 666 | dependencies = [ 667 | "cfg-if", 668 | "libc", 669 | "redox_syscall", 670 | "smallvec", 671 | "windows-targets 0.52.6", 672 | ] 673 | 674 | [[package]] 675 | name = "pkg-config" 676 | version = "0.3.31" 677 | source = "registry+https://github.com/rust-lang/crates.io-index" 678 | checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" 679 | 680 | [[package]] 681 | name = "proc-macro2" 682 | version = "1.0.93" 683 | source = "registry+https://github.com/rust-lang/crates.io-index" 684 | checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99" 685 | dependencies = [ 686 | "unicode-ident", 687 | ] 688 | 689 | [[package]] 690 | name = "quicknotes" 691 | version = "1.1.0" 692 | dependencies = [ 693 | "anyhow", 694 | "chrono", 695 | "chrono-english", 696 | "clap", 697 | "colored", 698 | "directories", 699 | "insta", 700 | "itertools", 701 | "nucleo-picker", 702 | "regex", 703 | "rusqlite", 704 | "rusqlite_migration", 705 | "serde", 706 | "serde_derive", 707 | "sha2", 708 | "stringreader", 709 | "tempfile", 710 | "test-case", 711 | "textwrap", 712 | "thiserror 2.0.11", 713 | "toml", 714 | "walkdir", 715 | ] 716 | 717 | [[package]] 718 | name = "quote" 719 | version = "1.0.38" 720 | source = "registry+https://github.com/rust-lang/crates.io-index" 721 | checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" 722 | dependencies = [ 723 | "proc-macro2", 724 | ] 725 | 726 | [[package]] 727 | name = "rayon" 728 | version = "1.10.0" 729 | source = "registry+https://github.com/rust-lang/crates.io-index" 730 | checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" 731 | dependencies = [ 732 | "either", 733 | "rayon-core", 734 | ] 735 | 736 | [[package]] 737 | name = "rayon-core" 738 | version = "1.12.1" 739 | source = "registry+https://github.com/rust-lang/crates.io-index" 740 | checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" 741 | dependencies = [ 742 | "crossbeam-deque", 743 | "crossbeam-utils", 744 | ] 745 | 746 | [[package]] 747 | name = "redox_syscall" 748 | version = "0.5.8" 749 | source = "registry+https://github.com/rust-lang/crates.io-index" 750 | checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" 751 | dependencies = [ 752 | "bitflags", 753 | ] 754 | 755 | [[package]] 756 | name = "redox_users" 757 | version = "0.4.6" 758 | source = "registry+https://github.com/rust-lang/crates.io-index" 759 | checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" 760 | dependencies = [ 761 | "getrandom", 762 | "libredox", 763 | "thiserror 1.0.69", 764 | ] 765 | 766 | [[package]] 767 | name = "regex" 768 | version = "1.11.1" 769 | source = "registry+https://github.com/rust-lang/crates.io-index" 770 | checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" 771 | dependencies = [ 772 | "aho-corasick", 773 | "memchr", 774 | "regex-automata", 775 | "regex-syntax", 776 | ] 777 | 778 | [[package]] 779 | name = "regex-automata" 780 | version = "0.4.9" 781 | source = "registry+https://github.com/rust-lang/crates.io-index" 782 | checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" 783 | dependencies = [ 784 | "aho-corasick", 785 | "memchr", 786 | "regex-syntax", 787 | ] 788 | 789 | [[package]] 790 | name = "regex-syntax" 791 | version = "0.8.5" 792 | source = "registry+https://github.com/rust-lang/crates.io-index" 793 | checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" 794 | 795 | [[package]] 796 | name = "rusqlite" 797 | version = "0.32.1" 798 | source = "registry+https://github.com/rust-lang/crates.io-index" 799 | checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" 800 | dependencies = [ 801 | "bitflags", 802 | "fallible-iterator", 803 | "fallible-streaming-iterator", 804 | "hashlink", 805 | "libsqlite3-sys", 806 | "smallvec", 807 | ] 808 | 809 | [[package]] 810 | name = "rusqlite_migration" 811 | version = "1.3.1" 812 | source = "registry+https://github.com/rust-lang/crates.io-index" 813 | checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" 814 | dependencies = [ 815 | "log", 816 | "rusqlite", 817 | ] 818 | 819 | [[package]] 820 | name = "rustix" 821 | version = "0.38.43" 822 | source = "registry+https://github.com/rust-lang/crates.io-index" 823 | checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" 824 | dependencies = [ 825 | "bitflags", 826 | "errno", 827 | "libc", 828 | "linux-raw-sys", 829 | "windows-sys 0.59.0", 830 | ] 831 | 832 | [[package]] 833 | name = "rustversion" 834 | version = "1.0.19" 835 | source = "registry+https://github.com/rust-lang/crates.io-index" 836 | checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" 837 | 838 | [[package]] 839 | name = "same-file" 840 | version = "1.0.6" 841 | source = "registry+https://github.com/rust-lang/crates.io-index" 842 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 843 | dependencies = [ 844 | "winapi-util", 845 | ] 846 | 847 | [[package]] 848 | name = "scanlex" 849 | version = "0.1.4" 850 | source = "registry+https://github.com/rust-lang/crates.io-index" 851 | checksum = "088c5d71572124929ea7549a8ce98e1a6fd33d0a38367b09027b382e67c033db" 852 | 853 | [[package]] 854 | name = "scopeguard" 855 | version = "1.2.0" 856 | source = "registry+https://github.com/rust-lang/crates.io-index" 857 | checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 858 | 859 | [[package]] 860 | name = "serde" 861 | version = "1.0.217" 862 | source = "registry+https://github.com/rust-lang/crates.io-index" 863 | checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" 864 | dependencies = [ 865 | "serde_derive", 866 | ] 867 | 868 | [[package]] 869 | name = "serde_derive" 870 | version = "1.0.217" 871 | source = "registry+https://github.com/rust-lang/crates.io-index" 872 | checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" 873 | dependencies = [ 874 | "proc-macro2", 875 | "quote", 876 | "syn", 877 | ] 878 | 879 | [[package]] 880 | name = "serde_spanned" 881 | version = "0.6.8" 882 | source = "registry+https://github.com/rust-lang/crates.io-index" 883 | checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" 884 | dependencies = [ 885 | "serde", 886 | ] 887 | 888 | [[package]] 889 | name = "sha2" 890 | version = "0.10.8" 891 | source = "registry+https://github.com/rust-lang/crates.io-index" 892 | checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" 893 | dependencies = [ 894 | "cfg-if", 895 | "cpufeatures", 896 | "digest", 897 | ] 898 | 899 | [[package]] 900 | name = "shlex" 901 | version = "1.3.0" 902 | source = "registry+https://github.com/rust-lang/crates.io-index" 903 | checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 904 | 905 | [[package]] 906 | name = "signal-hook" 907 | version = "0.3.17" 908 | source = "registry+https://github.com/rust-lang/crates.io-index" 909 | checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801" 910 | dependencies = [ 911 | "libc", 912 | "signal-hook-registry", 913 | ] 914 | 915 | [[package]] 916 | name = "signal-hook-mio" 917 | version = "0.2.4" 918 | source = "registry+https://github.com/rust-lang/crates.io-index" 919 | checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" 920 | dependencies = [ 921 | "libc", 922 | "mio", 923 | "signal-hook", 924 | ] 925 | 926 | [[package]] 927 | name = "signal-hook-registry" 928 | version = "1.4.2" 929 | source = "registry+https://github.com/rust-lang/crates.io-index" 930 | checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" 931 | dependencies = [ 932 | "libc", 933 | ] 934 | 935 | [[package]] 936 | name = "similar" 937 | version = "2.6.0" 938 | source = "registry+https://github.com/rust-lang/crates.io-index" 939 | checksum = "1de1d4f81173b03af4c0cbed3c898f6bff5b870e4a7f5d6f4057d62a7a4b686e" 940 | 941 | [[package]] 942 | name = "smallvec" 943 | version = "1.13.2" 944 | source = "registry+https://github.com/rust-lang/crates.io-index" 945 | checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" 946 | 947 | [[package]] 948 | name = "smawk" 949 | version = "0.3.2" 950 | source = "registry+https://github.com/rust-lang/crates.io-index" 951 | checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" 952 | 953 | [[package]] 954 | name = "stringreader" 955 | version = "0.1.1" 956 | source = "registry+https://github.com/rust-lang/crates.io-index" 957 | checksum = "913e7b03d63752f6cdd2df77da36749d82669904798fe8944b9ec3d23f159905" 958 | 959 | [[package]] 960 | name = "strsim" 961 | version = "0.11.1" 962 | source = "registry+https://github.com/rust-lang/crates.io-index" 963 | checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" 964 | 965 | [[package]] 966 | name = "syn" 967 | version = "2.0.96" 968 | source = "registry+https://github.com/rust-lang/crates.io-index" 969 | checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" 970 | dependencies = [ 971 | "proc-macro2", 972 | "quote", 973 | "unicode-ident", 974 | ] 975 | 976 | [[package]] 977 | name = "tempfile" 978 | version = "3.15.0" 979 | source = "registry+https://github.com/rust-lang/crates.io-index" 980 | checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" 981 | dependencies = [ 982 | "cfg-if", 983 | "fastrand", 984 | "getrandom", 985 | "once_cell", 986 | "rustix", 987 | "windows-sys 0.59.0", 988 | ] 989 | 990 | [[package]] 991 | name = "test-case" 992 | version = "3.3.1" 993 | source = "registry+https://github.com/rust-lang/crates.io-index" 994 | checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" 995 | dependencies = [ 996 | "test-case-macros", 997 | ] 998 | 999 | [[package]] 1000 | name = "test-case-core" 1001 | version = "3.3.1" 1002 | source = "registry+https://github.com/rust-lang/crates.io-index" 1003 | checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" 1004 | dependencies = [ 1005 | "cfg-if", 1006 | "proc-macro2", 1007 | "quote", 1008 | "syn", 1009 | ] 1010 | 1011 | [[package]] 1012 | name = "test-case-macros" 1013 | version = "3.3.1" 1014 | source = "registry+https://github.com/rust-lang/crates.io-index" 1015 | checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" 1016 | dependencies = [ 1017 | "proc-macro2", 1018 | "quote", 1019 | "syn", 1020 | "test-case-core", 1021 | ] 1022 | 1023 | [[package]] 1024 | name = "textwrap" 1025 | version = "0.16.1" 1026 | source = "registry+https://github.com/rust-lang/crates.io-index" 1027 | checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9" 1028 | dependencies = [ 1029 | "smawk", 1030 | "unicode-linebreak", 1031 | "unicode-width 0.1.14", 1032 | ] 1033 | 1034 | [[package]] 1035 | name = "thiserror" 1036 | version = "1.0.69" 1037 | source = "registry+https://github.com/rust-lang/crates.io-index" 1038 | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" 1039 | dependencies = [ 1040 | "thiserror-impl 1.0.69", 1041 | ] 1042 | 1043 | [[package]] 1044 | name = "thiserror" 1045 | version = "2.0.11" 1046 | source = "registry+https://github.com/rust-lang/crates.io-index" 1047 | checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" 1048 | dependencies = [ 1049 | "thiserror-impl 2.0.11", 1050 | ] 1051 | 1052 | [[package]] 1053 | name = "thiserror-impl" 1054 | version = "1.0.69" 1055 | source = "registry+https://github.com/rust-lang/crates.io-index" 1056 | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" 1057 | dependencies = [ 1058 | "proc-macro2", 1059 | "quote", 1060 | "syn", 1061 | ] 1062 | 1063 | [[package]] 1064 | name = "thiserror-impl" 1065 | version = "2.0.11" 1066 | source = "registry+https://github.com/rust-lang/crates.io-index" 1067 | checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" 1068 | dependencies = [ 1069 | "proc-macro2", 1070 | "quote", 1071 | "syn", 1072 | ] 1073 | 1074 | [[package]] 1075 | name = "toml" 1076 | version = "0.8.19" 1077 | source = "registry+https://github.com/rust-lang/crates.io-index" 1078 | checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" 1079 | dependencies = [ 1080 | "serde", 1081 | "serde_spanned", 1082 | "toml_datetime", 1083 | "toml_edit", 1084 | ] 1085 | 1086 | [[package]] 1087 | name = "toml_datetime" 1088 | version = "0.6.8" 1089 | source = "registry+https://github.com/rust-lang/crates.io-index" 1090 | checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" 1091 | dependencies = [ 1092 | "serde", 1093 | ] 1094 | 1095 | [[package]] 1096 | name = "toml_edit" 1097 | version = "0.22.22" 1098 | source = "registry+https://github.com/rust-lang/crates.io-index" 1099 | checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" 1100 | dependencies = [ 1101 | "indexmap", 1102 | "serde", 1103 | "serde_spanned", 1104 | "toml_datetime", 1105 | "winnow", 1106 | ] 1107 | 1108 | [[package]] 1109 | name = "typenum" 1110 | version = "1.17.0" 1111 | source = "registry+https://github.com/rust-lang/crates.io-index" 1112 | checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" 1113 | 1114 | [[package]] 1115 | name = "unicode-ident" 1116 | version = "1.0.14" 1117 | source = "registry+https://github.com/rust-lang/crates.io-index" 1118 | checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" 1119 | 1120 | [[package]] 1121 | name = "unicode-linebreak" 1122 | version = "0.1.5" 1123 | source = "registry+https://github.com/rust-lang/crates.io-index" 1124 | checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" 1125 | 1126 | [[package]] 1127 | name = "unicode-segmentation" 1128 | version = "1.12.0" 1129 | source = "registry+https://github.com/rust-lang/crates.io-index" 1130 | checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" 1131 | 1132 | [[package]] 1133 | name = "unicode-width" 1134 | version = "0.1.14" 1135 | source = "registry+https://github.com/rust-lang/crates.io-index" 1136 | checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" 1137 | 1138 | [[package]] 1139 | name = "unicode-width" 1140 | version = "0.2.0" 1141 | source = "registry+https://github.com/rust-lang/crates.io-index" 1142 | checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" 1143 | 1144 | [[package]] 1145 | name = "utf8parse" 1146 | version = "0.2.2" 1147 | source = "registry+https://github.com/rust-lang/crates.io-index" 1148 | checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" 1149 | 1150 | [[package]] 1151 | name = "vcpkg" 1152 | version = "0.2.15" 1153 | source = "registry+https://github.com/rust-lang/crates.io-index" 1154 | checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" 1155 | 1156 | [[package]] 1157 | name = "version_check" 1158 | version = "0.9.5" 1159 | source = "registry+https://github.com/rust-lang/crates.io-index" 1160 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 1161 | 1162 | [[package]] 1163 | name = "walkdir" 1164 | version = "2.5.0" 1165 | source = "registry+https://github.com/rust-lang/crates.io-index" 1166 | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" 1167 | dependencies = [ 1168 | "same-file", 1169 | "winapi-util", 1170 | ] 1171 | 1172 | [[package]] 1173 | name = "wasi" 1174 | version = "0.11.0+wasi-snapshot-preview1" 1175 | source = "registry+https://github.com/rust-lang/crates.io-index" 1176 | checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" 1177 | 1178 | [[package]] 1179 | name = "wasm-bindgen" 1180 | version = "0.2.100" 1181 | source = "registry+https://github.com/rust-lang/crates.io-index" 1182 | checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" 1183 | dependencies = [ 1184 | "cfg-if", 1185 | "once_cell", 1186 | "rustversion", 1187 | "wasm-bindgen-macro", 1188 | ] 1189 | 1190 | [[package]] 1191 | name = "wasm-bindgen-backend" 1192 | version = "0.2.100" 1193 | source = "registry+https://github.com/rust-lang/crates.io-index" 1194 | checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" 1195 | dependencies = [ 1196 | "bumpalo", 1197 | "log", 1198 | "proc-macro2", 1199 | "quote", 1200 | "syn", 1201 | "wasm-bindgen-shared", 1202 | ] 1203 | 1204 | [[package]] 1205 | name = "wasm-bindgen-macro" 1206 | version = "0.2.100" 1207 | source = "registry+https://github.com/rust-lang/crates.io-index" 1208 | checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" 1209 | dependencies = [ 1210 | "quote", 1211 | "wasm-bindgen-macro-support", 1212 | ] 1213 | 1214 | [[package]] 1215 | name = "wasm-bindgen-macro-support" 1216 | version = "0.2.100" 1217 | source = "registry+https://github.com/rust-lang/crates.io-index" 1218 | checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" 1219 | dependencies = [ 1220 | "proc-macro2", 1221 | "quote", 1222 | "syn", 1223 | "wasm-bindgen-backend", 1224 | "wasm-bindgen-shared", 1225 | ] 1226 | 1227 | [[package]] 1228 | name = "wasm-bindgen-shared" 1229 | version = "0.2.100" 1230 | source = "registry+https://github.com/rust-lang/crates.io-index" 1231 | checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" 1232 | dependencies = [ 1233 | "unicode-ident", 1234 | ] 1235 | 1236 | [[package]] 1237 | name = "winapi" 1238 | version = "0.3.9" 1239 | source = "registry+https://github.com/rust-lang/crates.io-index" 1240 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 1241 | dependencies = [ 1242 | "winapi-i686-pc-windows-gnu", 1243 | "winapi-x86_64-pc-windows-gnu", 1244 | ] 1245 | 1246 | [[package]] 1247 | name = "winapi-i686-pc-windows-gnu" 1248 | version = "0.4.0" 1249 | source = "registry+https://github.com/rust-lang/crates.io-index" 1250 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 1251 | 1252 | [[package]] 1253 | name = "winapi-util" 1254 | version = "0.1.9" 1255 | source = "registry+https://github.com/rust-lang/crates.io-index" 1256 | checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" 1257 | dependencies = [ 1258 | "windows-sys 0.59.0", 1259 | ] 1260 | 1261 | [[package]] 1262 | name = "winapi-x86_64-pc-windows-gnu" 1263 | version = "0.4.0" 1264 | source = "registry+https://github.com/rust-lang/crates.io-index" 1265 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 1266 | 1267 | [[package]] 1268 | name = "windows-core" 1269 | version = "0.52.0" 1270 | source = "registry+https://github.com/rust-lang/crates.io-index" 1271 | checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" 1272 | dependencies = [ 1273 | "windows-targets 0.52.6", 1274 | ] 1275 | 1276 | [[package]] 1277 | name = "windows-sys" 1278 | version = "0.48.0" 1279 | source = "registry+https://github.com/rust-lang/crates.io-index" 1280 | checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" 1281 | dependencies = [ 1282 | "windows-targets 0.48.5", 1283 | ] 1284 | 1285 | [[package]] 1286 | name = "windows-sys" 1287 | version = "0.52.0" 1288 | source = "registry+https://github.com/rust-lang/crates.io-index" 1289 | checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" 1290 | dependencies = [ 1291 | "windows-targets 0.52.6", 1292 | ] 1293 | 1294 | [[package]] 1295 | name = "windows-sys" 1296 | version = "0.59.0" 1297 | source = "registry+https://github.com/rust-lang/crates.io-index" 1298 | checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" 1299 | dependencies = [ 1300 | "windows-targets 0.52.6", 1301 | ] 1302 | 1303 | [[package]] 1304 | name = "windows-targets" 1305 | version = "0.48.5" 1306 | source = "registry+https://github.com/rust-lang/crates.io-index" 1307 | checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" 1308 | dependencies = [ 1309 | "windows_aarch64_gnullvm 0.48.5", 1310 | "windows_aarch64_msvc 0.48.5", 1311 | "windows_i686_gnu 0.48.5", 1312 | "windows_i686_msvc 0.48.5", 1313 | "windows_x86_64_gnu 0.48.5", 1314 | "windows_x86_64_gnullvm 0.48.5", 1315 | "windows_x86_64_msvc 0.48.5", 1316 | ] 1317 | 1318 | [[package]] 1319 | name = "windows-targets" 1320 | version = "0.52.6" 1321 | source = "registry+https://github.com/rust-lang/crates.io-index" 1322 | checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" 1323 | dependencies = [ 1324 | "windows_aarch64_gnullvm 0.52.6", 1325 | "windows_aarch64_msvc 0.52.6", 1326 | "windows_i686_gnu 0.52.6", 1327 | "windows_i686_gnullvm", 1328 | "windows_i686_msvc 0.52.6", 1329 | "windows_x86_64_gnu 0.52.6", 1330 | "windows_x86_64_gnullvm 0.52.6", 1331 | "windows_x86_64_msvc 0.52.6", 1332 | ] 1333 | 1334 | [[package]] 1335 | name = "windows_aarch64_gnullvm" 1336 | version = "0.48.5" 1337 | source = "registry+https://github.com/rust-lang/crates.io-index" 1338 | checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" 1339 | 1340 | [[package]] 1341 | name = "windows_aarch64_gnullvm" 1342 | version = "0.52.6" 1343 | source = "registry+https://github.com/rust-lang/crates.io-index" 1344 | checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" 1345 | 1346 | [[package]] 1347 | name = "windows_aarch64_msvc" 1348 | version = "0.48.5" 1349 | source = "registry+https://github.com/rust-lang/crates.io-index" 1350 | checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" 1351 | 1352 | [[package]] 1353 | name = "windows_aarch64_msvc" 1354 | version = "0.52.6" 1355 | source = "registry+https://github.com/rust-lang/crates.io-index" 1356 | checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" 1357 | 1358 | [[package]] 1359 | name = "windows_i686_gnu" 1360 | version = "0.48.5" 1361 | source = "registry+https://github.com/rust-lang/crates.io-index" 1362 | checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" 1363 | 1364 | [[package]] 1365 | name = "windows_i686_gnu" 1366 | version = "0.52.6" 1367 | source = "registry+https://github.com/rust-lang/crates.io-index" 1368 | checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" 1369 | 1370 | [[package]] 1371 | name = "windows_i686_gnullvm" 1372 | version = "0.52.6" 1373 | source = "registry+https://github.com/rust-lang/crates.io-index" 1374 | checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" 1375 | 1376 | [[package]] 1377 | name = "windows_i686_msvc" 1378 | version = "0.48.5" 1379 | source = "registry+https://github.com/rust-lang/crates.io-index" 1380 | checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" 1381 | 1382 | [[package]] 1383 | name = "windows_i686_msvc" 1384 | version = "0.52.6" 1385 | source = "registry+https://github.com/rust-lang/crates.io-index" 1386 | checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" 1387 | 1388 | [[package]] 1389 | name = "windows_x86_64_gnu" 1390 | version = "0.48.5" 1391 | source = "registry+https://github.com/rust-lang/crates.io-index" 1392 | checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" 1393 | 1394 | [[package]] 1395 | name = "windows_x86_64_gnu" 1396 | version = "0.52.6" 1397 | source = "registry+https://github.com/rust-lang/crates.io-index" 1398 | checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" 1399 | 1400 | [[package]] 1401 | name = "windows_x86_64_gnullvm" 1402 | version = "0.48.5" 1403 | source = "registry+https://github.com/rust-lang/crates.io-index" 1404 | checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" 1405 | 1406 | [[package]] 1407 | name = "windows_x86_64_gnullvm" 1408 | version = "0.52.6" 1409 | source = "registry+https://github.com/rust-lang/crates.io-index" 1410 | checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" 1411 | 1412 | [[package]] 1413 | name = "windows_x86_64_msvc" 1414 | version = "0.48.5" 1415 | source = "registry+https://github.com/rust-lang/crates.io-index" 1416 | checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" 1417 | 1418 | [[package]] 1419 | name = "windows_x86_64_msvc" 1420 | version = "0.52.6" 1421 | source = "registry+https://github.com/rust-lang/crates.io-index" 1422 | checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" 1423 | 1424 | [[package]] 1425 | name = "winnow" 1426 | version = "0.6.24" 1427 | source = "registry+https://github.com/rust-lang/crates.io-index" 1428 | checksum = "c8d71a593cc5c42ad7876e2c1fda56f314f3754c084128833e64f1345ff8a03a" 1429 | dependencies = [ 1430 | "memchr", 1431 | ] 1432 | 1433 | [[package]] 1434 | name = "zerocopy" 1435 | version = "0.7.35" 1436 | source = "registry+https://github.com/rust-lang/crates.io-index" 1437 | checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" 1438 | dependencies = [ 1439 | "zerocopy-derive", 1440 | ] 1441 | 1442 | [[package]] 1443 | name = "zerocopy-derive" 1444 | version = "0.7.35" 1445 | source = "registry+https://github.com/rust-lang/crates.io-index" 1446 | checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" 1447 | dependencies = [ 1448 | "proc-macro2", 1449 | "quote", 1450 | "syn", 1451 | ] 1452 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "quicknotes" 3 | description = "A notes application that makes taking notes... quick" 4 | version = "1.1.0" 5 | license = "BSD-3-Clause" 6 | homepage = "https://github.com/ollien/quicknotes" 7 | repository = "https://github.com/ollien/quicknotes" 8 | edition = "2021" 9 | rust-version = "1.83" 10 | 11 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 12 | 13 | [dependencies] 14 | anyhow = "1.0.94" 15 | chrono = "0.4.39" 16 | chrono-english = "0.1.7" 17 | clap = { version = "4.5.23", features = ["derive"] } 18 | colored = "2.1.0" 19 | directories = "5.0.1" 20 | itertools = "0.13.0" 21 | nucleo-picker = "0.7.0" 22 | regex = "1.11.1" 23 | rusqlite = { version = "0.32.1", features = ["bundled"] } 24 | rusqlite_migration = "1.3.1" 25 | serde = "1.0.215" 26 | serde_derive = "1.0.215" 27 | sha2 = "0.10.8" 28 | tempfile = "3.14.0" 29 | thiserror = "2.0.6" 30 | toml = "0.8.19" 31 | walkdir = "2.5.0" 32 | 33 | [dev-dependencies] 34 | insta = "1.41.1" 35 | stringreader = "0.1.1" 36 | test-case = "3.3.1" 37 | textwrap = "0.16.1" 38 | 39 | [profile.dev.package] 40 | insta.opt-level = 3 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2025, Nicholas Krichevsky 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `quicknotes` 2 | 3 | [![Crates.io Version](https://img.shields.io/crates/v/quicknotes)](https://crates.io/crates/quicknotes) 4 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/ollien/quicknotes/build_and_test.yml)](https://github.com/ollien/quicknotes/actions/workflows/build_and_test.yml) 5 | 6 | `quicknotes` is a notes application that makes taking notes... quick. 7 | You can edit your notes using your preferred text editor, and all notes are 8 | saved locally in plain text. 9 | 10 | ## Installation 11 | 12 | `quicknotes` can be installed from Cargo via 13 | 14 | ``` 15 | cargo install quicknotes 16 | ``` 17 | 18 | ## Usage 19 | 20 | To write a new note, all you have to do is run `quicknotes new ...`. 21 | For example, to create a new note about the time machine I am building, I would 22 | run `quicknotes new Flux Capacitor Design`. 23 | 24 | By default, this will launch the editor stored in `$EDITOR`, but this is 25 | configurable. All notes have a preamble, which must be preserved so that 26 | `quicknotes` can index your note, but after that, write what you want! There 27 | are no rules on formatting. 28 | 29 | ``` 30 | --- 31 | title = "Flux Capacitor Design" 32 | created_at = 2025-01-11T10:58:00.587807852-05:00 33 | --- 34 | 35 | If my calculations are correct, when this baby hits 88 miles per hour... 36 | ``` 37 | 38 | If you want to go back and revise your note, you can use `quicknotes open`, 39 | and search for your note. In general, the index will be automatically built 40 | when editing a note, but if for any reason you need to rebuild the index, 41 | you can run `quicknotes index`. 42 | 43 | `quicknotes` also supports "daily" notes, to aid your journaling. To open 44 | today's daily note, run `quicknotes daily`. This will create a new note with 45 | today's date, or open one if one already exists. You can also open a daily note 46 | from a previous day by doing `quicknotes daily <offset>`, where `offset` is a 47 | "fuzzy" date. You can either enter an absolute date (e.g. `2015-10-21`), or a 48 | relative date (e.g. `yesterday`, `2 days ago`). 49 | 50 | ## Configuration 51 | 52 | When you run `quicknotes` for the first time, a configuration file will be 53 | generated for you in your operating system's configuration directory. 54 | 55 | 56 | | Platform | Location | 57 | |------------|---------------------------------------------------------------------| 58 | | Linux | `$XDG_CONFIG_HOME/quicknotes/config.toml` | 59 | | macOS | `~/Library/Application Support/com.ollien.quicknotes/config.toml` | 60 | | Windows | `C:\Users\<Username>\AppData\Roaming\ollien\quicknotes\config.toml` | 61 | 62 | ```toml 63 | # required, directory where notes are stored 64 | notes_root = "/home/ferris/Documents/quicknotes/" 65 | 66 | # required, file extension for notes 67 | note_file_extension = ".md" 68 | 69 | # optional, uses $EDITOR if not specified, or `nano` if $EDITOR is unset 70 | editor_command = "/usr/bin/nvim" 71 | ``` 72 | 73 | ## Philosophy 74 | 75 | I wrote `quicknotes` for my personal workflow, where I am constantly in a 76 | shell and often just want to write something down quickly. I've found that if 77 | I make my notes system too complicated, I'll end up doing silly things like 78 | pasting things in random `vim` buffers. To that end, I've designed `quicknotes` 79 | to be as frictionless as possible. 80 | 81 | Contributions are absolutely welcome, but I will note that I have designed 82 | `quicknotes` for my needs, first and foremost, and may not accept new features 83 | unless they seem like something I could use. It is not my goal to design a 84 | "swiss-army-knife" notes app, as so many others have; I wanted a note-taking 85 | tool that would give me an easy framework I could use day-to-day, and worked 86 | out of the box. 87 | 88 | ## Development 89 | 90 | `quicknotes` is a bog-standard Rust project, so development should be as simple 91 | as `cargo build` to build the project, and `cargo test` to run the tests. 92 | 93 | ## License 94 | BSD-3 95 | -------------------------------------------------------------------------------- /src/edit.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | use std::path::Path; 3 | use std::process::Command; 4 | 5 | /// A text editor that can edit a given note. There is no requirement about the editor itself, 6 | /// just that it can edit a file at a given path. 7 | pub trait Editor { 8 | fn name(&self) -> &str; 9 | /// Edit the given note 10 | /// 11 | /// # Errors 12 | /// 13 | /// Returns an error if the editor had a problem editing the note. 14 | fn edit(&self, path: &Path) -> io::Result<()>; 15 | } 16 | 17 | impl<E: Editor> Editor for &E { 18 | fn name(&self) -> &str { 19 | (*self).name() 20 | } 21 | 22 | fn edit(&self, path: &Path) -> io::Result<()> { 23 | (*self).edit(path) 24 | } 25 | } 26 | 27 | /// An editor that runs a command to launch. This is useful for CLI tools such as `vim`. 28 | pub struct CommandEditor { 29 | command: String, 30 | } 31 | 32 | impl CommandEditor { 33 | #[must_use] 34 | pub fn new(command: String) -> Self { 35 | Self { command } 36 | } 37 | } 38 | 39 | impl Editor for CommandEditor { 40 | fn name(&self) -> &str { 41 | &self.command 42 | } 43 | 44 | fn edit(&self, path: &Path) -> io::Result<()> { 45 | Command::new(&self.command) 46 | .arg(path) 47 | .spawn()? 48 | .wait() 49 | .map(|_output| ()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/index.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::fs::OpenOptions; 3 | use std::io; 4 | use std::path::{Path, PathBuf}; 5 | use std::str::FromStr; 6 | 7 | use chrono::{DateTime, FixedOffset, NaiveDateTime, TimeZone}; 8 | use rusqlite::{Connection, Params, Row, Statement}; 9 | use rusqlite_migration::{Migrations, M}; 10 | use thiserror::Error; 11 | 12 | use crate::note::Preamble; 13 | use crate::warning; 14 | 15 | const DB_DATE_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.f"; 16 | 17 | #[derive(Clone, Debug, Eq, PartialEq)] 18 | pub struct IndexedNote { 19 | pub preamble: Preamble, 20 | pub kind: NoteKind, 21 | } 22 | 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 24 | pub enum NoteKind { 25 | Note, 26 | Daily, 27 | } 28 | 29 | impl NoteKind { 30 | fn to_sql_enum(self) -> String { 31 | match self { 32 | Self::Note => "note".to_string(), 33 | Self::Daily => "daily".to_string(), 34 | } 35 | } 36 | 37 | fn try_from_sql_enum(sql_enum: &str) -> Result<Self, InvalidNoteKindString> { 38 | match sql_enum { 39 | "note" => Ok(Self::Note), 40 | "daily" => Ok(Self::Daily), 41 | _ => Err(InvalidNoteKindString(sql_enum.to_owned())), 42 | } 43 | } 44 | } 45 | 46 | #[derive(Error, Debug)] 47 | #[error("invalid note kind '{0}'")] 48 | struct InvalidNoteKindString(String); 49 | 50 | pub fn open(path: &Path) -> Result<Connection, OpenError> { 51 | let mut connection = Connection::open(path).map_err(OpenError::ConnectionOpenError)?; 52 | 53 | setup_database(&mut connection).map_err(OpenError::MigrationError)?; 54 | 55 | Ok(connection) 56 | } 57 | 58 | #[derive(Error, Debug)] 59 | pub enum OpenError { 60 | #[error("could not open index: {0}")] 61 | ConnectionOpenError(rusqlite::Error), 62 | 63 | #[error("could not setup index: {0}")] 64 | MigrationError(MigrationError), 65 | } 66 | 67 | #[derive(Error, Debug)] 68 | #[error(transparent)] 69 | pub struct MigrationError(#[from] rusqlite_migration::Error); 70 | 71 | pub fn reset(path: &Path) -> Result<(), ResetError> { 72 | OpenOptions::new() 73 | .write(true) 74 | .truncate(true) 75 | .open(path) 76 | .map(|_file| ()) 77 | .or_else(|err| { 78 | if err.kind() == io::ErrorKind::NotFound { 79 | Ok(()) 80 | } else { 81 | Err(ResetError(err)) 82 | } 83 | }) 84 | } 85 | 86 | #[derive(Error, Debug)] 87 | #[error("could not reset index database: {0}")] 88 | pub struct ResetError(io::Error); 89 | 90 | pub fn add_note( 91 | connection: &mut Connection, 92 | preamble: &Preamble, 93 | kind: NoteKind, 94 | path: &Path, 95 | ) -> Result<(), InsertError> { 96 | let path_string = path 97 | .to_str() 98 | .ok_or_else(|| InsertError::BadPath(path.to_owned()))?; 99 | 100 | connection 101 | .execute( 102 | "INSERT INTO notes VALUES (?1, ?2, ?3, ?4, ?5) 103 | ON CONFLICT(filepath) DO UPDATE SET 104 | title=?2, 105 | created_at=?3, 106 | utc_offset_seconds=?4, 107 | kind=?5 108 | ;", 109 | ( 110 | &path_string, 111 | &preamble.title, 112 | preamble.created_at.format(DB_DATE_FORMAT).to_string(), 113 | preamble.created_at.offset().local_minus_utc(), 114 | kind.to_sql_enum(), 115 | ), 116 | ) 117 | .map(|_rows| ()) 118 | .map_err(InsertError::DatabaseError) 119 | } 120 | 121 | #[derive(Error, Debug)] 122 | pub enum InsertError { 123 | #[error("could not insert into index database: {0}")] 124 | DatabaseError(rusqlite::Error), 125 | 126 | #[error("cannot insert a non-utf-8 path to the database: {0}")] 127 | BadPath(PathBuf), 128 | } 129 | 130 | pub fn all_notes( 131 | connection: &mut Connection, 132 | ) -> Result<HashMap<PathBuf, IndexedNote>, LookupError> { 133 | let mut query = connection 134 | .prepare("SELECT filepath, title, created_at, utc_offset_seconds, kind FROM notes;")?; 135 | 136 | lookup_notes(&mut query, []) 137 | } 138 | 139 | pub fn notes_with_kind( 140 | connection: &mut Connection, 141 | kind: NoteKind, 142 | ) -> Result<HashMap<PathBuf, IndexedNote>, LookupError> { 143 | let mut query = connection.prepare( 144 | "SELECT filepath, title, created_at, utc_offset_seconds, kind FROM notes WHERE kind=?;", 145 | )?; 146 | 147 | lookup_notes(&mut query, [kind.to_sql_enum()]) 148 | } 149 | 150 | fn lookup_notes<P: Params>( 151 | query: &mut Statement<'_>, 152 | params: P, 153 | ) -> Result<HashMap<PathBuf, IndexedNote>, LookupError> { 154 | let notes = query 155 | .query_map(params, |row| match unpack_row(row) { 156 | Err(QueryFailure::DatabaseFailure(err)) => Err(err), 157 | Err(QueryFailure::InvalidRow(msg)) => { 158 | // TODO: perhaps we want some kind of read-repair here. 159 | warning!("{msg}; skipping entry"); 160 | 161 | Ok(None) 162 | } 163 | Ok((path, preamble)) => Ok(Some((path, preamble))), 164 | })? 165 | .filter_map(Result::transpose) 166 | .collect::<Result<HashMap<_, _>, _>>()?; 167 | 168 | Ok(notes) 169 | } 170 | 171 | #[derive(Error, Debug)] 172 | #[error(transparent)] 173 | pub struct LookupError(#[from] rusqlite::Error); 174 | 175 | pub fn delete_note(connection: &mut Connection, path: &Path) -> Result<(), DeleteError> { 176 | let path_string = path 177 | .to_str() 178 | .ok_or_else(|| DeleteError::BadPath(path.to_owned()))?; 179 | 180 | connection 181 | .execute("DELETE FROM notes WHERE filepath = ?;", (&path_string,)) 182 | .map(|_affected| ()) 183 | .map_err(DeleteError::DatabaseError) 184 | } 185 | 186 | #[derive(Error, Debug)] 187 | pub enum DeleteError { 188 | #[error("could not delete from index database: {0}")] 189 | DatabaseError(rusqlite::Error), 190 | 191 | #[error("cannot delete a non-utf-8 path from the database: {0}")] 192 | BadPath(PathBuf), 193 | } 194 | 195 | fn setup_database(connection: &mut Connection) -> Result<(), MigrationError> { 196 | migrations().to_latest(connection)?; 197 | 198 | Ok(()) 199 | } 200 | 201 | fn unpack_row(row: &Row) -> Result<(PathBuf, IndexedNote), QueryFailure> { 202 | let raw_filepath: String = row.get(0)?; 203 | let title: String = row.get(1)?; 204 | let raw_created_at: String = row.get(2)?; 205 | let raw_utc_offset: i32 = row.get(3)?; 206 | let raw_kind: String = row.get(4)?; 207 | 208 | let filepath = PathBuf::from_str(&raw_filepath).unwrap(); // infallible error type 209 | let created_at = datetime_from_database(&raw_created_at, raw_utc_offset)?; 210 | let kind = NoteKind::try_from_sql_enum(&raw_kind) 211 | .map_err(|err| QueryFailure::InvalidRow(err.to_string()))?; 212 | 213 | Ok(( 214 | filepath, 215 | IndexedNote { 216 | kind, 217 | preamble: Preamble { title, created_at }, 218 | }, 219 | )) 220 | } 221 | 222 | fn datetime_from_database( 223 | timestamp: &str, 224 | utc_offset_seconds: i32, 225 | ) -> Result<DateTime<FixedOffset>, QueryFailure> { 226 | let offset = FixedOffset::east_opt(utc_offset_seconds).ok_or_else(|| { 227 | QueryFailure::InvalidRow(format!("Invalid UTC offset \"{utc_offset_seconds}\"")) 228 | })?; 229 | 230 | NaiveDateTime::parse_from_str(timestamp, DB_DATE_FORMAT) 231 | .map_err(|err| QueryFailure::InvalidRow(format!("Invalid date \"{timestamp}\", {err}"))) 232 | .and_then(|datetime| { 233 | offset 234 | .from_local_datetime(&datetime) 235 | .single() 236 | .ok_or_else(|| { 237 | QueryFailure::InvalidRow(format!( 238 | "Invalid date \"{timestamp} at offset {utc_offset_seconds}\"" 239 | )) 240 | }) 241 | }) 242 | } 243 | 244 | enum QueryFailure { 245 | InvalidRow(String), 246 | DatabaseFailure(rusqlite::Error), 247 | } 248 | 249 | impl From<rusqlite::Error> for QueryFailure { 250 | fn from(error: rusqlite::Error) -> Self { 251 | Self::DatabaseFailure(error) 252 | } 253 | } 254 | 255 | fn migrations() -> Migrations<'static> { 256 | Migrations::new(vec![ 257 | M::up( 258 | "CREATE TABLE notes ( 259 | filepath TEXT PRIMARY KEY, 260 | title TEXT NOT NULL, 261 | created_at DATETIME NOT NULL, 262 | utc_offset_seconds INTEGER NOT NULL 263 | );", 264 | ), 265 | // Add the notes column. This migration is imperfect, because it classifies everything as 'note', 266 | // but given the index can be recreated, this is no big deal. Plus, I am the only one who used 267 | // the version without this :) 268 | M::up( 269 | r" 270 | CREATE TEMPORARY TABLE intermediate_notes AS 271 | SELECT 272 | filepath, title, created_at, utc_offset_seconds, 'note' 273 | FROM notes; 274 | DROP TABLE notes; 275 | CREATE TABLE notes ( 276 | filepath TEXT PRIMARY KEY, 277 | title TEXT NOT NULL, 278 | created_at DATETIME NOT NULL, 279 | utc_offset_seconds INTEGER NOT NULL, 280 | kind CHECK (kind IN ('note', 'daily')) NOT NULL 281 | ); 282 | INSERT INTO notes SELECT * FROM intermediate_notes; 283 | DROP TABLE intermediate_notes; 284 | ", 285 | ), 286 | ]) 287 | } 288 | 289 | #[cfg(test)] 290 | mod tests { 291 | use std::path::PathBuf; 292 | use std::str::FromStr; 293 | 294 | use chrono::{FixedOffset, TimeZone}; 295 | 296 | use super::*; 297 | 298 | #[test] 299 | pub fn migrations_valid() { 300 | migrations() 301 | .validate() 302 | .expect("failed to validate migrations"); 303 | } 304 | 305 | #[test] 306 | pub fn can_insert_note() { 307 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 308 | setup_database(&mut connection).expect("could not setup test database"); 309 | 310 | let preamble = Preamble { 311 | title: "Hello world".to_string(), 312 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 313 | .unwrap() 314 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 315 | .single() 316 | .unwrap(), 317 | }; 318 | 319 | add_note( 320 | &mut connection, 321 | &preamble, 322 | NoteKind::Note, 323 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 324 | ) 325 | .unwrap(); 326 | } 327 | 328 | #[test] 329 | pub fn can_update_note_by_reinserting() { 330 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 331 | setup_database(&mut connection).expect("could not setup test database"); 332 | 333 | let preamble1 = Preamble { 334 | title: "Hello world".to_string(), 335 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 336 | .unwrap() 337 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 338 | .single() 339 | .unwrap(), 340 | }; 341 | 342 | let preamble2 = Preamble { 343 | title: "Hello world!!".to_string(), 344 | ..preamble1 345 | }; 346 | 347 | // insert the first note 348 | add_note( 349 | &mut connection, 350 | &preamble1, 351 | NoteKind::Note, 352 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 353 | ) 354 | .unwrap(); 355 | 356 | // ... then update 357 | add_note( 358 | &mut connection, 359 | &preamble2, 360 | NoteKind::Note, 361 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 362 | ) 363 | .expect("Failed to update note"); 364 | 365 | let notes = all_notes(&mut connection) 366 | .unwrap() 367 | .into_iter() 368 | .collect::<Vec<_>>(); 369 | 370 | // should only have the one note, which is the valid one 371 | assert_eq!( 372 | notes, 373 | [( 374 | PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt") 375 | .unwrap(), 376 | IndexedNote { 377 | preamble: preamble2, 378 | kind: NoteKind::Note 379 | } 380 | )] 381 | ); 382 | } 383 | 384 | #[test] 385 | pub fn cannot_insert_note_with_invalid_utf8_path() { 386 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 387 | 388 | let preamble = Preamble { 389 | title: "Hello world".to_string(), 390 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 391 | .unwrap() 392 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 393 | .single() 394 | .unwrap(), 395 | }; 396 | 397 | // construct an invalid path (this is platform dependent) 398 | #[cfg(unix)] 399 | let path = { 400 | use std::ffi::OsStr; 401 | use std::os::unix::ffi::OsStrExt; 402 | PathBuf::from(OsStr::from_bytes(&[0xFF, 0xFF])) 403 | }; 404 | 405 | // construct an invalid path (this is platform dependent) 406 | #[cfg(windows)] 407 | let path = { 408 | use std::ffi::OsString; 409 | use std::os::windows::ffi::OsStringExt; 410 | PathBuf::from(OsString::from_wide(&[0xD800])) 411 | }; 412 | 413 | #[cfg(not(any(unix, windows)))] 414 | panic!("Cannot run test on neither windows or unix"); 415 | 416 | let insert_result = add_note(&mut connection, &preamble, NoteKind::Note, &path); 417 | 418 | assert!(insert_result.is_err()); 419 | } 420 | 421 | #[test] 422 | pub fn can_select_inserted_notes() { 423 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 424 | setup_database(&mut connection).expect("could not setup test database"); 425 | 426 | let preamble1 = Preamble { 427 | title: "Hello world".to_string(), 428 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 429 | .unwrap() 430 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 431 | .single() 432 | .unwrap(), 433 | }; 434 | 435 | add_note( 436 | &mut connection, 437 | &preamble1, 438 | NoteKind::Note, 439 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 440 | ) 441 | .unwrap(); 442 | 443 | let preamble2 = Preamble { 444 | title: "notes notes notes".to_string(), 445 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 446 | .unwrap() 447 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 448 | .single() 449 | .unwrap(), 450 | }; 451 | 452 | add_note( 453 | &mut connection, 454 | &preamble2, 455 | NoteKind::Note, 456 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/notes-notes-notes.txt") 457 | .unwrap(), 458 | ) 459 | .unwrap(); 460 | 461 | let notes = all_notes(&mut connection).expect("Failed to query notes"); 462 | 463 | assert_eq!( 464 | notes.get( 465 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt") 466 | .unwrap(), 467 | ), 468 | Some(&IndexedNote { 469 | preamble: preamble1, 470 | kind: NoteKind::Note 471 | }) 472 | ); 473 | 474 | assert_eq!( 475 | notes.get( 476 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/notes-notes-notes.txt") 477 | .unwrap(), 478 | ), 479 | Some(&IndexedNote { 480 | preamble: preamble2, 481 | kind: NoteKind::Note 482 | }) 483 | ); 484 | } 485 | 486 | #[test] 487 | pub fn selecting_skips_notes_with_malformed_timestamps() { 488 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 489 | setup_database(&mut connection).expect("could not setup test database"); 490 | 491 | let valid_note_preamble = Preamble { 492 | title: "This note is valid".to_string(), 493 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 494 | .unwrap() 495 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 496 | .single() 497 | .unwrap(), 498 | }; 499 | 500 | add_note( 501 | &mut connection, 502 | &valid_note_preamble, 503 | NoteKind::Note, 504 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/this-note-is-valid.txt") 505 | .unwrap(), 506 | ) 507 | .unwrap(); 508 | 509 | connection 510 | .execute( 511 | r#"INSERT INTO notes VALUES ( 512 | "/home/ferris/Documents/quicknotes/notes/this-note-is-not-valid.txt", 513 | "This note is not valid", 514 | "malformed timestamp", 515 | 0, 516 | 'note' 517 | )"#, 518 | [], 519 | ) 520 | .unwrap(); 521 | 522 | let notes = all_notes(&mut connection) 523 | .expect("Failed to query notes") 524 | .into_iter() 525 | .collect::<Vec<_>>(); 526 | 527 | // should only have the one note, which is the valid one 528 | assert_eq!( 529 | notes, 530 | [( 531 | PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/this-note-is-valid.txt") 532 | .unwrap(), 533 | IndexedNote { 534 | preamble: valid_note_preamble, 535 | kind: NoteKind::Note 536 | } 537 | )] 538 | ); 539 | } 540 | 541 | #[test] 542 | pub fn selecting_filters_notes_of_other_kinds() { 543 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 544 | setup_database(&mut connection).expect("could not setup test database"); 545 | 546 | let preamble1 = Preamble { 547 | title: "Hello world".to_string(), 548 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 549 | .unwrap() 550 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 551 | .single() 552 | .unwrap(), 553 | }; 554 | 555 | add_note( 556 | &mut connection, 557 | &preamble1, 558 | NoteKind::Note, 559 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 560 | ) 561 | .unwrap(); 562 | 563 | let preamble2 = Preamble { 564 | title: "2015-10-21".to_string(), 565 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 566 | .unwrap() 567 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 568 | .single() 569 | .unwrap(), 570 | }; 571 | 572 | add_note( 573 | &mut connection, 574 | &preamble2, 575 | NoteKind::Daily, 576 | &PathBuf::from_str("/home/ferris/Documents/quicknotes/daily/2015-10-21.txt").unwrap(), 577 | ) 578 | .unwrap(); 579 | 580 | let notes = 581 | notes_with_kind(&mut connection, NoteKind::Note).expect("Failed to query notes"); 582 | 583 | let expected_entry = ( 584 | PathBuf::from_str("/home/ferris/Documents/quicknotes/notes/hello-world.txt").unwrap(), 585 | IndexedNote { 586 | preamble: preamble1, 587 | kind: NoteKind::Note, 588 | }, 589 | ); 590 | 591 | assert_eq!(notes.into_iter().collect::<Vec<_>>(), vec![expected_entry]); 592 | } 593 | 594 | #[test] 595 | pub fn delete_note_is_idempotent() { 596 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 597 | setup_database(&mut connection).expect("could not setup test database"); 598 | 599 | // if this is ok, the test passes 600 | delete_note(&mut connection, Path::new("/does/not/exist")).expect("could not delete note"); 601 | } 602 | 603 | #[test] 604 | pub fn delete_note_removes_notes_from_database() { 605 | let mut connection = Connection::open_in_memory().expect("could not open test database"); 606 | setup_database(&mut connection).expect("could not setup test database"); 607 | 608 | connection 609 | .execute( 610 | r#"INSERT INTO notes VALUES ( 611 | "/home/ferris/Documents/quicknotes/notes/my-cool-note.txt", 612 | "Hello, world!", 613 | "2015-10-22T07:28:00.000", 614 | 0, 615 | 'note' 616 | )"#, 617 | [], 618 | ) 619 | .unwrap(); 620 | 621 | let notes = all_notes(&mut connection) 622 | .expect("Failed to query notes") 623 | .into_iter() 624 | .collect::<Vec<_>>(); 625 | 626 | // Prove the note is there 627 | assert!(!notes.is_empty()); 628 | 629 | delete_note( 630 | &mut connection, 631 | Path::new("/home/ferris/Documents/quicknotes/notes/my-cool-note.txt"), 632 | ) 633 | .expect("could not delete note"); 634 | 635 | let notes = all_notes(&mut connection) 636 | .expect("Failed to query notes") 637 | .into_iter() 638 | .collect::<Vec<_>>(); 639 | 640 | // Prove the note is now gone 641 | assert!(notes.is_empty()); 642 | } 643 | } 644 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic)] 2 | #![allow(clippy::enum_variant_names)] 3 | 4 | use std::collections::HashMap; 5 | use std::fs::{self, File, OpenOptions}; 6 | use std::io; 7 | use std::path::{Path, PathBuf}; 8 | 9 | use chrono::{DateTime, NaiveDate, TimeZone}; 10 | use index::{LookupError as IndexLookupError, OpenError as IndexOpenError}; 11 | use io::Write; 12 | use note::{Preamble, SerializeError}; 13 | use rusqlite::Connection; 14 | use storage::{ 15 | store_if_different, StoreIfDifferentError, StoreNote, StoreNoteAt, StoreNoteIn, TempFileHandle, 16 | }; 17 | use tempfile::{Builder as TempFileBuilder, NamedTempFile, TempPath}; 18 | use thiserror::Error; 19 | use walkdir::{DirEntry, WalkDir}; 20 | 21 | pub use edit::{CommandEditor, Editor}; 22 | pub use index::{IndexedNote, NoteKind}; 23 | pub use note::Preamble as NotePreamble; 24 | 25 | mod edit; 26 | mod index; 27 | mod note; 28 | mod storage; 29 | 30 | macro_rules! warning { 31 | ($($arg:tt)*) => {{ 32 | use colored::Colorize; 33 | 34 | eprint!("{}: ", "warning".yellow()); 35 | eprintln!($($arg)*) 36 | }}; 37 | } 38 | 39 | pub(crate) use warning; 40 | 41 | pub struct NoteConfig { 42 | pub root_dir: PathBuf, 43 | pub file_extension: String, 44 | pub temp_root_override: Option<PathBuf>, 45 | } 46 | 47 | impl NoteConfig { 48 | #[must_use] 49 | pub fn notes_directory_path(&self) -> PathBuf { 50 | self.root_dir.join(Path::new("notes")) 51 | } 52 | 53 | #[must_use] 54 | pub fn daily_directory_path(&self) -> PathBuf { 55 | self.root_dir.join(Path::new("daily")) 56 | } 57 | 58 | #[must_use] 59 | pub fn index_db_path(&self) -> PathBuf { 60 | self.root_dir.join(Path::new(".index.sqlite3")) 61 | } 62 | } 63 | 64 | /// Create a new note. 65 | /// 66 | /// The note will be created in the notes directory, with a name as close to the given title as 67 | /// possible, and then opened in the editor. 68 | /// 69 | /// Returns the path of the note, or None if nothing was written to the note. 70 | /// 71 | /// # Errors 72 | /// 73 | /// Returns an error if there is an I/O failure creating the note, the editor fails to launch, or 74 | /// if there is a problem adding the note to the index. 75 | pub fn make_note<E: Editor, Tz: TimeZone>( 76 | config: &NoteConfig, 77 | editor: E, 78 | title: String, 79 | creation_time: &DateTime<Tz>, 80 | ) -> Result<Option<PathBuf>, MakeNoteError> { 81 | let filename_stem = note::filename_stem_for_title(&title); 82 | let store = StoreNoteIn { 83 | storage_directory: config.notes_directory_path(), 84 | preferred_file_stem: filename_stem, 85 | file_extension: config.file_extension.clone(), 86 | }; 87 | 88 | let maybe_written_path = 89 | make_note_with_store(config, store, editor, title, creation_time, NoteKind::Note)?; 90 | 91 | Ok(maybe_written_path) 92 | } 93 | 94 | /// An error that occurred during a call to [`make_note`]. [errors section](`make_note#Errors`) 95 | /// for more details. 96 | #[derive(Error, Debug)] 97 | #[error(transparent)] 98 | pub struct MakeNoteError { 99 | #[from] 100 | inner: MakeNoteAtError, 101 | } 102 | 103 | /// Create or open a daily note for the given date. 104 | /// 105 | /// This operates very similarly to [`make_note`], but the title of the note will be the 106 | /// date part of the creation time. If one already exists, it will be opened instead of 107 | /// creating a new one. 108 | /// 109 | /// Returns the path of the note, or None if nothing was written to the note. 110 | /// 111 | /// # Errors 112 | /// 113 | /// Returns an error if there is an I/O failure creating the note, the editor fails to launch, or 114 | /// if there is a problem adding the note to the index. 115 | pub fn make_or_open_daily<E: Editor, Tz: TimeZone>( 116 | config: &NoteConfig, 117 | editor: E, 118 | for_day: NaiveDate, 119 | creation_time: &DateTime<Tz>, 120 | ) -> Result<Option<PathBuf>, MakeOrOpenDailyNoteError> { 121 | let filename_stem = note::filename_stem_for_date(for_day); 122 | let destination_path = config 123 | .daily_directory_path() 124 | .join(filename_stem) 125 | .with_extension(&config.file_extension); 126 | 127 | let destination_exists = ensure_note_exists(&destination_path) 128 | .map(|()| true) 129 | .or_else(|err| { 130 | if err.kind() == io::ErrorKind::NotFound { 131 | Ok(false) 132 | } else { 133 | Err(InnerMakeOrOpenDailyNoteError::NoteLookupError { 134 | destination: destination_path.display().to_string(), 135 | err, 136 | }) 137 | } 138 | })?; 139 | 140 | if destination_exists { 141 | open_existing_note_in_editor(config, editor, NoteKind::Daily, &destination_path) 142 | .map_err(InnerMakeOrOpenDailyNoteError::from)?; 143 | 144 | Ok(Some(destination_path)) 145 | } else { 146 | // We should be able to store the note with the date's name. 147 | // 148 | // Technically someone could come in and put a file there while we are 149 | // editing this note, but that is not behavior we really support. 150 | // That file will not be overwritten. 151 | // 152 | // Plus, the dailies directory is separate from the notes directory, 153 | // so without manual intervention, one cannot enter this scenario. 154 | let store = StoreNoteAt { 155 | destination: destination_path, 156 | }; 157 | 158 | let maybe_actual_path = make_note_with_store( 159 | config, 160 | store, 161 | editor, 162 | for_day.format("%Y-%m-%d").to_string(), 163 | creation_time, 164 | NoteKind::Daily, 165 | ) 166 | .map_err(InnerMakeOrOpenDailyNoteError::from)?; 167 | 168 | Ok(maybe_actual_path) 169 | } 170 | } 171 | 172 | /// An error that occurred during a call to [`make_or_open_daily`]. See its 173 | /// [errors section](`make_or_open_daily#Errors`) for more details. 174 | #[derive(Error, Debug)] 175 | #[error(transparent)] 176 | pub struct MakeOrOpenDailyNoteError { 177 | #[from] 178 | inner: InnerMakeOrOpenDailyNoteError, 179 | } 180 | 181 | #[derive(Error, Debug)] 182 | enum InnerMakeOrOpenDailyNoteError { 183 | #[error("could not check if note exists at {destination:?}: {err}")] 184 | NoteLookupError { 185 | destination: String, 186 | #[source] 187 | err: io::Error, 188 | }, 189 | 190 | #[error("could not open daily note: {0}")] 191 | OpenNoteError(#[from] OpenExistingNoteInEditorError), 192 | 193 | #[error("could not create new daily note: {0}")] 194 | MakeNoteAtError(#[from] MakeNoteAtError), 195 | } 196 | 197 | /// Open an existing note at the given path in the editor. 198 | /// 199 | /// # Errors 200 | /// 201 | /// Returns an error if there was an I/O problem locating the existing note, the editor 202 | /// fails to launch, or there is a problem updating the note's entry in the index. 203 | pub fn open_note<E: Editor>( 204 | config: &NoteConfig, 205 | editor: E, 206 | kind: NoteKind, 207 | path: &Path, 208 | ) -> Result<(), OpenNoteError> { 209 | open_existing_note(config, editor, kind, path)?; 210 | 211 | Ok(()) 212 | } 213 | 214 | #[derive(Error, Debug)] 215 | #[error(transparent)] 216 | pub struct OpenNoteError { 217 | #[from] 218 | inner: OpenExistingNoteError, 219 | } 220 | 221 | /// Index all notes in the notes and dailies directories. This will also remove deleted files 222 | /// from the index. 223 | /// 224 | /// # Errors 225 | /// 226 | /// Returns an error if there is a problem opening or the index. 227 | /// 228 | /// Note that this will return `Ok` if there is a problem indexing an individual note, but a 229 | /// warning will be printed to stderr. 230 | pub fn index_notes(config: &NoteConfig) -> Result<(), IndexNotesError> { 231 | index_all_notes(config)?; 232 | 233 | Ok(()) 234 | } 235 | 236 | #[derive(Error, Debug)] 237 | #[error(transparent)] 238 | pub struct IndexNotesError { 239 | #[from] 240 | inner: IndexAllNotesError, 241 | } 242 | 243 | /// Get all of the notes currently stored in the index, and metadata about them. 244 | /// 245 | /// The returned `HashMap` maps from the path where the note to the metadata stored in its preamble. 246 | /// 247 | /// # Errors 248 | /// 249 | /// Returns an error if there was a problem opening or reading from the index. 250 | pub fn indexed_notes( 251 | config: &NoteConfig, 252 | ) -> Result<HashMap<PathBuf, IndexedNote>, IndexedNotesError> { 253 | let notes = all_indexed_notes(config)?; 254 | 255 | Ok(notes) 256 | } 257 | 258 | #[derive(Error, Debug)] 259 | #[error(transparent)] 260 | pub struct IndexedNotesError { 261 | #[from] 262 | inner: AllIndexedNotesError, 263 | } 264 | 265 | /// Get all of the notes currently stored in the index with the given kind, and metadata about them. 266 | /// 267 | /// The returned `HashMap` maps from the path where the note to the metadata stored in its preamble. 268 | /// 269 | /// # Errors 270 | /// 271 | /// Returns an error if there was a problem opening or reading from the index. 272 | pub fn indexed_notes_with_kind( 273 | config: &NoteConfig, 274 | kind: NoteKind, 275 | ) -> Result<HashMap<PathBuf, IndexedNote>, IndexedNotesWithKindError> { 276 | let notes = kinded_indexed_notes(config, kind)?; 277 | 278 | Ok(notes) 279 | } 280 | 281 | #[derive(Error, Debug)] 282 | #[error(transparent)] 283 | pub struct IndexedNotesWithKindError { 284 | #[from] 285 | inner: KindedIndexedNotesError, 286 | } 287 | 288 | fn make_note_with_store<E: Editor, Tz: TimeZone, S: StoreNote>( 289 | config: &NoteConfig, 290 | store: S, 291 | editor: E, 292 | title: String, 293 | creation_time: &DateTime<Tz>, 294 | kind: NoteKind, 295 | ) -> Result<Option<PathBuf>, MakeNoteAtError> { 296 | let tempfile = make_tempfile(config).map_err(MakeNoteAtError::CreateTempfileError)?; 297 | let preamble = Preamble::new(title, creation_time.fixed_offset()); 298 | 299 | let serialized_preamble = write_preamble(&preamble, &tempfile)?; 300 | open_in_editor(editor, &tempfile)?; 301 | 302 | let handle = TempFileHandle::open(tempfile).map_err(MakeNoteAtError::OpenNoteError)?; 303 | let maybe_actual_path = store_if_different(store, handle, &serialized_preamble)?; 304 | 305 | match maybe_actual_path { 306 | Some(actual_destination_path) => { 307 | let mut index_connection = open_index_database(config)?; 308 | index_note(&mut index_connection, kind, &actual_destination_path)?; 309 | 310 | Ok(Some(actual_destination_path)) 311 | } 312 | 313 | None => Ok(None), 314 | } 315 | } 316 | 317 | #[derive(Error, Debug)] 318 | #[error(transparent)] 319 | enum MakeNoteAtError { 320 | #[error("could not create temporary file: {0}")] 321 | CreateTempfileError(io::Error), 322 | 323 | #[error("could not write preamble to file: {0}")] 324 | WritePreambleError(#[from] WritePreambleError), 325 | 326 | #[error("could not open note for storage: {0}")] 327 | OpenNoteError(io::Error), 328 | 329 | #[error(transparent)] 330 | StoreNoteError(#[from] StoreIfDifferentError), 331 | 332 | #[error(transparent)] 333 | EditorSpawnError(#[from] OpenInEditorError), 334 | 335 | #[error(transparent)] 336 | IndexNoteError(#[from] IndexNoteError), 337 | 338 | #[error(transparent)] 339 | IndexOpenError(#[from] IndexOpenError), 340 | } 341 | 342 | fn make_tempfile(config: &NoteConfig) -> Result<TempPath, io::Error> { 343 | let mut builder = TempFileBuilder::new(); 344 | let file_extension_suffix = format!(".{}", config.file_extension); 345 | let builder = builder.suffix(&file_extension_suffix); 346 | 347 | if let Some(temp_dir) = config.temp_root_override.as_ref() { 348 | builder 349 | .tempfile_in(temp_dir) 350 | .map(NamedTempFile::into_temp_path) 351 | } else { 352 | builder.tempfile().map(NamedTempFile::into_temp_path) 353 | } 354 | } 355 | 356 | fn write_preamble(preamble: &Preamble, path: &Path) -> Result<String, WritePreambleError> { 357 | let mut file = OpenOptions::new() 358 | .write(true) 359 | .create(false) 360 | .open(path) 361 | .map_err(WritePreambleError::OpenError)?; 362 | 363 | let serialized_preamble = preamble.serialize()?; 364 | let to_write = format!("{serialized_preamble}\n\n"); 365 | file.write_all(to_write.as_bytes()) 366 | .map_err(WritePreambleError::WriteError)?; 367 | 368 | Ok(to_write) 369 | } 370 | 371 | #[derive(Error, Debug)] 372 | #[error(transparent)] 373 | enum WritePreambleError { 374 | OpenError(io::Error), 375 | EncodeError(#[from] SerializeError), 376 | WriteError(io::Error), 377 | } 378 | 379 | fn open_existing_note<E: Editor>( 380 | config: &NoteConfig, 381 | editor: E, 382 | kind: NoteKind, 383 | path: &Path, 384 | ) -> Result<(), OpenExistingNoteError> { 385 | ensure_note_exists(path).map_err(|error| OpenExistingNoteError::LookupError { 386 | path: path.to_owned(), 387 | error, 388 | })?; 389 | open_existing_note_in_editor(config, editor, kind, path)?; 390 | 391 | Ok(()) 392 | } 393 | 394 | #[derive(Error, Debug)] 395 | #[error(transparent)] 396 | enum OpenExistingNoteError { 397 | #[error("could not open note at {path}: {error}")] 398 | LookupError { 399 | path: PathBuf, 400 | #[source] 401 | error: io::Error, 402 | }, 403 | 404 | #[error(transparent)] 405 | OpenNoteInEditorError(#[from] OpenExistingNoteInEditorError), 406 | } 407 | 408 | fn ensure_note_exists(path: &Path) -> Result<(), io::Error> { 409 | fs::metadata(path).and_then(|metadata| { 410 | if metadata.is_dir() { 411 | Err(io::Error::new( 412 | io::ErrorKind::IsADirectory, 413 | "file is a directory", 414 | )) 415 | } else { 416 | Ok(()) 417 | } 418 | }) 419 | } 420 | 421 | fn open_existing_note_in_editor<E: Editor>( 422 | config: &NoteConfig, 423 | editor: E, 424 | kind: NoteKind, 425 | path: &Path, 426 | ) -> Result<(), OpenExistingNoteInEditorError> { 427 | open_in_editor(editor, path)?; 428 | 429 | let mut index_connection = open_index_database(config)?; 430 | 431 | index_note(&mut index_connection, kind, path) 432 | .or_else(|err| { 433 | let IndexNoteError::PreambleError(err) = err else { 434 | return Err(err) 435 | }; 436 | 437 | match index::delete_note(&mut index_connection, path) { 438 | Ok(()) => { 439 | warning!("After editing, the note could not be reindexed. It has been removed from the index. Original error: {err}"); 440 | Ok(()) 441 | } 442 | 443 | Err(delete_err) => { 444 | warning!("After editing, the note could not be reindexed. There was a subsequent failure that prevented it from being removed from the index, so there is now a stale entry. You can fix this by running `quicknotes index`. Original error: {err}; Delete error: {delete_err}"); 445 | Ok(()) 446 | } 447 | } 448 | })?; 449 | 450 | Ok(()) 451 | } 452 | 453 | #[derive(Error, Debug)] 454 | #[allow(clippy::enum_variant_names)] 455 | enum OpenExistingNoteInEditorError { 456 | #[error(transparent)] 457 | EditorSpawnError(#[from] OpenInEditorError), 458 | 459 | #[error(transparent)] 460 | IndexOpenError(#[from] IndexOpenError), 461 | 462 | #[error(transparent)] 463 | IndexNoteError(#[from] IndexNoteError), 464 | } 465 | 466 | fn open_in_editor<E: Editor>(editor: E, path: &Path) -> Result<(), OpenInEditorError> { 467 | editor.edit(path).map_err(|err| OpenInEditorError { 468 | editor: editor.name().to_owned(), 469 | err, 470 | }) 471 | } 472 | 473 | #[derive(Error, Debug)] 474 | #[error("could not spawn editor '{editor}': {err}")] 475 | struct OpenInEditorError { 476 | editor: String, 477 | #[source] 478 | err: io::Error, 479 | } 480 | 481 | fn index_all_notes(config: &NoteConfig) -> Result<(), IndexAllNotesError> { 482 | // This is a bit of a hack, but is easier than trying to prune stale entries from 483 | // the index 484 | reset_index_database(config)?; 485 | let mut connection = open_index_database(config)?; 486 | 487 | for (kind, path) in note_file_paths(config) { 488 | if let Err(err) = index_note(&mut connection, kind, &path) { 489 | warning!("could not index note at {}: {}", path.display(), err); 490 | } 491 | } 492 | 493 | Ok(()) 494 | } 495 | 496 | #[derive(Error, Debug)] 497 | enum IndexAllNotesError { 498 | #[error(transparent)] 499 | IndexResetError(#[from] index::ResetError), 500 | 501 | #[error(transparent)] 502 | IndexOpenError(#[from] IndexOpenError), 503 | } 504 | 505 | fn all_indexed_notes( 506 | config: &NoteConfig, 507 | ) -> Result<HashMap<PathBuf, IndexedNote>, AllIndexedNotesError> { 508 | let mut connection = open_index_database(config)?; 509 | let notes = index::all_notes(&mut connection)?; 510 | 511 | Ok(notes) 512 | } 513 | 514 | #[derive(Error, Debug)] 515 | enum AllIndexedNotesError { 516 | #[error(transparent)] 517 | IndexOpenError(#[from] IndexOpenError), 518 | 519 | #[error("could not query index database: {0}")] 520 | QueryError(#[from] IndexLookupError), 521 | } 522 | 523 | fn kinded_indexed_notes( 524 | config: &NoteConfig, 525 | kind: NoteKind, 526 | ) -> Result<HashMap<PathBuf, IndexedNote>, KindedIndexedNotesError> { 527 | let mut connection = open_index_database(config)?; 528 | let notes = index::notes_with_kind(&mut connection, kind)?; 529 | 530 | Ok(notes) 531 | } 532 | 533 | #[derive(Error, Debug)] 534 | enum KindedIndexedNotesError { 535 | #[error(transparent)] 536 | IndexOpenError(#[from] IndexOpenError), 537 | 538 | #[error("could not query index database: {0}")] 539 | QueryError(#[from] IndexLookupError), 540 | } 541 | 542 | fn reset_index_database(config: &NoteConfig) -> Result<(), index::ResetError> { 543 | index::reset(&config.index_db_path()) 544 | } 545 | 546 | fn open_index_database(config: &NoteConfig) -> Result<Connection, IndexOpenError> { 547 | index::open(&config.index_db_path()) 548 | } 549 | 550 | /// Get all note file paths in a best-effort fashion. If there is an error where some 551 | /// notes cannot be read, warnings will be logged. 552 | fn note_file_paths(config: &NoteConfig) -> impl Iterator<Item = (NoteKind, PathBuf)> { 553 | WalkDir::new(config.notes_directory_path()) 554 | .into_iter() 555 | .map(|entry| (NoteKind::Note, entry)) 556 | .chain( 557 | WalkDir::new(config.daily_directory_path()) 558 | .into_iter() 559 | .map(|entry| (NoteKind::Daily, entry)), 560 | ) 561 | .filter_map(|(note_kind, entry_res)| { 562 | // skip entires we can't read, so we can get the rest 563 | unpack_walkdir_entry_result(entry_res) 564 | .ok() 565 | .and_then(|entry| { 566 | let isnt_dir = !entry.file_type().is_dir(); 567 | isnt_dir.then_some((note_kind, entry.into_path())) 568 | }) 569 | }) 570 | } 571 | 572 | fn unpack_walkdir_entry_result( 573 | entry_res: Result<DirEntry, walkdir::Error>, 574 | ) -> Result<DirEntry, ()> { 575 | match entry_res { 576 | Ok(entry) => Ok(entry), 577 | Err(err) => { 578 | if let Some(path) = err.path() { 579 | warning!( 580 | "Cannot traverse {}: {}", 581 | path.display().to_string(), 582 | io::Error::from(err) 583 | ); 584 | } else { 585 | warning!("Cannot traverse notes: {}", io::Error::from(err)); 586 | } 587 | 588 | Err(()) 589 | } 590 | } 591 | } 592 | 593 | fn index_note( 594 | index_connection: &mut Connection, 595 | kind: NoteKind, 596 | path: &Path, 597 | ) -> Result<(), IndexNoteError> { 598 | let mut file = File::open(path).map_err(IndexNoteError::OpenError)?; 599 | let preamble = note::extract_preamble(&mut file).map_err(IndexNoteError::PreambleError)?; 600 | 601 | index::add_note(index_connection, &preamble, kind, path).map_err(IndexNoteError::IndexError) 602 | } 603 | 604 | #[derive(Error, Debug)] 605 | #[allow(clippy::enum_variant_names)] 606 | enum IndexNoteError { 607 | #[error("could not open note for indexing: {0}")] 608 | OpenError(io::Error), 609 | 610 | #[error("could not read preamble from note: {0}")] 611 | PreambleError(note::InvalidPreambleError), 612 | 613 | #[error(transparent)] 614 | IndexError(index::InsertError), 615 | } 616 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic)] 2 | 3 | use std::collections::HashMap; 4 | use std::fmt::Display; 5 | use std::fs::{self, File}; 6 | use std::io::{self, Read, Write}; 7 | use std::path::{Path, PathBuf}; 8 | use std::{env, process}; 9 | 10 | use anyhow::anyhow; 11 | use chrono::{DateTime, FixedOffset, Local, NaiveDate, Timelike}; 12 | use chrono_english::Dialect; 13 | use clap::builder::PossibleValuesParser; 14 | use clap::{Arg, Command as ClapCommand}; 15 | use colored::Colorize; 16 | use directories::{ProjectDirs, UserDirs}; 17 | use itertools::Itertools; 18 | use nucleo_picker::error::PickError; 19 | use nucleo_picker::nucleo::pattern::CaseMatching; 20 | use nucleo_picker::{Picker, PickerOptions, Render}; 21 | use quicknotes::{open_note, CommandEditor, IndexedNote, NoteConfig}; 22 | use serde::{de, Deserialize, Deserializer}; 23 | use serde_derive::{Deserialize, Serialize}; 24 | 25 | trait UnwrapOrExit<T> { 26 | fn unwrap_or_exit(self, msg: &str) -> T; 27 | } 28 | 29 | #[derive(Clone, Debug)] 30 | struct IndexEntry { 31 | path: PathBuf, 32 | note: IndexedNote, 33 | rendered_title_override: Option<String>, 34 | } 35 | 36 | struct IndexedNoteRenderer; 37 | 38 | impl IndexEntry { 39 | fn new(path: PathBuf, note: IndexedNote) -> Self { 40 | Self { 41 | path, 42 | note, 43 | rendered_title_override: None, 44 | } 45 | } 46 | } 47 | 48 | impl Render<IndexEntry> for IndexedNoteRenderer { 49 | type Str<'a> = &'a str; 50 | 51 | fn render<'a>(&self, entry: &'a IndexEntry) -> Self::Str<'a> { 52 | match &entry.rendered_title_override { 53 | Some(title_override) => title_override, 54 | None => &entry.note.preamble.title, 55 | } 56 | } 57 | } 58 | 59 | #[derive(Serialize, Deserialize)] 60 | struct OnDiskConfig { 61 | #[serde(deserialize_with = "OnDiskConfig::deserialize_notes_root")] 62 | pub notes_root: PathBuf, 63 | 64 | #[serde(deserialize_with = "OnDiskConfig::deserialize_extension")] 65 | pub note_file_extension: String, 66 | 67 | #[serde(skip_serializing_if = "Option::is_none")] 68 | pub editor_command: Option<String>, 69 | } 70 | 71 | impl OnDiskConfig { 72 | fn unpack(self, fallback_editor_command: &str) -> (NoteConfig, CommandEditor) { 73 | let editor = CommandEditor::new( 74 | self.editor_command 75 | .unwrap_or_else(|| fallback_editor_command.to_owned()), 76 | ); 77 | 78 | let note_config = NoteConfig { 79 | root_dir: self.notes_root, 80 | file_extension: self.note_file_extension, 81 | temp_root_override: None, 82 | }; 83 | 84 | (note_config, editor) 85 | } 86 | 87 | fn deserialize_extension<'a, D: Deserializer<'a>>(deserializer: D) -> Result<String, D::Error> { 88 | let ext: String = Deserialize::deserialize(deserializer)?; 89 | 90 | Ok(ext.trim_start_matches('.').into()) 91 | } 92 | 93 | fn deserialize_notes_root<'a, D: Deserializer<'a>>( 94 | deserializer: D, 95 | ) -> Result<PathBuf, D::Error> { 96 | let notes_root: PathBuf = Deserialize::deserialize(deserializer)?; 97 | if notes_root.is_absolute() { 98 | Ok(notes_root) 99 | } else { 100 | Err(de::Error::custom("must be an absolute path")) 101 | } 102 | } 103 | } 104 | 105 | impl<T, E: Display> UnwrapOrExit<T> for Result<T, E> { 106 | fn unwrap_or_exit(self, msg: &str) -> T { 107 | match self { 108 | Ok(value) => value, 109 | Err(err) => { 110 | eprintln!("{}: {msg} - {err}", "error".red()); 111 | 112 | process::exit(1) 113 | } 114 | } 115 | } 116 | } 117 | 118 | fn main() { 119 | let command = cli_command(); 120 | let (note_config, editor) = load_config() 121 | .unwrap_or_exit("could not load configuration file") 122 | .unpack(&fallback_editor()); 123 | 124 | match command.get_matches().subcommand() { 125 | Some(("new", submatches)) => run_new(¬e_config, &editor, submatches), 126 | Some(("daily", submatches)) => run_daily(¬e_config, &editor, submatches), 127 | Some(("index", _submatches)) => run_index(¬e_config), 128 | Some(("open", submatches)) => run_open(¬e_config, &editor, submatches), 129 | _ => unreachable!(), 130 | } 131 | } 132 | 133 | fn cli_command() -> ClapCommand { 134 | ClapCommand::new("qn") 135 | .subcommand_required(true) 136 | .arg_required_else_help(true) 137 | .subcommand( 138 | ClapCommand::new("new") 139 | .arg(Arg::new("title").num_args(1..).required(true)) 140 | .about("Create a new note") 141 | .long_about( 142 | concat!( 143 | "Create a new note.", 144 | " The title for the note can be entered into the shell directly, including spaces.") 145 | , 146 | ) 147 | ) 148 | .subcommand( 149 | ClapCommand::new("daily") 150 | .arg(Arg::new("offset").num_args(1..).required(false)) 151 | .about("Open or create a daily note") 152 | .long_about( 153 | concat!( 154 | "Open a daily note, or create one one does not already exist.", 155 | " Optionally, an offset can be supplied, which is a fuzzy date relative to today.", 156 | " Acceptable formats include, but are not limited to, \"2015-10-21\", \"yesterday\" \"3 days ago\"" 157 | ) 158 | ) 159 | ) 160 | .subcommand( 161 | ClapCommand::new("index") 162 | .about("Index the notes directory") 163 | .long_about( 164 | concat!( 165 | "Scan the notes directory, and add the notes there to the index.", 166 | " This generally should not be necessary, as opening a note adds it to the index automatically,", 167 | " but if notes are edited outside of quicknotes or deleted, then this can be useful." 168 | ) 169 | ) 170 | ) 171 | .subcommand( 172 | ClapCommand::new("open") 173 | .arg( 174 | Arg::new("kind") 175 | .value_parser(PossibleValuesParser::new(vec!["note", "daily", "all"])) 176 | .default_value("note") 177 | ) 178 | .about("Open an existing note") 179 | .long_about( 180 | concat!( 181 | "Open an existing note.", 182 | " Optionally, the type of note can be specified. Defaults to 'note'", 183 | " (i.e. those created with quicknotes new).", 184 | ) 185 | ) 186 | ) 187 | } 188 | 189 | fn run_new(config: &NoteConfig, editor: &CommandEditor, args: &clap::ArgMatches) { 190 | ensure_notes_dir_exists(config).unwrap_or_exit("could not create notes directory"); 191 | 192 | let title = args 193 | .get_many::<String>("title") 194 | .unwrap_or_default() 195 | .join(" "); 196 | 197 | let path = quicknotes::make_note(config, editor, title, &Local::now()) 198 | .unwrap_or_exit("could not create note"); 199 | 200 | if path.is_none() { 201 | eprintln!("nothing was written in the note; note discarded"); 202 | } 203 | } 204 | 205 | fn run_daily(config: &NoteConfig, editor: &CommandEditor, args: &clap::ArgMatches) { 206 | ensure_daily_dir_exists(config).unwrap_or_exit("could not create dailies directory"); 207 | let now = Local::now(); 208 | let note_date = args.get_many::<String>("offset").map_or_else( 209 | || now.date_naive(), 210 | |offset_args| { 211 | let offset = offset_args.into_iter().join(" "); 212 | 213 | fuzzy_offset_from_date(now.date_naive(), &offset) 214 | .unwrap_or_exit("could not parse daily note offset") 215 | }, 216 | ); 217 | 218 | let path = quicknotes::make_or_open_daily(config, editor, note_date, &now) 219 | .unwrap_or_exit("could not create daily note"); 220 | 221 | if path.is_none() { 222 | eprintln!("nothing was written in the note; note discarded"); 223 | } 224 | } 225 | 226 | fn run_index(config: &NoteConfig) { 227 | ensure_root_dir_exists(config).unwrap_or_exit("could not create root quicknotes directory"); 228 | 229 | quicknotes::index_notes(config).unwrap_or_exit("could not index notes"); 230 | } 231 | 232 | fn run_open(config: &NoteConfig, editor: &CommandEditor, args: &clap::ArgMatches) { 233 | ensure_root_dir_exists(config).unwrap_or_exit("could not create root quicknotes directory"); 234 | 235 | let kind = args 236 | .get_one::<String>("kind") 237 | .expect("kind has a default value"); 238 | 239 | let indexed_notes = match kind.as_str() { 240 | "all" => quicknotes::indexed_notes(config).unwrap_or_exit("couldn't load notes"), 241 | 242 | "note" => quicknotes::indexed_notes_with_kind(config, quicknotes::NoteKind::Note) 243 | .unwrap_or_exit("couldn't load notes"), 244 | 245 | "daily" => quicknotes::indexed_notes_with_kind(config, quicknotes::NoteKind::Daily) 246 | .unwrap_or_exit("couldn't load notes"), 247 | 248 | _ => unreachable!("invalid argument, should be caught by clap"), 249 | }; 250 | 251 | let mut picker = PickerOptions::new() 252 | .highlight(true) 253 | .case_matching(CaseMatching::Smart) 254 | .picker(IndexedNoteRenderer); 255 | 256 | let picker_injector = picker.injector(); 257 | 258 | for entry in build_index_entires(indexed_notes) { 259 | picker_injector.push(entry); 260 | } 261 | 262 | if let Some(selected_note) = pick(&mut picker).unwrap_or_exit("could not launch picker") { 263 | open_note(config, editor, selected_note.note.kind, &selected_note.path) 264 | .unwrap_or_exit("could not open selected file"); 265 | } 266 | } 267 | 268 | fn load_config() -> anyhow::Result<OnDiskConfig> { 269 | let config_file = config_file_path()?; 270 | match File::open(&config_file) { 271 | Ok(mut file_handle) => read_config_file(&mut file_handle) 272 | .map_err(|err| anyhow!("reading {}: {err}", config_file.display())), 273 | 274 | Err(e) if e.kind() == io::ErrorKind::NotFound => { 275 | ensure_config_directory_exists()?; 276 | let config_file = config_file_path()?; 277 | eprintln!( 278 | "{}: no configuration found; generating one for you at {}", 279 | "warning".yellow(), 280 | config_file.display() 281 | ); 282 | 283 | let config = write_default_config(&config_file)?; 284 | 285 | Ok(config) 286 | } 287 | 288 | Err(e) => Err(e.into()), 289 | } 290 | } 291 | 292 | fn read_config_file<R: Read>(file: &mut R) -> anyhow::Result<OnDiskConfig> { 293 | let mut raw_config = String::new(); 294 | file.read_to_string(&mut raw_config)?; 295 | 296 | let config = toml::from_str(&raw_config)?; 297 | 298 | Ok(config) 299 | } 300 | 301 | fn write_default_config(config_file: &Path) -> anyhow::Result<OnDiskConfig> { 302 | let config = default_config()?; 303 | let serialized_config = toml::to_string_pretty(&config)?; 304 | let mut config_file_handle = File::create(config_file)?; 305 | write!(config_file_handle, "{serialized_config}")?; 306 | 307 | Ok(config) 308 | } 309 | 310 | fn default_config() -> anyhow::Result<OnDiskConfig> { 311 | let notes_root = default_notes_root()?; 312 | Ok(OnDiskConfig { 313 | notes_root, 314 | note_file_extension: ".md".to_string(), 315 | editor_command: None, 316 | }) 317 | } 318 | 319 | fn fallback_editor() -> String { 320 | env::var("EDITOR").unwrap_or_else(|_err| "nano".to_string()) 321 | } 322 | 323 | fn ensure_config_directory_exists() -> anyhow::Result<()> { 324 | let config_directory = config_directory_path()?; 325 | ensure_directory_exists(&config_directory) 326 | } 327 | 328 | fn ensure_notes_dir_exists(config: &NoteConfig) -> anyhow::Result<()> { 329 | ensure_directory_exists(&config.notes_directory_path()) 330 | } 331 | 332 | fn ensure_daily_dir_exists(config: &NoteConfig) -> anyhow::Result<()> { 333 | ensure_directory_exists(&config.daily_directory_path()) 334 | } 335 | 336 | fn ensure_root_dir_exists(config: &NoteConfig) -> anyhow::Result<()> { 337 | ensure_directory_exists(&config.root_dir) 338 | } 339 | 340 | fn ensure_directory_exists(path: &Path) -> anyhow::Result<()> { 341 | match fs::create_dir_all(path) { 342 | Ok(()) => Ok(()), 343 | Err(err) if err.kind() == io::ErrorKind::AlreadyExists => Ok(()), 344 | Err(err) => Err(err.into()), 345 | } 346 | } 347 | 348 | fn config_file_path() -> anyhow::Result<PathBuf> { 349 | let dir = config_directory_path()?; 350 | 351 | Ok(dir.join(Path::new("config.toml"))) 352 | } 353 | 354 | fn config_directory_path() -> anyhow::Result<PathBuf> { 355 | let project_dirs = project_dirs()?; 356 | 357 | Ok(project_dirs.config_dir().to_owned()) 358 | } 359 | 360 | fn default_notes_root() -> anyhow::Result<PathBuf> { 361 | let user_dirs = user_dirs()?; 362 | user_dirs.document_dir().map_or_else( 363 | || Err(anyhow!("could not locate documents directory")), 364 | |path| Ok(path.join("quicknotes/")), 365 | ) 366 | } 367 | 368 | fn user_dirs() -> anyhow::Result<UserDirs> { 369 | UserDirs::new().map_or_else( 370 | || Err(anyhow!("could not locate home directory for current user")), 371 | Ok, 372 | ) 373 | } 374 | 375 | fn project_dirs() -> anyhow::Result<ProjectDirs> { 376 | // TODO: I guess this means you can't configure this if there is no home directory for 377 | // the current user. Not typical but is possible. 378 | ProjectDirs::from("com", "ollien", "quicknotes").map_or_else( 379 | || Err(anyhow!("could not locate home directory current user")), 380 | Ok, 381 | ) 382 | } 383 | 384 | fn build_index_entires(entries: HashMap<PathBuf, IndexedNote>) -> Vec<IndexEntry> { 385 | entries 386 | .into_iter() 387 | .map(|(path, note)| IndexEntry::new(path, note)) 388 | .into_group_map_by(|entry| entry.note.preamble.title.clone()) 389 | .into_iter() 390 | .flat_map(|(title, entries)| { 391 | let length = entries.len(); 392 | 393 | entries.into_iter().map(move |entry| { 394 | if length == 1 { 395 | entry 396 | } else { 397 | let overridden_title = 398 | override_title_with_date(&title, entry.note.preamble.created_at); 399 | 400 | IndexEntry { 401 | rendered_title_override: Some(overridden_title), 402 | ..entry 403 | } 404 | } 405 | }) 406 | }) 407 | .collect::<Vec<_>>() 408 | } 409 | 410 | fn override_title_with_date(title: &str, created_at: DateTime<FixedOffset>) -> String { 411 | let formatted_date = created_at 412 | .format("(%Y-%m-%d %H:%M:%S)") 413 | .to_string() 414 | .bright_blue(); 415 | 416 | format!("{title} {formatted_date}") 417 | } 418 | 419 | fn pick<T: Send + Sync + 'static, R: Render<T>>( 420 | picker: &mut Picker<T, R>, 421 | ) -> anyhow::Result<Option<&T>> { 422 | picker.pick().or_else(|err| { 423 | if let PickError::UserInterrupted = err { 424 | // A user hitting ctrl-c is no different than esc for this purpose 425 | Ok(None) 426 | } else { 427 | Err(err.into()) 428 | } 429 | }) 430 | } 431 | 432 | fn fuzzy_offset_from_date(date: NaiveDate, offset: &str) -> Result<NaiveDate, anyhow::Error> { 433 | // this will always be valid because 00:00:00 is a valid time 434 | let marker = date.and_hms_opt(0, 0, 0).unwrap().and_utc(); 435 | let changed = chrono_english::parse_date_string(offset, marker, Dialect::Us)?; 436 | if changed.num_seconds_from_midnight() > 0 { 437 | return Err(anyhow!("invalid offset")); 438 | } 439 | 440 | Ok(changed.date_naive()) 441 | } 442 | 443 | #[cfg(test)] 444 | mod tests { 445 | use chrono::TimeZone; 446 | use quicknotes::{Editor, NotePreamble}; 447 | use serde::de::value::StrDeserializer; 448 | use serde::de::IntoDeserializer; 449 | 450 | use super::*; 451 | 452 | #[test] 453 | fn on_disk_config_unpack_does_not_replace_configured_editor() { 454 | let disk_config = OnDiskConfig { 455 | notes_root: Path::new("/home/me/notes").to_owned(), 456 | note_file_extension: ".txt".to_string(), 457 | editor_command: Some("vim".to_string()), 458 | }; 459 | 460 | let (_note_config, editor) = disk_config.unpack("emacs"); 461 | 462 | assert_eq!(editor.name(), "vim"); 463 | } 464 | 465 | #[test] 466 | fn on_disk_config_unpack_sets_missing_editor() { 467 | let disk_config = OnDiskConfig { 468 | notes_root: Path::new("/home/me/notes").to_owned(), 469 | note_file_extension: ".txt".to_string(), 470 | editor_command: None, 471 | }; 472 | 473 | let (_note_config, editor) = disk_config.unpack("vim"); 474 | 475 | assert_eq!(editor.name(), "vim"); 476 | } 477 | 478 | #[test] 479 | fn on_disk_config_unpack_copies_file_extension() { 480 | let disk_config = OnDiskConfig { 481 | notes_root: Path::new("/home/me/notes").to_owned(), 482 | note_file_extension: ".md".to_string(), 483 | editor_command: None, 484 | }; 485 | 486 | let (note_config, _editor) = disk_config.unpack("vim"); 487 | 488 | assert_eq!(note_config.file_extension, ".md"); 489 | } 490 | 491 | #[test] 492 | fn deserialize_extension_removes_dot_to_file_extension() { 493 | let deserializer: StrDeserializer<'static, serde::de::value::Error> = 494 | ".md".into_deserializer(); 495 | let extension = OnDiskConfig::deserialize_extension(deserializer) 496 | .expect("failed to deserialize extension"); 497 | 498 | assert_eq!(extension, "md"); 499 | } 500 | 501 | #[test] 502 | fn deserialize_extension_preserves_lack_of_dot_in_file_extension() { 503 | let deserializer: StrDeserializer<'static, serde::de::value::Error> = 504 | "txt".into_deserializer(); 505 | let extension = OnDiskConfig::deserialize_extension(deserializer) 506 | .expect("failed to deserialize extension"); 507 | 508 | assert_eq!(extension, "txt"); 509 | } 510 | 511 | #[test] 512 | fn deserialize_notes_root_allows_absolute_paths() { 513 | let deserializer: StrDeserializer<'static, serde::de::value::Error> = 514 | "/home/ferris/Documents/quicknotes/".into_deserializer(); 515 | 516 | let notes_root = OnDiskConfig::deserialize_notes_root(deserializer) 517 | .expect("failed to deserialize extension"); 518 | 519 | assert_eq!( 520 | "/home/ferris/Documents/quicknotes/", 521 | notes_root.to_str().unwrap() 522 | ); 523 | } 524 | 525 | #[test] 526 | fn deserialize_notes_root_does_not_allow_relative_paths() { 527 | let deserializer: StrDeserializer<'static, serde::de::value::Error> = 528 | "Documents/quicknotes/".into_deserializer(); 529 | 530 | assert!(OnDiskConfig::deserialize_notes_root(deserializer).is_err()); 531 | } 532 | 533 | #[test] 534 | fn fuzzy_offset_from_date_allows_date_based_offsets() { 535 | let date = 536 | fuzzy_offset_from_date(NaiveDate::from_ymd_opt(2015, 10, 21).unwrap(), "2 days ago") 537 | .expect("could not convert from offset"); 538 | 539 | assert_eq!(date, NaiveDate::from_ymd_opt(2015, 10, 19).unwrap()); 540 | } 541 | 542 | #[test] 543 | fn fuzzy_offset_from_date_does_not_allow_time_based_offset() { 544 | let res = fuzzy_offset_from_date( 545 | NaiveDate::from_ymd_opt(2015, 10, 21).unwrap(), 546 | "3 hours ago", 547 | ); 548 | 549 | assert!( 550 | res.is_err(), 551 | "should not have been able to perform this conversion" 552 | ); 553 | } 554 | 555 | #[test] 556 | fn build_index_entries_does_not_override_titles_for_unique_notes() { 557 | let make_created_at = |day_offset: u32| { 558 | FixedOffset::east_opt(-7 * 60 * 60) 559 | .unwrap() 560 | .with_ymd_and_hms(2015, 10, 21 + day_offset, 7, 28, 0) 561 | .single() 562 | .unwrap() 563 | }; 564 | 565 | let notes = HashMap::from([ 566 | ( 567 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/abc.txt"), 568 | IndexedNote { 569 | preamble: NotePreamble { 570 | created_at: make_created_at(0), 571 | title: "abc".to_string(), 572 | }, 573 | kind: quicknotes::NoteKind::Note, 574 | }, 575 | ), 576 | ( 577 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/def.txt"), 578 | IndexedNote { 579 | preamble: NotePreamble { 580 | created_at: make_created_at(1), 581 | title: "def".to_string(), 582 | }, 583 | kind: quicknotes::NoteKind::Note, 584 | }, 585 | ), 586 | ( 587 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/xyz.txt"), 588 | IndexedNote { 589 | preamble: NotePreamble { 590 | created_at: make_created_at(2), 591 | title: "xyz".to_string(), 592 | }, 593 | kind: quicknotes::NoteKind::Note, 594 | }, 595 | ), 596 | ]); 597 | 598 | let overrides = build_index_entires(notes) 599 | .into_iter() 600 | .map(|entry| entry.rendered_title_override) 601 | .collect::<Vec<_>>(); 602 | 603 | assert!(overrides == vec![None, None, None]); 604 | } 605 | 606 | #[test] 607 | fn build_index_entries_overrides_titles_of_notes_with_matching_titles() { 608 | let make_created_at = |day_offset: u32| { 609 | FixedOffset::east_opt(-7 * 60 * 60) 610 | .unwrap() 611 | .with_ymd_and_hms(2015, 10, 21 + day_offset, 7, 28, 0) 612 | .single() 613 | .unwrap() 614 | }; 615 | 616 | let notes = HashMap::from([ 617 | ( 618 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/abc.txt"), 619 | IndexedNote { 620 | preamble: NotePreamble { 621 | created_at: make_created_at(0), 622 | title: "abc".to_string(), 623 | }, 624 | kind: quicknotes::NoteKind::Note, 625 | }, 626 | ), 627 | ( 628 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/def.txt"), 629 | IndexedNote { 630 | preamble: NotePreamble { 631 | created_at: make_created_at(1), 632 | title: "def".to_string(), 633 | }, 634 | kind: quicknotes::NoteKind::Note, 635 | }, 636 | ), 637 | ( 638 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/abc2.txt"), 639 | IndexedNote { 640 | preamble: NotePreamble { 641 | created_at: make_created_at(2), 642 | title: "abc".to_string(), 643 | }, 644 | kind: quicknotes::NoteKind::Note, 645 | }, 646 | ), 647 | ]); 648 | 649 | let overrides = build_index_entires(notes) 650 | .into_iter() 651 | .map(|entry| (entry.path, entry.rendered_title_override)) 652 | .collect::<HashMap<_, _>>(); 653 | 654 | let expected = HashMap::from([ 655 | ( 656 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/def.txt"), 657 | None, 658 | ), 659 | ( 660 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/abc.txt"), 661 | Some(override_title_with_date("abc", make_created_at(0))), 662 | ), 663 | ( 664 | PathBuf::from("/home/ferris/Documents/quicknotes/notes/abc2.txt"), 665 | Some(override_title_with_date("abc", make_created_at(2))), 666 | ), 667 | ]); 668 | 669 | assert_eq!(overrides, expected); 670 | } 671 | 672 | #[test] 673 | fn title_override_starts_with_title() { 674 | let created_at = FixedOffset::east_opt(-7 * 60 * 60) 675 | .unwrap() 676 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 677 | .single() 678 | .unwrap(); 679 | 680 | let title = override_title_with_date("abc", created_at); 681 | 682 | assert!(title.starts_with("abc ")); 683 | } 684 | 685 | #[test] 686 | fn title_override_contains_the_date() { 687 | let created_at = FixedOffset::east_opt(-7 * 60 * 60) 688 | .unwrap() 689 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 690 | .single() 691 | .unwrap(); 692 | 693 | let title = override_title_with_date("abc", created_at); 694 | 695 | assert!(title.contains("2015-10-21 07:28:00")); 696 | } 697 | } 698 | -------------------------------------------------------------------------------- /src/note.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, BufRead, BufReader, Read}; 2 | 3 | use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, Offset, TimeZone, Timelike}; 4 | use itertools::Itertools; 5 | use serde::{de, ser, Deserialize, Deserializer, Serialize, Serializer}; 6 | use serde_derive::{Deserialize, Serialize}; 7 | use thiserror::Error; 8 | use toml::value::Datetime as TomlDateTime; 9 | 10 | /// Holds metadata about the note. This metadata is stored in the first section of the note when 11 | /// stored on disk. 12 | #[derive(Deserialize, Serialize, PartialEq, Eq, Clone, Debug)] 13 | pub struct Preamble { 14 | pub title: String, 15 | #[serde( 16 | serialize_with = "serialize_datetime", 17 | deserialize_with = "deserialize_datetime" 18 | )] 19 | pub created_at: DateTime<FixedOffset>, 20 | } 21 | 22 | impl Preamble { 23 | /// Serialize the preamble for being written to a note. It will be serialized 24 | /// as a TOML encoded string, between two `---`s. For example 25 | /// 26 | /// ```text 27 | /// --- 28 | /// title = "my cool note" 29 | /// created_at = 2015-10-21T07:28:00-07:00 30 | /// --- 31 | /// ``` 32 | /// 33 | /// # Errors 34 | /// Returns an error if the data stored in the preamble is not serializable at TOML 35 | pub fn serialize(&self) -> Result<String, SerializeError> { 36 | let toml_preamble = toml::to_string_pretty(self).map_err(SerializeError)?; 37 | let serialized = format!("---\n{}\n---", toml_preamble.trim_end()); 38 | 39 | Ok(serialized) 40 | } 41 | } 42 | 43 | #[derive(Error, Debug)] 44 | #[error(transparent)] 45 | pub struct SerializeError(toml::ser::Error); 46 | 47 | impl Preamble { 48 | #[must_use] 49 | pub fn new(title: String, created_at: DateTime<FixedOffset>) -> Self { 50 | Self { title, created_at } 51 | } 52 | } 53 | 54 | pub fn filename_stem_for_title(title: &str) -> String { 55 | let base_name = title 56 | .to_lowercase() 57 | .split(' ') 58 | .map(remove_specials) 59 | .join("-"); 60 | 61 | base_name 62 | } 63 | 64 | pub fn filename_stem_for_date(date: NaiveDate) -> String { 65 | date.format("%Y-%m-%d").to_string() 66 | } 67 | 68 | pub fn extract_preamble<R: Read>(reader: R) -> Result<Preamble, InvalidPreambleError> { 69 | let mut buffered_reader = BufReader::new(reader); 70 | ensure_preamble_fence(&mut buffered_reader)?; 71 | let toml = read_until_closing_fence(&mut buffered_reader)?; 72 | 73 | toml::from_str(&toml).map_err(InvalidPreambleError::DeserializeError) 74 | } 75 | 76 | #[derive(Error, Debug)] 77 | pub enum InvalidPreambleError { 78 | #[error("preamble did not terminate")] 79 | UnterminatedFence(), 80 | 81 | #[error("'{0}' is not a valid fence")] 82 | MalformedFence(String), 83 | 84 | #[error("{0}")] 85 | DeserializeError(toml::de::Error), 86 | 87 | #[error(transparent)] 88 | IOError(io::Error), 89 | } 90 | 91 | fn ensure_preamble_fence<R: BufRead>(mut reader: R) -> Result<(), InvalidPreambleError> { 92 | let mut text = String::new(); 93 | reader 94 | .read_line(&mut text) 95 | .map_err(InvalidPreambleError::IOError)?; 96 | 97 | if text == "---\n" { 98 | Ok(()) 99 | } else { 100 | Err(InvalidPreambleError::MalformedFence(text.clone())) 101 | } 102 | } 103 | 104 | fn read_until_closing_fence<R: BufRead>(mut reader: R) -> Result<String, InvalidPreambleError> { 105 | let mut toml = String::new(); 106 | loop { 107 | let mut line = String::new(); 108 | match reader.read_line(&mut line) { 109 | Err(err) => { 110 | return Err(InvalidPreambleError::IOError(err)); 111 | } 112 | 113 | Ok(0) => { 114 | return Err(InvalidPreambleError::UnterminatedFence()); 115 | } 116 | 117 | Ok(_n) if line == "---" || line == "---\n" => { 118 | return Ok(toml); 119 | } 120 | 121 | Ok(_n) => { 122 | toml += &line; 123 | } 124 | } 125 | } 126 | } 127 | 128 | fn remove_specials(s: &str) -> String { 129 | s.chars() 130 | .filter(|c| !c.is_ascii() || c.is_ascii_alphanumeric()) 131 | .join("") 132 | } 133 | 134 | fn serialize_datetime<S: Serializer, T: TimeZone>( 135 | dt: &DateTime<T>, 136 | serializer: S, 137 | ) -> Result<S::Ok, S::Error> { 138 | let toml_datetime = toml_datetime::<_, S>(dt)?; 139 | 140 | toml_datetime.serialize(serializer) 141 | } 142 | 143 | fn toml_datetime<Tz: TimeZone, S: Serializer>(dt: &DateTime<Tz>) -> Result<TomlDateTime, S::Error> { 144 | let utc_offset_minutes = utc_offset_seconds(dt) 145 | .try_into() 146 | .map(|seconds: i16| seconds / 60) 147 | .map_err(|_err| ser::Error::custom("utc offset must fit into a u16"))?; 148 | 149 | let converted = TomlDateTime { 150 | date: Some(toml::value::Date { 151 | year: dt 152 | .year() 153 | .try_into() 154 | .map_err(|_err| ser::Error::custom("year must fit into a u16"))?, 155 | month: dt 156 | .month() 157 | .try_into() 158 | .map_err(|_err| ser::Error::custom("month must fit into a u8"))?, 159 | day: dt 160 | .day() 161 | .try_into() 162 | .map_err(|_err| ser::Error::custom("day must fit into a u8"))?, 163 | }), 164 | time: Some(toml::value::Time { 165 | hour: dt 166 | .hour() 167 | .try_into() 168 | .map_err(|_err| ser::Error::custom("hour must fit into a u8"))?, 169 | minute: dt 170 | .minute() 171 | .try_into() 172 | .map_err(|_err| ser::Error::custom("minute must fit into a u8"))?, 173 | second: dt 174 | .second() 175 | .try_into() 176 | .map_err(|_err| ser::Error::custom("second must fit into a u8"))?, 177 | 178 | nanosecond: dt.nanosecond(), 179 | }), 180 | offset: Some(toml::value::Offset::Custom { 181 | minutes: utc_offset_minutes, 182 | }), 183 | }; 184 | 185 | Ok(converted) 186 | } 187 | 188 | fn deserialize_datetime<'a, D: Deserializer<'a>>( 189 | deserializer: D, 190 | ) -> Result<chrono::DateTime<FixedOffset>, D::Error> { 191 | let dt: TomlDateTime = Deserialize::deserialize(deserializer)?; 192 | let date = dt.date.ok_or(de::Error::custom("missing date"))?; 193 | let time = dt.time.ok_or(de::Error::custom("missing time"))?; 194 | let offset = dt 195 | .offset 196 | .ok_or(de::Error::custom("missing timezone offset"))?; 197 | let offset_minutes = match offset { 198 | toml::value::Offset::Z => 0, 199 | toml::value::Offset::Custom { minutes } => minutes, 200 | }; 201 | let offset_seconds = offset_minutes * 60; 202 | 203 | FixedOffset::east_opt(offset_seconds.into()) 204 | .ok_or_else(|| de::Error::custom("offset {offset_minutes} out of range"))? 205 | .with_ymd_and_hms( 206 | date.year.into(), 207 | date.month.into(), 208 | date.day.into(), 209 | time.hour.into(), 210 | time.minute.into(), 211 | time.second.into(), 212 | ) 213 | // Take the later of the two times, arbitrarily 214 | .latest() 215 | .ok_or_else(|| de::Error::custom("timestamp {dt} is unresolvable"))? 216 | .with_nanosecond(time.nanosecond) 217 | .ok_or_else(|| de::Error::custom("timestamp {dt} is unresolvable")) 218 | } 219 | 220 | fn utc_offset_seconds<Tz: TimeZone>(dt: &DateTime<Tz>) -> i32 { 221 | dt.offset().fix().local_minus_utc() 222 | } 223 | 224 | #[cfg(test)] 225 | mod tests { 226 | use chrono::FixedOffset; 227 | use stringreader::StringReader; 228 | use test_case::test_case; 229 | 230 | use super::*; 231 | 232 | #[test] 233 | fn can_serialize_preamble_as_toml() { 234 | let preamble = Preamble { 235 | title: "Hello world".to_string(), 236 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 237 | .unwrap() 238 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 239 | .single() 240 | .unwrap(), 241 | }; 242 | 243 | assert_eq!( 244 | "---\ntitle = \"Hello world\"\ncreated_at = 2015-10-21T07:28:00-07:00\n---", 245 | preamble.serialize().unwrap() 246 | ); 247 | } 248 | 249 | #[test_case("---\ntitle = \"Hello world\"\ncreated_at = 2015-10-21T07:28:00-07:00\n---"; "preamble alone")] 250 | #[test_case("---\ntitle = \"Hello world\"\ncreated_at = 2015-10-21T07:28:00-07:00\n---\nsick notes bro"; "preamble with data after it")] 251 | fn can_read_preamble(contents: &str) { 252 | let reader = StringReader::new(contents); 253 | 254 | let preamble = extract_preamble(reader).expect("failed to parse preamble"); 255 | let expected = Preamble { 256 | title: "Hello world".to_string(), 257 | created_at: FixedOffset::east_opt(-7 * 60 * 60) 258 | .unwrap() 259 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 260 | .single() 261 | .unwrap(), 262 | }; 263 | 264 | assert_eq!(preamble, expected); 265 | } 266 | 267 | #[test] 268 | fn filename_for_title_converts_to_lowercase() { 269 | assert_eq!("note", filename_stem_for_title("Note")); 270 | } 271 | 272 | #[test] 273 | fn filename_for_title_converts_spaces_to_dashes() { 274 | assert_eq!( 275 | "my-awesome-note", 276 | filename_stem_for_title("my awesome note") 277 | ); 278 | } 279 | 280 | #[test] 281 | fn filename_for_title_removes_specials() { 282 | assert_eq!("im-a-note", filename_stem_for_title("i'm a note")); 283 | } 284 | 285 | #[test] 286 | fn filename_for_date_uses_date_in_simple_iso_format() { 287 | assert_eq!( 288 | "2015-10-21", 289 | filename_stem_for_date(NaiveDate::from_ymd_opt(2015, 10, 21).unwrap()) 290 | ); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/storage.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, File, OpenOptions}; 2 | use std::io::{self, BufReader, Read, Seek}; 3 | use std::path::{Path, PathBuf}; 4 | 5 | use itertools::Itertools; 6 | use regex::Regex; 7 | use sha2::{Digest, Sha256}; 8 | use tempfile::TempPath; 9 | use thiserror::Error; 10 | 11 | use crate::warning; 12 | 13 | pub struct TempFileHandle { 14 | opened: BufReader<File>, 15 | path: TempPath, 16 | } 17 | 18 | /// Stores the given tempfile into a storage medium. This trait can not be implemented 19 | /// by other modules, in order to avoid heap allocations for handling the error. 20 | pub trait StoreNote: sealed::StoreNote { 21 | fn store(self, tempfile: TempFileHandle) -> Result<PathBuf, StoreNoteError>; 22 | } 23 | 24 | #[derive(Error, Debug)] 25 | #[error(transparent)] 26 | pub struct StoreNoteError { 27 | inner: InnerStoreNoteError, 28 | } 29 | 30 | #[derive(Error, Debug)] 31 | #[error(transparent)] 32 | enum InnerStoreNoteError { 33 | StoreNoteInError(#[from] StoreNoteInError), 34 | StoreNoteAtError(#[from] StoreNoteAtError), 35 | } 36 | 37 | mod sealed { 38 | pub trait StoreNote {} 39 | 40 | impl<T: super::StoreNote> StoreNote for T {} 41 | } 42 | 43 | // A [`StoreNote`] strategy which stores the note at the given destination, 44 | // regardless of the underlying filesystem's contents. It will not overwrite 45 | // files at the existing location. 46 | pub struct StoreNoteAt { 47 | pub destination: PathBuf, 48 | } 49 | 50 | /// A [`StoreNote`] strategy which stores the note at the given directory, but 51 | /// prevents clobbering existing filenames. 52 | pub struct StoreNoteIn { 53 | pub storage_directory: PathBuf, 54 | pub preferred_file_stem: String, 55 | pub file_extension: String, 56 | } 57 | 58 | impl TempFileHandle { 59 | pub fn open(temppath: TempPath) -> Result<Self, io::Error> { 60 | let file = File::open(&temppath)?; 61 | 62 | Ok(Self { 63 | opened: BufReader::new(file), 64 | path: temppath, 65 | }) 66 | } 67 | } 68 | 69 | impl StoreNote for StoreNoteAt { 70 | fn store(self, tempfile: TempFileHandle) -> Result<PathBuf, StoreNoteError> { 71 | self.do_store(tempfile) 72 | .map_err(|err| StoreNoteError { inner: err.into() }) 73 | } 74 | } 75 | 76 | impl StoreNoteAt { 77 | fn do_store(self, mut tempfile: TempFileHandle) -> Result<PathBuf, StoreNoteAtError> { 78 | match copy_to_destination(&mut tempfile.opened, &self.destination) { 79 | Ok(()) => Ok(self.destination), 80 | 81 | Err(err) => { 82 | let tempfile_path = tempfile.path.display().to_string(); 83 | try_preserve_note(tempfile)?; 84 | 85 | Err(StoreNoteAtError::CopyError { 86 | err: err.into(), 87 | destination: self.destination.display().to_string(), 88 | src: tempfile_path, 89 | }) 90 | } 91 | } 92 | } 93 | } 94 | 95 | #[derive(Error, Debug)] 96 | enum StoreNoteAtError { 97 | #[error("could not store note at {destination}. It still exists at {src:?}: {err}")] 98 | CopyError { 99 | src: String, 100 | destination: String, 101 | #[source] 102 | err: io::Error, 103 | }, 104 | 105 | #[error(transparent)] 106 | TryPreserveNoteError(#[from] TryPreserveNoteError), 107 | } 108 | 109 | impl StoreNote for StoreNoteIn { 110 | fn store(self, tempfile: TempFileHandle) -> Result<PathBuf, StoreNoteError> { 111 | self.do_store(tempfile) 112 | .map_err(|err| StoreNoteError { inner: err.into() }) 113 | } 114 | } 115 | 116 | impl StoreNoteIn { 117 | fn do_store(self, mut tempfile: TempFileHandle) -> Result<PathBuf, StoreNoteInError> { 118 | let mut destination = self 119 | .storage_directory 120 | .join(self.preferred_file_stem) 121 | .with_extension(&self.file_extension); 122 | 123 | // This is a loop to prevent the race where we generate a new filename and 124 | // something else inserts it quickly. It is technically possible this loops 125 | // forever, but it is extremely unlikely. 126 | loop { 127 | match copy_to_destination(&mut tempfile.opened, &destination) { 128 | Ok(()) => return Ok(destination), 129 | 130 | Err(err @ CopyToDestinationError::FileSetupError(..)) 131 | if err.is_destination_exists() => 132 | { 133 | warning!( 134 | "Note already exists at {}, generating new filename...", 135 | destination.display() 136 | ); 137 | 138 | match generate_unclobbered_destination(&destination) { 139 | Ok(new_destination) => { 140 | // Loop, and try to store 141 | destination = new_destination; 142 | } 143 | 144 | Err(err) => { 145 | let tempfile_path = tempfile.path.display().to_string(); 146 | try_preserve_note(tempfile)?; 147 | 148 | return Err(StoreNoteInError::NoteClobberPreventionError { 149 | err, 150 | destination: destination.display().to_string(), 151 | src: tempfile_path, 152 | }); 153 | } 154 | } 155 | } 156 | 157 | Err(err) => { 158 | let tempfile_path = tempfile.path.display().to_string(); 159 | try_preserve_note(tempfile)?; 160 | 161 | return Err(StoreNoteInError::CopyError { 162 | err: err.into(), 163 | destination: destination.display().to_string(), 164 | src: tempfile_path, 165 | }); 166 | } 167 | } 168 | } 169 | } 170 | } 171 | 172 | #[derive(Error, Debug)] 173 | enum StoreNoteInError { 174 | #[error("could not store note at {destination}. It still exists at {src:?}: {err}")] 175 | CopyError { 176 | src: String, 177 | destination: String, 178 | #[source] 179 | err: io::Error, 180 | }, 181 | 182 | #[error("could not store note at {destination}; file exists with the same name, and could not generate new filename for note. It still exists at {src}: {err}")] 183 | NoteClobberPreventionError { 184 | src: String, 185 | destination: String, 186 | err: GenerateUnclobberedDestinationError, 187 | }, 188 | 189 | #[error(transparent)] 190 | TryPreserveNoteError(#[from] TryPreserveNoteError), 191 | } 192 | 193 | fn copy_to_destination<R: Read>(mut src: R, to: &Path) -> Result<(), CopyToDestinationError> { 194 | let mut destination_file = OpenOptions::new() 195 | .write(true) 196 | .create_new(true) 197 | .open(to) 198 | .map_err(CopyToDestinationError::FileSetupError)?; 199 | 200 | io::copy(&mut src, &mut destination_file).map_err(CopyToDestinationError::CopyError)?; 201 | 202 | Ok(()) 203 | } 204 | 205 | #[derive(Error, Debug)] 206 | enum CopyToDestinationError { 207 | #[error(transparent)] 208 | FileSetupError(io::Error), 209 | 210 | #[error(transparent)] 211 | CopyError(io::Error), 212 | } 213 | 214 | impl From<CopyToDestinationError> for io::Error { 215 | fn from(value: CopyToDestinationError) -> io::Error { 216 | match value { 217 | CopyToDestinationError::FileSetupError(err) 218 | | CopyToDestinationError::CopyError(err) => err, 219 | } 220 | } 221 | } 222 | 223 | impl CopyToDestinationError { 224 | fn is_destination_exists(&self) -> bool { 225 | if let Self::FileSetupError(err) = self { 226 | err.kind() == io::ErrorKind::AlreadyExists 227 | } else { 228 | false 229 | } 230 | } 231 | } 232 | 233 | pub fn store_if_different<S: StoreNote>( 234 | storage: S, 235 | mut tempfile: TempFileHandle, 236 | against: &str, 237 | ) -> Result<Option<PathBuf>, StoreIfDifferentError> { 238 | match is_different(&mut tempfile, against) { 239 | Ok(false) => Ok(None), 240 | 241 | Ok(true) => { 242 | let path = storage 243 | .store(tempfile) 244 | .map_err(|err| StoreIfDifferentError(err.into()))?; 245 | 246 | Ok(Some(path)) 247 | } 248 | 249 | Err(err) => { 250 | let path = tempfile.path.to_path_buf(); 251 | try_preserve_note(tempfile).map_err(|err| StoreIfDifferentError(err.into()))?; 252 | 253 | Err(InnerStoreIfDifferentError::CheckFileError { path, err }.into()) 254 | } 255 | } 256 | } 257 | 258 | #[derive(Error, Debug)] 259 | #[error(transparent)] 260 | pub struct StoreIfDifferentError(#[from] InnerStoreIfDifferentError); 261 | 262 | #[derive(Error, Debug)] 263 | enum InnerStoreIfDifferentError { 264 | #[error("could not check note before storing it; it still exists at {path}: {err}")] 265 | CheckFileError { 266 | path: PathBuf, 267 | 268 | #[source] 269 | err: io::Error, 270 | }, 271 | 272 | #[error(transparent)] 273 | TryPreserveNoteError(#[from] TryPreserveNoteError), 274 | 275 | #[error(transparent)] 276 | StoreNoteError(#[from] StoreNoteError), 277 | } 278 | 279 | fn is_different(tempfile: &mut TempFileHandle, against: &str) -> Result<bool, io::Error> { 280 | let mut against_hasher = Sha256::new(); 281 | against_hasher.update(against.as_bytes()); 282 | let against_hash = against_hasher.finalize(); 283 | 284 | let mut file_hasher = Sha256::new(); 285 | io::copy(&mut tempfile.opened, &mut file_hasher)?; 286 | let file_hash = file_hasher.finalize(); 287 | 288 | if against_hash == file_hash { 289 | return Ok(false); 290 | } 291 | 292 | tempfile.opened.rewind()?; 293 | 294 | Ok(true) 295 | } 296 | 297 | fn try_preserve_note(tempfile: TempFileHandle) -> Result<(), TryPreserveNoteError> { 298 | // Store the path in case the keep operation fails somehow 299 | let tempfile_path = tempfile.path.to_path_buf(); 300 | 301 | match tempfile.path.keep() { 302 | Ok(_result) => Ok(()), 303 | Err(tempfile::PathPersistError { 304 | error: keep_error, .. 305 | }) => match fs::read_to_string(tempfile_path) { 306 | Ok(contents) => { 307 | warning!("Your note could not be saved due to an error. Here are its contents"); 308 | eprintln!("{contents}"); 309 | Ok(()) 310 | } 311 | Err(read_error) => Err(TryPreserveNoteError { 312 | keep_error, 313 | read_error, 314 | }), 315 | }, 316 | } 317 | } 318 | 319 | #[derive(Error, Debug)] 320 | #[error("note was unable to be preserved ({keep_error}), and then could not be read for you ({read_error}).")] 321 | struct TryPreserveNoteError { 322 | // This should VERY RARELY happen. There are failsafes to make this as hard as possible. 323 | // You can see why at its usage, but tl;dr tempfile can fail to keep the file (on windows) 324 | #[source] 325 | keep_error: io::Error, 326 | read_error: io::Error, 327 | } 328 | 329 | fn generate_unclobbered_destination( 330 | path: &Path, 331 | ) -> Result<PathBuf, GenerateUnclobberedDestinationError> { 332 | // These were already both generated from rust strings, so must be UTF-8 333 | let stem = path 334 | .file_stem() 335 | .expect("path is already a full filename") 336 | .to_str() 337 | .expect("filename must be UTF-8"); 338 | 339 | let extension = path 340 | .extension() 341 | .expect("path is already a full filename") 342 | .to_str() 343 | .expect("file extension must be UTF-8"); 344 | 345 | let dir = path.parent().expect("path is already a full path"); 346 | let destination = find_next_destination_basename(dir, stem, extension) 347 | .map(|basename| path.with_file_name(basename))?; 348 | 349 | Ok(destination) 350 | } 351 | 352 | #[derive(Error, Debug)] 353 | #[error("could not generate new filename for note: {0}")] 354 | struct GenerateUnclobberedDestinationError(#[from] FindNextDestinationBasenameError); 355 | 356 | fn find_next_destination_basename( 357 | dir: &Path, 358 | stem: &str, 359 | extension: &str, 360 | ) -> Result<String, FindNextDestinationBasenameError> { 361 | let pattern = Regex::new(&format!( 362 | r"{}-(\d+).{}", 363 | regex::escape(stem), 364 | regex::escape(extension) 365 | )) 366 | .unwrap(); 367 | 368 | let r = fs::read_dir(dir).map_err(FindNextDestinationBasenameError::ReadDirError)?; 369 | let suffix_num = r 370 | .filter_map_ok(|entry| { 371 | let raw_file_name = entry.file_name(); 372 | let file_name = raw_file_name.to_str()?; 373 | let captured_suffix = pattern.captures(file_name).and_then(|captures| { 374 | captures 375 | .iter() 376 | .nth(1) 377 | .expect("pattern must have one capture group") 378 | }); 379 | 380 | captured_suffix.map(|suffix| { 381 | suffix 382 | .as_str() 383 | .parse::<u32>() 384 | .expect("pattern must guarantee we have a number") 385 | }) 386 | }) 387 | .try_fold(0, |acc, n_result| n_result.map(|n| acc.max(n))) 388 | .map(|max| max + 1) 389 | .map_err(FindNextDestinationBasenameError::ReadDirError)?; 390 | 391 | Ok(format!("{stem}-{suffix_num}.{extension}")) 392 | } 393 | 394 | #[derive(Error, Debug)] 395 | enum FindNextDestinationBasenameError { 396 | #[error("could not read directory contents: {0}")] 397 | ReadDirError(io::Error), 398 | } 399 | -------------------------------------------------------------------------------- /tests/index_test.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, FixedOffset, TimeZone}; 2 | use itertools::Itertools; 3 | use quicknotes::{NoteConfig, NoteKind}; 4 | use testutil::{AppendEditor, OverwriteEditor}; 5 | 6 | mod testutil; 7 | 8 | fn test_time() -> DateTime<FixedOffset> { 9 | FixedOffset::east_opt(-7 * 60 * 60) 10 | .unwrap() 11 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 12 | .single() 13 | .unwrap() 14 | } 15 | 16 | #[test] 17 | fn indexes_existing_files_on_disk() { 18 | let roots = testutil::setup_filesystem(); 19 | let cool_note_path = roots 20 | .note_root 21 | .path() 22 | .join("notes") 23 | .join("my-cool-note.txt"); 24 | 25 | std::fs::write( 26 | &cool_note_path, 27 | textwrap::dedent( 28 | r#" 29 | --- 30 | title = "my cool note" 31 | created_at = 2015-10-21T07:28:00-07:00 32 | --- 33 | "# 34 | .trim_start_matches("\n"), 35 | ), 36 | ) 37 | .expect("could not write note"); 38 | 39 | let awesome_note_path = roots 40 | .note_root 41 | .path() 42 | .join("notes") 43 | .join("my-awesome-note.txt"); 44 | 45 | std::fs::write( 46 | &awesome_note_path, 47 | textwrap::dedent( 48 | r#" 49 | --- 50 | title = "my awesome note" 51 | created_at = 2015-10-22T07:28:00-07:00 52 | --- 53 | "# 54 | .trim_start_matches("\n"), 55 | ), 56 | ) 57 | .expect("could not write note"); 58 | 59 | let config = NoteConfig { 60 | file_extension: "txt".to_string(), 61 | root_dir: roots.note_root.path().to_owned(), 62 | temp_root_override: Some(roots.temp_root.path().to_owned()), 63 | }; 64 | 65 | quicknotes::index_notes(&config).expect("could not index notes"); 66 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 67 | 68 | assert_eq!( 69 | notes 70 | .into_iter() 71 | .map(|(path, note)| (path, note.preamble.title)) 72 | .sorted() 73 | .collect::<Vec<_>>(), 74 | vec![ 75 | (awesome_note_path, "my awesome note".to_string()), 76 | (cool_note_path, "my cool note".to_string()) 77 | ] 78 | ) 79 | } 80 | 81 | #[test] 82 | fn deleted_files_are_removed_from_the_index() { 83 | let roots = testutil::setup_filesystem(); 84 | let cool_note_path = roots 85 | .note_root 86 | .path() 87 | .join("notes") 88 | .join("my-cool-note.txt"); 89 | 90 | std::fs::write( 91 | &cool_note_path, 92 | textwrap::dedent( 93 | r#" 94 | --- 95 | title = "my cool note" 96 | created_at = 2015-10-21T07:28:00-07:00 97 | --- 98 | "# 99 | .trim_start_matches("\n"), 100 | ), 101 | ) 102 | .expect("could not write note"); 103 | 104 | let awesome_note_path = roots 105 | .note_root 106 | .path() 107 | .join("notes") 108 | .join("my-awesome-note.txt"); 109 | 110 | std::fs::write( 111 | &awesome_note_path, 112 | textwrap::dedent( 113 | r#" 114 | --- 115 | title = "my awesome note" 116 | created_at = 2015-10-22T07:28:00-07:00 117 | --- 118 | "# 119 | .trim_start_matches("\n"), 120 | ), 121 | ) 122 | .expect("could not write note"); 123 | 124 | let config = NoteConfig { 125 | file_extension: "txt".to_string(), 126 | root_dir: roots.note_root.path().to_owned(), 127 | temp_root_override: Some(roots.temp_root.path().to_owned()), 128 | }; 129 | 130 | quicknotes::index_notes(&config).expect("could not index notes"); 131 | std::fs::remove_file(&awesome_note_path).expect("could not remote note"); 132 | quicknotes::index_notes(&config).expect("could not re-index notes"); 133 | 134 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 135 | 136 | assert_eq!( 137 | notes 138 | .into_iter() 139 | .map(|(path, note)| (path, note.preamble.title)) 140 | .collect::<Vec<_>>(), 141 | vec![(cool_note_path, "my cool note".to_string())] 142 | ) 143 | } 144 | 145 | #[test] 146 | fn notes_are_added_to_the_index_when_they_are_created() { 147 | let roots = testutil::setup_filesystem(); 148 | 149 | let config = NoteConfig { 150 | file_extension: "txt".to_string(), 151 | root_dir: roots.note_root.path().to_owned(), 152 | temp_root_override: Some(roots.temp_root.path().to_owned()), 153 | }; 154 | 155 | let mut editor = AppendEditor::new(); 156 | editor.note_contents("hello, world!\n".to_string()); 157 | 158 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()) 159 | .expect("could not write note"); 160 | 161 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 162 | let cool_note_path = roots.note_root.path().join("notes/my-cool-note.txt"); 163 | 164 | assert_eq!( 165 | notes 166 | .into_iter() 167 | .map(|(path, note)| (path, note.preamble.title)) 168 | .collect::<Vec<_>>(), 169 | vec![(cool_note_path, "my cool note".to_string())] 170 | ) 171 | } 172 | 173 | #[test] 174 | fn opening_a_note_reindexes_it() { 175 | let roots = testutil::setup_filesystem(); 176 | let cool_note_path = roots 177 | .note_root 178 | .path() 179 | .join("notes") 180 | .join("my-cool-note.txt"); 181 | 182 | std::fs::write( 183 | &cool_note_path, 184 | textwrap::dedent( 185 | r#" 186 | --- 187 | title = "my cool note" 188 | created_at = 2015-10-21T07:28:00-07:00 189 | --- 190 | "# 191 | .trim_start_matches("\n"), 192 | ), 193 | ) 194 | .expect("could not write note"); 195 | 196 | let awesome_note_path = roots 197 | .note_root 198 | .path() 199 | .join("notes") 200 | .join("my-awesome-note.txt"); 201 | 202 | std::fs::write( 203 | &awesome_note_path, 204 | textwrap::dedent( 205 | r#" 206 | --- 207 | title = "my awesome note" 208 | created_at = 2015-10-22T07:28:00-07:00 209 | --- 210 | "# 211 | .trim_start_matches("\n"), 212 | ), 213 | ) 214 | .expect("could not write note"); 215 | 216 | let config = NoteConfig { 217 | file_extension: "txt".to_string(), 218 | root_dir: roots.note_root.path().to_owned(), 219 | temp_root_override: Some(roots.temp_root.path().to_owned()), 220 | }; 221 | 222 | quicknotes::index_notes(&config).expect("could not index notes"); 223 | let mut overwrite_editor = OverwriteEditor::new(); 224 | overwrite_editor.note_contents(textwrap::dedent( 225 | r#" 226 | --- 227 | title = "my super awesome note" 228 | created_at = 2015-10-22T07:28:00-07:00 229 | --- 230 | "# 231 | .trim_start_matches("\n"), 232 | )); 233 | 234 | quicknotes::open_note( 235 | &config, 236 | &overwrite_editor, 237 | NoteKind::Note, 238 | &awesome_note_path, 239 | ) 240 | .expect("could not open note for editing"); 241 | 242 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 243 | 244 | assert_eq!( 245 | notes 246 | .into_iter() 247 | .map(|(path, note)| (path, note.preamble.title)) 248 | .sorted() 249 | .collect::<Vec<_>>(), 250 | vec![ 251 | (awesome_note_path, "my super awesome note".to_string()), 252 | (cool_note_path, "my cool note".to_string()), 253 | ] 254 | ) 255 | } 256 | 257 | #[test] 258 | fn editing_a_note_to_have_an_invalid_preamble_removes_it_from_the_index() { 259 | let roots = testutil::setup_filesystem(); 260 | let cool_note_path = roots 261 | .note_root 262 | .path() 263 | .join("notes") 264 | .join("my-cool-note.txt"); 265 | 266 | std::fs::write( 267 | &cool_note_path, 268 | textwrap::dedent( 269 | r#" 270 | --- 271 | title = "my cool note" 272 | created_at = 2015-10-21T07:28:00-07:00 273 | --- 274 | "# 275 | .trim_start_matches("\n"), 276 | ), 277 | ) 278 | .expect("could not write note"); 279 | 280 | let awesome_note_path = roots 281 | .note_root 282 | .path() 283 | .join("notes") 284 | .join("my-awesome-note.txt"); 285 | 286 | std::fs::write( 287 | &awesome_note_path, 288 | textwrap::dedent( 289 | r#" 290 | --- 291 | title = "my awesome note" 292 | created_at = 2015-10-22T07:28:00-07:00 293 | --- 294 | "# 295 | .trim_start_matches("\n"), 296 | ), 297 | ) 298 | .expect("could not write note"); 299 | 300 | let config = NoteConfig { 301 | file_extension: "txt".to_string(), 302 | root_dir: roots.note_root.path().to_owned(), 303 | temp_root_override: Some(roots.temp_root.path().to_owned()), 304 | }; 305 | 306 | quicknotes::index_notes(&config).expect("could not index notes"); 307 | let mut overwrite_editor = OverwriteEditor::new(); 308 | overwrite_editor.note_contents(textwrap::dedent( 309 | r#" 310 | --- 311 | title = "my awesome note" 312 | "# 313 | .trim_start_matches("\n"), 314 | )); 315 | 316 | quicknotes::open_note( 317 | &config, 318 | &overwrite_editor, 319 | NoteKind::Note, 320 | &awesome_note_path, 321 | ) 322 | .expect("could not open note for editing"); 323 | 324 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 325 | 326 | assert_eq!( 327 | notes 328 | .into_iter() 329 | .map(|(path, note)| (path, note.preamble.title)) 330 | .collect::<Vec<_>>(), 331 | vec![(cool_note_path, "my cool note".to_string()),] 332 | ) 333 | } 334 | 335 | #[test] 336 | fn daily_notes_are_marked_with_daily_kind() { 337 | let roots = testutil::setup_filesystem(); 338 | 339 | let config = NoteConfig { 340 | file_extension: "txt".to_string(), 341 | root_dir: roots.note_root.path().to_owned(), 342 | temp_root_override: Some(roots.temp_root.path().to_owned()), 343 | }; 344 | 345 | let mut append_editor = AppendEditor::new(); 346 | append_editor.note_contents("today was a cool day\n".to_string()); 347 | 348 | let datetime = test_time(); 349 | 350 | quicknotes::make_or_open_daily(&config, &append_editor, datetime.date_naive(), &datetime) 351 | .expect("could not open note for editing"); 352 | 353 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 354 | let daily_note_path = roots.note_root.path().join("daily").join("2015-10-21.txt"); 355 | 356 | assert_eq!( 357 | notes 358 | .into_iter() 359 | .map(|(path, note)| (path, note.kind)) 360 | .collect::<Vec<_>>(), 361 | vec![(daily_note_path, NoteKind::Daily),] 362 | ) 363 | } 364 | 365 | #[test] 366 | fn regular_notes_are_marked_with_notes_kind() { 367 | let roots = testutil::setup_filesystem(); 368 | 369 | let config = NoteConfig { 370 | file_extension: "txt".to_string(), 371 | root_dir: roots.note_root.path().to_owned(), 372 | temp_root_override: Some(roots.temp_root.path().to_owned()), 373 | }; 374 | 375 | let mut append_editor = AppendEditor::new(); 376 | append_editor.note_contents("today was a cool day\n".to_string()); 377 | 378 | quicknotes::make_note( 379 | &config, 380 | &append_editor, 381 | "my cool note".to_string(), 382 | &test_time(), 383 | ) 384 | .expect("could not open note for editing"); 385 | 386 | let notes = quicknotes::indexed_notes(&config).expect("could not read indexed notes"); 387 | let daily_note_path = roots 388 | .note_root 389 | .path() 390 | .join("notes") 391 | .join("my-cool-note.txt"); 392 | 393 | assert_eq!( 394 | notes 395 | .into_iter() 396 | .map(|(path, note)| (path, note.kind)) 397 | .collect::<Vec<_>>(), 398 | vec![(daily_note_path, NoteKind::Note),] 399 | ) 400 | } 401 | 402 | #[test] 403 | fn can_lookup_only_one_kind_of_note() { 404 | let roots = testutil::setup_filesystem(); 405 | let cool_note_path = roots 406 | .note_root 407 | .path() 408 | .join("notes") 409 | .join("my-cool-note.txt"); 410 | 411 | std::fs::write( 412 | &cool_note_path, 413 | textwrap::dedent( 414 | r#" 415 | --- 416 | title = "my cool note" 417 | created_at = 2015-10-21T07:28:00-07:00 418 | --- 419 | "# 420 | .trim_start_matches("\n"), 421 | ), 422 | ) 423 | .expect("could not write note"); 424 | 425 | let awesome_note_path = roots 426 | .note_root 427 | .path() 428 | .join("notes") 429 | .join("my-awesome-note.txt"); 430 | 431 | std::fs::write( 432 | &awesome_note_path, 433 | textwrap::dedent( 434 | r#" 435 | --- 436 | title = "my awesome note" 437 | created_at = 2015-10-22T07:28:00-07:00 438 | --- 439 | "# 440 | .trim_start_matches("\n"), 441 | ), 442 | ) 443 | .expect("could not write note"); 444 | 445 | let daily_note_path = roots.note_root.path().join("daily").join("2015-10-21.txt"); 446 | std::fs::write( 447 | &daily_note_path, 448 | textwrap::dedent( 449 | r#" 450 | --- 451 | title = "2015-10-21" 452 | created_at = 2015-10-21T07:28:00-07:00 453 | --- 454 | "# 455 | .trim_start_matches("\n"), 456 | ), 457 | ) 458 | .expect("could not write note"); 459 | 460 | let config = NoteConfig { 461 | file_extension: "txt".to_string(), 462 | root_dir: roots.note_root.path().to_owned(), 463 | temp_root_override: Some(roots.temp_root.path().to_owned()), 464 | }; 465 | 466 | quicknotes::index_notes(&config).expect("could not index notes"); 467 | 468 | let notes = quicknotes::indexed_notes_with_kind(&config, NoteKind::Daily) 469 | .expect("could not read indexed notes"); 470 | 471 | assert_eq!( 472 | notes 473 | .into_iter() 474 | .map(|(path, note)| (path, note.preamble.title)) 475 | .collect::<Vec<_>>(), 476 | vec![(daily_note_path, "2015-10-21".to_string())] 477 | ) 478 | } 479 | -------------------------------------------------------------------------------- /tests/note_test.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, OpenOptions}; 2 | 3 | use chrono::{DateTime, FixedOffset, TimeZone}; 4 | use quicknotes::NoteConfig; 5 | use testutil::{AppendEditor, SwappingEditor}; 6 | 7 | mod testutil; 8 | 9 | fn test_time() -> DateTime<FixedOffset> { 10 | FixedOffset::east_opt(-7 * 60 * 60) 11 | .unwrap() 12 | .with_ymd_and_hms(2015, 10, 21, 7, 28, 0) 13 | .single() 14 | .unwrap() 15 | } 16 | 17 | #[test] 18 | fn writes_notes_to_notes_directory() { 19 | let roots = testutil::setup_filesystem(); 20 | let config = NoteConfig { 21 | file_extension: "txt".to_string(), 22 | root_dir: roots.note_root.path().to_owned(), 23 | temp_root_override: Some(roots.temp_root.path().to_owned()), 24 | }; 25 | 26 | let mut editor = AppendEditor::new(); 27 | editor.note_contents("hello, world!\n".to_string()); 28 | 29 | let stored_path = 30 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()) 31 | .expect("could not write note") 32 | .expect("file has contents, so path should have been returned"); 33 | 34 | let expected_note_path = roots.note_root.path().join("notes/my-cool-note.txt"); 35 | 36 | assert_eq!(stored_path, expected_note_path); 37 | 38 | let note_contents = fs::read_to_string(expected_note_path).expect("failed to open note"); 39 | insta::assert_snapshot!(note_contents); 40 | } 41 | 42 | #[test] 43 | fn writes_dailies_to_notes_directory() { 44 | let roots = testutil::setup_filesystem(); 45 | let config = NoteConfig { 46 | file_extension: "txt".to_string(), 47 | root_dir: roots.note_root.path().to_owned(), 48 | temp_root_override: Some(roots.temp_root.path().to_owned()), 49 | }; 50 | 51 | let mut editor = AppendEditor::new(); 52 | editor.note_contents("today was a cool day\n".to_string()); 53 | let datetime = test_time(); 54 | 55 | let stored_path = 56 | quicknotes::make_or_open_daily(&config, editor, datetime.date_naive(), &datetime) 57 | .expect("could not write note") 58 | .expect("file has contents, so path should have been returned"); 59 | 60 | let expected_note_path = roots.note_root.path().join("daily/2015-10-21.txt"); 61 | 62 | assert_eq!(stored_path, expected_note_path); 63 | let note_contents = fs::read_to_string(expected_note_path).expect("failed to open note"); 64 | 65 | insta::assert_snapshot!(note_contents); 66 | } 67 | 68 | #[test] 69 | fn writes_notes_to_notes_directory_even_if_inode_changes() { 70 | let roots = testutil::setup_filesystem(); 71 | let config = NoteConfig { 72 | file_extension: "txt".to_string(), 73 | root_dir: roots.note_root.path().to_owned(), 74 | temp_root_override: Some(roots.temp_root.path().to_owned()), 75 | }; 76 | 77 | let mut append_editor = AppendEditor::new(); 78 | append_editor.note_contents("hello, world!\n".to_string()); 79 | let editor = SwappingEditor::new(append_editor); 80 | 81 | let stored_path = 82 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()) 83 | .expect("could not write note") 84 | .expect("file has contents, so path should have been returned"); 85 | 86 | let expected_note_path = roots.note_root.path().join("notes/my-cool-note.txt"); 87 | assert_eq!(stored_path, expected_note_path); 88 | 89 | let note_contents = fs::read_to_string(expected_note_path).expect("failed to open note"); 90 | insta::assert_snapshot!(note_contents); 91 | } 92 | 93 | #[test] 94 | fn editing_an_existing_daily_alters_the_same_file() { 95 | let roots = testutil::setup_filesystem(); 96 | let config = NoteConfig { 97 | file_extension: "txt".to_string(), 98 | root_dir: roots.note_root.path().to_owned(), 99 | temp_root_override: Some(roots.temp_root.path().to_owned()), 100 | }; 101 | 102 | let datetime = test_time(); 103 | let mut editor = AppendEditor::new(); 104 | 105 | editor.note_contents("today was a cool day\n".to_string()); 106 | quicknotes::make_or_open_daily(&config, &editor, datetime.date_naive(), &datetime) 107 | .expect("could not write note"); 108 | 109 | editor.note_contents("I have more to say!\n".to_string()); 110 | quicknotes::make_or_open_daily(&config, &editor, datetime.date_naive(), &datetime) 111 | .expect("could not write note"); 112 | 113 | let expected_note_path = roots.note_root.path().join("daily/2015-10-21.txt"); 114 | let note_contents = fs::read_to_string(expected_note_path).expect("failed to open note"); 115 | 116 | insta::assert_snapshot!(note_contents); 117 | } 118 | 119 | #[test] 120 | fn opening_two_notes_with_the_same_name_prevents_clobbering() { 121 | let roots = testutil::setup_filesystem(); 122 | 123 | let config = NoteConfig { 124 | file_extension: "txt".to_string(), 125 | root_dir: roots.note_root.path().to_owned(), 126 | temp_root_override: Some(roots.temp_root.path().to_owned()), 127 | }; 128 | 129 | let mut editor = AppendEditor::new(); 130 | 131 | editor.note_contents("hello, world!\n".to_string()); 132 | let note_path = 133 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()) 134 | .expect("could not write note") 135 | .expect("file has contents, so path should have been returned"); 136 | 137 | let original_note_contents = fs::read_to_string(¬e_path).expect("failed to open note"); 138 | 139 | let mut editor = AppendEditor::new(); 140 | editor.note_contents("oh no\n".to_string()); 141 | let second_note_result = 142 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()); 143 | 144 | let upd_note_path = second_note_result 145 | .expect("failed to write note") 146 | .expect("file has contents, so path should have been returned"); 147 | 148 | let upd_original_location_contents = 149 | fs::read_to_string(¬e_path).expect("failed to open note"); 150 | 151 | assert_eq!( 152 | upd_original_location_contents, original_note_contents, 153 | "original note contents changed" 154 | ); 155 | 156 | let upd_note_contents = fs::read_to_string(&upd_note_path).expect("failed to open note"); 157 | insta::assert_snapshot!(upd_note_contents); 158 | } 159 | 160 | #[test] 161 | fn opening_two_notes_with_the_same_name_prevents_clobbering_even_if_collision_exists_on_disk() { 162 | let roots = testutil::setup_filesystem(); 163 | 164 | let config = NoteConfig { 165 | file_extension: "txt".to_string(), 166 | root_dir: roots.note_root.path().to_owned(), 167 | temp_root_override: Some(roots.temp_root.path().to_owned()), 168 | }; 169 | 170 | let mut editor = AppendEditor::new(); 171 | 172 | editor.note_contents("hello, world!\n".to_string()); 173 | let note_path = 174 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()) 175 | .expect("could not write note") 176 | .expect("file has contents, so path should have been returned"); 177 | 178 | let original_note_contents = fs::read_to_string(¬e_path).expect("failed to open note"); 179 | 180 | // precondition for setting up rest of test 181 | assert_eq!( 182 | note_path 183 | .file_name() 184 | .map(|s| s.to_str().expect("filename is not valid unicode")), 185 | Some("my-cool-note.txt") 186 | ); 187 | 188 | // Create dummy files for the clobber repair to collide with 189 | OpenOptions::new() 190 | .create_new(true) 191 | .write(true) 192 | .open(dbg!(note_path.with_file_name("my-cool-note-1.txt"))) 193 | .expect("could not create dummy note"); 194 | 195 | OpenOptions::new() 196 | .create_new(true) 197 | .write(true) 198 | .open(note_path.with_file_name("my-cool-note-2.txt")) 199 | .expect("could not create dummy note"); 200 | 201 | OpenOptions::new() 202 | .create_new(true) 203 | .write(true) 204 | .open(note_path.with_file_name("my-cool-note-3.txt")) 205 | .expect("could not create dummy note"); 206 | 207 | let mut editor = AppendEditor::new(); 208 | editor.note_contents("oh no\n".to_string()); 209 | let second_note_result = 210 | quicknotes::make_note(&config, editor, "my cool note".to_string(), &test_time()); 211 | 212 | let upd_note_path = second_note_result 213 | .expect("failed to write note") 214 | .expect("file has contents, so path should have been returned"); 215 | 216 | // The new note should not be 1-3 217 | assert_eq!( 218 | upd_note_path, 219 | note_path.with_file_name("my-cool-note-4.txt") 220 | ); 221 | 222 | let upd_original_location_contents = 223 | fs::read_to_string(¬e_path).expect("failed to open note"); 224 | 225 | assert_eq!( 226 | upd_original_location_contents, original_note_contents, 227 | "original note contents changed" 228 | ); 229 | 230 | let upd_note_contents = fs::read_to_string(&upd_note_path).expect("failed to open note"); 231 | insta::assert_snapshot!(upd_note_contents); 232 | } 233 | 234 | #[test] 235 | fn writing_nothing_to_file_results_in_no_file_written() { 236 | let roots = testutil::setup_filesystem(); 237 | let config = NoteConfig { 238 | file_extension: "txt".to_string(), 239 | root_dir: roots.note_root.path().to_owned(), 240 | temp_root_override: Some(roots.temp_root.path().to_owned()), 241 | }; 242 | 243 | let stored_path = quicknotes::make_note( 244 | &config, 245 | AppendEditor::new(), 246 | "my cool note".to_string(), 247 | &test_time(), 248 | ) 249 | .expect("could not write note"); 250 | 251 | assert_eq!(stored_path, None); 252 | 253 | let contents = fs::read_dir(roots.note_root).expect("could not read notes dir"); 254 | assert!(contents.into_iter().next().is_none()); 255 | } 256 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__editing_an_existing_daily_alters_the_same_file.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: note_contents 4 | --- 5 | --- 6 | title = "2015-10-21" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | today was a cool day 11 | I have more to say! 12 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__opening_two_notes_with_the_same_name_prevents_clobbering.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: upd_note_contents 4 | --- 5 | --- 6 | title = "my cool note" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | oh no 11 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__opening_two_notes_with_the_same_name_prevents_clobbering_even_if_collision_exists_on_disk.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: upd_note_contents 4 | --- 5 | --- 6 | title = "my cool note" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | oh no 11 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__writes_dailies_to_notes_directory.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: note_contents 4 | --- 5 | --- 6 | title = "2015-10-21" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | today was a cool day 11 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__writes_notes_to_notes_directory.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: note_contents 4 | --- 5 | --- 6 | title = "my cool note" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | hello, world! 11 | -------------------------------------------------------------------------------- /tests/snapshots/note_test__writes_notes_to_notes_directory_even_if_inode_changes.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/note_test.rs 3 | expression: note_contents 4 | --- 5 | --- 6 | title = "my cool note" 7 | created_at = 2015-10-21T07:28:00-07:00 8 | --- 9 | 10 | hello, world! 11 | -------------------------------------------------------------------------------- /tests/testutil/mod.rs: -------------------------------------------------------------------------------- 1 | // each test file is its own crate, so just because something is used in one place doesn't make it dead 2 | #![allow(dead_code)] 3 | 4 | use std::fs::{self, OpenOptions}; 5 | use std::io::Write; 6 | 7 | use quicknotes::Editor; 8 | use tempfile::{tempdir, NamedTempFile, TempDir}; 9 | 10 | pub struct FilesystemRoots { 11 | pub note_root: TempDir, 12 | pub temp_root: TempDir, 13 | } 14 | 15 | #[derive(Default)] 16 | pub struct AppendEditor { 17 | to_insert: Option<String>, 18 | } 19 | 20 | impl AppendEditor { 21 | pub fn new() -> Self { 22 | Self::default() 23 | } 24 | 25 | pub fn note_contents(&mut self, contents: String) { 26 | self.to_insert = Some(contents); 27 | } 28 | } 29 | 30 | impl Editor for AppendEditor { 31 | fn name(&self) -> &str { 32 | "test_append_editor" 33 | } 34 | 35 | fn edit(&self, path: &std::path::Path) -> std::io::Result<()> { 36 | if let Some(to_insert) = self.to_insert.as_ref() { 37 | let mut file = OpenOptions::new() 38 | .append(true) 39 | .open(path) 40 | .expect("could not open note file for editing"); 41 | 42 | write!(file, "{to_insert}")?; 43 | } 44 | Ok(()) 45 | } 46 | } 47 | 48 | #[derive(Default)] 49 | pub struct OverwriteEditor { 50 | to_insert: Option<String>, 51 | } 52 | 53 | impl OverwriteEditor { 54 | pub fn new() -> Self { 55 | Self::default() 56 | } 57 | 58 | pub fn note_contents(&mut self, contents: String) { 59 | self.to_insert = Some(contents); 60 | } 61 | } 62 | 63 | impl Editor for OverwriteEditor { 64 | fn name(&self) -> &str { 65 | "test_overwrite_editor" 66 | } 67 | 68 | fn edit(&self, path: &std::path::Path) -> std::io::Result<()> { 69 | if let Some(to_insert) = self.to_insert.as_ref() { 70 | let mut file = OpenOptions::new() 71 | .write(true) 72 | .truncate(true) 73 | .open(path) 74 | .expect("could not open note file for editing"); 75 | 76 | write!(file, "{to_insert}")?; 77 | } 78 | 79 | Ok(()) 80 | } 81 | } 82 | 83 | pub struct SwappingEditor<E> { 84 | inner: E, 85 | } 86 | 87 | impl<E: Editor> SwappingEditor<E> { 88 | pub fn new(editor: E) -> Self { 89 | Self { inner: editor } 90 | } 91 | } 92 | 93 | impl<E: Editor> Editor for SwappingEditor<E> { 94 | fn name(&self) -> &str { 95 | "test_swapping_editor" 96 | } 97 | 98 | fn edit(&self, path: &std::path::Path) -> std::io::Result<()> { 99 | // Emulate something like vim, which will move the file into place. This breaks 100 | // implementations that depend on the original tempfiles inode 101 | let swap_path = NamedTempFile::new()?.into_temp_path(); 102 | fs::copy(path, &swap_path)?; 103 | self.inner.edit(&swap_path)?; 104 | fs::rename(&swap_path, path)?; 105 | swap_path.keep()?; 106 | 107 | Ok(()) 108 | } 109 | } 110 | 111 | pub fn setup_filesystem() -> FilesystemRoots { 112 | let note_root = tempdir().expect("could not make temp dir for notes root"); 113 | let temp_root = tempdir().expect("could not make temp dir for temp root"); 114 | 115 | std::fs::create_dir(note_root.path().join("notes")) 116 | .expect("could not make notes dir for testing"); 117 | std::fs::create_dir(note_root.path().join("daily")) 118 | .expect("could not make daily dir for testing"); 119 | 120 | FilesystemRoots { 121 | note_root, 122 | temp_root, 123 | } 124 | } 125 | --------------------------------------------------------------------------------