├── .editorconfig ├── .github └── workflows │ ├── codeql-analysis.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── DEV_NOTES.MD ├── LICENSE ├── README.md ├── SECURITY.md ├── biome.json ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── src ├── features │ ├── selectionFunctions.ts │ └── transporterFunctions.ts ├── main.ts ├── settings.ts ├── ui │ ├── GenericFuzzySuggester2.ts │ ├── PluginCommands.ts │ ├── QuickCaptureModal.ts │ ├── SettingsTab.ts │ ├── SilentFileAndTagSuggester2.ts │ └── silentFileAndTagSuggesterSuggest1.ts └── utils │ ├── blockId.ts │ ├── bookmarks.ts │ ├── dailyNotesPages.ts │ ├── fileCacheAnalyzer.ts │ ├── fileNavigatior.ts │ ├── fileSystem.ts │ ├── tags.ts │ └── views.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs ├── version-github-action.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.yml] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | max_line_length = 90 19 | indent_style = space 20 | indent_size = 2 21 | end_of_line = lf 22 | insert_final_newline = true 23 | trim_trailing_whitespace = true 24 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: [main] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [main] 20 | schedule: 21 | - cron: '44 12 * * 3' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: ['javascript'] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - name: Use Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '21.x' 19 | 20 | - name: Build plugin 21 | run: | 22 | npm install 23 | npm run build 24 | 25 | - name: Create release 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | run: | 29 | tag="${GITHUB_REF#refs/tags/}" 30 | 31 | gh release create "$tag" \ 32 | --title="$tag" \ 33 | build/main.js manifest.json styles.css 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | @updates.md 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | build 11 | *.js.map 12 | dict-MyDict.json 13 | 14 | # obsidian 15 | build/data.json 16 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.12 2 | 3 | ### Updates 4 | - Updating plugin to newest Obsidian recommendations https://docs.obsidian.md/oo24/plugin. 5 | - Transition to Biome from EsLint and Prettier. 6 | - The internal command names have been renamed. Any plugins using these internal command names will need to be updated. 7 | 8 | # 1.0.11 9 | 10 | Fixes: 11 | - Problem with bookmarks to DNPTODAY and DNPTOMORROW 12 | 13 | # 1.0.10 14 | 15 | Fixes: 16 | 17 | - Bad link to online help corrected [#75](https://github.com/TfTHacker/obsidian42-text-transporter/issues/75) 18 | - Blockembeds were not working with callouts [#72](https://github.com/TfTHacker/obsidian42-text-transporter/issues/72) 19 | - If a file conatined a ; in its name, it would error out for some commands. [#64](https://github.com/TfTHacker/obsidian42-text-transporter/issues/64) 20 | - If a custom Alias placeholder was defined, it was not being used in the Replace link with text & alias command [#68](https://github.com/TfTHacker/obsidian42-text-transporter/issues/68) 21 | 22 | # 1.0.7 23 | 24 | - Removed from settings ability to remove icon from ribbon bar. This feature is now native to Obsidian and the plug doesn't need to manage it. 25 | - Removed debugging toggle from settings. In the end, this wasn't useful for troubleshooting. Let us apply the KISS principle. 26 | - Dependencies updated 27 | 28 | # 1.0.6 29 | 30 | - Dependencies updated 31 | - Cleaned up file names 32 | - Added a GitHub action to automate the release process 33 | 34 | # 1.0.4 35 | 36 | - New: DNPTOMORROW for templating. Great code contribution (https://github.com/TfTHacker/obsidian42-text-transporter/pull/65). Thank you @bwydoogh. 37 | - Updated project dependencies and updated to latest esbuild process 38 | 39 | # 1.0.3 40 | 41 | Fixes: 42 | 43 | - Problem with Quick Capture not working if there isn't an open document 44 | - Fixed images in README.md 45 | -------------------------------------------------------------------------------- /DEV_NOTES.MD: -------------------------------------------------------------------------------- 1 | DEV_NOTES.MD 2 | 3 | # Updating the version 4 | 5 | 1. update pacakage.json version number 6 | 2. npm run version (updates the manifest and version file) 7 | 3. commit repo 8 | 4. npm run githubaction (commits the version number tag to the repo and pushes it, which kicks of the github action to prepare the release) 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 TfTHacker 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 | # Text Transporter - advanced text management for Obsidian 2 | 3 | Text Transporter is the Swiss Army Knife of text manipulation plugins for Obsidian that allows you to modify contents of files in your vault, even when the file is not visible. In addition Text Transporter provides a number of convenient functions for quickly selecting lines, blocks and more, along with quickly creating block references. 4 | 5 | Text Transporter will make you a text ninja! Text Transporter is made with extra heart for keyboard lovers! 6 | 7 | Learn more about Text Transporter: https://tfthacker.com/transporter or follow me at https://twitter.com/tfthacker for updates. 8 | 9 | # Help with Text Transporter 10 | 11 | There are a number of new requests in the Issues database. If you are interested in helping expand the functionality of Text Transporter, please take a look at the Issues database and see if there is something you can help with. I am always looking for Pull Requests to help improve Text Transporter from the community. 12 | 13 | # Other things 14 | 15 | You might also be interested in a few products I have made for Obsidian: 16 | 17 | 18 | - [JournalCraft](https://tfthacker.com/jco) - A curated collection of 10 powerful journaling templates designed to enhance your journaling experience. Whether new to journaling or looking to step up your game, JournalCraft has something for you. 19 | - [Cornell Notes Learning Vault](https://tfthacker.com/cornell-notes) - This vault teaches you how to use the Cornell Note-Taking System in your Obsidian vault. It includes learning material, samples, and Obsidian configuration files to enable Cornell Notes in your vault. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is super important to me. So please don't hesitate to bring to my attention any issues you see. 4 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "tab" 15 | }, 16 | "organizeImports": { 17 | "enabled": true 18 | }, 19 | "linter": { 20 | "enabled": true, 21 | "rules": { 22 | "recommended": true, 23 | "style": { 24 | "noParameterAssign": "off" 25 | } 26 | } 27 | }, 28 | "javascript": { 29 | "formatter": { 30 | "quoteStyle": "double" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | import fs from 'fs'; 5 | import console from 'console'; 6 | 7 | fs.copyFile('manifest.json', 'build/manifest.json', (err) => { 8 | if (err) console.log(err); 9 | }); 10 | fs.copyFile('styles.css', 'build/styles.css', (err) => { 11 | if (err) console.log(err); 12 | }); 13 | 14 | const prod = process.argv[2] === 'production'; 15 | 16 | const context = await esbuild.context({ 17 | entryPoints: ['src/main.ts'], 18 | bundle: true, 19 | minify: prod, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins 35 | ], 36 | format: 'cjs', 37 | target: 'es2018', 38 | logLevel: 'info', 39 | sourcemap: prod ? false : 'inline', 40 | treeShaking: true, 41 | outfile: 'build/main.js' 42 | }); 43 | 44 | if (prod) { 45 | console.log('Building for production'); 46 | await context.rebuild(); 47 | process.exit(0); 48 | } else { 49 | await context.watch(); 50 | } 51 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian42-text-transporter", 3 | "name": "Text Transporter", 4 | "version": "1.0.12", 5 | "minAppVersion": "1.7.2", 6 | "description": "Advanced text tools for working with text in your vault", 7 | "author": "TfTHacker", 8 | "authorUrl": "https://github.com/TfTHacker/obsidian42-text-transporter", 9 | "helpUrl": "https://tfthacker.com/transporter", 10 | "isDesktopOnly": false, 11 | "fundingUrl": { 12 | "Sponsor my work": "https://tfthacker.com/sponsor" 13 | } 14 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian42-text-transporter", 3 | "version": "1.0.12", 4 | "description": "Text Transporter.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node --no-warnings esbuild.config.mjs", 8 | "build": "node --no-warnings esbuild.config.mjs production", 9 | "lint": "biome check ./src", 10 | "version": "node version-bump.mjs", 11 | "githubaction": "node version-github-action.mjs" 12 | }, 13 | "author": "TfT Hacker", 14 | "license": "MIT", 15 | "repository": { 16 | "type": "git", 17 | "url": "git://github.com/TfTHacker/obsidian42-text-transporter.git" 18 | }, 19 | "devDependencies": { 20 | "@biomejs/biome": "1.9.4", 21 | "@popperjs/core": "^2.11.8", 22 | "@types/node": "^22.9.0", 23 | "builtin-modules": "4.0.0", 24 | "esbuild": "0.24.0", 25 | "moment": "^2.30.1", 26 | "obsidian": "^1.7.2", 27 | "obsidian-typings": "^2.3.0", 28 | "tslib": "^2.8.1", 29 | "typescript": "5.6.3" 30 | }, 31 | "dependencies": { 32 | "flatpickr": "^4.6.13", 33 | "fuse.js": "7.0.0", 34 | "nanoid": "^5.0.8", 35 | "obsidian-daily-notes-interface": "^0.9.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/features/selectionFunctions.ts: -------------------------------------------------------------------------------- 1 | import type { EditorPosition, EditorSelection, SectionCache } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import { 4 | type CacheDetails, 5 | FileCacheAnalyzer, 6 | } from "../utils/fileCacheAnalyzer"; 7 | import { getActiveView } from "../utils/views"; 8 | 9 | // Select the current line in the editor of activeLeaf 10 | export function selectCurrentLine(plugin: TextTransporterPlugin): void { 11 | const activeView = getActiveView(plugin); 12 | const activeEditor = activeView.editor; 13 | const currentLine = activeEditor.getCursor().line; 14 | const selections = activeEditor.listSelections(); 15 | if (selections.length === 1) { 16 | const sel: EditorSelection = selections[0]; 17 | const lineLength = activeEditor.getLine(currentLine).length; 18 | if ( 19 | sel.anchor.line === sel.head.line && 20 | (sel.anchor.ch === lineLength || sel.head.ch === lineLength) && 21 | activeEditor.getSelection().length > 0 22 | ) { 23 | const f = new FileCacheAnalyzer(plugin, activeView.file.path); 24 | const block = f.getBlockAtLine(currentLine, true); 25 | activeEditor.setSelection( 26 | { line: block.lineStart, ch: 0 }, 27 | { line: block.lineEnd, ch: block.position.end.col }, 28 | ); 29 | } else if (sel.anchor.line === sel.head.line) 30 | activeEditor.setSelection( 31 | { line: currentLine, ch: 0 }, 32 | { line: currentLine, ch: activeEditor.getLine(currentLine).length }, 33 | ); 34 | } 35 | } 36 | 37 | // select the next block or previous block. 38 | // if nextBlock true - goto next, if false, go to previous 39 | export function selectAdjacentBlock( 40 | plugin: TextTransporterPlugin, 41 | nextBlock: boolean, 42 | ): void { 43 | const activeView = getActiveView(plugin); 44 | const activeEditor = activeView.editor; 45 | const currentLine = activeEditor.getCursor().line; 46 | const currentLineEmpty = 47 | activeEditor.getLine(currentLine).trim().length === 0; 48 | const f = new FileCacheAnalyzer(plugin, activeView.file.path); 49 | let nextBlockSelection: CacheDetails; 50 | if (nextBlock) 51 | if (currentLineEmpty) 52 | nextBlockSelection = f.getBlockAtLine(currentLine, true); //nothing selected, go to nearst next block 53 | else nextBlockSelection = f.getBlockAfterLine(currentLine); 54 | else if (currentLineEmpty) 55 | nextBlockSelection = f.getBlockAtLine(currentLine, false); //nothing selected, go to nearst previous block 56 | else nextBlockSelection = f.getBlockBeforeLine(currentLine); 57 | if (nextBlockSelection !== null) { 58 | const start: EditorPosition = { 59 | line: nextBlockSelection.position.start.line, 60 | ch: nextBlockSelection.position.start.col, 61 | }; 62 | const end: EditorPosition = { 63 | line: nextBlockSelection.position.end.line, 64 | ch: nextBlockSelection.position.end.col, 65 | }; 66 | activeEditor.setSelection(start, end); 67 | activeEditor.scrollIntoView({ from: start, to: end }); 68 | } 69 | } 70 | 71 | //get the current block information from the cache 72 | export function indentifyCurrentSection( 73 | plugin: TextTransporterPlugin, 74 | ): SectionCache { 75 | const activeView = getActiveView(plugin); 76 | const activeEditor = activeView.editor; 77 | const currentLine = activeEditor.getCursor().line; 78 | const cache = this.app.metadataCache.getFileCache(activeView.file); 79 | return cache.sections.find( 80 | (section) => 81 | section.position.start.line <= currentLine && 82 | section.position.end.line >= currentLine, 83 | ); 84 | } 85 | 86 | // Select the current section in the editor of activeLeaf and extend the selection in a given direction 87 | export function selectCurrentSection( 88 | plugin: TextTransporterPlugin, 89 | directionUP = true, 90 | ): void { 91 | const activeView = getActiveView(plugin); 92 | const activeEditor = activeView.editor; 93 | const currentLine = activeEditor.getCursor().line; 94 | const cache = this.app.metadataCache.getFileCache(activeView.file); 95 | const f = new FileCacheAnalyzer(plugin, activeView.file.path); 96 | const currentRange: EditorSelection[] = activeEditor.listSelections(); 97 | if ( 98 | (currentRange[0].anchor.line === currentRange[0].head.line && 99 | currentRange[0].head.ch !== activeEditor.getSelection().length) || 100 | (currentRange[0].head.ch === 0 && 101 | currentRange[0].anchor.ch === 0 && 102 | activeEditor.getRange( 103 | { line: currentLine, ch: activeEditor.getLine(currentLine).length }, 104 | { line: currentLine, ch: 0 }, 105 | ).length !== 0) 106 | ) { 107 | // line not selected, so select the current line 108 | activeEditor.setSelection( 109 | { line: currentLine, ch: 0 }, 110 | { line: currentLine, ch: activeEditor.getLine(currentLine).length }, 111 | ); 112 | } else { 113 | // test if this is a block, if it is, select it 114 | const lastLineOfBlock = f.details.find((section) => { 115 | if ( 116 | currentLine >= Number(section.position.start.line) && 117 | currentLine <= Number(section.position.end.line) 118 | ) { 119 | return section.position.start; 120 | } 121 | }); 122 | if (lastLineOfBlock === undefined) { 123 | // likely empty line is being triggered, nothing to select. so try to select the nearest block 124 | let nearestBlock = null; 125 | for (const value of Object.entries(f.details)) { 126 | if (value.position) { 127 | if ( 128 | directionUP === false && 129 | currentLine < Number(value.position.end.line) && 130 | nearestBlock === null 131 | ) { 132 | nearestBlock = value; 133 | } else if ( 134 | directionUP === true && 135 | currentLine > Number(value.position.start.line) 136 | ) { 137 | nearestBlock = value; 138 | } 139 | } 140 | } 141 | if (nearestBlock === null && currentLine === 0 && f.details.length > 0) 142 | nearestBlock = cache.sections[0]; // first line, but no text to select, so select first block 143 | if (nearestBlock !== null) { 144 | activeEditor.setSelection( 145 | { line: nearestBlock.position.start.line, ch: 0 }, 146 | { 147 | line: nearestBlock.position.end.line, 148 | ch: nearestBlock.position.end.col, 149 | }, 150 | ); 151 | return; 152 | } 153 | } 154 | const curSels = activeEditor.listSelections(); 155 | if ( 156 | lastLineOfBlock && 157 | lastLineOfBlock.type === "paragraph" && 158 | curSels.length === 1 && 159 | curSels[0].anchor.line !== lastLineOfBlock.position.start.line && 160 | curSels[0].head.line !== lastLineOfBlock.position.end.line 161 | ) { 162 | // this clause is testing if the line is selected or some aspect of the block. if not a whole block selected, select the block 163 | activeEditor.setSelection( 164 | { line: lastLineOfBlock.position.start.line, ch: 0 }, 165 | { 166 | line: lastLineOfBlock.position.end.line, 167 | ch: lastLineOfBlock.position.end.col, 168 | }, 169 | ); 170 | } else { 171 | // something is selected, so expand the selection 172 | let firstSelectedLine = 0; 173 | let lastSelectedLine = 0; 174 | let currentBlock = null; 175 | let proceedingBlock = null; 176 | let nextBlock = null; 177 | if (currentRange[0].anchor.line < currentRange[0].head.line) { 178 | firstSelectedLine = currentRange[0].anchor.line; 179 | lastSelectedLine = currentRange[0].head.line; 180 | } else { 181 | firstSelectedLine = currentRange[0].head.line; 182 | lastSelectedLine = currentRange[0].anchor.line; 183 | } 184 | for (let i = 0; i < f.details.length; i++) { 185 | if (currentLine >= f.details[i].position.end.line) { 186 | currentBlock = f.details[i]; 187 | try { 188 | nextBlock = f.details[i + 1]; 189 | } catch (e) { 190 | console.log(e); 191 | } 192 | } 193 | if (firstSelectedLine > f.details[i].position.end.line) 194 | proceedingBlock = f.details[i]; 195 | } 196 | if (proceedingBlock && directionUP) { 197 | activeEditor.setSelection( 198 | { line: proceedingBlock.position.start.line, ch: 0 }, 199 | { 200 | line: currentBlock.position.end.line, 201 | ch: activeEditor.getLine(currentBlock.position.end.line).length, 202 | }, 203 | ); 204 | activeEditor.scrollIntoView({ 205 | from: proceedingBlock.position.start, 206 | to: proceedingBlock.position.start, 207 | }); 208 | } else if (directionUP) { 209 | activeEditor.setSelection( 210 | { line: 0, ch: 0 }, 211 | { 212 | line: lastSelectedLine, 213 | ch: activeEditor.getLine(lastSelectedLine).length, 214 | }, 215 | ); 216 | activeEditor.scrollIntoView({ 217 | from: { line: 0, ch: 0 }, 218 | to: { line: firstSelectedLine, ch: 0 }, 219 | }); 220 | } else if (nextBlock && directionUP === false) { 221 | activeEditor.setSelection( 222 | { line: firstSelectedLine, ch: 0 }, 223 | { 224 | line: nextBlock.position.end.line, 225 | ch: activeEditor.getLine(nextBlock.position.end.line).length, 226 | }, 227 | ); 228 | activeEditor.scrollIntoView({ 229 | from: nextBlock.position.start, 230 | to: nextBlock.position.start, 231 | }); 232 | } 233 | } 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /src/features/transporterFunctions.ts: -------------------------------------------------------------------------------- 1 | import { type LinkCache, Notice, type TFile, getLinkpath } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import type { SuggesterItem } from "../ui/GenericFuzzySuggester2"; 4 | import { generateBlockId } from "../utils/blockId"; 5 | import { 6 | type CacheDetails, 7 | FileCacheAnalyzer, 8 | } from "../utils/fileCacheAnalyzer"; 9 | import { 10 | displayFileLineSuggester, 11 | getUniqueLinkPath, 12 | openFileInObsidian, 13 | parseBookmarkForItsElements, 14 | } from "../utils/fileNavigatior"; 15 | import { getActiveView } from "../utils/views"; 16 | 17 | export function cleanupHeaderNameForBlockReference(header: string): string { 18 | return header.replace(/\[|\]|#|\|/g, "").replace(/:/g, " "); 19 | } 20 | 21 | // loops through current selected text and adds block refs to each paragraph 22 | // returns all block refs found in selection 23 | // optionally copies them to clipboard 24 | export async function addBlockRefsToSelection( 25 | plugin: TextTransporterPlugin, 26 | copyToClipbard: boolean, 27 | copyAsAlias = false, 28 | aliasText = "*", 29 | ): Promise> { 30 | const activeView = getActiveView(plugin); 31 | const activeEditor = activeView.editor; 32 | const f = new FileCacheAnalyzer(plugin, activeView.file.path); 33 | const curSels = activeEditor.listSelections(); 34 | const blockRefs = []; 35 | for (const sel of curSels) { 36 | const startLine = 37 | sel.anchor.line > sel.head.line ? sel.head.line : sel.anchor.line; 38 | const endLine = 39 | sel.anchor.line > sel.head.line ? sel.anchor.line : sel.head.line; 40 | for ( 41 | let selectedLineInEditor = startLine; 42 | selectedLineInEditor <= endLine; 43 | selectedLineInEditor++ 44 | ) { 45 | for ( 46 | let sectionCounter = 0; 47 | sectionCounter < f.details.length; 48 | sectionCounter++ 49 | ) { 50 | const section = f.details[sectionCounter]; 51 | if ( 52 | selectedLineInEditor >= section.position.start.line && 53 | selectedLineInEditor <= section.position.end.line 54 | ) { 55 | if ( 56 | (section.type === "paragraph" || 57 | section.type === "list" || 58 | section.type === "blockquote" || 59 | section.type === "callout") && 60 | !section.blockId 61 | ) { 62 | const newId = generateBlockId(); 63 | activeEditor.replaceRange( 64 | ` ^${newId}`, 65 | { 66 | line: Number(section.position.end.line), 67 | ch: section.position.end.col, 68 | }, 69 | { 70 | line: Number(section.position.end.line), 71 | ch: section.position.end.col, 72 | }, 73 | ); 74 | blockRefs.push(`#^${newId}`); 75 | selectedLineInEditor = section.position.end.line; 76 | break; 77 | } 78 | if ( 79 | section.type === "paragraph" || 80 | section.type === "list" || 81 | section.type === "blockquote" 82 | ) { 83 | blockRefs.push(`#^${section.blockId}`); 84 | selectedLineInEditor = section.position.end.line; 85 | break; 86 | } 87 | if (section.type === "heading") { 88 | blockRefs.push( 89 | `#${cleanupHeaderNameForBlockReference(section.headingText)}`, 90 | ); 91 | selectedLineInEditor = section.position.end.line; 92 | break; 93 | } 94 | } 95 | } 96 | } //selectedLineInEditor 97 | } //curSels 98 | 99 | if (copyToClipbard && blockRefs.length > 0) { 100 | let block = ""; 101 | const blockPrefix = copyAsAlias === false ? "!" : ""; //if alias, don't do embed preview 102 | aliasText = copyAsAlias === true ? `|${aliasText}` : ""; 103 | const uniqueLinkPath = getUniqueLinkPath(activeView.file.path); 104 | // biome-ignore lint/complexity/noForEach: 105 | blockRefs.forEach( 106 | // biome-ignore lint/suspicious/noAssignInExpressions: 107 | (b) => (block += `${blockPrefix}[[${uniqueLinkPath}${b}${aliasText}]]\n`), 108 | ); 109 | navigator.clipboard.writeText(block).then((text) => text); 110 | } 111 | return blockRefs; 112 | } 113 | 114 | export async function copyOrPushLineOrSelectionToNewLocation( 115 | plugin: TextTransporterPlugin, 116 | copySelection: boolean, 117 | newText: string, 118 | targetFileName: string, 119 | targetFileLineNumber: number, 120 | targetFileContentsArray: Array, 121 | ): Promise { 122 | if (targetFileLineNumber === -1) { 123 | //go to top of file, but test for YAML 124 | const f = new FileCacheAnalyzer(plugin, targetFileName); 125 | if (f.details.length > 0 && f.details[0].type === "yaml") 126 | targetFileLineNumber = f.details[0].lineEnd; 127 | } 128 | targetFileContentsArray.splice(Number(targetFileLineNumber) + 1, 0, { 129 | display: newText, 130 | info: "", 131 | }); 132 | let newContents = ""; 133 | for (const line of targetFileContentsArray) 134 | newContents += `${line.display}\n`; 135 | newContents = newContents.substring(0, newContents.length - 1); 136 | await plugin.app.vault.adapter.write(targetFileName, newContents); 137 | if (copySelection === false) { 138 | //this is a move, so delete the selection 139 | const activeEditor = getActiveView(plugin).editor; 140 | const currentLine = activeEditor.getCursor().line; 141 | const textSelection = activeEditor.getSelection(); 142 | if ( 143 | textSelection === "" || 144 | activeEditor.getLine(currentLine).length === textSelection.length 145 | ) 146 | activeEditor.replaceRange( 147 | "", 148 | { line: currentLine, ch: 0 }, 149 | { line: currentLine + 1, ch: 0 }, 150 | ); 151 | else activeEditor.replaceSelection(""); //replace whatever is the selection 152 | } 153 | } 154 | 155 | // Copies or pushes (transfers) the current line or selection to another file 156 | // copySelection = true for copy, false for move 157 | // defaultSelectionText (use this function to push text, without changes to local editor) 158 | export async function copyOrPushLineOrSelectionToNewLocationWithFileLineSuggester( 159 | plugin: TextTransporterPlugin, 160 | copySelection: boolean, 161 | defaultSelectionText = "", 162 | ): Promise { 163 | const activeEditor = 164 | defaultSelectionText === "" ? getActiveView(plugin).editor : null; 165 | let selectedText = 166 | defaultSelectionText === "" 167 | ? activeEditor.getSelection() 168 | : defaultSelectionText; 169 | if (selectedText === "") 170 | selectedText = activeEditor.getLine(activeEditor.getCursor().line); //get text from current line 171 | await displayFileLineSuggester( 172 | plugin, 173 | false, 174 | true, 175 | false, 176 | async ( 177 | targetFileName, 178 | fileContentsArray, 179 | lineNumber, 180 | endLineNumber, 181 | evtFileSelected, 182 | evtFirstLine, 183 | ) => { 184 | await copyOrPushLineOrSelectionToNewLocation( 185 | plugin, 186 | copySelection, 187 | selectedText, 188 | targetFileName, 189 | lineNumber, 190 | fileContentsArray, 191 | ); 192 | if ( 193 | (evtFileSelected && 194 | (evtFileSelected.ctrlKey || evtFileSelected.metaKey)) || 195 | (evtFirstLine && (evtFirstLine.ctrlKey || evtFirstLine.metaKey)) 196 | ) { 197 | const linesSelected = selectedText.split("\n").length; 198 | const lineCount = linesSelected > 1 ? linesSelected - 1 : 0; 199 | openFileInObsidian(plugin, targetFileName, lineNumber + 1, lineCount); 200 | } 201 | }, 202 | ); 203 | } 204 | 205 | // this is primarily used by the context menu for doing copy/push actions 206 | export async function copyOrPushLineOrSelectionToNewLocationUsingCurrentCursorLocationAndBoomark( 207 | plugin: TextTransporterPlugin, 208 | copySelection: boolean, 209 | bookmarkText: string, 210 | evt?: MouseEvent | KeyboardEvent, 211 | ): Promise { 212 | const bookmarkInfo = await parseBookmarkForItsElements( 213 | plugin, 214 | bookmarkText, 215 | false, 216 | ); 217 | if (bookmarkInfo.errorNumber === 1) 218 | new Notice("Location in the bookmark does not exist."); 219 | else if (bookmarkInfo.errorNumber === 2) 220 | new Notice("File as defined in the bookmark does not exist."); 221 | else { 222 | const activeEditor = getActiveView(plugin).editor; 223 | const currentLine = activeEditor.getCursor().line; 224 | let textSelection = activeEditor.getSelection(); 225 | if (textSelection === "") textSelection = activeEditor.getLine(currentLine); //get text from current line 226 | copyOrPushLineOrSelectionToNewLocation( 227 | plugin, 228 | copySelection, 229 | textSelection, 230 | bookmarkInfo.fileName, 231 | bookmarkInfo.fileLineNumber, 232 | bookmarkInfo.fileBookmarkContentsArray, 233 | ); 234 | if (evt && (evt.ctrlKey || evt.metaKey)) { 235 | const linesSelected = textSelection.split("\n").length; 236 | const lineCount = linesSelected > 1 ? linesSelected - 1 : 0; 237 | openFileInObsidian( 238 | plugin, 239 | bookmarkInfo.fileName, 240 | bookmarkInfo.fileLineNumber + 1, 241 | lineCount, 242 | ); 243 | } 244 | } 245 | } 246 | 247 | //Copies current file to clipbaord as a link or sends it to another file 248 | export async function copyCurrentFileNameAsLinkToNewLocation( 249 | plugin: TextTransporterPlugin, 250 | copyToCliboard: boolean, 251 | ): Promise { 252 | const fileLink = `[[${getUniqueLinkPath(getActiveView(plugin).file.path)}]]`; 253 | if (copyToCliboard) { 254 | navigator.clipboard.writeText(fileLink).then((text) => text); 255 | new Notice(`${fileLink}\n\n Copied to the clipboard.`); 256 | } else 257 | copyOrPushLineOrSelectionToNewLocationWithFileLineSuggester( 258 | plugin, 259 | true, 260 | fileLink, 261 | ); 262 | } 263 | 264 | //copy a block reference of the current line to another file 265 | export async function pushBlockReferenceToAnotherFile( 266 | plugin: TextTransporterPlugin, 267 | ): Promise { 268 | await displayFileLineSuggester( 269 | plugin, 270 | false, 271 | true, 272 | false, 273 | async ( 274 | targetFileName, 275 | fileContentsArray, 276 | startLine, 277 | endLineNumber, 278 | evtFileSelected, 279 | evtFirstLine, 280 | ) => { 281 | if (startLine === -1) { 282 | //go to top of file, but test for YAML 283 | const f = new FileCacheAnalyzer(plugin, targetFileName); 284 | if (f.details.length > 0 && f.details[0].type === "yaml") 285 | startLine = f.details[0].lineEnd; 286 | } 287 | const results = await addBlockRefsToSelection(plugin, false); 288 | let blockRefs = ""; 289 | const fileName = getActiveView(plugin).file.path; 290 | if (results.length > 0) { 291 | for (const ref of results) blockRefs += `![[${fileName}${ref}]]\n`; 292 | blockRefs = blockRefs.substring(0, blockRefs.length - 1); 293 | fileContentsArray.splice(Number(startLine) + 1, 0, { 294 | display: blockRefs, 295 | info: "", 296 | }); 297 | let newContents = ""; 298 | for (const line of fileContentsArray) 299 | newContents += `${line.display}\n`; 300 | newContents = newContents.substring(0, newContents.length - 1); 301 | plugin.app.vault.adapter.write(targetFileName, newContents); 302 | if ( 303 | (evtFileSelected && 304 | (evtFileSelected.ctrlKey || evtFileSelected.metaKey)) || 305 | (evtFirstLine && (evtFirstLine.ctrlKey || evtFirstLine.metaKey)) 306 | ) { 307 | openFileInObsidian(plugin, targetFileName, startLine + 1); 308 | } 309 | } 310 | }, 311 | ); 312 | } 313 | 314 | // Pull (move) a line or lines from another file 315 | export async function copyOrPulLineOrSelectionFromAnotherLocation( 316 | plugin: TextTransporterPlugin, 317 | copySelection: boolean, 318 | ): Promise { 319 | await displayFileLineSuggester( 320 | plugin, 321 | true, 322 | false, 323 | true, 324 | async ( 325 | targetFileName, 326 | fileContentsArray, 327 | startLine, 328 | endLine, 329 | evtFileSelected, 330 | evtFirstLine, 331 | evetLastLine, 332 | ) => { 333 | const ctrlKey = 334 | evtFileSelected?.ctrlKey || 335 | evtFirstLine?.ctrlKey || 336 | evetLastLine?.ctrlKey; 337 | // biome-ignore lint/suspicious/noAssignInExpressions: 338 | startLine = startLine === -1 ? (startLine = 0) : startLine; 339 | // biome-ignore lint/suspicious/noAssignInExpressions: 340 | endLine = endLine === -1 ? (endLine = 0) : endLine; 341 | let stringToInsertIntoSelection = ""; 342 | for (const element of fileContentsArray.slice(startLine, endLine + 1)) 343 | stringToInsertIntoSelection += `${element.display}\n`; 344 | stringToInsertIntoSelection = stringToInsertIntoSelection.substring( 345 | 0, 346 | stringToInsertIntoSelection.length - 1, 347 | ); 348 | getActiveView(plugin).editor.replaceSelection( 349 | stringToInsertIntoSelection, 350 | ); 351 | if (copySelection === false) { 352 | //pull selection, which means deleting what was just copied from original file 353 | fileContentsArray.splice(startLine, endLine + 1 - startLine); 354 | let newContents = ""; 355 | for (const line of fileContentsArray) 356 | newContents += `${line.display}\n`; 357 | newContents = newContents.substring(0, newContents.length - 1); 358 | await plugin.app.vault.adapter.write(targetFileName, newContents); 359 | if (ctrlKey) 360 | await openFileInObsidian(plugin, targetFileName, startLine); 361 | } else if (ctrlKey) 362 | await openFileInObsidian( 363 | plugin, 364 | targetFileName, 365 | startLine, 366 | endLine - startLine, 367 | ); 368 | }, 369 | ); 370 | } 371 | 372 | // pull a block reference from another file and insert into the current location 373 | export async function pullBlockReferenceFromAnotherFile( 374 | plugin: TextTransporterPlugin, 375 | ): Promise { 376 | await displayFileLineSuggester( 377 | plugin, 378 | true, 379 | false, 380 | true, 381 | async ( 382 | targetFileName, 383 | fileContentsArray, 384 | startLine, 385 | endLine, 386 | evtFileSelected, 387 | evtFirstLine, 388 | evetLastLine, 389 | ) => { 390 | // biome-ignore lint/suspicious/noAssignInExpressions: 391 | startLine = startLine === -1 ? (startLine = 0) : startLine; 392 | // biome-ignore lint/suspicious/noAssignInExpressions: 393 | endLine = endLine === -1 ? (endLine = 0) : endLine; 394 | const f = new FileCacheAnalyzer(plugin, targetFileName); 395 | const fileContents = ( 396 | await plugin.app.vault.adapter.read(targetFileName) 397 | ).split("\n"); 398 | let fileChanged = false; 399 | const blockRefs = []; 400 | for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) { 401 | for ( 402 | let sectionCounter = 0; 403 | sectionCounter < f.details.length; 404 | sectionCounter++ 405 | ) { 406 | const section = f.details[sectionCounter]; 407 | if ( 408 | lineNumber >= section.position.start.line && 409 | lineNumber <= section.position.end.line 410 | ) { 411 | if ( 412 | (section.type === "paragraph" || section.type === "list") && 413 | !section.blockId 414 | ) { 415 | const newId = generateBlockId(); 416 | fileContents.splice( 417 | section.position.end.line, 418 | 1, 419 | `${fileContents[section.position.end.line]} ^${newId}`, 420 | ); 421 | blockRefs.push(`#^${newId}`); 422 | fileChanged = true; 423 | lineNumber = section.position.end.line; 424 | break; 425 | } 426 | if (section.type === "paragraph" || section.type === "list") { 427 | blockRefs.push(`#^${section.blockId}`); 428 | lineNumber = section.position.end.line; 429 | break; 430 | } 431 | if (section.type === "heading") { 432 | const heading = cleanupHeaderNameForBlockReference( 433 | section.headingText, 434 | ); 435 | blockRefs.push(`#${heading}`); 436 | lineNumber = section.position.end.line; 437 | break; 438 | } 439 | } 440 | } //sectionCounter 441 | } //lineNumber 442 | // Save new block refs to target file 443 | if (fileChanged === true) { 444 | let newContents = ""; 445 | for (const line of fileContents) newContents += `${line}\n`; 446 | newContents = newContents.substring(0, newContents.length - 1); 447 | await plugin.app.vault.adapter.write(targetFileName, newContents); 448 | } 449 | // insert the block refs in current cursor location 450 | if (blockRefs.length > 0) { 451 | let blockRefTextToInsert = ""; 452 | for (const ref of blockRefs) 453 | blockRefTextToInsert += `![[${targetFileName}${ref}]]\n`; 454 | blockRefTextToInsert = blockRefTextToInsert.substring( 455 | 0, 456 | blockRefTextToInsert.length - 1, 457 | ); 458 | getActiveView(plugin).editor.replaceSelection(blockRefTextToInsert); 459 | } 460 | if ( 461 | evtFileSelected.ctrlKey || 462 | evtFirstLine.ctrlKey || 463 | evetLastLine.ctrlKey 464 | ) { 465 | openFileInObsidian( 466 | plugin, 467 | targetFileName, 468 | startLine, 469 | endLine - startLine, 470 | ); 471 | } 472 | }, 473 | ); 474 | } 475 | 476 | export function testIfCursorIsOnALink( 477 | plugin: TextTransporterPlugin, 478 | ): LinkCache { 479 | const activeView = getActiveView(plugin); 480 | const activeEditor = activeView.editor; 481 | const currentLine = activeEditor.getCursor().line; 482 | const cache = this.app.metadataCache.getFileCache(activeView.file); 483 | if (cache.links || cache.embeds || cache.headings) { 484 | const ch = activeEditor.getCursor().ch; 485 | let linkInfo: LinkCache = null; 486 | if (cache.links) 487 | linkInfo = cache.links.find( 488 | (l: LinkCache) => 489 | l.position.start.line === currentLine && 490 | ch >= l.position.start.col && 491 | ch <= l.position.end.col, 492 | ); 493 | if (!linkInfo && cache.embeds) 494 | linkInfo = cache.embeds.find( 495 | (l: LinkCache) => 496 | l.position.start.line === currentLine && 497 | ch >= l.position.start.col && 498 | ch <= l.position.end.col, 499 | ); 500 | return linkInfo ? linkInfo : null; 501 | } 502 | return null; 503 | } 504 | 505 | export async function copyBlockReferenceToCurrentCusorLocation( 506 | plugin: TextTransporterPlugin, 507 | linkInfo: LinkCache, 508 | leaveAliasToFile: boolean, 509 | ): Promise { 510 | const file: TFile = plugin.app.metadataCache.getFirstLinkpathDest( 511 | getLinkpath(linkInfo.link), 512 | "/", 513 | ); 514 | let fileContents = await plugin.app.vault.read(file); 515 | const cache = new FileCacheAnalyzer(plugin, file.path); 516 | if (cache.details && linkInfo.link.includes("^")) { 517 | //blockref 518 | const blockRefId = linkInfo.link.substr(linkInfo.link.indexOf("^") + 1); 519 | const pos = cache.details.find( 520 | (b: CacheDetails) => b.blockId === blockRefId, 521 | ).position; 522 | fileContents = fileContents 523 | .split("\n") 524 | .slice(pos.start.line, pos.end.line + 1) 525 | .join("\n"); 526 | fileContents = fileContents.replace(`^${blockRefId}`, ""); 527 | } else if (cache.details && linkInfo.link.contains("#")) { 528 | //header link 529 | const headerId = linkInfo.link.substr(linkInfo.link.indexOf("#") + 1); 530 | const pos = cache.getPositionOfHeaderAndItsChildren(headerId); 531 | fileContents = fileContents 532 | .split("\n") 533 | .slice(pos.start.line, pos.end.line + 1) 534 | .join("\n"); 535 | } 536 | if (leaveAliasToFile) 537 | fileContents += ` [[${linkInfo.link}|${plugin.settings.blockRefAliasIndicator}]]`; 538 | getActiveView(plugin).editor.replaceRange( 539 | fileContents, 540 | { line: linkInfo.position.start.line, ch: linkInfo.position.start.col }, 541 | { line: linkInfo.position.end.line, ch: linkInfo.position.end.col }, 542 | ); 543 | } 544 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin, addIcon } from "obsidian"; 2 | import { DEFAULT_SETTINGS, type Settings } from "./settings"; 3 | import PluginCommands from "./ui/PluginCommands"; 4 | import { SettingsTab } from "./ui/SettingsTab"; 5 | import FileSystem from "./utils/fileSystem"; 6 | 7 | export default class TextTransporterPlugin extends Plugin { 8 | APP_NAME = this.manifest.name; 9 | APP_ID = this.manifest.id; 10 | settings: Settings; 11 | ribbonIcon: HTMLElement; 12 | fs: FileSystem; 13 | commands: PluginCommands; 14 | 15 | async onload(): Promise { 16 | console.log(`loading ${this.APP_NAME}`); 17 | this.fs = new FileSystem(this); 18 | await this.loadSettings(); 19 | this.commands = new PluginCommands(this); 20 | addIcons(); 21 | this.ribbonIcon = this.addRibbonIcon( 22 | "TextTransporter", 23 | this.APP_NAME, 24 | async () => this.commands.masterControlProgram(this), 25 | ); 26 | this.addSettingTab(new SettingsTab(this.app, this)); 27 | } 28 | 29 | onunload(): void { 30 | console.log(`unloading ${this.APP_NAME}`); 31 | } 32 | 33 | async loadSettings(): Promise { 34 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 35 | } 36 | 37 | async saveSettings(): Promise { 38 | await this.saveData(this.settings); 39 | } 40 | } 41 | 42 | export function addIcons(): void { 43 | addIcon( 44 | "TextTransporter", 45 | ` 46 | 47 | 48 | `, 49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | export interface Settings { 2 | blockRefAliasIndicator: string; 3 | bookmarks: string; 4 | } 5 | 6 | export const DEFAULT_SETTINGS: Settings = { 7 | blockRefAliasIndicator: "*", 8 | bookmarks: "", 9 | }; 10 | -------------------------------------------------------------------------------- /src/ui/GenericFuzzySuggester2.ts: -------------------------------------------------------------------------------- 1 | import { type FuzzyMatch, FuzzySuggestModal } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | 4 | export interface SuggesterItem { 5 | display: string; // displayed to user 6 | // biome-ignore lint/suspicious/noExplicitAny: 7 | info: any; // supplmental info for the callback 8 | } 9 | 10 | /* USAGE: 11 | let x = new GenericFuzzySuggester(this); 12 | let data = new Array(); 13 | for (let index = 0; index < 10000; index++) 14 | data.push( { display: 'display me ' + index, info: 'info ' + index } ); 15 | x.setSuggesterData(data); 16 | const result = x.display( (i: SuggesterItem, evt: MouseEvent | KeyboardEvent )=>{ }); 17 | */ 18 | 19 | export class GenericFuzzySuggester extends FuzzySuggestModal { 20 | data: SuggesterItem[]; 21 | // biome-ignore lint/suspicious/noExplicitAny: 22 | callbackFunction: any; 23 | 24 | constructor(plugin: TextTransporterPlugin) { 25 | super(plugin.app); 26 | this.scope.register(["Shift"], "Enter", (evt) => this.enterTrigger(evt)); 27 | this.scope.register(["Ctrl"], "Enter", (evt) => this.enterTrigger(evt)); 28 | } 29 | 30 | setSuggesterData(suggesterData: Array): void { 31 | this.data = suggesterData; 32 | } 33 | 34 | async display( 35 | callBack: (item: SuggesterItem, evt: MouseEvent | KeyboardEvent) => void, 36 | // biome-ignore lint/suspicious/noExplicitAny: 37 | ): Promise { 38 | this.callbackFunction = callBack; 39 | this.open(); 40 | } 41 | 42 | getItems(): SuggesterItem[] { 43 | return this.data; 44 | } 45 | 46 | getItemText(item: SuggesterItem): string { 47 | return item.display; 48 | } 49 | 50 | onChooseItem(): void { 51 | return; 52 | } // required by TS, but not using 53 | 54 | renderSuggestion(item: FuzzyMatch, el: HTMLElement): void { 55 | el.createEl("div", { text: item.item.display }); 56 | } 57 | 58 | enterTrigger(evt: KeyboardEvent): void { 59 | const selectedText = document.querySelector( 60 | ".suggestion-item.is-selected div", 61 | )?.textContent; 62 | const item = this.data.find((i) => i.display === selectedText); 63 | if (item) { 64 | this.invokeCallback(item, evt); 65 | this.close(); 66 | } 67 | } 68 | 69 | onChooseSuggestion( 70 | item: FuzzyMatch, 71 | evt: MouseEvent | KeyboardEvent, 72 | ): void { 73 | this.invokeCallback(item.item, evt); 74 | } 75 | 76 | invokeCallback(item: SuggesterItem, evt: MouseEvent | KeyboardEvent): void { 77 | this.callbackFunction(item, evt); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/ui/PluginCommands.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Notice } from "obsidian"; 2 | import * as selectionTools from "../features/selectionFunctions"; 3 | import * as transporter from "../features/transporterFunctions"; 4 | import type TextTransporterPlugin from "../main"; 5 | import { 6 | addBookmarkFromCurrentView, 7 | openBookmark, 8 | removeBookmark, 9 | } from "../utils/bookmarks"; 10 | import { ViewType, getActiveViewType } from "../utils/views"; 11 | import { 12 | GenericFuzzySuggester, 13 | type SuggesterItem, 14 | } from "./GenericFuzzySuggester2"; 15 | import QuickCaptureModal from "./QuickCaptureModal"; 16 | 17 | export default class PluginCommands { 18 | plugin: TextTransporterPlugin; 19 | // commands notes 20 | // shortcut - MUST be unique, used as part of the Command Palette ID 21 | // isContextMenuItem - this is a context menu item or not 22 | // cmItemEnabled - is the context menu item enabled 23 | commands = [ 24 | { 25 | caption: "Quick Capture", 26 | shortcut: "QC", 27 | group: "QuickCapture", 28 | editModeOnly: false, 29 | isContextMenuItem: false, 30 | cmItemEnabled: false, 31 | icon: "highlight-glyph", 32 | command: async (): Promise => 33 | new QuickCaptureModal(this.plugin).open(), 34 | }, 35 | { 36 | caption: "Select current line/expand to block", 37 | shortcut: "SB", 38 | group: "Selection", 39 | editModeOnly: true, 40 | isContextMenuItem: false, 41 | cmItemEnabled: false, 42 | icon: "highlight-glyph", 43 | command: async (): Promise => 44 | selectionTools.selectCurrentLine(this.plugin), 45 | }, 46 | { 47 | caption: "Select block - previous", 48 | shortcut: "BP", 49 | group: "Selection", 50 | editModeOnly: true, 51 | isContextMenuItem: false, 52 | cmItemEnabled: false, 53 | icon: "highlight-glyph", 54 | command: async (): Promise => 55 | selectionTools.selectAdjacentBlock(this.plugin, false), 56 | }, 57 | { 58 | caption: "Select block - next", 59 | shortcut: "BN", 60 | group: "Selection", 61 | editModeOnly: true, 62 | isContextMenuItem: false, 63 | cmItemEnabled: false, 64 | icon: "highlight-glyph", 65 | command: async (): Promise => 66 | selectionTools.selectAdjacentBlock(this.plugin, true), 67 | }, 68 | { 69 | caption: "Select current line/expand up into previous block", 70 | group: "Selection", 71 | shortcut: "SP", 72 | editModeOnly: true, 73 | isContextMenuItem: false, 74 | cmItemEnabled: false, 75 | icon: "highlight-glyph", 76 | command: async (): Promise => 77 | selectionTools.selectCurrentSection(this.plugin, true), 78 | }, 79 | { 80 | caption: "Select current line/expand down into next block", 81 | group: "Selection", 82 | shortcut: "SN", 83 | editModeOnly: true, 84 | isContextMenuItem: false, 85 | cmItemEnabled: false, 86 | icon: "highlight-glyph", 87 | command: async (): Promise => 88 | selectionTools.selectCurrentSection(this.plugin, false), 89 | }, 90 | { 91 | caption: "Replace link with text", 92 | shortcut: "RLT", 93 | group: "replace", 94 | editModeOnly: true, 95 | isContextMenuItem: true, 96 | cmItemEnabled: true, 97 | icon: "lines-of-text", 98 | command: async (): Promise => { 99 | const linkInfo = transporter.testIfCursorIsOnALink(this.plugin); 100 | if (linkInfo) 101 | await transporter.copyBlockReferenceToCurrentCusorLocation( 102 | this.plugin, 103 | linkInfo, 104 | false, 105 | ); 106 | else new Notice("No link selected in editor."); 107 | }, 108 | }, 109 | { 110 | caption: "Replace link with text & alias", 111 | shortcut: "RLA", 112 | group: "replace", 113 | editModeOnly: true, 114 | isContextMenuItem: true, 115 | cmItemEnabled: true, 116 | icon: "lines-of-text", 117 | command: async (): Promise => { 118 | const linkInfo = transporter.testIfCursorIsOnALink(this.plugin); 119 | if (linkInfo) 120 | await transporter.copyBlockReferenceToCurrentCusorLocation( 121 | this.plugin, 122 | linkInfo, 123 | true, 124 | ); 125 | else new Notice("No link selected in editor."); 126 | }, 127 | }, 128 | { 129 | caption: "Copy block embeds from this selection", 130 | shortcut: "CC", 131 | group: "block", 132 | editModeOnly: true, 133 | isContextMenuItem: true, 134 | cmItemEnabled: true, 135 | icon: "blocks", 136 | command: async (): Promise> => 137 | transporter.addBlockRefsToSelection(this.plugin, true), 138 | }, 139 | { 140 | caption: "Copy block embeds as an alias", 141 | shortcut: "CA", 142 | group: "block", 143 | editModeOnly: true, 144 | isContextMenuItem: true, 145 | cmItemEnabled: true, 146 | icon: "blocks", 147 | command: async (): Promise> => 148 | await transporter.addBlockRefsToSelection( 149 | this.plugin, 150 | true, 151 | true, 152 | this.plugin.settings.blockRefAliasIndicator, 153 | ), 154 | }, 155 | { 156 | caption: "Copy line/selection to another file", 157 | shortcut: "CLT", 158 | group: "ToFile", 159 | editModeOnly: true, 160 | isContextMenuItem: true, 161 | cmItemEnabled: true, 162 | icon: "left-arrow-with-tail", 163 | command: async (): Promise => 164 | transporter.copyOrPushLineOrSelectionToNewLocationWithFileLineSuggester( 165 | this.plugin, 166 | true, 167 | ), 168 | }, 169 | { 170 | caption: "Push line/selection to another file", 171 | shortcut: "PLT", 172 | group: "ToFile", 173 | editModeOnly: true, 174 | isContextMenuItem: true, 175 | cmItemEnabled: true, 176 | icon: "left-arrow-with-tail", 177 | command: async (): Promise => 178 | transporter.copyOrPushLineOrSelectionToNewLocationWithFileLineSuggester( 179 | this.plugin, 180 | false, 181 | ), 182 | }, 183 | { 184 | caption: "Push line/selection to another file as a block embed", 185 | shortcut: "PLB", 186 | group: "ToFile", 187 | editModeOnly: true, 188 | isContextMenuItem: true, 189 | cmItemEnabled: true, 190 | icon: "left-arrow-with-tail", 191 | command: async (): Promise => 192 | transporter.pushBlockReferenceToAnotherFile(this.plugin), 193 | }, 194 | { 195 | caption: "Send link of current note to a file", 196 | shortcut: "SLF", 197 | editModeOnly: true, 198 | group: "Send", 199 | isContextMenuItem: true, 200 | cmItemEnabled: true, 201 | icon: "paper-plane", 202 | command: async (): Promise => 203 | transporter.copyCurrentFileNameAsLinkToNewLocation(this.plugin, false), 204 | }, 205 | { 206 | caption: "Send link of current note to the Clipboard", 207 | shortcut: "SLC", 208 | editModeOnly: true, 209 | group: "Send", 210 | isContextMenuItem: true, 211 | cmItemEnabled: true, 212 | icon: "paper-plane", 213 | command: async (): Promise => 214 | transporter.copyCurrentFileNameAsLinkToNewLocation(this.plugin, true), 215 | }, 216 | { 217 | caption: "Copy line(s) from another file", 218 | shortcut: "CLF", 219 | editModeOnly: true, 220 | group: "FromFile", 221 | isContextMenuItem: true, 222 | cmItemEnabled: true, 223 | icon: "right-arrow-with-tail", 224 | command: async (): Promise => 225 | transporter.copyOrPulLineOrSelectionFromAnotherLocation( 226 | this.plugin, 227 | true, 228 | ), 229 | }, 230 | { 231 | caption: "Pull line(s) from another file", 232 | shortcut: "LLF", 233 | editModeOnly: true, 234 | group: "FromFile", 235 | isContextMenuItem: true, 236 | cmItemEnabled: true, 237 | icon: "right-arrow-with-tail", 238 | command: async (): Promise => 239 | transporter.copyOrPulLineOrSelectionFromAnotherLocation( 240 | this.plugin, 241 | false, 242 | ), 243 | }, 244 | { 245 | caption: "Pull Line(s) from another file as block embeds", 246 | shortcut: "LLB", 247 | group: "FromFile", 248 | editModeOnly: true, 249 | isContextMenuItem: true, 250 | cmItemEnabled: true, 251 | icon: "right-arrow-with-tail", 252 | command: async (): Promise => 253 | transporter.pullBlockReferenceFromAnotherFile(this.plugin), 254 | }, 255 | { 256 | caption: "Add a New Bookmark from this file", 257 | shortcut: "BA", 258 | group: "Bookmarks", 259 | editModeOnly: true, 260 | isContextMenuItem: true, 261 | cmItemEnabled: true, 262 | icon: "go-to-file", 263 | command: async (): Promise => 264 | addBookmarkFromCurrentView(this.plugin), 265 | }, 266 | { 267 | caption: "Open a bookmarked file", 268 | shortcut: "BO", 269 | group: "Bookmarks", 270 | editModeOnly: false, 271 | isContextMenuItem: false, 272 | cmItemEnabled: false, 273 | icon: "go-to-file", 274 | command: async (): Promise => await openBookmark(this.plugin), 275 | }, 276 | { 277 | caption: "Remove a Bookmark", 278 | shortcut: "BR", 279 | group: "Bookmarks", 280 | editModeOnly: true, 281 | isContextMenuItem: false, 282 | cmItemEnabled: false, 283 | icon: "go-to-file", 284 | command: async (): Promise => removeBookmark(this.plugin), 285 | }, 286 | ]; 287 | 288 | async reloadPlugin(): Promise { 289 | new Notice(`Reloading plugin: ${this.plugin.APP_NAME}`); 290 | // @ts-ignore 291 | await app.plugins.disablePlugin("obsidian42-text-transporter"); 292 | // @ts-ignore 293 | await app.plugins.enablePlugin("obsidian42-text-transporter"); 294 | } 295 | 296 | // list of all commands available in Command Pallet format 297 | async masterControlProgram(plugin: TextTransporterPlugin): Promise { 298 | // Yes this is a reference to Tron https://www.imdb.com/title/tt0084827/ 299 | const currentView = 300 | this.plugin.app.workspace.getActiveViewOfType(MarkdownView); 301 | let editMode = true; 302 | if (!currentView || currentView.getMode() !== "source") editMode = false; 303 | 304 | const gfs = new GenericFuzzySuggester(this.plugin); 305 | const cpCommands: Array = []; 306 | for (const cmd of this.commands) { 307 | const activeView = getActiveViewType(plugin); 308 | let addCommand = false; 309 | if ( 310 | cmd.group === "replace" && 311 | activeView === ViewType.source && 312 | transporter.testIfCursorIsOnALink(this.plugin) 313 | ) 314 | addCommand = true; 315 | else if ( 316 | cmd.group !== "replace" && 317 | (cmd.editModeOnly === false || (editMode && cmd.editModeOnly)) 318 | ) 319 | addCommand = true; 320 | else if ( 321 | (cmd.shortcut === "SLF" || cmd.shortcut === "SLC") && 322 | activeView !== ViewType.none 323 | ) { 324 | //send command. show file exists 325 | addCommand = true; 326 | } 327 | if (addCommand) 328 | cpCommands.push({ 329 | display: `${cmd.caption} (${cmd.shortcut})`, 330 | info: cmd.command, 331 | }); 332 | } 333 | 334 | if (editMode) { 335 | for (const bookmark of plugin.settings.bookmarks.split("\n")) { 336 | if (bookmark.substr(0, 1) === "*") { 337 | cpCommands.push({ 338 | display: `Copy to: ${bookmark}`, 339 | info: async (e) => { 340 | await transporter.copyOrPushLineOrSelectionToNewLocationUsingCurrentCursorLocationAndBoomark( 341 | plugin, 342 | true, 343 | bookmark, 344 | e, 345 | ); 346 | }, 347 | }); 348 | cpCommands.push({ 349 | display: ` Push: ${bookmark}`, 350 | info: async (e) => { 351 | await transporter.copyOrPushLineOrSelectionToNewLocationUsingCurrentCursorLocationAndBoomark( 352 | plugin, 353 | false, 354 | bookmark, 355 | e, 356 | ); 357 | }, 358 | }); 359 | } 360 | } 361 | } 362 | 363 | gfs.setSuggesterData(cpCommands); 364 | // biome-ignore lint/suspicious/noExplicitAny: 365 | gfs.display(async (i: any, evt: MouseEvent | KeyboardEvent) => i.info(evt)); //call the callback 366 | } 367 | 368 | constructor(plugin: TextTransporterPlugin) { 369 | this.plugin = plugin; 370 | this.plugin.addCommand({ 371 | id: "combinedCommands", 372 | name: "All Commands List", 373 | icon: "TextTransporter", 374 | callback: async () => { 375 | await this.masterControlProgram(this.plugin); 376 | }, 377 | }); 378 | false; 379 | 380 | // load context menu settings from plugin settings 381 | for (let i = 0; i < this.commands.length; i++) 382 | if ( 383 | this.plugin.settings[`cMenuEnabled-${this.commands[i].shortcut}`] !== 384 | undefined 385 | ) 386 | this.commands[i].cmItemEnabled = 387 | this.plugin.settings[`cMenuEnabled-${this.commands[i].shortcut}`]; 388 | 389 | this.plugin.registerEvent( 390 | this.plugin.app.workspace.on("editor-menu", (menu) => { 391 | menu.addSeparator(); 392 | for (const value of this.commands) { 393 | let addCommand = false; 394 | if (value.cmItemEnabled === true && value.group !== "replace") 395 | addCommand = true; 396 | else if ( 397 | value.cmItemEnabled === true && 398 | value.group === "replace" && 399 | transporter.testIfCursorIsOnALink(this.plugin) 400 | ) 401 | addCommand = true; 402 | if (addCommand) { 403 | menu.addItem((item) => { 404 | item 405 | .setTitle(value.caption) 406 | .setIcon(value.icon) 407 | .onClick(async () => { 408 | await value.command(); 409 | }); 410 | }); 411 | } 412 | } 413 | //load bookmmarks in CM 414 | const bookmarks = plugin.settings.bookmarks.split("\n"); 415 | if (bookmarks.length > 0) { 416 | menu.addSeparator(); 417 | for (const bookmark of bookmarks) { 418 | if (bookmark.substr(0, 1) === "*") { 419 | const bookmarkText = ( 420 | bookmark.length >= 40 421 | ? `${bookmark.substr(0, 40)}...` 422 | : bookmark 423 | ).replace("*", ""); 424 | menu.addItem((item) => { 425 | item 426 | .setTitle(`Copy to: ${bookmarkText}`) 427 | .setIcon("star-list") 428 | .onClick( 429 | async (e) => 430 | await transporter.copyOrPushLineOrSelectionToNewLocationUsingCurrentCursorLocationAndBoomark( 431 | plugin, 432 | true, 433 | bookmark, 434 | e, 435 | ), 436 | ); 437 | }); 438 | menu.addItem((item) => { 439 | item 440 | .setTitle(`Push to: ${bookmarkText}`) 441 | .onClick( 442 | async (e) => 443 | await transporter.copyOrPushLineOrSelectionToNewLocationUsingCurrentCursorLocationAndBoomark( 444 | plugin, 445 | false, 446 | bookmark, 447 | e, 448 | ), 449 | ); 450 | }); 451 | } 452 | } 453 | } 454 | menu.addSeparator(); 455 | }), 456 | ); 457 | 458 | for (const value of Object.values(this.commands)) { 459 | if (value.editModeOnly) { 460 | this.plugin.addCommand({ 461 | id: value.shortcut, 462 | icon: value.icon, 463 | name: `${value.caption} (${value.shortcut})`, 464 | editorCallback: value.command, 465 | }); 466 | } else { 467 | this.plugin.addCommand({ 468 | id: value.shortcut, 469 | icon: value.icon, 470 | name: `${value.caption} (${value.shortcut})`, 471 | callback: value.command, 472 | }); 473 | } 474 | } 475 | } 476 | } 477 | -------------------------------------------------------------------------------- /src/ui/QuickCaptureModal.ts: -------------------------------------------------------------------------------- 1 | import { Modal, Platform, Setting } from "obsidian"; 2 | import * as transporter from "../features/transporterFunctions"; 3 | import type TextTransporterPlugin from "../main"; 4 | import { SilentFileAndTagSuggester } from "./SilentFileAndTagSuggester2"; 5 | 6 | export default class QuickCaptureModal extends Modal { 7 | plugin: TextTransporterPlugin; 8 | suggester: SilentFileAndTagSuggester; 9 | 10 | constructor(plugin: TextTransporterPlugin) { 11 | super(plugin.app); 12 | this.plugin = plugin; 13 | } 14 | 15 | async submitForm(qcText: string): Promise { 16 | if (qcText.trim().length === 0) return; //no text do nothing 17 | transporter.copyOrPushLineOrSelectionToNewLocationWithFileLineSuggester( 18 | this.plugin, 19 | true, 20 | qcText, 21 | ); 22 | this.close(); 23 | } 24 | 25 | onOpen(): void { 26 | let qcInput = ""; 27 | 28 | this.titleEl.createEl("div", "Quick Capture").setText("Quick Capture"); 29 | 30 | this.contentEl.createEl("form", {}, (formEl) => { 31 | new Setting(formEl).addTextArea((textEl) => { 32 | // biome-ignore lint/suspicious/noAssignInExpressions: 33 | textEl.onChange((value) => (qcInput = value)); 34 | textEl.inputEl.rows = 6; 35 | if (Platform.isIosApp) textEl.inputEl.style.width = "100%"; 36 | else if (Platform.isDesktopApp) { 37 | textEl.inputEl.rows = 10; 38 | textEl.inputEl.cols = 100; 39 | } 40 | textEl.inputEl.addEventListener("keydown", async (e: KeyboardEvent) => { 41 | if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { 42 | e.preventDefault(); 43 | await this.submitForm(qcInput); 44 | } 45 | }); 46 | window.setTimeout(() => textEl.inputEl.focus(), 10); 47 | this.suggester = new SilentFileAndTagSuggester( 48 | this.plugin.app, 49 | textEl.inputEl, 50 | ); 51 | }); 52 | 53 | formEl.createDiv("modal-button-container", (buttonContainerEl) => { 54 | buttonContainerEl 55 | .createEl("button", { 56 | attr: { type: "submit" }, 57 | cls: "mod-cta", 58 | text: "Capture", 59 | }) 60 | .addEventListener("click", async (e) => { 61 | e.preventDefault(); 62 | await this.submitForm(qcInput); 63 | }); 64 | }); 65 | }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/ui/SettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type App, 3 | Platform, 4 | PluginSettingTab, 5 | Setting, 6 | type ToggleComponent, 7 | } from "obsidian"; 8 | import type TextTransporterPlugin from "../main"; 9 | 10 | export class SettingsTab extends PluginSettingTab { 11 | plugin: TextTransporterPlugin; 12 | 13 | constructor(app: App, plugin: TextTransporterPlugin) { 14 | super(app, plugin); 15 | this.plugin = plugin; 16 | } 17 | 18 | display(): void { 19 | const { containerEl } = this; 20 | containerEl.empty(); 21 | 22 | new Setting(containerEl) 23 | .setName("Alias placeholder") 24 | .setDesc("Placeholder text used for an aliased block reference.") 25 | .addText((text) => 26 | text 27 | .setValue(this.plugin.settings.blockRefAliasIndicator) 28 | .onChange(async (value) => { 29 | if (value.trim() === "") 30 | this.plugin.settings.blockRefAliasIndicator = "*"; 31 | //empty value, default to * 32 | else this.plugin.settings.blockRefAliasIndicator = value; 33 | await this.plugin.saveSettings(); 34 | }), 35 | ); 36 | 37 | new Setting(containerEl).setName("Bookmarks").setHeading(); 38 | 39 | new Setting(containerEl) 40 | .setName("Bookmarks") 41 | .setDesc( 42 | `Predefined destinations within files that appear at the top of the file selector. 43 | Each line represents one bookmark. The line starts with the path to the file (ex: directory1/subdirectory/filename.md) 44 | If just the file path is provided, the file contents will be shown for insertion. 45 | If after the file name there is a semicolon followed by either: TOP BOTTOM or text to find in the document as an insertion point. Example:\n 46 | directory1/subdirectory/filename1.md;TOP directory1/subdirectory/filename2.md;BOTTOM directory1/subdirectory/filename3.md;# Inbox 47 | Optionally DNPTODAY or DNPTOMORROW can be used in the place of a file name to default to today's or tomorrows Daily Notes Page. 48 | `, 49 | ) 50 | .addTextArea((textEl) => { 51 | textEl 52 | .setPlaceholder( 53 | " directory1/subdirectory/filename1.md;\n directory1/subdirectory/filename2.md;TOP\n directory1/subdirectory/filename3.md;BOTTOM\n directory1/subdirectory/filename4.md;# Inbox", 54 | ) 55 | .setValue(this.plugin.settings.bookmarks || "") 56 | .onChange((value) => { 57 | this.plugin.settings.bookmarks = value; 58 | this.plugin.saveData(this.plugin.settings); 59 | }); 60 | textEl.inputEl.rows = 6; 61 | if (Platform.isIosApp) textEl.inputEl.style.width = "100%"; 62 | else if (Platform.isDesktopApp) { 63 | textEl.inputEl.rows = 15; 64 | textEl.inputEl.cols = 120; 65 | } 66 | }); 67 | 68 | const desc = document.createDocumentFragment(); 69 | desc.append( 70 | desc.createEl("a", { 71 | href: "https://tfthacker.com/Obsidian+Plugins+by+TfTHacker/Text+Transporter/Bookmarks+in+Text+Transporter", 72 | text: "Additional documentation for bookmarks.", 73 | }), 74 | ); 75 | containerEl.createEl("div", { text: "" }).append(desc); 76 | 77 | new Setting(containerEl) 78 | .setName("Context menu commands: enable/disable") 79 | .setHeading(); 80 | 81 | for (const command of this.plugin.commands.commands) { 82 | new Setting(containerEl) 83 | .setName(command.caption) 84 | .addToggle((cb: ToggleComponent) => { 85 | cb.setValue(command.cmItemEnabled); 86 | cb.onChange(async (value: boolean) => { 87 | command.cmItemEnabled = value; 88 | // @ts-ignore 89 | this.plugin.settings[`cMenuEnabled-${command.shortcut}`] = value; 90 | await this.plugin.saveSettings(); 91 | }); 92 | }); 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/ui/SilentFileAndTagSuggester2.ts: -------------------------------------------------------------------------------- 1 | // Thanks bro! 2 | // https://github.com/chhoumann/quickadd/blob/779ae3d884981790531b26b414a57577b46f7147/src/gui/silentFileAndTagSuggester.ts 3 | 4 | import Fuse from "fuse.js"; 5 | import type { App, TAbstractFile } from "obsidian"; 6 | import { TFile } from "obsidian"; 7 | import { TextInputSuggest } from "./silentFileAndTagSuggesterSuggest1"; 8 | 9 | enum TagOrFile { 10 | Tag = 0, 11 | File = 1, 12 | } 13 | 14 | // This is not an accurate wikilink regex - but works for its intended purpose. 15 | const FILE_LINK_REGEX = new RegExp(/\[\[([^\]]*)$/); 16 | const TAG_REGEX = new RegExp(/#([^ ]*)$/); 17 | 18 | export class SilentFileAndTagSuggester extends TextInputSuggest { 19 | private lastInput = ""; 20 | private lastInputType: TagOrFile; 21 | private files: TFile[]; 22 | private unresolvedLinkNames: string[]; 23 | private tags: string[]; 24 | 25 | // constructor(public app: App, public inputEl: HTMLInputElement) { 26 | constructor( 27 | public app: App, 28 | public inputEl: HTMLTextAreaElement, 29 | ) { 30 | super(app, inputEl); 31 | this.files = app.vault.getMarkdownFiles(); 32 | this.unresolvedLinkNames = this.getUnresolvedLinkNames(app); 33 | 34 | // @ts-ignore 35 | this.tags = Object.keys(app.metadataCache.getTags()); 36 | } 37 | 38 | getSuggestions(inputStr: string): string[] { 39 | const cursorPosition: number = this.inputEl.selectionStart; 40 | const inputBeforeCursor: string = inputStr.substr(0, cursorPosition); 41 | const fileLinkMatch = FILE_LINK_REGEX.exec(inputBeforeCursor); 42 | const tagMatch = TAG_REGEX.exec(inputBeforeCursor); 43 | 44 | let suggestions: string[] = []; 45 | 46 | if (tagMatch) { 47 | const tagInput: string = tagMatch[1]; 48 | this.lastInput = tagInput; 49 | this.lastInputType = TagOrFile.Tag; 50 | suggestions = this.tags.filter((tag) => 51 | tag.toLowerCase().contains(tagInput.toLowerCase()), 52 | ); 53 | } 54 | 55 | if (fileLinkMatch) { 56 | const fileNameInput: string = fileLinkMatch[1]; 57 | this.lastInput = fileNameInput; 58 | this.lastInputType = TagOrFile.File; 59 | suggestions = this.files 60 | .filter((file) => 61 | file.path.toLowerCase().contains(fileNameInput.toLowerCase()), 62 | ) 63 | .map((file) => file.path); 64 | suggestions.push( 65 | ...this.unresolvedLinkNames.filter((name) => 66 | name.toLowerCase().contains(fileNameInput.toLowerCase()), 67 | ), 68 | ); 69 | } 70 | 71 | const fuse = new Fuse(suggestions, { 72 | findAllMatches: true, 73 | threshold: 0.8, 74 | }); 75 | return fuse.search(this.lastInput).map((value) => value.item); 76 | } 77 | 78 | renderSuggestion(item: string, el: HTMLElement): void { 79 | if (item) el.setText(item); 80 | } 81 | 82 | selectSuggestion(item: string): void { 83 | const cursorPosition: number = this.inputEl.selectionStart; 84 | const lastInputLength: number = this.lastInput.length; 85 | const currentInputValue: string = this.inputEl.value; 86 | let insertedEndPosition = 0; 87 | 88 | if (this.lastInputType === TagOrFile.File) { 89 | const linkFile: TAbstractFile = 90 | this.app.vault.getAbstractFileByPath(item); 91 | 92 | if (linkFile instanceof TFile) { 93 | insertedEndPosition = this.makeLinkObsidianMethod( 94 | linkFile, 95 | currentInputValue, 96 | cursorPosition, 97 | lastInputLength, 98 | ); 99 | } else { 100 | insertedEndPosition = this.makeLinkManually( 101 | currentInputValue, 102 | item.replace(/.md$/, ""), 103 | cursorPosition, 104 | lastInputLength, 105 | ); 106 | } 107 | } 108 | 109 | if (this.lastInputType === TagOrFile.Tag) { 110 | this.inputEl.value = this.getNewInputValueForTag( 111 | currentInputValue, 112 | item, 113 | cursorPosition, 114 | lastInputLength, 115 | ); 116 | insertedEndPosition = cursorPosition - lastInputLength + item.length - 1; 117 | } 118 | 119 | this.inputEl.trigger("input"); 120 | this.close(); 121 | this.inputEl.setSelectionRange(insertedEndPosition, insertedEndPosition); 122 | } 123 | 124 | private makeLinkObsidianMethod( 125 | linkFile: TFile, 126 | currentInputValue: string, 127 | cursorPosition: number, 128 | lastInputLength: number, 129 | ) { 130 | const link = this.app.fileManager.generateMarkdownLink(linkFile, ""); 131 | this.inputEl.value = this.getNewInputValueForFileLink( 132 | currentInputValue, 133 | link, 134 | cursorPosition, 135 | lastInputLength, 136 | ); 137 | return cursorPosition - lastInputLength + link.length + 2; 138 | } 139 | 140 | private makeLinkManually( 141 | currentInputValue: string, 142 | item: string, 143 | cursorPosition: number, 144 | lastInputLength: number, 145 | ) { 146 | this.inputEl.value = this.getNewInputValueForFileName( 147 | currentInputValue, 148 | item, 149 | cursorPosition, 150 | lastInputLength, 151 | ); 152 | return cursorPosition - lastInputLength + item.length + 2; 153 | } 154 | 155 | private getNewInputValueForFileLink( 156 | currentInputElValue: string, 157 | selectedItem: string, 158 | cursorPosition: number, 159 | lastInputLength: number, 160 | ): string { 161 | return `${currentInputElValue.substr(0, cursorPosition - lastInputLength - 2)}${selectedItem}${currentInputElValue.substr( 162 | cursorPosition, 163 | )}`; 164 | } 165 | 166 | private getNewInputValueForFileName( 167 | currentInputElValue: string, 168 | selectedItem: string, 169 | cursorPosition: number, 170 | lastInputLength: number, 171 | ): string { 172 | return `${currentInputElValue.substr(0, cursorPosition - lastInputLength)}${selectedItem}]]${currentInputElValue.substr( 173 | cursorPosition, 174 | )}`; 175 | } 176 | 177 | private getNewInputValueForTag( 178 | currentInputElValue: string, 179 | selectedItem: string, 180 | cursorPosition: number, 181 | lastInputLength: number, 182 | ) { 183 | return `${currentInputElValue.substr(0, cursorPosition - lastInputLength - 1)}${selectedItem}${currentInputElValue.substr( 184 | cursorPosition, 185 | )}`; 186 | } 187 | 188 | private getUnresolvedLinkNames(app: App): string[] { 189 | const unresolvedLinks: Record> = app 190 | .metadataCache.unresolvedLinks; 191 | const unresolvedLinkNames: Set = new Set(); 192 | 193 | for (const sourceFileName in unresolvedLinks) { 194 | for (const unresolvedLink in unresolvedLinks[sourceFileName]) { 195 | unresolvedLinkNames.add(unresolvedLink); 196 | } 197 | } 198 | 199 | return Array.from(unresolvedLinkNames); 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/ui/silentFileAndTagSuggesterSuggest1.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { type Instance as PopperInstance, createPopper } from "@popperjs/core"; 4 | import { type App, type ISuggestOwner, Scope } from "obsidian"; 5 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 18 | this.owner = owner; 19 | this.containerEl = containerEl; 20 | 21 | containerEl.on( 22 | "click", 23 | ".suggestion-item", 24 | this.onSuggestionClick.bind(this), 25 | ); 26 | containerEl.on( 27 | "mousemove", 28 | ".suggestion-item", 29 | this.onSuggestionMouseover.bind(this), 30 | ); 31 | 32 | scope.register([], "ArrowUp", (event) => { 33 | if (!event.isComposing) { 34 | this.setSelectedItem(this.selectedItem - 1, true); 35 | return false; 36 | } 37 | }); 38 | 39 | scope.register([], "ArrowDown", (event) => { 40 | if (!event.isComposing) { 41 | this.setSelectedItem(this.selectedItem + 1, true); 42 | return false; 43 | } 44 | }); 45 | 46 | scope.register([], "Enter", (event) => { 47 | if (!event.isComposing) { 48 | this.useSelectedItem(event); 49 | return false; 50 | } 51 | }); 52 | } 53 | 54 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 55 | event.preventDefault(); 56 | 57 | const item = this.suggestions.indexOf(el); 58 | this.setSelectedItem(item, false); 59 | this.useSelectedItem(event); 60 | } 61 | 62 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 63 | const item = this.suggestions.indexOf(el); 64 | this.setSelectedItem(item, false); 65 | } 66 | 67 | setSuggestions(values: T[]) { 68 | this.containerEl.empty(); 69 | const suggestionEls: HTMLDivElement[] = []; 70 | 71 | // biome-ignore lint/complexity/noForEach: 72 | values.forEach((value) => { 73 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 74 | this.owner.renderSuggestion(value, suggestionEl); 75 | suggestionEls.push(suggestionEl); 76 | }); 77 | 78 | this.values = values; 79 | this.suggestions = suggestionEls; 80 | this.setSelectedItem(0, false); 81 | } 82 | 83 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 84 | const currentValue = this.values[this.selectedItem]; 85 | if (currentValue) { 86 | this.owner.selectSuggestion(currentValue, event); 87 | } 88 | } 89 | 90 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 91 | const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 92 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 93 | const selectedSuggestion = this.suggestions[normalizedIndex]; 94 | 95 | prevSelectedSuggestion?.removeClass("is-selected"); 96 | selectedSuggestion?.addClass("is-selected"); 97 | 98 | this.selectedItem = normalizedIndex; 99 | 100 | if (scrollIntoView) { 101 | selectedSuggestion.scrollIntoView(false); 102 | } 103 | } 104 | } 105 | 106 | export abstract class TextInputSuggest implements ISuggestOwner { 107 | protected app: App; 108 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 109 | 110 | private popper: PopperInstance; 111 | private scope: Scope; 112 | private suggestEl: HTMLElement; 113 | private suggest: Suggest; 114 | 115 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 116 | this.app = app; 117 | this.inputEl = inputEl; 118 | this.scope = new Scope(); 119 | 120 | this.suggestEl = createDiv("suggestion-container"); 121 | const suggestion = this.suggestEl.createDiv("suggestion"); 122 | this.suggest = new Suggest(this, suggestion, this.scope); 123 | 124 | this.scope.register([], "Escape", this.close.bind(this)); 125 | 126 | this.inputEl.addEventListener("input", this.onInputChanged.bind(this)); 127 | this.inputEl.addEventListener("focus", this.onInputChanged.bind(this)); 128 | this.inputEl.addEventListener("blur", this.close.bind(this)); 129 | this.suggestEl.on( 130 | "mousedown", 131 | ".suggestion-container", 132 | (event: MouseEvent) => { 133 | event.preventDefault(); 134 | }, 135 | ); 136 | } 137 | 138 | onInputChanged(): void { 139 | const inputStr = this.inputEl.value; 140 | const suggestions = this.getSuggestions(inputStr); 141 | 142 | if (!suggestions) { 143 | this.close(); 144 | return; 145 | } 146 | 147 | if (suggestions.length > 0) { 148 | this.suggest.setSuggestions(suggestions); 149 | // biome-ignore lint/suspicious/noExplicitAny: 150 | this.open((this.app).dom.appContainerEl, this.inputEl); 151 | } else { 152 | this.close(); 153 | } 154 | } 155 | 156 | open(container: HTMLElement, inputEl: HTMLElement): void { 157 | // biome-ignore lint/suspicious/noExplicitAny: 158 | (this.app).keymap.pushScope(this.scope); 159 | 160 | container.appendChild(this.suggestEl); 161 | this.popper = createPopper(inputEl, this.suggestEl, { 162 | placement: "bottom-start", 163 | modifiers: [ 164 | { 165 | name: "sameWidth", 166 | enabled: true, 167 | fn: ({ state, instance }) => { 168 | // Note: positioning needs to be calculated twice - 169 | // first pass - positioning it according to the width of the popper 170 | // second pass - position it with the width bound to the reference element 171 | // we need to early exit to avoid an infinite loop 172 | const targetWidth = `${state.rects.reference.width}px`; 173 | if (state.styles.popper.width === targetWidth) { 174 | return; 175 | } 176 | state.styles.popper.width = targetWidth; 177 | instance.update(); 178 | }, 179 | phase: "beforeWrite", 180 | requires: ["computeStyles"], 181 | }, 182 | ], 183 | }); 184 | } 185 | 186 | close(): void { 187 | // biome-ignore lint/suspicious/noExplicitAny: 188 | (this.app).keymap.popScope(this.scope); 189 | 190 | this.suggest.setSuggestions([]); 191 | if (this.popper) this.popper.destroy(); 192 | this.suggestEl.detach(); 193 | } 194 | 195 | abstract getSuggestions(inputStr: string): T[]; 196 | abstract renderSuggestion(item: T, el: HTMLElement): void; 197 | abstract selectSuggestion(item: T): void; 198 | } 199 | -------------------------------------------------------------------------------- /src/utils/blockId.ts: -------------------------------------------------------------------------------- 1 | import { customAlphabet } from "nanoid"; 2 | 3 | export const generateBlockId = customAlphabet( 4 | "abcdefghijklmnopqrstuvwz0123456789", 5 | 6, 6 | ); 7 | -------------------------------------------------------------------------------- /src/utils/bookmarks.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView, Notice } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import { 4 | GenericFuzzySuggester, 5 | type SuggesterItem, 6 | } from "../ui/GenericFuzzySuggester2"; 7 | import { 8 | openFileInObsidian, 9 | parseBookmarkForItsElements, 10 | } from "./fileNavigatior"; 11 | 12 | // Creates a bookmark from the current selection point. 13 | // Bookmarks can be created for: 14 | // Top of file 15 | // Bottom of file 16 | // Specific location of file based on matching a string 17 | // Optionally, a bookmark can be added to the context menu by adding an asterisk to the beginning of the line. 18 | export async function addBookmarkFromCurrentView( 19 | plugin: TextTransporterPlugin, 20 | ): Promise { 21 | const currentView = plugin.app.workspace.getActiveViewOfType(MarkdownView); 22 | if (!currentView || currentView.getMode() !== "source") { 23 | new Notice("A file must be in source edit mode to add a bookmark"); 24 | return; 25 | } 26 | const currentLineText = currentView.editor.getLine( 27 | currentView.editor.getCursor().line, 28 | ); 29 | const locationChooser = new GenericFuzzySuggester(this); 30 | const data = new Array(); 31 | data.push({ display: "TOP: Bookmark the top of the file ", info: "TOP" }); 32 | data.push({ 33 | display: 34 | "TOP: Bookmark the top of the file and mark as a context menu location", 35 | info: "TOP*", 36 | }); 37 | data.push({ 38 | display: "BOTTOM: Bookmark the bottom of the file ", 39 | info: "BOTTOM", 40 | }); 41 | data.push({ 42 | display: 43 | "BOTTOM: Bookmark the bottom of the file and mark as a context menu location", 44 | info: "BOTTOM*", 45 | }); 46 | if (currentLineText.length > 0) { 47 | data.push({ 48 | display: `Location: of selected text "${currentLineText}"`, 49 | info: currentLineText, 50 | }); 51 | data.push({ 52 | display: `Location: of selected text and mark as a context menu location "${currentLineText}"`, 53 | info: `${currentLineText}*`, 54 | }); 55 | } 56 | locationChooser.setSuggesterData(data); 57 | locationChooser.display((location: SuggesterItem) => { 58 | let command = location.info; 59 | let prefix = ""; 60 | if (location.info.indexOf("*") > 0) { 61 | command = command.replace("*", ""); 62 | prefix = "*"; 63 | } 64 | if (location) { 65 | const newBookmark = `${prefix + currentView.file.path};${command}`; 66 | if (plugin.settings.bookmarks.split("\n").find((b) => b === newBookmark)) 67 | new Notice(`The bookmark: ${newBookmark} already exists.`); 68 | else { 69 | plugin.settings.bookmarks = `${plugin.settings.bookmarks.trim()}\n${newBookmark}`; 70 | plugin.saveData(plugin.settings); 71 | new Notice(`The bookmark: ${newBookmark} saved.`); 72 | } 73 | } 74 | }); 75 | } 76 | 77 | // Quick way to remove a bookmark from the bookmarks list 78 | export async function removeBookmark( 79 | plugin: TextTransporterPlugin, 80 | ): Promise { 81 | const bookmarks = plugin.settings.bookmarks.split("\n"); 82 | if (bookmarks.length === 0) new Notice("There are no bookmarks defined."); 83 | else { 84 | const bookmarkChooser = new GenericFuzzySuggester(this); 85 | const data = new Array(); 86 | for (const b of bookmarks) data.push({ display: b, info: b }); 87 | bookmarkChooser.setSuggesterData(data); 88 | bookmarkChooser.display((bookmarkLine: SuggesterItem) => { 89 | const newBookmarks = bookmarks.filter((b) => b !== bookmarkLine.info); 90 | plugin.settings.bookmarks = newBookmarks.join("\n"); 91 | plugin.saveData(plugin.settings); 92 | }); 93 | } 94 | } 95 | 96 | // Open the file of a bookmark at its defined location 97 | export async function openBookmark( 98 | plugin: TextTransporterPlugin, 99 | ): Promise { 100 | const bookmarks = plugin.settings.bookmarks.split("\n"); 101 | if (bookmarks.length === 0) new Notice("There are no bookmarks defined."); 102 | else { 103 | const fileList = new Array(); 104 | for (let i = bookmarks.length - 1; i >= 0; i--) 105 | fileList.unshift({ display: bookmarks[i], info: bookmarks[i] }); 106 | const chooser = new GenericFuzzySuggester(plugin); 107 | chooser.setSuggesterData(fileList); 108 | chooser.setPlaceholder("Select a file"); 109 | await chooser.display(async (fileSelected: SuggesterItem) => { 110 | const bookmarkInfo = await parseBookmarkForItsElements( 111 | plugin, 112 | fileSelected.info, 113 | false, 114 | ); 115 | openFileInObsidian( 116 | plugin, 117 | bookmarkInfo.fileName, 118 | bookmarkInfo.fileLineNumber, 119 | 0, 120 | ); 121 | }); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/utils/dailyNotesPages.ts: -------------------------------------------------------------------------------- 1 | import { moment } from "obsidian"; 2 | import { 3 | createDailyNote, 4 | getAllDailyNotes, 5 | getDailyNote, 6 | } from "obsidian-daily-notes-interface"; 7 | 8 | // Get or create DNP for today's date 9 | export async function getDnpForToday(): Promise { 10 | let dnp = getDailyNote(moment(), getAllDailyNotes()); 11 | if (dnp === null) dnp = await createDailyNote(moment()); 12 | return dnp.path; 13 | } 14 | 15 | export async function getDnpForTomorrow(): Promise { 16 | let dnp = getDailyNote(moment().add(1, "days"), getAllDailyNotes()); 17 | if (dnp === null) dnp = await createDailyNote(moment().add(1, "days")); 18 | return dnp.path; 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/fileCacheAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CacheItem, 3 | CachedMetadata, 4 | HeadingCache, 5 | Loc, 6 | Pos, 7 | SectionCache, 8 | TFile, 9 | } from "obsidian"; 10 | import type TextTransporterPlugin from "../main"; 11 | 12 | export interface CacheDetails { 13 | index: number; 14 | type: string; 15 | lineStart: number; // helper value, make it easier to loop through this object. 16 | lineEnd: number; // helper value, make it easier to loop through this object 17 | position: Pos; 18 | blockId?: string; // if a block id is assigned, this will have a value 19 | headingText?: string; 20 | headingLevel?: number; 21 | } 22 | 23 | export class FileCacheAnalyzer { 24 | cache: CachedMetadata; 25 | details: Array = []; 26 | plugin: TextTransporterPlugin; 27 | fileFullPath: string; 28 | 29 | constructor(plugin: TextTransporterPlugin, fileFullPath: string) { 30 | this.plugin = plugin; 31 | this.cache = ( 32 | plugin.app.metadataCache.getCache(fileFullPath) 33 | ); 34 | this.fileFullPath = fileFullPath; 35 | 36 | if (this.cache.sections) { 37 | for (const section of this.cache.sections) { 38 | switch (section.type) { 39 | case "heading": 40 | this.breakdownCacheItems(this.cache.headings, section, false); 41 | break; 42 | case "list": 43 | this.breakdownCacheItems(this.cache.listItems, section, true); 44 | break; 45 | default: 46 | this.details.push({ 47 | index: 0, 48 | type: section.type, 49 | lineStart: section.position.start.line, 50 | lineEnd: section.position.end.line, 51 | position: section.position, 52 | blockId: section.id, 53 | }); 54 | break; 55 | } 56 | } 57 | for (const i in this.details) this.details[i].index = Number(i); 58 | } 59 | } 60 | 61 | getBlockAtLine(line: number, defaultForward: boolean): CacheDetails { 62 | let lastBlockToMatch = this.details[0]; //default to 0 element 63 | for (let i = 0; i < this.details.length; i++) { 64 | const currentItem = this.details[i]; 65 | if (defaultForward === false && line >= currentItem.lineEnd) 66 | lastBlockToMatch = currentItem; 67 | else if (defaultForward) { 68 | const nextItem = this.details[i + 1]; 69 | if (line > currentItem.lineEnd && nextItem && line < nextItem.lineStart) 70 | lastBlockToMatch = nextItem; 71 | else if (line >= currentItem.lineStart) lastBlockToMatch = currentItem; 72 | } 73 | } 74 | return lastBlockToMatch; 75 | } 76 | 77 | getBlockAfterLine(line: number): CacheDetails { 78 | const blockIndexAtLine = this.getBlockAtLine(line, true).index; 79 | if (this.details.length === 1) return this.details[0]; 80 | if (this.details.length - 1 > blockIndexAtLine) 81 | return this.details[blockIndexAtLine + 1]; 82 | return null; 83 | } 84 | 85 | getBlockBeforeLine(line: number): CacheDetails { 86 | const blockNumberAtLine = this.getBlockAtLine(line, false).index; 87 | if (this.details.length === 0) return null; 88 | if (blockNumberAtLine > 0 && this.details.length >= blockNumberAtLine) 89 | return this.details[blockNumberAtLine - 1]; 90 | return this.details[0]; 91 | } 92 | 93 | getPositionOfHeaderAndItsChildren(headerName: string): Pos { 94 | let startLine: Loc = null; 95 | let endLine: Loc = null; 96 | let headingLevel = null; 97 | for (const h of this.details) { 98 | if ( 99 | startLine === null && 100 | h.type === "heading" && 101 | h.headingText === headerName 102 | ) { 103 | startLine = h.position.start; 104 | headingLevel = h.headingLevel; 105 | endLine = h.position.end; 106 | } else if ( 107 | startLine != null && 108 | h.type === "heading" && 109 | h.headingLevel <= headingLevel 110 | ) { 111 | break; 112 | } else endLine = h.position.end; 113 | } 114 | return startLine === null ? null : { start: startLine, end: endLine }; 115 | } 116 | 117 | //debugging function: creats a doc with information 118 | async createDocumentWithInfo(): Promise { 119 | let output = `# ${this.fileFullPath}\n\n`; 120 | for (const item of this.details) { 121 | output += `${item.type} ${item.lineStart}->${item.lineEnd} ${item.blockId ? item.blockId : ""}\n`; 122 | } 123 | const fileName = "/fileBreadkown.md"; 124 | await this.plugin.app.vault.adapter.write(fileName, output); 125 | const newFile = await this.plugin.app.vault.getAbstractFileByPath(fileName); 126 | const leaf = this.plugin.app.workspace.splitActiveLeaf("vertical"); 127 | leaf.openFile(newFile); 128 | } 129 | 130 | breakdownCacheItems( 131 | cacheItems: Array, 132 | section: SectionCache, 133 | checkForBlockRefs: boolean, 134 | ): void { 135 | let itemsFoundTrackToBreakOut = false; 136 | for (const itemInCache of cacheItems) { 137 | const positionInSameRange = this.positionOfItemWithinSameRange( 138 | itemInCache.position, 139 | section.position, 140 | ); 141 | if (positionInSameRange === false && itemsFoundTrackToBreakOut === true) { 142 | break; // this looks funny but is for perf, but prevents the loop from continuing once matches have been found, but the item is no longer matched. 143 | } 144 | if (positionInSameRange) { 145 | itemsFoundTrackToBreakOut = true; // will prevent the whole cacheItems from being looped once a match is found 146 | // section has a match in cache, so insert into details 147 | const itemToAppend: CacheDetails = { 148 | index: 0, 149 | type: section.type, 150 | lineStart: itemInCache.position.start.line, 151 | lineEnd: itemInCache.position.end.line, 152 | position: itemInCache.position, 153 | }; 154 | 155 | const heading = itemInCache; 156 | if (heading.heading) { 157 | //check if there is heading text 158 | itemToAppend.headingText = heading.heading; 159 | itemToAppend.headingLevel = heading.level; 160 | } 161 | 162 | if (checkForBlockRefs && this.cache.blocks) { 163 | // check for block references and insert them 164 | for (const b of Object.values(this.cache.blocks)) { 165 | if ( 166 | this.positionOfItemWithinSameRange( 167 | b.position, 168 | itemInCache.position, 169 | ) 170 | ) { 171 | itemToAppend.blockId = b.id; 172 | break; 173 | } 174 | } 175 | } 176 | this.details.push(itemToAppend); 177 | } 178 | } // cacheItems.forEach 179 | } 180 | 181 | // compares to Pos objects to see if they are in the same range 182 | positionOfItemWithinSameRange( 183 | firstPosition: Pos, 184 | secondPosition: Pos, 185 | ): boolean { 186 | return ( 187 | firstPosition.start.line >= secondPosition.start.line && 188 | firstPosition.end.line <= secondPosition.end.line 189 | ); 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /src/utils/fileNavigatior.ts: -------------------------------------------------------------------------------- 1 | import { type Editor, Notice, type TFile, getLinkpath } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import { 4 | GenericFuzzySuggester, 5 | type SuggesterItem, 6 | } from "../ui/GenericFuzzySuggester2"; 7 | import { getDnpForToday, getDnpForTomorrow } from "./dailyNotesPages"; 8 | import { 9 | blocksWhereTagIsUsed, 10 | filesWhereTagIsUsed, 11 | getAllTagsJustTagNames, 12 | } from "./tags"; 13 | import { ViewType, getActiveView, getActiveViewType } from "./views"; 14 | 15 | export interface FileChooserCallback { 16 | ( 17 | targetFileName: string, 18 | fileContentsArray: Array, 19 | startLine: number, 20 | endLine: number, 21 | evtFileselected?: MouseEvent | KeyboardEvent, 22 | evtFirstLine?: MouseEvent | KeyboardEvent, 23 | evetLastLine?: MouseEvent | KeyboardEvent, 24 | ); 25 | } 26 | 27 | const TAG_FILE_SEARCH = "#### #tag file search ####"; 28 | const TAG_BLOCK_SEARCH = "---- #tag block search ----"; 29 | 30 | export const getUniqueLinkPath = (filePath: string): string => { 31 | //@ts-ignore 32 | return app.metadataCache.fileToLinktext( 33 | app.vault.getAbstractFileByPath(filePath), 34 | "", 35 | ); 36 | }; 37 | 38 | export async function createFileChooser( 39 | plugin: TextTransporterPlugin, 40 | excludeFileFromList?: string, 41 | ): Promise { 42 | const fileList: Array = await plugin.fs.getAllFiles(); 43 | if (excludeFileFromList) 44 | ///don't include this file if needed 45 | for (let i = 0; i < fileList.length; i++) { 46 | if ( 47 | fileList[i].info.localeCompare(excludeFileFromList, undefined, { 48 | sensitivity: "base", 49 | }) === 0 50 | ) { 51 | fileList.splice(i, 1); 52 | break; 53 | } 54 | } 55 | 56 | fileList.unshift({ display: TAG_BLOCK_SEARCH, info: TAG_BLOCK_SEARCH }); 57 | fileList.unshift({ display: TAG_FILE_SEARCH, info: TAG_FILE_SEARCH }); 58 | 59 | // add bookmarks to suggester 60 | if (plugin.settings.bookmarks.trim().length > 0) { 61 | const bookmarks = plugin.settings.bookmarks.trim().split("\n"); 62 | for (let i = bookmarks.length - 1; i >= 0; i--) { 63 | let filePath = bookmarks[i]; 64 | if (filePath.search(";") > 0) 65 | filePath = filePath.substr(0, filePath.search(";")); 66 | filePath = filePath.replace("*", ""); 67 | if ( 68 | filePath === "DNPTODAY" || 69 | filePath === "DNPTOMORROW" || 70 | (await plugin.app.vault.adapter.exists(filePath)) 71 | ) { 72 | fileList.unshift({ 73 | display: `Bookmark: ${bookmarks[i]}`, 74 | info: bookmarks[i], 75 | }); 76 | } 77 | } 78 | } 79 | 80 | const chooser = new GenericFuzzySuggester(plugin); 81 | chooser.setSuggesterData(fileList); 82 | chooser.setPlaceholder("Select a file"); 83 | return chooser; 84 | } 85 | 86 | // convert file into an array based on suggesterITem 87 | export async function convertFileIntoArray( 88 | plugin: TextTransporterPlugin, 89 | filePath: string, 90 | ): Promise> { 91 | const fileContentsArray: Array = []; 92 | for (const [key, value] of Object.entries( 93 | (await plugin.app.vault.adapter.read(filePath)).split("\n"), 94 | )) 95 | fileContentsArray.push({ display: value, info: key }); 96 | return fileContentsArray; 97 | } 98 | 99 | export async function openFileInObsidian( 100 | plugin: TextTransporterPlugin, 101 | filePath: string, 102 | gotoStartLineNumber = 0, 103 | lineCount = 0, 104 | ): Promise { 105 | const newLeaf = plugin.app.workspace.splitActiveLeaf("vertical"); 106 | const file: TFile = plugin.app.metadataCache.getFirstLinkpathDest( 107 | getLinkpath(filePath), 108 | "/", 109 | ); 110 | await newLeaf.openFile(file, { active: true }); 111 | setTimeout(async () => { 112 | const editor: Editor = getActiveView(plugin).editor; 113 | editor.setSelection( 114 | { line: gotoStartLineNumber, ch: 0 }, 115 | { 116 | line: gotoStartLineNumber + lineCount, 117 | ch: editor.getLine(gotoStartLineNumber + lineCount).length, 118 | }, 119 | ); 120 | editor.scrollIntoView({ 121 | from: { line: gotoStartLineNumber + lineCount, ch: 0 }, 122 | to: { line: gotoStartLineNumber + lineCount, ch: 0 }, 123 | }); 124 | }, 500); 125 | } 126 | 127 | export interface bookmarkInfo { 128 | fileName: string; 129 | fileLineNumber: number; 130 | fileBookmarkContentsArray: Array; 131 | errorNumber: number; 132 | contextMenuCommand: boolean; 133 | } 134 | 135 | // pullTypeRequest - if it is a pull type reqeust, this should be true, some commands might need different behavior if a pull 136 | export async function parseBookmarkForItsElements( 137 | plugin: TextTransporterPlugin, 138 | bookmarkCommandString: string, 139 | pullTypeRequest = false, 140 | ): Promise { 141 | let error = 0; // error = 0 no problem, 1 = location in file does not exists, 2 file doesnt exist 142 | let isContextMenuCommand = false; 143 | if (bookmarkCommandString.substr(0, 1) === "*") { 144 | isContextMenuCommand = true; 145 | bookmarkCommandString = bookmarkCommandString.substring(1); 146 | } 147 | let filePath = bookmarkCommandString.substring( 148 | 0, 149 | bookmarkCommandString.search(";"), 150 | ); 151 | const command = bookmarkCommandString 152 | .substring(filePath.length + 1) 153 | .toLocaleUpperCase() 154 | .trim(); 155 | try { 156 | if (filePath === "DNPTODAY") filePath = await getDnpForToday(); 157 | if (filePath === "DNPTOMORROW") filePath = await getDnpForTomorrow(); 158 | let lineNumber = -1; //default for top 159 | let fileBkmrkContentsArray: Array = null; 160 | if (await plugin.app.vault.adapter.exists(filePath)) { 161 | fileBkmrkContentsArray = await convertFileIntoArray(plugin, filePath); 162 | if (command === "BOTTOM" || command !== "TOP") { 163 | if (command === "BOTTOM") 164 | lineNumber = fileBkmrkContentsArray.length - 1; 165 | else { 166 | // bookmark has a location, so find in file. 167 | for (let i = 0; i < fileBkmrkContentsArray.length; i++) { 168 | if ( 169 | fileBkmrkContentsArray[i].display.toLocaleUpperCase().trim() === 170 | command 171 | ) { 172 | lineNumber = pullTypeRequest === true ? i + 1 : i; 173 | break; 174 | } 175 | } 176 | if (lineNumber === -1) error = 1; //location doesnt exist in file 177 | } 178 | } 179 | } else error = 2; 180 | 181 | return { 182 | fileName: filePath, 183 | fileLineNumber: lineNumber, 184 | fileBookmarkContentsArray: fileBkmrkContentsArray, 185 | errorNumber: error, 186 | contextMenuCommand: isContextMenuCommand, 187 | }; 188 | } catch (e) { 189 | new Notice( 190 | `Something is wrong with the bookmark. File system reports: ${e.toString()}`, 191 | ); 192 | error = 2; 193 | } 194 | } 195 | 196 | export async function createTagFileListChooser( 197 | plugin: TextTransporterPlugin, 198 | returnEndPoint: boolean, 199 | showTop: boolean, 200 | callback: FileChooserCallback, 201 | ): Promise { 202 | const tagList = getAllTagsJustTagNames(); 203 | if (tagList.length <= 0) { 204 | new Notice("No tags in this vault"); 205 | return; 206 | } 207 | 208 | const tagListArray: Array = []; 209 | for (const tag of tagList) tagListArray.push({ display: tag, info: tag }); 210 | 211 | const tagChooser = new GenericFuzzySuggester(plugin); 212 | tagChooser.setSuggesterData(tagListArray); 213 | tagChooser.setPlaceholder("Select a tag"); 214 | await tagChooser.display(async (tagChosen: SuggesterItem) => { 215 | const tagFileListArray: Array = []; 216 | const filesForChosenTag = filesWhereTagIsUsed(tagChosen.info); 217 | for (const tag of filesForChosenTag) 218 | tagFileListArray.push({ display: tag, info: tag }); 219 | 220 | const tagFileChooser = new GenericFuzzySuggester(plugin); 221 | tagFileChooser.setSuggesterData(tagFileListArray); 222 | tagFileChooser.setPlaceholder("Select a file"); 223 | 224 | await tagFileChooser.display( 225 | async ( 226 | fieleChosen: SuggesterItem, 227 | evtFile: MouseEvent | KeyboardEvent, 228 | ) => { 229 | const fileContentsArray: Array = 230 | await convertFileIntoArray(plugin, fieleChosen.info); 231 | if (showTop) 232 | fileContentsArray.unshift({ display: "-- Top of file --", info: -1 }); 233 | await displayFileLineSuggesterFromFileList( 234 | plugin, 235 | returnEndPoint, 236 | showTop, 237 | fieleChosen.info, 238 | fileContentsArray, 239 | 0, 240 | evtFile, 241 | callback, 242 | ); 243 | }, 244 | ); 245 | }); 246 | } 247 | 248 | export async function createTagBlockListChooser( 249 | plugin: TextTransporterPlugin, 250 | returnEndPoint: boolean, 251 | showTop: boolean, 252 | callback: FileChooserCallback, 253 | ): Promise { 254 | const tagList = getAllTagsJustTagNames(); 255 | if (tagList.length <= 0) { 256 | new Notice("No tags in this vault"); 257 | return; 258 | } 259 | 260 | const tagListArray: Array = []; 261 | for (const tag of tagList) tagListArray.push({ display: tag, info: tag }); 262 | 263 | const tagChooser = new GenericFuzzySuggester(plugin); 264 | tagChooser.setSuggesterData(tagListArray); 265 | tagChooser.setPlaceholder("Select a tag"); 266 | await tagChooser.display(async (tagChosen: SuggesterItem) => { 267 | const tagFileListArray: Array = []; 268 | const tagBlocks = blocksWhereTagIsUsed(plugin, tagChosen.info); 269 | for (const tag of await tagBlocks) 270 | tagFileListArray.push({ 271 | display: `${tag.file}\n${tag.blockText}`, 272 | info: tag, 273 | }); 274 | 275 | const tagBlockChooser = new GenericFuzzySuggester(plugin); 276 | tagBlockChooser.setSuggesterData(tagFileListArray); 277 | tagBlockChooser.setPlaceholder("Select a block"); 278 | 279 | await tagBlockChooser.display( 280 | async (tagBlock: SuggesterItem, evt: MouseEvent | KeyboardEvent) => { 281 | callback( 282 | tagBlock.info.file, 283 | await convertFileIntoArray(plugin, tagBlock.info.file), 284 | tagBlock.info.position.start.line, 285 | tagBlock.info.position.end.line, 286 | evt, 287 | ); 288 | }, 289 | ); 290 | }); 291 | } 292 | 293 | // displays a file selection suggester, then the contents of the file, then calls the callback with: 294 | // Callback function will receive: Path+FileName, file contents as an array and line choosen 295 | // if returnEndPoint = true, another suggester is shown so user can select endpoint of selection from file 296 | // show top will diplsay -- top at top of suggester 297 | // pullTypeRequest - if it is a pull type reqeust, this should be true, some commands might need different behavior if a pull 298 | export async function displayFileLineSuggester( 299 | plugin: TextTransporterPlugin, 300 | returnEndPoint: boolean, 301 | showTop: boolean, 302 | pullTypeRequest: boolean, 303 | callback: FileChooserCallback, 304 | ): Promise { 305 | const chooser = 306 | getActiveViewType(plugin) === ViewType.none 307 | ? await createFileChooser(plugin) 308 | : await createFileChooser(plugin, getActiveView(plugin).file.path); 309 | await chooser.display( 310 | async ( 311 | fileSelected: SuggesterItem, 312 | evtFileSelected: MouseEvent | KeyboardEvent, 313 | ) => { 314 | const shiftKeyUsed = evtFileSelected.shiftKey; 315 | 316 | let fileContentsStartingLine = 0; 317 | let targetFileName = fileSelected.info; 318 | 319 | if (targetFileName === TAG_FILE_SEARCH) { 320 | await createTagFileListChooser( 321 | plugin, 322 | returnEndPoint, 323 | showTop, 324 | callback, 325 | ); 326 | return; 327 | } 328 | if (targetFileName === TAG_BLOCK_SEARCH) { 329 | await createTagBlockListChooser( 330 | plugin, 331 | returnEndPoint, 332 | showTop, 333 | callback, 334 | ); 335 | return; 336 | } 337 | if ( 338 | targetFileName.includes("DNPTODAY") || 339 | targetFileName.includes("DNPTOMORROW") || 340 | targetFileName.includes(".md;") 341 | ) { 342 | // a bookmark was selected with a command. process callback 343 | const bkmkInfo = await parseBookmarkForItsElements( 344 | plugin, 345 | targetFileName, 346 | pullTypeRequest, 347 | ); 348 | if (shiftKeyUsed === false) { 349 | // bookmark location, perform the transport command 350 | callback( 351 | bkmkInfo.fileName, 352 | bkmkInfo.fileBookmarkContentsArray, 353 | bkmkInfo.fileLineNumber, 354 | bkmkInfo.fileLineNumber, 355 | evtFileSelected, 356 | ); 357 | return; 358 | } 359 | // use the bookmarked location as starting point for next step in commands 360 | fileContentsStartingLine = bkmkInfo.fileLineNumber; 361 | targetFileName = bkmkInfo.fileName; 362 | showTop = false; 363 | } 364 | 365 | const fileContentsArray: Array = 366 | await convertFileIntoArray(plugin, targetFileName); 367 | if (showTop) 368 | fileContentsArray.unshift({ display: "-- Top of file --", info: -1 }); 369 | 370 | await displayFileLineSuggesterFromFileList( 371 | plugin, 372 | returnEndPoint, 373 | showTop, 374 | targetFileName, 375 | fileContentsArray, 376 | fileContentsStartingLine, 377 | evtFileSelected, 378 | callback, 379 | ); 380 | }, 381 | ); 382 | } 383 | 384 | // supports displayFileLineSuggester and displayTagFileSuggester 385 | export async function displayFileLineSuggesterFromFileList( 386 | plugin: TextTransporterPlugin, 387 | returnEndPoint: boolean, 388 | showTop: boolean, 389 | targetFileName: string, 390 | fileContentsArray: Array, 391 | fileContentsStartingLine: number, 392 | evtFileSelected: MouseEvent | KeyboardEvent, 393 | callback: FileChooserCallback, 394 | ): Promise { 395 | const firstLinechooser = new GenericFuzzySuggester(plugin); 396 | firstLinechooser.setPlaceholder("Select the line from file"); 397 | 398 | const lineCountArray: SuggesterItem[] = fileContentsArray.map((item) => { 399 | const lineNumber = Number(item.info) + 1; 400 | return { 401 | display: (lineNumber > 0 ? `${lineNumber} ` : "") + item.display, 402 | info: item.info, 403 | }; 404 | }); 405 | 406 | if (fileContentsStartingLine > 0) 407 | firstLinechooser.setSuggesterData( 408 | lineCountArray.slice(fileContentsStartingLine), 409 | ); 410 | else firstLinechooser.setSuggesterData(lineCountArray); 411 | 412 | await firstLinechooser.display( 413 | async ( 414 | iFileLocation: SuggesterItem, 415 | evtFirstLine: MouseEvent | KeyboardEvent, 416 | ) => { 417 | let startFilePosition = Number(iFileLocation.info); 418 | const endFilePosition = startFilePosition; 419 | if (showTop) fileContentsArray.splice(0, 1); // remove "-- Top of File -- " 420 | if (returnEndPoint) { 421 | //if expecting endpoint, show suggester again 422 | if (startFilePosition === fileContentsArray.length - 1) { 423 | //only one element in file, or selection is end of file 424 | callback( 425 | targetFileName, 426 | fileContentsArray, 427 | startFilePosition, 428 | startFilePosition, 429 | evtFileSelected, 430 | evtFirstLine, 431 | ); 432 | } else { 433 | startFilePosition = startFilePosition === -1 ? 0 : startFilePosition; 434 | const endPointArray = fileContentsArray.slice(startFilePosition); 435 | const lastLineChooser = new GenericFuzzySuggester(plugin); 436 | lastLineChooser.setSuggesterData(endPointArray); 437 | lastLineChooser.setPlaceholder( 438 | "Select the last line for the selection", 439 | ); 440 | await lastLineChooser.display( 441 | async ( 442 | iFileLocationEndPoint: SuggesterItem, 443 | evetLastLine: MouseEvent | KeyboardEvent, 444 | ) => { 445 | callback( 446 | targetFileName, 447 | fileContentsArray, 448 | startFilePosition, 449 | Number(iFileLocationEndPoint.info), 450 | evtFileSelected, 451 | evtFirstLine, 452 | evetLastLine, 453 | ); 454 | }, 455 | ); 456 | } 457 | } else { 458 | callback( 459 | targetFileName, 460 | fileContentsArray, 461 | startFilePosition, 462 | endFilePosition, 463 | evtFileSelected, 464 | evtFirstLine, 465 | ); 466 | } 467 | }, 468 | ); 469 | } 470 | -------------------------------------------------------------------------------- /src/utils/fileSystem.ts: -------------------------------------------------------------------------------- 1 | import { type App, type TAbstractFile, TFolder, Vault } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import type { SuggesterItem } from "../ui/GenericFuzzySuggester2"; 4 | 5 | enum FileSystemReturnType { 6 | foldersOnly = 1, 7 | filesOnly = 2, 8 | filesAndFolders = 3, 9 | } 10 | 11 | function testFolderExclusion( 12 | folder: string, 13 | exclusionFolders: Array, 14 | ): boolean { 15 | // return true if should be excluded 16 | for (const eFolder of exclusionFolders) 17 | if (folder.startsWith(`${eFolder}/`)) return true; 18 | return false; 19 | } 20 | 21 | async function getFiles( 22 | app: App, 23 | returnType: FileSystemReturnType, 24 | responseArray: Array, 25 | exclusionFolders: Array, 26 | ) { 27 | // first list just files 28 | if ( 29 | returnType === FileSystemReturnType.filesOnly || 30 | returnType === FileSystemReturnType.filesAndFolders 31 | ) 32 | for (const file of app.vault.getMarkdownFiles()) 33 | if (!testFolderExclusion(file.path, exclusionFolders)) 34 | responseArray.push({ display: file.path, info: file.path }); //add file to array 35 | 36 | // second list folders 37 | if ( 38 | returnType === FileSystemReturnType.foldersOnly || 39 | returnType === FileSystemReturnType.filesAndFolders 40 | ) { 41 | Vault.recurseChildren( 42 | app.vault.getRoot(), 43 | (abstractFile: TAbstractFile) => { 44 | if (abstractFile instanceof TFolder) { 45 | const path = 46 | abstractFile.path === "/" 47 | ? abstractFile.path 48 | : `${abstractFile.path}/`; 49 | responseArray.push({ display: path, info: path }); //add file to array 50 | } 51 | }, 52 | ); 53 | } 54 | } 55 | 56 | async function addLastOpenFiles(app: App, responseArray: Array) { 57 | const lastOpenFiles = app.workspace.getLastOpenFiles(); 58 | if (lastOpenFiles.length === 0) return; 59 | 60 | //confirm file exists 61 | for (let iLF = 0; iLF < lastOpenFiles.length; iLF++) 62 | if ((await app.vault.adapter.exists(lastOpenFiles[iLF])) === false) 63 | lastOpenFiles.splice(iLF, 1); 64 | 65 | //remove recent files from list 66 | for (let iLF = 0; iLF < lastOpenFiles.length; iLF++) { 67 | const recentFile = lastOpenFiles[iLF]; 68 | for (let iFile = 0; iFile < responseArray.length; iFile++) { 69 | if (recentFile === responseArray[iFile].info) { 70 | responseArray.splice(iFile, 1); 71 | break; 72 | } 73 | } 74 | } 75 | 76 | // add recent files to the top of the list 77 | for (let i = lastOpenFiles.length - 1; i >= 0; i--) 78 | responseArray.unshift({ 79 | display: `Recent file: ${lastOpenFiles[i]}`, 80 | info: lastOpenFiles[i], 81 | }); //add file to array 82 | } 83 | 84 | export default class FileSystem { 85 | plugin: TextTransporterPlugin; 86 | exclusionFolders: Array = []; 87 | dnpLabel: string; 88 | 89 | constructor(plugin: TextTransporterPlugin) { 90 | this.plugin = plugin; 91 | } 92 | 93 | setExclusionFolders(exclusion: Array): void { 94 | this.exclusionFolders = exclusion; 95 | } 96 | 97 | async getAllFolders(): Promise> { 98 | const results: Array = []; 99 | await getFiles( 100 | this.plugin.app, 101 | FileSystemReturnType.foldersOnly, 102 | results, 103 | this.exclusionFolders, 104 | ); 105 | return results; 106 | } 107 | 108 | async getAllFiles(): Promise> { 109 | const results: Array = []; 110 | await getFiles( 111 | this.plugin.app, 112 | FileSystemReturnType.filesOnly, 113 | results, 114 | this.exclusionFolders, 115 | ); 116 | await addLastOpenFiles(this.plugin.app, results); 117 | return results; 118 | } 119 | 120 | async getAllFoldersAndFiles(): Promise> { 121 | const results: Array = []; 122 | await getFiles( 123 | this.plugin.app, 124 | FileSystemReturnType.filesAndFolders, 125 | results, 126 | this.exclusionFolders, 127 | ); 128 | await addLastOpenFiles(this.plugin.app, results); 129 | return results; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/utils/tags.ts: -------------------------------------------------------------------------------- 1 | import type { App, CachedMetadata, Pos } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | import { FileCacheAnalyzer } from "./fileCacheAnalyzer"; 4 | import { convertFileIntoArray } from "./fileNavigatior"; 5 | 6 | interface TagLocation { 7 | tag: string; 8 | filePath: string; 9 | position: Pos; 10 | } 11 | 12 | //convenience function 13 | export function getAllTagsWithCounts(): string[] { 14 | //@ts-ignore 15 | return app.metadataCache.getTags(); 16 | } 17 | 18 | export function getAllTagsJustTagNames(): string[] { 19 | //@ts-ignore 20 | return Object.keys(app.metadataCache.getTags()).sort((a, b) => 21 | a.localeCompare(b), 22 | ); 23 | } 24 | 25 | export function locationsWhereTagIsUsed(findTag: string): Array { 26 | // @ts-ignore 27 | const oApp: App = app; 28 | const results = []; 29 | for (const file of oApp.vault.getMarkdownFiles()) { 30 | const cache: CachedMetadata = oApp.metadataCache.getFileCache(file); 31 | if (cache.tags) 32 | for (const tag of cache.tags) 33 | if (findTag === tag.tag) 34 | results.push({ 35 | tag: tag, 36 | filePath: file.path, 37 | position: tag.position, 38 | }); 39 | } 40 | return results.sort((a: TagLocation, b: TagLocation) => 41 | a.filePath.localeCompare(b.filePath), 42 | ); 43 | } 44 | 45 | export function filesWhereTagIsUsed(findTag: string): string[] { 46 | const filesList = []; 47 | for (const l of locationsWhereTagIsUsed(findTag)) 48 | if (!filesList.includes(l.filePath)) filesList.push(l.filePath); 49 | return filesList; 50 | } 51 | 52 | export async function blocksWhereTagIsUsed( 53 | plugin: TextTransporterPlugin, 54 | findTag: string, 55 | ): Promise { 56 | const blockInfo = []; 57 | for (const l of locationsWhereTagIsUsed(findTag)) { 58 | const f = new FileCacheAnalyzer(plugin, l.filePath); 59 | const block = f.getBlockAtLine(l.position.start.line, true); 60 | if (block.type !== "yaml") { 61 | const taggedFileArray = await convertFileIntoArray(plugin, l.filePath); 62 | let blockText = ""; 63 | for (const line of taggedFileArray.slice( 64 | block.lineStart, 65 | block.lineEnd + 1, 66 | )) 67 | blockText += `${line.display}\n`; 68 | blockInfo.push({ 69 | file: l.filePath, 70 | position: block.position, 71 | blockText: blockText.trim(), 72 | }); 73 | } 74 | } 75 | return blockInfo; 76 | } 77 | -------------------------------------------------------------------------------- /src/utils/views.ts: -------------------------------------------------------------------------------- 1 | import { MarkdownView } from "obsidian"; 2 | import type TextTransporterPlugin from "../main"; 3 | 4 | export enum ViewType { 5 | source = 0, 6 | preview = 1, 7 | none = 2, 8 | } 9 | 10 | export function getActiveView(plugin: TextTransporterPlugin): MarkdownView { 11 | return plugin.app.workspace.getActiveViewOfType(MarkdownView); 12 | } 13 | 14 | export function getActiveViewType(plugin: TextTransporterPlugin): ViewType { 15 | const currentView = getActiveView(plugin); 16 | if (!currentView) return ViewType.none; 17 | if (currentView.getMode() === "source") return ViewType.source; 18 | if (currentView.getMode() === "preview") return ViewType.preview; 19 | } 20 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* no css defined */ -------------------------------------------------------------------------------- /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 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"], 15 | "types": ["obsidian-typings"] 16 | }, 17 | "include": ["**/*.ts", "src/types.d.ts"] 18 | } 19 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /version-github-action.mjs: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { exec } from 'child_process'; 3 | 4 | // Read the manifest.json file 5 | fs.readFile('manifest.json', 'utf8', (err, data) => { 6 | if (err) { 7 | console.error(`Error reading file from disk: ${err}`); 8 | } else { 9 | // Parse the file content to a JavaScript object 10 | const manifest = JSON.parse(data); 11 | 12 | // Extract the version 13 | const version = manifest.version; 14 | 15 | // Execute the git commands 16 | exec(`git tag -a ${version} -m "${version}" && git push origin ${version}`, (error, stdout, stderr) => { 17 | if (error) { 18 | console.error(`exec error: ${error}`); 19 | return; 20 | } 21 | console.log(`stdout: ${stdout}`); 22 | console.error(`stderr: ${stderr}`); 23 | }); 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.4": "1.4.16", 3 | "1.0.5": "1.5.0", 4 | "1.0.6": "1.5.11", 5 | "1.0.10": "1.5.11", 6 | "1.0.11": "1.5.11", 7 | "1.0.12": "1.7.2" 8 | } 9 | --------------------------------------------------------------------------------