├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── workflows │ └── release.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── LICENSE.MD ├── README.md ├── copy_to_vault.sh ├── docs └── dev-docs.md ├── manifest.json ├── package.json ├── rollup.config.js ├── rust-toolchain.toml ├── src ├── lib.rs ├── main.ts ├── rs │ ├── Errors │ │ ├── cast_error.rs │ │ ├── matching_error.rs │ │ └── mod.rs │ ├── matching │ │ ├── link_finder.rs │ │ ├── link_finder_result.rs │ │ ├── link_match.rs │ │ ├── link_target_candidate.rs │ │ ├── mod.rs │ │ └── regex_match.rs │ ├── mod.rs │ ├── note │ │ ├── mod.rs │ │ ├── note.rs │ │ └── note_scanned_event.rs │ ├── replacer │ │ ├── mod.rs │ │ ├── note_change_operation.rs │ │ └── replacement.rs │ ├── text │ │ ├── mod.rs │ │ ├── text_context.rs │ │ ├── text_context_tail.rs │ │ └── text_util.rs │ └── util │ │ ├── mod.rs │ │ ├── preferrable_item.rs │ │ ├── range.rs │ │ └── wasm_util.rs └── ts │ ├── MainModal.tsx │ ├── components │ ├── containers │ │ ├── MainComponent.tsx │ │ ├── MatchSelectionComponent.tsx │ │ ├── MatcherComponent.tsx │ │ └── StartComponent.tsx │ ├── list-items │ │ └── ReplacementItemComponent.tsx │ ├── lists │ │ ├── LinkFinderResultsListComponent.tsx │ │ ├── LinkMatchesListComponent.tsx │ │ ├── LinkTargetCandidatesListComponent.tsx │ │ └── ReplacementsSelectionComponent.tsx │ ├── other │ │ ├── LoadingComponent.tsx │ │ └── ProgressComponent.tsx │ └── titles │ │ ├── LinkFinderResultTitleComponent.tsx │ │ └── LinkMatchTitleComponent.tsx │ ├── context.ts │ ├── hooks.ts │ ├── objects │ ├── IgnoreRange.ts │ ├── JsNote.ts │ └── Progress.ts │ ├── util.ts │ └── webWorkers │ └── WasmWorker.ts ├── styles.css └── tsconfig.json /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '🐛 Bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '🚀 Feature' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obisidian-note-linker 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: jetli/wasm-pack-action@v0.3.0 18 | with: 19 | # Optional version of wasm-pack to install(eg. 'v0.9.1', 'latest') 20 | version: 'latest' 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: "14.x" # You might need to adjust this value to your own version 26 | - name: Build 27 | id: build 28 | run: | 29 | npm install 30 | npm run build 31 | mkdir ${{ env.PLUGIN_NAME }} 32 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 33 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 34 | ls 35 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 36 | - name: Create Release 37 | id: create_release 38 | uses: actions/create-release@v1 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_NOTE_LINKER_TOKEN }} 41 | VERSION: ${{ github.ref }} 42 | with: 43 | tag_name: ${{ github.ref }} 44 | release_name: ${{ github.ref }} 45 | draft: false 46 | prerelease: false 47 | - name: Upload zip file 48 | id: upload-zip 49 | uses: actions/upload-release-asset@v1 50 | env: 51 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_NOTE_LINKER_TOKEN }} 52 | with: 53 | upload_url: ${{ steps.create_release.outputs.upload_url }} 54 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 55 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 56 | asset_content_type: application/zip 57 | - name: Upload main.js 58 | id: upload-main 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_NOTE_LINKER_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./main.js 65 | asset_name: main.js 66 | asset_content_type: text/javascript 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_NOTE_LINKER_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | - name: Upload main.js 78 | id: upload-css 79 | uses: actions/upload-release-asset@v1 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.OBSIDIAN_NOTE_LINKER_TOKEN }} 82 | with: 83 | upload_url: ${{ steps.create_release.outputs.upload_url }} 84 | asset_path: ./styles.css 85 | asset_name: styles.css 86 | asset_content_type: text/css -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .rts2_cache_cjs 3 | main.js 4 | *.log 5 | /target 6 | package-lock.json 7 | .idea 8 | Cargo.lock 9 | .vscode 10 | .vscode-test 11 | .vscode-launch.json 12 | .vscode-tasks.json 13 | copy_to_vault.sh -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | [[package]] 4 | name = "aho-corasick" 5 | version = "0.7.18" 6 | source = "registry+https://github.com/rust-lang/crates.io-index" 7 | checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" 8 | dependencies = [ 9 | "memchr", 10 | ] 11 | 12 | [[package]] 13 | name = "bit-set" 14 | version = "0.5.2" 15 | source = "registry+https://github.com/rust-lang/crates.io-index" 16 | checksum = "6e11e16035ea35e4e5997b393eacbf6f63983188f7a2ad25bfb13465f5ad59de" 17 | dependencies = [ 18 | "bit-vec", 19 | ] 20 | 21 | [[package]] 22 | name = "bit-vec" 23 | version = "0.6.3" 24 | source = "registry+https://github.com/rust-lang/crates.io-index" 25 | checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" 26 | 27 | [[package]] 28 | name = "bumpalo" 29 | version = "3.6.1" 30 | source = "registry+https://github.com/rust-lang/crates.io-index" 31 | checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" 32 | 33 | [[package]] 34 | name = "cfg-if" 35 | version = "1.0.0" 36 | source = "registry+https://github.com/rust-lang/crates.io-index" 37 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 38 | 39 | [[package]] 40 | name = "console_error_panic_hook" 41 | version = "0.1.7" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" 44 | dependencies = [ 45 | "cfg-if", 46 | "wasm-bindgen", 47 | ] 48 | 49 | [[package]] 50 | name = "fancy-regex" 51 | version = "0.10.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "0678ab2d46fa5195aaf59ad034c083d351377d4af57f3e073c074d0da3e3c766" 54 | dependencies = [ 55 | "bit-set", 56 | "regex", 57 | ] 58 | 59 | [[package]] 60 | name = "itoa" 61 | version = "1.0.2" 62 | source = "registry+https://github.com/rust-lang/crates.io-index" 63 | checksum = "112c678d4050afce233f4f2852bb2eb519230b3cf12f33585275537d7e41578d" 64 | 65 | [[package]] 66 | name = "js-sys" 67 | version = "0.3.57" 68 | source = "registry+https://github.com/rust-lang/crates.io-index" 69 | checksum = "671a26f820db17c2a2750743f1dd03bafd15b98c9f30c7c2628c024c05d73397" 70 | dependencies = [ 71 | "wasm-bindgen", 72 | ] 73 | 74 | [[package]] 75 | name = "lazy_static" 76 | version = "1.4.0" 77 | source = "registry+https://github.com/rust-lang/crates.io-index" 78 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 79 | 80 | [[package]] 81 | name = "log" 82 | version = "0.4.14" 83 | source = "registry+https://github.com/rust-lang/crates.io-index" 84 | checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" 85 | dependencies = [ 86 | "cfg-if", 87 | ] 88 | 89 | [[package]] 90 | name = "memchr" 91 | version = "2.5.0" 92 | source = "registry+https://github.com/rust-lang/crates.io-index" 93 | checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" 94 | 95 | [[package]] 96 | name = "obisidian-note-linker" 97 | version = "1.2.6" 98 | dependencies = [ 99 | "console_error_panic_hook", 100 | "fancy-regex", 101 | "js-sys", 102 | "serde", 103 | "serde-wasm-bindgen", 104 | "serde_json", 105 | "thiserror", 106 | "unicode-segmentation", 107 | "wasm-bindgen", 108 | ] 109 | 110 | [[package]] 111 | name = "proc-macro2" 112 | version = "1.0.42" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "c278e965f1d8cf32d6e0e96de3d3e79712178ae67986d9cf9151f51e95aac89b" 115 | dependencies = [ 116 | "unicode-ident", 117 | ] 118 | 119 | [[package]] 120 | name = "quote" 121 | version = "1.0.9" 122 | source = "registry+https://github.com/rust-lang/crates.io-index" 123 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 124 | dependencies = [ 125 | "proc-macro2", 126 | ] 127 | 128 | [[package]] 129 | name = "regex" 130 | version = "1.5.5" 131 | source = "registry+https://github.com/rust-lang/crates.io-index" 132 | checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" 133 | dependencies = [ 134 | "aho-corasick", 135 | "memchr", 136 | "regex-syntax", 137 | ] 138 | 139 | [[package]] 140 | name = "regex-syntax" 141 | version = "0.6.25" 142 | source = "registry+https://github.com/rust-lang/crates.io-index" 143 | checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" 144 | 145 | [[package]] 146 | name = "ryu" 147 | version = "1.0.10" 148 | source = "registry+https://github.com/rust-lang/crates.io-index" 149 | checksum = "f3f6f92acf49d1b98f7a81226834412ada05458b7364277387724a237f062695" 150 | 151 | [[package]] 152 | name = "serde" 153 | version = "1.0.140" 154 | source = "registry+https://github.com/rust-lang/crates.io-index" 155 | checksum = "fc855a42c7967b7c369eb5860f7164ef1f6f81c20c7cc1141f2a604e18723b03" 156 | dependencies = [ 157 | "serde_derive", 158 | ] 159 | 160 | [[package]] 161 | name = "serde-wasm-bindgen" 162 | version = "0.4.3" 163 | source = "registry+https://github.com/rust-lang/crates.io-index" 164 | checksum = "1cfc62771e7b829b517cb213419236475f434fb480eddd76112ae182d274434a" 165 | dependencies = [ 166 | "js-sys", 167 | "serde", 168 | "wasm-bindgen", 169 | ] 170 | 171 | [[package]] 172 | name = "serde_derive" 173 | version = "1.0.140" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "6f2122636b9fe3b81f1cb25099fcf2d3f542cdb1d45940d56c713158884a05da" 176 | dependencies = [ 177 | "proc-macro2", 178 | "quote", 179 | "syn", 180 | ] 181 | 182 | [[package]] 183 | name = "serde_json" 184 | version = "1.0.82" 185 | source = "registry+https://github.com/rust-lang/crates.io-index" 186 | checksum = "82c2c1fdcd807d1098552c5b9a36e425e42e9fbd7c6a37a8425f390f781f7fa7" 187 | dependencies = [ 188 | "itoa", 189 | "ryu", 190 | "serde", 191 | ] 192 | 193 | [[package]] 194 | name = "syn" 195 | version = "1.0.98" 196 | source = "registry+https://github.com/rust-lang/crates.io-index" 197 | checksum = "c50aef8a904de4c23c788f104b7dddc7d6f79c647c7c8ce4cc8f73eb0ca773dd" 198 | dependencies = [ 199 | "proc-macro2", 200 | "quote", 201 | "unicode-ident", 202 | ] 203 | 204 | [[package]] 205 | name = "thiserror" 206 | version = "1.0.31" 207 | source = "registry+https://github.com/rust-lang/crates.io-index" 208 | checksum = "bd829fe32373d27f76265620b5309d0340cb8550f523c1dda251d6298069069a" 209 | dependencies = [ 210 | "thiserror-impl", 211 | ] 212 | 213 | [[package]] 214 | name = "thiserror-impl" 215 | version = "1.0.31" 216 | source = "registry+https://github.com/rust-lang/crates.io-index" 217 | checksum = "0396bc89e626244658bef819e22d0cc459e795a5ebe878e6ec336d1674a8d79a" 218 | dependencies = [ 219 | "proc-macro2", 220 | "quote", 221 | "syn", 222 | ] 223 | 224 | [[package]] 225 | name = "unicode-ident" 226 | version = "1.0.2" 227 | source = "registry+https://github.com/rust-lang/crates.io-index" 228 | checksum = "15c61ba63f9235225a22310255a29b806b907c9b8c964bcbd0a2c70f3f2deea7" 229 | 230 | [[package]] 231 | name = "unicode-segmentation" 232 | version = "1.9.0" 233 | source = "registry+https://github.com/rust-lang/crates.io-index" 234 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 235 | 236 | [[package]] 237 | name = "wasm-bindgen" 238 | version = "0.2.80" 239 | source = "registry+https://github.com/rust-lang/crates.io-index" 240 | checksum = "27370197c907c55e3f1a9fbe26f44e937fe6451368324e009cba39e139dc08ad" 241 | dependencies = [ 242 | "cfg-if", 243 | "serde", 244 | "serde_json", 245 | "wasm-bindgen-macro", 246 | ] 247 | 248 | [[package]] 249 | name = "wasm-bindgen-backend" 250 | version = "0.2.80" 251 | source = "registry+https://github.com/rust-lang/crates.io-index" 252 | checksum = "53e04185bfa3a779273da532f5025e33398409573f348985af9a1cbf3774d3f4" 253 | dependencies = [ 254 | "bumpalo", 255 | "lazy_static", 256 | "log", 257 | "proc-macro2", 258 | "quote", 259 | "syn", 260 | "wasm-bindgen-shared", 261 | ] 262 | 263 | [[package]] 264 | name = "wasm-bindgen-macro" 265 | version = "0.2.80" 266 | source = "registry+https://github.com/rust-lang/crates.io-index" 267 | checksum = "17cae7ff784d7e83a2fe7611cfe766ecf034111b49deb850a3dc7699c08251f5" 268 | dependencies = [ 269 | "quote", 270 | "wasm-bindgen-macro-support", 271 | ] 272 | 273 | [[package]] 274 | name = "wasm-bindgen-macro-support" 275 | version = "0.2.80" 276 | source = "registry+https://github.com/rust-lang/crates.io-index" 277 | checksum = "99ec0dc7a4756fffc231aab1b9f2f578d23cd391390ab27f952ae0c9b3ece20b" 278 | dependencies = [ 279 | "proc-macro2", 280 | "quote", 281 | "syn", 282 | "wasm-bindgen-backend", 283 | "wasm-bindgen-shared", 284 | ] 285 | 286 | [[package]] 287 | name = "wasm-bindgen-shared" 288 | version = "0.2.80" 289 | source = "registry+https://github.com/rust-lang/crates.io-index" 290 | checksum = "d554b7f530dee5964d9a9468d95c1f8b8acae4f282807e7d27d4b03099a46744" 291 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "obisidian-note-linker" 3 | version = "1.2.7" 4 | authors = ["Alexander Weichart"] 5 | edition = "2018" 6 | 7 | [lib] 8 | crate-type = ["cdylib"] 9 | 10 | [dependencies] 11 | wasm-bindgen = { version = "0.2.80", features = ["serde-serialize"] } 12 | js-sys = "0.3.57" 13 | fancy-regex = "0.10.0" 14 | unicode-segmentation = "1.9.0" 15 | thiserror = "1.0.31" 16 | console_error_panic_hook = "0.1.7" 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0.82" 19 | serde-wasm-bindgen = "0.4.3" 20 | -------------------------------------------------------------------------------- /LICENSE.MD: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Alexander Weichart 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 | ## 🔗 Obsidian Note Linker 2 | 3 | A plugin to automatically create new links between notes in Obsidian. 4 | 5 | ![ezgif com-gif-maker(4)](https://user-images.githubusercontent.com/55558407/187985324-c13860b0-42e0-41d8-9498-8df936948dfd.gif) 6 | 7 | 8 | ### 🤨 How does it work? 9 | 10 | The plugin checks each note in the vault for references to other note names (or their aliases). 11 | If a reference is found, it gets added to a list. This list is then displayed to the user, who can select which notes to 12 | link. 13 | 14 | #### Disclaimer: 15 | 16 | The current version has only been tested by myself, and a few beta testers. No bugs are currently known. However, I advise you to backup your vault before applying any changes using this plugin, since the plugin has not been tested by enough people. 17 | 18 | ### ⬇️ Installation 19 | 20 | You can install this plugin by downloading it from the Obsidian Plugin store, or via [this direct link](https://obsidian.md/plugins?id=obisidian-note-linker). 21 | 22 | ### 👨‍💻 Development 23 | 24 | The plugin is written in Rust (compiled to WebAssembly) and TypeScript. 25 | For more information please, check the [dev docs](docs/dev-docs.md). 26 | 27 | ### 📃 Credits 28 | 29 | Created based on the Obsidian Rust Plugin template by [trashhalo](https://github.com/trashhalo/obsidian-rust-plugin). 30 | -------------------------------------------------------------------------------- /copy_to_vault.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # get first argument 4 | vault=$1 5 | 6 | # check if path exists 7 | if [ ! -d "$vault" ]; then 8 | echo "Vault does not exist, did you change the path in package.json?" 9 | exit 1 10 | fi 11 | 12 | plugin_path="$1/.obsidian/plugins/obsidian-note-linker" 13 | 14 | # create plugin directory if it does not exist 15 | 16 | if [ ! -d "$plugin_path" ]; then 17 | echo "Creating plugin directory in $vault" 18 | mkdir -p "$plugin_path" 19 | fi 20 | 21 | # remove all files inside of the directory 22 | echo "Removing old plugin files in $vault" 23 | rm -rf "${plugin_path:?}"/* 24 | 25 | 26 | # copy ./manifest.json, ./styles.css and ./main.js to ~/Desktop/YouTube/.obsidian/plugins/obsidian-note-linker/ 27 | 28 | echo "Copying new plugin files to $vault" 29 | cp ./manifest.json "$plugin_path" 30 | cp ./styles.css "$plugin_path" 31 | cp ./main.js "$plugin_path" 32 | 33 | echo "Done" -------------------------------------------------------------------------------- /docs/dev-docs.md: -------------------------------------------------------------------------------- 1 | ## Dev Docs 2 | 3 | ### 📁 Project structure 4 | 5 | #### Rust 6 | 7 | The Rust part of the project handles the business logic such as scanning notes via regex or applying changes to notes. 8 | It is written to be independent of Obsidian, meaning the plugin could be ported over to other note-taking apps with minimal changes. 9 | 10 | #### Typescript 11 | 12 | The TypeScript part of the project is used create the UI using React. Also, it serves as an accesses point to the Obsidian plugin API to retrieve notes, metadata, etc. 13 | 14 | ### 🤝 Contributing 15 | 16 | Contributions are welcome, but please make sure they are understandable and no bloat 17 | 18 | #### Building 19 | 20 | - Build the project by running `npm run build` in the root directory 21 | - To easily copy the build results into your vault, run `npm run build-copy` in the root directory 22 | - NOTE: Don't forget to: 23 | - change the vault path in `package.json` to your vault path! 24 | - make the copy script executable by running `chmod +x ./copy_to_vault.sh` 25 | 26 | ### 🗺️ Roadmap 27 | 28 | ### Near future 29 | 30 | - [x] become an official community plugin 31 | - [ ] await user feedback on the reliability of the plugin to move out of beta 32 | 33 | ### Future (ideas) 34 | 35 | - [ ] caching of scanned notes to improve performance on re scans 36 | - [ ] scanning options (e.g. ignore notes, custom regex, etc.) 37 | - [ ] multithreading (depends on how WASM develops over time) 38 | - [ ] NLP based approach (e.g. "link to notes with similar content") 39 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obisidian-note-linker", 3 | "name": "Note Linker", 4 | "version": "1.2.7", 5 | "description": "Automatically find and link notes in Obsidian", 6 | "author": "Alexander Weichart", 7 | "authorUrl": "https://github.com/AlexW00", 8 | "isDesktopOnly": true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obisidian-note-linker", 3 | "version": "1.2.7", 4 | "description": "Automatically find and link notes in Obsidian", 5 | "main": "main.js", 6 | "scripts": { 7 | "build": "wasm-pack build --target web && rollup --config rollup.config.js", 8 | "build:copy": "npm run build && ./copy_to_vault.sh /home/aw/Documents/Obsidian/anki" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "MIT", 13 | "devDependencies": { 14 | "@rollup/plugin-commonjs": "^15.1.0", 15 | "@rollup/plugin-node-resolve": "^9.0.0", 16 | "@rollup/plugin-replace": "^4.0.0", 17 | "@types/react": "^18.0.12", 18 | "@types/react-dom": "^18.0.5", 19 | "rollup": "^2.32.1", 20 | "rollup-plugin-base64": "^1.0.1", 21 | "rollup-plugin-web-worker-loader": "^1.6.1", 22 | "tslib": "^2.4.0", 23 | "typescript": "^4.7.4" 24 | }, 25 | "dependencies": { 26 | "@rollup/plugin-typescript": "^8.3.2", 27 | "@surma/rollup-plugin-off-main-thread": "^2.2.3", 28 | "comlink": "^4.3.1", 29 | "obsidian": "^0.14.8", 30 | "react": "^18.1.0", 31 | "react-dom": "^18.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { base64 } from 'rollup-plugin-base64'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import webWorkerLoader from 'rollup-plugin-web-worker-loader'; 6 | import replace from '@rollup/plugin-replace'; 7 | 8 | export default { 9 | input: 'src/main.ts', 10 | output: { 11 | //file: 'main.js', 12 | dir: '.', 13 | sourcemap: 'inline', 14 | format: 'cjs', 15 | exports: 'default', 16 | }, 17 | external: ['obsidian', 'React', 'ReactDOM', 'path', 'fs'], 18 | plugins: [ 19 | replace({ 20 | delimiters: ['', ''], 21 | }), 22 | nodeResolve({ browser: true }), 23 | commonjs({ ignore: ['original-fs'] }), 24 | base64({ include: "**/*.wasm" }), 25 | typescript(), 26 | webWorkerLoader({ 27 | targetPlatform: 'browser', 28 | extensions: ['.ts'], 29 | preserveSource: true, 30 | sourcemap: true, 31 | }), 32 | ] 33 | }; -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "1.70.0" -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use js_sys::{Array, Function}; 4 | use wasm_bindgen::JsValue; 5 | use wasm_bindgen::prelude::*; 6 | 7 | use crate::rs::matching::link_finder; 8 | use crate::rs::matching::link_finder_result::LinkFinderResult; 9 | use crate::rs::note::note::Note; 10 | use crate::rs::note::note_scanned_event::NoteScannedEvent; 11 | 12 | mod rs; 13 | 14 | #[wasm_bindgen] 15 | pub fn find_in_vault(context: JsValue, notes: Array, callback: Function) -> Array { 16 | let notes: Vec = notes.iter() 17 | .filter_map(|note: JsValue| Note::try_from(note).ok()) 18 | .collect(); 19 | 20 | let mut res: Vec = vec![]; 21 | 22 | notes.clone().iter_mut().enumerate().for_each(|(index, note)| { 23 | let _ = call_callback(&callback, &context, build_args(note, index)); 24 | 25 | let link_finder_result_option = link_finder::find_links(note, ¬es); 26 | if let Some(r) = link_finder_result_option { 27 | res.push(r); 28 | } 29 | }); 30 | 31 | let array: Array = Array::new(); 32 | for r in res { 33 | let js: JsValue = r.into(); 34 | array.push(&js); 35 | } 36 | array 37 | } 38 | 39 | 40 | #[wasm_bindgen] 41 | pub fn find_in_note(context: JsValue, active_note: Note, notes: Array, callback: Function) -> Array { 42 | let notes: Vec = notes.iter() 43 | .filter_map(|note: JsValue| Note::try_from(note).ok()) 44 | .collect(); 45 | let mut note = active_note.clone(); 46 | 47 | let mut res: Vec = vec![]; 48 | 49 | let _ = call_callback(&callback, &context, build_args(¬e, 0)); 50 | 51 | let link_finder_result_option = link_finder::find_links(&mut note, ¬es); 52 | if let Some(r) = link_finder_result_option { 53 | res.push(r); 54 | } 55 | 56 | let array: Array = Array::new(); 57 | for r in res { 58 | let js: JsValue = r.into(); 59 | array.push(&js); 60 | } 61 | array 62 | } 63 | 64 | fn build_args(note: &Note, index: usize) -> Array { 65 | let args = js_sys::Array::new(); 66 | let note_scanned_event = NoteScannedEvent::new(note, index).to_json_string(); 67 | let js: JsValue = note_scanned_event.into(); 68 | args.push(&js); 69 | args 70 | } 71 | 72 | fn call_callback(callback: &Function, context: &JsValue, args: Array) -> Result<(), JsValue> { 73 | callback.apply(context, &args)?; 74 | Ok(()) 75 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from "obsidian"; 2 | import rustPlugin from "../pkg/obisidian_note_linker_bg.wasm"; 3 | import * as wasm from "../pkg"; 4 | import MainModal from "./ts/MainModal"; 5 | import { init_panic_hook } from "../pkg/"; 6 | import * as Comlink from "comlink"; 7 | 8 | // @ts-ignore 9 | import Wcw from "web-worker:./ts/webWorkers/WasmWorker.ts"; 10 | import { MatchingMode } from "./ts/components/containers/MainComponent"; 11 | 12 | export default class RustPlugin extends Plugin { 13 | async onload() { 14 | // init wasm 15 | const buffer = Uint8Array.from(atob(rustPlugin as unknown as string), (c) => 16 | c.charCodeAt(0) 17 | ); 18 | await wasm.default(Promise.resolve(buffer)); 19 | init_panic_hook(); 20 | 21 | this.addRibbonIcon("link", "Note Linker", () => this.openModal()); 22 | this.addCommand({ 23 | id: "open-note-linker", 24 | name: "Open", 25 | callback: this.openModal, 26 | }); 27 | this.addCommand({ 28 | id: "open-note-linker-vault", 29 | name: "Scan Vault", 30 | callback: () => this.openModal(MatchingMode.Vault), 31 | }); 32 | this.addCommand({ 33 | id: "open-note-linker-note", 34 | name: "Scan Note", 35 | callback: () => this.openModal(MatchingMode.Note), 36 | }); 37 | } 38 | 39 | openModal = async (_matchingModal?: MatchingMode) => { 40 | // init the secondary wasm thread (for searching) 41 | const wcw = new Wcw(); 42 | const WasmComlinkWorker = Comlink.wrap(wcw); 43 | let wasmWorkerInstance: Comlink.Remote; 44 | 45 | wasmWorkerInstance = await new WasmComlinkWorker(); 46 | await wasmWorkerInstance.init(); 47 | 48 | const linkMatchSelectionModal = new MainModal( 49 | app, 50 | wasmWorkerInstance, 51 | () => { 52 | wcw.terminate(); 53 | }, 54 | _matchingModal 55 | ); 56 | linkMatchSelectionModal.open(); 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /src/rs/Errors/cast_error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum CastError { 5 | #[error("Failed to cast Captures → Regex Match")] 6 | CapturesToRegexMatch(), 7 | } -------------------------------------------------------------------------------- /src/rs/Errors/matching_error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum MatchingError { 5 | #[error("Failed to get link matches")] 6 | GetLinkMatchesError(), 7 | } -------------------------------------------------------------------------------- /src/rs/Errors/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod cast_error; 2 | pub mod matching_error; -------------------------------------------------------------------------------- /src/rs/matching/link_finder.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | use std::ops::Add; 3 | 4 | use fancy_regex::{escape, Regex}; 5 | 6 | use crate::rs::util::wasm_util::log; 7 | use crate::{LinkFinderResult}; 8 | use crate::rs::matching::link_match::LinkMatch; 9 | use crate::rs::matching::regex_match::RegexMatch; 10 | use crate::rs::note::note::Note; 11 | 12 | type LinkFinder = Regex; 13 | 14 | /// A match of a link in a note. 15 | struct LinkFinderMatchingResult<'m> { 16 | regex_matches: Vec, 17 | note: &'m Note, 18 | target_note: &'m Note, 19 | } 20 | 21 | impl<'m> LinkFinderMatchingResult<'m> { 22 | fn find_matches(note: &'m mut Note, target_note: &'m Note) -> Self { 23 | // build the regex 24 | let regex_matches: Vec = build_link_finder(target_note) 25 | // find all matches 26 | .captures_iter(note.get_sanitized_content()) 27 | // map the results to a vector of RegexMatch 28 | .filter_map(|capture_result| { 29 | match capture_result { 30 | Ok(captures) => { 31 | RegexMatch::try_from(captures).ok() 32 | } 33 | _ => { 34 | None 35 | } 36 | } 37 | } 38 | ) 39 | .collect(); 40 | 41 | LinkFinderMatchingResult { 42 | regex_matches, 43 | note, 44 | target_note, 45 | } 46 | } 47 | } 48 | 49 | /// Creates a vec of LinkMatch from a LinkFinderMatchingResult. 50 | impl<'m> Into> for LinkFinderMatchingResult<'m> { 51 | fn into(self) -> Vec { 52 | let note: &Note = self.note; 53 | let target_note: &Note = self.target_note; 54 | let text_link_matches: Vec = self.regex_matches 55 | .into_iter() 56 | .map(|regex_match: RegexMatch| { 57 | LinkMatch::new_from_match(®ex_match, note, target_note) 58 | }) 59 | .collect(); 60 | text_link_matches 61 | } 62 | } 63 | 64 | /// Constructs a Regex string that matches any of the provided strings. 65 | fn concat_as_regex_string(strings: &[String]) -> String { 66 | strings.iter() 67 | .enumerate() 68 | .fold("".to_string(), |prev, (index, current)| { 69 | return if index == 0 { format!("(\\b{}\\b)", current) } else { format!("{}|(\\b{}\\b)", prev, current) }; 70 | }) 71 | .add("") 72 | } 73 | 74 | /// Constructs a LinkFinder for the provided target note. 75 | fn build_link_finder(target_note: &Note) -> LinkFinder { 76 | let mut escaped_search_strings: Vec = target_note.aliases_vec().iter().map(|alias| escape(alias).to_string()).collect(); 77 | let escaped_title = escape(&*target_note.title()).to_string(); 78 | escaped_search_strings.push(escaped_title); 79 | 80 | let regex_string = concat_as_regex_string(&escaped_search_strings); 81 | //log(&format!("Regex string: {}", regex_string)); 82 | 83 | // "(?i)" makes the expression case insensitive 84 | Regex::new(&*format!(r"(?i){}", regex_string)).unwrap() 85 | } 86 | 87 | /// Finds all link candidates in the provided note. 88 | fn find_link_matches(target_note: &Note, note_to_check: &mut Note) -> Option> { 89 | if !&target_note.title().eq(¬e_to_check.title()) { 90 | let link_finder_match = LinkFinderMatchingResult::find_matches( 91 | note_to_check, 92 | target_note, 93 | ); 94 | let link_matches: Vec = link_finder_match.into(); 95 | return Some(link_matches); 96 | } 97 | None 98 | } 99 | 100 | /// Merges the provided link match into the existing list of link matches. 101 | /// If the link match is already in the list, it is merged with the existing link match. 102 | /// If the link match is not in the list, it is added to the list. 103 | fn merge_link_match_into_link_matches(mut merged_link_matches: Vec, link_match: LinkMatch) -> Vec { 104 | let index = merged_link_matches.iter() 105 | .position(|m: &LinkMatch| m.position().is_equal_to(&link_match.position())); 106 | 107 | if let Some(index) = index { 108 | // merge it into the existing match, if the position is the same 109 | merged_link_matches[index].merge_link_target_candidates(link_match); 110 | } else { 111 | // otherwise push a new match 112 | merged_link_matches.push(link_match); 113 | } 114 | merged_link_matches 115 | } 116 | 117 | /// Complete function that finds all link candidates in the provided note. 118 | pub fn find_links(note_to_check: &mut Note, target_note_candidates: &[Note]) -> Option { 119 | let link_matches: Vec = 120 | target_note_candidates 121 | .iter() 122 | .filter_map(|target_note: &Note, | find_link_matches(target_note, note_to_check)) 123 | .flatten() 124 | .fold(Vec::new(), merge_link_match_into_link_matches); 125 | 126 | if !link_matches.is_empty() { 127 | return Some(LinkFinderResult::new( 128 | note_to_check.clone(), 129 | link_matches, 130 | )); 131 | } 132 | 133 | None 134 | } -------------------------------------------------------------------------------- /src/rs/matching/link_finder_result.rs: -------------------------------------------------------------------------------- 1 | use js_sys::Array; 2 | use serde::{Deserialize, Serialize}; 3 | use wasm_bindgen::prelude::*; 4 | 5 | use crate::rs::matching::link_match::{link_match_vec_into_array, LinkMatch}; 6 | use crate::rs::note::note::Note; 7 | 8 | /// The result of the Link Finder searching a single Note for links. 9 | #[wasm_bindgen] 10 | #[derive(Serialize, Deserialize)] 11 | pub struct LinkFinderResult { 12 | note: Note, // the note that was searched 13 | 14 | #[serde(rename = "linkMatches")] 15 | _link_matches: Vec, // the link matches found in the note 16 | } 17 | 18 | #[wasm_bindgen] 19 | impl LinkFinderResult { 20 | #[wasm_bindgen(getter)] 21 | pub fn note(&self) -> Note { self.note.clone() } 22 | #[wasm_bindgen(getter, js_name = "linkMatches")] 23 | pub fn link_matches(&self) -> Array { 24 | link_match_vec_into_array(self._link_matches.clone()) 25 | } 26 | #[wasm_bindgen(method, js_name = "toJSON")] 27 | pub fn to_json_string(&self) -> String { 28 | serde_json::to_string(self).unwrap() 29 | } 30 | 31 | #[wasm_bindgen(method, js_name = "fromJSON")] 32 | pub fn from_json_string(json_string: &str) -> Self { 33 | serde_json::from_str(json_string).unwrap() 34 | } 35 | } 36 | 37 | impl LinkFinderResult { 38 | pub fn new(note: Note, _link_matches: Vec) -> Self { 39 | LinkFinderResult { 40 | note, 41 | _link_matches, 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/rs/matching/link_match.rs: -------------------------------------------------------------------------------- 1 | use js_sys::{Array}; 2 | use wasm_bindgen::prelude::*; 3 | use serde::{Serialize, Deserialize}; 4 | 5 | use crate::rs::matching::link_target_candidate::{link_target_candidate_vec_into_array, LinkTargetCandidate}; 6 | use crate::rs::matching::regex_match::RegexMatch; 7 | use crate::rs::note::note::Note; 8 | use crate::rs::text::text_context::TextContext; 9 | use crate::rs::util::range::Range; 10 | 11 | /// A text passage, that has been identified as a possible link. 12 | #[wasm_bindgen] 13 | #[derive(Clone, Serialize, Deserialize)] 14 | pub struct LinkMatch { 15 | position: Range, // the position of the match in the text 16 | matched_text: String, // the text that was matched 17 | context: TextContext, // the context of the match (the text before and after the match) 18 | 19 | #[serde(rename = "linkTargetCandidates")] 20 | _link_target_candidates: Vec // the link target candidates for the match 21 | } 22 | 23 | #[wasm_bindgen] 24 | impl LinkMatch { 25 | #[wasm_bindgen(getter)] 26 | pub fn position(&self) -> Range { self.position.clone() } 27 | 28 | #[wasm_bindgen(getter, js_name = "matchedText")] 29 | pub fn matched_text(&self) -> String { self.matched_text.clone() } 30 | 31 | #[wasm_bindgen(getter)] 32 | pub fn context(&self) -> TextContext { self.context.clone() } 33 | 34 | #[wasm_bindgen(getter, js_name = "linkTargetCandidates")] 35 | pub fn link_target_candidates(&self) -> Array { 36 | link_target_candidate_vec_into_array(self._link_target_candidates.clone()) 37 | } 38 | } 39 | 40 | impl LinkMatch { 41 | pub fn new(position: Range, matched_text: String, context: TextContext, _link_target_candidates: Vec) -> Self { 42 | LinkMatch { 43 | position, 44 | matched_text, 45 | context, 46 | _link_target_candidates 47 | } 48 | } 49 | 50 | pub fn new_from_match(regex_match: &RegexMatch, note: &Note, target_note: &Note) -> Self { 51 | let link_target_candidates: Vec = vec![LinkTargetCandidate::new( 52 | target_note.title(), 53 | target_note.path(), 54 | target_note.aliases_vec(), 55 | regex_match.capture_index 56 | )]; 57 | Self::new( 58 | regex_match.position.clone(), 59 | regex_match.matched_text.clone(), 60 | TextContext::new(note, regex_match.position.clone(), regex_match.matched_text.clone()), 61 | link_target_candidates 62 | ) 63 | } 64 | 65 | /// Merges this LinkMatch with another LinkMatch. by combining their link target candidates. 66 | pub fn merge_link_target_candidates(&mut self, link_match: LinkMatch) { 67 | for mut candidate in link_match._link_target_candidates { 68 | // uncheck all candidates 69 | candidate.un_prefer_all(); 70 | self._link_target_candidates.push(candidate); 71 | } 72 | } 73 | } 74 | 75 | pub fn link_match_vec_into_array(link_matches: Vec) -> Array { 76 | let link_matches_array = Array::new(); 77 | for link_match in link_matches { 78 | link_matches_array.push(&link_match.into()); 79 | } 80 | link_matches_array 81 | } -------------------------------------------------------------------------------- /src/rs/matching/link_target_candidate.rs: -------------------------------------------------------------------------------- 1 | use js_sys::Array; 2 | use serde::{Deserialize, Serialize}; 3 | use wasm_bindgen::prelude::*; 4 | 5 | use crate::rs::util::preferrable_item::{preferrable_item_vec_to_array, PreferrableItem}; 6 | 7 | /// A candidate (note) for a Link Match to link to. 8 | #[wasm_bindgen] 9 | #[derive(Clone, Serialize, Deserialize)] 10 | pub struct LinkTargetCandidate { 11 | title: String, // the title of the note 12 | path: String, // the path of the note 13 | 14 | #[serde(rename = "replacement_candidates")] 15 | _replacement_candidates: Vec, // the possible replacements for the matched text 16 | } 17 | 18 | #[wasm_bindgen] 19 | impl LinkTargetCandidate { 20 | #[wasm_bindgen(getter)] 21 | pub fn title(&self) -> String { self.title.clone() } 22 | 23 | #[wasm_bindgen(getter)] 24 | pub fn path(&self) -> String { self.path.clone() } 25 | 26 | #[wasm_bindgen(getter, js_name = "replacementCandidates")] 27 | pub fn replacement_candidates(&self) -> Array { 28 | preferrable_item_vec_to_array(self._replacement_candidates.clone()) 29 | } 30 | 31 | #[wasm_bindgen(method, js_name = "deSelectAll")] 32 | pub fn un_prefer_all(&mut self) { 33 | for replacement_candidate in &mut self._replacement_candidates { 34 | replacement_candidate.is_preferred = false; 35 | } 36 | } 37 | } 38 | 39 | impl LinkTargetCandidate { 40 | pub fn new(title: String, path: String, aliases: &[String], selected_index: usize) -> Self { 41 | let mut _replacement_candidates: Vec = vec![]; 42 | let replacement_candidate_title = PreferrableItem::new(title.clone(), true); 43 | _replacement_candidates.push(replacement_candidate_title); 44 | 45 | aliases.iter().enumerate().for_each(|(index, alias)| { 46 | let replacement_candidate_alias = PreferrableItem::new( 47 | alias.clone(), 48 | // add one because the index starts with the title at 0 49 | true, 50 | ); 51 | _replacement_candidates.push(replacement_candidate_alias); 52 | }); 53 | 54 | LinkTargetCandidate { 55 | title, 56 | path, 57 | _replacement_candidates, 58 | } 59 | } 60 | } 61 | 62 | pub fn link_target_candidate_vec_into_array(link_target_candidates: Vec) -> Array { 63 | let link_target_candidates_array = Array::new(); 64 | for link_target_candidate in link_target_candidates { 65 | link_target_candidates_array.push(&link_target_candidate.into()); 66 | } 67 | link_target_candidates_array 68 | } 69 | -------------------------------------------------------------------------------- /src/rs/matching/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod link_target_candidate; 2 | pub mod link_match; 3 | pub mod link_finder_result; 4 | pub mod link_finder; 5 | pub mod regex_match; -------------------------------------------------------------------------------- /src/rs/matching/regex_match.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use fancy_regex::Captures; 4 | 5 | use crate::rs::Errors::cast_error::CastError; 6 | use crate::rs::util::range::Range; 7 | use crate::rs::util::wasm_util::log; 8 | 9 | /// Utility struct that represents a single match of a regular expression in a note. 10 | pub struct RegexMatch { 11 | pub position: Range, 12 | pub matched_text: String, 13 | pub capture_index: usize, 14 | } 15 | 16 | 17 | impl<'c> TryFrom> for RegexMatch { 18 | type Error = CastError; 19 | 20 | fn try_from(captures: Captures) -> Result { 21 | let valid = captures.iter() 22 | // get index of capture group 23 | .enumerate() 24 | // filter out all capture groups that didn't match 25 | .filter_map(|(i, c)| c.map(|c_| (c_, i))) 26 | // pick the last match 27 | .last(); 28 | 29 | match valid { 30 | Some((m, capture_index)) => { 31 | //log(&format!("Found match {} at index {}", m.as_str().to_string(), capture_index )); 32 | Ok( 33 | RegexMatch { 34 | position: Range::new(m.start(), m.end()), 35 | matched_text: m.as_str().to_string(), 36 | capture_index, 37 | } 38 | ) 39 | } 40 | _ => Err(CastError::CapturesToRegexMatch()) 41 | } 42 | } 43 | } -------------------------------------------------------------------------------- /src/rs/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod text; 2 | pub mod matching; 3 | pub mod note; 4 | pub mod util; 5 | mod Errors; 6 | mod replacer; -------------------------------------------------------------------------------- /src/rs/note/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod note_scanned_event; 2 | pub mod note; -------------------------------------------------------------------------------- /src/rs/note/note.rs: -------------------------------------------------------------------------------- 1 | extern crate unicode_segmentation; 2 | 3 | use std::convert::TryFrom; 4 | 5 | use js_sys::Array; 6 | use serde::{Deserialize, Serialize}; 7 | use wasm_bindgen::prelude::*; 8 | use wasm_bindgen::JsValue; 9 | 10 | use crate::rs::text::text_util::create_string_with_n_characters; 11 | use crate::rs::util::range::{array_to_range_vec, Range}; 12 | use crate::rs::util::wasm_util::generic_of_jsval; 13 | use crate::rs::util::wasm_util::log; 14 | 15 | /// A single note. 16 | #[wasm_bindgen] 17 | #[derive(Clone, Serialize, Deserialize)] 18 | pub struct Note { 19 | title: String, // the title of the note 20 | path: String, // the path of the note 21 | content: String, // the content of the note 22 | #[serde(skip)] 23 | aliases: Array, // possible aliases for the note 24 | #[serde(skip)] 25 | ignore: Array, // text ranges that should be ignored by the Link Finder 26 | 27 | // private fields for JSON serialization 28 | #[serde(rename = "aliases")] 29 | _aliases: Vec, 30 | #[serde(rename = "ignore")] 31 | _ignore: Vec, 32 | #[serde(skip)] 33 | _sanitized_content: String, 34 | } 35 | 36 | #[wasm_bindgen] 37 | impl Note { 38 | #[wasm_bindgen(constructor)] 39 | pub fn new( 40 | title: String, 41 | path: String, 42 | content: String, 43 | aliases: Array, 44 | ignore: Array, 45 | ) -> Note { 46 | let ignore_vec = array_to_range_vec(ignore.clone()); 47 | Note { 48 | title: title, 49 | path, 50 | content: content.clone(), 51 | aliases: aliases.clone(), 52 | ignore, 53 | 54 | _aliases: array_to_string_vec(aliases.clone()), 55 | _ignore: ignore_vec.clone(), 56 | _sanitized_content: Note::sanitize_content(content, ignore_vec), // no need to clone anymore 57 | } 58 | } 59 | 60 | #[wasm_bindgen(getter)] 61 | pub fn title(&self) -> String { 62 | self.title.clone() 63 | } 64 | #[wasm_bindgen(getter)] 65 | pub fn path(&self) -> String { 66 | self.path.clone() 67 | } 68 | #[wasm_bindgen(getter)] 69 | pub fn content(&self) -> String { 70 | self.content.clone() 71 | } 72 | #[wasm_bindgen(getter)] 73 | pub fn aliases(&self) -> Array { 74 | self.aliases.clone() 75 | } 76 | #[wasm_bindgen(getter)] 77 | pub fn ignore(&self) -> Array { 78 | self.ignore.clone() 79 | } 80 | 81 | #[wasm_bindgen(method, js_name = "toJSON")] 82 | pub fn to_json_string(&self) -> String { 83 | serde_json::to_string(self).unwrap() 84 | } 85 | 86 | #[wasm_bindgen(method, js_name = "fromJSON")] 87 | pub fn from_json_string(json_string: &str) -> Self { 88 | serde_json::from_str(json_string).unwrap() 89 | } 90 | } 91 | 92 | impl Note { 93 | pub fn aliases_vec(&self) -> &Vec { 94 | &self._aliases 95 | } 96 | pub fn ignore_vec(&self) -> &Vec { 97 | &self._ignore 98 | } 99 | 100 | /// Cleans up the note content by: 101 | /// - removing all parts of the content that are in the ignore list 102 | fn sanitize_content(mut content: String, ignore_vec: Vec) -> String { 103 | let mut _offset: usize = 0; 104 | let mut _last_start: usize = 9999999; 105 | // the ignore ranges are sorted 106 | for ignore in ignore_vec { 107 | if _last_start == ignore.start() { 108 | // skip this range 109 | continue; 110 | } else { 111 | _last_start = ignore.start(); 112 | } 113 | 114 | let offset = _offset; 115 | let range: std::ops::Range = ignore.clone().into(); 116 | let split_content: Vec = content.encode_utf16().collect(); 117 | let mut new_content: String = String::new(); 118 | 119 | let before_vec_utf_16 = split_content 120 | .iter() 121 | .take(range.start + offset) 122 | .map(|c| c.clone()) 123 | .collect::>(); 124 | 125 | let before_string = String::from_utf16_lossy(before_vec_utf_16.as_slice()); 126 | new_content.push_str(&before_string); 127 | 128 | let replacement_utf_16_vec = split_content 129 | .iter() 130 | .skip(range.start + offset) 131 | .take(range.end - range.start) 132 | .map(|c| c.clone()) 133 | .collect::>(); 134 | 135 | // for the content that should be ignored, we need to push a blank string 136 | // with the *same* length as the utf-8 slice of the string that should be ignored 137 | // if we were to simple push a string of the utf-16 length, 138 | // the regex matching later on would produce false positions 139 | 140 | // utf 16 length 141 | let replacement_len_utf_16 = replacement_utf_16_vec.len(); 142 | // utf 8 length 143 | let replacement_len_utf_8 = 144 | String::from_utf16_lossy(replacement_utf_16_vec.as_slice()).len(); 145 | // now create a string with the same length as the placeholder, but no content 146 | let placeholder_to_push = create_string_with_n_characters(replacement_len_utf_8, '_'); 147 | _offset += replacement_len_utf_8 - replacement_len_utf_16; 148 | new_content.push_str(&placeholder_to_push); 149 | 150 | let after_vec_utf_16 = split_content 151 | .iter() 152 | .skip(range.end + offset) 153 | .map(|c| c.clone()) 154 | .collect::>(); 155 | 156 | let after_string = String::from_utf16_lossy(after_vec_utf_16.as_slice()); 157 | new_content.push_str(&after_string); 158 | 159 | content = new_content 160 | } 161 | 162 | content 163 | } 164 | 165 | pub fn get_sanitized_content(&mut self) -> &String { 166 | if self._sanitized_content.is_empty() { 167 | self._sanitized_content = 168 | Note::sanitize_content(self.content.clone(), self._ignore.clone()); 169 | } 170 | &self._sanitized_content 171 | } 172 | } 173 | 174 | impl TryFrom for Note { 175 | type Error = (); 176 | fn try_from(js: JsValue) -> Result { 177 | note_from_js_value(js).ok_or(()) 178 | } 179 | } 180 | 181 | #[wasm_bindgen] 182 | pub fn note_from_js_value(js: JsValue) -> Option { 183 | generic_of_jsval(js, "Note").unwrap_or(None) 184 | } 185 | 186 | pub fn array_to_string_vec(array: Array) -> Vec { 187 | array 188 | .iter() 189 | .filter_map(|a: JsValue| a.as_string()) 190 | .collect() 191 | } 192 | -------------------------------------------------------------------------------- /src/rs/note/note_scanned_event.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | use crate::rs::note::note::Note; 5 | 6 | /// An event that is emitted when a Note has been scanned for links by the Link Finder. 7 | #[wasm_bindgen] 8 | #[derive(Serialize, Deserialize)] 9 | pub struct NoteScannedEvent { 10 | note_title: String, 11 | note_path: String, 12 | index: usize, 13 | } 14 | #[wasm_bindgen] 15 | impl NoteScannedEvent { 16 | #[wasm_bindgen(constructor)] 17 | pub fn new(note: &Note, index: usize) -> NoteScannedEvent { 18 | NoteScannedEvent { 19 | note_title: note.title(), 20 | note_path: note.path(), 21 | index, 22 | } 23 | } 24 | #[wasm_bindgen(getter, js_name="noteTitle")] 25 | pub fn note_title(&self) -> String { self.note_title.clone() } 26 | 27 | #[wasm_bindgen(getter, js_name="notePath")] 28 | pub fn note_path(&self) -> String { self.note_path.clone() } 29 | 30 | #[wasm_bindgen(getter, js_name="index")] 31 | pub fn index(&self) -> usize { self.index } 32 | 33 | #[wasm_bindgen(method, js_name = "toJSON")] 34 | pub fn to_json_string(&self) -> String { 35 | serde_json::to_string(self).unwrap() 36 | } 37 | 38 | #[wasm_bindgen(method, js_name = "fromJSON")] 39 | pub fn from_json_string(json_string: &str) -> Self { 40 | serde_json::from_str(json_string).unwrap() 41 | } 42 | } -------------------------------------------------------------------------------- /src/rs/replacer/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod replacement; 2 | pub mod note_change_operation; -------------------------------------------------------------------------------- /src/rs/replacer/note_change_operation.rs: -------------------------------------------------------------------------------- 1 | use js_sys::{Array}; 2 | use wasm_bindgen::prelude::*; 3 | use crate::rs::replacer::replacement::Replacement; 4 | use crate::rs::util::wasm_util::generic_of_jsval; 5 | 6 | /// Changes that should be made to a Note. 7 | /// Created after the user selected all the replacements that should be made. 8 | #[wasm_bindgen] 9 | pub struct NoteChangeOperation { 10 | path: String, 11 | content: String, 12 | replacements: Array 13 | } 14 | 15 | #[wasm_bindgen] 16 | impl NoteChangeOperation { 17 | #[wasm_bindgen(constructor)] 18 | pub fn new(path: String, content: String, replacements: Array) -> Self { 19 | NoteChangeOperation { 20 | path, 21 | content, 22 | replacements 23 | } 24 | } 25 | 26 | #[wasm_bindgen(getter)] 27 | pub fn path(&self) -> String {self.path.clone()} 28 | #[wasm_bindgen(getter)] 29 | pub fn content(&self) -> String {self.content.clone()} 30 | #[wasm_bindgen(getter)] 31 | pub fn replacements(&self) -> Array { self.replacements.clone() } 32 | #[wasm_bindgen(setter)] 33 | pub fn set_replacements(&mut self, replacements: Array) { self.replacements = replacements } 34 | 35 | /// Applies the changes to the content. 36 | #[wasm_bindgen(method, js_name = "applyReplacements")] 37 | pub fn apply_replacements (&mut self) { 38 | let mut new_content = self.content.clone(); 39 | // we need to calculate the offset here, because replacements can be 40 | // shorter/longer than the original text, therefore distorting the position of the replacement 41 | let mut offset: i16 = 0; 42 | 43 | self.get_unique_replacements().iter().for_each (|replacement: &Replacement| { 44 | let substitute: &String = &replacement.substitute(); 45 | let mut range: std::ops::Range = replacement.position().into(); 46 | 47 | let offset_range_start = range.start as i16 + offset; 48 | let offset_range_end = range.end as i16 + offset; 49 | range.start = if offset_range_start >= 0 { offset_range_start as usize } else { 0 }; 50 | range.end = if offset_range_end >= 0 { offset_range_end as usize } else { 0 }; 51 | 52 | let number_of_characters_replaced = range.len() as i16; 53 | let replacement_length = substitute.len() as i16; 54 | 55 | new_content.replace_range(range, substitute); 56 | 57 | offset += replacement_length - number_of_characters_replaced; 58 | }); 59 | self.content = new_content; 60 | } 61 | } 62 | 63 | impl NoteChangeOperation { 64 | // returns only replacements that do not overlap each other, sorted 65 | // TODO: Show in the UI, that the filtered out replacements are not applied 66 | pub fn get_unique_replacements(&self) -> Vec { 67 | let mut unique_replacements: Vec = Vec::new(); 68 | self.replacements.iter().for_each(|js_replacement: JsValue| { 69 | let replacement_to_check: Replacement = generic_of_jsval(js_replacement, "Replacement").unwrap(); 70 | 71 | if !unique_replacements.iter().any( 72 | |replacement: &Replacement| { 73 | replacement.position().does_overlap(&replacement_to_check.position()) 74 | } 75 | ) { unique_replacements.push(replacement_to_check) } 76 | }); 77 | unique_replacements.sort_by( 78 | |replacement_1: &Replacement, replacement_2: &Replacement| { 79 | replacement_1.position().start().cmp(&replacement_2.position().start()) 80 | } 81 | ); 82 | unique_replacements 83 | } 84 | } -------------------------------------------------------------------------------- /src/rs/replacer/replacement.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | use crate::rs::util::range::Range; 3 | 4 | /// A single replacement that should be performed on a text. 5 | #[wasm_bindgen] 6 | pub struct Replacement { 7 | position: Range, // the position of the replacement in the text 8 | substitute: String, // the text that should be inserted instead of the original text 9 | 10 | original_substitute: String, // the original text that should be replaced 11 | target_note_path: String, // the path of the note that should be linked to 12 | } 13 | 14 | #[wasm_bindgen] 15 | impl Replacement { 16 | #[wasm_bindgen(constructor)] 17 | pub fn new(position: Range, substitute: String, original_substitute: String, target_note_path: String) -> Self { 18 | Replacement { 19 | position, 20 | substitute, 21 | original_substitute, 22 | target_note_path 23 | } 24 | } 25 | 26 | #[wasm_bindgen(getter)] 27 | pub fn position(&self) -> Range {self.position.clone()} 28 | #[wasm_bindgen(getter)] 29 | pub fn substitute(&self) -> String { self.substitute.clone() } 30 | #[wasm_bindgen(getter, js_name = "originalSubstitute")] 31 | pub fn original_substitute(&self) -> String { self.original_substitute.clone() } 32 | #[wasm_bindgen(getter, js_name = "targetNotePath")] 33 | pub fn target_note_path(&self) -> String { self.target_note_path.clone() } 34 | } -------------------------------------------------------------------------------- /src/rs/text/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod text_context; 2 | pub mod text_util; 3 | pub mod text_context_tail; 4 | 5 | -------------------------------------------------------------------------------- /src/rs/text/text_context.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | use crate::rs::note::note::Note; 4 | use crate::rs::text::text_context_tail::TextContextTail; 5 | use crate::rs::util::range::Range; 6 | use serde::{Serialize, Deserialize}; 7 | 8 | /// The text before and after a match as well as the match itself. 9 | #[wasm_bindgen] 10 | #[derive(Clone, Serialize, Deserialize)] 11 | pub struct TextContext { 12 | left_context_tail: TextContextTail, 13 | right_context_tail: TextContextTail, 14 | match_text: String, 15 | } 16 | 17 | #[wasm_bindgen] 18 | impl TextContext { 19 | #[wasm_bindgen(getter, js_name = "leftContextTail")] 20 | pub fn left_context_tail(&self) -> TextContextTail { 21 | self.left_context_tail.clone() 22 | } 23 | #[wasm_bindgen(getter, js_name = "rightContextTail")] 24 | pub fn right_context_tail(&self) -> TextContextTail { 25 | self.right_context_tail.clone() 26 | } 27 | #[wasm_bindgen(getter, js_name = "matchText")] 28 | pub fn match_text(&self) -> String { 29 | self.match_text.clone() 30 | } 31 | } 32 | 33 | impl TextContext { 34 | pub fn new(note: &Note, match_position: Range, match_text: String) -> TextContext { 35 | TextContext { 36 | left_context_tail: TextContextTail::new(note, &match_position, true), 37 | right_context_tail: TextContextTail::new(note, &match_position, false), 38 | match_text, 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/rs/text/text_context_tail.rs: -------------------------------------------------------------------------------- 1 | use wasm_bindgen::prelude::*; 2 | 3 | use crate::rs::note::note::Note; 4 | use crate::rs::text::text_util::get_nearest_char_boundary; 5 | use crate::rs::util::range; 6 | use serde::{Serialize, Deserialize}; 7 | 8 | /// One of the two sides of a text context. 9 | #[wasm_bindgen] 10 | #[derive(Clone, Serialize, Deserialize)] 11 | pub struct TextContextTail { 12 | text: String, 13 | position: range::Range, 14 | } 15 | 16 | #[wasm_bindgen] 17 | impl TextContextTail { 18 | #[wasm_bindgen(getter)] 19 | pub fn text(&self) -> String { self.text.clone() } 20 | #[wasm_bindgen(getter)] 21 | pub fn position(&self) -> range::Range { self.position.clone() } 22 | } 23 | 24 | impl TextContextTail { 25 | pub(crate) const TAIL_SIZE: usize = 10; 26 | 27 | pub fn new(note: &Note, match_position: &range::Range, is_left_tail: bool) -> TextContextTail { 28 | let position = if is_left_tail { 29 | range::Range::new( 30 | if match_position.start() >= TextContextTail::TAIL_SIZE * 2 { 31 | match_position.start() - &TextContextTail::TAIL_SIZE * 2 32 | } else { 33 | 0 34 | }, 35 | match_position.start(), 36 | ) 37 | } else { 38 | range::Range::new( 39 | match_position.end(), 40 | if match_position.end() + TextContextTail::TAIL_SIZE * 2 >= note.content().len() { 41 | note.content().len() - 1 42 | } else { 43 | match_position.end() + TextContextTail::TAIL_SIZE * 2 44 | }, 45 | ) 46 | }; 47 | TextContextTail { 48 | text: TextContextTail::get_context_text( 49 | position.clone(), 50 | ¬e.content()), 51 | position, 52 | } 53 | } 54 | 55 | fn get_context_text(text_position: range::Range, text: &str) -> String { 56 | let start = get_nearest_char_boundary(text, text_position.start(), true); 57 | let end = get_nearest_char_boundary(text, text_position.end(), false); 58 | if end > start { 59 | return text[start..end] 60 | .chars() 61 | .map( 62 | // replace newline with whitespace 63 | |c| if c == '\n' { ' ' } else { c }, 64 | ) 65 | .collect() 66 | } 67 | "".to_string() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/rs/text/text_util.rs: -------------------------------------------------------------------------------- 1 | /// Creates a string with n times character c. 2 | pub fn create_string_with_n_characters(n: usize, c: char) -> String { 3 | let mut s = String::new(); 4 | for _ in 0..n { 5 | s.push(c); 6 | } 7 | s 8 | } 9 | 10 | /// returns the nearest char boundary (e.g. that is not an emoji) 11 | pub fn get_nearest_char_boundary(text: &str, position: usize, do_expand_left: bool) -> usize { 12 | let mut i = position; 13 | let mut direction = do_expand_left; 14 | while i > 0 && !text.is_char_boundary(i) { 15 | if text.len() == i || i == 0 { 16 | direction = !direction; 17 | } 18 | if direction { 19 | i -= 1; 20 | } else { 21 | i += 1; 22 | } 23 | } 24 | i 25 | } 26 | -------------------------------------------------------------------------------- /src/rs/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod wasm_util; 2 | pub mod range; 3 | pub mod preferrable_item; -------------------------------------------------------------------------------- /src/rs/util/preferrable_item.rs: -------------------------------------------------------------------------------- 1 | use js_sys::Array; 2 | use serde::{Deserialize, Serialize}; 3 | use wasm_bindgen::prelude::*; 4 | 5 | /// An item that has a preferred state. 6 | #[wasm_bindgen] 7 | #[derive(Clone, Serialize, Deserialize)] 8 | pub struct PreferrableItem { 9 | pub(crate) content: String, 10 | pub(crate) is_preferred: bool, 11 | } 12 | 13 | #[wasm_bindgen] 14 | impl PreferrableItem { 15 | #[wasm_bindgen(constructor)] 16 | pub fn new(content: String, is_preferred: bool) -> PreferrableItem { 17 | PreferrableItem { 18 | content, 19 | is_preferred, 20 | } 21 | } 22 | 23 | #[wasm_bindgen(getter)] 24 | pub fn content(&self) -> String { self.content.clone() } 25 | #[wasm_bindgen(getter, js_name = "isPreferred")] 26 | pub fn is_preferred(&self) -> bool { self.is_preferred } 27 | } 28 | 29 | pub fn preferrable_item_vec_to_array(preferrable_items_vec: Vec) -> Array { 30 | let preferrable_items_array = Array::new(); 31 | for preferrable_item in preferrable_items_vec { 32 | preferrable_items_array.push(&preferrable_item.into()); 33 | } 34 | preferrable_items_array 35 | } -------------------------------------------------------------------------------- /src/rs/util/range.rs: -------------------------------------------------------------------------------- 1 | use std::convert::TryFrom; 2 | 3 | use js_sys::Array; 4 | use serde::{Deserialize, Serialize}; 5 | use thiserror::Error; 6 | use wasm_bindgen::prelude::*; 7 | use wasm_bindgen::JsValue; 8 | 9 | use crate::rs::util::wasm_util::generic_of_jsval; 10 | 11 | /// A range (of text in a note). 12 | /// Implemented so that there also is a Range class in the shared JS code. 13 | #[wasm_bindgen] 14 | #[derive(Clone, Serialize, Deserialize)] 15 | pub struct Range { 16 | start: usize, 17 | end: usize, 18 | } 19 | 20 | #[wasm_bindgen] 21 | impl Range { 22 | #[wasm_bindgen(constructor)] 23 | pub fn new(start: usize, end: usize) -> Range { 24 | Range { start, end } 25 | } 26 | 27 | #[wasm_bindgen(getter)] 28 | pub fn start(&self) -> usize { 29 | self.start 30 | } 31 | 32 | #[wasm_bindgen(getter)] 33 | pub fn end(&self) -> usize { 34 | self.end 35 | } 36 | 37 | #[wasm_bindgen(method)] 38 | pub fn is_equal_to(&self, range: &Range) -> bool { 39 | self.start == range.start && self.end == range.end 40 | } 41 | 42 | #[wasm_bindgen(method)] 43 | pub fn does_overlap(&self, range: &Range) -> bool { 44 | self.start <= range.end && range.start <= self.end 45 | } 46 | } 47 | 48 | impl Into> for Range { 49 | fn into(self) -> std::ops::Range { 50 | std::ops::Range { 51 | start: self.start, 52 | end: self.end, 53 | } 54 | } 55 | } 56 | 57 | #[derive(Debug, Error)] 58 | pub enum RangeError { 59 | #[error("Range is faulty")] 60 | Faulty, 61 | #[error("Range cast failed")] 62 | Cast, 63 | } 64 | 65 | impl TryFrom for Range { 66 | type Error = RangeError; 67 | fn try_from(value: JsValue) -> Result { 68 | range_from_js_value(value.clone()).ok_or(RangeError::Cast) 69 | } 70 | } 71 | 72 | impl From> for Range { 73 | fn from(range: std::ops::Range) -> Self { 74 | Range { 75 | start: range.start, 76 | end: range.end, 77 | } 78 | } 79 | } 80 | 81 | #[wasm_bindgen] 82 | pub fn range_from_js_value(js: JsValue) -> Option { 83 | let result = generic_of_jsval(js, "Range"); 84 | if let Ok(range) = result { 85 | Some(range) 86 | } else { 87 | None 88 | } 89 | } 90 | 91 | pub fn array_to_range_vec(range_array: Array) -> Vec { 92 | range_array 93 | .iter() 94 | .filter_map(|js_val_range: JsValue| generic_of_jsval(js_val_range, "Range").ok()) 95 | .collect() 96 | } 97 | -------------------------------------------------------------------------------- /src/rs/util/wasm_util.rs: -------------------------------------------------------------------------------- 1 | extern crate console_error_panic_hook; 2 | 3 | use std::panic; 4 | 5 | use js_sys::Array; 6 | use wasm_bindgen::{JsCast, JsValue}; 7 | use wasm_bindgen::convert::FromWasmAbi; 8 | use wasm_bindgen::prelude::*; 9 | 10 | /// Creates an generic of a JsValue 11 | /// Code from: https://github.com/rustwasm/wasm-bindgen/issues/2231 12 | pub fn generic_of_jsval>(js: JsValue, classname: &str) -> Result { 13 | use js_sys::{Object, Reflect}; 14 | let ctor_name = Object::get_prototype_of(&js).constructor().name(); 15 | if ctor_name == classname { 16 | let ptr = Reflect::get(&js, &JsValue::from_str("ptr"))?; 17 | let ptr_u32: u32 = ptr.as_f64().ok_or(JsValue::NULL)? as u32; 18 | let foo = unsafe { T::from_abi(ptr_u32) }; 19 | Ok(foo) 20 | } else { 21 | Err(JsValue::NULL) 22 | } 23 | } 24 | 25 | #[wasm_bindgen] 26 | pub fn init_panic_hook() { 27 | console_error_panic_hook::set_once(); 28 | } 29 | 30 | #[wasm_bindgen] 31 | extern "C" { 32 | // import console log 33 | #[wasm_bindgen(js_namespace = console)] 34 | pub fn log(s: &str); 35 | } -------------------------------------------------------------------------------- /src/ts/MainModal.tsx: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | import React from "react"; 3 | import { createRoot, Root } from "react-dom/client"; 4 | import { AppContext, WasmWorkerInstanceContext } from "./context"; 5 | import { 6 | MainComponent, 7 | MatchingMode, 8 | } from "./components/containers/MainComponent"; 9 | 10 | export default class MainModal extends Modal { 11 | private root: Root; 12 | private readonly wasmComlinkWorkerInstance: any; 13 | private readonly _onClose: () => void; 14 | private readonly _matchingMode?: MatchingMode; 15 | 16 | constructor( 17 | app: App, 18 | instance: any, 19 | _onClose: () => void, 20 | _matchingMode?: MatchingMode 21 | ) { 22 | super(app); 23 | this.wasmComlinkWorkerInstance = instance; 24 | this._onClose = _onClose; 25 | this._matchingMode = _matchingMode ?? MatchingMode.None; 26 | } 27 | 28 | onOpen() { 29 | this.root = createRoot(this.contentEl); 30 | // add class to root 31 | this.root.render( 32 | 33 | 36 | 37 | 38 | 39 | ); 40 | } 41 | 42 | onClose() { 43 | super.onClose(); 44 | this._onClose(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/ts/components/containers/MainComponent.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { StartComponent } from "./StartComponent"; 3 | import { MatcherComponent } from "./MatcherComponent"; 4 | 5 | export enum MatchingMode { 6 | None, 7 | Vault, 8 | Note, 9 | } 10 | 11 | export interface MainComponentProps { 12 | _matchingMode: MatchingMode; 13 | } 14 | 15 | export const MainComponent = ({ _matchingMode }: MainComponentProps) => { 16 | const [matchingMode, setMatchingMode] = useState(_matchingMode); 17 | 18 | const onClickScan = (type: MatchingMode) => { 19 | setMatchingMode(type); 20 | }; 21 | 22 | if (matchingMode == MatchingMode.None) 23 | return ; 24 | else return ; 25 | }; 26 | -------------------------------------------------------------------------------- /src/ts/components/containers/MatchSelectionComponent.tsx: -------------------------------------------------------------------------------- 1 | import {NoteFilesContext} from "../../context"; 2 | import {LinkFinderResultsList} from "../lists/LinkFinderResultsListComponent"; 3 | import * as React from "react"; 4 | import {useEffect, useState} from "react"; 5 | import {TFile} from "obsidian"; 6 | import { 7 | LinkMatch, 8 | LinkTargetCandidate, 9 | NoteChangeOperation, 10 | LinkFinderResult, 11 | Replacement, 12 | PreferrableItem 13 | } from "../../../../pkg"; 14 | import {useApp} from "../../hooks"; 15 | import {LoadingComponent} from "../other/LoadingComponent"; 16 | 17 | interface MatchSelectionComponentProps { 18 | linkFinderResults: Array; 19 | onClickReplaceButton: (noteChangeOperations: Map, noteFiles: Map) => void; 20 | } 21 | 22 | export const MatchSelectionComponent = ({ 23 | linkFinderResults, 24 | onClickReplaceButton 25 | }: MatchSelectionComponentProps) => { 26 | 27 | const {vault, fileManager} = useApp(); 28 | const [noteChangeOperations, setNoteChangeOperations] = useState>(undefined); 29 | 30 | const [noteFiles] = useState>(() => { 31 | const noteFiles = new Map(); 32 | vault.getFiles().forEach((file: TFile) => noteFiles.set(file.path, file)) 33 | return noteFiles 34 | }); 35 | 36 | const initNoteChangeOperations = (linkFinderResults: Array) => { 37 | const operations: Map = new Map; 38 | linkFinderResults.forEach((result: LinkFinderResult) => { 39 | const path = result.note.path; 40 | const content = result.note.content; 41 | const replacements: Array = []; 42 | result.linkMatches.forEach((match: LinkMatch) => { 43 | match.linkTargetCandidates.forEach((candidate: LinkTargetCandidate) => { 44 | candidate.replacementCandidates.forEach((replacementCandidate: PreferrableItem) => { 45 | if (replacementCandidate.isPreferred) { 46 | replacements.push( 47 | new Replacement( 48 | match.position, 49 | fileManager.generateMarkdownLink( 50 | noteFiles.get(candidate.path), 51 | result.note.path, 52 | null, 53 | replacementCandidate.content == result.note.title 54 | ? null 55 | : replacementCandidate.content 56 | ), 57 | replacementCandidate.content, 58 | candidate.path 59 | ) 60 | ) 61 | return; 62 | } 63 | }) 64 | }) 65 | }) 66 | if (replacements.length > 0) operations.set(path, new NoteChangeOperation( 67 | path, 68 | content, 69 | replacements 70 | )) 71 | }) 72 | setNoteChangeOperations(operations) 73 | } 74 | 75 | useEffect(() => initNoteChangeOperations(linkFinderResults), [linkFinderResults]); 76 | 77 | return ( 78 | {noteChangeOperations !== undefined ? onClickReplaceButton(noteChangeOperations, noteFiles)} 80 | /> : } 81 | ) 82 | } -------------------------------------------------------------------------------- /src/ts/components/containers/MatcherComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect, useState } from "react"; 3 | import * as Comlink from "comlink"; 4 | import { 5 | NoteChangeOperation, 6 | LinkFinderResult, 7 | NoteScannedEvent, 8 | } from "../../../../pkg"; 9 | import JsNote from "../../objects/JsNote"; 10 | import Progress from "../../objects/Progress"; 11 | import { ProgressComponent } from "../other/ProgressComponent"; 12 | import { MatchSelectionComponent } from "./MatchSelectionComponent"; 13 | import { Notice, TFile } from "obsidian"; 14 | import { useApp, useWasmWorkerInstance } from "../../hooks"; 15 | import { MatchingMode } from "./MainComponent"; 16 | 17 | enum MatchingState { 18 | Initializing, 19 | Scanning, 20 | Selecting, 21 | Replacing, 22 | Finished, 23 | Error, 24 | } 25 | 26 | export const MatcherComponent = ({ 27 | matchingMode, 28 | }: { 29 | matchingMode: MatchingMode; 30 | }) => { 31 | const { vault, metadataCache } = useApp(); 32 | const wasmWorkerInstance = useWasmWorkerInstance(); 33 | 34 | const [matchingState, setMatchingState] = useState( 35 | MatchingState.Initializing 36 | ); 37 | const [numberOfLinkedNotes, setNumberOfLinkedNotes] = useState(0); 38 | const [linkFinderResults, setLinkFinderResults] = useState< 39 | Array 40 | >([]); 41 | const [linkMatchingProgress, setLinkMatchingProgress] = useState( 42 | new Progress( 43 | matchingMode == MatchingMode.Vault ? JsNote.getNumberOfNotes(vault) : 1 44 | ) 45 | ); 46 | 47 | const onLinkMatchingProgress = (serializedNoteScannedEvent: string) => { 48 | const noteScannedEvent: NoteScannedEvent = NoteScannedEvent.fromJSON( 49 | serializedNoteScannedEvent 50 | ); 51 | const newLinkMatchingProgress = new Progress( 52 | linkMatchingProgress.max, 53 | noteScannedEvent 54 | ); 55 | setLinkMatchingProgress(newLinkMatchingProgress); 56 | }; 57 | 58 | const onStartReplacing = () => { 59 | setMatchingState(MatchingState.Replacing); 60 | }; 61 | 62 | const onFinishReplacing = (num: number) => { 63 | setNumberOfLinkedNotes(num); 64 | setMatchingState(MatchingState.Finished); 65 | }; 66 | 67 | const handleReplaceButtonClicked = ( 68 | noteChangeOperations: Map, 69 | noteFiles: Map 70 | ) => { 71 | onStartReplacing(); 72 | const operations: Array> = []; 73 | let totalNum = 0; 74 | noteChangeOperations.forEach((op: NoteChangeOperation) => { 75 | totalNum += op.replacements.length; 76 | op.applyReplacements(); 77 | const noteFile = noteFiles.get(op.path); 78 | operations.push(vault.modify(noteFile, op.content)); 79 | }); 80 | Promise.all(operations).then(() => onFinishReplacing(totalNum)); 81 | }; 82 | 83 | const getLinkFinderResults = async (jsNotes: JsNote[]) => { 84 | setMatchingState(MatchingState.Scanning); 85 | const noteStrings: Array = jsNotes.map((jsNote: JsNote) => 86 | jsNote.toJSON() 87 | ); 88 | if (matchingMode == MatchingMode.Vault) { 89 | // Search entire vault 90 | return wasmWorkerInstance.findInVault( 91 | noteStrings, 92 | Comlink.proxy(onLinkMatchingProgress) 93 | ); 94 | } else { 95 | // Search only the active note 96 | const activeFile = app.workspace.getActiveFile(); 97 | if (activeFile !== null && activeFile.extension === "md") { 98 | const activeNoteString = ( 99 | await JsNote.fromFile(activeFile, vault, metadataCache) 100 | ).toJSON(); 101 | 102 | return wasmWorkerInstance.findInNote( 103 | activeNoteString, 104 | noteStrings, 105 | Comlink.proxy(onLinkMatchingProgress) 106 | ); 107 | } else { 108 | new Notice("No active note found"); 109 | } 110 | } 111 | }; 112 | 113 | const showMatchSelection = ( 114 | serializedNoteLinkMatchResults: Array 115 | ) => { 116 | const linkFinderResults: Array = 117 | serializedNoteLinkMatchResults.map((linkFinderResult: string) => 118 | LinkFinderResult.fromJSON(linkFinderResult) 119 | ); 120 | setLinkFinderResults(linkFinderResults); 121 | setMatchingState(MatchingState.Selecting); 122 | }; 123 | 124 | const showError = (error: Error) => { 125 | console.error(error); 126 | setMatchingState(MatchingState.Error); 127 | }; 128 | 129 | useEffect(() => { 130 | JsNote.getNotesFromVault(vault, metadataCache) 131 | .then(getLinkFinderResults) 132 | .then(showMatchSelection) 133 | .catch(showError); 134 | }, [wasmWorkerInstance]); 135 | 136 | if (matchingState == MatchingState.Initializing) { 137 | return
🏗️ Retrieving notes...
; 138 | } else if (matchingState == MatchingState.Scanning) 139 | return ; 140 | else if (matchingState == MatchingState.Selecting) 141 | return ( 142 | 146 | ); 147 | else if (matchingState == MatchingState.Replacing) 148 | return
⏳ Linking Notes...
; 149 | else if (matchingState == MatchingState.Finished) 150 | return ( 151 |
152 | 🎉 Successfully created {numberOfLinkedNotes} new links! 153 |
154 | ); 155 | else 156 | return ( 157 |
158 | 💀 An error occurred while linking notes. 159 |
160 | ); 161 | }; 162 | -------------------------------------------------------------------------------- /src/ts/components/containers/StartComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { MatchingMode } from "./MainComponent"; 3 | 4 | interface StartComponentProps { 5 | onClickScan: (type: MatchingMode) => void; 6 | } 7 | 8 | export const StartComponent = ({ onClickScan }: StartComponentProps) => { 9 | return ( 10 |
11 |

🔗 Obsidian Note Linker

12 | 13 | Note: Please backup your vault before using this plugin. This plugin is 14 | in beta stage and has therefore not been tested sufficiently. 15 | 16 |
17 | 20 | 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /src/ts/components/list-items/ReplacementItemComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {useCallback} from "react"; 3 | import {LinkMatch, LinkTargetCandidate, PreferrableItem, Replacement} from "../../../../pkg"; 4 | import {generateMockupMdLink} from "../../util"; 5 | import {useApp, useLinkFinderResult, useLinkMatch, useLinkTargetCandidate, useNoteFiles,} from "../../hooks"; 6 | 7 | interface ReplacementItemComponentProps { 8 | selectedReplacement: Replacement, 9 | setSelectedReplacement: React.Dispatch>, 10 | replacementCandidate: PreferrableItem 11 | } 12 | 13 | export const ReplacementItemComponent = ({ 14 | selectedReplacement, 15 | setSelectedReplacement, 16 | replacementCandidate 17 | }: ReplacementItemComponentProps) => { 18 | const {fileManager} = useApp(); 19 | const parentNote = useLinkFinderResult().note; 20 | const linkMatch = useLinkMatch(); 21 | const linkTargetCandidate = useLinkTargetCandidate(); 22 | const noteFiles = useNoteFiles(); 23 | 24 | 25 | const isSelected = useCallback(() => { 26 | if (selectedReplacement === undefined) return false; 27 | return selectedReplacement.position.is_equal_to(linkMatch.position) && 28 | selectedReplacement.targetNotePath == linkTargetCandidate.path && 29 | selectedReplacement.originalSubstitute == replacementCandidate.content 30 | }, [selectedReplacement]); 31 | 32 | 33 | const subtractReplacement = () => { 34 | setSelectedReplacement(undefined); 35 | } 36 | 37 | const addReplacement = (noteChangeOperationToAdd: Replacement) => { 38 | setSelectedReplacement(noteChangeOperationToAdd); 39 | } 40 | 41 | const handleSelect = (replacementCandidate: PreferrableItem, candidate: LinkTargetCandidate, doAdd: boolean, linkMatch: LinkMatch) => { 42 | const replacement = new Replacement( 43 | linkMatch.position, 44 | fileManager.generateMarkdownLink( 45 | noteFiles.get(candidate.path), 46 | parentNote.path, 47 | null, 48 | replacementCandidate.content == parentNote.title 49 | ? null 50 | : replacementCandidate.content 51 | ), 52 | replacementCandidate.content, 53 | candidate.path 54 | ); 55 | 56 | if (doAdd) addReplacement(replacement) 57 | else subtractReplacement() 58 | } 59 | 60 | return ( 61 |
  • handleSelect(replacementCandidate, linkTargetCandidate, !isSelected(), linkMatch)}> 63 | { 68 | }} 69 | /> 70 | 71 | "{replacementCandidate.content}" 72 | 73 |
    74 | 75 | 76 | "... {linkMatch.context.leftContextTail.text} 77 | 78 | 79 | {generateMockupMdLink(replacementCandidate.content, linkTargetCandidate.title)} 80 | 81 | 82 | {linkMatch.context.rightContextTail.text} ..." 83 | 84 |
    85 |
  • 86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /src/ts/components/lists/LinkFinderResultsListComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { LinkFinderResult, Note, NoteChangeOperation } from "../../../../pkg"; 3 | import { LinkFinderResultContext } from "../../context"; 4 | import { LinkMatchesListComponent } from "./LinkMatchesListComponent"; 5 | 6 | interface LinkFinderResultsListProps { 7 | linkFinderResults: Array; 8 | onClickReplaceButton: () => void; 9 | noteChangeOperations: Map; 10 | setNoteChangeOperations: React.Dispatch< 11 | React.SetStateAction> 12 | >; 13 | } 14 | 15 | export const LinkFinderResultsList = ({ 16 | linkFinderResults, 17 | onClickReplaceButton, 18 | noteChangeOperations, 19 | setNoteChangeOperations, 20 | }: LinkFinderResultsListProps) => { 21 | const [currentPage, setCurrentPage] = React.useState(0); 22 | const itemsPerPage = 30; 23 | 24 | const totalPages = Math.ceil(linkFinderResults.length / itemsPerPage); 25 | 26 | const currentItems = linkFinderResults.slice( 27 | currentPage * itemsPerPage, 28 | (currentPage + 1) * itemsPerPage 29 | ); 30 | 31 | const findNoteChangeOperation = ( 32 | note: Note 33 | ): NoteChangeOperation | undefined => { 34 | return noteChangeOperations.get(note.path); 35 | }; 36 | 37 | const findReplacements = (note: Note) => { 38 | return findNoteChangeOperation(note)?.replacements ?? []; 39 | }; 40 | 41 | const totalReplacements = React.useCallback(() => { 42 | let total = 0; 43 | noteChangeOperations?.forEach( 44 | (noteChangeOperation: NoteChangeOperation) => { 45 | total += noteChangeOperation?.replacements?.length; 46 | } 47 | ); 48 | return total; 49 | }, [noteChangeOperations]); 50 | 51 | const handlePrevPage = () => { 52 | setCurrentPage((prevPage) => Math.max(prevPage - 1, 0)); 53 | }; 54 | 55 | const handleNextPage = () => { 56 | setCurrentPage((prevPage) => Math.min(prevPage + 1, totalPages - 1)); 57 | }; 58 | 59 | if (linkFinderResults.length !== 0) 60 | return ( 61 |
    62 |

    Note Link Matches

    63 |
      64 | {currentItems.map((linkFinderResult: LinkFinderResult) => { 65 | const selectedReplacements = findReplacements( 66 | linkFinderResult.note 67 | ); 68 | return ( 69 | 73 | 78 | 79 | ); 80 | })} 81 |
    82 |
    83 | 86 | 87 | Page {currentPage + 1} of {totalPages} 88 | 89 | 95 |
    96 | 99 |
    100 | ); 101 | else 102 | return ( 103 |
    👀 No notes to link could be found.
    104 | ); 105 | }; 106 | -------------------------------------------------------------------------------- /src/ts/components/lists/LinkMatchesListComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dispatch, SetStateAction } from "react"; 3 | import { LinkMatch, NoteChangeOperation, Replacement } from "../../../../pkg"; 4 | import { LinkTargetCandidatesListComponent } from "./LinkTargetCandidatesListComponent"; 5 | import { LinkFinderResultTitleComponent } from "../titles/LinkFinderResultTitleComponent"; 6 | import { LinkMatchContext } from "../../context"; 7 | import { useLinkFinderResult } from "../../hooks"; 8 | 9 | interface LinkMatchesListComponentProps { 10 | selectedReplacements: Array; 11 | noteChangeOperations: Map; 12 | setNoteChangeOperations: Dispatch< 13 | SetStateAction> 14 | >; 15 | } 16 | 17 | export const LinkMatchesListComponent = ( 18 | ({ 19 | selectedReplacements, 20 | noteChangeOperations, 21 | setNoteChangeOperations, 22 | }: LinkMatchesListComponentProps) => { 23 | const linkFinderResult = useLinkFinderResult(); 24 | const parentNote = useLinkFinderResult().note; 25 | 26 | return ( 27 |
  • 28 | 32 |
      33 | {linkFinderResult.linkMatches.map((link_match: LinkMatch) => { 34 | // console.log(noteChangeOperations); 35 | const noteChangeOperation: NoteChangeOperation = 36 | noteChangeOperations.get(parentNote.path); 37 | const selectedReplacement = selectedReplacements.find( 38 | (replacement: Replacement) => { 39 | return replacement.position.is_equal_to(link_match.position); 40 | } 41 | ); 42 | 43 | return ( 44 | 48 | 54 | 55 | ); 56 | })} 57 |
    58 |
  • 59 | ); 60 | } 61 | ); 62 | -------------------------------------------------------------------------------- /src/ts/components/lists/LinkTargetCandidatesListComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dispatch, SetStateAction } from "react"; 3 | import { 4 | LinkTargetCandidate, 5 | NoteChangeOperation, 6 | Replacement, 7 | } from "../../../../pkg"; 8 | import { LinkMatchTitleComponent } from "../titles/LinkMatchTitleComponent"; 9 | import { ReplacementsSelectionComponent } from "./ReplacementsSelectionComponent"; 10 | import { LinkTargetCandidateContext } from "../../context"; 11 | import { useLinkFinderResult, useLinkMatch } from "../../hooks"; 12 | 13 | interface LinkTargetCandidatesListComponentProps { 14 | _selectedReplacement: Replacement; 15 | noteChangeOperation: NoteChangeOperation; 16 | noteChangeOperations: Map; 17 | setNoteChangeOperations: Dispatch< 18 | SetStateAction> 19 | >; 20 | } 21 | 22 | export const LinkTargetCandidatesListComponent = 23 | ({ 24 | _selectedReplacement, 25 | noteChangeOperation, 26 | noteChangeOperations, 27 | setNoteChangeOperations, 28 | }: LinkTargetCandidatesListComponentProps) => { 29 | const linkMatch = useLinkMatch(); 30 | const parentNote = useLinkFinderResult().note; 31 | 32 | const [selectedReplacement, setSelectedReplacement] = 33 | React.useState(_selectedReplacement); 34 | const createNoteChangeOperation = () => { 35 | return new NoteChangeOperation(parentNote.path, parentNote.content, [ 36 | selectedReplacement, 37 | ]); 38 | }; 39 | 40 | const removeReplacement = () => { 41 | if (noteChangeOperation) { 42 | noteChangeOperation.replacements = 43 | noteChangeOperation.replacements?.filter( 44 | (r: Replacement) => !r.position.is_equal_to(linkMatch.position) 45 | ); 46 | saveNoteChangeOperation(noteChangeOperation); 47 | } 48 | }; 49 | 50 | const addReplacement = (replacement: Replacement) => { 51 | const _noteChangeOperation = 52 | noteChangeOperation !== undefined 53 | ? noteChangeOperation 54 | : createNoteChangeOperation(); 55 | _noteChangeOperation.replacements = 56 | _noteChangeOperation.replacements?.filter( 57 | (r: Replacement) => !r.position.is_equal_to(linkMatch.position) 58 | ); 59 | _noteChangeOperation.replacements?.push(replacement); 60 | saveNoteChangeOperation(_noteChangeOperation); 61 | }; 62 | 63 | const saveNoteChangeOperation = ( 64 | _noteChangeOperation: NoteChangeOperation 65 | ) => { 66 | const _noteChangeOperations = new Map(noteChangeOperations.entries()); 67 | if (_noteChangeOperation.replacements.length == 0) 68 | _noteChangeOperations.delete(_noteChangeOperation.path); 69 | else _noteChangeOperations.set(parentNote.path, _noteChangeOperation); 70 | setNoteChangeOperations(_noteChangeOperations); 71 | }; 72 | 73 | React.useEffect(() => { 74 | if (selectedReplacement === undefined) removeReplacement(); 75 | else addReplacement(selectedReplacement); 76 | }, [selectedReplacement]); 77 | 78 | return ( 79 |
    80 | 84 |
      85 | {linkMatch.linkTargetCandidates.map( 86 | (linkTargetCandidate: LinkTargetCandidate) => ( 87 | 91 | 95 | 96 | ) 97 | )} 98 |
    99 |
    100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /src/ts/components/lists/ReplacementsSelectionComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {PreferrableItem, Replacement} from "../../../../pkg"; 3 | import {ReplacementItemComponent} from "../list-items/ReplacementItemComponent"; 4 | import {useLinkTargetCandidate} from "../../hooks"; 5 | 6 | 7 | interface ReplacementsSelectionComponentProps { 8 | selectedReplacement: Replacement, 9 | setSelectedReplacement: React.Dispatch> 10 | } 11 | 12 | export const ReplacementsSelectionComponent = ({ 13 | selectedReplacement, 14 | setSelectedReplacement 15 | }: ReplacementsSelectionComponentProps) => { 16 | const linkTargetCandidate = useLinkTargetCandidate(); 17 | return ( 18 |
  • 19 | 🔗{linkTargetCandidate.path} 20 |
      21 | {linkTargetCandidate.replacementCandidates.map((replacementCandidate: PreferrableItem, index: number) => 22 | 27 | )} 28 |
    29 |
  • 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/ts/components/other/LoadingComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export const LoadingComponent = ({loadingText}: { loadingText: string }) => { 4 | 5 | const ASCII_ART_CARD = (String.raw` 6 | Note Link Matches 7 | ┌───────────────────┐ 8 | │ ──── │ 9 | │ □ ────────────── │ 10 | │ □ ────────────── │ 11 | └───────────────────┘ 12 | ┌───────────────────┐ 13 | │ ──── │ 14 | │ □ ────────────── │ 15 | │ □ ────────────── │ 16 | │ ──── │ 17 | │ □ ────────────── │ 18 | └───────────────────┘ 19 | `); 20 | return
    21 |

    {loadingText}

    22 | {ASCII_ART_CARD} 23 |
    24 | } -------------------------------------------------------------------------------- /src/ts/components/other/ProgressComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import Progress from "../../objects/Progress"; 3 | 4 | interface LinkMatcherProgressComponentProps { 5 | progress: Progress; 6 | } 7 | 8 | export const ProgressComponent = ({ 9 | progress, 10 | }: LinkMatcherProgressComponentProps) => { 11 | return ( 12 |
    13 |

    🔎 Scanning notes...

    14 | {progress.asAsciiArt()} 15 | 16 | {progress.asDescriptionText()} 17 | 18 |
    19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/ts/components/titles/LinkFinderResultTitleComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | interface LinkFinderResultTitleProps { 4 | noteTitle: string, 5 | notePath: string 6 | } 7 | 8 | export const LinkFinderResultTitleComponent = ({noteTitle, notePath}: LinkFinderResultTitleProps) => { 9 | 10 | return ( 11 |
    12 |

    {noteTitle}

    13 | {notePath} 14 |
    15 | ); 16 | }; -------------------------------------------------------------------------------- /src/ts/components/titles/LinkMatchTitleComponent.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import {Range} from "../../../../pkg"; 3 | 4 | interface linkFinderResultLinkMatchTitleProps { 5 | matchedText: string, 6 | position: Range 7 | } 8 | 9 | export const LinkMatchTitleComponent = ({matchedText, position}: linkFinderResultLinkMatchTitleProps) => { 10 | return ( 11 |
    12 |

    13 | "{matchedText}" 14 |

    15 | 16 | ({position.start}-{position.end}) 17 | 18 |
    19 | ) 20 | }; -------------------------------------------------------------------------------- /src/ts/context.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Dispatch, SetStateAction } from "react"; 3 | import { App, TFile } from "obsidian"; 4 | import { 5 | LinkFinderResult, 6 | LinkMatch, 7 | LinkTargetCandidate, 8 | NoteChangeOperation, 9 | PreferrableItem, 10 | Replacement, 11 | } from "../../pkg"; 12 | 13 | export const AppContext = React.createContext(undefined); 14 | export const WasmWorkerInstanceContext = React.createContext(undefined); 15 | 16 | export const NoteFilesContext = 17 | React.createContext>(undefined); 18 | export const LinkFinderResultContext = 19 | React.createContext(undefined); 20 | export const LinkMatchContext = React.createContext(undefined); 21 | export const LinkTargetCandidateContext = 22 | React.createContext(undefined); 23 | export const ReplacementCandidateContext = 24 | React.createContext(undefined); 25 | export const ReplacementContext = React.createContext<{ 26 | replacement: Replacement; 27 | setReplacement: Dispatch>; 28 | }>(undefined); 29 | -------------------------------------------------------------------------------- /src/ts/hooks.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AppContext, 3 | LinkFinderResultContext, 4 | LinkMatchContext, 5 | LinkTargetCandidateContext, 6 | NoteFilesContext, 7 | ReplacementCandidateContext, 8 | WasmWorkerInstanceContext, 9 | } from "./context"; 10 | import { App, TFile } from "obsidian"; 11 | import React, { Dispatch, SetStateAction, useContext } from "react"; 12 | import { 13 | LinkFinderResult, 14 | LinkMatch, 15 | LinkTargetCandidate, 16 | NoteChangeOperation, 17 | Replacement, 18 | PreferrableItem, 19 | } from "../../pkg"; 20 | 21 | // Context hooks 22 | export const useApp = (): App | undefined => { 23 | return React.useContext(AppContext); 24 | }; 25 | 26 | export const useWasmWorkerInstance = (): any | undefined => { 27 | return React.useContext(WasmWorkerInstanceContext); 28 | }; 29 | 30 | export const useNoteFiles = (): Map | undefined => { 31 | return React.useContext(NoteFilesContext); 32 | }; 33 | 34 | export const useLinkFinderResult = (): LinkFinderResult | undefined => { 35 | return React.useContext(LinkFinderResultContext); 36 | }; 37 | 38 | export const useLinkMatch = (): LinkMatch | undefined => { 39 | return React.useContext(LinkMatchContext); 40 | }; 41 | 42 | export const useLinkTargetCandidate = (): LinkTargetCandidate | undefined => { 43 | return React.useContext(LinkTargetCandidateContext); 44 | }; 45 | 46 | export const useReplacementCandidate = (): PreferrableItem | undefined => { 47 | return React.useContext(ReplacementCandidateContext); 48 | }; 49 | -------------------------------------------------------------------------------- /src/ts/objects/IgnoreRange.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CachedMetadata, 3 | CacheItem, 4 | HeadingCache, 5 | LinkCache, 6 | TFile, 7 | } from "obsidian"; 8 | import { Range } from "../../../pkg"; 9 | 10 | class IgnoreRangeBuilder { 11 | private readonly _ignoreRanges: IgnoreRange[] = []; 12 | private readonly _cache: CachedMetadata; 13 | private _content: string; 14 | private _name: string; 15 | 16 | constructor(content: string, cache: CachedMetadata, name: string) { 17 | this._content = content; 18 | this._cache = cache; 19 | this._name = name; 20 | } 21 | 22 | public build(): IgnoreRange[] { 23 | return this._ignoreRanges.sort((a, b) => a.start - b.start); 24 | } 25 | 26 | // Adds an ignore range from the cache for a specific section type 27 | private addCacheSections(type: string): IgnoreRangeBuilder { 28 | (this._cache.sections ? this._cache.sections : []) 29 | .filter((section) => section.type === type) 30 | .forEach((section) => { 31 | const ignoreRange = new IgnoreRange( 32 | section.position.start.offset, 33 | section.position.end.offset 34 | ); 35 | this._ignoreRanges.push(ignoreRange); 36 | 37 | this._content = 38 | this._content.substring(0, ignoreRange.start) + 39 | " ".repeat(ignoreRange.end - ignoreRange.start) + 40 | this._content.substring(ignoreRange.end); 41 | }); 42 | return this; 43 | } 44 | 45 | // adds an ignroe range from the cache for an array of cache items 46 | private addCacheItem(cacheItem: CacheItem[]) { 47 | (cacheItem ? cacheItem : []).forEach((item) => { 48 | const ignoreRange = new IgnoreRange( 49 | item.position.start.offset, 50 | item.position.end.offset 51 | ); 52 | this._ignoreRanges.push(ignoreRange); 53 | this._content = 54 | this._content.substring(0, ignoreRange.start) + 55 | " ".repeat(ignoreRange.end - ignoreRange.start) + 56 | this._content.substring(ignoreRange.end); 57 | }); 58 | return this; 59 | } 60 | 61 | // adds internal links to the ignore ranges 62 | // internal links are of the form [[link text]] or [[#link text]] 63 | public addInternalLinks(): IgnoreRangeBuilder { 64 | return this.addCacheItem(this._cache.links); 65 | } 66 | 67 | // adds all headings to the ignore ranges 68 | // headings are of the form # Heading 69 | public addHeadings(): IgnoreRangeBuilder { 70 | return this.addCacheItem(this._cache.headings); 71 | } 72 | 73 | // adds code blocks to the ignore ranges 74 | // code blocks are of the form ```code``` 75 | public addCodeSections(): IgnoreRangeBuilder { 76 | return this.addCacheSections("code"); 77 | } 78 | 79 | // utility function to add ignore ranges from a regex 80 | private addIgnoreRangesWithRegex(regex: RegExp): IgnoreRangeBuilder { 81 | this._content = this._content.replace(regex, (match, ...args) => { 82 | const start = args[args.length - 2]; 83 | const end = start + match.length; 84 | this._ignoreRanges.push(new IgnoreRange(start, end)); 85 | return " ".repeat(match.length); 86 | }); 87 | return this; 88 | } 89 | 90 | // adds all web links to the ignore ranges 91 | public addWebLinks(): IgnoreRangeBuilder { 92 | // web links are of the form https://www.example.com or http://www.example.com or www.example.com 93 | const regex = /https?:\/\/www\..+|www\..+/g; 94 | return this.addIgnoreRangesWithRegex(regex); 95 | } 96 | 97 | // adds all md links to the ignore ranges 98 | public addMdLinks(): IgnoreRangeBuilder { 99 | // md links are of the form [link text](link) 100 | const regex = /\[([^\[]+)\](\(.*\))/g; 101 | return this.addIgnoreRangesWithRegex(regex); 102 | } 103 | 104 | // adds all html like text sections to the ignore ranges 105 | public addHtml(): IgnoreRangeBuilder { 106 | const regex = /<[^>]+>([^>]+<[^>]+>)?/g; 107 | return this.addIgnoreRangesWithRegex(regex); 108 | } 109 | 110 | public addMdMetadata(): IgnoreRangeBuilder { 111 | const regex = /---(.|\n)*---/g; 112 | return this.addIgnoreRangesWithRegex(regex); 113 | } 114 | } 115 | 116 | export default class IgnoreRange extends Range { 117 | constructor(start: number, end: number) { 118 | super(start, end); 119 | } 120 | 121 | static getIgnoreRangesFromCache( 122 | content: string, 123 | cache: CachedMetadata, 124 | name: string 125 | ): IgnoreRange[] { 126 | const ignoreRanges: IgnoreRange[] = new IgnoreRangeBuilder( 127 | content, 128 | cache, 129 | name 130 | ) 131 | // from cache 132 | .addInternalLinks() 133 | .addHeadings() 134 | .addCodeSections() 135 | // from regex 136 | .addMdMetadata() 137 | .addHtml() 138 | .addMdLinks() 139 | .addWebLinks() 140 | .build(); 141 | 142 | return ignoreRanges; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/ts/objects/JsNote.ts: -------------------------------------------------------------------------------- 1 | import { MetadataCache, parseFrontMatterAliases, TFile, Vault } from "obsidian"; 2 | import IgnoreRange from "./IgnoreRange"; 3 | import { Note } from "../../../pkg"; 4 | import { getImpliedNodeFormatForFile } from "typescript"; 5 | 6 | export default class JsNote extends Note { 7 | constructor( 8 | title: string, 9 | path: string, 10 | content: string, 11 | aliases: string[] = [], 12 | ignore: IgnoreRange[] = [] 13 | ) { 14 | super(title, path, content, aliases, ignore); 15 | } 16 | 17 | static getNumberOfNotes(vault: Vault): number { 18 | return vault.getMarkdownFiles().length; 19 | } 20 | 21 | static async getNotesFromVault( 22 | vault: Vault, 23 | cache: MetadataCache 24 | ): Promise { 25 | const notes = vault.getMarkdownFiles().map(async (file, index) => { 26 | return await JsNote.fromFile(file, vault, cache); 27 | }); 28 | return await Promise.all(notes); 29 | } 30 | 31 | static async fromFile( 32 | file: TFile, 33 | vault: Vault, 34 | cache: MetadataCache 35 | ): Promise { 36 | const name = file.basename; 37 | const path = file.path; 38 | const content = await vault.cachedRead(file); 39 | const aliases = 40 | parseFrontMatterAliases(cache.getFileCache(file).frontmatter) ?? []; 41 | const ignoreRanges = 42 | IgnoreRange.getIgnoreRangesFromCache( 43 | content, 44 | cache.getFileCache(file), 45 | file.name 46 | ) ?? []; 47 | let jsNote: JsNote = new JsNote(name, path, content, aliases, ignoreRanges); 48 | return jsNote; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/ts/objects/Progress.ts: -------------------------------------------------------------------------------- 1 | import {NoteScannedEvent} from "../../../pkg"; 2 | 3 | export default class Progress { 4 | 5 | current: number; 6 | max: number; 7 | noteScannedEvent: NoteScannedEvent; 8 | 9 | PROGRESS_BAR_BORDER_LEFT = "["; 10 | PROGRESS_BAR_BORDER_RIGHT = "]"; 11 | PROGRESS_BAR_ARROW_BODY = "="; 12 | PROGRESS_BAR_ARROW_HEAD = ">"; 13 | PROGRESS_BAR_MISSING_BODY = "-"; 14 | 15 | PROGRESS_BAR_LENGTH = 20; 16 | 17 | constructor(max: number, noteScannedEvent?: NoteScannedEvent) { 18 | this.max = max; 19 | this.current = noteScannedEvent === undefined ? 0 : noteScannedEvent.index; 20 | this.noteScannedEvent = noteScannedEvent; 21 | } 22 | 23 | public isComplete(): boolean { 24 | return this.current >= this.max 25 | } 26 | 27 | public asAsciiArt(): string { 28 | const percentage = this.current / this.max; 29 | const progressLength = Math.floor(percentage * this.PROGRESS_BAR_LENGTH); 30 | const missingLength = this.PROGRESS_BAR_LENGTH - progressLength; 31 | 32 | return this.PROGRESS_BAR_BORDER_LEFT + 33 | this.PROGRESS_BAR_ARROW_BODY.repeat(progressLength) + 34 | this.PROGRESS_BAR_ARROW_HEAD + 35 | this.PROGRESS_BAR_MISSING_BODY.repeat(missingLength) + 36 | this.PROGRESS_BAR_BORDER_RIGHT + 37 | `(${this.current}/${this.max})`; 38 | } 39 | 40 | public asDescriptionText(): string { 41 | return (this.noteScannedEvent !== undefined) ? `Scanned: ${this.noteScannedEvent.noteTitle}` : ""; 42 | } 43 | 44 | } -------------------------------------------------------------------------------- /src/ts/util.ts: -------------------------------------------------------------------------------- 1 | export const isAlias = (replacement: string, targetNoteTitle: string) : boolean => { 2 | return replacement != targetNoteTitle; 3 | } 4 | 5 | export const generateMockupMdLink = (replacement: string, targetNoteTitle: string) : string => { 6 | return isAlias(replacement, targetNoteTitle) 7 | ? `[[${targetNoteTitle}|${replacement}]]` 8 | : `[[${replacement}]]`; 9 | } -------------------------------------------------------------------------------- /src/ts/webWorkers/WasmWorker.ts: -------------------------------------------------------------------------------- 1 | import * as Comlink from "comlink"; 2 | import rustPlugin from "../../../pkg/obisidian_note_linker_bg.wasm"; 3 | import * as wasm from "../../../pkg"; 4 | import { 5 | init_panic_hook, 6 | Note, 7 | LinkFinderResult, 8 | find_in_vault, 9 | find_in_note, 10 | } from "../../../pkg"; 11 | 12 | class WasmWorker { 13 | public async init() { 14 | // @ts-ignore 15 | const buffer = Uint8Array.from(atob(rustPlugin), (c) => c.charCodeAt(0)); 16 | await wasm.default(Promise.resolve(buffer)); 17 | init_panic_hook(); 18 | } 19 | 20 | public findInVault( 21 | serializedNotes: Array, 22 | callback: Function 23 | ): Array { 24 | const notes: Array = serializedNotes.map((noteString) => 25 | Note.fromJSON(noteString) 26 | ); 27 | const noteMatchingResults: Array = find_in_vault( 28 | this, 29 | notes, 30 | callback 31 | ); 32 | return noteMatchingResults.map((noteMatchingResult) => 33 | noteMatchingResult.toJSON() 34 | ); 35 | } 36 | 37 | public findInNote( 38 | serializedNote: string, 39 | searializedNotes: Array, 40 | callback: Function 41 | ): Array { 42 | const note: Note = Note.fromJSON(serializedNote); 43 | const notes: Array = searializedNotes.map((noteString) => 44 | Note.fromJSON(noteString) 45 | ); 46 | const noteMatchingResults: Array = find_in_note( 47 | this, 48 | note, 49 | notes, 50 | callback 51 | ); 52 | return noteMatchingResults.map((noteMatchingResult) => 53 | noteMatchingResult.toJSON() 54 | ); 55 | } 56 | } 57 | 58 | Comlink.expose(WasmWorker); 59 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --super-small-margin: 5px; 3 | --small-margin: 10px; 4 | --default-margin: 15px; 5 | --double-default-margin: 30px; 6 | 7 | --default-padding: 10px; 8 | --small-padding: 5px; 9 | 10 | --default-round-borders: 10px; 11 | --small-round-borders: 7px; 12 | 13 | --small-font-size: 10px; 14 | --enlarged-default-font-size: 15px; 15 | 16 | --warning-background-color: rgb(255, 165, 0, 0.5); 17 | --success-background-color: rgb(50, 205, 50, 0.5); 18 | --info-background-color: rgb(100, 149, 237, 0.5); 19 | } 20 | 21 | /* General */ 22 | 23 | button { 24 | font-size: var(--enlarged-default-font-size); 25 | border-radius: var(--default-round-borders); 26 | } 27 | 28 | .loading-component { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | } 33 | 34 | .multiline { 35 | white-space: pre-wrap; 36 | } 37 | 38 | /* Start component */ 39 | 40 | .start-component { 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | 45 | .start-component > h1 { 46 | margin-bottom: var(--double-default-margin); 47 | } 48 | 49 | .start-component > span { 50 | margin-bottom: var(--double-default-margin); 51 | } 52 | 53 | .start-component > button { 54 | align-self: center; 55 | } 56 | 57 | .start-component > .button-container { 58 | display: flex; 59 | flex-direction: row; 60 | justify-content: center; 61 | margin-top: 0; 62 | } 63 | 64 | /* Progress bar */ 65 | 66 | .progress-bar-component { 67 | display: flex; 68 | flex-direction: column; 69 | justify-content: space-between; 70 | align-items: center; 71 | margin-bottom: var(--double-default-margin); 72 | } 73 | 74 | .progress-bar-component > .ascii-art-progress-bar { 75 | border: 1px solid var(--text-normal); 76 | padding: var(--default-padding); 77 | font-size: var(--enlarged-default-font-size); 78 | } 79 | 80 | /* Note Matching Results List */ 81 | 82 | .note-matching-result-list { 83 | display: flex; 84 | flex-direction: column; 85 | } 86 | 87 | .note-matching-result-list > h1 { 88 | border-bottom: 1px solid var(--background-modifier-border); 89 | align-self: center; 90 | } 91 | 92 | .note-matching-result-list > button { 93 | margin-top: var(--default-margin); 94 | align-self: center; 95 | width: 100%; 96 | } 97 | 98 | /* Link Matches List Component */ 99 | 100 | .link-matches-list { 101 | /*add border 2px*/ 102 | border: 1px solid var(--background-modifier-border); 103 | background: var(--background-primary); 104 | border-radius: var(--default-round-borders); 105 | margin-top: var(--default-margin); 106 | padding: var(--small-padding); 107 | } 108 | 109 | /* Note Matching Result Title*/ 110 | 111 | .note-matching-result-title { 112 | display: flex; 113 | flex-direction: column; 114 | align-items: center; 115 | justify-content: center; 116 | margin-top: var(--small-margin); 117 | margin-bottom: var(--small-margin); 118 | } 119 | 120 | .note-matching-result-title > h3 { 121 | margin: 0; 122 | } 123 | 124 | /* Link Match Title */ 125 | 126 | .link-match-title { 127 | display: flex; 128 | flex-direction: row; 129 | } 130 | 131 | .link-match-title > h4 { 132 | margin: 0; 133 | padding-right: var(--small-padding); 134 | } 135 | 136 | /* Link Target Candidates List */ 137 | 138 | .link-target-candidates-list { 139 | padding: var(--default-padding); 140 | } 141 | 142 | .link-target-candidates-list > ul { 143 | padding-left: var(--default-padding); 144 | } 145 | 146 | /* Replacements Selection */ 147 | 148 | .replacements-selection { 149 | margin-top: var(--super-small-margin); 150 | } 151 | 152 | .replacements-selection > ul { 153 | padding-left: var(--default-padding); 154 | } 155 | 156 | /* Replacement Item */ 157 | 158 | .replacement-item { 159 | display: flex; 160 | flex-direction: row; 161 | } 162 | 163 | .replacement-item > .matched-text { 164 | white-space: nowrap; 165 | } 166 | 167 | .replacement-context { 168 | font-size: var(--enlarged-default-font-size); 169 | color: var(--text-faint); 170 | white-space: nowrap; 171 | overflow: hidden; 172 | text-overflow: ellipsis; 173 | } 174 | 175 | .replacement-context > .arrow-icon { 176 | padding-left: var(--small-padding); 177 | padding-right: var(--small-padding); 178 | } 179 | 180 | #replacement-context > .link-preview { 181 | color: var(--text-muted) !important; 182 | } 183 | 184 | /* Other styling */ 185 | 186 | .hide-list-styling { 187 | list-style: none; 188 | margin: 0; 189 | padding: 0; 190 | } 191 | 192 | .light-description { 193 | font-size: var(--small-font-size); 194 | color: var(--text-muted); 195 | font-style: italic; 196 | } 197 | 198 | .warning-toast { 199 | background: var(--warning-background-color); 200 | border-radius: var(--small-round-borders); 201 | padding: var(--small-padding); 202 | } 203 | 204 | .success-toast { 205 | background: var(--success-background-color); 206 | border-radius: var(--small-round-borders); 207 | padding: var(--small-padding); 208 | } 209 | 210 | .info-toast { 211 | background: var(--info-background-color); 212 | border-radius: var(--small-round-borders); 213 | padding: var(--small-padding); 214 | } 215 | 216 | .pagination-controls { 217 | display: flex; 218 | flex-direction: row; 219 | justify-content: center; 220 | margin-top: var(--default-margin); 221 | } 222 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "DOM", 14 | "ES5", 15 | "ES6", 16 | "ES7" 17 | ], 18 | "allowSyntheticDefaultImports": true, 19 | "jsx": "react" 20 | }, 21 | "include": [ 22 | "**/*.ts" 23 | ] 24 | } 25 | --------------------------------------------------------------------------------