diff options
-rw-r--r-- | .gitignore | 5 | ||||
-rw-r--r-- | .gitlab-ci.yml | 12 | ||||
-rw-r--r-- | Cargo.lock | 1119 | ||||
-rw-r--r-- | Cargo.toml | 19 | ||||
-rwxr-xr-x | check | 52 | ||||
-rw-r--r-- | debian/cargo-checksum.json | 0 | ||||
-rw-r--r-- | debian/changelog | 6 | ||||
-rw-r--r-- | debian/compat | 2 | ||||
-rw-r--r-- | debian/control | 18 | ||||
-rw-r--r-- | debian/copyright | 23 | ||||
-rwxr-xr-x | debian/rules | 14 | ||||
-rw-r--r-- | debian/source/format | 1 | ||||
-rw-r--r-- | jt.md | 339 | ||||
-rw-r--r-- | jt.subplot | 15 | ||||
-rw-r--r-- | src/bin/jt.rs | 29 | ||||
-rw-r--r-- | src/cmd.rs | 143 | ||||
-rw-r--r-- | src/config.rs | 130 | ||||
-rw-r--r-- | src/error.rs | 116 | ||||
-rw-r--r-- | src/git.rs | 62 | ||||
-rw-r--r-- | src/journal.rs | 258 | ||||
-rw-r--r-- | src/lib.rs | 7 | ||||
-rw-r--r-- | src/opt.rs | 75 | ||||
-rw-r--r-- | src/template.rs | 66 | ||||
-rw-r--r-- | subplot/jt.py | 192 | ||||
-rw-r--r-- | subplot/jt.yaml | 115 |
25 files changed, 2818 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b10284 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +*.pdf +*.html +test.py +test.log diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..42a4224 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,12 @@ +default: + image: rust:latest + +build-job: + stage: build + script: + - curl -s https://gitlab.com/subplot/subplot/-/raw/main/install-debian.sh | bash + - apt-get update + - apt-get install -y black subplot texlive-latex-base texlive-latex-recommended texlive-fonts-recommended plantuml jq + - rustup component add clippy + - rustup component add rustfmt + - if ! ./check; then echo =====================; tail -n 500 test.log; exit 1; fi diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4b4d361 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1119 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd2405b3ac1faab2990b74d728624cd9fd115651fcecc7c2d8daf01376275ba" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2c685bad3eb3d45a01354cedb7d5faa66194d1d58ba6e267a8de788f79db38" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets 0.48.5", +] + +[[package]] +name = "clap" +version = "4.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e54881c004cec7895b0068a0a954cd5d62da01aef83fa35b1e594497bf5445" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cb82d7f531603d2fd1f507441cdd35184fa81beff7bd489570de7f773460bb" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "env_logger" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95b3f3e67048839cb0d0781f445682a35113da7121f7c949db0e2be96a4fbece" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "glob" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" + +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "hermit-abi" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "iana-time-zone" +version = "0.1.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a67363e2aa4443928ce15e57ebae94fd8949958fd1223c4cfc0cd473ad7539" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ignore" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d530e1a18b1cb4c484e6e34556a0d948706958449fca0cab753d649f2bce3d1f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is-terminal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bad00257d07be169d870ab665980b06cdb366d792ad690bf2e76876dc503455" +dependencies = [ + "hermit-abi", + "rustix", + "windows-sys", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1d36f1235bc969acba30b7f5990b864423a6068a10f7c90ae8f0112e3a59d1" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "jt" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "directories-next", + "glob", + "log", + "pretty_env_logger", + "regex", + "serde", + "serde_yaml", + "tera", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.152" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13e3bf6590cbc649f4d1a3eefc9d5d6eb746f5200ffb04e5e142700b8faa56e7" + +[[package]] +name = "libredox" +version = "0.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c833ca1e66078851dba29046874e38f08b2c883700aa29a03ddd3b23814ee8" +dependencies = [ + "bitflags 2.4.1", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4cd1a83af159aa67994778be9070f0ae1bd732942279cabb14f86f986a21456" + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "num-traits" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e3200413f237f41ab11ad6d161bc7239c84dcb631773ccd7de3dfe4b5c267c" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "pest" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f200d8d83c44a45b21764d1916299752ca035d15ecd46faca3e9a2a2bf6ad06" +dependencies = [ + "memchr", + "thiserror", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcd6ab1236bbdb3a49027e920e693192ebfe8913f6d60e294de57463a493cfde" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a31940305ffc96863a735bef7c7994a00b325a7138fdbc5bda0f1a0476d3275" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ff62f5259e53b78d1af898941cdcdccfae7385cf7d793a6e55de5d05bb4b7d" +dependencies = [ + "once_cell", + "pest", + "sha2", +] + +[[package]] +name = "pretty_env_logger" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c" +dependencies = [ + "env_logger", + "log", +] + +[[package]] +name = "proc-macro2" +version = "1.0.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95fc56cda0b5c3325f5fbbd7ff9fda9e02bb00bb3dac51252d2f1bfa1cb8cc8c" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_users" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18479200779601e498ada4e8c1e1f50e3ee19deb0259c25825a98b5603b2cb4" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + +[[package]] +name = "rustix" +version = "0.38.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322394588aaf33c24007e8bb3238ee3e4c5c09c084ab32bc73890b99ff326bca" +dependencies = [ + "bitflags 2.4.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63261df402c67811e9ac6def069e4786148c4563f4b50fd4bf30aa370d626b02" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46fe8f8603d81ba86327b23a2e9cdf49e1255fb94a4c5f297f6ee0547178ea2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "176e46fa42316f18edd598015a5166857fc835ec732f5215eac6b7bdbf0a84f4" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1bf28c79a99f70ee1f1d83d10c875d2e70618417fda01ad1785e027579d9d38" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "globwalk", + "lazy_static", + "pest", + "pest_derive", + "regex", + "serde", + "serde_json", + "unic-segment", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54378c645627613241d077a3a79db965db602882668f9136ac42af9ecb730ad" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa0faa943b50f3db30a20aa7e265dbc66076993efed8463e8de414e5d06d3471" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "ucd-trie" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "walkdir" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1223296a201415c7fad14792dbefaace9bd52b62d33453ade1c5b5f07555406" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcdc935b63408d58a32f8cc9738a0bffd8f05cc7c002086c6ef20b7312ad9dcd" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e4c238561b2d428924c49815533a8b9121c664599558a5d9ec51f8a1740a999" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae1abb6806dc1ad9e560ed242107c0f6c84335f1749dd4e8ddb012ebd5e25a7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d91413b1c31d7539ba5ef2451af3f0b833a005eb27a631cec32bc0635a8602b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +dependencies = [ + "winapi", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ae7c12b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "jt" +version = "0.1.0" +authors = ["Lars Wirzenius <liw@liw.fi>"] +edition = "2018" + +[dependencies] +anyhow = "1.0.79" +chrono = "0.4.31" +clap = { version = "4.4.16", features = ["derive"] } +directories-next = "2.0.0" +glob = "0.3.1" +log = "0.4.20" +pretty_env_logger = "0.5.0" +regex = "1.10.2" +serde = { version = "1.0.195", features = ["derive"] } +serde_yaml = "0.9.30" +tera = { version = "1.19.1", default-features = false } +thiserror = "1.0.56" @@ -0,0 +1,52 @@ +#!/bin/sh + +set -eu + +verbose=false +offline= +if [ "$#" -gt 0 ]; then + case "$1" in + verbose | -v | --verbose) + verbose=true + shift + ;; + --offline) + offline=--offline + shift + ;; + esac +fi + +hideok= + +if command -v chronic >/dev/null; then + hideok=chronic +fi + +if $verbose; then + hideok= +fi + +$hideok cargo check --all-targets $offline +cargo clippy -q $offline +$hideok cargo build --all-targets $offline +$hideok cargo test $offline + +$hideok cargo fmt -- --check +if command -v black >/dev/null; then + $hideok find . -type f -name '*.py' ! -name test.py -exec black --check '{}' + +fi + +$hideok subplot docgen jt.subplot --output jt.html + +$hideok subplot codegen jt.subplot --output test.py +rm -f test.log +target="$( + cargo metadata --format-version=1 | + python3 -c 'import json, sys; o = json.load(sys.stdin); print (o["target_directory"])' +)" +if ! $hideok python3 test.py --log test.log --env "CARGO_TARGET_DIR=$target" "$@"; then + cat test.log +fi + +echo "Everything seems to be in order." diff --git a/debian/cargo-checksum.json b/debian/cargo-checksum.json new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/debian/cargo-checksum.json diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..d1a3b83 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +jt (0.1.0-1) UNRELEASED; urgency=medium + + * Initial packaging. This is not intended to be uploaded to Debian, so + no closing of an ITP bug. + + -- Lars Wirzenius <liw@liw.fi> Sat, 28 Sep 2019 16:45:49 +0300 diff --git a/debian/compat b/debian/compat new file mode 100644 index 0000000..021ea30 --- /dev/null +++ b/debian/compat @@ -0,0 +1,2 @@ +10 + diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..9d0d6c3 --- /dev/null +++ b/debian/control @@ -0,0 +1,18 @@ +Source: jt +Maintainer: Lars Wirzenius <liw@liw.fi> +Section: misc +Priority: optional +Standards-Version: 4.2.0 +Build-Depends: + debhelper (>= 10~), + moreutils, + python3, + subplot +Homepage: https://jt2.liw.fi + +Package: jt +Architecture: any +Depends: ${misc:Depends}, ${shlibs:Depends} +Built-Using: ${cargo:Built-Using} +Description: personal journalling tool + jt adds entries to a personal journal. diff --git a/debian/copyright b/debian/copyright new file mode 100644 index 0000000..9bf2a22 --- /dev/null +++ b/debian/copyright @@ -0,0 +1,23 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ +Upstream-Name: jt +Upstream-Contact: Lars Wirzenius <liw@liw.fi> +Source: http://git.liw.fi/jt2 + +Files: * +Copyright: 2021, Lars Wirzenius, Daniel Silverstone +License: GPL-3+ + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + . + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + . + You should have received a copy of the GNU General Public License + along with this program. If not, see <http://www.gnu.org/licenses/>. + . + On a Debian system, you can find a copy of GPL version 3 at + /usr/share/common-licenses/GPL-3 . diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..6bf3503 --- /dev/null +++ b/debian/rules @@ -0,0 +1,14 @@ +#!/usr/bin/make -f + +%: + dh $@ + +override_dh_auto_build: + true + +override_dh_auto_install: + cargo install --path=. --root=debian/jt --offline + find debian/jt -name '.crates*' -delete + +override_dh_auto_test: + echo disabled tests diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..163aaf8 --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (quilt) @@ -0,0 +1,339 @@ +# Introduction + +The **jt** software (short for "journalling tool") is a helper for +maintaining a journal or personal knowledge base. It has been written +for the personal use of its authors, but might be useful for others. + +The guiding principle for jt is that having longevity for one's data +and complete control over it are crucial. Because of these, the +approach taken by jt is to build a static web site from source files +stored in a version control system. The files will be edited with a +text editor chosen by the journal writer, rather than via a custom +web, desktop, or mobile application. The built journal is then served +with some suitable web server. + +The role of jt is to make it easier to create new journal entries (new +Markdown files), and to make all the common tasks of maintaining a +knowledge base have minimal friction. + +## Example + +The following example creates a new journal, which will be the default +journal for the user, and a new journal entry. The entry is a draft +until it's finished. + +~~~sh +$ jt --dirname ~/Journal init default "My private journal" +$ jt new --tag meta --tag journalling "My first journal entry" +... # text editor is opened so the new entry can be written +$ jt finish 0 first-entry +~~~ + + +# Acceptance criteria and their verification + +This chapter defines detailed acceptance criteria and how they're +verified using *scenarios* for the [Subplot][] tool. + +[Subplot]: https://subplot.liw.fi/ + +## Configuration file handling + +These scenarios verify that jt handles its configuration file +correctly. + +### Shows defaults if no configuration file is present + +~~~scenario +given an installed jt +when I run jt config +then stdout matches regex dirname:.*/\.local.share/jt +then stdout matches regex editor: "/usr/bin/editor" +~~~ + +### Gives error if configuration missing file specified + +~~~scenario +given an installed jt +when I try to run jt --config does-not-exist config +then command fails +then stderr contains "does-not-exist" +~~~ + +### Accepts empty configuration file + +~~~scenario +given an installed jt +given file empty.yaml +when I run jt --config empty.yaml config +then stdout matches regex dirname:.*/\.local.share/jt +then stdout matches regex editor: "/usr/bin/editor" +~~~ + +~~~{#empty.yaml .file .yaml} +{} +~~~ + +### Accepts configuration file + +Note that the configuration file uses a tilde syntax to refer to the +user's home directory. + +~~~scenario +given an installed jt +given file config.yaml +when I run jt --config config.yaml config +then stdout matches regex dirname:.*/.*/journal +then stdout matches regex editor: "emacs" +~~~ + +~~~{#config.yaml .file .yaml} +dirname: ~/journal +editor: emacs +~~~ + +### Command line options override configuration file fields + +~~~scenario +given an installed jt +given file config.yaml +when I run jt --config config.yaml --dirname xxx --editor yyy config +then stdout matches regex dirname: "xxx" +then stdout matches regex editor: "yyy" +~~~ + + + +### Rejects configuration file with extra entries + +~~~scenario +given an installed jt +given file toomuch.yaml +when I try to run jt --config toomuch.yaml config +then command fails +then stderr contains "unknown_field" +~~~ + +~~~{#toomuch.yaml .file .yaml} +unknown_field: foo +~~~ + + +## Create a new local journal repository + +`jt` works on a local repository, and it can be created an initialised +using the tool. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful +and directory jrnl exists +then there are no uncommitted changes in jrnl + +when I run jt --dirname jrnl is-journal +then command is successful + +when I try to run jt --dirname bogus is-journal +then command fails +~~~ + + +## Create a new draft, edit it, then publish it + +Verify that we can create a new draft entry for the journal. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful +and there are no drafts in jrnl +and there are no journal entries in jrnl + +when I run jt --editor=none --dirname=jrnl new "Abracadabra" +then command is successful +and there is one draft in jrnl +and draft 0 in jrnl contains "Abracadabra" +and draft 0 in jrnl contains "!meta date=" + +when I run jt --dirname=jrnl list +then stdout matches regex ^0 Abracadabra$ + +given an executable script append.sh +when I run jt --editor=./append.sh --dirname=jrnl edit 0 +then command is successful +and draft 0 in jrnl contains "Open sesame!" + +when I run jt --dirname=jrnl finish 0 abra +then command is successful +and there is one journal entry in jrnl, at FILE +and file name <FILE> ends with .mdwn +and journal entry <FILE> contains "Abracadabra" +and journal entry <FILE> contains "Open sesame!" +and there are no drafts in jrnl +and there are no uncommitted changes in jrnl +~~~ + +~~~{#append.sh .file .numberLines} +#!/bin/sh +set -eux +echo "Open sesame!" >> "$1" +~~~ + +## Create two drafts + +Verify that we can create two draft entries at the same time. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful +then there are no drafts in jrnl +then there are no journal entries in jrnl + +when I run jt --editor=none --dirname=jrnl new "Abracadabra" +then command is successful +then there is one draft in jrnl +then draft 0 in jrnl contains "Abracadabra" + +when I run jt --editor=none --dirname=jrnl new "Simsalabim" +then command is successful +then there are two drafts in jrnl +then draft 0 in jrnl contains "Abracadabra" +then draft 1 in jrnl contains "Simsalabim" + +given an executable script append.sh +when I run jt --editor=./append.sh --dirname=jrnl edit 0 +then draft 0 in jrnl contains "Open sesame!" +when I run jt --editor=./append.sh --dirname=jrnl edit 1 +then draft 1 in jrnl contains "Open sesame!" + +when I run jt --dirname=jrnl finish 0 abra +then command is successful +then there is one journal entry in jrnl, at FILE +then journal entry <FILE> contains "Abracadabra" +then journal entry <FILE> contains "Open sesame!" +then there is one draft in jrnl + +when I run jt --dirname=jrnl finish 1 sim +then command is successful +then there are two journal entries in jrnl, at FILE1 and FILE2 +then journal entry <FILE1> contains "Abracadabra" +then journal entry <FILE2> contains "Simsalabim" +then there are no drafts in jrnl +then there are no uncommitted changes in jrnl +~~~ + + + + + +## Remove a draft + +Verify that we can remove a draft, and then create a new one. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful +and there are no drafts in jrnl +and there are no journal entries in jrnl + +when I run jt --editor=none --dirname=jrnl new "Hulabaloo" +then command is successful +and there is one draft in jrnl +and draft 0 in jrnl contains "Hulabaloo" +and draft 0 in jrnl contains "!meta date=" + +when I run jt --dirname=jrnl remove 0 +then command is successful +and there are no drafts in jrnl +and there are no journal entries in jrnl + +when I run jt --editor=none --dirname=jrnl new "Abracadabra" +then command is successful +and there is one draft in jrnl +and draft 0 in jrnl contains "Abracadabra" +and draft 0 in jrnl contains "!meta date=" +~~~ + +## Override template for new journal entries + +Verify that we can have a custom template for new journal entries. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful + +given file jrnl/.config/templates/new_entry from new_entry_template + +when I run jt --editor=none --dirname=jrnl new "Abracadabra" +then command is successful +and there is one draft in jrnl +and draft 0 in jrnl contains "custom new entry template" +~~~ + +~~~{#new_entry_template .file .numberLines} +This is a custom new entry template. +~~~ + +## Use topic pages + +Verify that we can create a new topic page and a new entry referring +to that topic page. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful + +when I try to run jt --editor=none --dirname=jrnl new --topic foo.bar "Abracadabra" +then command fails +then stderr contains "foo.bar" + +when I run jt --editor=none --dirname=jrnl new-topic topics/foo.bar "Things about Foobars" +then command is successful +then file jrnl/topics/foo.bar.mdwn contains "Things about Foobars" +then there are no uncommitted changes in jrnl + +when I run jt --editor=none --dirname=jrnl new --topic topics/foo.bar "Abracadabra" +then command is successful +and there is one draft in jrnl +and draft 0 in jrnl links to "topics/foo.bar" +~~~ + +## Allow many topics per post + +Sometimes a post relates to several topics. + +~~~scenario +given an installed jt + +when I run jt --dirname jrnl init default "My test journal" +then command is successful + +when I run jt --editor=none --dirname=jrnl new-topic topics/foo "Foo" +when I run jt --editor=none --dirname=jrnl new-topic topics/bar "Bar" + +when I run jt --editor=none --dirname=jrnl new --topic topics/foo --topic topics/bar "Abracadabra" +then command is successful +then there is one draft in jrnl +then draft 0 in jrnl links to "topics/foo" +then draft 0 in jrnl links to "topics/bar" +~~~ + + +# Colophon + +This document is meant to be processed with the [Subplot][] program to +typeset into HTML or PDF or to generate a program that automatically +verifies that all acceptance criteria are met. + +[Subplot]: https://subplot.liw.fi/ diff --git a/jt.subplot b/jt.subplot new file mode 100644 index 0000000..ea3f1b2 --- /dev/null +++ b/jt.subplot @@ -0,0 +1,15 @@ +title: "jt—a journalling tool" +authors: + - Lars Wirzenius + - Daniel Silverstone +markdowns: +- jt.md +bindings: +- subplot/jt.yaml +- lib/files.yaml +- lib/runcmd.yaml +impls: + python: + - subplot/jt.py + - lib/files.py + - lib/runcmd.py diff --git a/src/bin/jt.rs b/src/bin/jt.rs new file mode 100644 index 0000000..21bf254 --- /dev/null +++ b/src/bin/jt.rs @@ -0,0 +1,29 @@ +use jt::config::Configuration; +use jt::opt::{Opt, SubCommand}; + +use clap::Parser; + +fn main() { + if let Err(err) = do_work() { + eprintln!("ERROR: {:?}", err); + std::process::exit(1); + } +} + +fn do_work() -> anyhow::Result<()> { + pretty_env_logger::init_custom_env("JT_LOG"); + let opt = Opt::parse(); + let config = Configuration::read(&opt)?; + match opt.cmd { + SubCommand::Config(x) => x.run(&config)?, + SubCommand::Init(x) => x.run(&config)?, + SubCommand::IsJournal(x) => x.run(&config)?, + SubCommand::New(x) => x.run(&config)?, + SubCommand::NewTopic(x) => x.run(&config)?, + SubCommand::List(x) => x.run(&config)?, + SubCommand::Edit(x) => x.run(&config)?, + SubCommand::Remove(x) => x.run(&config)?, + SubCommand::Finish(x) => x.run(&config)?, + } + Ok(()) +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..9c96af1 --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,143 @@ +use crate::config::Configuration; +use crate::error::JournalError; +use crate::journal::Journal; +use clap::Parser; +use log::debug; +use std::path::PathBuf; + +#[derive(Debug, Parser)] +pub struct Config {} + +impl Config { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + config.dump(); + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Init { + #[clap(help = "Short name for journal")] + journalname: String, + + #[clap(help = "Short description of journal, its title")] + description: String, +} + +impl Init { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + debug!( + "init: journalname={:?} description={:?}", + self.journalname, self.description + ); + Journal::init(&config.dirname, &config.entries)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct IsJournal {} + +impl IsJournal { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + if !Journal::is_journal(&config.dirname, &config.entries) { + return Err(JournalError::NotAJournal( + config.dirname.display().to_string(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct New { + #[clap(help = "Title of new draft")] + title: String, + + #[clap(long, help = "Add links to topic pages")] + topic: Vec<PathBuf>, +} + +impl New { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.new_draft(&self.title, &self.topic, &config.editor)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct List {} + +impl List { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.list_drafts()?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct NewTopic { + #[clap(help = "Path to topic page in journal")] + path: PathBuf, + + #[clap(help = "Title of topic page")] + title: String, +} + +impl NewTopic { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + journal.new_topic(&self.path, &self.title, &config.editor)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Edit { + /// Draft id. + draft: String, +} + +impl Edit { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.edit_draft(&config.editor, &filename)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Remove { + /// Draft id. + draft: String, +} + +impl Remove { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.remove_draft(&filename)?; + Ok(()) + } +} + +#[derive(Debug, Parser)] +pub struct Finish { + /// Draft id. + draft: String, + + /// Set base name of published file. + basename: String, +} + +impl Finish { + pub fn run(&self, config: &Configuration) -> Result<(), JournalError> { + let journal = Journal::new(&config.dirname, &config.entries)?; + let filename = journal.pick_draft(&self.draft)?; + journal.finish_draft(&filename, &self.basename)?; + Ok(()) + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6995e24 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,130 @@ +//! Configuration file handling. + +use crate::error::JournalError; +use crate::opt::Opt; + +use directories_next::ProjectDirs; +use serde::Deserialize; +use std::default::Default; +use std::path::{Path, PathBuf}; + +const APP: &str = "jt"; + +// The configuration file we read. +// +// Some of the fields are optional in the file. We will use default +// values for those, or get them command line options. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields)] +struct InputConfiguration { + dirname: Option<PathBuf>, + editor: Option<String>, + entries: Option<PathBuf>, +} + +impl InputConfiguration { + fn read(filename: &Path) -> Result<Self, JournalError> { + let text = std::fs::read(filename) + .map_err(|err| JournalError::ReadConfig(filename.to_path_buf(), err))?; + let config = serde_yaml::from_slice(&text) + .map_err(|err| JournalError::ConfigSyntax(filename.to_path_buf(), err))?; + Ok(config) + } +} + +/// The run-time configuration. +/// +/// This is the configuration as read from the configuration file, if +/// any, and with all command line options applied. Nothing here is +/// optional. +#[derive(Debug, Deserialize)] +pub struct Configuration { + /// The directory where the journal is stored. + pub dirname: PathBuf, + + /// The editor to open for editing journal entry drafts. + pub editor: String, + + /// The directory where new entries are put. + /// + /// This is the full path name, not relative to `dirname`. + pub entries: PathBuf, +} + +impl Configuration { + /// Read configuration file. + /// + /// The configuration is read from the file specified by the user + /// on the command line, or from a default location following the + /// XDG base directory specification. Note that only one of those + /// is read. + /// + /// It's OK for the default configuration file to be missing, but + /// if one is specified by the user explicitly, that one MUST + /// exist. + pub fn read(opt: &Opt) -> Result<Self, JournalError> { + let proj_dirs = + ProjectDirs::from("", "", APP).expect("could not figure out home directory"); + let filename = match &opt.global.config { + Some(path) => { + if !path.exists() { + return Err(JournalError::ConfigMissing(path.to_path_buf())); + } + path.to_path_buf() + } + None => proj_dirs.config_dir().to_path_buf().join("config.yaml"), + }; + let input = if filename.exists() { + InputConfiguration::read(&filename)? + } else { + InputConfiguration::default() + }; + + let dirname = if let Some(path) = &opt.global.dirname { + path.to_path_buf() + } else if let Some(path) = &input.dirname { + expand_tilde(path) + } else { + proj_dirs.data_dir().to_path_buf() + }; + + Ok(Self { + dirname: dirname.clone(), + editor: if let Some(name) = &opt.global.editor { + name.to_string() + } else if let Some(name) = &input.editor { + name.to_string() + } else { + "/usr/bin/editor".to_string() + }, + entries: if let Some(entries) = &opt.global.entries { + dirname.join(entries) + } else if let Some(entries) = &input.entries { + dirname.join(entries) + } else { + dirname.join("entries") + }, + }) + } + + /// Write configuration to stdout. + pub fn dump(&self) { + println!("{:#?}", self); + } +} + +fn expand_tilde(path: &Path) -> PathBuf { + if path.starts_with("~/") { + if let Some(home) = std::env::var_os("HOME") { + let mut expanded = PathBuf::from(home); + for comp in path.components().skip(1) { + expanded.push(comp); + } + expanded + } else { + path.to_path_buf() + } + } else { + path.to_path_buf() + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..c86ad13 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,116 @@ +//! Errors returned from functions in this crate. + +use std::path::PathBuf; + +#[derive(Debug, thiserror::Error)] +pub enum JournalError { + /// Configuration file does not exist. + #[error("specified configuration file does not exist: {0}")] + ConfigMissing(PathBuf), + + /// Failed to read the configuration file. + /// + /// This is for permission problems and such. + #[error("failed to read configuration file {0}")] + ReadConfig(PathBuf, #[source] std::io::Error), + + /// Configuration file has a syntax error. + #[error("failed to understand configuration file syntax: {0}")] + ConfigSyntax(PathBuf, #[source] serde_yaml::Error), + + /// The specified directory does not look like a journal. + #[error("directory {0} is not a journal")] + NotAJournal(String), + + /// The specified directory for the journal does not exist. + #[error("journal directory does not exist: {0}")] + NoJournalDirectory(PathBuf), + + /// Failed to create the directory for the journal. + #[error("failed to create journal directory {0}")] + CreateDirectory(PathBuf, #[source] std::io::Error), + + /// Failed to rename entry when finishing it. + #[error("failed to rename journal entry {0} to {1}: {2}")] + RenameEntry(PathBuf, PathBuf, #[source] std::io::Error), + + /// Failed to rename draft. + #[error("failed to remove draft {0}: {1}")] + RemoveDraft(PathBuf, #[source] std::io::Error), + + /// Failed to write entry. + #[error("failed to create journal entry {0}: {1}")] + WriteEntry(PathBuf, #[source] std::io::Error), + + /// Failed to write topic page. + #[error("failed to create topic page {0}: {1}")] + WriteTopic(PathBuf, #[source] std::io::Error), + + /// User chose a draft that doesn't exist. + #[error("No draft {0} in {1}")] + NoSuchDraft(String, PathBuf), + + /// Too many drafts. + #[error("there are already {0} drafts in {1}, can't create more")] + TooManyDrafts(usize, PathBuf), + + /// User chose a topic that doesn't exist. + #[error("No topic page {0}")] + NoSuchTopic(PathBuf), + + /// Failed to read draft. + #[error("failed to drafts {0}: {1}")] + ReadDraft(PathBuf, #[source] std::io::Error), + + /// Draft is not UTF8. + #[error("draft {0} is not UTF8: {1}")] + DraftNotUUtf8(PathBuf, #[source] std::string::FromUtf8Error), + + /// Failed to get metadata for specific file in drafts folder. + #[error("failed to stat draft in {0}: {1}")] + StatDraft(PathBuf, #[source] std::io::Error), + + /// Error spawning git. + #[error("failed to start git in {0}: {1}")] + SpawnGit(PathBuf, std::io::Error), + + /// Git init failed. + #[error("git init failed in {0}: {1}")] + GitInit(PathBuf, String), + + /// Git add failed. + #[error("git add failed in {0}: {1}")] + GitAdd(PathBuf, String), + + /// Git commit failed. + #[error("git commit failed in {0}: {1}")] + GitCommit(PathBuf, String), + + /// Error spawning editor. + #[error("failed to start editor {0}: {1}")] + SpawnEditor(PathBuf, #[source] std::io::Error), + + /// Editor failed. + #[error("editor {0} failed: {1}")] + EditorFailed(PathBuf, String), + + /// Template not found. + #[error("template not found: {0}")] + TemplateNotFound(String), + + /// Failed to render a Tera template. + #[error("template {0} failed to render: {1}")] + TemplateRender(String, #[source] tera::Error), + + /// Failed to make a path relative to a directory. + #[error("failed to make {0} relative to {1}: {2}")] + RelativePath(PathBuf, PathBuf, std::path::StripPrefixError), + + /// Problem with glob pattern. + #[error("Error in glob pattern {0}: {1}")] + PatternError(String, #[source] glob::PatternError), + + /// Problem when matching glob pattern on actual files. + #[error("Failed to match glob pattern {0}: {1}")] + GlobError(String, #[source] glob::GlobError), +} diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..5bb53f0 --- /dev/null +++ b/src/git.rs @@ -0,0 +1,62 @@ +use crate::error::JournalError; +use log::debug; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +pub fn init<P: AsRef<Path>>(dirname: P) -> Result<(), JournalError> { + let dirname = dirname.as_ref(); + debug!("git init {}", dirname.display()); + + let output = Command::new("git") + .arg("init") + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git init exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitInit(dirname.to_path_buf(), stderr)); + } + Ok(()) +} + +pub fn add<P: AsRef<Path>>(dirname: P, files: &[P]) -> Result<(), JournalError> { + let args = &["add", "--"]; + let mut args: Vec<OsString> = args.iter().map(OsString::from).collect(); + for f in files { + args.push(OsString::from(f.as_ref())); + } + + let dirname = dirname.as_ref(); + debug!("git add in {}", dirname.display()); + + let output = Command::new("git") + .args(&args) + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitAdd(dirname.to_path_buf(), stderr)); + } + Ok(()) +} + +pub fn commit<P: AsRef<Path>>(dirname: P, msg: &str) -> Result<(), JournalError> { + let dirname = dirname.as_ref(); + debug!("git commit in {}", dirname.display()); + + let output = Command::new("git") + .args(["commit", "-m", msg]) + .current_dir(dirname) + .output() + .map_err(|err| JournalError::SpawnGit(dirname.to_path_buf(), err))?; + debug!("git exit code was {:?}", output.status.code()); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); + return Err(JournalError::GitCommit(dirname.to_path_buf(), stderr)); + } + Ok(()) +} diff --git a/src/journal.rs b/src/journal.rs new file mode 100644 index 0000000..a0d7cda --- /dev/null +++ b/src/journal.rs @@ -0,0 +1,258 @@ +use crate::error::JournalError; +use crate::git; +use crate::template::Templates; +use chrono::{DateTime, Local}; +use glob::glob; +use regex::Regex; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tera::Context; + +const MAX_DRAFT_COUNT: usize = 1000; + +pub struct Journal { + dirname: PathBuf, + entries: PathBuf, + templates: Templates, +} + +impl Journal { + pub fn is_journal(path: &Path, entries: &Path) -> bool { + is_dir(path) && is_dir(entries) + } + + pub fn init(path: &Path, entries: &Path) -> Result<Self, JournalError> { + std::fs::create_dir(path) + .map_err(|err| JournalError::CreateDirectory(path.to_path_buf(), err))?; + git::init(path)?; + std::fs::create_dir(entries) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + Ok(Self { + dirname: path.to_path_buf(), + entries: entries.to_path_buf(), + templates: Templates::new(path)?, + }) + } + + pub fn new(path: &Path, entries: &Path) -> Result<Self, JournalError> { + if Self::is_journal(path, entries) { + let dirname = path.to_path_buf(); + let entries = entries.to_path_buf(); + let templates = Templates::new(path)?; + Ok(Self { + dirname, + entries, + templates, + }) + } else { + Err(JournalError::NotAJournal(path.display().to_string())) + } + } + + fn dirname(&self) -> &Path { + &self.dirname + } + + fn relative(&self, path: &Path) -> Result<PathBuf, JournalError> { + let path = path.strip_prefix(self.dirname()).map_err(|err| { + JournalError::RelativePath(path.to_path_buf(), self.dirname().to_path_buf(), err) + })?; + Ok(path.to_path_buf()) + } + + fn drafts(&self) -> PathBuf { + self.dirname().join("drafts") + } + + fn entries(&self) -> PathBuf { + self.entries.clone() + } + + pub fn new_draft( + &self, + title: &str, + topics: &[PathBuf], + editor: &str, + ) -> Result<(), JournalError> { + let drafts = self.drafts(); + if !drafts.exists() { + std::fs::create_dir(&drafts) + .map_err(|err| JournalError::CreateDirectory(drafts.to_path_buf(), err))?; + } + + let mut context = Context::new(); + context.insert("title", title); + context.insert("date", ¤t_timestamp()); + let mut full_topics = vec![]; + for topic in topics.iter() { + let pathname = topic_path(self.dirname(), topic); + if !pathname.exists() { + return Err(JournalError::NoSuchTopic(topic.to_path_buf())); + } + full_topics.push(topic.display().to_string()); + } + context.insert("topics", &full_topics); + + let pathname = self.pick_file_id(&drafts)?; + let text = self.templates.new_draft(&context)?; + std::fs::write(&pathname, text) + .map_err(|err| JournalError::WriteEntry(pathname.to_path_buf(), err))?; + self.edit(editor, &pathname)?; + Ok(()) + } + + fn pick_file_id(&self, dirname: &Path) -> Result<PathBuf, JournalError> { + for i in 0..MAX_DRAFT_COUNT { + let basename = format!("{}.md", i); + let pathname = dirname.join(basename); + if !pathname.exists() { + return Ok(pathname); + } + } + Err(JournalError::TooManyDrafts( + MAX_DRAFT_COUNT, + dirname.to_path_buf(), + )) + } + + pub fn pick_draft(&self, id: &str) -> Result<PathBuf, JournalError> { + let drafts = self.drafts(); + let filename = drafts.join(format!("{}.md", id)); + if filename.exists() { + Ok(filename) + } else { + Err(JournalError::NoSuchDraft(id.to_string(), self.drafts())) + } + } + + pub fn list_drafts(&self) -> Result<(), JournalError> { + let prefix = format!("{}/", self.drafts().display()); + let pattern = format!("{}*.md", prefix); + let entries = + glob(&pattern).map_err(|err| JournalError::PatternError(pattern.to_string(), err))?; + for entry in entries { + let entry = entry.map_err(|err| JournalError::GlobError(pattern.to_string(), err))?; + let title = get_title(&entry)?; + let entry = entry.file_stem().unwrap().to_string_lossy(); + let entry = entry.strip_prefix(&prefix).unwrap_or(&entry); + println!("{} {}", entry, title); + } + Ok(()) + } + + fn edit(&self, editor: &str, filename: &Path) -> Result<(), JournalError> { + if editor == "none" { + return Ok(()); + } + match Command::new(editor).arg(filename).output() { + Err(err) => Err(JournalError::SpawnEditor(filename.to_path_buf(), err)), + Ok(output) => { + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(JournalError::EditorFailed( + filename.to_path_buf(), + stderr.into_owned(), + )) + } + } + } + } + + pub fn edit_draft(&self, editor: &str, filename: &Path) -> Result<(), JournalError> { + self.edit(editor, filename)?; + Ok(()) + } + + pub fn remove_draft(&self, filename: &Path) -> Result<(), JournalError> { + std::fs::remove_file(filename) + .map_err(|err| JournalError::RemoveDraft(filename.to_path_buf(), err))?; + Ok(()) + } + + pub fn finish_draft(&self, filename: &Path, basename: &str) -> Result<(), JournalError> { + let entries = self.entries(); + if !entries.exists() { + std::fs::create_dir(&entries) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + } + + let subdir = entries.join(Local::now().format("%Y/%m/%d").to_string()); + std::fs::create_dir_all(&subdir) + .map_err(|err| JournalError::CreateDirectory(entries.to_path_buf(), err))?; + + let basename = PathBuf::from(format!("{}.mdwn", basename)); + let entry = subdir.join(basename); + std::fs::rename(filename, &entry).map_err(|err| { + JournalError::RenameEntry(filename.to_path_buf(), entry.to_path_buf(), err) + })?; + + let entry = self.relative(&entry)?; + git::add(self.dirname(), &[&entry])?; + + let msg = format!("journal entry {}", entry.display()); + git::commit(self.dirname(), &msg)?; + Ok(()) + } + + pub fn new_topic(&self, path: &Path, title: &str, editor: &str) -> Result<(), JournalError> { + let mut context = Context::new(); + context.insert("title", title); + + let dirname = self.dirname(); + if !dirname.exists() { + return Err(JournalError::NoJournalDirectory(dirname.to_path_buf())); + } + + let pathname = topic_path(self.dirname(), path); + let parent = pathname.parent().unwrap(); + if !parent.is_dir() { + std::fs::create_dir_all(parent) + .map_err(|err| JournalError::CreateDirectory(parent.to_path_buf(), err))?; + } + + let text = self.templates.new_topic(&context)?; + std::fs::write(&pathname, text) + .map_err(|err| JournalError::WriteTopic(pathname.to_path_buf(), err))?; + self.edit(editor, &pathname)?; + + let topic = self.relative(&pathname)?; + git::add(self.dirname(), &[&topic])?; + + let msg = format!("new topic {}", topic.display()); + git::commit(self.dirname(), &msg)?; + + Ok(()) + } +} + +fn is_dir(path: &Path) -> bool { + if let Ok(meta) = std::fs::symlink_metadata(path) { + meta.is_dir() + } else { + false + } +} + +fn topic_path(dirname: &Path, topic: &Path) -> PathBuf { + dirname.join(format!("{}.mdwn", topic.display())) +} + +fn current_timestamp() -> String { + let now: DateTime<Local> = Local::now(); + now.to_rfc2822() +} + +fn get_title(filename: &Path) -> Result<String, JournalError> { + let text = std::fs::read(filename) + .map_err(|err| JournalError::ReadDraft(filename.to_path_buf(), err))?; + let text = String::from_utf8_lossy(&text); + let pat = Regex::new(r#"^\[\[!meta title="(?P<title>.*)"\]\]"#).unwrap(); + if let Some(caps) = pat.captures(&text) { + if let Some(m) = caps.name("title") { + return Ok(m.as_str().to_string()); + } + } + Ok("(untitled)".to_string()) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..509e6f1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod cmd; +pub mod config; +pub mod error; +pub mod git; +pub mod journal; +pub mod opt; +pub mod template; diff --git a/src/opt.rs b/src/opt.rs new file mode 100644 index 0000000..5a81ab6 --- /dev/null +++ b/src/opt.rs @@ -0,0 +1,75 @@ +//! Command line options. + +use crate::cmd; +use clap::Parser; +use std::path::PathBuf; + +/// A parsed command line. +#[derive(Debug, Parser)] +#[clap(about = "maintain a journal")] +pub struct Opt { + /// Global options, common for all subcommands. + #[clap(flatten)] + pub global: GlobalOptions, + + /// The subcommand. + #[clap(subcommand)] + pub cmd: SubCommand, +} + +/// Global options. +/// +/// These options are common to all subcommands. +#[derive(Debug, Parser)] +pub struct GlobalOptions { + /// Which configuration file to read. + #[structopt(short, long, help = "Configuration file")] + pub config: Option<PathBuf>, + + /// Which directory to use for the journal. + #[structopt(short, long, help = "Directory where journal should be stored")] + pub dirname: Option<PathBuf>, + + /// Sub-directory in journal where new entries are put. + #[structopt(long)] + pub entries: Option<PathBuf>, + + /// Which editor to invoke for editing journal entry drafts. + #[structopt( + long, + short, + help = "Invoke EDITOR for user to edit draft journal entry" + )] + pub editor: Option<String>, +} + +/// A subcommand. +#[derive(Debug, Parser)] +pub enum SubCommand { + /// Show configuration. + Config(cmd::Config), + + /// Create a new journal in the chosen directory. + Init(cmd::Init), + + /// Check if a directory is a journal. + IsJournal(cmd::IsJournal), + + /// Create draft for a new journal entry. + New(cmd::New), + + /// List current drafts. + List(cmd::List), + + /// Create topic page. + NewTopic(cmd::NewTopic), + + /// Invoke editor on journal entry draft. + Edit(cmd::Edit), + + /// Remove a journal entry draft. + Remove(cmd::Remove), + + /// Finish a journal entry draft. + Finish(cmd::Finish), +} diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..f001a34 --- /dev/null +++ b/src/template.rs @@ -0,0 +1,66 @@ +use crate::error::JournalError; +use std::path::Path; +use tera::{Context, Tera}; + +const NEW_ENTRY: &str = r#"[[!meta title="{{ title }}"]] +[[!meta date="{{ date }}"]] +{% for topic in topics %} +[[!meta link="{{ topic }}"]] +{% endfor %} + +"#; + +const NEW_TOPIC: &str = r#"[[!meta title="{{ title }}"]] + +Describe the topic here. + +# Entries + +[[!inline pages="link(.)" archive=yes reverse=yes trail=yes]] +"#; + +pub struct Templates { + tera: Tera, +} + +impl Templates { + pub fn new(dirname: &Path) -> Result<Self, JournalError> { + let glob = format!("{}/.config/templates/*", dirname.display()); + let mut tera = if let Ok(tera) = Tera::new(&glob) { + tera + } else { + Tera::default() + }; + add_default_template(&mut tera, "new_entry", NEW_ENTRY); + add_default_template(&mut tera, "new_topic", NEW_TOPIC); + Ok(Self { tera }) + } + + pub fn new_draft(&self, context: &Context) -> Result<String, JournalError> { + self.render("new_entry", context) + } + + pub fn new_topic(&self, context: &Context) -> Result<String, JournalError> { + self.render("new_topic", context) + } + + fn render(&self, name: &str, context: &Context) -> Result<String, JournalError> { + match self.tera.render(name, context) { + Ok(s) => Ok(s), + Err(e) => match e.kind { + tera::ErrorKind::TemplateNotFound(x) => Err(JournalError::TemplateNotFound(x)), + _ => Err(JournalError::TemplateRender(name.to_string(), e)), + }, + } + } +} + +fn add_default_template(tera: &mut Tera, name: &str, template: &str) { + let context = Context::new(); + if let Err(err) = tera.render(name, &context) { + if let tera::ErrorKind::TemplateNotFound(_) = err.kind { + tera.add_raw_template(name, template) + .expect("Tera::add_raw_template"); + } + } +} diff --git a/subplot/jt.py b/subplot/jt.py new file mode 100644 index 0000000..416e830 --- /dev/null +++ b/subplot/jt.py @@ -0,0 +1,192 @@ +import logging +import os + + +def install_jt(ctx): + runcmd_prepend_to_path = globals()["runcmd_prepend_to_path"] + runcmd_run = globals()["runcmd_run"] + runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"] + srcdir = globals()["srcdir"] + + target = os.environ.get("CARGO_TARGET_DIR", os.path.join(srcdir, "target")) + bindir = os.path.join(target, "debug") + runcmd_prepend_to_path(ctx, bindir) + + # Configure git. + + runcmd_run(ctx, ["git", "config", "--global", "user.name", "J. Random Hacker"]) + runcmd_exit_code_is_zero(ctx) + + runcmd_run(ctx, ["git", "config", "--global", "user.email", "subplot@example.com"]) + runcmd_exit_code_is_zero(ctx) + + +def create_script(ctx, filename=None): + get_file = globals()["get_file"] + text = get_file(filename) + open(filename, "wb").write(text) + os.chmod(filename, 0o755) + + +def run_jt_init(ctx, dirname=None, journalname=None, title=None): + runcmd_run = globals()["runcmd_run"] + runcmd_run(ctx, [_binary("jt"), "init", dirname, journalname, title]) + + +def run_jt_list_journals(ctx): + runcmd_run = globals()["runcmd_run"] + runcmd_run(ctx, [_binary("jt"), "list-journals"]) + + +def run_jt_is_journal(ctx, dirname=None): + runcmd_run = globals()["runcmd_run"] + runcmd_run(ctx, [_binary("jt"), "is-journal", dirname]) + + +def run_jt_new(ctx, title=None, dirname=None): + runcmd_run = globals()["runcmd_run"] + runcmd_run( + ctx, [_binary("jt"), "new", title, "--dirname", dirname, "--editor=none"] + ) + + +def run_jt_edit(ctx, editor=None, dirname=None): + runcmd_run = globals()["runcmd_run"] + env = dict(os.environ) + env["JT_LOG"] = "jt" + runcmd_run( + ctx, [_binary("jt"), "edit", "--dirname", dirname, "--editor", editor], env=env + ) + + +def run_jt_finish(ctx, dirname=None): + runcmd_run = globals()["runcmd_run"] + runcmd_run(ctx, [_binary("jt"), "finish", "--dirname", dirname]) + + +def _binary(name): + srcdir = globals()["srcdir"] + return os.path.join(srcdir, "target", "debug", "jt") + + +def output_contains(ctx, pattern=None): + assert_eq = globals()["assert_eq"] + logging.debug("checking if %r contains", ctx["stdout"], pattern) + assert_eq(pattern in ctx["stdout"], True) + + +def journal_has_no_drafts(ctx, dirname=None): + _journal_has_n_drafts(ctx, 0, dirname=dirname) + + +def journal_has_one_draft(ctx, dirname=None): + _journal_has_n_drafts(ctx, 1, dirname=dirname) + + +def journal_has_two_drafts(ctx, dirname=None): + _journal_has_n_drafts(ctx, 2, dirname=dirname) + + +def _journal_has_n_drafts(ctx, n, dirname=None): + assert_eq = globals()["assert_eq"] + logging.debug(f"checking {dirname} has {n} drafts") + drafts = os.path.join(dirname, "drafts") + assert_eq(len(_find_files(drafts)), n) + + +def journal_has_no_entries(ctx, dirname=None): + assert_eq = globals()["assert_eq"] + logging.debug(f"checking {dirname} has no entries") + entries = os.path.join(dirname, "entries") + assert_eq(_find_files(entries), []) + + +def journal_has_one_entry(ctx, dirname=None, variable=None): + assert_eq = globals()["assert_eq"] + logging.debug( + f"checking {dirname} has one entry, whose filename is remembered as {variable}" + ) + entries = os.path.join(dirname, "entries") + files = _find_files(entries) + assert_eq(len(files), 1) + variables = ctx.get("variables", {}) + variables[variable] = files[0] + ctx["variables"] = variables + + +def journal_has_two_entries(ctx, dirname=None, variable1=None, variable2=None): + assert_eq = globals()["assert_eq"] + logging.debug(f"checking {dirname} has two entries") + entries = os.path.join(dirname, "entries") + files = list(sorted(_find_files(entries))) + assert_eq(len(files), 2) + variables = ctx.get("variables", {}) + variables[variable1] = files[0] + variables[variable2] = files[1] + ctx["variables"] = variables + + +def _find_files(root): + if not os.path.exists(root): + return [] + + files = [] + for dirname, _, basenames in os.walk(root): + for basename in basenames: + files.append(os.path.join(dirname, basename)) + return files + + +def draft_contains_string(ctx, dirname=None, draftno=None, pattern=None): + logging.debug(f"checking draft {draftno} in {dirname} has contains {pattern!r}") + draft = os.path.join(dirname, "drafts", f"{draftno}.md") + with open(draft) as f: + data = f.read() + logging.debug(f"draft content: {data!r}") + assert pattern in data + + +def draft_links_to_topic(ctx, dirname=None, draftno=None, topic=None): + logging.debug(f"checking draft {draftno} in {dirname} links to {topic!r}") + draft_contains_string( + ctx, dirname=dirname, draftno=draftno, pattern=f'\n[[!meta link="{topic}"]]\n' + ) + + +def edit_draft(ctx, dirname=None, draftno=None, text=None): + logging.debug(f"editing draft {draftno} in {dirname} to also contain {text!r}") + draft = os.path.join(dirname, "drafts", f"{draftno}.md") + with open(draft, "a") as f: + f.write(text) + + +def file_contains(ctx, variable=None, pattern=None): + logging.debug(f"checking {variable} contains {pattern!r}") + + variables = ctx.get("variables", {}) + logging.debug(f"variables: {variables!r}") + + filename = variables[variable] + with open(filename) as f: + data = f.read() + logging.debug(f"file content: {data!r}") + assert pattern in data + + +def file_name_has_suffix(ctx, varname=None, suffix=None): + variables = ctx.get("variables", {}) + filename = variables[varname] + assert filename.endswith(suffix) + + +def git_is_clean(ctx, dirname=None): + runcmd_run = globals()["runcmd_run"] + runcmd_exit_code_is_zero = globals()["runcmd_exit_code_is_zero"] + runcmd_get_stdout = globals()["runcmd_get_stdout"] + assert_eq = globals()["assert_eq"] + + runcmd_run(ctx, ["git", "status", "--porcelain"], cwd=dirname) + runcmd_exit_code_is_zero(ctx) + + stdout = runcmd_get_stdout(ctx) + assert_eq(stdout, "") diff --git a/subplot/jt.yaml b/subplot/jt.yaml new file mode 100644 index 0000000..a121ddf --- /dev/null +++ b/subplot/jt.yaml @@ -0,0 +1,115 @@ +- given: "an installed jt" + impl: + python: + function: install_jt + +- given: "an executable script {filename}" + types: + filename: file + impl: + python: + function: create_script + +- when: I invoke jt init (?P<dirname>\S+) (?P<journalname>\S+) "(?P<title>.*)" + types: + dirname: word + journalname: word + title: text + regex: true + impl: + python: + function: run_jt_init + +- when: I invoke jt list-journals + impl: + python: + function: run_jt_list_journals + +- when: I invoke jt is-journal {dirname} + impl: + python: + function: run_jt_is_journal + +- when: I invoke jt new "{title:text}" --editor=none --dirname={dirname} + impl: + python: + function: run_jt_new + +- when: "I invoke jt edit --editor={editor} --dirname={dirname}" + regex: false + impl: + python: + function: run_jt_edit + +- when: I invoke jt finish --dirname={dirname} + impl: + python: + function: run_jt_finish + +- when: I edit draft {draftno} in {dirname} to also contain "{text}" + impl: + python: + function: edit_draft + +- then: output contains "(?P<pattern>.*)" + types: + pattern: text + regex: true + impl: + python: + function: output_contains + +- then: there are no drafts in {dirname} + impl: + python: + function: journal_has_no_drafts + +- then: there is one draft in {dirname} + impl: + python: + function: journal_has_one_draft + +- then: there are two drafts in {dirname} + impl: + python: + function: journal_has_two_drafts + +- then: draft {draftno} in {dirname} contains "{pattern:text}" + impl: + python: + function: draft_contains_string + +- then: draft {draftno} in {dirname} links to "{topic}" + impl: + python: + function: draft_links_to_topic + +- then: there are no journal entries in {dirname} + impl: + python: + function: journal_has_no_entries + +- then: there is one journal entry in {dirname}, at {variable} + impl: + python: + function: journal_has_one_entry + +- then: there are two journal entries in {dirname}, at {variable1} and {variable2} + impl: + python: + function: journal_has_two_entries + +- then: journal entry <{variable}> contains "{pattern:text}" + impl: + python: + function: file_contains + +- then: file name <{varname}> ends with {suffix} + impl: + python: + function: file_name_has_suffix + +- then: there are no uncommitted changes in {dirname} + impl: + python: + function: git_is_clean |