├── .cargo └── config ├── .gitattributes ├── .gitignore ├── .travis.yml ├── Cargo.lock ├── Cargo.toml ├── Changelog.md ├── Chocolatey ├── .gitignore ├── noteexplorer.nuspec └── tools │ ├── LICENSE.txt │ └── VERIFICATION.txt ├── LICENSE.txt ├── README.md ├── build-choco.ps1 ├── build.sh ├── src ├── ftree.rs ├── lib.rs ├── main.rs ├── mdparse.rs └── note.rs └── testdata ├── 12345678901 Test Note 1.md ├── 20201010101010.md ├── BOM.md ├── BackLinks.md ├── Empty File With Name.md ├── File Name Title.md ├── Links.md ├── Markdown1.md ├── One-liner.md ├── Tasks.md ├── Win-1252.md ├── yaml1.md ├── yaml2.md └── yaml3.md /.cargo/config: -------------------------------------------------------------------------------- 1 | [target.x86_64-pc-windows-msvc] 2 | rustflags = ["-C", "target-feature=+crt-static"] 3 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Default behaviour 2 | * text=auto 3 | 4 | # Tell Git not to mess with newlines in test data 5 | testdata/*.md binary 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .vscode 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: rust 2 | rust: stable 3 | cache: cargo 4 | 5 | os: osx 6 | env: MACOSX_DEPLOYMENT_TARGET=10.7 TARGET=x86_64-apple-darwin 7 | script: 8 | - cargo build --release --target $TARGET --locked 9 | - cargo test --verbose 10 | 11 | before_deploy: 12 | - zip --junk-path noteexplorer-macos-x86-${TRAVIS_TAG}.zip ${TRAVIS_BUILD_DIR}/target/${TARGET}/release/noteexplorer 13 | deploy: 14 | provider: releases 15 | api_key: 16 | - secure: "YWnNxflAfxnCHe4ADZKhgjZCjkCH278GBuPbge/4fKTxp8vyZ7Rt4UAtlnWMGoSCRKY73xIDE3sf/nkCCC9CnaLkGpXMmav6aIKoG6tS8bdjwwzUxU4NoZ+61SI+70eAkR/IBA0u6LX+OgrvCVOng+HC+4Fn89KTlAwTNgdAKKrRSjcJyPVr0RviuDkpv+MZeI70qGtTdF9U6LLGtWuv4m1y1f3NISBNS6T8HGym4nxrrahHQe/pX11Gog/Np3t/n63i1uSUQfgRDX54Aqb9XDJH4RbBeCgUQTBQ/zDs1ir8VenvlpgoF0OKEHMkAlow6dlMzOmIprMY8fZHKQWmQ/mpouWciprKawxZW9TbDSmYkkn8UR5OKSaOwwAwc6L98StwXw5qF3pSAEc582TMDXuvI52l0bFE9bAMKIHGJ1GhdFnp+AoE1XQ0U4hMpKTk3rlGAT0ocuYOfUozY9Go4v/17ln+SSW9vadbL/qj48tSP5SurRk+6pd4vAUZy0LLcWZj4uBwr/kByVZ/VbMI3zvsHBmzL/J1ProuVir3unP2ydVmCnqmnHsoGft33V8CEh+qTjVizMu4OtuZ9ESjd4EI/Wesltyb7ikMIEH2pQe3cD2UPEUOTsM/ZGFhPAFw6jJajKCVJfadzXTi2JqllaRvFhN7kSjLTQJe3EHBLsM=" 17 | file: noteexplorer-macos-x86-${TRAVIS_TAG}.zip 18 | skip_cleanup: true 19 | draft: true 20 | on: 21 | tags: true 22 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "aho-corasick" 7 | version = "0.7.15" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" 10 | dependencies = [ 11 | "memchr", 12 | ] 13 | 14 | [[package]] 15 | name = "ansi_term" 16 | version = "0.11.0" 17 | source = "registry+https://github.com/rust-lang/crates.io-index" 18 | checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" 19 | dependencies = [ 20 | "winapi", 21 | ] 22 | 23 | [[package]] 24 | name = "ansi_term" 25 | version = "0.12.1" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" 28 | dependencies = [ 29 | "winapi", 30 | ] 31 | 32 | [[package]] 33 | name = "atty" 34 | version = "0.2.14" 35 | source = "registry+https://github.com/rust-lang/crates.io-index" 36 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 37 | dependencies = [ 38 | "hermit-abi", 39 | "libc", 40 | "winapi", 41 | ] 42 | 43 | [[package]] 44 | name = "autocfg" 45 | version = "1.0.1" 46 | source = "registry+https://github.com/rust-lang/crates.io-index" 47 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 48 | 49 | [[package]] 50 | name = "bitflags" 51 | version = "1.2.1" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" 54 | 55 | [[package]] 56 | name = "chrono" 57 | version = "0.4.19" 58 | source = "registry+https://github.com/rust-lang/crates.io-index" 59 | checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" 60 | dependencies = [ 61 | "libc", 62 | "num-integer", 63 | "num-traits", 64 | "time", 65 | "winapi", 66 | ] 67 | 68 | [[package]] 69 | name = "clap" 70 | version = "2.33.3" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" 73 | dependencies = [ 74 | "ansi_term 0.11.0", 75 | "atty", 76 | "bitflags", 77 | "strsim", 78 | "textwrap", 79 | "unicode-width", 80 | "vec_map", 81 | ] 82 | 83 | [[package]] 84 | name = "debug_print" 85 | version = "1.0.0" 86 | source = "registry+https://github.com/rust-lang/crates.io-index" 87 | checksum = "8f215f9b7224f49fb73256115331f677d868b34d18b65dbe4db392e6021eea90" 88 | 89 | [[package]] 90 | name = "hermit-abi" 91 | version = "0.1.18" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" 94 | dependencies = [ 95 | "libc", 96 | ] 97 | 98 | [[package]] 99 | name = "lazy_static" 100 | version = "1.4.0" 101 | source = "registry+https://github.com/rust-lang/crates.io-index" 102 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 103 | 104 | [[package]] 105 | name = "libc" 106 | version = "0.2.93" 107 | source = "registry+https://github.com/rust-lang/crates.io-index" 108 | checksum = "9385f66bf6105b241aa65a61cb923ef20efc665cb9f9bb50ac2f0c4b7f378d41" 109 | 110 | [[package]] 111 | name = "memchr" 112 | version = "2.3.4" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" 115 | 116 | [[package]] 117 | name = "noteexplorer" 118 | version = "0.3.0" 119 | dependencies = [ 120 | "ansi_term 0.12.1", 121 | "chrono", 122 | "clap", 123 | "debug_print", 124 | "lazy_static", 125 | "regex", 126 | "rprompt", 127 | "walkdir", 128 | ] 129 | 130 | [[package]] 131 | name = "num-integer" 132 | version = "0.1.44" 133 | source = "registry+https://github.com/rust-lang/crates.io-index" 134 | checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" 135 | dependencies = [ 136 | "autocfg", 137 | "num-traits", 138 | ] 139 | 140 | [[package]] 141 | name = "num-traits" 142 | version = "0.2.14" 143 | source = "registry+https://github.com/rust-lang/crates.io-index" 144 | checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" 145 | dependencies = [ 146 | "autocfg", 147 | ] 148 | 149 | [[package]] 150 | name = "regex" 151 | version = "1.4.5" 152 | source = "registry+https://github.com/rust-lang/crates.io-index" 153 | checksum = "957056ecddbeba1b26965114e191d2e8589ce74db242b6ea25fc4062427a5c19" 154 | dependencies = [ 155 | "aho-corasick", 156 | "memchr", 157 | "regex-syntax", 158 | ] 159 | 160 | [[package]] 161 | name = "regex-syntax" 162 | version = "0.6.23" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "24d5f089152e60f62d28b835fbff2cd2e8dc0baf1ac13343bef92ab7eed84548" 165 | 166 | [[package]] 167 | name = "rprompt" 168 | version = "1.0.5" 169 | source = "registry+https://github.com/rust-lang/crates.io-index" 170 | checksum = "b386f4748bdae2aefc96857f5fda07647f851d089420e577831e2a14b45230f8" 171 | 172 | [[package]] 173 | name = "same-file" 174 | version = "1.0.6" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" 177 | dependencies = [ 178 | "winapi-util", 179 | ] 180 | 181 | [[package]] 182 | name = "strsim" 183 | version = "0.8.0" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" 186 | 187 | [[package]] 188 | name = "textwrap" 189 | version = "0.11.0" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" 192 | dependencies = [ 193 | "unicode-width", 194 | ] 195 | 196 | [[package]] 197 | name = "time" 198 | version = "0.1.44" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" 201 | dependencies = [ 202 | "libc", 203 | "wasi", 204 | "winapi", 205 | ] 206 | 207 | [[package]] 208 | name = "unicode-width" 209 | version = "0.1.8" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" 212 | 213 | [[package]] 214 | name = "vec_map" 215 | version = "0.8.2" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" 218 | 219 | [[package]] 220 | name = "walkdir" 221 | version = "2.3.2" 222 | source = "registry+https://github.com/rust-lang/crates.io-index" 223 | checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" 224 | dependencies = [ 225 | "same-file", 226 | "winapi", 227 | "winapi-util", 228 | ] 229 | 230 | [[package]] 231 | name = "wasi" 232 | version = "0.10.0+wasi-snapshot-preview1" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" 235 | 236 | [[package]] 237 | name = "winapi" 238 | version = "0.3.9" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 241 | dependencies = [ 242 | "winapi-i686-pc-windows-gnu", 243 | "winapi-x86_64-pc-windows-gnu", 244 | ] 245 | 246 | [[package]] 247 | name = "winapi-i686-pc-windows-gnu" 248 | version = "0.4.0" 249 | source = "registry+https://github.com/rust-lang/crates.io-index" 250 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 251 | 252 | [[package]] 253 | name = "winapi-util" 254 | version = "0.1.5" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 257 | dependencies = [ 258 | "winapi", 259 | ] 260 | 261 | [[package]] 262 | name = "winapi-x86_64-pc-windows-gnu" 263 | version = "0.4.0" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 266 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "noteexplorer" 3 | version = "0.3.0" 4 | description = "Helps organizing your stack of linked Markdown notes" 5 | authors = ["Christian Davén "] 6 | edition = "2021" 7 | license = "MIT" 8 | readme = "README.md" 9 | homepage = "https://github.com/cdaven/noteexplorer" 10 | repository = "https://github.com/cdaven/noteexplorer.git" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | ansi_term = "0.12" 16 | chrono = "0.4" 17 | clap = "~2.33.0" 18 | debug_print = "1" 19 | lazy_static = "1.4" 20 | regex = "1" 21 | rprompt = "1.0" 22 | walkdir = "2" 23 | 24 | [profile.release] 25 | lto = true 26 | -------------------------------------------------------------------------------- /Changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## Release 0.3.0 - July 13, 2021 4 | 5 | - Drop support for labels and sections in links (`[[label|filename#section]]`) 6 | - Allow all characters in links, to make all invalid links findable with `list-broken-links` 7 | - Added missing pipe character (`|`) to list of illegal filename characters 8 | - Assume numerical IDs for faster searching 9 | - Match titles and filenames without case sensitivity 10 | 11 | ## Release 0.2.1 - January 31, 2021 12 | 13 | - Fix bug where parser missed links in nested ordered lists 14 | 15 | ## Release 0.2.0 - January 29, 2021 16 | 17 | - New Markdown parser that supports YAML front matter and ignores code blocks 18 | - Searching for note files is much faster 19 | - Removed feature to have backlinks heading include newlines and tabs 20 | - Allow editing notes after backlinks section, and not overwrite it 21 | - The `list-todos` subcommand is renamed `list-tasks` 22 | 23 | ## Release 0.1.1 - January 19, 2021 24 | 25 | A few minor bugfixes: 26 | 27 | - Don't count links to self as incoming/backlinks 28 | - Don't list backlinks more than once per linking page 29 | - When updating filenames, accept case differences 30 | - Allow non-word characters after ID 31 | 32 | ## Release 0.1.0 - January 19, 2021 33 | 34 | The very first release for Windows and Linux. 35 | -------------------------------------------------------------------------------- /Chocolatey/.gitignore: -------------------------------------------------------------------------------- 1 | *.nupkg 2 | tools/*.exe 3 | -------------------------------------------------------------------------------- /Chocolatey/noteexplorer.nuspec: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | noteexplorer 5 | 0.3.0 6 | Christian Davén 7 | NoteExplorer 8 | Christian Davén 9 | https://github.com/cdaven/noteexplorer 10 | https://github.com/cdaven/noteexplorer 11 | https://github.com/cdaven/noteexplorer/tree/main/Chocolatey 12 | https://github.com/cdaven/noteexplorer/issues 13 | https://github.com/cdaven/noteexplorer/blob/main/LICENSE.txt 14 | false 15 | NoteExplorer is a tool to help organizing your stack of (wiki-)linked Markdown notes. 16 | https://github.com/cdaven/noteexplorer/blob/main/Changelog.md 17 | NoteExplorer is a CLI tool to help organizing your stack of (wiki-)linked Markdown notes. 18 | 19 | Features: 20 | 21 | - Adds or updates a "backlinks" section in notes 22 | - Network analysis of links 23 | - Collects tasks scattered in notes 24 | - Reveals broken links 25 | - Updates filenames based on ID and title 26 | 27 | zettelkasten markdown 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /Chocolatey/tools/LICENSE.txt: -------------------------------------------------------------------------------- 1 | From: https://github.com/cdaven/noteexplorer/blob/main/LICENSE.txt 2 | 3 | MIT License 4 | 5 | Copyright (c) 2020-2021 Christian Davén 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /Chocolatey/tools/VERIFICATION.txt: -------------------------------------------------------------------------------- 1 | VERIFICATION 2 | Verification is intended to assist the Chocolatey moderators and community 3 | in verifying that this package's contents are trustworthy. 4 | 5 | This package is published by the software author himself. 6 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020-2021 Christian Davén 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # README 2 | 3 | NoteExplorer is a CLI tool to help organizing your stack of (wiki-)linked Markdown notes. 4 | 5 | Features: 6 | 7 | - Adds or updates a "backlinks" section in notes 8 | - Network analysis of links 9 | - Collects tasks scattered in notes 10 | - Reveals broken links 11 | - Updates filenames based on ID and title 12 | 13 | ## General tips 14 | 15 | When trying out NoteExplorer, please make backups of your notes, in case the tool doesn't work as expected, or you change your mind. 16 | 17 | I keep all my notes in a Git repository, and commit them before trying something new. That way, I can always revert unwanted changes. 18 | 19 | ## Usage 20 | 21 | ``` 22 | noteexplorer.exe [OPTIONS] [PATH] [SUBCOMMAND] 23 | 24 | FLAGS: 25 | -h, --help Prints help information 26 | -V, --version Prints version information 27 | 28 | OPTIONS: 29 | -b, --backlinks-heading Heading to insert before backlinks [default: ...] 30 | -e, --extension File extension of note files [default: md] 31 | -i, --id-format Regular expression pattern for note ID:s [default: \d{14}] 32 | 33 | ARGS: 34 | Path to the note files directory [default: .] 35 | 36 | SUBCOMMANDS: 37 | help Prints this message or the help of the given subcommand(s) 38 | list-broken-links Prints a list of broken links 39 | list-isolated Prints a list of notes with no incoming or outgoing links 40 | list-sinks Prints a list of notes with no outgoing links 41 | list-sources Prints a list of notes with no incoming links 42 | list-tasks Prints a list of tasks 43 | remove-backlinks Removes backlink sections in all notes 44 | update-backlinks Updates backlink sections in all notes 45 | update-filenames Updates note filenames with ID and title 46 | ``` 47 | 48 | ### Options 49 | 50 | #### Extension 51 | 52 | ```sh 53 | --extension "md" 54 | ``` 55 | 56 | Alias: `-e` 57 | 58 | The extension of the files that should be read. 59 | 60 | #### ID Format 61 | 62 | ```sh 63 | --id-format "\d{14}" 64 | ``` 65 | 66 | Alias: `-i` 67 | 68 | A regular expression for finding note ID:s in text and filenames. Read more below. 69 | 70 | #### Backlinks heading 71 | 72 | ```sh 73 | --backlinks-heading "## Links to this note" 74 | ``` 75 | 76 | Alias: `-b` 77 | 78 | The heading that is expected or will be inserted before backlinks in notes. 79 | 80 | Unfortunately, it seems that you cannot use "--" as part of the heading, since the arguments parser will think this is another option. 81 | 82 | Note that in order to change this heading for existing notes, you must first run the subcommand `remove-backlinks` and specify the current heading. Otherwise, you will get multiple backlink sections! 83 | 84 | ### Subcommands 85 | 86 | Note that all subcommands that explore connections between notes ignore links from the backlinks section, since these should not be considered outgoing links. To make sure this works, you have to include the `--backlinks-heading` option for these subcommands as well. 87 | 88 | #### list-broken-links 89 | 90 | Alias: `broken` 91 | 92 | Lists all broken links from all notes. A broken link is one that NoteExplorer cannot resolve. 93 | 94 | #### list-isolated 95 | 96 | Alias: `isolated` 97 | 98 | Lists all isolated notes, meaning notes with no incoming or outgoing links. 99 | 100 | (The term is from graph theory.) 101 | 102 | #### list-sinks 103 | 104 | Alias: `sinks` 105 | 106 | Lists all sink notes, meaning notes with no outgoing links, but at least one incoming link. 107 | 108 | (The term is from graph theory.) 109 | 110 | #### list-sources 111 | 112 | Alias: `sources` 113 | 114 | Lists all source notes, meaning notes with no incoming links, but at least one outgoing link. 115 | 116 | (The term is from graph theory.) 117 | 118 | #### list-tasks 119 | 120 | Alias: `tasks` 121 | 122 | Lists all open tasks from all notes. A task is a list item that starts with `- [ ] `. When you tick the box (`[x]`), the item will not show up in the list anymore. 123 | 124 | To "delete" tasks without removing them from the note, use the regular strikethrough/deleted syntax: `- ~~[ ] Give ring to Frodo~~` 125 | 126 | Note that tasks in numbered lists are not included. 127 | 128 | #### remove-backlinks 129 | 130 | Removes backlinks from all notes, using the heading from the `--backlinks-heading` argument. 131 | 132 | #### update-backlinks 133 | 134 | Alias: `backlinks` 135 | 136 | Updates backlinks in notes, following the heading from the `--backlinks-heading` argument. 137 | 138 | Read more about [Backlink sections in notes](https://github.com/cdaven/noteexplorer/wiki/Backlinks-sections-in-notes) 139 | 140 | #### update-filenames 141 | 142 | Alias: `rename` 143 | 144 | Updates filenames of notes, based on the template `() .<extension>`. So, if the ID is 20210119212027 and the title in the note is "There and back again", the filename will become "20210119212027 There and back again.md". (The extension is set with the `--extension` option.) 145 | 146 | If there is no ID, the filename will be just the title. If there is not title, the filename will be just the ID. Read more about how NoteExplorer picks the [title](https://github.com/cdaven/noteexplorer/wiki/How-NoteExplorer-picks-the-title-of-a-note) and ID of a note. 147 | 148 | Filename backlinks to renamed files are updated to the new filename, so that the links are not broken. 149 | 150 | Some invalid characters will be cleaned from the title before saving as a file, since the operating systems object to them. Read more about this below, in "Filename links". 151 | 152 | Asks for confirmation for each rename, unless you specify the flag `-f` *last* in the command. 153 | 154 | ## Installation 155 | 156 | For now, binaries exist for Windows and Linux, and can be [downloaded from GitHub](https://github.com/cdaven/noteexplorer/releases). 157 | 158 | I suggest you put the executable in the root folder of your notes, and keep a terminal window open. Maybe create some shell scripts with your preferred arguments. 159 | 160 | You can also install the Windows version [with Chocolatey](https://chocolatey.org/packages/noteexplorer/): 161 | 162 | ```sh 163 | choco install noteexplorer 164 | ``` 165 | 166 | ## Limitations/rules 167 | 168 | NoteExplorer is in many ways inspired by [the ideas behind Zettlr](https://docs.zettlr.com/en/academic/zkn-method/), but should work with other note-taking systems that support Markdown wikilinks. 169 | 170 | ### Filename links 171 | 172 | You can link to the target note's filename, like `[[The Hobbit]]`. Note that filename links are case insensitive and the path and file extension is omitted. Your note-taking application is assumed to find the right file anyway. 173 | 174 | From version 0.3.0, illegal filename characters and `[` and `]` are allowed in filename links. This makes it easier to find invalid links with the `list-broken-links` subcommand. 175 | 176 | ### ID links 177 | 178 | The idea behind the Zettelkasten ID is to allow the filename to change without having to update all links pointing to that file. The ID can be included in the filename or the note itself. 179 | 180 | Of course, NoteExplorer must know the format of your IDs to be able to detect them. The default value is 14 digits (in regular expressions speak, this is "\d{14}"), which is a timestamp like 20210119212027. You can specify another format with the `--id-format` option. 181 | 182 | IDs are assumed to be numerical, which also means that they are compared with case sensitivity, which is faster and easier. 183 | 184 | ID links are simple enough: `[[20210119212027]]`. Just the ID, no filename or title. 185 | 186 | ### Specifying the ID of a note 187 | 188 | It's highly recommended to put the note's ID either in the filename, the YAML frontmatter, or at the top of the note. 189 | 190 | You cannot put the ID in indented or fenced code blocks, in nested list items or in the backlinks section. 191 | 192 | If there is a string mathing your ID format in the filename, it is assumed to be the note's ID. Otherwise, the note's contents is scanned for the first matching ID. 193 | 194 | In both cases, the ID must have either a space or nothing in front of it, and a "non-word" character or nothing after it. This makes sure we don't match URLs with `/` and phone numbers with `+` before long numbers. 195 | 196 | Note that if you specify the note's ID as a link: `[[20210119212027]]`, it will be considered a link to another note, and not the note's own ID. 197 | 198 | ### Parsing note titles 199 | 200 | [How NoteExplorer picks the title of a note](https://github.com/cdaven/noteexplorer/wiki/How-NoteExplorer-picks-the-title-of-a-note) 201 | 202 | ### Traversing your note collection 203 | 204 | The `PATH` given to NoteExplorer is the root directory. All subdirectories will be traversed, looking for notes. However, all files and directories that begin with a dot (`.`) are ignored, since they are by tradition hidden in Linux and Mac OS. 205 | 206 | If two or more notes use the same ID, you will get a warning. 207 | 208 | Yet another limitation: NoteExplorer can only read files in UTF-8, with or without byte order mark (BOM). 209 | 210 | ### Limitations to the Markdown parser 211 | 212 | The Markdown parser is simple, and will not honor HTML comments. 213 | 214 | If you use styling in the headings (e.g. **bold** or _italic_), it will not be stripped. 215 | 216 | ## Background 217 | 218 | This tool was inspired by [Andy Matuschak's note-link-janitor](https://github.com/andymatuschak/note-link-janitor/). 219 | 220 | Read more in [Why is NoteExplorer written in Rust?](https://github.com/cdaven/noteexplorer/wiki/Why-is-NoteExplorer-written-in-Rust%3F) 221 | 222 | ## Developing and building 223 | 224 | NoteExplorer is written in Rust, and can be built on (at least) Windows, Mac OS and Linux. 225 | 226 | First, [install Rust](https://www.rust-lang.org/tools/install) and all its dependencies. 227 | 228 | Then you can build NoteExplorer by simply running `cargo build` or `cargo build --release`. 229 | 230 | - Run unit tests with `cargo test`. 231 | - Lint code with `cargo clippy`. 232 | - Profile code with `cargo profiler callgrind`. 233 | -------------------------------------------------------------------------------- /build-choco.ps1: -------------------------------------------------------------------------------- 1 | param([String]$version="0.0.0") 2 | 3 | (Get-Content .\Cargo.toml) -Replace "version = ""[^""]+""", "version = ""$version""" | Set-Content .\Cargo.toml 4 | (Get-Content .\Chocolatey\noteexplorer.nuspec) -Replace "<version>[^<]+</version>", "<version>$version</version>" | Set-Content .\Chocolatey\noteexplorer.nuspec 5 | 6 | $target="x86_64-pc-windows-msvc" 7 | 8 | cargo build --release --offline --target=$target 9 | zip --junk-path R:\noteexplorer-win-x64-${version}.zip .\target\$target\release\noteexplorer.exe 10 | 11 | Copy-Item .\target\$target\release\noteexplorer.exe .\Chocolatey\tools\ 12 | Set-Location .\Chocolatey 13 | choco pack 14 | Set-Location .. 15 | 16 | "" 17 | "Version set to $version" 18 | "When ready, publish package:" 19 | "choco push .\Chocolatey\noteexplorer.$version.nupkg --source https://push.chocolatey.org/" 20 | "Remember to update Changelog.md and commit" 21 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DEFAULTVERSION="0.0.0" 4 | VERSION="${1:-$DEFAULTVERSION}" 5 | TARGET=x86_64-unknown-linux-musl 6 | 7 | rustup target add ${TARGET} 8 | cargo build --release --target=${TARGET} --locked 9 | zip --junk-path /mnt/r/noteexplorer-linux-x64-${VERSION}.zip target/${TARGET}/release/noteexplorer 10 | -------------------------------------------------------------------------------- /src/ftree.rs: -------------------------------------------------------------------------------- 1 | use ansi_term::Colour; 2 | use std::path; 3 | use walkdir::{DirEntry, WalkDir}; 4 | 5 | pub fn get_files(root: &path::Path, ext: &str) -> Vec<path::PathBuf> { 6 | let mut files = Vec::new(); 7 | 8 | if root.is_dir() { 9 | let walker = WalkDir::new(root).into_iter(); 10 | for entry in walker.filter_entry(|e| !is_hidden(e)) { 11 | let entry = match entry { 12 | Ok(e) => e, 13 | Err(err) => { 14 | let path = err.path().unwrap_or_else(|| path::Path::new("")).display(); 15 | eprintln!( 16 | "{} Couldn't access {}", 17 | Colour::Yellow.paint("Warning:"), 18 | path 19 | ); 20 | continue; 21 | } 22 | }; 23 | 24 | let path = entry.into_path(); 25 | if ext == path.extension().unwrap_or_default().to_str().unwrap() { 26 | files.push(path); 27 | } 28 | } 29 | } 30 | 31 | files 32 | } 33 | 34 | fn is_hidden(entry: &DirEntry) -> bool { 35 | entry 36 | .file_name() 37 | .to_str() 38 | .map(|s| s.starts_with('.')) 39 | .unwrap_or(false) 40 | } 41 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | mod ftree; 2 | mod mdparse; 3 | mod note; 4 | 5 | use chrono::Utc; 6 | use debug_print::debug_println; 7 | use note::{NoteCollection, NoteMeta}; 8 | use std::error::Error; 9 | use std::fs; 10 | 11 | #[derive(Debug)] 12 | pub struct Config { 13 | pub id_pattern: String, 14 | pub backlinks_heading: String, 15 | pub extension: String, 16 | pub path: String, 17 | pub command: String, 18 | pub force: bool, 19 | } 20 | 21 | pub fn run(config: Config) -> Result<(), Box<dyn Error>> { 22 | let start_time = Utc::now(); 23 | let notes = NoteCollection::collect_files( 24 | &fs::canonicalize(&config.path)?, 25 | &config.extension, 26 | mdparse::NoteParser::new(&config.id_pattern, &config.backlinks_heading)?, 27 | ); 28 | let duration_collect_files = Utc::now() - start_time; 29 | 30 | let start_time = Utc::now(); 31 | match config.command.as_str() { 32 | "list-broken-links" => print_broken_links(¬es), 33 | "list-sources" => print_sources(¬es), 34 | "list-sinks" => print_sinks(¬es), 35 | "list-isolated" => print_isolated(¬es), 36 | "list-tasks" => print_tasks(¬es), 37 | "remove-backlinks" => remove_backlinks(¬es), 38 | "update-backlinks" => update_backlinks(¬es), 39 | "update-filenames" => update_filenames(¬es, config.force)?, 40 | _ => print_stats(¬es), 41 | } 42 | let duration_subcommand = Utc::now() - start_time; 43 | 44 | debug_println!( 45 | "NoteCollection::collect_files() took {} ms", 46 | duration_collect_files.num_milliseconds() 47 | ); 48 | debug_println!( 49 | "Subcommand {} took {} ms", 50 | &config.command, 51 | duration_subcommand.num_milliseconds() 52 | ); 53 | 54 | Ok(()) 55 | } 56 | 57 | fn print_stats(note_collection: &NoteCollection) { 58 | println!("# Statistics\n"); 59 | 60 | println!("- Notes in collection: {}", note_collection.count()); 61 | println!("- Notes with ID: {}", note_collection.count_with_id()); 62 | println!("- Wikilinks: {}", note_collection.count_links()); 63 | } 64 | 65 | fn print_tasks(note_collection: &NoteCollection) { 66 | let tasks = note_collection.get_tasks(); 67 | let num_tasks: usize = tasks.iter().map(|(_, t)| t.len()).sum(); 68 | println!("# Tasks\n"); 69 | println!("There are {} tasks in your notes", num_tasks); 70 | 71 | for (note, note_tasks) in tasks { 72 | println!("\n## {}\n", note.get_wikilink_to()); 73 | 74 | for task in note_tasks { 75 | println!("- [ ] {}", task); 76 | } 77 | } 78 | } 79 | 80 | fn print_sources(note_collection: &NoteCollection) { 81 | let notes = note_collection.get_sources(); 82 | 83 | println!("# Source notes\n"); 84 | println!( 85 | "{} notes have no incoming links, but at least one outgoing link\n", 86 | notes.len() 87 | ); 88 | print_note_wikilink_list(¬es); 89 | } 90 | 91 | fn print_sinks(note_collection: &NoteCollection) { 92 | let notes = note_collection.get_sinks(); 93 | 94 | println!("# Sink notes\n"); 95 | println!( 96 | "{} notes have no outgoing links, but at least one incoming link\n", 97 | notes.len() 98 | ); 99 | print_note_wikilink_list(¬es); 100 | } 101 | 102 | fn print_isolated(note_collection: &NoteCollection) { 103 | let notes = note_collection.get_isolated(); 104 | 105 | println!("# Isolated notes\n"); 106 | println!("{} notes have no incoming or outgoing links\n", notes.len()); 107 | print_note_wikilink_list(¬es); 108 | } 109 | 110 | fn print_note_wikilink_list(notes: &[NoteMeta]) { 111 | for note in notes { 112 | println!("- {}", note.get_wikilink_to()); 113 | } 114 | } 115 | 116 | fn print_broken_links(note_collection: &NoteCollection) { 117 | let broken_links = note_collection.get_broken_links(); 118 | if broken_links.len() == 0 { 119 | println!("No broken links found"); 120 | } else { 121 | println!("# Broken links\n"); 122 | 123 | for (link, notes) in broken_links { 124 | let linkers: Vec<String> = notes.iter().map(|n| n.get_wikilink_to()).collect(); 125 | println!("- \"{}\" links to unknown {}", linkers.join(" and "), link); 126 | } 127 | } 128 | } 129 | 130 | fn remove_backlinks(note_collection: &NoteCollection) { 131 | let removed = note_collection.remove_backlinks(); 132 | println!("Removed backlinks section from {} notes", removed.len()); 133 | } 134 | 135 | fn update_backlinks(note_collection: &NoteCollection) { 136 | let updated = note_collection.update_backlinks(); 137 | println!("Updated backlinks section in {} notes", updated.len()); 138 | 139 | for note in updated { 140 | println!("- {}", note.get_wikilink_to()); 141 | } 142 | } 143 | 144 | fn update_filenames(note_collection: &NoteCollection, force: bool) -> Result<(), Box<dyn Error>> { 145 | let mut affected_backlinks = false; 146 | for (note, new_stem) in note_collection.get_mismatched_filenames() { 147 | let original_filename = format!("{}.{}", note.stem, note.extension); 148 | let new_filename = format!("{}.{}", new_stem, note.extension); 149 | let reply = if force { 150 | "y".to_owned() 151 | } else { 152 | rprompt::prompt_reply_stdout(&format!( 153 | "Rename \"{}\" to \"{}\"? ([y]/n) ", 154 | original_filename, new_filename 155 | ))? 156 | }; 157 | 158 | if reply == "y" || reply.is_empty() { 159 | if note.path.ends_with(&original_filename) { 160 | // If note has links to other notes and NOT an ID, this means that there 161 | // are backlinks in other notes that are now linking to the old filename 162 | affected_backlinks = affected_backlinks || (note.has_links && note.id.is_none()); 163 | 164 | let updated_notes = note_collection.rename_note(¬e, &new_stem)?; 165 | if updated_notes.len() > 0 { 166 | for n in updated_notes { 167 | println!("- Updated link from {}", n.get_wikilink_to()); 168 | } 169 | } 170 | } else { 171 | // TODO: Return as Err 172 | eprintln!("Error: probably a bug in how the file name path is determined"); 173 | } 174 | } 175 | } 176 | 177 | if affected_backlinks { 178 | println!("You should probably update-backlinks now"); 179 | } 180 | 181 | Ok(()) 182 | } 183 | 184 | #[cfg(test)] 185 | mod tests { 186 | use crate::*; 187 | use std::env::temp_dir; 188 | use std::io::Write; 189 | use std::path::PathBuf; 190 | use std::{fs, io}; 191 | 192 | /// Create directory, removing it first if it exists, 193 | /// together with all files and subdirectories. Careful! 194 | fn create_dir(dir: &PathBuf) -> io::Result<()> { 195 | if dir.exists() { 196 | fs::remove_dir_all(dir)?; 197 | } 198 | 199 | fs::create_dir(&dir)?; 200 | 201 | Ok(()) 202 | } 203 | 204 | fn write_to_tmp_file(dir: &mut PathBuf, filename: &str, contents: &str) -> io::Result<()> { 205 | dir.push(filename); 206 | let mut file = fs::File::create(dir)?; 207 | file.write_all(contents.as_bytes())?; 208 | Ok(()) 209 | } 210 | 211 | #[test] 212 | fn rename_file() { 213 | let mut dir = temp_dir(); 214 | dir.push("noteexplorer-test-rename"); 215 | create_dir(&dir).unwrap(); 216 | 217 | write_to_tmp_file(&mut dir.clone(), "noteexplorer-test-rename-1.md", "# Rename This 1\r\nHere is a link to another file: [[Noteexplorer-test-rename-2]]. And some text after. [[Backlink-File]]").unwrap(); 218 | write_to_tmp_file(&mut dir.clone(), "noteexplorer-test-rename-2.md", "# Rename Then 2\r\nHere is a link to another file: [[noteexplorer-TEST-rename-1]]. And some text after. [[Backlink-File]]").unwrap(); 219 | write_to_tmp_file(&mut dir.clone(), "noteexplorer-test-rename-3.md", "# Rename That 3\r\nHere are the links: [[noteexplorer-test-rename-1]] and [[noteexplorer-test-rename-2]]. And some text after").unwrap(); 220 | write_to_tmp_file( 221 | &mut dir.clone(), 222 | "backlink-file.md", 223 | "## Backlinks\n\n- [[noteexplorer-TEST-rename-1]]\n\n- [[noteexplorer-TEST-rename-2]]", 224 | ) 225 | .unwrap(); 226 | 227 | let notes_before = NoteCollection::collect_files( 228 | &dir, 229 | &"md", 230 | crate::mdparse::NoteParser::new(&r"\d{14}", &"## Backlinks").unwrap(), 231 | ); 232 | 233 | // No extra notes should be found 234 | assert_eq!(notes_before.count(), 4); 235 | // No broken links in the test data 236 | assert_eq!(notes_before.get_broken_links().len(), 0); 237 | 238 | update_filenames(¬es_before, true).unwrap(); 239 | 240 | let notes_after = NoteCollection::collect_files( 241 | &dir, 242 | &"md", 243 | crate::mdparse::NoteParser::new(&r"\d{14}", &"## Backlinks").unwrap(), 244 | ); 245 | 246 | for note in notes_after.into_meta_vec() { 247 | match note.title.as_str() { 248 | "Rename This 1" => { 249 | assert_eq!(note.stem, "Rename This 1"); 250 | } 251 | "Rename Then 2" => { 252 | assert_eq!(note.stem, "Rename Then 2"); 253 | } 254 | "Rename That 3" => { 255 | assert_eq!(note.stem, "Rename That 3"); 256 | } 257 | "backlink-file" => { 258 | assert_eq!(note.stem, "backlink-file"); 259 | } 260 | _ => { 261 | panic!("Unrecognized note title"); 262 | } 263 | }; 264 | } 265 | 266 | assert_eq!(notes_after.count(), 4); 267 | assert_eq!(notes_after.get_broken_links().len(), 0); 268 | assert_eq!(notes_after.get_isolated().len(), 0); 269 | assert_eq!(notes_after.get_sources().len(), 1); 270 | assert_eq!(notes_after.get_sinks().len(), 1); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use clap::{crate_version, App, Arg, SubCommand}; 3 | use debug_print::debug_println; 4 | use noteexplorer::{run, Config}; 5 | use std::process; 6 | 7 | fn main() { 8 | let matches = App::new("NoteExplorer") 9 | .version(crate_version!()) 10 | .author("Christian Davén <christian@daven.se>") 11 | .about("Helps organizing your stack of linked Markdown notes") 12 | .arg( 13 | Arg::with_name("extension") 14 | .short("e") 15 | .long("extension") 16 | .help("File extension of note files") 17 | .takes_value(true) 18 | .value_name("ext") 19 | .default_value("md"), 20 | ) 21 | .arg( 22 | Arg::with_name("id_format") 23 | .short("i") 24 | .long("id-format") 25 | .help("Regular expression pattern for note ID:s") 26 | .takes_value(true) 27 | .value_name("format") 28 | .default_value("\\d{14}"), 29 | ) 30 | .arg( 31 | Arg::with_name("backlinks_heading") 32 | .short("b") 33 | .long("backlinks-heading") 34 | .help("Heading to insert before backlinks") 35 | .takes_value(true) 36 | .value_name("format") 37 | .default_value("## Links to this note"), 38 | ) 39 | .arg( 40 | Arg::with_name("PATH") 41 | .help("Path to the note files directory") 42 | .default_value(".") 43 | .index(1), 44 | ) 45 | .subcommand( 46 | SubCommand::with_name("list-broken-links") 47 | .alias("brokenlinks") 48 | .about("Prints a list of broken links"), 49 | ) 50 | .subcommand( 51 | SubCommand::with_name("list-isolated") 52 | .alias("isolated") 53 | .about("Prints a list of notes with no incoming or outgoing links"), 54 | ) 55 | .subcommand( 56 | SubCommand::with_name("list-sinks") 57 | .alias("sinks") 58 | .about("Prints a list of notes with no outgoing links"), 59 | ) 60 | .subcommand( 61 | SubCommand::with_name("list-sources") 62 | .alias("sources") 63 | .about("Prints a list of notes with no incoming links"), 64 | ) 65 | .subcommand( 66 | SubCommand::with_name("list-tasks") 67 | .aliases(&["tasks", "todos"]) 68 | .about("Prints a list of tasks"), 69 | ) 70 | .subcommand( 71 | SubCommand::with_name("update-backlinks") 72 | .alias("backlinks") 73 | .about("Updates backlink sections in all notes"), 74 | ) 75 | .subcommand( 76 | SubCommand::with_name("remove-backlinks") 77 | .about("Removes backlink sections in all notes"), 78 | ) 79 | .subcommand( 80 | SubCommand::with_name("update-filenames") 81 | .alias("rename") 82 | .about("Updates note filenames with ID and title") 83 | .arg( 84 | Arg::with_name("force") 85 | .short("f") 86 | .help("Always update names, never prompt"), 87 | ), 88 | ) 89 | .get_matches(); 90 | 91 | let command = matches.subcommand_name().unwrap_or_default(); 92 | let mut force = false; 93 | if let Some(c) = matches.subcommand_matches("update-filenames") { 94 | force = c.is_present("force"); 95 | } 96 | 97 | let config = Config { 98 | extension: matches.value_of("extension").unwrap().to_string(), 99 | id_pattern: matches.value_of("id_format").unwrap().to_string(), 100 | backlinks_heading: matches.value_of("backlinks_heading").unwrap().to_string(), 101 | path: matches.value_of("PATH").unwrap().to_string(), 102 | command: command.to_string(), 103 | force 104 | }; 105 | 106 | let start_time = Utc::now(); 107 | if let Err(e) = run(config) { 108 | eprintln!("Application error: {}", e); 109 | process::exit(1); 110 | } 111 | let duration = Utc::now() - start_time; 112 | debug_println!("run() took {} ms", duration.num_milliseconds()); 113 | } 114 | -------------------------------------------------------------------------------- /src/mdparse.rs: -------------------------------------------------------------------------------- 1 | use crate::note::WikiLink; 2 | use lazy_static::*; 3 | use regex::Regex; 4 | use std::borrow::Cow; 5 | 6 | lazy_static! { 7 | static ref YAML_TITLE_EXPR: Regex = 8 | Regex::new(r#"\A\s*['"]?title['"]?\s*: \s*['"]?([^'"]+)['"]?\z"#).unwrap(); 9 | 10 | static ref LINK_CHARS: &'static str = "[^<>:*?|/\\]\\[\"\\\\\\t]"; 11 | static ref WIKILINK_SIMPLE_EXPR: Regex = Regex::new( 12 | &"\\[\\[(.+?)\\]\\]" 13 | .replace("{:link_chars:}", *LINK_CHARS) 14 | ) 15 | .unwrap(); 16 | static ref TASK_EXPR: Regex = Regex::new(r"\A\s*[-+*]\s+\[ \]\s+(.+?)\z").unwrap(); 17 | static ref BACKLINK_EXPR: Regex = Regex::new(r"\A[-+*]\s*(.*?)\z").unwrap(); 18 | static ref INDENTED_LIST_EXPR: Regex = Regex::new(r"\A\s+([-+*]|\d+\.)\s.+\z").unwrap(); 19 | 20 | /// Characters that can be escaped in Markdown 21 | static ref ESCAPED_CHARS_EXPR: Regex = Regex::new(r"\\([\\`\*_{}\[\]<>()#+-\.!|])").unwrap(); 22 | 23 | // Two ways to start and end code blocks 24 | static ref CODEBLOCK_TOKEN_1: &'static str = "```"; 25 | static ref CODEBLOCK_TOKEN_2: &'static str = "~~~"; 26 | } 27 | 28 | #[derive(Debug)] 29 | enum ParseState<'a> { 30 | Initial, 31 | Yaml, 32 | Regular, 33 | CodeBlock(&'a str), 34 | BackLinks, 35 | } 36 | 37 | #[derive(Debug)] 38 | pub struct NoteData { 39 | pub titles: Vec<String>, 40 | pub ids: Vec<String>, 41 | pub links: Vec<WikiLink>, 42 | pub tasks: Vec<String>, 43 | pub backlinks_start: Option<usize>, 44 | pub backlinks_end: Option<usize>, 45 | } 46 | 47 | #[derive(Debug)] 48 | pub struct NoteParser { 49 | id_pattern: String, 50 | id_expr: Regex, 51 | pub backlinks_heading: String, 52 | } 53 | 54 | impl NoteParser { 55 | pub fn new(id_pattern: &str, backlinks_heading: &str) -> Result<NoteParser, &'static str> { 56 | let id_expr_str = format!(r"(?:\A|\s)({})(?:\z|\b)", &id_pattern); 57 | let id_expr = match Regex::new(&id_expr_str) { 58 | Ok(expr) => expr, 59 | Err(_) => return Err("Cannot parse ID format as regular expression"), 60 | }; 61 | 62 | // Replace whitespace character representations 63 | let backlinks_heading = backlinks_heading.to_string(); 64 | 65 | Ok(NoteParser { 66 | id_pattern: id_pattern.to_string(), 67 | id_expr, 68 | backlinks_heading, 69 | }) 70 | } 71 | 72 | pub fn parse(&self, text: &str) -> NoteData { 73 | let mut titles = Vec::new(); 74 | let mut ids = Vec::new(); 75 | let mut links = Vec::new(); 76 | let mut tasks = Vec::new(); 77 | let mut backlinks_start: Option<usize> = None; 78 | let mut backlinks_end: Option<usize> = None; 79 | 80 | let mut state = ParseState::Initial; 81 | let mut start_end = find_first_line(&text, starts_with_bom(&text)); 82 | 83 | loop { 84 | if start_end.is_none() { 85 | break; 86 | } 87 | 88 | let start = start_end.unwrap().0; 89 | let end = start_end.unwrap().1; 90 | let ln = &text[start..end]; 91 | let ln_bytes = ln.as_bytes(); 92 | 93 | match state { 94 | ParseState::Initial => { 95 | if ln.starts_with("---") { 96 | state = ParseState::Yaml; 97 | } else { 98 | // Parse the line again in another state 99 | state = ParseState::Regular; 100 | continue; 101 | } 102 | } 103 | ParseState::Yaml => { 104 | if ln.starts_with("---") || ln.starts_with("...") { 105 | state = ParseState::Regular; 106 | } else if ln_bytes[0] == b'#' { 107 | // Ignore comments 108 | } else { 109 | if ln.len() > 7 { 110 | if let Some(capture) = YAML_TITLE_EXPR.captures(ln) { 111 | titles.push(capture[1].to_owned()); 112 | } 113 | } 114 | if let Some(capture) = self.id_expr.captures(ln) { 115 | ids.push(capture[1].to_owned()); 116 | } 117 | if ln.len() > 4 && ln.contains("[[") { 118 | if let Some(wl) = self.get_wiki_links(ln) { 119 | links.extend(wl); 120 | } 121 | } 122 | } 123 | } 124 | ParseState::Regular => { 125 | // Heading 1 126 | if ln_bytes.len() > 2 && ln_bytes[0] == b'#' && ln_bytes[1] == b' ' { 127 | titles.push( 128 | // Remove {.attributes} and trailing spaces 129 | // See https://pandoc.org/MANUAL.html#pandocs-markdown 130 | escape_markdown( 131 | NoteParser::strip_heading_attributes(&ln[2..]).trim_end(), 132 | ) 133 | .to_string(), 134 | ); 135 | if let Some(capture) = self.id_expr.captures(ln) { 136 | ids.push(capture[1].to_owned()); 137 | } 138 | if ln.len() > 4 && ln.contains("[[") { 139 | if let Some(wl) = self.get_wiki_links(ln) { 140 | links.extend(wl); 141 | } 142 | } 143 | } else if (ln_bytes[0] == b'\t' || ln.starts_with(" ")) 144 | && !INDENTED_LIST_EXPR.is_match(ln) 145 | { 146 | // Ignore code blocks (not indented list items) and line-breaks 147 | } else if ln.starts_with(*CODEBLOCK_TOKEN_1) 148 | || ln.starts_with(*CODEBLOCK_TOKEN_2) 149 | { 150 | state = ParseState::CodeBlock(&ln[..3]); 151 | } else if ln == self.backlinks_heading { 152 | backlinks_start = Some(start); 153 | state = ParseState::BackLinks; 154 | } else { 155 | if let Some(capture) = self.id_expr.captures(ln) { 156 | ids.push(capture[1].to_owned()); 157 | } 158 | if ln.len() > 4 && ln.contains('[') { 159 | if let Some(wl) = self.get_wiki_links(ln) { 160 | links.extend(wl); 161 | } 162 | if let Some(capture) = TASK_EXPR.captures(ln) { 163 | tasks.push(capture[1].to_string()); 164 | } 165 | } 166 | } 167 | } 168 | ParseState::CodeBlock(token) => { 169 | if ln.starts_with(token) { 170 | // Found end token 171 | state = ParseState::Regular; 172 | } 173 | } 174 | ParseState::BackLinks => { 175 | if !BACKLINK_EXPR.is_match(ln) { 176 | // Backlinks list had ended, something else is here 177 | backlinks_end = Some(start); 178 | 179 | // Parse the line again in another state 180 | state = ParseState::Regular; 181 | continue; 182 | } 183 | } 184 | } 185 | 186 | // Parse the next line 187 | start_end = find_next_line(&text, end); 188 | } 189 | 190 | NoteData { 191 | titles, 192 | ids, 193 | links, 194 | tasks, 195 | backlinks_start, 196 | backlinks_end, 197 | } 198 | } 199 | 200 | /// Remove Pandoc-style attributes at the end of a heading ("{#id}") 201 | pub fn strip_heading_attributes(text: &str) -> &str { 202 | if text.as_bytes()[text.len() - 1] == b'}' { 203 | if let Some(start) = text.rfind('{') { 204 | return &text[..start]; 205 | } 206 | } 207 | text 208 | } 209 | 210 | pub fn get_id(&self, text: &str) -> Option<String> { 211 | match self.id_expr.captures(&text) { 212 | None => None, 213 | Some(capture) => Some(capture[1].to_string()), 214 | } 215 | } 216 | 217 | #[inline] 218 | fn is_id(&self, text: &str) -> bool { 219 | self.id_expr.is_match(text) 220 | } 221 | 222 | pub fn remove_id(&self, text: &str) -> String { 223 | self.id_expr.replace(text, "").trim().to_owned() 224 | } 225 | 226 | pub fn get_wiki_links(&self, text: &str) -> Option<Vec<WikiLink>> { 227 | let mut captures = WIKILINK_SIMPLE_EXPR.captures_iter(&text).peekable(); 228 | if captures.peek().is_none() { 229 | return None; 230 | } 231 | let mut links = Vec::new(); 232 | for capture in captures { 233 | let link = capture[1].to_string(); 234 | if self.is_id(&link) { 235 | links.push(WikiLink::Id(link)); 236 | } else { 237 | links.push(WikiLink::FileName(link)); 238 | } 239 | } 240 | Some(links) 241 | } 242 | } 243 | 244 | /// Returns the size of the BOM if it exists 245 | fn starts_with_bom(text: &str) -> usize { 246 | if text.len() >= 3 && text.chars().next().unwrap() == '\u{feff}' { 247 | 3 248 | } else { 249 | 0 250 | } 251 | } 252 | 253 | fn find_newline(text: &str, offset: usize) -> Option<usize> { 254 | let mut pos = offset; 255 | for char in text[offset..].bytes() { 256 | if char == b'\n' || char == b'\r' { 257 | return Some(pos); 258 | } 259 | pos += 1; 260 | } 261 | None 262 | } 263 | 264 | /// Find byte position (start, end) of first line, or None 265 | fn find_first_line(text: &str, offset: usize) -> Option<(usize, usize)> { 266 | let mut pos = offset; 267 | for char in text[offset..].chars() { 268 | if char == '\n' || char == '\r' { 269 | pos += 1; 270 | } else { 271 | match find_newline(&text, pos) { 272 | None => return Some((pos, text.len())), 273 | Some(pos_next_newline) => { 274 | return Some((pos, pos_next_newline)); 275 | } 276 | } 277 | } 278 | } 279 | None 280 | } 281 | 282 | /// Find byte position (start, end) of next line, or None 283 | fn find_next_line(text: &str, offset: usize) -> Option<(usize, usize)> { 284 | match find_newline(&text, offset) { 285 | None => None, 286 | Some(pos) => find_first_line(&text, pos), 287 | } 288 | } 289 | 290 | fn escape_markdown(text: &str) -> Cow<str> { 291 | ESCAPED_CHARS_EXPR.replace_all(text, "$1") 292 | } 293 | 294 | #[cfg(test)] 295 | mod tests { 296 | use crate::mdparse; 297 | use crate::mdparse::{NoteParser, WikiLink}; 298 | use std::fs; 299 | 300 | #[test] 301 | fn trim_bom() { 302 | let with_bom = fs::read_to_string(r"testdata/Markdown1.md").unwrap(); 303 | 304 | assert!(with_bom.starts_with('\u{feff}')); 305 | assert_eq!(mdparse::starts_with_bom(&with_bom), 3); 306 | 307 | assert_eq!(mdparse::starts_with_bom(&"Hello, world!"), 0); 308 | assert_eq!(mdparse::starts_with_bom(&"."), 0); 309 | } 310 | 311 | #[test] 312 | fn find_first_line() { 313 | assert_eq!(mdparse::find_first_line("", 0), None); 314 | assert_eq!(mdparse::find_first_line("\r", 0), None); 315 | assert_eq!(mdparse::find_first_line("\n", 0), None); 316 | 317 | let text = "Lorem ipsum dolor sit amet"; 318 | let (s1, e1) = mdparse::find_first_line(text, 0).unwrap(); 319 | assert_eq!(s1, 0); 320 | assert_eq!(&text[s1..e1], "Lorem ipsum dolor sit amet"); 321 | 322 | let text = "Lorem ipsum dolor sit amet\r\n"; 323 | let (s1, e1) = mdparse::find_first_line(text, 0).unwrap(); 324 | assert_eq!(s1, 0); 325 | assert_eq!(&text[s1..e1], "Lorem ipsum dolor sit amet"); 326 | 327 | let text = "\r\r\n\nLorem ipsum dolor sit amet"; 328 | let (s1, e1) = mdparse::find_first_line(text, 0).unwrap(); 329 | assert_eq!(s1, 4); 330 | assert_eq!(&text[s1..e1], "Lorem ipsum dolor sit amet"); 331 | 332 | let text = "\rLorem\ripsum\ndolor\r\nsit\namet\r\n"; 333 | let (s1, e1) = mdparse::find_first_line(text, 0).unwrap(); 334 | assert_eq!(s1, 1); 335 | assert_eq!(&text[s1..e1], "Lorem"); 336 | 337 | let text = "🔥"; 338 | let (s1, e1) = mdparse::find_first_line(text, 0).unwrap(); 339 | assert_eq!(s1, 0); 340 | assert_eq!(&text[s1..e1], "🔥"); 341 | } 342 | 343 | #[test] 344 | fn find_next_line() { 345 | assert_eq!(mdparse::find_next_line("", 0), None); 346 | assert_eq!(mdparse::find_next_line("\r", 0), None); 347 | assert_eq!(mdparse::find_next_line("\n", 0), None); 348 | assert_eq!( 349 | mdparse::find_next_line("Lorem ipsum dolor sit amet", 0), 350 | None 351 | ); 352 | assert_eq!(mdparse::find_next_line("🔥", 0), None); 353 | 354 | let text = "\rLorem\ripsum\ndolor\r\nsit\namet\r\n"; 355 | let (s1, e1) = mdparse::find_next_line(text, 0).unwrap(); 356 | println!("1. {}..{} = {}", s1, e1, &text[s1..e1]); 357 | assert_eq!(s1, 1); 358 | assert_eq!(&text[s1..e1], "Lorem"); 359 | 360 | let (s1, e1) = mdparse::find_next_line(&text, s1).unwrap(); 361 | assert_eq!(s1, 7); 362 | assert_eq!(&text[s1..e1], "ipsum"); 363 | 364 | let (s1, e1) = mdparse::find_next_line(&text, s1).unwrap(); 365 | assert_eq!(s1, 13); 366 | assert_eq!(&text[s1..e1], "dolor"); 367 | 368 | let text = "🔥\n🔥\n"; 369 | let (s1, e1) = mdparse::find_next_line(text, 0).unwrap(); 370 | assert_eq!(s1, 5); 371 | assert_eq!(&text[s1..e1], "🔥"); 372 | } 373 | 374 | #[test] 375 | fn strip_attributes() { 376 | assert_eq!( 377 | NoteParser::strip_heading_attributes("My heading"), 378 | "My heading" 379 | ); 380 | assert_eq!( 381 | NoteParser::strip_heading_attributes("My heading {#foo}"), 382 | "My heading " 383 | ); 384 | assert_eq!( 385 | NoteParser::strip_heading_attributes("My {heading} {#foo}"), 386 | "My {heading} " 387 | ); 388 | 389 | // Only remove {} at the end! 390 | assert_eq!( 391 | NoteParser::strip_heading_attributes("{-} My heading"), 392 | "{-} My heading" 393 | ); 394 | 395 | assert_eq!( 396 | NoteParser::strip_heading_attributes("My }{ heading"), 397 | "My }{ heading" 398 | ); 399 | } 400 | 401 | #[test] 402 | fn parse_md1() { 403 | let text = fs::read_to_string(r"testdata/Markdown1.md").unwrap(); 404 | let parser = NoteParser::new( 405 | r"\d{12,14}", 406 | "## Links to this note {#backlinks .unnumbered}", 407 | ) 408 | .unwrap(); 409 | let data = parser.parse(&text); 410 | 411 | let expected_ids = [ 412 | "123123123123", 413 | "1111111111110", 414 | "1234567891011", 415 | "1212121212121", 416 | "9900021212121", 417 | ]; 418 | 419 | assert_eq!(data.ids.len(), expected_ids.len()); 420 | for (expected, actual) in expected_ids.iter().zip(data.ids.iter()) { 421 | assert_eq!(actual, expected); 422 | } 423 | 424 | let expected_titles = [ 425 | "Markdown: A markup language", 426 | "Markdown Test File", 427 | "This is a heading inside a comment", 428 | "Then another heading", 429 | ]; 430 | 431 | assert_eq!(data.titles.len(), expected_titles.len()); 432 | for (expected, actual) in expected_titles.iter().zip(data.titles.iter()) { 433 | assert_eq!(actual, expected); 434 | } 435 | 436 | let expected_links = [ 437 | WikiLink::FileName("Related note1".to_owned()), 438 | WikiLink::FileName("Related note2".to_owned()), 439 | WikiLink::Id("1234567891011".to_owned()), 440 | WikiLink::FileName("should this count as a link".to_owned()), 441 | WikiLink::FileName("One last link".to_owned()), 442 | ]; 443 | 444 | assert_eq!(data.links.len(), expected_links.len()); 445 | for (expected, actual) in expected_links.iter().zip(data.links.iter()) { 446 | assert_eq!(actual, expected); 447 | } 448 | } 449 | 450 | #[test] 451 | fn parse_links() { 452 | let text = fs::read_to_string(r"testdata/Links.md").unwrap(); 453 | let parser = NoteParser::new(r"\d{11,14}", "**Links to this note**").unwrap(); 454 | let data = parser.parse(&text); 455 | 456 | let expected_links = vec![ 457 | WikiLink::Id("20210104073402".to_owned()), 458 | WikiLink::Id("20210103212011".to_owned()), 459 | WikiLink::FileName("Filename Link".to_owned()), 460 | WikiLink::FileName("Search Query Link".to_owned()), 461 | WikiLink::FileName("Regular Link To Wiki URI".to_owned()), 462 | WikiLink::FileName("Org-Mode Link Text][Org-Mode Link".to_owned()), 463 | WikiLink::FileName("using labelled links|labelling wiki links".to_owned()), 464 | WikiLink::FileName("the filename first#section then".to_owned()), 465 | WikiLink::FileName("my [not so pretty] link".to_owned()), 466 | WikiLink::FileName(" some text and then [[a link".to_owned()), 467 | ]; 468 | 469 | for expected_link in &expected_links { 470 | assert!(data.links.contains(expected_link)); 471 | } 472 | 473 | let unexpected_links = vec![ 474 | WikiLink::FileName("Inside Fenced Code Block".to_owned()), 475 | WikiLink::FileName("Also fenced".to_owned()), 476 | ]; 477 | 478 | for unexpected_link in &unexpected_links { 479 | assert!(!data.links.contains(unexpected_link)); 480 | } 481 | 482 | assert_eq!(data.links.len(), expected_links.len()); 483 | } 484 | 485 | #[test] 486 | fn oneliner_parser() { 487 | let text = r"# Just a heading \#"; 488 | let parser = NoteParser::new(r"\d{14}", "## Links to this note").unwrap(); 489 | let data = parser.parse(&text); 490 | 491 | assert!(data.titles.contains(&"Just a heading #".to_owned())); 492 | assert_eq!(data.titles.len(), 1); 493 | assert_eq!(data.links.len(), 0); 494 | assert_eq!(data.ids.len(), 0); 495 | assert_eq!(data.tasks.len(), 0); 496 | assert!(data.backlinks_start.is_none()); 497 | assert!(data.backlinks_end.is_none()); 498 | } 499 | 500 | #[test] 501 | fn escaped_characters() { 502 | assert_eq!( 503 | mdparse::escape_markdown(r"C\#\! \{ 0 \+\- 1 \}"), 504 | "C#! { 0 +- 1 }" 505 | ); 506 | assert_eq!( 507 | mdparse::escape_markdown(r"Escape\.\`\(\\\[\|\*\]\)"), 508 | r"Escape.`(\[|*])" 509 | ); 510 | } 511 | } 512 | -------------------------------------------------------------------------------- /src/note.rs: -------------------------------------------------------------------------------- 1 | use crate::ftree; 2 | use crate::mdparse::NoteParser; 3 | use ansi_term::Colour; 4 | use chrono::Utc; 5 | use debug_print::debug_println; 6 | use lazy_static::*; 7 | use regex::Error; 8 | use regex::Regex; 9 | use std::borrow::Cow; 10 | use std::cell::{Ref, RefCell}; 11 | use std::collections::{HashMap, HashSet}; 12 | use std::fmt; 13 | use std::hash::{Hash, Hasher}; 14 | use std::iter::FromIterator; 15 | use std::rc::Rc; 16 | use std::{fs, io, path}; 17 | 18 | lazy_static! { 19 | static ref EMPTY_STRING: String = String::from(""); 20 | // These characters are replaced with " " (illegal in Windows) 21 | static ref ILLEGAL_FILE_CHARS: Regex = Regex::new("[<>:*?|/\"\\\\\\t\\r\\n]").unwrap(); 22 | // "." at the beginning or end are removed 23 | static ref SURROUNDING_DOTS: Regex = Regex::new(r"(\A\.|\.\z)").unwrap(); 24 | // Replace double spaces with single 25 | static ref DOUBLE_SPACES: Regex = Regex::new(r" +").unwrap(); 26 | } 27 | 28 | macro_rules! wikilink { 29 | ($i:expr) => { format!("[[{}]]", $i); } 30 | } 31 | 32 | 33 | #[derive(Debug)] 34 | pub struct NoteFile { 35 | /// Full path to file 36 | pub path: String, 37 | /// Filename without path and extension 38 | pub stem: String, 39 | /// Filename extension without leading dot 40 | pub extension: String, 41 | /// File contents 42 | pub content: String, 43 | } 44 | 45 | impl NoteFile { 46 | fn new(path: &path::PathBuf) -> Result<NoteFile, io::Error> { 47 | Ok(NoteFile { 48 | path: path.as_os_str().to_str().unwrap().to_string(), 49 | stem: path 50 | .file_stem() 51 | .expect("Error in file_stem()") 52 | .to_str() 53 | .unwrap() 54 | .to_string(), 55 | extension: path 56 | .extension() 57 | .expect("Error in extension()") 58 | .to_str() 59 | .unwrap() 60 | .to_string(), 61 | content: fs::read_to_string(&path)?, 62 | }) 63 | } 64 | 65 | /** Clean filename to comply with Windows, OSX and Linux rules, plus the extra rule that filenames don't start with dots or have leading spaces */ 66 | fn clean_filename(filename: &str) -> String { 67 | DOUBLE_SPACES 68 | .replace_all( 69 | &SURROUNDING_DOTS 70 | .replace_all( 71 | &ILLEGAL_FILE_CHARS.replace_all(&filename, " ").to_string(), 72 | "", 73 | ) 74 | .to_string(), 75 | " ", 76 | ) 77 | .trim() 78 | .to_string() 79 | } 80 | 81 | pub fn save(path: &str, contents: &str) -> io::Result<()> { 82 | // Make sure file always ends with one newline 83 | fs::write(&path, String::from(contents.trim_end()) + "\n") 84 | } 85 | 86 | /// Renames file, assuming that the path is valid and escaped 87 | pub fn rename(&self, new_stem: &str) -> io::Result<NoteFile> { 88 | let new_path = path::Path::new(&self.path) 89 | .with_file_name(new_stem) 90 | .with_extension(&self.extension); 91 | fs::rename(&self.path, &new_path)?; 92 | Ok(NoteFile { 93 | path: new_path.as_os_str().to_str().unwrap().to_string(), 94 | stem: new_stem.to_string(), 95 | extension: self.extension.clone(), 96 | content: self.content.clone(), 97 | }) 98 | } 99 | 100 | pub fn replace_contents(&self, contents: &str) -> NoteFile { 101 | NoteFile { 102 | path: self.path.clone(), 103 | stem: self.stem.clone(), 104 | extension: self.extension.clone(), 105 | content: contents.to_owned(), 106 | } 107 | } 108 | } 109 | 110 | #[derive(Debug)] 111 | struct Note { 112 | file: NoteFile, 113 | title: String, 114 | title_lower: String, 115 | id: Option<String>, 116 | links: HashSet<WikiLink>, 117 | tasks: Vec<String>, 118 | backlinks_start: Option<usize>, 119 | backlinks_end: Option<usize>, 120 | parser: Rc<NoteParser>, 121 | } 122 | 123 | // Use path as unique identifier for notes 124 | impl PartialEq for Note { 125 | fn eq(&self, other: &Self) -> bool { 126 | self.file.path == other.file.path 127 | } 128 | } 129 | 130 | impl Eq for Note {} 131 | 132 | // Use path as unique identifier for notes 133 | impl Hash for Note { 134 | fn hash<H: Hasher>(&self, state: &mut H) { 135 | self.file.path.hash(state); 136 | } 137 | } 138 | 139 | type RcRefNote = Rc<RefCell<Note>>; 140 | 141 | impl Note { 142 | fn new(file: NoteFile, parser: Rc<NoteParser>) -> Note { 143 | let data = parser.parse(&file.content); 144 | 145 | let id = match parser.get_id(&file.stem) { 146 | // Prefer ID from filename if it exists 147 | Some(file_id) => Some(file_id), 148 | None => data.ids.into_iter().next(), 149 | }; 150 | 151 | let title = if !data.titles.is_empty() { 152 | // Prefer title from contents 153 | data.titles.into_iter().next().unwrap_or_default() 154 | } else { 155 | // Fall back to filename minus ID 156 | parser.remove_id(&file.stem) 157 | }; 158 | 159 | Note { 160 | id, 161 | title_lower: title.to_lowercase(), 162 | title, 163 | links: HashSet::from_iter(data.links), 164 | tasks: data.tasks, 165 | backlinks_start: data.backlinks_start, 166 | backlinks_end: data.backlinks_end, 167 | parser, 168 | file, 169 | } 170 | } 171 | 172 | /// Replace NoteFile object in mutable copy 173 | fn set_file(&mut self, file: NoteFile) { 174 | self.file = file 175 | } 176 | 177 | fn has_backlinks(&self) -> bool { 178 | self.backlinks_start.is_some() 179 | } 180 | 181 | /// Returns note contents with the backlinks section left out. 182 | fn get_contents_without_backlinks(&self) -> String { 183 | if let Some(start) = self.backlinks_start { 184 | let end = self 185 | .backlinks_end 186 | .unwrap_or_else(|| self.file.content.len()); 187 | 188 | let new_len = start + self.file.content.len() - end; 189 | let mut contents = String::with_capacity(new_len); 190 | contents.push_str(&self.file.content[..start]); 191 | if end < self.file.content.len() { 192 | contents.push_str(&self.file.content[end..]); 193 | } 194 | assert_eq!(contents.len(), new_len); 195 | contents 196 | } else { 197 | self.file.content.to_owned() 198 | } 199 | } 200 | 201 | /// Returns note contents with the backlinks section switched or added 202 | fn get_contents_with_new_backlinks(&self, heading: &str, backlinks: &str) -> String { 203 | let make_contents = |before: &str, after: &str| { 204 | [before.trim_end(), heading, backlinks, after] 205 | .join("\n\n") 206 | .trim_end() 207 | .to_owned() 208 | }; 209 | 210 | if let Some(start) = self.backlinks_start { 211 | let end = self 212 | .backlinks_end 213 | .unwrap_or_else(|| self.file.content.len()); 214 | 215 | make_contents(&self.file.content[..start], &self.file.content[end..]) 216 | } else { 217 | make_contents(&self.file.content, &"") 218 | } 219 | } 220 | 221 | /// Returns backlinks section without the heading, trimmed 222 | fn get_backlinks_section_without_heading(&self) -> Option<&str> { 223 | if let Some(start) = self.backlinks_start { 224 | let end = self 225 | .backlinks_end 226 | .unwrap_or_else(|| self.file.content.len()); 227 | 228 | Some(&self.file.content[start + self.parser.backlinks_heading.len()..end].trim()) 229 | } else { 230 | None 231 | } 232 | } 233 | 234 | fn has_outgoing_links(&self) -> bool { 235 | !self.links.is_empty() 236 | } 237 | 238 | /// Return a copy of the note's meta data 239 | fn into_meta(&self) -> NoteMeta { 240 | NoteMeta { 241 | path: self.file.path.clone(), 242 | stem: self.file.stem.clone(), 243 | extension: self.file.extension.clone(), 244 | title: self.title.clone(), 245 | id: self.id.clone(), 246 | has_links: self.links.len() > 0, 247 | } 248 | } 249 | 250 | fn get_wikilink_to(&self) -> String { 251 | Note::get_wikilink(&self.id, &self.title, &self.file.stem) 252 | } 253 | 254 | fn get_wikilink(id: &Option<String>, title: &str, file_stem: &str) -> String { 255 | // Link either to ID or filename 256 | let link_target = if let Some(i) = id { i } else { file_stem }; 257 | 258 | let link_desc = if title.to_lowercase() == link_target.to_lowercase() { 259 | // No need for a link description that matches the link target 260 | // (E.g. "[[Filename link]] Filename link") 261 | &EMPTY_STRING 262 | } else { 263 | title 264 | }; 265 | 266 | format!("[[{}]] {}", link_target, link_desc) 267 | .trim_end() 268 | .to_string() 269 | } 270 | 271 | pub fn get_filename_link(&self) -> WikiLink { 272 | WikiLink::FileName(self.file.stem.to_string()) 273 | } 274 | 275 | fn is_link_to(&self, link: &WikiLink) -> bool { 276 | match link { 277 | WikiLink::FileName(filename) => { 278 | self.file.stem.to_lowercase() == filename.to_lowercase() 279 | } 280 | WikiLink::Id(id) => { 281 | self.id.as_ref().unwrap_or(&EMPTY_STRING).to_lowercase() == id.to_lowercase() 282 | } 283 | } 284 | } 285 | 286 | pub fn save(&self) -> io::Result<()> { 287 | NoteFile::save(&self.file.path, &self.file.content) 288 | } 289 | } 290 | 291 | #[derive(PartialEq, Eq, Hash, Debug)] 292 | pub struct NoteMeta { 293 | pub path: String, 294 | pub stem: String, 295 | pub extension: String, 296 | pub title: String, 297 | pub id: Option<String>, 298 | pub has_links: bool, 299 | } 300 | 301 | impl NoteMeta { 302 | pub fn get_wikilink_to(&self) -> String { 303 | Note::get_wikilink(&self.id, &self.title, &self.stem) 304 | } 305 | } 306 | 307 | #[derive(Eq, Clone, Debug)] 308 | pub enum WikiLink { 309 | Id(String), 310 | FileName(String), 311 | } 312 | 313 | // Case-insensitive matching for the WikiLink value 314 | impl PartialEq for WikiLink { 315 | fn eq(&self, other: &Self) -> bool { 316 | use WikiLink::*; 317 | match (self, other) { 318 | (Id(a), Id(b)) => a == b, 319 | (FileName(a), FileName(b)) => a.to_lowercase() == b.to_lowercase(), 320 | _ => false, 321 | } 322 | } 323 | } 324 | 325 | // Case-insensitive hashing for the WikiLink value 326 | impl Hash for WikiLink { 327 | fn hash<H: Hasher>(&self, state: &mut H) { 328 | use WikiLink::*; 329 | match self { 330 | Id(link) => link.hash(state), 331 | FileName(link) => link.to_lowercase().hash(state), 332 | } 333 | } 334 | } 335 | 336 | impl fmt::Display for WikiLink { 337 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 338 | use WikiLink::*; 339 | match self { 340 | Id(link) => write!(f, "[[{}]]", link), 341 | FileName(link) => write!(f, "[[{}]]", link), 342 | } 343 | } 344 | } 345 | 346 | pub struct NoteCollection { 347 | /// Lookup for IDs and file names to all notes 348 | notes: HashMap<WikiLink, RcRefNote>, 349 | /// Lookup for links, with the target as key 350 | backlinks: HashMap<WikiLink, Vec<RcRefNote>>, 351 | } 352 | 353 | impl NoteCollection { 354 | pub fn collect_files(root: &path::Path, extension: &str, parser: NoteParser) -> NoteCollection { 355 | let parser = Rc::new(parser); 356 | let mut notes = HashMap::new(); 357 | let mut backlinks = HashMap::new(); 358 | 359 | let start_time = Utc::now(); 360 | let note_paths = ftree::get_files(root, extension); 361 | let duration_get_files = Utc::now() - start_time; 362 | let start_time = Utc::now(); 363 | for path in note_paths { 364 | let note_file = match NoteFile::new(&path) { 365 | Ok(nf) => nf, 366 | Err(err) => { 367 | eprintln!( 368 | "{} Couldn't read file {}: {}", 369 | Colour::Red.paint("Error:"), 370 | path.to_string_lossy(), 371 | err 372 | ); 373 | continue; 374 | } 375 | }; 376 | 377 | let note = Rc::new(RefCell::new(Note::new(note_file, Rc::clone(&parser)))); 378 | 379 | if let Some(id) = ¬e.borrow().id { 380 | if let Some(conflicting_note) = 381 | notes.insert(WikiLink::Id(id.clone()), Rc::clone(¬e)) 382 | { 383 | eprintln!( 384 | "{} The id {} was used in both \"{}\" and \"{}\"", 385 | Colour::Yellow.paint("Warning:"), 386 | id, 387 | note.borrow().file.stem, 388 | conflicting_note.borrow().file.stem 389 | ); 390 | } 391 | } 392 | 393 | notes.insert(note.borrow().get_filename_link(), Rc::clone(¬e)); 394 | 395 | for link in ¬e.borrow().links { 396 | // Ignore "backlinks" to self 397 | if !note.borrow().is_link_to(link) { 398 | backlinks 399 | .entry(link.clone()) 400 | .or_insert_with(Vec::new) 401 | .push(Rc::clone(¬e)); 402 | } 403 | } 404 | } 405 | let duration_note_loop = Utc::now() - start_time; 406 | 407 | debug_println!( 408 | "ftree::get_files() took {} ms", 409 | duration_get_files.num_milliseconds() 410 | ); 411 | debug_println!( 412 | "loading and parsing notes took {} ms", 413 | duration_note_loop.num_milliseconds() 414 | ); 415 | 416 | NoteCollection { notes, backlinks } 417 | } 418 | 419 | /// Get iterator over notes 420 | fn get_notes_iter(&self) -> impl Iterator<Item = Ref<Note>> { 421 | self.notes 422 | .iter() 423 | // Only look at filename ids/keys, since all notes have 424 | // exactly one filename, but 0..1 ids. 425 | .filter(|(k, _)| matches!(k, WikiLink::FileName(_))) 426 | .map(|(_, v)| v.borrow()) 427 | } 428 | 429 | /// Get vector of notes, sorted by title 430 | fn get_sorted_notes(&self) -> Vec<Ref<Note>> { 431 | let mut notes: Vec<Ref<Note>> = self.get_notes_iter().collect(); 432 | notes.sort_by(|a, b| a.title_lower.cmp(&b.title_lower)); 433 | notes 434 | } 435 | 436 | pub fn count(&self) -> usize { 437 | self.get_notes_iter().count() 438 | } 439 | 440 | pub fn count_with_id(&self) -> usize { 441 | self.get_notes_iter().filter(|n| n.id.is_some()).count() 442 | } 443 | 444 | pub fn count_links(&self) -> usize { 445 | self.backlinks.len() 446 | } 447 | 448 | pub fn into_meta_vec(&self) -> Vec<NoteMeta> { 449 | let mut notes = Vec::with_capacity(self.count()); 450 | for note in &self.get_sorted_notes() { 451 | notes.push(note.into_meta()); 452 | } 453 | notes 454 | } 455 | 456 | fn note_has_incoming_links(&self, note: &Note) -> bool { 457 | if self 458 | .backlinks 459 | .contains_key(&WikiLink::FileName(note.file.stem.to_string())) 460 | { 461 | return true; 462 | } 463 | 464 | if let Some(id) = ¬e.id { 465 | if self.backlinks.contains_key(&WikiLink::Id(id.to_string())) { 466 | return true; 467 | } 468 | } 469 | 470 | false 471 | } 472 | 473 | /// Get incoming links to note. Can contain duplicates! 474 | fn get_incoming_links(&self, note: &Note) -> Vec<RcRefNote> { 475 | let empty: Vec<RcRefNote> = Vec::new(); 476 | 477 | let mut links: Vec<&RcRefNote> = self 478 | .backlinks 479 | .get(¬e.get_filename_link()) 480 | .unwrap_or(&empty) 481 | .iter() 482 | .collect(); 483 | 484 | if let Some(id) = ¬e.id { 485 | links.extend( 486 | &mut self 487 | .backlinks 488 | .get(&WikiLink::Id(id.to_string())) 489 | .unwrap_or(&empty) 490 | .iter(), 491 | ) 492 | } 493 | 494 | links.iter().map(|rcn| Rc::clone(rcn)).collect() 495 | } 496 | 497 | /// Get notes with no incoming links, but at least one outgoing 498 | pub fn get_sources(&self) -> Vec<NoteMeta> { 499 | let mut sources = Vec::new(); 500 | for note in &self.get_sorted_notes() { 501 | if note.has_outgoing_links() && !self.note_has_incoming_links(note) { 502 | sources.push(note.into_meta()); 503 | } 504 | } 505 | sources 506 | } 507 | 508 | /** Get notes with no outgoing links, but at least one incoming */ 509 | pub fn get_sinks(&self) -> Vec<NoteMeta> { 510 | let mut sinks = Vec::new(); 511 | for note in &self.get_sorted_notes() { 512 | if !note.has_outgoing_links() && self.note_has_incoming_links(note) { 513 | sinks.push(note.into_meta()); 514 | } 515 | } 516 | sinks 517 | } 518 | 519 | /** Get notes with no incoming or outgoing links */ 520 | pub fn get_isolated(&self) -> Vec<NoteMeta> { 521 | let mut isolated = Vec::new(); 522 | for note in &self.get_sorted_notes() { 523 | if !note.has_outgoing_links() && !self.note_has_incoming_links(note) { 524 | isolated.push(note.into_meta()); 525 | } 526 | } 527 | isolated 528 | } 529 | 530 | pub fn get_broken_links(&self) -> Vec<(&WikiLink, Vec<NoteMeta>)> { 531 | let mut notes = Vec::new(); 532 | let linked: HashSet<&WikiLink> = self.backlinks.keys().collect(); 533 | let existing: HashSet<&WikiLink> = self.notes.keys().collect(); 534 | for broken in linked.difference(&existing) { 535 | let linkers: Vec<NoteMeta> = self.backlinks[broken] 536 | .iter() 537 | .map(|note| note.borrow().into_meta()) 538 | .collect(); 539 | notes.push((*broken, linkers)); 540 | } 541 | notes 542 | } 543 | 544 | pub fn get_tasks(&self) -> Vec<(NoteMeta, Vec<String>)> { 545 | let mut tasks = Vec::new(); 546 | for note in &self.get_sorted_notes() { 547 | if !note.tasks.is_empty() { 548 | tasks.push((note.into_meta(), note.tasks.clone())); 549 | } 550 | } 551 | tasks 552 | } 553 | 554 | pub fn remove_backlinks(&self) -> Vec<NoteMeta> { 555 | let mut notes = Vec::new(); 556 | for note in &self.get_sorted_notes() { 557 | if note.has_backlinks() { 558 | if let Err(e) = 559 | NoteFile::save(¬e.file.path, ¬e.get_contents_without_backlinks()) 560 | { 561 | eprintln!("Error while saving note file {}: {}", note.file.path, e); 562 | } else { 563 | notes.push(note.into_meta()); 564 | } 565 | } 566 | } 567 | notes 568 | } 569 | 570 | pub fn update_backlinks(&self) -> Vec<NoteMeta> { 571 | let mut notes = Vec::new(); 572 | for note in &self.get_sorted_notes() { 573 | let incoming_links = self.get_incoming_links(note); 574 | let mut incoming_links: Vec<Ref<Note>> = 575 | incoming_links.iter().map(|n| n.borrow()).collect(); 576 | 577 | // First sort by filename to get a stable sort when titles are identical 578 | incoming_links.sort_by(|a, b| a.file.stem.cmp(&b.file.stem)); 579 | incoming_links.sort_by(|a, b| a.title_lower.cmp(&b.title_lower)); 580 | 581 | let mut new_backlinks: Vec<String> = incoming_links 582 | .iter() 583 | .map(|linking_note| "- ".to_string() + &linking_note.get_wikilink_to()) 584 | .collect(); 585 | 586 | // Remove possible duplicate links 587 | new_backlinks.dedup(); 588 | 589 | let new_section = new_backlinks.join("\n"); 590 | 591 | let current_section = note 592 | .get_backlinks_section_without_heading() 593 | .unwrap_or_default(); 594 | 595 | if current_section != new_section { 596 | let new_contents = if !new_section.is_empty() { 597 | // Add or update backlinks 598 | note.get_contents_with_new_backlinks( 599 | ¬e.parser.backlinks_heading, 600 | &new_section, 601 | ) 602 | } else { 603 | // Remove backlinks 604 | note.get_contents_without_backlinks() 605 | }; 606 | if let Err(e) = NoteFile::save(¬e.file.path, &new_contents) { 607 | eprintln!("Error while saving note file {}: {}", note.file.path, e); 608 | } else { 609 | notes.push(note.into_meta()); 610 | } 611 | } 612 | } 613 | notes 614 | } 615 | 616 | pub fn get_mismatched_filenames(&self) -> Vec<(NoteMeta, String)> { 617 | let mut fs = Vec::new(); 618 | for note in &self.get_sorted_notes() { 619 | let new_filename = if let Some(id) = ¬e.id { 620 | NoteFile::clean_filename(&format!("{} {}", id, ¬e.title)) 621 | } else { 622 | NoteFile::clean_filename(¬e.title) 623 | }; 624 | if note.file.stem.to_lowercase() != new_filename.to_lowercase() { 625 | fs.push((note.into_meta(), new_filename)); 626 | } 627 | } 628 | fs 629 | } 630 | 631 | pub fn rename_note(&self, note_meta: &NoteMeta, new_stem: &str) -> io::Result<Vec<NoteMeta>> { 632 | let note = &self.notes[&WikiLink::FileName(note_meta.stem.to_string())]; 633 | 634 | // Rename note file and replace NoteFile object in Note 635 | let new_note_file = note.borrow().file.rename(&new_stem)?; 636 | note.borrow_mut().set_file(new_note_file); 637 | 638 | Ok(self.update_filename_backlinks_to(¬e_meta.stem, &new_stem)?) 639 | } 640 | 641 | fn update_filename_backlinks_to( 642 | &self, 643 | old_file_stem: &str, 644 | new_file_stem: &str, 645 | ) -> io::Result<Vec<NoteMeta>> { 646 | let mut updated_notes = Vec::new(); 647 | let old_filename_id = WikiLink::FileName(old_file_stem.to_owned()); 648 | if self.backlinks.contains_key(&old_filename_id) { 649 | // Use Regex to make case-insensitive search and replace 650 | let search = 651 | literal_to_ci_regex(&Note::get_wikilink(&None, &EMPTY_STRING, &old_file_stem)) 652 | .unwrap(); 653 | let new_link = Note::get_wikilink(&None, &EMPTY_STRING, &new_file_stem); 654 | 655 | for backlink in self.backlinks[&old_filename_id].iter() { 656 | { 657 | let new_contents = ®ex_literal_search_replace( 658 | &backlink.borrow().file.content, 659 | &search, 660 | &new_link, 661 | ) 662 | .to_string(); 663 | 664 | if &backlink.borrow().file.content != new_contents { 665 | let new_note_file = backlink.borrow().file.replace_contents(new_contents); 666 | backlink.borrow_mut().set_file(new_note_file); 667 | updated_notes.push(backlink.borrow().into_meta()); 668 | } 669 | } 670 | backlink.borrow().save()?; 671 | } 672 | } 673 | 674 | Ok(updated_notes) 675 | } 676 | } 677 | 678 | fn literal_to_ci_regex(search: &str) -> Result<Regex, Error> { 679 | Regex::new(&format!("(?i){}", ®ex::escape(&search))) 680 | } 681 | 682 | fn regex_literal_search_replace<'a>( 683 | text: &'a str, 684 | search: &Regex, 685 | replace: &'a str, 686 | ) -> Cow<'a, str> { 687 | search.replace_all(&text, |_: ®ex::Captures| &replace) 688 | } 689 | 690 | #[cfg(test)] 691 | mod tests { 692 | use crate::note::*; 693 | 694 | fn get_default_parser() -> NoteParser { 695 | NoteParser::new(r"\d{11,14}", "**Links to this note**").expect("Test parser failed") 696 | } 697 | 698 | #[test] 699 | fn title_and_id_parser() { 700 | let parser = Rc::new(get_default_parser()); 701 | let note = Note::new( 702 | NoteFile::new(&path::PathBuf::from(r"testdata/File Name Title.md")).unwrap(), 703 | Rc::clone(&parser), 704 | ); 705 | 706 | assert_eq!(note.file.stem, "File Name Title"); 707 | assert_eq!(note.id.unwrap(), "1234567890123"); 708 | assert_eq!(note.title, "The Title In the Note Contents"); 709 | } 710 | 711 | #[test] 712 | fn yaml1_title_parser() { 713 | let parser = Rc::new(get_default_parser()); 714 | let note = Note::new( 715 | NoteFile::new(&path::PathBuf::from(r"testdata/yaml1.md")).unwrap(), 716 | Rc::clone(&parser), 717 | ); 718 | 719 | assert_eq!(note.title, "Plain YAML title"); 720 | assert!(note.id.is_none()); 721 | } 722 | 723 | #[test] 724 | fn yaml2_title_parser() { 725 | let parser = Rc::new(get_default_parser()); 726 | let note = Note::new( 727 | NoteFile::new(&path::PathBuf::from(r"testdata/yaml2.md")).unwrap(), 728 | Rc::clone(&parser), 729 | ); 730 | 731 | assert_eq!(note.title, "Plein: YAML title"); 732 | assert_eq!(note.id.unwrap(), "123123123123"); 733 | } 734 | 735 | #[test] 736 | fn yaml3_title_parser() { 737 | let parser = Rc::new(get_default_parser()); 738 | let note = Note::new( 739 | NoteFile::new(&path::PathBuf::from(r"testdata/yaml3.md")).unwrap(), 740 | Rc::clone(&parser), 741 | ); 742 | 743 | assert_eq!(note.title, "Das Title"); 744 | assert!(note.id.is_none()); 745 | } 746 | 747 | #[test] 748 | fn empty_file_parser() { 749 | let parser = Rc::new(get_default_parser()); 750 | let note = Note::new( 751 | NoteFile::new(&path::PathBuf::from(r"testdata/Empty File With Name.md")).unwrap(), 752 | Rc::clone(&parser), 753 | ); 754 | 755 | assert_eq!(note.file.stem, "Empty File With Name"); 756 | assert_eq!(note.id, None); 757 | assert_eq!(note.title, "Empty File With Name"); 758 | } 759 | 760 | #[test] 761 | fn title_parser() { 762 | let parser = Rc::new(get_default_parser()); 763 | let note = Note::new( 764 | NoteFile::new(&path::PathBuf::from(r"testdata/12345678901 Test Note 1.md")).unwrap(), 765 | Rc::clone(&parser), 766 | ); 767 | 768 | assert_eq!(note.title, "Test Note 1"); 769 | } 770 | 771 | #[test] 772 | fn oneliner_parser() { 773 | let parser = Rc::new(get_default_parser()); 774 | let note = Note::new( 775 | NoteFile::new(&path::PathBuf::from(r"testdata/One-liner.md")).unwrap(), 776 | Rc::clone(&parser), 777 | ); 778 | 779 | assert_eq!(note.title, "Just a Heading"); 780 | } 781 | 782 | #[test] 783 | fn task_parser() { 784 | let parser = Rc::new(get_default_parser()); 785 | let note = Note::new( 786 | NoteFile::new(&path::PathBuf::from(r"testdata/Tasks.md")).unwrap(), 787 | Rc::clone(&parser), 788 | ); 789 | 790 | assert!(note.tasks.contains(&"Don't forget to remember".to_string())); 791 | assert!(note.tasks.contains(&"Buy milk!".to_string())); 792 | assert!(note.tasks.contains(&"Nested".to_string())); 793 | assert!(note.tasks.contains(&"Tabbed with [[link]]".to_string())); 794 | assert!(note.tasks.contains(&"Final line".to_string())); 795 | assert_eq!(note.tasks.len(), 5); 796 | 797 | assert!(note 798 | .links 799 | .contains(&WikiLink::FileName(String::from("link")))); 800 | } 801 | 802 | #[test] 803 | fn wikilink_traits() { 804 | assert_ne!( 805 | WikiLink::Id("1234567890".to_string()), 806 | WikiLink::Id("0987654321".to_string()) 807 | ); 808 | assert_ne!( 809 | WikiLink::FileName("The name".to_string()), 810 | WikiLink::Id("Le nom".to_string()) 811 | ); 812 | assert_ne!( 813 | WikiLink::FileName("1234567890".to_string()), 814 | WikiLink::Id("1234567890".to_string()) 815 | ); 816 | assert_eq!( 817 | WikiLink::Id("1234567890".to_string()), 818 | WikiLink::Id("1234567890".to_string()) 819 | ); 820 | assert_eq!( 821 | WikiLink::FileName("AÉÖÜÅÑ".to_string()), 822 | WikiLink::FileName("aéöüåñ".to_string()) 823 | ); 824 | 825 | let mut map = HashMap::new(); 826 | map.insert(WikiLink::Id("1234567890".to_string()), "Some value"); 827 | map.insert(WikiLink::Id("9876543210".to_string()), "Some value"); 828 | map.insert(WikiLink::FileName("1234567890".to_string()), "Some value"); 829 | map.insert(WikiLink::FileName("ÅSTRÖM".to_string()), "Some value"); 830 | map.insert(WikiLink::FileName("åström".to_string()), "Some value"); 831 | map.insert(WikiLink::FileName("Astrom".to_string()), "Some value"); 832 | assert_eq!(map.len(), 5); 833 | } 834 | 835 | #[test] 836 | fn backlinks() { 837 | let parser = Rc::new(get_default_parser()); 838 | let note = Note::new( 839 | NoteFile::new(&path::PathBuf::from(r"testdata/BackLinks.md")).unwrap(), 840 | Rc::clone(&parser), 841 | ); 842 | 843 | // All links in this file is in the backlinks section 844 | assert_eq!(note.links.len(), 0); 845 | } 846 | 847 | #[test] 848 | fn replace_backlinks() { 849 | let parser = Rc::new(get_default_parser()); 850 | let note = Note::new( 851 | NoteFile::new(&path::PathBuf::from(r"testdata/BackLinks.md")).unwrap(), 852 | Rc::clone(&parser), 853 | ); 854 | 855 | let c1 = note.get_contents_without_backlinks(); 856 | assert_eq!( 857 | c1, 858 | "# Backlinks test case\r\n\r\nSome note text\r\n\r\n<!-- Here be dragons -->\r\n" 859 | ); 860 | 861 | let c2 = note.get_backlinks_section_without_heading().unwrap(); 862 | assert_eq!( 863 | c2.trim(), 864 | "- [[§An outline note]]\r\n- [[20201012145848]] Another note\r\n* Not a link" 865 | ); 866 | 867 | let c3 = 868 | note.get_contents_with_new_backlinks("## Links to this note", "- [[The one and only]]"); 869 | assert_eq!(c3, "# Backlinks test case\r\n\r\nSome note text\n\n## Links to this note\n\n- [[The one and only]]\n\n<!-- Here be dragons -->"); 870 | } 871 | 872 | #[test] 873 | fn add_backlinks1() { 874 | let parser = Rc::new(get_default_parser()); 875 | let note = Note::new( 876 | NoteFile::new(&path::PathBuf::from(r"testdata/One-liner.md")).unwrap(), 877 | Rc::clone(&parser), 878 | ); 879 | 880 | let c1 = note.get_contents_without_backlinks(); 881 | assert_eq!(c1, "# Just a Heading"); 882 | 883 | assert!(note.get_backlinks_section_without_heading().is_none()); 884 | 885 | let c3 = note.get_contents_with_new_backlinks( 886 | "## Links to this note", 887 | "- [[Link one]]\n- [[Link two]]", 888 | ); 889 | assert_eq!( 890 | c3, 891 | "# Just a Heading\n\n## Links to this note\n\n- [[Link one]]\n- [[Link two]]" 892 | ); 893 | } 894 | 895 | #[test] 896 | fn add_backlinks2() { 897 | let parser = Rc::new(get_default_parser()); 898 | let note = Note::new( 899 | NoteFile::new(&path::PathBuf::from(r"testdata/12345678901 Test Note 1.md")).unwrap(), 900 | Rc::clone(&parser), 901 | ); 902 | 903 | assert!(note.get_backlinks_section_without_heading().is_none()); 904 | 905 | let c3 = note.get_contents_with_new_backlinks( 906 | "## Links to this note", 907 | "- [[Link one]]\n- [[Link two]]", 908 | ); 909 | assert_eq!( 910 | c3, 911 | "This is the ID: 112233445566\n\n## Links to this note\n\n- [[Link one]]\n- [[Link two]]" 912 | ); 913 | } 914 | 915 | #[test] 916 | fn clean_filename() { 917 | assert_eq!( 918 | NoteFile::clean_filename("Just a normal file name"), 919 | "Just a normal file name" 920 | ); 921 | assert_eq!( 922 | NoteFile::clean_filename("<Is/this\\a::regular?*?*?file>"), 923 | "Is this a regular file" 924 | ); 925 | assert_eq!(NoteFile::clean_filename(".hidden file"), "hidden file"); 926 | assert_eq!( 927 | NoteFile::clean_filename("illegal in windows."), 928 | "illegal in windows" 929 | ); 930 | assert_eq!( 931 | NoteFile::clean_filename("a . in the middle"), 932 | "a . in the middle" 933 | ); 934 | assert_eq!( 935 | NoteFile::clean_filename("pipe | is also forbidden"), 936 | "pipe is also forbidden" 937 | ); 938 | assert_eq!( 939 | NoteFile::clean_filename("Try some whitespace: \t\r\n--"), 940 | "Try some whitespace --" 941 | ); 942 | assert_eq!( 943 | NoteFile::clean_filename("C# is a nice language!"), 944 | "C# is a nice language!" 945 | ); 946 | assert_eq!(NoteFile::clean_filename(".:/?."), ""); 947 | } 948 | 949 | #[test] 950 | fn file_encodings_utf8_bom() { 951 | let parser = Rc::new(get_default_parser()); 952 | let note = Note::new( 953 | NoteFile::new(&path::PathBuf::from(r"testdata/BOM.md")).unwrap(), 954 | Rc::clone(&parser), 955 | ); 956 | 957 | assert_eq!(note.file.content.chars().next().unwrap(), '\u{feff}'); 958 | } 959 | 960 | #[test] 961 | fn file_encodings_win1252() { 962 | match NoteFile::new(&path::PathBuf::from(r"testdata/Win-1252.md")) { 963 | Ok(_) => panic!("Shouldn't be able to read Win-1252 file"), 964 | Err(_) => (), 965 | }; 966 | } 967 | 968 | #[test] 969 | fn file_without_title() { 970 | let parser = Rc::new(get_default_parser()); 971 | let note = Note::new( 972 | NoteFile::new(&path::PathBuf::from(r"testdata/20201010101010.md")).unwrap(), 973 | Rc::clone(&parser), 974 | ); 975 | 976 | assert_eq!(note.title, "".to_owned()); 977 | assert_eq!(note.id, Some("20201010101010".to_owned())); 978 | assert_eq!(note.file.stem, "20201010101010".to_owned()); 979 | } 980 | } 981 | -------------------------------------------------------------------------------- /testdata/12345678901 Test Note 1.md: -------------------------------------------------------------------------------- 1 | This is the ID: 112233445566 2 | -------------------------------------------------------------------------------- /testdata/20201010101010.md: -------------------------------------------------------------------------------- 1 | No title in here 2 | -------------------------------------------------------------------------------- /testdata/BOM.md: -------------------------------------------------------------------------------- 1 | Yxmördaren Julia Blomqvist på fäktning i Schweiz -------------------------------------------------------------------------------- /testdata/BackLinks.md: -------------------------------------------------------------------------------- 1 | # Backlinks test case 2 | 3 | Some note text 4 | 5 | **Links to this note** 6 | 7 | - [[§An outline note]] 8 | - [[20201012145848]] Another note 9 | * Not a link 10 | 11 | <!-- Here be dragons --> 12 | -------------------------------------------------------------------------------- /testdata/Empty File With Name.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdaven/noteexplorer/c460be7cc4e987be9133a858f029cb6beda8dbb6/testdata/Empty File With Name.md -------------------------------------------------------------------------------- /testdata/File Name Title.md: -------------------------------------------------------------------------------- 1 | #atag 2 | 3 | # The Title In the Note Contents 4 | 5 | This is a link and not an ID: [[987654321012]] 6 | 7 | This is a phone number and not an ID: +442071838750 8 | 9 | This is a URL and not an ID: https://some.cloud.app/file/756188601385 10 | 11 | This is the ID: 1234567890123. 12 | 13 | ... 14 | -------------------------------------------------------------------------------- /testdata/Links.md: -------------------------------------------------------------------------------- 1 | --- 2 | - related: [[20210104073402]], parsed in Yaml now 3 | ... 4 | 5 | - [ ] check 6 | - [[]] 7 | - Indented listst should also work 8 | + [[Filename Link]] 9 | 1. [[Search Query Link]] 10 | 2. [[20210103212011]] 11 | - [Link Text]([[Regular Link To Wiki URI]]) 12 | - [[Org-Mode Link Text][Org-Mode Link]] 13 | 14 | ## Advanced syntax 15 | 16 | NoteExplorer has chosen not to support [[using labelled links|labelling wiki links]] and linking straight to a section: [[the filename first#section then]]. 17 | 18 | However, filenames and links should probably be able to contain [] chars: [[my [not so pretty] link]] 19 | 20 | This should not become a link: [[ some text and then [[a link]] and then some ]]. 21 | 22 | ## Code 23 | 24 | ```javascript 25 | var s = "[[Inside Fenced Code Block]]" 26 | ``` 27 | 28 | [[Also fenced]] 29 | 30 | ## Newline 31 | 32 | Links spanning newlines should not be accepted: [[no 33 | way]] 34 | -------------------------------------------------------------------------------- /testdata/Markdown1.md: -------------------------------------------------------------------------------- 1 |  2 | --- 3 | # title: no title 4 | "title" : 'Markdown: A markup language' 5 | id: 123123123123 6 | seealso: [[Related note1]], [[Related note2]] 7 | ... 8 | 9 | #atag #an-other #the_third 10 | 11 | 1111111111110 12 | 13 | # Markdown Test File {#h1} 14 | 15 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Praesent facilisis efficitur turpis, eget porta purus sodales eu. In hac habitasse platea dictumst. Vestibulum quis massa vitae ex vehicula eleifend. Etiam non ligula dapibus, tristique tellus id, euismod elit. Nulla urna purus, eleifend sit amet nisl vel, commodo imperdiet leo. In hac habitasse platea dictumst. Nullam eget lacus libero. Proin suscipit ut ante ac pellentesque. 16 | 17 | <p>I really like using Markdown.</p> 18 | 19 | That is so funny! 😂 20 | 21 | \# Not a heading, since it was escaped 22 | 23 | Empty heading: 24 | 25 | # 26 | 27 | > #### The quarterly results look great! 28 | > 29 | > - Revenue was off the chart. 30 | > - Profits were higher than ever. 31 | > 32 | > *Everything* is going according to **plan**. 33 | 34 | ![Tux, the Linux mascot](/assets/images/tux.png) 35 | 36 | Setext-style headings are not supported 37 | =================================== 38 | 39 | ## Lines, 1234567891011 40 | 41 | ___ 42 | 43 | --- 44 | 45 | *** 46 | 47 | ## Lists [[1234567891011]] 48 | 49 | 1. First item 50 | 2. Second item 51 | 3. Third item 52 | 4. Fourth item 53 | 54 | + First item 55 | + Second item 56 | + Third item 57 | + Fourth item 58 | 59 | \* Without the backslash, this would be a bullet in an unordered list. 60 | 61 | ## YAML 62 | 63 | Ignore YAML inside the file: 64 | 65 | --- 66 | title: Bogus 67 | --- 68 | 69 | Etiam quis massa pharetra, feugiat arcu id, scelerisque sem. Morbi vel augue quis arcu scelerisque egestas. Aliquam finibus elit leo. Mauris non lacinia neque, et rutrum lectus. Cras nisl mi, sollicitudin id hendrerit ut, scelerisque nec sem. Suspendisse rutrum eros tortor, eu vulputate orci luctus ut. Cras vitae faucibus elit, in molestie ipsum. Proin dictum sed justo quis condimentum. Nam a luctus eros. Nulla auctor congue nunc. Nam eu blandit lectus. Nunc lectus ex, varius vel lorem sed, pellentesque semper ex. Vestibulum tempor elit tellus, vitae aliquet dui varius eget. 70 | 71 | ## Comments 72 | 73 | <!--Let me comment this: 74 | # This is a heading inside a comment 75 | And some text as well--> 76 | 77 | You can also use comments inline: <!-- [[should this count as a link]]? ID Should count: 1212121212121 --> 78 | 79 | ## Markdown links 80 | 81 | I love supporting the **[EFF](https://eff.org)**. 82 | This is the *[Markdown Guide](https://www.markdownguide.org)*. 83 | See the section on [`code`](#code). 84 | 85 | In a hole in the ground there lived a hobbit. Not a nasty, dirty, wet hole, filled with the ends 86 | of worms and an oozy smell, nor yet a dry, bare, sandy hole with nothing in it to sit down on or to 87 | eat: it was a [hobbit-hole][1], and that means comfort. 88 | 89 | [1]: <https://en.wikipedia.org/wiki/Hobbit#Lifestyle> "Hobbit lifestyles" 90 | 91 | Here's a simple footnote[^1] 92 | 93 | [^1]: This is the first footnote. 94 | 95 | ## Code 96 | 97 | Indented with tabs: 98 | 99 | #[derive(Debug)] 100 | pub struct NoteData { 101 | 0000021212121, 102 | pub ids: Vec<String>, 103 | pub links: Vec<WikiLink>, 104 | pub tasks: Vec<String>, 105 | pub backlinks: Vec<String>, 106 | } 107 | 108 | Indented with spaces: 109 | 110 | #[derive(Debug)] 111 | 0000021212121, 112 | pub struct NoteData { 113 | pub titles: Vec<String>, 114 | pub ids: Vec<String>, 115 | pub links: Vec<WikiLink>, 116 | pub tasks: Vec<String>, 117 | pub backlinks: Vec<String>, 118 | } 119 | 120 | Fenced blocks: 121 | 122 | ```rust 123 | #[derive(Debug)] 124 | 0000021212121, 125 | pub struct NoteData { 126 | pub titles: Vec<String>, 127 | pub ids: Vec<String>, 128 | pub links: Vec<WikiLink>, 129 | pub tasks: Vec<String>, 130 | pub backlinks: Vec<String>, 131 | } 132 | ``` 133 | 134 | ~~~rust 135 | #[derive(Debug)] 136 | 0000021212121, 137 | pub struct NoteData { 138 | pub titles: Vec<String>, 139 | pub ids: Vec<String>, 140 | pub links: Vec<WikiLink>, 141 | pub tasks: Vec<String>, 142 | pub backlinks: Vec<String>, 143 | } 144 | ~~~ 145 | 146 | Inline code: ``Use `code` in your Markdown file.`` 147 | 148 | ## Definition lists 149 | 150 | First Term 151 | : This is the definition of the first term. 152 | 153 | Second Term 154 | : This is one definition of the second term. 155 | : This is another definition of the second term. 156 | 157 | ## Tables 158 | 159 | | Syntax | Description | 160 | | ----------- | ----------- | 161 | | Header | Title | 162 | | Paragraph | Text | 163 | 164 | ## Pandoc-supported HTML identifiers {#identifier .class .class key=value key=value} 165 | 166 | Write something about this in a wiki page with a link to https://pandoc.org/MANUAL.html#pandocs-markdown 167 | 168 | ## Links to this note {#backlinks .unnumbered} 169 | 170 | - [[There could be a link here]] 171 | - [[And here as well]] 172 | 173 | <!-- Should this be allowed? --> 174 | 175 | # Then another heading 176 | 177 | Etiam accumsan mi sit amet erat varius vulputate. Morbi erat sapien, eleifend blandit orci sit amet, convallis condimentum diam. Sed vel commodo risus, in bibendum ante. Ut consequat sit amet dolor vel porta. Quisque dignissim lorem eleifend dui ultrices malesuada nec vel libero. Pellentesque euismod, nisl finibus pellentesque tempus, massa velit euismod ipsum, id egestas nulla nisi a sem. Nunc varius nibh eget turpis aliquam imperdiet. Aenean viverra tortor id cursus efficitur. Aliquam molestie pharetra vulputate. Suspendisse nec pharetra augue, volutpat eleifend risus. Mauris vel ante at neque dapibus accumsan sit amet sed leo. Vestibulum nec semper orci. Nam varius dui quis risus mollis, porta placerat massa efficitur. 178 | 179 | [[One last link]] 180 | 181 | 9900021212121 -------------------------------------------------------------------------------- /testdata/One-liner.md: -------------------------------------------------------------------------------- 1 | # Just a Heading -------------------------------------------------------------------------------- /testdata/Tasks.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | [] No 4 | [ ] Nope 5 | - [] Not gonna happen 6 | - [ ]Still no 7 | - [ ] Don't forget to remember 8 | * [ ] Buy milk! 9 | - [ ] Nested 10 | + [ ] Tabbed with [[link]] 11 | - [x] Done 12 | ~~- [ ] Stricken~~ 13 | 14 | An then in [ ] text, or possibly - [ ] this 15 | 16 | - [ ] Final line -------------------------------------------------------------------------------- /testdata/Win-1252.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cdaven/noteexplorer/c460be7cc4e987be9133a858f029cb6beda8dbb6/testdata/Win-1252.md -------------------------------------------------------------------------------- /testdata/yaml1.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Plain YAML title 3 | --- 4 | 5 | # YAML 6 | 7 | Paragraph 8 | -------------------------------------------------------------------------------- /testdata/yaml2.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | --- 5 | # title: no title 6 | keywords: Something else 7 | author: Me 8 | "title" : 'Plein: YAML title' 9 | id: 123123123123 10 | ... 11 | 12 | # YAML 13 | 14 | Paragraph 15 | -------------------------------------------------------------------------------- /testdata/yaml3.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Das Title" 3 | author: "Das Author" 4 | --- 5 | 6 | And some text 7 | --------------------------------------------------------------------------------