├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── assets ├── contextmenu.png ├── openwith.png ├── pathexplorer.png ├── pathexplorer_settings.png └── settings.png ├── esbuild.config.mjs ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── conflict.ts ├── file_manager.ts ├── icon_modal.ts ├── main.ts ├── obsidian-internals.d.ts ├── open_with_cmd.ts ├── path_explorer.ts ├── path_modal.ts └── settings.ts ├── styles.css ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.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: "18.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 | --draft \ 34 | main.js manifest.json styles.css 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # backup 12 | backup 13 | 14 | # Don't include the compiled main.js file in the repo. 15 | # They should be uploaded to GitHub releases instead. 16 | main.js 17 | src/cmd_test.txt 18 | 19 | # Exclude sourcemaps 20 | *.map 21 | 22 | # obsidian 23 | data.json 24 | 25 | # Exclude macOS Finder (System Explorer) View States 26 | .DS_Store 27 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Juan Fco Sicilia 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 | # File Manager Plugin for Obsidian 2 | 3 | This plugin enhances the Obsidian File Explorer by introducing essential file management features. It adds several new commands to interact with the `File Explorer`, allowing users to bind hotkeys for efficient keyboard-only file management. 4 | 5 | **Version 1.2**: Introduces the new [`pathexplorer`](#pathexplorer-codeblock) codeblock. See [details](#pathexplorer-codeblock) below. 6 | 7 | **Version 1.3**: Introduces the ability to use URL schemas as commands in `Open With...`. 8 | 9 | **Version 1.4**: Introduces `move note` and `go to folder` commands. `Pathexplorer` can show now absolute paths and you can use environment variables in the `paths`. 10 | 11 | ## Features 12 | 13 | - **Open With**: Open files or folders using custom commands. 14 | - **Create Subfolder**: Create a subfolder within the current folder. 15 | - **Create Folder**: Create a sibling folder. 16 | - **Create Note**: Create an empty note in the current folder. 17 | - **Duplicate**: Duplicate files or folders. 18 | - **Move**: Move selected files or folders to a new location. 19 | - **Copy**: Copy selected files or folders to a new location. 20 | - **Copy, Cut, Paste**: Copy or cut selected files or folders to the clipboard and paste them. 21 | - **Clear Clipboard**: Clear the clipboard contents. 22 | - **Toggle Selection**: Toggle the selection of a file or folder. 23 | - **Select All**: Select all files and folders. 24 | - **Invert Selection**: Invert the current selection. 25 | - **Deselect All**: Clear all selections. 26 | - **Rename**: Rename files or folders. 27 | 28 | *Version 1.1* 29 | 30 | - **Go to File/Folder**: Locate and focus on a file or folder in the file explorer. 31 | - **Open file/folder with...**: Open file or folder in the file explorer with custom program. 32 | 33 | *Version 1.2* 34 | 35 | - **`pathexplorer` codeblock**: Display files and folders from specified paths and open them using custom commands. 36 | 37 | *Version 1.4* 38 | 39 | - **Move note**: Move active note to a new location. 40 | - **Go to Folder**: Locate and focus on a folder in the file explorer. 41 | 42 | ### Copy/Move Conflict Resolution 43 | 44 | When file conflicts occur, choose from the following resolution methods: 45 | 46 | - **Overwrite**: Replace the existing file or folder. 47 | - **Skip**: Ignore the conflicting file or folder. 48 | - **Keep**: Retain both files by renaming the new one. 49 | 50 | ## Usage 51 | 52 | ### File Explorer Commands 53 | 54 | > **NOTE**: These commands are available only when the `File Explorer` panel is focused. 55 | 56 | - `File Manager: Create a subfolder within the focused or active file/folder`. 57 | - `File Manager: Create a folder as sibling of the focused or active file/folder`. 58 | - `File Manager: Create a note within the focused or active folder`. 59 | - `File Manager: Duplicate focused or active file/folder`, 60 | - `File Manager: Copy selected files/folders to clipboard`, 61 | - `File Manager: Cut selected files/folders to clipboard`, 62 | - `File Manager: Paste files/folders from clipboard to selected folder`, 63 | - `File Manager: Clear clipboard`, 64 | - `File Manager: Move selected files/folders to a new folder`. 65 | - `File Manager: Copy selected files/folders to a new folder`. 66 | - `File Manager: Select all siblings and children of the focused or active file/folder`. 67 | - `File Manager: Toggle selection of the focused or active file/folder`. 68 | - `File Manager: Clear selection`. 69 | - `File Manager: Invert selection`. 70 | - `File Manager: Rename focused or active file/folder`. 71 | 72 | *Version 1.1* 73 | 74 | - `File Manager: Go to file or folder in file explorer`. 75 | - `File Manager: Open with `. 76 | 77 | *Version 1.4* 78 | 79 | - `File Manager: Move active note to a new folder`. 80 | - `File Manager: Go to folder in file explorer`. 81 | 82 | 83 | #### Global Commands 84 | 85 | > **NOTE**: The following commands are available if a file explorer exists in Obsidian. 86 | 87 | - `File Manager: Go to file or folder in file explorer`. 88 | - `File Manager: Go to folder in file explorer`. 89 | 90 | > **NOTE**: This command is globally available. If the file explorer is active, the focused or selected file/folder will be used for the `Open With` command. Otherwise, the currently active document will be used. 91 | 92 | - `File Manager: Open with ...` 93 | 94 | ### Open with... 95 | 96 | Create custom `Open With` commands in the settings tab. 97 | 98 | ![Open With](./assets/openwith.png) 99 | 100 | **NEW:** Version 1.3.1 allows to define app URL Schemas as commands (for example: `ulysses://x-callback-url/open?path={{file_path}}`). 101 | 102 | The `Open With` commands are also available in the File Context Menu if enabled in the settings. 103 | 104 | 105 | 106 | ### pathexplorer codeblock 107 | 108 | Version 1.2 introduces the `pathexplorer` codeblock. For example, adding the following codeblock to a note: 109 | 110 | ```` 111 | ```pathexplorer 112 | # Path or paths to explore. 113 | path $HOME/dev/dump_shortcuts 114 | path c:\tools\obsidian 115 | path %USERPROFILE%\projects 116 | 117 | # If present include dump_shortcuts as root of files and folders. 118 | include-root 119 | 120 | # Use .gitignore syntax to ignore files/folders. 121 | ignore .git/ 122 | ignore .venv/ 123 | ignore old/ 124 | ignore build/ 125 | ignore dist/ 126 | ignore test*/ 127 | ignore __pycache__/ 128 | ignore .* 129 | ignore *.bat 130 | ignore *.spec 131 | 132 | # Define max-depth (default 1) 133 | max-depth 3 134 | 135 | # Define max-files (default 100) 136 | max-files 20 137 | ``` 138 | ```` 139 | 140 | Will render the following output in reader mode: 141 | 142 | ![Settings](./assets/pathexplorer.png) 143 | 144 | #### `pathexplorer` codeblock syntax. 145 | 146 | > **#** 147 | > 148 | > For line comments 149 | > 150 | > **path \** 151 | > 152 | > *Version 1.4* 153 | > 154 | > Now you can use environment variables in the path. The Linux/Mac format (`$`) and the Windows format (`%%`) can be used interchangeably. On Windows, the `HOME` environment variable will be translated to `USERPROFILE` if it does not exist. 155 | > 156 | > Specify paths to explore. Multiple paths can be defined, one per line. 157 | > 158 | > **include-root** 159 | > 160 | > Include the root folder as the parent of its children. 161 | > 162 | > **max-depth** 163 | > 164 | > Max depth level to explore in the tree. 165 | > 166 | > **max-files** 167 | > 168 | > Max number of files/folders to display. 169 | > 170 | > **ignore** 171 | > 172 | > Ignore files/folders using `.gitignore` patterns. Inverted patterns (`!`) are also supported. Multiple ignore patterns can be defined, one per line. 173 | > 174 | > **flat [\ | hide-files | hide-folders]** 175 | > 176 | > Display files and folders as a list without hierarchy. Use optional flags to hide files or folders. 177 | > 178 | > **hide-icons** 179 | > 180 | > Hide command icons next to files/folders. 181 | > 182 | > *Version 1.4* 183 | > 184 | > **absolute-path [\ | all | root]** 185 | > 186 | > Display absolute path on folders. By default `none` is used (no absolute paths). `all` flag shows absolute path in every folder. `root` flag show absolute path in root folder. 187 | 188 | 189 | Create custom patterns in the settings tab. 190 | 191 | ![pathexplorer settings](./assets/pathexplorer_settings.png) 192 | 193 | ## Installation 194 | 195 | Install `File Manager` from the Community Plugins section. 196 | 197 | ## Configuration 198 | 199 | Customize plugin behavior using the general settings tab. 200 | 201 | **NOTE**: Refer to the `Open With` and `pathexplorer` sections for additional settings. 202 | 203 | ![Settings](./assets/settings.png) 204 | 205 | ## Development 206 | 207 | 1. Clone this repository into the `.obsidian/plugins` folder of an Obsidian Vault. 208 | 2. Ensure Node.js version is at least v16 (`node --version`). 209 | 3. Run `npm install` to install dependencies. 210 | 4. Run `npm run dev` to start compilation in watch mode. 211 | 5. Enable the `File Manager` plugin in Obsidian settings. 212 | 213 | ## Support 214 | 215 | For issues or feature requests, open an issue on the GitHub repository. 216 | 217 | ## License 218 | 219 | This plugin is licensed under the MIT License. 220 | 221 | ## Roadmap 222 | 223 | - Add **merge** functionality for folder copy/move. 224 | - Auto-select files in the destination after copying/moving. 225 | - Add **sorting** customization to `pathexplorer` codeblock. 226 | 227 | ## Acknowledgments 228 | 229 | This plugin was inspired by the following plugins. Thanks to their developers: 230 | 231 | - [Obsidian Open With](https://github.com/phibr0/obsidian-open-with) 232 | - [Obsidian File Explorer Count](https://github.com/ozntel/file-explorer-note-count) 233 | 234 | -------------------------------------------------------------------------------- /assets/contextmenu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfsicilia/obsidian-file-manager/b7649a7d12a3d684ab8da87291924eb8f9ffa1ac/assets/contextmenu.png -------------------------------------------------------------------------------- /assets/openwith.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfsicilia/obsidian-file-manager/b7649a7d12a3d684ab8da87291924eb8f9ffa1ac/assets/openwith.png -------------------------------------------------------------------------------- /assets/pathexplorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfsicilia/obsidian-file-manager/b7649a7d12a3d684ab8da87291924eb8f9ffa1ac/assets/pathexplorer.png -------------------------------------------------------------------------------- /assets/pathexplorer_settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfsicilia/obsidian-file-manager/b7649a7d12a3d684ab8da87291924eb8f9ffa1ac/assets/pathexplorer_settings.png -------------------------------------------------------------------------------- /assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfsicilia/obsidian-file-manager/b7649a7d12a3d684ab8da87291924eb8f9ffa1ac/assets/settings.png -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === "production"; 12 | 13 | const context = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ["./src/main.ts"], 18 | bundle: true, 19 | external: [ 20 | "obsidian", 21 | "electron", 22 | "@codemirror/autocomplete", 23 | "@codemirror/collab", 24 | "@codemirror/commands", 25 | "@codemirror/language", 26 | "@codemirror/lint", 27 | "@codemirror/search", 28 | "@codemirror/state", 29 | "@codemirror/view", 30 | "@lezer/common", 31 | "@lezer/highlight", 32 | "@lezer/lr", 33 | ...builtins, 34 | ], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | minify: prod, 42 | }); 43 | 44 | if (prod) { 45 | await context.rebuild(); 46 | process.exit(0); 47 | } else { 48 | await context.watch(); 49 | } 50 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "file-manager", 3 | "name": "File Manager", 4 | "version": "1.4.0", 5 | "minAppVersion": "0.15.0", 6 | "description": "Adds missing features to the file explorer.", 7 | "author": "Juan Sicilia", 8 | "isDesktopOnly": true 9 | } 10 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-manager", 3 | "version": "1.4.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "file-manager", 9 | "version": "1.4.0", 10 | "license": "MIT", 11 | "dependencies": { 12 | "ignore": "^6.0.2", 13 | "open": "^8.2.1" 14 | }, 15 | "devDependencies": { 16 | "@types/node": "^16.11.6", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "esbuild": "0.17.3", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | } 25 | }, 26 | "node_modules/@codemirror/state": { 27 | "version": "6.4.1", 28 | "dev": true, 29 | "license": "MIT", 30 | "peer": true 31 | }, 32 | "node_modules/@codemirror/view": { 33 | "version": "6.34.1", 34 | "dev": true, 35 | "license": "MIT", 36 | "peer": true, 37 | "dependencies": { 38 | "@codemirror/state": "^6.4.0", 39 | "style-mod": "^4.1.0", 40 | "w3c-keyname": "^2.2.4" 41 | } 42 | }, 43 | "node_modules/@esbuild/win32-x64": { 44 | "version": "0.17.3", 45 | "cpu": [ 46 | "x64" 47 | ], 48 | "dev": true, 49 | "license": "MIT", 50 | "optional": true, 51 | "os": [ 52 | "win32" 53 | ], 54 | "engines": { 55 | "node": ">=12" 56 | } 57 | }, 58 | "node_modules/@eslint-community/eslint-utils": { 59 | "version": "4.4.0", 60 | "dev": true, 61 | "license": "MIT", 62 | "peer": true, 63 | "dependencies": { 64 | "eslint-visitor-keys": "^3.3.0" 65 | }, 66 | "engines": { 67 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 68 | }, 69 | "peerDependencies": { 70 | "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" 71 | } 72 | }, 73 | "node_modules/@eslint-community/regexpp": { 74 | "version": "4.11.1", 75 | "dev": true, 76 | "license": "MIT", 77 | "peer": true, 78 | "engines": { 79 | "node": "^12.0.0 || ^14.0.0 || >=16.0.0" 80 | } 81 | }, 82 | "node_modules/@eslint/eslintrc": { 83 | "version": "2.1.4", 84 | "dev": true, 85 | "license": "MIT", 86 | "peer": true, 87 | "dependencies": { 88 | "ajv": "^6.12.4", 89 | "debug": "^4.3.2", 90 | "espree": "^9.6.0", 91 | "globals": "^13.19.0", 92 | "ignore": "^5.2.0", 93 | "import-fresh": "^3.2.1", 94 | "js-yaml": "^4.1.0", 95 | "minimatch": "^3.1.2", 96 | "strip-json-comments": "^3.1.1" 97 | }, 98 | "engines": { 99 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 100 | }, 101 | "funding": { 102 | "url": "https://opencollective.com/eslint" 103 | } 104 | }, 105 | "node_modules/@eslint/eslintrc/node_modules/ignore": { 106 | "version": "5.3.2", 107 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 108 | "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 109 | "dev": true, 110 | "license": "MIT", 111 | "peer": true, 112 | "engines": { 113 | "node": ">= 4" 114 | } 115 | }, 116 | "node_modules/@eslint/js": { 117 | "version": "8.57.1", 118 | "dev": true, 119 | "license": "MIT", 120 | "peer": true, 121 | "engines": { 122 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 123 | } 124 | }, 125 | "node_modules/@humanwhocodes/config-array": { 126 | "version": "0.13.0", 127 | "dev": true, 128 | "license": "Apache-2.0", 129 | "peer": true, 130 | "dependencies": { 131 | "@humanwhocodes/object-schema": "^2.0.3", 132 | "debug": "^4.3.1", 133 | "minimatch": "^3.0.5" 134 | }, 135 | "engines": { 136 | "node": ">=10.10.0" 137 | } 138 | }, 139 | "node_modules/@humanwhocodes/module-importer": { 140 | "version": "1.0.1", 141 | "dev": true, 142 | "license": "Apache-2.0", 143 | "peer": true, 144 | "engines": { 145 | "node": ">=12.22" 146 | }, 147 | "funding": { 148 | "type": "github", 149 | "url": "https://github.com/sponsors/nzakas" 150 | } 151 | }, 152 | "node_modules/@humanwhocodes/object-schema": { 153 | "version": "2.0.3", 154 | "dev": true, 155 | "license": "BSD-3-Clause", 156 | "peer": true 157 | }, 158 | "node_modules/@nodelib/fs.scandir": { 159 | "version": "2.1.5", 160 | "dev": true, 161 | "license": "MIT", 162 | "dependencies": { 163 | "@nodelib/fs.stat": "2.0.5", 164 | "run-parallel": "^1.1.9" 165 | }, 166 | "engines": { 167 | "node": ">= 8" 168 | } 169 | }, 170 | "node_modules/@nodelib/fs.stat": { 171 | "version": "2.0.5", 172 | "dev": true, 173 | "license": "MIT", 174 | "engines": { 175 | "node": ">= 8" 176 | } 177 | }, 178 | "node_modules/@nodelib/fs.walk": { 179 | "version": "1.2.8", 180 | "dev": true, 181 | "license": "MIT", 182 | "dependencies": { 183 | "@nodelib/fs.scandir": "2.1.5", 184 | "fastq": "^1.6.0" 185 | }, 186 | "engines": { 187 | "node": ">= 8" 188 | } 189 | }, 190 | "node_modules/@types/codemirror": { 191 | "version": "5.60.8", 192 | "dev": true, 193 | "license": "MIT", 194 | "dependencies": { 195 | "@types/tern": "*" 196 | } 197 | }, 198 | "node_modules/@types/estree": { 199 | "version": "1.0.6", 200 | "dev": true, 201 | "license": "MIT" 202 | }, 203 | "node_modules/@types/json-schema": { 204 | "version": "7.0.15", 205 | "dev": true, 206 | "license": "MIT" 207 | }, 208 | "node_modules/@types/node": { 209 | "version": "16.18.112", 210 | "dev": true, 211 | "license": "MIT" 212 | }, 213 | "node_modules/@types/tern": { 214 | "version": "0.23.9", 215 | "dev": true, 216 | "license": "MIT", 217 | "dependencies": { 218 | "@types/estree": "*" 219 | } 220 | }, 221 | "node_modules/@typescript-eslint/eslint-plugin": { 222 | "version": "5.29.0", 223 | "dev": true, 224 | "license": "MIT", 225 | "dependencies": { 226 | "@typescript-eslint/scope-manager": "5.29.0", 227 | "@typescript-eslint/type-utils": "5.29.0", 228 | "@typescript-eslint/utils": "5.29.0", 229 | "debug": "^4.3.4", 230 | "functional-red-black-tree": "^1.0.1", 231 | "ignore": "^5.2.0", 232 | "regexpp": "^3.2.0", 233 | "semver": "^7.3.7", 234 | "tsutils": "^3.21.0" 235 | }, 236 | "engines": { 237 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 238 | }, 239 | "funding": { 240 | "type": "opencollective", 241 | "url": "https://opencollective.com/typescript-eslint" 242 | }, 243 | "peerDependencies": { 244 | "@typescript-eslint/parser": "^5.0.0", 245 | "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" 246 | }, 247 | "peerDependenciesMeta": { 248 | "typescript": { 249 | "optional": true 250 | } 251 | } 252 | }, 253 | "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { 254 | "version": "5.3.2", 255 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 256 | "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 257 | "dev": true, 258 | "license": "MIT", 259 | "engines": { 260 | "node": ">= 4" 261 | } 262 | }, 263 | "node_modules/@typescript-eslint/parser": { 264 | "version": "5.29.0", 265 | "dev": true, 266 | "license": "BSD-2-Clause", 267 | "dependencies": { 268 | "@typescript-eslint/scope-manager": "5.29.0", 269 | "@typescript-eslint/types": "5.29.0", 270 | "@typescript-eslint/typescript-estree": "5.29.0", 271 | "debug": "^4.3.4" 272 | }, 273 | "engines": { 274 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 275 | }, 276 | "funding": { 277 | "type": "opencollective", 278 | "url": "https://opencollective.com/typescript-eslint" 279 | }, 280 | "peerDependencies": { 281 | "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" 282 | }, 283 | "peerDependenciesMeta": { 284 | "typescript": { 285 | "optional": true 286 | } 287 | } 288 | }, 289 | "node_modules/@typescript-eslint/scope-manager": { 290 | "version": "5.29.0", 291 | "dev": true, 292 | "license": "MIT", 293 | "dependencies": { 294 | "@typescript-eslint/types": "5.29.0", 295 | "@typescript-eslint/visitor-keys": "5.29.0" 296 | }, 297 | "engines": { 298 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 299 | }, 300 | "funding": { 301 | "type": "opencollective", 302 | "url": "https://opencollective.com/typescript-eslint" 303 | } 304 | }, 305 | "node_modules/@typescript-eslint/type-utils": { 306 | "version": "5.29.0", 307 | "dev": true, 308 | "license": "MIT", 309 | "dependencies": { 310 | "@typescript-eslint/utils": "5.29.0", 311 | "debug": "^4.3.4", 312 | "tsutils": "^3.21.0" 313 | }, 314 | "engines": { 315 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 316 | }, 317 | "funding": { 318 | "type": "opencollective", 319 | "url": "https://opencollective.com/typescript-eslint" 320 | }, 321 | "peerDependencies": { 322 | "eslint": "*" 323 | }, 324 | "peerDependenciesMeta": { 325 | "typescript": { 326 | "optional": true 327 | } 328 | } 329 | }, 330 | "node_modules/@typescript-eslint/types": { 331 | "version": "5.29.0", 332 | "dev": true, 333 | "license": "MIT", 334 | "engines": { 335 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 336 | }, 337 | "funding": { 338 | "type": "opencollective", 339 | "url": "https://opencollective.com/typescript-eslint" 340 | } 341 | }, 342 | "node_modules/@typescript-eslint/typescript-estree": { 343 | "version": "5.29.0", 344 | "dev": true, 345 | "license": "BSD-2-Clause", 346 | "dependencies": { 347 | "@typescript-eslint/types": "5.29.0", 348 | "@typescript-eslint/visitor-keys": "5.29.0", 349 | "debug": "^4.3.4", 350 | "globby": "^11.1.0", 351 | "is-glob": "^4.0.3", 352 | "semver": "^7.3.7", 353 | "tsutils": "^3.21.0" 354 | }, 355 | "engines": { 356 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 357 | }, 358 | "funding": { 359 | "type": "opencollective", 360 | "url": "https://opencollective.com/typescript-eslint" 361 | }, 362 | "peerDependenciesMeta": { 363 | "typescript": { 364 | "optional": true 365 | } 366 | } 367 | }, 368 | "node_modules/@typescript-eslint/utils": { 369 | "version": "5.29.0", 370 | "dev": true, 371 | "license": "MIT", 372 | "dependencies": { 373 | "@types/json-schema": "^7.0.9", 374 | "@typescript-eslint/scope-manager": "5.29.0", 375 | "@typescript-eslint/types": "5.29.0", 376 | "@typescript-eslint/typescript-estree": "5.29.0", 377 | "eslint-scope": "^5.1.1", 378 | "eslint-utils": "^3.0.0" 379 | }, 380 | "engines": { 381 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 382 | }, 383 | "funding": { 384 | "type": "opencollective", 385 | "url": "https://opencollective.com/typescript-eslint" 386 | }, 387 | "peerDependencies": { 388 | "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" 389 | } 390 | }, 391 | "node_modules/@typescript-eslint/visitor-keys": { 392 | "version": "5.29.0", 393 | "dev": true, 394 | "license": "MIT", 395 | "dependencies": { 396 | "@typescript-eslint/types": "5.29.0", 397 | "eslint-visitor-keys": "^3.3.0" 398 | }, 399 | "engines": { 400 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 401 | }, 402 | "funding": { 403 | "type": "opencollective", 404 | "url": "https://opencollective.com/typescript-eslint" 405 | } 406 | }, 407 | "node_modules/@ungap/structured-clone": { 408 | "version": "1.2.0", 409 | "dev": true, 410 | "license": "ISC", 411 | "peer": true 412 | }, 413 | "node_modules/acorn": { 414 | "version": "8.12.1", 415 | "dev": true, 416 | "license": "MIT", 417 | "peer": true, 418 | "bin": { 419 | "acorn": "bin/acorn" 420 | }, 421 | "engines": { 422 | "node": ">=0.4.0" 423 | } 424 | }, 425 | "node_modules/acorn-jsx": { 426 | "version": "5.3.2", 427 | "dev": true, 428 | "license": "MIT", 429 | "peer": true, 430 | "peerDependencies": { 431 | "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" 432 | } 433 | }, 434 | "node_modules/ajv": { 435 | "version": "6.12.6", 436 | "dev": true, 437 | "license": "MIT", 438 | "peer": true, 439 | "dependencies": { 440 | "fast-deep-equal": "^3.1.1", 441 | "fast-json-stable-stringify": "^2.0.0", 442 | "json-schema-traverse": "^0.4.1", 443 | "uri-js": "^4.2.2" 444 | }, 445 | "funding": { 446 | "type": "github", 447 | "url": "https://github.com/sponsors/epoberezkin" 448 | } 449 | }, 450 | "node_modules/ansi-regex": { 451 | "version": "5.0.1", 452 | "dev": true, 453 | "license": "MIT", 454 | "peer": true, 455 | "engines": { 456 | "node": ">=8" 457 | } 458 | }, 459 | "node_modules/ansi-styles": { 460 | "version": "4.3.0", 461 | "dev": true, 462 | "license": "MIT", 463 | "peer": true, 464 | "dependencies": { 465 | "color-convert": "^2.0.1" 466 | }, 467 | "engines": { 468 | "node": ">=8" 469 | }, 470 | "funding": { 471 | "url": "https://github.com/chalk/ansi-styles?sponsor=1" 472 | } 473 | }, 474 | "node_modules/argparse": { 475 | "version": "2.0.1", 476 | "dev": true, 477 | "license": "Python-2.0", 478 | "peer": true 479 | }, 480 | "node_modules/array-union": { 481 | "version": "2.1.0", 482 | "dev": true, 483 | "license": "MIT", 484 | "engines": { 485 | "node": ">=8" 486 | } 487 | }, 488 | "node_modules/balanced-match": { 489 | "version": "1.0.2", 490 | "dev": true, 491 | "license": "MIT", 492 | "peer": true 493 | }, 494 | "node_modules/brace-expansion": { 495 | "version": "1.1.11", 496 | "dev": true, 497 | "license": "MIT", 498 | "peer": true, 499 | "dependencies": { 500 | "balanced-match": "^1.0.0", 501 | "concat-map": "0.0.1" 502 | } 503 | }, 504 | "node_modules/braces": { 505 | "version": "3.0.3", 506 | "dev": true, 507 | "license": "MIT", 508 | "dependencies": { 509 | "fill-range": "^7.1.1" 510 | }, 511 | "engines": { 512 | "node": ">=8" 513 | } 514 | }, 515 | "node_modules/builtin-modules": { 516 | "version": "3.3.0", 517 | "dev": true, 518 | "license": "MIT", 519 | "engines": { 520 | "node": ">=6" 521 | }, 522 | "funding": { 523 | "url": "https://github.com/sponsors/sindresorhus" 524 | } 525 | }, 526 | "node_modules/callsites": { 527 | "version": "3.1.0", 528 | "dev": true, 529 | "license": "MIT", 530 | "peer": true, 531 | "engines": { 532 | "node": ">=6" 533 | } 534 | }, 535 | "node_modules/chalk": { 536 | "version": "4.1.2", 537 | "dev": true, 538 | "license": "MIT", 539 | "peer": true, 540 | "dependencies": { 541 | "ansi-styles": "^4.1.0", 542 | "supports-color": "^7.1.0" 543 | }, 544 | "engines": { 545 | "node": ">=10" 546 | }, 547 | "funding": { 548 | "url": "https://github.com/chalk/chalk?sponsor=1" 549 | } 550 | }, 551 | "node_modules/color-convert": { 552 | "version": "2.0.1", 553 | "dev": true, 554 | "license": "MIT", 555 | "peer": true, 556 | "dependencies": { 557 | "color-name": "~1.1.4" 558 | }, 559 | "engines": { 560 | "node": ">=7.0.0" 561 | } 562 | }, 563 | "node_modules/color-name": { 564 | "version": "1.1.4", 565 | "dev": true, 566 | "license": "MIT", 567 | "peer": true 568 | }, 569 | "node_modules/concat-map": { 570 | "version": "0.0.1", 571 | "dev": true, 572 | "license": "MIT", 573 | "peer": true 574 | }, 575 | "node_modules/cross-spawn": { 576 | "version": "7.0.3", 577 | "dev": true, 578 | "license": "MIT", 579 | "peer": true, 580 | "dependencies": { 581 | "path-key": "^3.1.0", 582 | "shebang-command": "^2.0.0", 583 | "which": "^2.0.1" 584 | }, 585 | "engines": { 586 | "node": ">= 8" 587 | } 588 | }, 589 | "node_modules/debug": { 590 | "version": "4.3.7", 591 | "dev": true, 592 | "license": "MIT", 593 | "dependencies": { 594 | "ms": "^2.1.3" 595 | }, 596 | "engines": { 597 | "node": ">=6.0" 598 | }, 599 | "peerDependenciesMeta": { 600 | "supports-color": { 601 | "optional": true 602 | } 603 | } 604 | }, 605 | "node_modules/deep-is": { 606 | "version": "0.1.4", 607 | "dev": true, 608 | "license": "MIT", 609 | "peer": true 610 | }, 611 | "node_modules/define-lazy-prop": { 612 | "version": "2.0.0", 613 | "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", 614 | "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", 615 | "license": "MIT", 616 | "engines": { 617 | "node": ">=8" 618 | } 619 | }, 620 | "node_modules/dir-glob": { 621 | "version": "3.0.1", 622 | "dev": true, 623 | "license": "MIT", 624 | "dependencies": { 625 | "path-type": "^4.0.0" 626 | }, 627 | "engines": { 628 | "node": ">=8" 629 | } 630 | }, 631 | "node_modules/doctrine": { 632 | "version": "3.0.0", 633 | "dev": true, 634 | "license": "Apache-2.0", 635 | "peer": true, 636 | "dependencies": { 637 | "esutils": "^2.0.2" 638 | }, 639 | "engines": { 640 | "node": ">=6.0.0" 641 | } 642 | }, 643 | "node_modules/esbuild": { 644 | "version": "0.17.3", 645 | "dev": true, 646 | "hasInstallScript": true, 647 | "license": "MIT", 648 | "bin": { 649 | "esbuild": "bin/esbuild" 650 | }, 651 | "engines": { 652 | "node": ">=12" 653 | }, 654 | "optionalDependencies": { 655 | "@esbuild/android-arm": "0.17.3", 656 | "@esbuild/android-arm64": "0.17.3", 657 | "@esbuild/android-x64": "0.17.3", 658 | "@esbuild/darwin-arm64": "0.17.3", 659 | "@esbuild/darwin-x64": "0.17.3", 660 | "@esbuild/freebsd-arm64": "0.17.3", 661 | "@esbuild/freebsd-x64": "0.17.3", 662 | "@esbuild/linux-arm": "0.17.3", 663 | "@esbuild/linux-arm64": "0.17.3", 664 | "@esbuild/linux-ia32": "0.17.3", 665 | "@esbuild/linux-loong64": "0.17.3", 666 | "@esbuild/linux-mips64el": "0.17.3", 667 | "@esbuild/linux-ppc64": "0.17.3", 668 | "@esbuild/linux-riscv64": "0.17.3", 669 | "@esbuild/linux-s390x": "0.17.3", 670 | "@esbuild/linux-x64": "0.17.3", 671 | "@esbuild/netbsd-x64": "0.17.3", 672 | "@esbuild/openbsd-x64": "0.17.3", 673 | "@esbuild/sunos-x64": "0.17.3", 674 | "@esbuild/win32-arm64": "0.17.3", 675 | "@esbuild/win32-ia32": "0.17.3", 676 | "@esbuild/win32-x64": "0.17.3" 677 | } 678 | }, 679 | "node_modules/escape-string-regexp": { 680 | "version": "4.0.0", 681 | "dev": true, 682 | "license": "MIT", 683 | "peer": true, 684 | "engines": { 685 | "node": ">=10" 686 | }, 687 | "funding": { 688 | "url": "https://github.com/sponsors/sindresorhus" 689 | } 690 | }, 691 | "node_modules/eslint": { 692 | "version": "8.57.1", 693 | "dev": true, 694 | "license": "MIT", 695 | "peer": true, 696 | "dependencies": { 697 | "@eslint-community/eslint-utils": "^4.2.0", 698 | "@eslint-community/regexpp": "^4.6.1", 699 | "@eslint/eslintrc": "^2.1.4", 700 | "@eslint/js": "8.57.1", 701 | "@humanwhocodes/config-array": "^0.13.0", 702 | "@humanwhocodes/module-importer": "^1.0.1", 703 | "@nodelib/fs.walk": "^1.2.8", 704 | "@ungap/structured-clone": "^1.2.0", 705 | "ajv": "^6.12.4", 706 | "chalk": "^4.0.0", 707 | "cross-spawn": "^7.0.2", 708 | "debug": "^4.3.2", 709 | "doctrine": "^3.0.0", 710 | "escape-string-regexp": "^4.0.0", 711 | "eslint-scope": "^7.2.2", 712 | "eslint-visitor-keys": "^3.4.3", 713 | "espree": "^9.6.1", 714 | "esquery": "^1.4.2", 715 | "esutils": "^2.0.2", 716 | "fast-deep-equal": "^3.1.3", 717 | "file-entry-cache": "^6.0.1", 718 | "find-up": "^5.0.0", 719 | "glob-parent": "^6.0.2", 720 | "globals": "^13.19.0", 721 | "graphemer": "^1.4.0", 722 | "ignore": "^5.2.0", 723 | "imurmurhash": "^0.1.4", 724 | "is-glob": "^4.0.0", 725 | "is-path-inside": "^3.0.3", 726 | "js-yaml": "^4.1.0", 727 | "json-stable-stringify-without-jsonify": "^1.0.1", 728 | "levn": "^0.4.1", 729 | "lodash.merge": "^4.6.2", 730 | "minimatch": "^3.1.2", 731 | "natural-compare": "^1.4.0", 732 | "optionator": "^0.9.3", 733 | "strip-ansi": "^6.0.1", 734 | "text-table": "^0.2.0" 735 | }, 736 | "bin": { 737 | "eslint": "bin/eslint.js" 738 | }, 739 | "engines": { 740 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 741 | }, 742 | "funding": { 743 | "url": "https://opencollective.com/eslint" 744 | } 745 | }, 746 | "node_modules/eslint-scope": { 747 | "version": "5.1.1", 748 | "dev": true, 749 | "license": "BSD-2-Clause", 750 | "dependencies": { 751 | "esrecurse": "^4.3.0", 752 | "estraverse": "^4.1.1" 753 | }, 754 | "engines": { 755 | "node": ">=8.0.0" 756 | } 757 | }, 758 | "node_modules/eslint-utils": { 759 | "version": "3.0.0", 760 | "dev": true, 761 | "license": "MIT", 762 | "dependencies": { 763 | "eslint-visitor-keys": "^2.0.0" 764 | }, 765 | "engines": { 766 | "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" 767 | }, 768 | "funding": { 769 | "url": "https://github.com/sponsors/mysticatea" 770 | }, 771 | "peerDependencies": { 772 | "eslint": ">=5" 773 | } 774 | }, 775 | "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { 776 | "version": "2.1.0", 777 | "dev": true, 778 | "license": "Apache-2.0", 779 | "engines": { 780 | "node": ">=10" 781 | } 782 | }, 783 | "node_modules/eslint-visitor-keys": { 784 | "version": "3.4.3", 785 | "dev": true, 786 | "license": "Apache-2.0", 787 | "engines": { 788 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 789 | }, 790 | "funding": { 791 | "url": "https://opencollective.com/eslint" 792 | } 793 | }, 794 | "node_modules/eslint/node_modules/eslint-scope": { 795 | "version": "7.2.2", 796 | "dev": true, 797 | "license": "BSD-2-Clause", 798 | "peer": true, 799 | "dependencies": { 800 | "esrecurse": "^4.3.0", 801 | "estraverse": "^5.2.0" 802 | }, 803 | "engines": { 804 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 805 | }, 806 | "funding": { 807 | "url": "https://opencollective.com/eslint" 808 | } 809 | }, 810 | "node_modules/eslint/node_modules/estraverse": { 811 | "version": "5.3.0", 812 | "dev": true, 813 | "license": "BSD-2-Clause", 814 | "peer": true, 815 | "engines": { 816 | "node": ">=4.0" 817 | } 818 | }, 819 | "node_modules/eslint/node_modules/ignore": { 820 | "version": "5.3.2", 821 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 822 | "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 823 | "dev": true, 824 | "license": "MIT", 825 | "peer": true, 826 | "engines": { 827 | "node": ">= 4" 828 | } 829 | }, 830 | "node_modules/espree": { 831 | "version": "9.6.1", 832 | "dev": true, 833 | "license": "BSD-2-Clause", 834 | "peer": true, 835 | "dependencies": { 836 | "acorn": "^8.9.0", 837 | "acorn-jsx": "^5.3.2", 838 | "eslint-visitor-keys": "^3.4.1" 839 | }, 840 | "engines": { 841 | "node": "^12.22.0 || ^14.17.0 || >=16.0.0" 842 | }, 843 | "funding": { 844 | "url": "https://opencollective.com/eslint" 845 | } 846 | }, 847 | "node_modules/esquery": { 848 | "version": "1.6.0", 849 | "dev": true, 850 | "license": "BSD-3-Clause", 851 | "peer": true, 852 | "dependencies": { 853 | "estraverse": "^5.1.0" 854 | }, 855 | "engines": { 856 | "node": ">=0.10" 857 | } 858 | }, 859 | "node_modules/esquery/node_modules/estraverse": { 860 | "version": "5.3.0", 861 | "dev": true, 862 | "license": "BSD-2-Clause", 863 | "peer": true, 864 | "engines": { 865 | "node": ">=4.0" 866 | } 867 | }, 868 | "node_modules/esrecurse": { 869 | "version": "4.3.0", 870 | "dev": true, 871 | "license": "BSD-2-Clause", 872 | "dependencies": { 873 | "estraverse": "^5.2.0" 874 | }, 875 | "engines": { 876 | "node": ">=4.0" 877 | } 878 | }, 879 | "node_modules/esrecurse/node_modules/estraverse": { 880 | "version": "5.3.0", 881 | "dev": true, 882 | "license": "BSD-2-Clause", 883 | "engines": { 884 | "node": ">=4.0" 885 | } 886 | }, 887 | "node_modules/estraverse": { 888 | "version": "4.3.0", 889 | "dev": true, 890 | "license": "BSD-2-Clause", 891 | "engines": { 892 | "node": ">=4.0" 893 | } 894 | }, 895 | "node_modules/esutils": { 896 | "version": "2.0.3", 897 | "dev": true, 898 | "license": "BSD-2-Clause", 899 | "peer": true, 900 | "engines": { 901 | "node": ">=0.10.0" 902 | } 903 | }, 904 | "node_modules/fast-deep-equal": { 905 | "version": "3.1.3", 906 | "dev": true, 907 | "license": "MIT", 908 | "peer": true 909 | }, 910 | "node_modules/fast-glob": { 911 | "version": "3.3.2", 912 | "dev": true, 913 | "license": "MIT", 914 | "dependencies": { 915 | "@nodelib/fs.stat": "^2.0.2", 916 | "@nodelib/fs.walk": "^1.2.3", 917 | "glob-parent": "^5.1.2", 918 | "merge2": "^1.3.0", 919 | "micromatch": "^4.0.4" 920 | }, 921 | "engines": { 922 | "node": ">=8.6.0" 923 | } 924 | }, 925 | "node_modules/fast-glob/node_modules/glob-parent": { 926 | "version": "5.1.2", 927 | "dev": true, 928 | "license": "ISC", 929 | "dependencies": { 930 | "is-glob": "^4.0.1" 931 | }, 932 | "engines": { 933 | "node": ">= 6" 934 | } 935 | }, 936 | "node_modules/fast-json-stable-stringify": { 937 | "version": "2.1.0", 938 | "dev": true, 939 | "license": "MIT", 940 | "peer": true 941 | }, 942 | "node_modules/fast-levenshtein": { 943 | "version": "2.0.6", 944 | "dev": true, 945 | "license": "MIT", 946 | "peer": true 947 | }, 948 | "node_modules/fastq": { 949 | "version": "1.17.1", 950 | "dev": true, 951 | "license": "ISC", 952 | "dependencies": { 953 | "reusify": "^1.0.4" 954 | } 955 | }, 956 | "node_modules/file-entry-cache": { 957 | "version": "6.0.1", 958 | "dev": true, 959 | "license": "MIT", 960 | "peer": true, 961 | "dependencies": { 962 | "flat-cache": "^3.0.4" 963 | }, 964 | "engines": { 965 | "node": "^10.12.0 || >=12.0.0" 966 | } 967 | }, 968 | "node_modules/fill-range": { 969 | "version": "7.1.1", 970 | "dev": true, 971 | "license": "MIT", 972 | "dependencies": { 973 | "to-regex-range": "^5.0.1" 974 | }, 975 | "engines": { 976 | "node": ">=8" 977 | } 978 | }, 979 | "node_modules/find-up": { 980 | "version": "5.0.0", 981 | "dev": true, 982 | "license": "MIT", 983 | "peer": true, 984 | "dependencies": { 985 | "locate-path": "^6.0.0", 986 | "path-exists": "^4.0.0" 987 | }, 988 | "engines": { 989 | "node": ">=10" 990 | }, 991 | "funding": { 992 | "url": "https://github.com/sponsors/sindresorhus" 993 | } 994 | }, 995 | "node_modules/flat-cache": { 996 | "version": "3.2.0", 997 | "dev": true, 998 | "license": "MIT", 999 | "peer": true, 1000 | "dependencies": { 1001 | "flatted": "^3.2.9", 1002 | "keyv": "^4.5.3", 1003 | "rimraf": "^3.0.2" 1004 | }, 1005 | "engines": { 1006 | "node": "^10.12.0 || >=12.0.0" 1007 | } 1008 | }, 1009 | "node_modules/flatted": { 1010 | "version": "3.3.1", 1011 | "dev": true, 1012 | "license": "ISC", 1013 | "peer": true 1014 | }, 1015 | "node_modules/fs.realpath": { 1016 | "version": "1.0.0", 1017 | "dev": true, 1018 | "license": "ISC", 1019 | "peer": true 1020 | }, 1021 | "node_modules/functional-red-black-tree": { 1022 | "version": "1.0.1", 1023 | "dev": true, 1024 | "license": "MIT" 1025 | }, 1026 | "node_modules/glob": { 1027 | "version": "7.2.3", 1028 | "dev": true, 1029 | "license": "ISC", 1030 | "peer": true, 1031 | "dependencies": { 1032 | "fs.realpath": "^1.0.0", 1033 | "inflight": "^1.0.4", 1034 | "inherits": "2", 1035 | "minimatch": "^3.1.1", 1036 | "once": "^1.3.0", 1037 | "path-is-absolute": "^1.0.0" 1038 | }, 1039 | "engines": { 1040 | "node": "*" 1041 | }, 1042 | "funding": { 1043 | "url": "https://github.com/sponsors/isaacs" 1044 | } 1045 | }, 1046 | "node_modules/glob-parent": { 1047 | "version": "6.0.2", 1048 | "dev": true, 1049 | "license": "ISC", 1050 | "peer": true, 1051 | "dependencies": { 1052 | "is-glob": "^4.0.3" 1053 | }, 1054 | "engines": { 1055 | "node": ">=10.13.0" 1056 | } 1057 | }, 1058 | "node_modules/globals": { 1059 | "version": "13.24.0", 1060 | "dev": true, 1061 | "license": "MIT", 1062 | "peer": true, 1063 | "dependencies": { 1064 | "type-fest": "^0.20.2" 1065 | }, 1066 | "engines": { 1067 | "node": ">=8" 1068 | }, 1069 | "funding": { 1070 | "url": "https://github.com/sponsors/sindresorhus" 1071 | } 1072 | }, 1073 | "node_modules/globby": { 1074 | "version": "11.1.0", 1075 | "dev": true, 1076 | "license": "MIT", 1077 | "dependencies": { 1078 | "array-union": "^2.1.0", 1079 | "dir-glob": "^3.0.1", 1080 | "fast-glob": "^3.2.9", 1081 | "ignore": "^5.2.0", 1082 | "merge2": "^1.4.1", 1083 | "slash": "^3.0.0" 1084 | }, 1085 | "engines": { 1086 | "node": ">=10" 1087 | }, 1088 | "funding": { 1089 | "url": "https://github.com/sponsors/sindresorhus" 1090 | } 1091 | }, 1092 | "node_modules/globby/node_modules/ignore": { 1093 | "version": "5.3.2", 1094 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", 1095 | "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", 1096 | "dev": true, 1097 | "license": "MIT", 1098 | "engines": { 1099 | "node": ">= 4" 1100 | } 1101 | }, 1102 | "node_modules/graphemer": { 1103 | "version": "1.4.0", 1104 | "dev": true, 1105 | "license": "MIT", 1106 | "peer": true 1107 | }, 1108 | "node_modules/has-flag": { 1109 | "version": "4.0.0", 1110 | "dev": true, 1111 | "license": "MIT", 1112 | "peer": true, 1113 | "engines": { 1114 | "node": ">=8" 1115 | } 1116 | }, 1117 | "node_modules/ignore": { 1118 | "version": "6.0.2", 1119 | "resolved": "https://registry.npmjs.org/ignore/-/ignore-6.0.2.tgz", 1120 | "integrity": "sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==", 1121 | "license": "MIT", 1122 | "engines": { 1123 | "node": ">= 4" 1124 | } 1125 | }, 1126 | "node_modules/import-fresh": { 1127 | "version": "3.3.0", 1128 | "dev": true, 1129 | "license": "MIT", 1130 | "peer": true, 1131 | "dependencies": { 1132 | "parent-module": "^1.0.0", 1133 | "resolve-from": "^4.0.0" 1134 | }, 1135 | "engines": { 1136 | "node": ">=6" 1137 | }, 1138 | "funding": { 1139 | "url": "https://github.com/sponsors/sindresorhus" 1140 | } 1141 | }, 1142 | "node_modules/imurmurhash": { 1143 | "version": "0.1.4", 1144 | "dev": true, 1145 | "license": "MIT", 1146 | "peer": true, 1147 | "engines": { 1148 | "node": ">=0.8.19" 1149 | } 1150 | }, 1151 | "node_modules/inflight": { 1152 | "version": "1.0.6", 1153 | "dev": true, 1154 | "license": "ISC", 1155 | "peer": true, 1156 | "dependencies": { 1157 | "once": "^1.3.0", 1158 | "wrappy": "1" 1159 | } 1160 | }, 1161 | "node_modules/inherits": { 1162 | "version": "2.0.4", 1163 | "dev": true, 1164 | "license": "ISC", 1165 | "peer": true 1166 | }, 1167 | "node_modules/is-docker": { 1168 | "version": "2.2.1", 1169 | "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", 1170 | "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", 1171 | "license": "MIT", 1172 | "bin": { 1173 | "is-docker": "cli.js" 1174 | }, 1175 | "engines": { 1176 | "node": ">=8" 1177 | }, 1178 | "funding": { 1179 | "url": "https://github.com/sponsors/sindresorhus" 1180 | } 1181 | }, 1182 | "node_modules/is-extglob": { 1183 | "version": "2.1.1", 1184 | "dev": true, 1185 | "license": "MIT", 1186 | "engines": { 1187 | "node": ">=0.10.0" 1188 | } 1189 | }, 1190 | "node_modules/is-glob": { 1191 | "version": "4.0.3", 1192 | "dev": true, 1193 | "license": "MIT", 1194 | "dependencies": { 1195 | "is-extglob": "^2.1.1" 1196 | }, 1197 | "engines": { 1198 | "node": ">=0.10.0" 1199 | } 1200 | }, 1201 | "node_modules/is-number": { 1202 | "version": "7.0.0", 1203 | "dev": true, 1204 | "license": "MIT", 1205 | "engines": { 1206 | "node": ">=0.12.0" 1207 | } 1208 | }, 1209 | "node_modules/is-path-inside": { 1210 | "version": "3.0.3", 1211 | "dev": true, 1212 | "license": "MIT", 1213 | "peer": true, 1214 | "engines": { 1215 | "node": ">=8" 1216 | } 1217 | }, 1218 | "node_modules/is-wsl": { 1219 | "version": "2.2.0", 1220 | "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", 1221 | "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", 1222 | "license": "MIT", 1223 | "dependencies": { 1224 | "is-docker": "^2.0.0" 1225 | }, 1226 | "engines": { 1227 | "node": ">=8" 1228 | } 1229 | }, 1230 | "node_modules/isexe": { 1231 | "version": "2.0.0", 1232 | "dev": true, 1233 | "license": "ISC", 1234 | "peer": true 1235 | }, 1236 | "node_modules/js-yaml": { 1237 | "version": "4.1.0", 1238 | "dev": true, 1239 | "license": "MIT", 1240 | "peer": true, 1241 | "dependencies": { 1242 | "argparse": "^2.0.1" 1243 | }, 1244 | "bin": { 1245 | "js-yaml": "bin/js-yaml.js" 1246 | } 1247 | }, 1248 | "node_modules/json-buffer": { 1249 | "version": "3.0.1", 1250 | "dev": true, 1251 | "license": "MIT", 1252 | "peer": true 1253 | }, 1254 | "node_modules/json-schema-traverse": { 1255 | "version": "0.4.1", 1256 | "dev": true, 1257 | "license": "MIT", 1258 | "peer": true 1259 | }, 1260 | "node_modules/json-stable-stringify-without-jsonify": { 1261 | "version": "1.0.1", 1262 | "dev": true, 1263 | "license": "MIT", 1264 | "peer": true 1265 | }, 1266 | "node_modules/keyv": { 1267 | "version": "4.5.4", 1268 | "dev": true, 1269 | "license": "MIT", 1270 | "peer": true, 1271 | "dependencies": { 1272 | "json-buffer": "3.0.1" 1273 | } 1274 | }, 1275 | "node_modules/levn": { 1276 | "version": "0.4.1", 1277 | "dev": true, 1278 | "license": "MIT", 1279 | "peer": true, 1280 | "dependencies": { 1281 | "prelude-ls": "^1.2.1", 1282 | "type-check": "~0.4.0" 1283 | }, 1284 | "engines": { 1285 | "node": ">= 0.8.0" 1286 | } 1287 | }, 1288 | "node_modules/locate-path": { 1289 | "version": "6.0.0", 1290 | "dev": true, 1291 | "license": "MIT", 1292 | "peer": true, 1293 | "dependencies": { 1294 | "p-locate": "^5.0.0" 1295 | }, 1296 | "engines": { 1297 | "node": ">=10" 1298 | }, 1299 | "funding": { 1300 | "url": "https://github.com/sponsors/sindresorhus" 1301 | } 1302 | }, 1303 | "node_modules/lodash.merge": { 1304 | "version": "4.6.2", 1305 | "dev": true, 1306 | "license": "MIT", 1307 | "peer": true 1308 | }, 1309 | "node_modules/merge2": { 1310 | "version": "1.4.1", 1311 | "dev": true, 1312 | "license": "MIT", 1313 | "engines": { 1314 | "node": ">= 8" 1315 | } 1316 | }, 1317 | "node_modules/micromatch": { 1318 | "version": "4.0.8", 1319 | "dev": true, 1320 | "license": "MIT", 1321 | "dependencies": { 1322 | "braces": "^3.0.3", 1323 | "picomatch": "^2.3.1" 1324 | }, 1325 | "engines": { 1326 | "node": ">=8.6" 1327 | } 1328 | }, 1329 | "node_modules/minimatch": { 1330 | "version": "3.1.2", 1331 | "dev": true, 1332 | "license": "ISC", 1333 | "peer": true, 1334 | "dependencies": { 1335 | "brace-expansion": "^1.1.7" 1336 | }, 1337 | "engines": { 1338 | "node": "*" 1339 | } 1340 | }, 1341 | "node_modules/moment": { 1342 | "version": "2.29.4", 1343 | "dev": true, 1344 | "license": "MIT", 1345 | "engines": { 1346 | "node": "*" 1347 | } 1348 | }, 1349 | "node_modules/ms": { 1350 | "version": "2.1.3", 1351 | "dev": true, 1352 | "license": "MIT" 1353 | }, 1354 | "node_modules/natural-compare": { 1355 | "version": "1.4.0", 1356 | "dev": true, 1357 | "license": "MIT", 1358 | "peer": true 1359 | }, 1360 | "node_modules/obsidian": { 1361 | "version": "1.7.2", 1362 | "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.7.2.tgz", 1363 | "integrity": "sha512-k9hN9brdknJC+afKr5FQzDRuEFGDKbDjfCazJwpgibwCAoZNYHYV8p/s3mM8I6AsnKrPKNXf8xGuMZ4enWelZQ==", 1364 | "dev": true, 1365 | "license": "MIT", 1366 | "dependencies": { 1367 | "@types/codemirror": "5.60.8", 1368 | "moment": "2.29.4" 1369 | }, 1370 | "peerDependencies": { 1371 | "@codemirror/state": "^6.0.0", 1372 | "@codemirror/view": "^6.0.0" 1373 | } 1374 | }, 1375 | "node_modules/once": { 1376 | "version": "1.4.0", 1377 | "dev": true, 1378 | "license": "ISC", 1379 | "peer": true, 1380 | "dependencies": { 1381 | "wrappy": "1" 1382 | } 1383 | }, 1384 | "node_modules/open": { 1385 | "version": "8.4.2", 1386 | "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", 1387 | "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", 1388 | "license": "MIT", 1389 | "dependencies": { 1390 | "define-lazy-prop": "^2.0.0", 1391 | "is-docker": "^2.1.1", 1392 | "is-wsl": "^2.2.0" 1393 | }, 1394 | "engines": { 1395 | "node": ">=12" 1396 | }, 1397 | "funding": { 1398 | "url": "https://github.com/sponsors/sindresorhus" 1399 | } 1400 | }, 1401 | "node_modules/optionator": { 1402 | "version": "0.9.4", 1403 | "dev": true, 1404 | "license": "MIT", 1405 | "peer": true, 1406 | "dependencies": { 1407 | "deep-is": "^0.1.3", 1408 | "fast-levenshtein": "^2.0.6", 1409 | "levn": "^0.4.1", 1410 | "prelude-ls": "^1.2.1", 1411 | "type-check": "^0.4.0", 1412 | "word-wrap": "^1.2.5" 1413 | }, 1414 | "engines": { 1415 | "node": ">= 0.8.0" 1416 | } 1417 | }, 1418 | "node_modules/p-limit": { 1419 | "version": "3.1.0", 1420 | "dev": true, 1421 | "license": "MIT", 1422 | "peer": true, 1423 | "dependencies": { 1424 | "yocto-queue": "^0.1.0" 1425 | }, 1426 | "engines": { 1427 | "node": ">=10" 1428 | }, 1429 | "funding": { 1430 | "url": "https://github.com/sponsors/sindresorhus" 1431 | } 1432 | }, 1433 | "node_modules/p-locate": { 1434 | "version": "5.0.0", 1435 | "dev": true, 1436 | "license": "MIT", 1437 | "peer": true, 1438 | "dependencies": { 1439 | "p-limit": "^3.0.2" 1440 | }, 1441 | "engines": { 1442 | "node": ">=10" 1443 | }, 1444 | "funding": { 1445 | "url": "https://github.com/sponsors/sindresorhus" 1446 | } 1447 | }, 1448 | "node_modules/parent-module": { 1449 | "version": "1.0.1", 1450 | "dev": true, 1451 | "license": "MIT", 1452 | "peer": true, 1453 | "dependencies": { 1454 | "callsites": "^3.0.0" 1455 | }, 1456 | "engines": { 1457 | "node": ">=6" 1458 | } 1459 | }, 1460 | "node_modules/path-exists": { 1461 | "version": "4.0.0", 1462 | "dev": true, 1463 | "license": "MIT", 1464 | "peer": true, 1465 | "engines": { 1466 | "node": ">=8" 1467 | } 1468 | }, 1469 | "node_modules/path-is-absolute": { 1470 | "version": "1.0.1", 1471 | "dev": true, 1472 | "license": "MIT", 1473 | "peer": true, 1474 | "engines": { 1475 | "node": ">=0.10.0" 1476 | } 1477 | }, 1478 | "node_modules/path-key": { 1479 | "version": "3.1.1", 1480 | "dev": true, 1481 | "license": "MIT", 1482 | "peer": true, 1483 | "engines": { 1484 | "node": ">=8" 1485 | } 1486 | }, 1487 | "node_modules/path-type": { 1488 | "version": "4.0.0", 1489 | "dev": true, 1490 | "license": "MIT", 1491 | "engines": { 1492 | "node": ">=8" 1493 | } 1494 | }, 1495 | "node_modules/picomatch": { 1496 | "version": "2.3.1", 1497 | "dev": true, 1498 | "license": "MIT", 1499 | "engines": { 1500 | "node": ">=8.6" 1501 | }, 1502 | "funding": { 1503 | "url": "https://github.com/sponsors/jonschlinkert" 1504 | } 1505 | }, 1506 | "node_modules/prelude-ls": { 1507 | "version": "1.2.1", 1508 | "dev": true, 1509 | "license": "MIT", 1510 | "peer": true, 1511 | "engines": { 1512 | "node": ">= 0.8.0" 1513 | } 1514 | }, 1515 | "node_modules/punycode": { 1516 | "version": "2.3.1", 1517 | "dev": true, 1518 | "license": "MIT", 1519 | "peer": true, 1520 | "engines": { 1521 | "node": ">=6" 1522 | } 1523 | }, 1524 | "node_modules/queue-microtask": { 1525 | "version": "1.2.3", 1526 | "dev": true, 1527 | "funding": [ 1528 | { 1529 | "type": "github", 1530 | "url": "https://github.com/sponsors/feross" 1531 | }, 1532 | { 1533 | "type": "patreon", 1534 | "url": "https://www.patreon.com/feross" 1535 | }, 1536 | { 1537 | "type": "consulting", 1538 | "url": "https://feross.org/support" 1539 | } 1540 | ], 1541 | "license": "MIT" 1542 | }, 1543 | "node_modules/regexpp": { 1544 | "version": "3.2.0", 1545 | "dev": true, 1546 | "license": "MIT", 1547 | "engines": { 1548 | "node": ">=8" 1549 | }, 1550 | "funding": { 1551 | "url": "https://github.com/sponsors/mysticatea" 1552 | } 1553 | }, 1554 | "node_modules/resolve-from": { 1555 | "version": "4.0.0", 1556 | "dev": true, 1557 | "license": "MIT", 1558 | "peer": true, 1559 | "engines": { 1560 | "node": ">=4" 1561 | } 1562 | }, 1563 | "node_modules/reusify": { 1564 | "version": "1.0.4", 1565 | "dev": true, 1566 | "license": "MIT", 1567 | "engines": { 1568 | "iojs": ">=1.0.0", 1569 | "node": ">=0.10.0" 1570 | } 1571 | }, 1572 | "node_modules/rimraf": { 1573 | "version": "3.0.2", 1574 | "dev": true, 1575 | "license": "ISC", 1576 | "peer": true, 1577 | "dependencies": { 1578 | "glob": "^7.1.3" 1579 | }, 1580 | "bin": { 1581 | "rimraf": "bin.js" 1582 | }, 1583 | "funding": { 1584 | "url": "https://github.com/sponsors/isaacs" 1585 | } 1586 | }, 1587 | "node_modules/run-parallel": { 1588 | "version": "1.2.0", 1589 | "dev": true, 1590 | "funding": [ 1591 | { 1592 | "type": "github", 1593 | "url": "https://github.com/sponsors/feross" 1594 | }, 1595 | { 1596 | "type": "patreon", 1597 | "url": "https://www.patreon.com/feross" 1598 | }, 1599 | { 1600 | "type": "consulting", 1601 | "url": "https://feross.org/support" 1602 | } 1603 | ], 1604 | "license": "MIT", 1605 | "dependencies": { 1606 | "queue-microtask": "^1.2.2" 1607 | } 1608 | }, 1609 | "node_modules/semver": { 1610 | "version": "7.6.3", 1611 | "dev": true, 1612 | "license": "ISC", 1613 | "bin": { 1614 | "semver": "bin/semver.js" 1615 | }, 1616 | "engines": { 1617 | "node": ">=10" 1618 | } 1619 | }, 1620 | "node_modules/shebang-command": { 1621 | "version": "2.0.0", 1622 | "dev": true, 1623 | "license": "MIT", 1624 | "peer": true, 1625 | "dependencies": { 1626 | "shebang-regex": "^3.0.0" 1627 | }, 1628 | "engines": { 1629 | "node": ">=8" 1630 | } 1631 | }, 1632 | "node_modules/shebang-regex": { 1633 | "version": "3.0.0", 1634 | "dev": true, 1635 | "license": "MIT", 1636 | "peer": true, 1637 | "engines": { 1638 | "node": ">=8" 1639 | } 1640 | }, 1641 | "node_modules/slash": { 1642 | "version": "3.0.0", 1643 | "dev": true, 1644 | "license": "MIT", 1645 | "engines": { 1646 | "node": ">=8" 1647 | } 1648 | }, 1649 | "node_modules/strip-ansi": { 1650 | "version": "6.0.1", 1651 | "dev": true, 1652 | "license": "MIT", 1653 | "peer": true, 1654 | "dependencies": { 1655 | "ansi-regex": "^5.0.1" 1656 | }, 1657 | "engines": { 1658 | "node": ">=8" 1659 | } 1660 | }, 1661 | "node_modules/strip-json-comments": { 1662 | "version": "3.1.1", 1663 | "dev": true, 1664 | "license": "MIT", 1665 | "peer": true, 1666 | "engines": { 1667 | "node": ">=8" 1668 | }, 1669 | "funding": { 1670 | "url": "https://github.com/sponsors/sindresorhus" 1671 | } 1672 | }, 1673 | "node_modules/style-mod": { 1674 | "version": "4.1.2", 1675 | "dev": true, 1676 | "license": "MIT", 1677 | "peer": true 1678 | }, 1679 | "node_modules/supports-color": { 1680 | "version": "7.2.0", 1681 | "dev": true, 1682 | "license": "MIT", 1683 | "peer": true, 1684 | "dependencies": { 1685 | "has-flag": "^4.0.0" 1686 | }, 1687 | "engines": { 1688 | "node": ">=8" 1689 | } 1690 | }, 1691 | "node_modules/text-table": { 1692 | "version": "0.2.0", 1693 | "dev": true, 1694 | "license": "MIT", 1695 | "peer": true 1696 | }, 1697 | "node_modules/to-regex-range": { 1698 | "version": "5.0.1", 1699 | "dev": true, 1700 | "license": "MIT", 1701 | "dependencies": { 1702 | "is-number": "^7.0.0" 1703 | }, 1704 | "engines": { 1705 | "node": ">=8.0" 1706 | } 1707 | }, 1708 | "node_modules/tslib": { 1709 | "version": "2.4.0", 1710 | "dev": true, 1711 | "license": "0BSD" 1712 | }, 1713 | "node_modules/tsutils": { 1714 | "version": "3.21.0", 1715 | "dev": true, 1716 | "license": "MIT", 1717 | "dependencies": { 1718 | "tslib": "^1.8.1" 1719 | }, 1720 | "engines": { 1721 | "node": ">= 6" 1722 | }, 1723 | "peerDependencies": { 1724 | "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" 1725 | } 1726 | }, 1727 | "node_modules/tsutils/node_modules/tslib": { 1728 | "version": "1.14.1", 1729 | "dev": true, 1730 | "license": "0BSD" 1731 | }, 1732 | "node_modules/type-check": { 1733 | "version": "0.4.0", 1734 | "dev": true, 1735 | "license": "MIT", 1736 | "peer": true, 1737 | "dependencies": { 1738 | "prelude-ls": "^1.2.1" 1739 | }, 1740 | "engines": { 1741 | "node": ">= 0.8.0" 1742 | } 1743 | }, 1744 | "node_modules/type-fest": { 1745 | "version": "0.20.2", 1746 | "dev": true, 1747 | "license": "(MIT OR CC0-1.0)", 1748 | "peer": true, 1749 | "engines": { 1750 | "node": ">=10" 1751 | }, 1752 | "funding": { 1753 | "url": "https://github.com/sponsors/sindresorhus" 1754 | } 1755 | }, 1756 | "node_modules/typescript": { 1757 | "version": "4.7.4", 1758 | "dev": true, 1759 | "license": "Apache-2.0", 1760 | "bin": { 1761 | "tsc": "bin/tsc", 1762 | "tsserver": "bin/tsserver" 1763 | }, 1764 | "engines": { 1765 | "node": ">=4.2.0" 1766 | } 1767 | }, 1768 | "node_modules/uri-js": { 1769 | "version": "4.4.1", 1770 | "dev": true, 1771 | "license": "BSD-2-Clause", 1772 | "peer": true, 1773 | "dependencies": { 1774 | "punycode": "^2.1.0" 1775 | } 1776 | }, 1777 | "node_modules/w3c-keyname": { 1778 | "version": "2.2.8", 1779 | "dev": true, 1780 | "license": "MIT", 1781 | "peer": true 1782 | }, 1783 | "node_modules/which": { 1784 | "version": "2.0.2", 1785 | "dev": true, 1786 | "license": "ISC", 1787 | "peer": true, 1788 | "dependencies": { 1789 | "isexe": "^2.0.0" 1790 | }, 1791 | "bin": { 1792 | "node-which": "bin/node-which" 1793 | }, 1794 | "engines": { 1795 | "node": ">= 8" 1796 | } 1797 | }, 1798 | "node_modules/word-wrap": { 1799 | "version": "1.2.5", 1800 | "dev": true, 1801 | "license": "MIT", 1802 | "peer": true, 1803 | "engines": { 1804 | "node": ">=0.10.0" 1805 | } 1806 | }, 1807 | "node_modules/wrappy": { 1808 | "version": "1.0.2", 1809 | "dev": true, 1810 | "license": "ISC", 1811 | "peer": true 1812 | }, 1813 | "node_modules/yocto-queue": { 1814 | "version": "0.1.0", 1815 | "dev": true, 1816 | "license": "MIT", 1817 | "peer": true, 1818 | "engines": { 1819 | "node": ">=10" 1820 | }, 1821 | "funding": { 1822 | "url": "https://github.com/sponsors/sindresorhus" 1823 | } 1824 | } 1825 | } 1826 | } 1827 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "file-manager", 3 | "version": "1.4.0", 4 | "description": "Adds missing features to the file explorer.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.17.3", 20 | "obsidian": "latest", 21 | "tslib": "2.4.0", 22 | "typescript": "4.7.4" 23 | }, 24 | "dependencies": { 25 | "ignore": "^6.0.2", 26 | "open": "^8.2.1" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/conflict.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Setting } from "obsidian"; 2 | 3 | // File conflict resolution methods when copying or moving files. 4 | export enum FileConflictResolution { 5 | SKIP = "SKIP", 6 | OVERWRITE = "OVERWRITE", 7 | KEEP = "KEEP", 8 | } 9 | 10 | // User can config the settings with the following options for file conflict resolution. 11 | export enum FileConflictOption { 12 | SKIP = "SKIP", 13 | OVERWRITE = "OVERWRITE", 14 | KEEP = "KEEP", 15 | PROMPT = "PROMPT", 16 | } 17 | 18 | // What to display when user prompt to choose the file conflict resolution method. 19 | export const FileConflictResolutionDescription: Record< 20 | FileConflictResolution, 21 | string 22 | > = { 23 | [FileConflictResolution.SKIP]: "Skip", 24 | [FileConflictResolution.OVERWRITE]: "Overwrite", 25 | [FileConflictResolution.KEEP]: "Keep", 26 | }; 27 | 28 | // What to display when choosing the file conflict resolution options setting. 29 | export const FileConflictOptionDescription: Record< 30 | FileConflictResolution | FileConflictOption, 31 | string 32 | > = { 33 | ...FileConflictResolutionDescription, 34 | [FileConflictOption.PROMPT]: "Prompt user", 35 | }; 36 | 37 | /** 38 | * Modal to prompt the user to choose the file conflict resolution method. 39 | */ 40 | export class ConflictModal extends Modal { 41 | private resolvePromise: (value: [FileConflictResolution, boolean]) => void; 42 | 43 | // Stores the user's choice to apply the same resolution to all conflicts. 44 | private applyToAll: boolean = false; 45 | 46 | constructor(app: App, file: string) { 47 | super(app); 48 | const { contentEl } = this; 49 | 50 | this.setTitle("There is a conflict with: "); 51 | const modalContent = contentEl.createDiv({ cls: "fn-modal-content" }); 52 | modalContent.createEl("p", { text: file }); 53 | 54 | const btnContainer = contentEl.createEl("div", { 55 | cls: "modal-button-container", 56 | }); 57 | 58 | const checkbox = btnContainer.createEl("label", { 59 | cls: "mod-checkbox", 60 | }); 61 | checkbox.tabIndex = -1; 62 | const input = checkbox.createEl("input", { type: "checkbox" }); 63 | checkbox.appendText("Don't ask again"); 64 | input.addEventListener("change", (e) => { 65 | const target = e.target as HTMLInputElement; 66 | this.applyToAll = target.checked; 67 | }); 68 | 69 | const resolutions: FileConflictResolution[] = Object.values( 70 | FileConflictResolution 71 | ) as FileConflictResolution[]; 72 | 73 | resolutions.forEach((resolution) => { 74 | const btn = btnContainer.createEl("button", { 75 | text: FileConflictResolutionDescription[resolution], 76 | cls: "mod-cta", 77 | }); 78 | btn.addEventListener("click", () => { 79 | this.resolvePromise([resolution, this.applyToAll]); 80 | this.close(); 81 | }); 82 | }); 83 | const cancelBtn = btnContainer.createEl("button", { 84 | text: "Cancel", 85 | cls: "mod-cancel", 86 | }); 87 | cancelBtn.addEventListener("click", async () => { 88 | this.resolvePromise([FileConflictResolution.SKIP, this.applyToAll]); 89 | this.close(); 90 | }); 91 | } 92 | 93 | onClose() { 94 | // On close, resolve the promise with the SKIP resolution method. 95 | if (this.resolvePromise) 96 | this.resolvePromise([FileConflictResolution.SKIP, this.applyToAll]); 97 | } 98 | 99 | openAndWait(): Promise<[FileConflictResolution, boolean]> { 100 | return new Promise((resolve) => { 101 | this.resolvePromise = resolve; 102 | this.open(); 103 | }); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/file_manager.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Vault, 3 | Platform, 4 | View, 5 | TAbstractFile, 6 | TFile, 7 | TFolder, 8 | FileSystemAdapter, 9 | } from "obsidian"; 10 | 11 | import { 12 | FileExplorer, 13 | FileOrFolderItem, 14 | FolderItem, 15 | MockApp, 16 | MockVault, 17 | MockWorkspace, 18 | MockTree, 19 | } from "obsidian-internals"; 20 | 21 | import { FileConflictResolution } from "conflict"; 22 | import FileManagerPlugin from "main"; 23 | import { normalize } from "path"; 24 | 25 | // ------------------------- Helper Functions ------------------------- 26 | 27 | export function getVaultAbsolutePath(): string { 28 | let adapter = this.app.vault.adapter; 29 | if (adapter instanceof FileSystemAdapter) return adapter.getBasePath(); 30 | 31 | return ""; 32 | } 33 | 34 | /** 35 | * Returns the absolute path of a file in the vault. It takes into account 36 | * the platform and the path separator. 37 | */ 38 | export function getAbsolutePathOfFile(file: TAbstractFile): string { 39 | const basePath = getVaultAbsolutePath(); 40 | const path = normalize(`${basePath}/${file.path}`); 41 | if (Platform.isDesktopApp && Platform.isWin) 42 | return path.replace(/\//g, "\\"); 43 | 44 | return path; 45 | } 46 | 47 | /** 48 | * Add a suffix before the extension of a file. If the file has no extension, 49 | * it will add the suffix at the end of the file name. 50 | * 51 | * Examples: 52 | * addSuffixBeforeExtension("home/notes/file.md", " copy") 53 | * -> "home/notes/file copy.md" 54 | * 55 | * addSuffixBeforeExtension("test", "copy") 56 | * -> "test copy" 57 | */ 58 | function addSuffixBeforeExtension(path: string, suffix: string): string { 59 | // Deal with extension if there is one after the last slash. 60 | const slashIndex = path.lastIndexOf(DIR_SEP); 61 | const dotIndex = path.lastIndexOf(EXT_SEP); 62 | if (dotIndex > slashIndex) { 63 | const [before, after] = [ 64 | path.slice(0, dotIndex), 65 | path.slice(dotIndex + 1), 66 | ]; 67 | return `${before}${suffix}.${after}`; 68 | } 69 | return path + suffix; 70 | } 71 | 72 | /** 73 | * Get an array of all ancestors of a path. 74 | * Exampls: 75 | * getAllAncestors("home/notes/file.md") 76 | * -> ["home", "home/notes"]. 77 | */ 78 | function getAllAncestors(path: string): string[] { 79 | const ancestors: string[] = []; 80 | const parts = path.split(DIR_SEP); 81 | for (let i = 1; i < parts.length; i++) { 82 | ancestors.push(parts.slice(0, i).join(DIR_SEP)); 83 | } 84 | return ancestors; 85 | } 86 | 87 | /** 88 | * Returns a non existing path by adding a sequence number to the path if 89 | * already exists in the file explorer. If `dealWithExt` is true, it will 90 | * consider the extension of the file and add the sequence number before it. 91 | * 92 | * Examples (imagine that "home/notes/file.md" already exists): 93 | * getNonExistingPath(fileExplorer, "home/notes/file.md") 94 | * -> "home/notes/file 1.md" 95 | * 96 | * getNonExistingPath(fileExplorer, "home/notes/file.md", false) 97 | * -> "home/notes/file.md 1" 98 | */ 99 | function genNonExistingPath( 100 | fileExplorer: FileExplorer, 101 | path: string, 102 | dealWithExt: boolean = true 103 | ): string { 104 | let newPath = path; 105 | // While path exists, add sequence number to avoid name collisions. 106 | for (let i = 1; fileExplorer.fileItems[newPath]; i++) { 107 | newPath = dealWithExt 108 | ? addSuffixBeforeExtension(path, ` ${i}`) 109 | : `${path} ${i}`; 110 | } 111 | return newPath; 112 | } 113 | 114 | /** 115 | * Prune the files/folders by removing the paths that are descendants 116 | * of other files/folders. 117 | * 118 | * Example: 119 | * 120 | * pruneDescendantsPaths(["home/notes", "home/notes/folder", "home/notes/file.md"]) 121 | * -> ["home/notes"] 122 | */ 123 | function pruneDescendantsPaths(paths: string[]): string[] { 124 | // Sort selected file/folder by the depth of the path. 125 | paths.sort((a, b) => { 126 | return a.split(DIR_SEP).length - b.split(DIR_SEP).length; 127 | }); 128 | 129 | // Store the file/folder paths in a map, grouped by their depth. 130 | const depthMap: Map> = new Map(); 131 | paths.forEach((path) => { 132 | const depth = path.split(DIR_SEP).length; 133 | if (!depthMap.has(depth)) depthMap.set(depth, new Set()); 134 | 135 | depthMap.get(depth)?.add(path); 136 | }); 137 | 138 | const ancestors: string[] = []; 139 | depthMap.forEach((depthPaths, depth) => { 140 | depthPaths.forEach((path) => { 141 | if (depth === 1) ancestors.push(path); 142 | else { 143 | const alreadyIn = getAllAncestors(path).some((ancestor) => 144 | depthMap.get(ancestor.split(DIR_SEP).length)?.has(ancestor) 145 | ); 146 | if (!alreadyIn) ancestors.push(path); 147 | } 148 | }); 149 | }); 150 | return ancestors; 151 | } 152 | 153 | /** 154 | * Returns a map where the key is the original path and the value is the 155 | * path without ancestors. 156 | * Example: 157 | 158 | * createPathToNameMap(["file.md", "notes/file2.md", "notes/folder/file3.md"]) 159 | * -> Map { "file.md":"file.md", "notes/file2.md":"file2.md", "notes/folder/file3.md":"file3.md" } 160 | 161 | * createPathToNameMap(["miDir", "dir1/dir2"]) 162 | * -> Map { "miDir":"miDir", "dir1/dir2":"dir2"} 163 | */ 164 | function createPathToNameMap(paths: string[]): Map { 165 | const pathsMap: Map = new Map(); 166 | paths.forEach((path) => pathsMap.set(path, path.split(DIR_SEP).pop()!)); 167 | return pathsMap; 168 | } 169 | 170 | /** 171 | * Uncollapse the folder, and all the ancestors, in the file explorer that 172 | * contains the file/folder with the given `path`. 173 | */ 174 | function uncollapsePath(fileExplorer: FileExplorer, path: string) { 175 | let item = fileExplorer.fileItems[path] as FileOrFolderItem; 176 | if (!item) return; 177 | 178 | // Loops through the ancestors and uncollapse them until the root. 179 | while (true) { 180 | if (item.file instanceof TFolder) 181 | (item as FolderItem).setCollapsed(false); 182 | const parent = item.file.parent; 183 | if (!parent || !parent.name) break; 184 | item = item.parent; 185 | } 186 | } 187 | 188 | /** 189 | * Scrolls the file explorer to bring the file/folder with the given `path` 190 | * into view. 191 | */ 192 | function scrollToPath(fileExplorer: FileExplorer, path: string) { 193 | // Get the file element in the DOM 194 | const fileEl = fileExplorer.containerEl.querySelector( 195 | `[data-path="${path}"]` 196 | ); 197 | // Scroll the container to bring the file element into view 198 | if (fileEl) fileEl.scrollIntoView({ behavior: "smooth", block: "center" }); 199 | } 200 | 201 | // ------------------------- File Manager ------------------------- 202 | 203 | export const FILE_EXPLORER_TYPE = "file-explorer"; 204 | 205 | export const DIR_SEP = "/"; 206 | export const EXT_SEP = "."; 207 | 208 | // When a conflict occurs, if the user selects one of these options, then 209 | // the folder where the file/folder is moved/copied will be expanded. 210 | const EXPAND_FOLDER_AFTER_OPERATION: (FileConflictResolution | null)[] = [ 211 | FileConflictResolution.KEEP, 212 | FileConflictResolution.OVERWRITE, 213 | null, 214 | ]; 215 | 216 | /** 217 | * FileManager class provides a set of methods to interact with the file explorer. 218 | */ 219 | export class FileManager { 220 | private plugin: FileManagerPlugin; 221 | private app: MockApp; 222 | private vault: MockVault; 223 | private workspace: MockWorkspace; 224 | 225 | constructor(plugin: FileManagerPlugin) { 226 | this.plugin = plugin; 227 | this.app = plugin.app as MockApp; 228 | this.vault = plugin.app.vault as MockVault; 229 | this.workspace = plugin.app.workspace as MockWorkspace; 230 | } 231 | 232 | /** 233 | * Returns the file explorer instance if it's available in the workspace. 234 | */ 235 | getFileExplorer(): FileExplorer | null { 236 | const leaves = this.workspace.getLeavesOfType(FILE_EXPLORER_TYPE); 237 | if (!leaves || leaves.length === 0) return null; 238 | return leaves[0].view as FileExplorer; 239 | } 240 | 241 | /** 242 | * Returns the file explorer instance and the active item in the file explorer. 243 | */ 244 | getFileExplorerAndActiveFileOrFolder(): { 245 | fileExplorer: FileExplorer | null; 246 | activeFileOrFolder: TAbstractFile | null; 247 | } { 248 | const fileExplorer = this.getFileExplorer(); 249 | if (!fileExplorer) 250 | return { fileExplorer: null, activeFileOrFolder: null }; 251 | 252 | const activeFileOrFolder = 253 | fileExplorer.tree.focusedItem?.file ?? fileExplorer.activeDom?.file; 254 | return { fileExplorer, activeFileOrFolder }; 255 | } 256 | 257 | /** 258 | * Returns the active file or folder in the file explorer. If no file or 259 | * folder is active, it returns null. 260 | */ 261 | getActiveFileOrFolder(): TAbstractFile | null { 262 | return this.getFileExplorerAndActiveFileOrFolder().activeFileOrFolder; 263 | } 264 | 265 | /** 266 | * Returns the selected files/folders in the file explorer. If no files/folders 267 | * are selected and `useActiveFileIfNonSelected` is true, it returns the 268 | * active file/folder if available. If no active 269 | * file/folder is available or `useActiveFileIfNonSelected` is false, it 270 | * returns an empty array. 271 | */ 272 | getSelectedFilesOrFolders( 273 | useActiveFileIfNonSelected: boolean = true 274 | ): TAbstractFile[] { 275 | const { fileExplorer, activeFileOrFolder } = 276 | this.getFileExplorerAndActiveFileOrFolder(); 277 | 278 | if (!fileExplorer) return []; 279 | 280 | // If no items are selected, return the active item if available and 281 | // `useActiveFileIfNonSelected` is true. 282 | if (fileExplorer.tree.selectedDoms.size === 0) { 283 | if (!useActiveFileIfNonSelected) return []; 284 | return activeFileOrFolder ? [activeFileOrFolder] : []; 285 | } 286 | 287 | // Return all selected files/folders as TAbstractFiles. 288 | return Array.from( 289 | Array.from(fileExplorer.tree.selectedDoms.values()).map( 290 | (item) => item.file 291 | ) 292 | ); 293 | } 294 | 295 | /** 296 | * Returns the selected files'/folders' path in the file explorer. If no files/folders 297 | * are selected and `useActiveFileIfNonSelected` is true, it returns the active 298 | * file'/folder' path if available. If no active file/folder is available or 299 | * `useActiveFileIfNonSelected` is false, it returns an empty array. 300 | * The returning paths can be pruned by removing the paths that are descendants 301 | * of other selected files/folders using the parameter `prune`. 302 | */ 303 | getSelectedFilesOrFoldersPath( 304 | prune: boolean = false, 305 | useActiveFileIfNonSelected: boolean = true 306 | ): string[] { 307 | const paths: string[] = this.getSelectedFilesOrFolders( 308 | useActiveFileIfNonSelected 309 | ).map((fileOrFolder) => fileOrFolder.path); 310 | return prune ? pruneDescendantsPaths(paths) : paths; 311 | } 312 | 313 | /** 314 | * Get all selected files/folders' paths in the file explorer, if `prune` is 315 | * true, it simplifies the total by removing the paths that are descendants 316 | * of other selected files/folders. For the resulting paths, it creates and 317 | * returns a map where the key is the source path of the selected 318 | * files/folders in file explorer, and the value is the simplified 319 | * destination path (e.g. home/notes/file.md -> file.md). 320 | * `useActiveFileIfNonSelected` is true, it returns the active file/folder 321 | * path if no files/folders are selected. 322 | */ 323 | getSelectedPathToNameMap( 324 | prune: boolean = false, 325 | useActiveFileIfNonSelected: boolean = true 326 | ): Map { 327 | return createPathToNameMap( 328 | this.getSelectedFilesOrFoldersPath( 329 | prune, 330 | useActiveFileIfNonSelected 331 | ) 332 | ); 333 | } 334 | 335 | /** 336 | * Returns if the file explorer is available in the workspace. 337 | */ 338 | isFileExplorerAvailable(): boolean { 339 | return this.getFileExplorer() !== null; 340 | } 341 | 342 | /** 343 | * Returns if the file explorer is currently active in the workspace. 344 | */ 345 | isFileExplorerActive(): boolean { 346 | const view = this.workspace.getActiveViewOfType(View); 347 | return view?.getViewType() === FILE_EXPLORER_TYPE; 348 | } 349 | 350 | /** 351 | * Returns if there is and active file or folder in the file explorer. NOTE: 352 | * FileExplorer must be active in the workspace, if not, it will return false. 353 | */ 354 | isActiveFileOrFolderAvailable(): boolean { 355 | if (!this.isFileExplorerActive()) return false; 356 | const { activeFileOrFolder } = 357 | this.getFileExplorerAndActiveFileOrFolder(); 358 | return activeFileOrFolder !== null; 359 | } 360 | 361 | /** 362 | * Returns true if the `path` is a file in the file explorer. 363 | */ 364 | isFile(path: string): boolean { 365 | try { 366 | return ( 367 | this.getFileExplorer()?.fileItems[path].file instanceof TFile 368 | ); 369 | } catch (e) { 370 | return false; 371 | } 372 | } 373 | 374 | /** 375 | * Returns true if the `path` is a folder in the file explorer. 376 | */ 377 | isFolder(path: string): boolean { 378 | try { 379 | return ( 380 | this.getFileExplorer()?.fileItems[path].file instanceof TFolder 381 | ); 382 | } catch (e) { 383 | return false; 384 | } 385 | } 386 | 387 | /** 388 | * Set focus and starts the rename operation of the file/folder in the 389 | * file explorer. 390 | */ 391 | protected _focusAndRenameFile( 392 | fileExplorer: FileExplorer, 393 | fileOrFolder: TAbstractFile 394 | ) { 395 | // TODO: Regresion - This was working in Obsidian 1.6. Not working in 1.7 396 | // Give chance to the user to rename the new folder. 397 | // Call to nextFrame is mandatory to show correctly the rename textbox. 398 | // this.app.nextFrame( 399 | // async () => await fileExplorer.startRenameFile(fileOrFolder) 400 | // ); 401 | setTimeout( 402 | async () => await fileExplorer.startRenameFile(fileOrFolder), 403 | 100 404 | ); 405 | } 406 | 407 | /** 408 | * It tries to perform the operation `operation` (e.g. copy, move, ...) 409 | * on the source `src` and destination `dest` paths. If the destination 410 | * path already exists, it will use the `resolve` parameter to solve the 411 | * conflict. The `resolve` could be a `FileConflictResolution` value or 412 | * null, in this later case, will call `getFileConflictResolutionMethod` 413 | * from the plugin, to get the user's choice. If SKIP, no operation is performed, 414 | * if OVERWRITE, the destination is deleted and the operation is performed, 415 | * if KEEP, the destination is renamed and the operation is performed. 416 | * It returns an array with three values: 417 | * - The `FileConflictResolution` applied or null if no conflict. 418 | * - A boolean indicating if the user wants to apply the same resolution 419 | * to all conflicts. 420 | * - The final destination path where the operation was performed (useful 421 | * if a new filename has been created to avoid conflicts (KEEP)). 422 | */ 423 | protected async _conflictSolver( 424 | fileExplorer: FileExplorer, 425 | src: string, 426 | dest: string, 427 | operation: (src: string, dest: string) => Promise, 428 | resolve: FileConflictResolution | null = null 429 | ): Promise<[FileConflictResolution | null, boolean, string]> { 430 | let applyToAll = true; 431 | // If destination doesn't exist, perform the operation (no conflict). 432 | if (!(await this.vault.exists(dest))) { 433 | await operation(src, dest); 434 | return [null, applyToAll, dest]; 435 | } 436 | // Destination file/folder already exists. Handle conflict! 437 | 438 | // If no resolution is provided, get the user's choice. 439 | if (!resolve) 440 | [resolve, applyToAll] = 441 | await this.plugin.getFileConflictResolutionMethod(dest); 442 | if (resolve === FileConflictResolution.OVERWRITE) { 443 | await this.app.fileManager.trashFile( 444 | fileExplorer.fileItems[dest].file 445 | ); 446 | await operation(src, dest); 447 | return [FileConflictResolution.OVERWRITE, applyToAll, dest]; 448 | } 449 | if (resolve === FileConflictResolution.KEEP) { 450 | // Generate a non existing path for the destination. 451 | dest = genNonExistingPath(fileExplorer, dest); 452 | await operation(src, dest); 453 | return [FileConflictResolution.KEEP, applyToAll, dest]; 454 | } 455 | return [FileConflictResolution.SKIP, applyToAll, dest]; 456 | } 457 | 458 | /** 459 | * Creates a new folder in the file explorer. The path where the new 460 | * folder will be created is determined by the `getFolderPath` function. 461 | * This function receives the focused/active file or folder in file explorer 462 | * and returns the path where the new folder will be created. 463 | * Returns the path of the new created folder or null if no folder was created. 464 | */ 465 | protected async _createFolder( 466 | getFolderPath: (activeFileOrFolder: TAbstractFile) => string | undefined 467 | ): Promise { 468 | const { fileExplorer, activeFileOrFolder } = 469 | this.getFileExplorerAndActiveFileOrFolder(); 470 | if (!fileExplorer || !activeFileOrFolder) return null; 471 | 472 | // Get the path of the folder where the new folder will be created. 473 | const path = getFolderPath(activeFileOrFolder); 474 | if (!path) return null; 475 | 476 | // Expand the parent folder to show the new folder, if it's not root. 477 | if (path !== DIR_SEP) uncollapsePath(fileExplorer, path); 478 | 479 | // Generate a non existing name for the new folder. 480 | let newPath = 481 | (path === DIR_SEP ? "" : path + DIR_SEP) + 482 | this.plugin.settings.newFolderName; 483 | newPath = genNonExistingPath(fileExplorer, newPath); 484 | 485 | // Create the new subfolder and set focus on it. 486 | const newFolder = await this.vault.createFolder(newPath); 487 | fileExplorer.tree.setFocusedItem( 488 | fileExplorer.fileItems[newFolder.path], 489 | true 490 | ); 491 | 492 | this._focusAndRenameFile(fileExplorer, newFolder); 493 | return newPath; 494 | } 495 | 496 | /** 497 | * Creates a new subfolder within the currently selected or active folder 498 | * in the file explorer. If the currently selected or active item is a file, 499 | * the parent folder is used as active folder. 500 | * Returns the path of the new created folder or null if no folder was created. 501 | */ 502 | async createSubFolder(): Promise { 503 | return await this._createFolder((fileOrFolder) => { 504 | return fileOrFolder instanceof TFolder 505 | ? fileOrFolder.path 506 | : fileOrFolder.parent?.path; 507 | }); 508 | } 509 | 510 | /** 511 | * Creates a new folder as a sibling of the currently selected or active 512 | * folder in the file explorer. If the currently selected or active item is 513 | * a file, the parent folder is used as active folder. 514 | * Returns the path of the new created folder or null if no folder was created. 515 | */ 516 | async createFolder(): Promise { 517 | return await this._createFolder((fileOrFolder) => { 518 | return fileOrFolder.parent?.path; 519 | }); 520 | } 521 | 522 | /** 523 | * Creates a new note in the currently focused or active folder. 524 | * Returns the path of the new note or null if no note was created. 525 | */ 526 | async createNote(): Promise { 527 | const { fileExplorer, activeFileOrFolder } = 528 | this.getFileExplorerAndActiveFileOrFolder(); 529 | if (!fileExplorer || !activeFileOrFolder) return null; 530 | 531 | const path: string = 532 | activeFileOrFolder instanceof TFolder 533 | ? activeFileOrFolder.path 534 | : activeFileOrFolder.parent?.path!; 535 | 536 | // Expand ancestors' folders to show the new note. 537 | uncollapsePath(fileExplorer, path); 538 | 539 | // Generate a non existing name for the new note. 540 | const newPath = genNonExistingPath( 541 | fileExplorer, 542 | path + DIR_SEP + this.plugin.settings.newNoteName 543 | ); 544 | 545 | // Create empty note and set focus on it. 546 | await this.vault.create(newPath, ""); 547 | const newNoteItem = fileExplorer.fileItems[newPath]; 548 | fileExplorer.tree.setFocusedItem(newNoteItem, true); 549 | 550 | this._focusAndRenameFile(fileExplorer, newNoteItem.file); 551 | return newPath; 552 | } 553 | 554 | /** 555 | * Rename the focused or active file/folder in the file explorer. 556 | */ 557 | async renameFile() { 558 | const { fileExplorer, activeFileOrFolder } = 559 | this.getFileExplorerAndActiveFileOrFolder(); 560 | if (!fileExplorer || !activeFileOrFolder) return; 561 | 562 | // Give chance to the user to rename the new folder. 563 | // Call to nextFrame is mandatory to show correctly the rename textbox. 564 | this.app.nextFrame( 565 | async () => await fileExplorer.startRenameFile(activeFileOrFolder) 566 | ); 567 | } 568 | 569 | /** 570 | * Duplicates the focused or active file/folder in the file explorer. 571 | */ 572 | async duplicateFile() { 573 | const { fileExplorer, activeFileOrFolder } = 574 | this.getFileExplorerAndActiveFileOrFolder(); 575 | if (!fileExplorer || !activeFileOrFolder) return; 576 | 577 | const src = activeFileOrFolder.path; 578 | const dest = addSuffixBeforeExtension( 579 | src, 580 | this.plugin.settings.duplicateSuffix 581 | ); 582 | const [_1, _2, path] = await this._copyFileOrFolder( 583 | fileExplorer, 584 | src, 585 | dest, 586 | FileConflictResolution.KEEP 587 | ); 588 | // Focus and rename the duplicated file/folder. 589 | const fileOrFolder = fileExplorer.fileItems[path].file; 590 | this._focusAndRenameFile(fileExplorer, fileOrFolder); 591 | } 592 | 593 | /** 594 | * Helper function that moves src file/folder to dest path, by using the 595 | * conflict solver to handle conflicts. 596 | * The `resolve` could be a `FileConflictResolution` value or 597 | * null, in this later case, will call `getFileConflictResolutionMethod` 598 | * from the plugin, to get the user's choice. If SKIP, no operation is performed, 599 | * if OVERWRITE, the destination is deleted and the operation is performed, 600 | * if KEEP, the destination is renamed and the operation is performed. 601 | * It returns an array with three values: 602 | * - The `FileConflictResolution` applied or null if no conflict. 603 | * - A boolean indicating if the user wants to apply the same resolution 604 | * to all conflicts. 605 | * - The final destination path where the operation was performed (useful 606 | * if a new filename has been created to avoid conflicts (KEEP)). 607 | */ 608 | protected async _moveFileOrFolder( 609 | fileExplorer: FileExplorer, 610 | src: string, 611 | dest: string, 612 | resolve: FileConflictResolution | null = null 613 | ): Promise<[FileConflictResolution | null, boolean, string]> { 614 | return this._conflictSolver( 615 | fileExplorer, 616 | src, 617 | dest, 618 | async (src, dest) => { 619 | const fileManager = fileExplorer.app.fileManager; 620 | const fileOrFolder: TAbstractFile = 621 | fileExplorer.fileItems[src].file; 622 | 623 | await fileManager.renameFile(fileOrFolder, dest); 624 | }, 625 | resolve 626 | ); 627 | } 628 | 629 | /** 630 | * Moves the selected files/folders in the file explorer to the specified 631 | * `path`. 632 | * 633 | * @param path - The destination path where the selected files/folders will 634 | * be moved. 635 | * @param pathToName - A map where the key is the source path of the selected 636 | * files/folders, and the value is the destination name. If null, it will 637 | * use the selected files/folders in the file explorer. 638 | * @param resolve - The conflict resolution strategy to use when a 639 | * file/folder with the same name already exists at the destination. If 640 | * null, the user will be prompted to choose a resolution. 641 | * @returns A map where the key is the destination path and the value is the 642 | * conflict resolution applied (or null if no conflict occurred). 643 | */ 644 | async moveFiles( 645 | path: string, 646 | pathToName: Map | null = null, 647 | resolve: FileConflictResolution | null = null 648 | ): Promise> { 649 | const stats: Map = new Map(); 650 | const fileExplorer = this.getFileExplorer(); 651 | if (!fileExplorer) return stats; 652 | 653 | if (!pathToName) pathToName = this.getSelectedPathToNameMap(true); 654 | 655 | for (const [src, dest] of pathToName) { 656 | const newDest = `${path}/${dest}`; 657 | const [resolution, applyToAll, _] = await this._moveFileOrFolder( 658 | fileExplorer, 659 | src, 660 | newDest, 661 | resolve 662 | ); 663 | // Check if we should expand a destination folder. 664 | if (EXPAND_FOLDER_AFTER_OPERATION.includes(resolution)) 665 | uncollapsePath(fileExplorer, path); 666 | 667 | stats.set(newDest, resolution); 668 | if (applyToAll) resolve = resolution; 669 | } 670 | return stats; 671 | } 672 | 673 | /** 674 | * Helper function that copies src file/folder to dest path, by using the 675 | * conflict solver to handle conflicts. 676 | * The `resolve` could be a `FileConflictResolution` value or 677 | * null, in this later case, will call `getFileConflictResolutionMethod` 678 | * from the plugin, to get the user's choice. If SKIP, no operation is performed, 679 | * if OVERWRITE, the destination is deleted and the operation is performed, 680 | * if KEEP, the destination is renamed and the operation is performed. 681 | * It returns an array with three values: 682 | * - The `FileConflictResolution` applied or null if no conflict. 683 | * - A boolean indicating if the user wants to apply the same resolution 684 | * to all conflicts. 685 | * - The final destination path where the operation was performed (useful 686 | * if a new filename has been created to avoid conflicts (KEEP)). 687 | */ 688 | protected async _copyFileOrFolder( 689 | fileExplorer: FileExplorer, 690 | src: string, 691 | dest: string, 692 | resolve: FileConflictResolution | null = null 693 | ): Promise<[FileConflictResolution | null, boolean, string]> { 694 | return this._conflictSolver( 695 | fileExplorer, 696 | src, 697 | dest, 698 | async (src, dest) => { 699 | const fileOrFolder: TAbstractFile = 700 | fileExplorer.fileItems[src].file; 701 | if (fileOrFolder instanceof TFile) { 702 | await this.vault.copy(fileOrFolder, dest); 703 | } 704 | // If folder, create folder and copy all recurse children. 705 | else if (fileOrFolder instanceof TFolder) { 706 | // Get all recurse children. Folders will come up first, so we 707 | // can create them before copying the files. 708 | const children: TAbstractFile[] = []; 709 | Vault.recurseChildren( 710 | fileOrFolder, 711 | (child: TAbstractFile) => { 712 | children.push(child); 713 | } 714 | ); 715 | // Create folders and copy files if the don't exist in the destination. 716 | for (const child of children) { 717 | const childPath = child.path.replace( 718 | new RegExp(`^${src}`), 719 | "" 720 | ); 721 | // TODO: I think this if is not necessary, because the conflict 722 | // solver should take care of it. Maybe if there is a MERGE option 723 | // it could be useful. 724 | if (await this.vault.exists(`${dest}${childPath}`)) 725 | continue; 726 | if (child instanceof TFile) { 727 | await this.vault.copy(child, `${dest}${childPath}`); 728 | } else if (child instanceof TFolder) { 729 | await this.vault.createFolder( 730 | `${dest}${childPath}` 731 | ); 732 | } 733 | } 734 | } 735 | }, 736 | resolve 737 | ); 738 | } 739 | 740 | /** 741 | * Copies the selected files/folders in the file explorer to the specified `path`. 742 | */ 743 | async copyFiles( 744 | path: string, 745 | pathToName: Map | null = null, 746 | resolve: FileConflictResolution | null = null 747 | ): Promise> { 748 | const stats: Map = new Map(); 749 | 750 | const fileExplorer = this.getFileExplorer(); 751 | if (!fileExplorer) return stats; 752 | 753 | if (!pathToName) pathToName = this.getSelectedPathToNameMap(true); 754 | 755 | for (const [src, dest] of pathToName) { 756 | const newDest = `${path}/${dest}`; 757 | const [resolution, applyToAll, _] = await this._copyFileOrFolder( 758 | fileExplorer, 759 | src, 760 | newDest, 761 | resolve 762 | ); 763 | // Check if we should expand a destination folder. 764 | if (EXPAND_FOLDER_AFTER_OPERATION.includes(resolution)) 765 | uncollapsePath(fileExplorer, path); 766 | 767 | stats.set(newDest, resolution); 768 | if (applyToAll) resolve = resolution; 769 | } 770 | return stats; 771 | } 772 | 773 | /** 774 | * Selects the items in the file explorer with the specified `paths`. If 775 | * `clearSelection` is true, it clears the selection before selecting new items. 776 | * Returns the total number of current selected items. 777 | */ 778 | selectItems(paths: string[], clearSelection: boolean = true): number { 779 | if (clearSelection) this.deselectAll(); 780 | 781 | const fileExplorer = this.getFileExplorer(); 782 | if (!fileExplorer) return 0; 783 | 784 | const tree = fileExplorer.tree as unknown as MockTree; 785 | paths.forEach((path) => { 786 | const item = fileExplorer.fileItems[path]; 787 | if (!item) return; 788 | tree.selectItem(item); 789 | }); 790 | return fileExplorer.tree.selectedDoms.size; 791 | } 792 | 793 | /** 794 | * Selects all items in the file explorer within the parent of the focused or 795 | * active item. Returns total number of selected items. If `clearSelection` is 796 | * true, it clears the selection before selecting new items. 797 | */ 798 | selectAll(clearSelection: boolean = true) { 799 | const { fileExplorer, activeFileOrFolder } = 800 | this.getFileExplorerAndActiveFileOrFolder(); 801 | if (!fileExplorer || !activeFileOrFolder) return; 802 | 803 | let parentPath = activeFileOrFolder.parent?.path; 804 | if (!parentPath) return; 805 | // If the parent path is the root, we need to remove the slash. 806 | parentPath = parentPath === DIR_SEP ? "" : parentPath + DIR_SEP; 807 | 808 | // Select all items dangling from parentPath. 809 | const tree = fileExplorer.tree as unknown as MockTree; 810 | if (clearSelection) tree.clearSelectedDoms(); 811 | for (const [path, item] of Object.entries(fileExplorer.fileItems)) { 812 | if (path.startsWith(parentPath)) tree.selectItem(item); 813 | } 814 | } 815 | 816 | /** 817 | * Toggles selection of the active file/folder in the file explorer. Returns 818 | * total number of selected items. If `clearSelection` is true, it clears the 819 | * selection before toggling. 820 | */ 821 | toggleSelect(clearSelection: boolean = false) { 822 | const { fileExplorer, activeFileOrFolder } = 823 | this.getFileExplorerAndActiveFileOrFolder(); 824 | if (!fileExplorer || !activeFileOrFolder) return; 825 | 826 | const tree = fileExplorer.tree as unknown as MockTree; 827 | if (clearSelection) tree.clearSelectedDoms(); 828 | 829 | // Toggle selection. 830 | const fileOrFolder = fileExplorer.fileItems[activeFileOrFolder.path]; 831 | const selected = fileExplorer.tree.selectedDoms.has(fileOrFolder); 832 | if (selected) tree.deselectItem(fileOrFolder); 833 | else tree.selectItem(fileOrFolder); 834 | } 835 | 836 | /** 837 | * Inverts the current selection of items in file explorer. 838 | */ 839 | invertSelection() { 840 | const fileExplorer = this.getFileExplorer(); 841 | if (!fileExplorer) return; 842 | 843 | // Get all items that are not currently selected. 844 | const toSelect = []; 845 | for (const item of Object.values(fileExplorer.fileItems)) { 846 | if (!fileExplorer.tree.selectedDoms.has(item)) toSelect.push(item); 847 | } 848 | 849 | const tree = fileExplorer.tree as unknown as MockTree; 850 | tree.clearSelectedDoms(); 851 | toSelect.forEach((item) => tree.selectItem(item)); 852 | } 853 | 854 | /** 855 | * Clears the selection of all items in the file explorer. It returns 856 | * the total number of items that were selected before clearing. 857 | */ 858 | deselectAll() { 859 | const fileExplorer = this.getFileExplorer(); 860 | if (!fileExplorer) return; 861 | 862 | const tree = fileExplorer.tree as unknown as MockTree; 863 | tree.clearSelectedDoms(); 864 | } 865 | 866 | /** 867 | * Focuses the file/folder in the file explorer with the specified `path`. 868 | * If the file/folder is a file, it will open it. 869 | */ 870 | focusPath(path: string) { 871 | const { fileExplorer, activeFileOrFolder } = 872 | this.getFileExplorerAndActiveFileOrFolder(); 873 | if (!fileExplorer || !activeFileOrFolder) return null; 874 | 875 | const item = fileExplorer.fileItems[path]; 876 | if (!item) return; 877 | 878 | // Focus File Explorer. 879 | this.workspace.setActiveLeaf( 880 | this.workspace.getLeavesOfType(FILE_EXPLORER_TYPE)[0], 881 | { focus: true } 882 | ); 883 | // Focus item in file explorer. 884 | uncollapsePath(fileExplorer, path); 885 | fileExplorer.tree.setFocusedItem(item, true); 886 | scrollToPath(fileExplorer, path); 887 | // Open item if it's a file. 888 | // if (item.file instanceof TFile) 889 | // this.workspace.getLeaf().openFile(item.file); 890 | } 891 | } 892 | -------------------------------------------------------------------------------- /src/icon_modal.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | FuzzySuggestModal, 4 | FuzzyMatch, 5 | Modal, 6 | Notice, 7 | getIconIds, 8 | setIcon, 9 | } from "obsidian"; 10 | 11 | export class LucideIconPickerModal extends FuzzySuggestModal { 12 | constructor(app: App, private toDo: (icon: string) => void) { 13 | super(app); 14 | this.setPlaceholder("Search for a Lucide icon..."); 15 | } 16 | 17 | // Get all available Lucide icons 18 | getItems(): string[] { 19 | return getIconIds(); 20 | // return getIconIds().map((icon) => icon.substring(7)); 21 | } 22 | 23 | /** 24 | * Returns the text to display for an icon. 25 | */ 26 | getItemText(icon: string): string { 27 | return `lucide-${icon}`; 28 | } 29 | 30 | // Render the icon with its name in the modal 31 | renderSuggestion(match: FuzzyMatch, el: HTMLElement): void { 32 | const iconContainer = el.createDiv(); 33 | setIcon(iconContainer.createSpan(), match.item); 34 | iconContainer.createSpan({ text: " " }); 35 | iconContainer.createSpan({ text: match.item }); 36 | } 37 | 38 | // Action when a user selects an icon 39 | onChooseItem(icon: string, evt: MouseEvent | KeyboardEvent): void { 40 | this.toDo(icon); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { Menu, TFile, TAbstractFile, Notice, Plugin, TFolder } from "obsidian"; 3 | 4 | import { FileOrFolderItem, MockTree } from "obsidian-internals"; 5 | 6 | import { FileManager, DIR_SEP } from "file_manager"; 7 | 8 | import { 9 | FileConflictOption, 10 | FileConflictResolution, 11 | ConflictModal, 12 | } from "conflict"; 13 | 14 | import { 15 | FileManagerSettingTab, 16 | FileManagerSettings, 17 | DEFAULT_SETTINGS, 18 | } from "settings"; 19 | 20 | import { OpenWithCmd, openFile } from "open_with_cmd"; 21 | import { PathExplorer, HREF_FILE_PREFIX } from "path_explorer"; 22 | import { SuggestPathModal } from "path_modal"; 23 | 24 | // Used to keep track of whether the user is copying or moving files 25 | enum FileOperation { 26 | COPY = "Copy", 27 | MOVE = "Move", 28 | } 29 | 30 | /** 31 | * Returns the stats of files opearations in a human-readable format. For example: 32 | * [DONE] file1.md\n, [OVERWRITE] file2.md\n, [SKIP] file3.md\n. 33 | */ 34 | function statsToString( 35 | stats: Map 36 | ): string { 37 | let str = ""; 38 | stats.forEach((resolve, path) => { 39 | const name = path.split(DIR_SEP).pop(); 40 | str += resolve === null ? `[DONE] ${name}\n` : `[${resolve}] ${name}\n`; 41 | }); 42 | return str; 43 | } 44 | 45 | /** 46 | * The main plugin class that provides file manager commands. 47 | */ 48 | export default class FileManagerPlugin extends Plugin { 49 | settings: FileManagerSettings; 50 | selectedStatusBar: HTMLElement; 51 | clipboardStatusBar: HTMLElement; 52 | 53 | // The file manager instance that provides file operations. 54 | private fm: FileManager = new FileManager(this); 55 | 56 | private pe: PathExplorer = new PathExplorer(this); 57 | 58 | // We keep track of the selected files/folders for a cut/copy operation. 59 | private clipboard: Map | null; 60 | // We keep track of the file operation so we can use it when pasting files 61 | // See copy-files-to-clipboard and cut-files-to-clipboard 62 | private fileOperation: FileOperation = FileOperation.COPY; 63 | 64 | /** 65 | * Helper function to check (checkFunction) if a command (operation) can 66 | * run. 67 | */ 68 | _checkCallback( 69 | checking: boolean, 70 | checkFunction: () => boolean, 71 | operation: (...args: any[]) => any, 72 | ...args: any[] 73 | ): boolean { 74 | if (!checkFunction()) return false; 75 | if (!checking) operation(...args); 76 | return true; 77 | } 78 | 79 | /** 80 | * Helper function to check (checkFunction) if a command (operation) can 81 | * run asynchronously. 82 | */ 83 | _checkAsyncCallback( 84 | checking: boolean, 85 | checkFunction: () => boolean, 86 | operation: (...args: any[]) => Promise, 87 | ...args: any[] 88 | ): boolean { 89 | if (!checkFunction()) return false; 90 | (async () => { 91 | if (!checking) await operation(...args); 92 | })(); 93 | return true; 94 | } 95 | 96 | /** 97 | * Helper function that checks if there is a file explorer with an active 98 | * file or folder, if so a command (operation) can run. 99 | */ 100 | isActiveFileOrFolderCallback( 101 | checking: boolean, 102 | operation: (...args: any[]) => any, 103 | ...args: any[] 104 | ): boolean { 105 | const func = this.fm.isActiveFileOrFolderAvailable.bind(this.fm); 106 | return this._checkCallback(checking, func, operation, ...args); 107 | } 108 | 109 | /** 110 | * Helper function that checks if there is a file explorer with an active 111 | * file or folder, if so a command (operation) can run asynchronously 112 | */ 113 | isActiveFileOrFolderAsyncCallback( 114 | checking: boolean, 115 | operation: (...args: any[]) => Promise, 116 | ...args: any[] 117 | ): boolean { 118 | const func = this.fm.isActiveFileOrFolderAvailable.bind(this.fm); 119 | return this._checkAsyncCallback(checking, func, operation, ...args); 120 | } 121 | 122 | /** 123 | * Helper function that checks if there is an active note, 124 | * if so a command (operation) can run. 125 | */ 126 | isActiveNoteCallback( 127 | checking: boolean, 128 | operation: (...args: any[]) => any, 129 | ...args: any[] 130 | ): boolean { 131 | const func = () => this.app.workspace.getActiveFile() instanceof TFile; 132 | return this._checkCallback(checking, func, operation, ...args); 133 | } 134 | 135 | /** 136 | * Helper function that checks if there is an active note, 137 | * if so a command (operation) can run asynchronously. 138 | */ 139 | isActiveNoteAsyncCallback( 140 | checking: boolean, 141 | operation: (...args: any[]) => Promise, 142 | ...args: any[] 143 | ): boolean { 144 | const func = () => this.app.workspace.getActiveFile() instanceof TFile; 145 | return this._checkAsyncCallback(checking, func, operation, ...args); 146 | } 147 | 148 | /** 149 | * Helper function that checks if there is a file explorer active, 150 | * if so a command (operation) can run. 151 | */ 152 | isFileExplorerActiveCallback( 153 | checking: boolean, 154 | operation: (...args: any[]) => any, 155 | ...args: any[] 156 | ): boolean { 157 | const func = this.fm.isFileExplorerActive.bind(this.fm); 158 | return this._checkCallback(checking, func, operation, ...args); 159 | } 160 | 161 | /** 162 | * Helper function that checks if there is a file explorer active, 163 | * if so a command (operation) can run asynchronously. 164 | */ 165 | isFileExplorerActiveAsyncCallback( 166 | checking: boolean, 167 | operation: (...args: any[]) => Promise, 168 | ...args: any[] 169 | ): boolean { 170 | const func = this.fm.isFileExplorerActive.bind(this.fm); 171 | return this._checkAsyncCallback(checking, func, operation, ...args); 172 | } 173 | 174 | /** 175 | * Helper function that checks if there is a file explorer available, 176 | * if so a command (operation) can run. 177 | */ 178 | isFileExplorerAvailableCallback( 179 | checking: boolean, 180 | operation: (...args: any[]) => any, 181 | ...args: any[] 182 | ): boolean { 183 | const func = this.fm.isFileExplorerAvailable.bind(this.fm); 184 | return this._checkCallback(checking, func, operation, ...args); 185 | } 186 | 187 | /** 188 | * Helper function that checks if there is a file explorer available, 189 | * if so a command (operation) can run asynchronously. 190 | */ 191 | isFileExplorerAvailableAsyncCallback( 192 | checking: boolean, 193 | operation: (...args: any[]) => Promise, 194 | ...args: any[] 195 | ): boolean { 196 | const func = this.fm.isFileExplorerAvailable.bind(this.fm); 197 | return this._checkAsyncCallback(checking, func, operation, ...args); 198 | } 199 | 200 | /** 201 | * Show the number of selected files/folders in the status bar. 202 | */ 203 | showSelectedInStatusBar() { 204 | this.selectedStatusBar.empty(); 205 | const selected = this.fm.getSelectedFilesOrFoldersPath(false, false); 206 | if (!this.settings.showSelectionStatusBar || !selected) return; 207 | 208 | let numFiles = 0; 209 | let numFolders = 0; 210 | for (const path of selected) { 211 | const fileOrFolder: TAbstractFile | null = 212 | this.app.vault.getAbstractFileByPath(path); 213 | if (fileOrFolder instanceof TFile) numFiles++; 214 | else if (fileOrFolder instanceof TFolder) numFolders++; 215 | } 216 | if (numFiles === 0 && numFolders === 0) return; 217 | 218 | const filesTxt = numFiles > 0 ? `${numFiles} 📄` : ""; 219 | const foldersTxt = numFolders > 0 ? `${numFolders} 📂` : ""; 220 | const msg = 221 | numFiles > 0 && numFolders > 0 222 | ? `(Selected: ${filesTxt} and ${foldersTxt})` 223 | : `(Selected: ${filesTxt}${foldersTxt})`; 224 | this.selectedStatusBar.createEl("span", { text: msg }); 225 | } 226 | 227 | /** 228 | * Show the number of files and folders in the clipboard in the status bar. 229 | */ 230 | showClipboardInStatusBar() { 231 | this.clipboardStatusBar.empty(); 232 | if (!this.settings.showClipboardStatusBar || !this.clipboard) return; 233 | 234 | let numFiles = 0; 235 | let numFolders = 0; 236 | for (const path of this.clipboard.keys()) { 237 | const fileOrFolder: TAbstractFile | null = 238 | this.app.vault.getAbstractFileByPath(path); 239 | if (fileOrFolder instanceof TFile) numFiles++; 240 | else if (fileOrFolder instanceof TFolder) numFolders++; 241 | } 242 | if (numFiles === 0 && numFolders === 0) return; 243 | 244 | const filesTxt = numFiles > 0 ? `${numFiles} 📄` : ""; 245 | const foldersTxt = numFolders > 0 ? `${numFolders} 📂` : ""; 246 | const prefix = 247 | this.fileOperation === FileOperation.COPY 248 | ? "Copied to clipboard:" 249 | : "Cut to clipboard:"; 250 | const msg = 251 | numFiles > 0 && numFolders > 0 252 | ? `(${prefix} ${filesTxt} and ${foldersTxt})` 253 | : `(${prefix} ${filesTxt}${foldersTxt})`; 254 | this.clipboardStatusBar.createEl("span", { text: msg }); 255 | } 256 | 257 | /** 258 | * Creates a new OpenWithCmd object with the given name, command and arguments. 259 | * The callback function of the command will first check if the file explorer 260 | * has an active file or folder, and then open the file with the given command 261 | * and arguments. 262 | */ 263 | createOpenWithCmd(name: string, cmd: string, args: string): OpenWithCmd { 264 | return { 265 | id: "open-with-" + name.toLowerCase(), 266 | name: "Open with " + name, 267 | checkCallback: (checking: boolean): boolean => { 268 | let file: TAbstractFile | null = this.fm.isFileExplorerActive() 269 | ? this.fm.getActiveFileOrFolder() 270 | : this.app.workspace.getActiveFile(); 271 | if (!file) return false; 272 | if (checking) return true; 273 | // All went well and not checking, so open the file. 274 | (async () => await openFile(file, cmd, args))(); 275 | return true; 276 | }, 277 | }; 278 | } 279 | 280 | /** 281 | * Monkey patch fileExplorer.tree.selectItem and fileExplorer.tree.deselectItem to 282 | * update selected files/folders in status bar. 283 | */ 284 | patchFileExplorerSelectionFunctions() { 285 | const fileExplorer = this.fm.getFileExplorer(); 286 | if (!fileExplorer) return; 287 | 288 | const showSelectedInStatusBarFunc = 289 | this.showSelectedInStatusBar.bind(this); 290 | const tree = fileExplorer.tree as unknown as MockTree; 291 | 292 | // Patch tree.selectItem to update selected files/folders in status bar. 293 | const oldSelectItemFunc = tree.selectItem.bind(fileExplorer.tree); 294 | tree.selectItem = function (e: FileOrFolderItem) { 295 | const res = oldSelectItemFunc(e); 296 | showSelectedInStatusBarFunc(); 297 | return res; 298 | }; 299 | 300 | // Patch tree.deselectItem to update selected files/folders in status bar. 301 | const oldDeselectItemFunc = tree.deselectItem.bind(fileExplorer.tree); 302 | tree.deselectItem = function (e: FileOrFolderItem) { 303 | const res = oldDeselectItemFunc(e); 304 | showSelectedInStatusBarFunc(); 305 | return res; 306 | }; 307 | } 308 | 309 | /** 310 | * The main plugin function that is called when the plugin is loaded. 311 | */ 312 | async onload() { 313 | // Load the settings. 314 | this.settings = DEFAULT_SETTINGS; 315 | await this.loadSettings(); 316 | 317 | // Monkey patch fileExplorer selection/deselection functions to update status 318 | // bar on selection/deselection of files/folders. 319 | this.app.workspace.onLayoutReady(() => { 320 | this.patchFileExplorerSelectionFunctions(); 321 | }); 322 | 323 | this.registerStatusBarItems(); 324 | this.registerVaultEvents(); 325 | this.registerMarkdownCodeBlockProcessors(); 326 | this.registerCommands(); 327 | this.registerContextMenus(); 328 | this.addSettingTab(new FileManagerSettingTab(this.app, this)); 329 | } 330 | 331 | onunload() {} 332 | 333 | /** 334 | * Returns the conflict resolution method to use when a file conflict occurs. 335 | */ 336 | async getFileConflictResolutionMethod( 337 | path: string 338 | ): Promise<[FileConflictResolution, boolean]> { 339 | const option = this.settings.conflictResolutionMethod; 340 | if (option === FileConflictOption.PROMPT) 341 | return await new ConflictModal(this.app, path).openAndWait(); 342 | return [option as FileConflictResolution, true]; 343 | } 344 | 345 | async loadSettings() { 346 | this.settings = { ...this.settings, ...(await this.loadData()) }; 347 | } 348 | 349 | async saveSettings() { 350 | await this.saveData(this.settings); 351 | 352 | // Update the status bar to reflect the new settings. 353 | this.showClipboardInStatusBar(); 354 | this.showSelectedInStatusBar(); 355 | } 356 | 357 | /** 358 | * Register all the status bar items for the plugin. 359 | */ 360 | registerStatusBarItems() { 361 | // Create status bar items for selected files/folders and clipboard. 362 | this.selectedStatusBar = this.addStatusBarItem(); 363 | this.clipboardStatusBar = this.addStatusBarItem(); 364 | } 365 | 366 | /** 367 | * Register all the vault events for the plugin. 368 | */ 369 | registerVaultEvents() { 370 | // Update selected files/folders and clipboard in status bar whenever 371 | // there is a delete event. 372 | this.app.vault.on("delete", (file: TAbstractFile) => { 373 | // If the file is in the clipboard, remove it from the clipboard 374 | // and update the status bar. 375 | if (this.clipboard && this.clipboard.has(file.path)) { 376 | this.clipboard.delete(file.path); 377 | this.showClipboardInStatusBar(); 378 | } 379 | // Always update the selected files/folders in the status bar. 380 | this.showSelectedInStatusBar(); 381 | }); 382 | } 383 | 384 | /** 385 | * Register all the markdown code block processors for the plugin. 386 | **/ 387 | registerMarkdownCodeBlockProcessors() { 388 | this.registerMarkdownCodeBlockProcessor( 389 | "pathexplorer", 390 | async (source, el, ctx) => this.pe.processCodeBlock(source, el) 391 | ); 392 | } 393 | 394 | /** 395 | * Register all the context menus for the plugin. 396 | */ 397 | registerContextMenus() { 398 | // Create file-menu Open With... items from saved settings. 399 | this.registerEvent( 400 | this.app.workspace.on("file-menu", (menu: Menu, file: TFile) => { 401 | this.settings.apps.forEach((app) => { 402 | if (!app.showInMenu) return; 403 | menu.addItem((item) => { 404 | item.setTitle(`Open with ${app.name}`) 405 | .setIcon(app.icon) 406 | .onClick( 407 | async () => 408 | await openFile(file, app.cmd, app.args) 409 | ); 410 | }); 411 | }); 412 | }) 413 | ); 414 | 415 | // Create url-menu Open With... items from saved settings. 416 | this.registerEvent( 417 | this.app.workspace.on("url-menu", (menu, url) => { 418 | if (!url.startsWith(HREF_FILE_PREFIX)) return; 419 | const path = decodeURI(url.replace(HREF_FILE_PREFIX, "")); 420 | const apps = this.settings.apps; 421 | const patterns = this.settings.patterns; 422 | for (const pattern of patterns) { 423 | const app = PathExplorer.matchPattern( 424 | pattern, 425 | path, 426 | fs.statSync(path).isDirectory(), 427 | apps 428 | ); 429 | if (!app) continue; 430 | 431 | menu.addItem((item) => { 432 | item.setTitle(`Open with ${app.name}`) 433 | .setIcon(app.icon) 434 | .onClick(async () => { 435 | await openFile(path, app.cmd, app.args); 436 | }); 437 | }); 438 | } 439 | }) 440 | ); 441 | } 442 | 443 | /** 444 | * Register all the commands for the plugin. 445 | */ 446 | registerCommands() { 447 | this.addCommand({ 448 | id: "create-subfolder", 449 | name: "Create a subfolder within the focused or active file/folder", 450 | checkCallback: (checking: boolean) => 451 | this.isActiveFileOrFolderAsyncCallback( 452 | checking, 453 | this.fm.createSubFolder.bind(this.fm) 454 | ), 455 | }); 456 | this.addCommand({ 457 | id: "create-folder", 458 | name: "Create a folder as sibling of the focused or active file/folder", 459 | checkCallback: (checking: boolean) => 460 | this.isActiveFileOrFolderAsyncCallback( 461 | checking, 462 | this.fm.createFolder.bind(this.fm) 463 | ), 464 | }); 465 | this.addCommand({ 466 | id: "create-note", 467 | name: "Create a note within the focused or active folder", 468 | checkCallback: (checking: boolean) => 469 | this.isActiveFileOrFolderAsyncCallback( 470 | checking, 471 | this.fm.createNote.bind(this.fm) 472 | ), 473 | }); 474 | this.addCommand({ 475 | id: "duplicate-file", 476 | name: "Duplicate focused or active file/folder", 477 | checkCallback: (checking: boolean) => 478 | this.isActiveFileOrFolderAsyncCallback( 479 | checking, 480 | this.fm.duplicateFile.bind(this.fm) 481 | ), 482 | }); 483 | this.addCommand({ 484 | id: "copy-files-to-clipboard", 485 | name: "Copy selected files/folders to clipboard", 486 | checkCallback: (checking: boolean) => 487 | this.isActiveFileOrFolderCallback(checking, () => { 488 | this.clipboard = this.fm.getSelectedPathToNameMap(true); 489 | this.fileOperation = FileOperation.COPY; 490 | new Notice("Files copied to clipboard"); 491 | this.showClipboardInStatusBar(); 492 | }), 493 | }); 494 | this.addCommand({ 495 | id: "cut-files-to-clipboard", 496 | name: "Cut selected files/folders to clipboard", 497 | checkCallback: (checking: boolean) => 498 | this.isActiveFileOrFolderCallback(checking, () => { 499 | this.clipboard = this.fm.getSelectedPathToNameMap(true); 500 | this.fileOperation = FileOperation.MOVE; 501 | new Notice("Files cut to clipboard"); 502 | this.showClipboardInStatusBar(); 503 | }), 504 | }); 505 | this.addCommand({ 506 | id: "paste-files-from-clipboard", 507 | name: "Paste files/folders from clipboard to selected folder", 508 | checkCallback: (checking: boolean) => 509 | this.isActiveFileOrFolderAsyncCallback(checking, async () => { 510 | if (this.clipboard) { 511 | const operation = 512 | this.fileOperation === FileOperation.COPY 513 | ? this.fm.copyFiles.bind(this.fm) 514 | : this.fm.moveFiles.bind(this.fm); 515 | const stats = await operation( 516 | this.fm.getActiveFileOrFolder()!.path, 517 | this.clipboard 518 | ); 519 | // Clear the clipboard after moving files remain in 520 | // clipboard if the operation is copy. 521 | if (this.fileOperation === FileOperation.MOVE) { 522 | this.clipboard = null; 523 | this.showClipboardInStatusBar(); 524 | } 525 | 526 | if (this.settings.showCopyMoveStats) 527 | new Notice( 528 | `${this.fileOperation} stats:\n\n` + 529 | statsToString(stats) 530 | ); 531 | } 532 | }), 533 | }); 534 | this.addCommand({ 535 | id: "clear-clipboard", 536 | name: "Clear clipboard", 537 | checkCallback: (checking: boolean) => { 538 | if (!this.clipboard) return false; 539 | if (checking) return true; 540 | this.clipboard = null; 541 | this.showClipboardInStatusBar(); 542 | }, 543 | }); 544 | this.addCommand({ 545 | id: "move-files", 546 | name: "Move selected files/folders to a new folder", 547 | checkCallback: (checking: boolean) => 548 | this.isActiveFileOrFolderCallback(checking, () => { 549 | new SuggestPathModal( 550 | this.app, 551 | this.app.vault.getAllFolders(true), 552 | async (path: string) => { 553 | const stats = await this.fm.moveFiles(path); 554 | if (this.settings.showCopyMoveStats) 555 | new Notice( 556 | "Move stats:\n\n" + statsToString(stats) 557 | ); 558 | } 559 | ).open(); 560 | }), 561 | }); 562 | this.addCommand({ 563 | id: "move-note", 564 | name: "Move active note to a new folder", 565 | checkCallback: (checking: boolean) => 566 | this.isActiveNoteCallback(checking, () => { 567 | new SuggestPathModal( 568 | this.app, 569 | this.app.vault.getAllFolders(true), 570 | async (path: string) => { 571 | const activeFile = 572 | this.app.workspace.getActiveFile(); 573 | if (!activeFile) return; 574 | 575 | // Move note to new folder. 576 | const note = new Map(); 577 | note.set(activeFile.path, activeFile.name); 578 | const stats = await this.fm.moveFiles(path, note); 579 | if (this.settings.showCopyMoveStats) 580 | new Notice( 581 | "Move stats:\n\n" + statsToString(stats) 582 | ); 583 | } 584 | ).open(); 585 | }), 586 | }); 587 | this.addCommand({ 588 | id: "copy-files", 589 | name: "Copy selected files/folders to a new folder", 590 | checkCallback: (checking: boolean) => 591 | this.isActiveFileOrFolderCallback(checking, () => { 592 | new SuggestPathModal( 593 | this.app, 594 | this.app.vault.getAllFolders(true), 595 | async (path: string) => { 596 | const stats = await this.fm.copyFiles(path); 597 | if (this.settings.showCopyMoveStats) 598 | new Notice( 599 | "Copy stats:\n\n" + statsToString(stats) 600 | ); 601 | } 602 | ).open(); 603 | }), 604 | }); 605 | this.addCommand({ 606 | id: "select-all", 607 | name: "Select all siblings and children of the focused or active file/folder", 608 | checkCallback: (checking: boolean) => 609 | this.isFileExplorerActiveCallback(checking, () => { 610 | this.fm.selectAll(true); 611 | this.showSelectedInStatusBar(); 612 | }), 613 | }); 614 | this.addCommand({ 615 | id: "toggle-select", 616 | name: "Toggle selection of the focused or active file/folder", 617 | checkCallback: (checking: boolean) => 618 | this.isFileExplorerActiveCallback(checking, () => { 619 | this.fm.toggleSelect(); 620 | this.showSelectedInStatusBar(); 621 | }), 622 | }); 623 | this.addCommand({ 624 | id: "deselect-all", 625 | name: "Clear selection", 626 | checkCallback: (checking: boolean) => 627 | this.isFileExplorerActiveCallback(checking, () => { 628 | this.fm.deselectAll(); 629 | this.showSelectedInStatusBar(); 630 | }), 631 | }); 632 | this.addCommand({ 633 | id: "invert-selection", 634 | name: "Invert selection", 635 | checkCallback: (checking: boolean) => 636 | this.isFileExplorerActiveCallback(checking, () => { 637 | this.fm.invertSelection(); 638 | this.showSelectedInStatusBar(); 639 | }), 640 | }); 641 | this.addCommand({ 642 | id: "rename", 643 | name: "Rename focused or active file/folder", 644 | checkCallback: (checking: boolean) => 645 | this.isFileExplorerActiveCallback( 646 | checking, 647 | this.fm.renameFile.bind(this.fm) 648 | ), 649 | }); 650 | this.addCommand({ 651 | id: "go-to-in-file-explorer", 652 | name: "Go to file or folder in file explorer", 653 | checkCallback: (checking: boolean) => 654 | this.isFileExplorerAvailableCallback(checking, () => { 655 | const allFilesAndFolders: TAbstractFile[] = [ 656 | ...this.app.vault.getAllFolders(true), 657 | ...this.app.vault.getFiles(), 658 | ]; 659 | // If available, add the parent folder of the active file 660 | // to the beginning of the list. 661 | const activeFile = this.app.workspace.getActiveFile(); 662 | if (activeFile && activeFile.parent instanceof TFolder) 663 | allFilesAndFolders.unshift(activeFile.parent); 664 | 665 | new SuggestPathModal( 666 | this.app, 667 | allFilesAndFolders, 668 | async (path: string) => { 669 | this.fm.focusPath(path); 670 | } 671 | ).open(); 672 | }), 673 | }); 674 | 675 | this.addCommand({ 676 | id: "go-to-folder_in-file-explorer", 677 | name: "Go to folder in file explorer", 678 | checkCallback: (checking: boolean) => 679 | this.isFileExplorerAvailableCallback(checking, () => { 680 | const allFolders = this.app.vault.getAllFolders(true); 681 | // If available, add the parent folder of the active file 682 | // to the beginning of the list. 683 | const activeFile = this.app.workspace.getActiveFile(); 684 | if (activeFile && activeFile.parent instanceof TFolder) 685 | allFolders.unshift(activeFile.parent); 686 | 687 | new SuggestPathModal( 688 | this.app, 689 | allFolders, 690 | async (path: string) => { 691 | this.fm.focusPath(path); 692 | } 693 | ).open(); 694 | }), 695 | }); 696 | 697 | // Create dynamic Open With commands from saved settings. 698 | this.settings.apps.forEach((app) => { 699 | this.addCommand( 700 | this.createOpenWithCmd(app.name, app.cmd, app.args) 701 | ); 702 | }); 703 | } 704 | } 705 | -------------------------------------------------------------------------------- /src/obsidian-internals.d.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TAbstractFile, 3 | TFile, 4 | TFolder, 5 | View, 6 | App, 7 | Vault, 8 | Workspace, 9 | } from "obsidian"; 10 | 11 | declare module "obsidian-internals" { 12 | export class FileExplorer extends View { 13 | fileItems: { [key: string]: FileOrFolderItem }; 14 | files: WeakMap; 15 | activeDom: FileOrFolderItem; 16 | tree: TreeItem; 17 | startRenameFile: (file: TAbstractFile) => Promise; 18 | getViewType(): string; 19 | getDisplayText(): string; 20 | } 21 | 22 | export type FileOrFolderItem = FolderItem | FileItem; 23 | 24 | export interface FileItem { 25 | el: HTMLDivElement; 26 | file: TFile; 27 | fileExplorer: FileExplorer; 28 | info: any; 29 | parent: FolderItem; 30 | selfEl: HTMLDivElement; 31 | innerEl: HTMLDivElement; 32 | } 33 | 34 | export interface FolderItem { 35 | el: HTMLDivElement; 36 | fileExplorer: FileExplorer; 37 | parent: FolderItem; 38 | info: any; 39 | selfEl: HTMLDivElement; 40 | innerEl: HTMLDivElement; 41 | file: TFolder; 42 | children: FileOrFolderItem[]; 43 | childrenEl: HTMLDivElement; 44 | collapseIndicatorEl: HTMLDivElement; 45 | collapsed: boolean; 46 | setCollapsed: (collapsed: boolean) => void; 47 | pusherEl: HTMLDivElement; 48 | } 49 | 50 | export interface TreeItem { 51 | focusedItem: FileOrFolderItem; 52 | setFocusedItem: (item: FileOrFolderItem, moveViewport: boolean) => void; 53 | selectedDoms: Set; 54 | } 55 | 56 | export class MockApp extends App { 57 | nextFrame: (callback: () => void) => void; 58 | } 59 | 60 | export class MockVault extends Vault { 61 | exists: (path: string) => Promise; 62 | } 63 | 64 | export class MockWorkspace extends Workspace {} 65 | 66 | export class MockTree { 67 | clearSelectedDoms: () => void; 68 | selectItem: (item: FileOrFolderItem) => void; 69 | deselectItem: (item: FileOrFolderItem) => void; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/open_with_cmd.ts: -------------------------------------------------------------------------------- 1 | import { getAbsolutePathOfFile } from "file_manager"; 2 | import { TAbstractFile, TFile, TFolder } from "obsidian"; 3 | import open from "open"; 4 | import path from "path"; 5 | import { promises as fsa } from "fs"; 6 | 7 | /** 8 | * It stores all the information to create a obsidian command with a check 9 | * callback. It will be used to create dynamic commands for all the apps 10 | * defined by the user to open files/folders with. 11 | */ 12 | export interface OpenWithCmd { 13 | id: string; 14 | name: string; 15 | checkCallback: (checking: boolean) => void; 16 | } 17 | 18 | // Variables to be replaced in the command arguments. 19 | export const VAR_FILE_PATH = "{{file_path}}"; 20 | export const VAR_FOLDER_PATH = "{{folder_path}}"; 21 | export const VAR_FILE_NAME = "{{file_name}}"; 22 | export const VAR_FOLDER_NAME = "{{folder_name}}"; 23 | 24 | // URL Schema separator. 25 | const URL_SCHEMA = "://"; 26 | 27 | // All the variables that can be replaced in the command arguments. 28 | export const ALL_VARS = [ 29 | VAR_FILE_NAME, 30 | VAR_FILE_PATH, 31 | VAR_FOLDER_NAME, 32 | VAR_FOLDER_PATH, 33 | ]; 34 | 35 | function _replaceVar( 36 | text: string, 37 | filePath: string, 38 | folderPath: string, 39 | fileName: string, 40 | folderName: string 41 | ) { 42 | text = text.includes(VAR_FILE_PATH) 43 | ? text.replace(VAR_FILE_PATH, filePath) 44 | : text; 45 | text = text.includes(VAR_FOLDER_PATH) 46 | ? text.replace(VAR_FOLDER_PATH, folderPath) 47 | : text; 48 | text = text.includes(VAR_FILE_NAME) 49 | ? text.replace(VAR_FILE_NAME, fileName) 50 | : text; 51 | text = text.includes(VAR_FOLDER_NAME) 52 | ? text.replace(VAR_FOLDER_NAME, folderName) 53 | : text; 54 | return text; 55 | } 56 | 57 | /** 58 | * Opens the file with the given command and arguments. 59 | * @param cmd The command to open the file with. 60 | * @param args The arguments to pass to the command. 61 | * @param filePath The path of the file to open. 62 | * @param folderPath The path of the folder containing the file. 63 | * @param fileName The name of the file. 64 | * @param folderName The name of the folder containing the file. 65 | */ 66 | async function _openPath( 67 | cmd: string, 68 | args: string, 69 | filePath: string, 70 | folderPath: string, 71 | fileName: string, 72 | folderName: string 73 | ) { 74 | // From version 1.3.1 the command could also be an App URL Schema (the 75 | // command contains "://"). In this case variables could appear in the URL 76 | // Schema, so we replace them and open it with the browser. Arguments are 77 | // not supported in this case. 78 | if (cmd.includes(URL_SCHEMA)) { 79 | cmd = _replaceVar(cmd, filePath, folderPath, fileName, folderName); 80 | window.open(cmd); 81 | return; 82 | } 83 | 84 | // Create the app object that will be passed to the open function. 85 | // It contains the "name" of the app and the "arguments" to pass to it. 86 | const app: { [key: string]: any } = {}; 87 | app.name = cmd; 88 | args = args.trim(); 89 | if (args) { 90 | app.arguments = args.split(","); 91 | app.arguments.forEach((arg: string, index: number) => { 92 | arg = arg.trim(); 93 | arg = _replaceVar(arg, filePath, folderPath, fileName, folderName); 94 | app.arguments[index] = arg; 95 | }); 96 | } 97 | //@ts-ignore 98 | await open("", { app }); 99 | } 100 | 101 | /** 102 | * Opens the file with the given command and arguments. 103 | * @param fileOrFolder The file/folder to open. 104 | * @param cmd The command to open the file with. 105 | * @param args The arguments to pass to the command. 106 | */ 107 | async function _openStringFile( 108 | fileOrFolder: string, 109 | cmd: string, 110 | args: string 111 | ) { 112 | const stats = await fsa.stat(fileOrFolder); 113 | const filePath = fileOrFolder; 114 | const folderPath = stats.isDirectory() 115 | ? fileOrFolder 116 | : path.dirname(fileOrFolder); 117 | const fileName = path.basename(filePath); 118 | const folderName = path.basename(folderPath); 119 | 120 | await _openPath(cmd, args, filePath, folderPath, fileName, folderName); 121 | } 122 | 123 | /** 124 | * Opens the file with the given command and arguments. 125 | * @param fileOrFolder The file/folder to open. 126 | * @param cmd The command to open the file with. 127 | * @param args The arguments to pass to the command. 128 | */ 129 | async function _openTAbstractFile( 130 | fileOrFolder: TAbstractFile, 131 | cmd: string, 132 | args: string 133 | ) { 134 | const folder: TFolder = 135 | fileOrFolder instanceof TFolder ? fileOrFolder : fileOrFolder.parent!; 136 | const filePath: string = getAbsolutePathOfFile(fileOrFolder); 137 | const folderPath: string = getAbsolutePathOfFile(folder); 138 | const fileName: string = fileOrFolder.name; 139 | const folderName: string = folder.name; 140 | await _openPath(cmd, args, filePath, folderPath, fileName, folderName); 141 | } 142 | 143 | /** 144 | * Opens the file with the given command and arguments. 145 | * @param fileOrFolder The file/folder to open. It can be a string path or an 146 | * Obsidian's TAbstractFile. 147 | * @param cmd The command to open the file with. 148 | * @param args The arguments to pass to the command. It can contain the 149 | * variables VAR_FILE_PATH, VAR_FOLDER_PATH, VAR_FILE_NAME and VAR_FOLDER_NAME 150 | * that will be replaced by the corresponding values of the file. 151 | */ 152 | export async function openFile( 153 | fileOrFolder: TAbstractFile | string, 154 | cmd: string, 155 | args: string 156 | ) { 157 | if (typeof fileOrFolder === "string") { 158 | await _openStringFile(fileOrFolder, cmd, args); 159 | } else { 160 | await _openTAbstractFile(fileOrFolder, cmd, args); 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/path_explorer.ts: -------------------------------------------------------------------------------- 1 | import { MockWorkspace } from "obsidian-internals"; 2 | import { MarkdownView, ButtonComponent, setIcon } from "obsidian"; 3 | import FileManagerPlugin from "main"; 4 | import { getVaultAbsolutePath } from "file_manager"; 5 | import { openFile } from "open_with_cmd"; 6 | import { promises as fsa } from "fs"; 7 | import ignore, { Ignore } from "ignore"; 8 | import path from "path"; 9 | import { 10 | PathPattern, 11 | AppCmd, 12 | APPLY_TO_FILES, 13 | APPLY_TO_FOLDERS, 14 | } from "settings"; 15 | 16 | // TreeNode interface for the directory tree structure. 17 | interface TreeNode { 18 | // File/folder name. 19 | name: string; 20 | // Full path to the file/folder. 21 | path: string; 22 | // True if it's a directory, false if it's a file. 23 | isDirectory: boolean; 24 | // Children nodes if it's a directory. 25 | children?: TreeNode[]; 26 | } 27 | 28 | // Constants for the pathexplorer codeblock. 29 | const MAX_DEPTH = 3; 30 | const MAX_FILES = 100; 31 | // Options for the flat parameter. 32 | const HIDE_FILES_PARAM = "hide-files"; 33 | const HIDE_FOLDERS_PARAM = "hide-folders"; 34 | // Options for the showAbsolutePath parameter. 35 | const ALL_PATHS = "all"; 36 | const ROOT_PATH = "root"; 37 | const NO_PATH = "none"; 38 | 39 | // Configuration for the pathexplorer codeblock. 40 | interface PathExplorerConfig { 41 | // Required. The paths to explore (1 or many). 42 | paths: string[]; 43 | // Optional. Patterns to ignore files/folders. Follows .gitignore syntax. 44 | ignorePatterns: string[]; 45 | // Maximum depth to explore. 46 | maxDepth: number; 47 | // Maximum number of files to show. 48 | maxFiles: number; 49 | // If true, include the root directory in the tree. 50 | includeRoot: boolean; 51 | // If true, show all files/folders in a flat structure. 52 | flat: boolean; 53 | // If true, hide folders in the tree (only for flat structure). 54 | hideFolders: boolean; 55 | // If true, hide files in the tree (only for flat structure). 56 | hideFiles: boolean; 57 | // If true, hide Open with.. icons. 58 | hideIcons: boolean; 59 | // Show absolute path in the tree. 60 | showAbsolutePath: typeof ALL_PATHS | typeof ROOT_PATH | typeof NO_PATH; 61 | } 62 | 63 | // Default configuration for the pathexplorer codeblock. 64 | const DEFAULT_CONFIG: PathExplorerConfig = { 65 | paths: [], 66 | ignorePatterns: [], 67 | maxDepth: MAX_DEPTH, 68 | maxFiles: MAX_FILES, 69 | includeRoot: false, 70 | flat: false, 71 | hideFiles: false, 72 | hideFolders: false, 73 | hideIcons: false, 74 | showAbsolutePath: NO_PATH, 75 | }; 76 | 77 | // If a line in a pathexplorer codeblock starts with COMMENT_TOKEN, it's ignored. 78 | const COMMENT_TOKEN = "#"; 79 | 80 | // Parameters key/value separator for the pathexplorer codeblock configuration. 81 | const PARAM_SEP = " "; 82 | 83 | // Prefix for local file links. 84 | export const HREF_FILE_PREFIX = "file:///"; 85 | 86 | // Parameters for the pathexplorer codeblock configuration. 87 | const PATH_PARAM = "path"; 88 | const IGNORE_PARAM = "ignore"; 89 | const MAX_DEPTH_PARAM = "max-depth"; 90 | const MAX_FILES_PARAM = "max-files"; 91 | const INCLUDE_ROOT_PARAM = "include-root"; 92 | const FLAT_PARAM = "flat"; 93 | const HIDE_ICONS_PARAM = "hide-icons"; 94 | const ABSOLUTE_PATH = "absolute-path"; 95 | 96 | /** 97 | * Exception class for PathExplorer. Used if any error comes up when parsing 98 | * configuration. 99 | */ 100 | class PathExplorerException extends Error { 101 | constructor(message: string) { 102 | super(message); // Pass the message to the parent Error class 103 | this.name = "PathExplorerException"; // Set a custom name for the error 104 | Object.setPrototypeOf(this, PathExplorerException.prototype); // Maintain prototype chain 105 | } 106 | } 107 | 108 | /** 109 | * PathExplorer class process pathexplorer codeblocks and generates 110 | * a view of the directory structure. 111 | * 112 | */ 113 | export class PathExplorer { 114 | private plugin: FileManagerPlugin; 115 | private workspace: MockWorkspace; 116 | 117 | /** 118 | * Create a new PathExplorer instance. 119 | * @param plugin The FileManagerPlugin instance from where the PathExplorer 120 | * is created. 121 | */ 122 | constructor(plugin: FileManagerPlugin) { 123 | this.plugin = plugin; 124 | this.workspace = plugin.app.workspace as MockWorkspace; 125 | } 126 | 127 | static matchPattern( 128 | pattern: PathPattern, 129 | path: string, 130 | isDirectory: boolean, 131 | apps: AppCmd[] 132 | ): AppCmd | undefined { 133 | // Check if pattern applies to file/folder. 134 | const notApplies = 135 | (pattern.applyTo === APPLY_TO_FILES && isDirectory) || 136 | (pattern.applyTo === APPLY_TO_FOLDERS && !isDirectory); 137 | if (notApplies) return undefined; 138 | 139 | // Check if the path matches the regex pattern. 140 | const re = new RegExp(pattern.regex); 141 | if (!re.test(path)) return undefined; 142 | 143 | return apps.find((app) => app.name === pattern.appName); 144 | } 145 | 146 | /** 147 | * Process the code block and generate the file explorer. 148 | * @param source The source of the code block. 149 | * @param el The element where to append the generated HTML tree. 150 | */ 151 | async processCodeBlock(source: string, el: HTMLElement) { 152 | try { 153 | // Parse config 154 | const pathExplorer = await this.parseConfig(source); 155 | 156 | // Retrieve directory structure 157 | const tree = await this.buildDirectoryTree(pathExplorer); 158 | 159 | // Create a div element to hold the tree and the refresh button. 160 | const treeEl = el.createDiv({ cls: "path-explorer" }); 161 | new ButtonComponent(treeEl) 162 | .setIcon("rotate-ccw") 163 | .setTooltip("Refresh") 164 | .setClass("path-explorer-refresh-button") 165 | .onClick(() => { 166 | const view = 167 | this.workspace.getActiveViewOfType(MarkdownView); 168 | if (view) view.previewMode.rerender(true); 169 | }); 170 | 171 | // Generate the HTML tree 172 | this.generateTreeHtml(tree, treeEl, pathExplorer); 173 | } catch (error) { 174 | if (error instanceof PathExplorerException) 175 | el.createDiv({ cls: "path-explorer-error" }).createSpan({ 176 | text: `[PathExplorerError⚠️] ${error.message}`, 177 | }); 178 | } 179 | } 180 | 181 | /** 182 | * Parse the configuration of the pathexplorer codeblock from the string 183 | * inside (`source`). 184 | * @param source The string inside the pathexplorer codeblock. 185 | * @throws {PathExplorerException} If the configuration is invalid. 186 | */ 187 | async parseConfig(source: string): Promise { 188 | // Create a new pathExplorer configuration with default values 189 | const pathExplorer: PathExplorerConfig = { 190 | ...DEFAULT_CONFIG, 191 | // Always create a new array to avoid modifying the default array. 192 | paths: [...DEFAULT_CONFIG.paths], 193 | ignorePatterns: [...DEFAULT_CONFIG.ignorePatterns], 194 | }; 195 | source.split("\n").forEach((line) => { 196 | line = line.trim(); 197 | if (!line || line.startsWith(COMMENT_TOKEN)) return; 198 | 199 | // Config parameters are key/[value] pairs separated by a space. 200 | const index = line.indexOf(PARAM_SEP); 201 | const key = index >= 0 ? line.slice(0, index).trim() : line; 202 | const value = index >= 0 ? line.slice(index + 1).trim() : ""; 203 | switch (key.toLowerCase()) { 204 | case PATH_PARAM: 205 | pathExplorer.paths.push(value); 206 | break; 207 | 208 | case IGNORE_PARAM: 209 | pathExplorer.ignorePatterns.push(value); 210 | break; 211 | 212 | case MAX_DEPTH_PARAM: 213 | pathExplorer.maxDepth = parseInt(value); 214 | if (isNaN(pathExplorer.maxDepth)) 215 | throw new PathExplorerException( 216 | `Invalid ${MAX_DEPTH_PARAM} value: ${value}` 217 | ); 218 | break; 219 | 220 | case MAX_FILES_PARAM: 221 | pathExplorer.maxFiles = parseInt(value); 222 | if (isNaN(pathExplorer.maxFiles)) 223 | throw new PathExplorerException( 224 | `Invalid ${MAX_FILES_PARAM} value: ${value}` 225 | ); 226 | break; 227 | 228 | case INCLUDE_ROOT_PARAM: 229 | pathExplorer.includeRoot = true; 230 | if (value) 231 | throw new PathExplorerException( 232 | `No value expected for ${INCLUDE_ROOT_PARAM}.` 233 | ); 234 | break; 235 | 236 | case HIDE_ICONS_PARAM: 237 | pathExplorer.hideIcons = true; 238 | if (value) 239 | throw new PathExplorerException( 240 | `No value expected for ${HIDE_ICONS_PARAM}.` 241 | ); 242 | break; 243 | 244 | case FLAT_PARAM: 245 | pathExplorer.flat = true; 246 | if (value === HIDE_FILES_PARAM) 247 | pathExplorer.hideFiles = true; 248 | else if (value === HIDE_FOLDERS_PARAM) 249 | pathExplorer.hideFolders = true; 250 | else if (value) 251 | throw new PathExplorerException( 252 | `Invalid ${FLAT_PARAM} option. Valid options: [${HIDE_FILES_PARAM}|${HIDE_FOLDERS_PARAM}|]` 253 | ); 254 | break; 255 | 256 | case ABSOLUTE_PATH: 257 | if (value === ALL_PATHS) 258 | pathExplorer.showAbsolutePath = ALL_PATHS; 259 | else if (value === ROOT_PATH) 260 | pathExplorer.showAbsolutePath = ROOT_PATH; 261 | else if (value === NO_PATH) 262 | pathExplorer.showAbsolutePath = NO_PATH; 263 | else 264 | throw new PathExplorerException( 265 | `Invalid ${ABSOLUTE_PATH} option. Valid options: [${ALL_PATHS}|${ROOT_PATH}|${NO_PATH}]` 266 | ); 267 | break; 268 | 269 | default: 270 | throw new PathExplorerException(`Unknown option: ${key}`); 271 | } 272 | }); 273 | // Check if at least one path is provided 274 | if (!pathExplorer.paths || pathExplorer.paths.length === 0) 275 | throw new PathExplorerException( 276 | "Path not provided (examples path /my/path | path ../relative | path c:\\my\\path)." 277 | ); 278 | 279 | for (let i = 0; i < pathExplorer.paths.length; i++) { 280 | // Substitute environment variables in the path. Could be in Unix 281 | // format ($HOME) or Windows format (%USERPROFILE%). 282 | // HOME will be replaced by USERPROFILE if HOME is not defined. 283 | pathExplorer.paths[i] = pathExplorer.paths[i].replace( 284 | /\$([A-Za-z_][A-Za-z0-9_]*)|%([A-Za-z_][A-Za-z0-9_]*)%/g, 285 | (_, unixEnvVar, winEnvVar) => { 286 | const envVar = unixEnvVar || winEnvVar; 287 | if ( 288 | envVar === "HOME" && 289 | !process.env.HOME && 290 | process.env.USERPROFILE 291 | ) 292 | return process.env.USERPROFILE; 293 | 294 | return ( 295 | process.env[envVar] || 296 | `$${unixEnvVar || `%${winEnvVar}%`}` 297 | ); 298 | } 299 | ); 300 | 301 | // If the path is relative, convert it to absolute adding vault path 302 | // and the current file's parent path. 303 | if (!path.isAbsolute(pathExplorer.paths[i])) { 304 | pathExplorer.paths[i] = path.join( 305 | getVaultAbsolutePath(), 306 | this.workspace.getActiveFile()!.parent!.path, 307 | pathExplorer.paths[i] 308 | ); 309 | } 310 | 311 | try { 312 | await fsa.access(pathExplorer.paths[i]); 313 | } catch (error) { 314 | throw new PathExplorerException( 315 | `Path provided does not exist: ${pathExplorer.paths[i]}` 316 | ); 317 | } 318 | } 319 | 320 | return pathExplorer; 321 | } 322 | 323 | /** 324 | * Build a tree structure from `pathExplorer.path`. `pathExplorer` has also 325 | * the configuration for the directory tree generation. 326 | * @param pathExplorer The configuration for the directory tree generation. 327 | * @returns The tree structure of the directory. 328 | */ 329 | async buildDirectoryTree( 330 | pathExplorer: PathExplorerConfig 331 | ): Promise { 332 | /** 333 | * Traverse the directory structure starting from the given `currentPath`. 334 | * The `ig` parameter is an instance of `Ignore` to check if the file/folder 335 | * should be ignored. The `level` parameter is the current depth level in the 336 | * directory structure. The `nFiles` parameter is the number of files processed 337 | * so far. `pathExplorer` has the configuration that will be used to traverse 338 | * the directory structure. 339 | * @param currentPath The current path to traverse. 340 | * @param ig The ignore instance to check if the file/folder should be ignored. 341 | * @param level The current depth level in the directory structure. 342 | * @returns The tree structure of the directory. 343 | */ 344 | async function traverse( 345 | currentPath: string, 346 | rootPath: string, 347 | ig: Ignore, 348 | level: number = 0 349 | ): Promise { 350 | // If level exceeds the max depth, return. 351 | if (level > pathExplorer.maxDepth) return null; 352 | // If the number of files exceeds the limit, return. 353 | if (nFiles >= pathExplorer.maxFiles) return null; 354 | 355 | const isDirectory = (await fsa.stat(currentPath)).isDirectory(); 356 | // If not rootPath, check if the current file/folder should be ignored. 357 | if (rootPath !== currentPath) { 358 | // Relative path to current file/folder from rootPath. 359 | const relative = 360 | path.relative(rootPath, currentPath) + 361 | (isDirectory ? "/" : ""); 362 | const test = ig.test(relative); 363 | if (test.ignored && !test.unignored) return null; 364 | } 365 | nFiles++; 366 | 367 | // Check if the path should be shown as absolute. 368 | let showAbs = false; 369 | if (pathExplorer.showAbsolutePath === ALL_PATHS) showAbs = true; 370 | else if (pathExplorer.showAbsolutePath === ROOT_PATH && level === 0) 371 | showAbs = true; 372 | showAbs = isDirectory ? showAbs : false; 373 | 374 | const node: TreeNode = { 375 | name: showAbs ? currentPath : path.basename(currentPath), 376 | path: currentPath, 377 | isDirectory: isDirectory, 378 | }; 379 | 380 | if (isDirectory) { 381 | // Node is a directory, create children array and traverse it. 382 | node.children = []; 383 | // Read the directory entries and traverse them. 384 | const entries = await fsa.readdir(currentPath, { 385 | withFileTypes: true, 386 | }); 387 | for (const entry of entries) { 388 | const fullPath = path.join(currentPath, entry.name); 389 | const child = await traverse( 390 | fullPath, 391 | rootPath, 392 | ig, 393 | level + 1 394 | ); 395 | if (child) node.children.push(child); 396 | } 397 | } 398 | return node; 399 | } 400 | 401 | // Count of files/folders traversed so far. 402 | let nFiles = 0; 403 | 404 | // Create ignore instance with the ignore patterns. 405 | const ig: Ignore = ignore().add(pathExplorer.ignorePatterns); 406 | 407 | // Traverse the paths in pathExplorer. 408 | const result: TreeNode[] = []; 409 | for (let i = 0; i < pathExplorer.paths.length; i++) { 410 | const rootPath = pathExplorer.paths[i]; 411 | const node = await traverse(rootPath, rootPath, ig); 412 | // If it was ignored, maxFiles was reached or maxDepth was reached 413 | // jump to the next path. 414 | if (!node) continue; 415 | 416 | // Include always files (!node.children). If it's a folder 417 | // include it if includeRoot is true. 418 | if (pathExplorer.includeRoot || !node.children) result.push(node); 419 | else result.push(...node.children); 420 | } 421 | return result; 422 | } 423 | 424 | /** 425 | * Build the HTML tree by traversing the given `tree` and append it to 426 | * the `treeEl` element. `pathExplorer` has the configuration for the HTML 427 | * tree generation. 428 | * @param tree The filesystem tree structure of the directory. 429 | * @param treeEl The element where to append the generated HTML tree. 430 | * @param pathExplorer The configuration for the HTML tree generation. 431 | */ 432 | generateTreeHtml( 433 | tree: TreeNode[], 434 | treeEl: HTMLElement, 435 | pathExplorer: PathExplorerConfig 436 | ) { 437 | // Get the apps and patterns from the settings. 438 | const apps: AppCmd[] = this.plugin.settings.apps; 439 | const patterns: PathPattern[] = this.plugin.settings.patterns; 440 | 441 | /** 442 | * Build the HTML tree by traversing the given `nodes` and append it to 443 | * the `treeEl` element. 444 | * @param nodes The filesystem tree structure of the directory. 445 | * @param treeEl The element where to append the generated HTML tree. 446 | */ 447 | function buildHtml(nodes: TreeNode[], treeEl: HTMLElement) { 448 | /** 449 | * Create a list item for the given `node` and append it to the 450 | * tree element (`treeEl`). The list item will contain the path to 451 | * the file/folder and clickable icons to open the file/folder with 452 | * the specified apps if the path matches corresponding pattern. 453 | */ 454 | function createListItem( 455 | node: TreeNode, 456 | treeEl: HTMLElement 457 | ): HTMLElement { 458 | // Show file/folder as a clickable link. 459 | const li = treeEl.createEl("li"); 460 | li.createSpan({ text: node.isDirectory ? "📂 " : "📄 " }); 461 | li.createEl("a", { 462 | href: `${HREF_FILE_PREFIX}${encodeURI(node.path)}`, 463 | cls: "external-link", 464 | text: node.name, 465 | }); 466 | if (pathExplorer.hideIcons) return li; 467 | 468 | // Check if the node matches any Open with... pattern. 469 | patterns.forEach((pattern) => { 470 | const app = PathExplorer.matchPattern( 471 | pattern, 472 | node.path, 473 | node.isDirectory, 474 | apps 475 | ); 476 | 477 | // Get the app info from the reference in pattern. 478 | if (!app) return; 479 | 480 | // Create clickable icon to open the file/folder with the app. 481 | const span = li.createSpan({ cls: "clickable" }); 482 | setIcon(span, app.icon); 483 | span.addEventListener("click", () => { 484 | openFile(node.path, app.cmd, app.args); 485 | }); 486 | }); 487 | return li; 488 | } 489 | 490 | // Recursively build the HTML tree by traversing the nodes. 491 | for (const node of nodes) { 492 | if (node.isDirectory) { 493 | // Process folders! 494 | let li: HTMLElement | undefined; 495 | 496 | if (!pathExplorer.hideFolders) 497 | li = createListItem(node, treeEl); 498 | 499 | if (node.children && node.children.length > 0) { 500 | // If flat is true no need to create a new ul element. 501 | if (pathExplorer.flat) buildHtml(node.children, treeEl); 502 | // hideFolders can only be true if flat is true, so li is defined. 503 | else buildHtml(node.children, li!.createEl("ul")); 504 | } 505 | } else { 506 | // Process files! 507 | if (!pathExplorer.hideFiles) createListItem(node, treeEl); 508 | } 509 | } 510 | } 511 | 512 | return buildHtml(tree, treeEl.createEl("ul")); 513 | } 514 | } 515 | -------------------------------------------------------------------------------- /src/path_modal.ts: -------------------------------------------------------------------------------- 1 | import { App, FuzzySuggestModal, TAbstractFile } from "obsidian"; 2 | 3 | /** 4 | * A modal that suggests folders to the user. 5 | */ 6 | export class SuggestPathModal extends FuzzySuggestModal { 7 | constructor( 8 | app: App, 9 | // The folders to suggest to the user 10 | private folders: TAbstractFile[], 11 | // The function to execute when the user selects a folder 12 | private toDo: (path: string) => Promise 13 | ) { 14 | super(app); 15 | } 16 | 17 | /** 18 | * Returns the folders to suggest to the user. 19 | */ 20 | getItems(): TAbstractFile[] { 21 | return this.folders; 22 | } 23 | 24 | /** 25 | * Returns the text to display for a folder. 26 | */ 27 | getItemText(folder: TAbstractFile): string { 28 | return folder.path; 29 | } 30 | 31 | /** 32 | * Executes the function toDo when the user selects a folder. 33 | */ 34 | onChooseItem(folder: TAbstractFile, evt: MouseEvent | KeyboardEvent) { 35 | (async () => await this.toDo(folder.path))(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | PluginSettingTab, 4 | Setting, 5 | Notice, 6 | ButtonComponent, 7 | setIcon, 8 | } from "obsidian"; 9 | import { 10 | VAR_FILE_PATH, 11 | VAR_FILE_NAME, 12 | VAR_FOLDER_NAME, 13 | VAR_FOLDER_PATH, 14 | } from "open_with_cmd"; 15 | import FileManagerPlugin from "main"; 16 | import { LucideIconPickerModal } from "icon_modal"; 17 | 18 | import { FileConflictOptionDescription, FileConflictOption } from "conflict"; 19 | 20 | /** 21 | * Stores the information of an application to open files with. 22 | */ 23 | export interface AppCmd { 24 | name: string; 25 | cmd: string; 26 | args: string; 27 | showInMenu: boolean; 28 | icon: string; 29 | } 30 | 31 | export interface PathPattern { 32 | regex: string; 33 | applyTo: string; 34 | appName: string; 35 | icon: string; 36 | } 37 | 38 | interface DropdownOption { 39 | value: string; 40 | text: string; 41 | } 42 | 43 | export const APPLY_TO_FILES = "files"; 44 | export const APPLY_TO_FOLDERS = "folders"; 45 | export const APPLY_TO_FILES_AND_FOLDERS = "files and folders"; 46 | const APPLY_TO: DropdownOption[] = [ 47 | { value: APPLY_TO_FILES_AND_FOLDERS, text: "Files & Folders" }, 48 | { value: APPLY_TO_FILES, text: "Files" }, 49 | { value: APPLY_TO_FOLDERS, text: "Folders" }, 50 | ]; 51 | 52 | /** 53 | * FileManager plugin settings 54 | */ 55 | export interface FileManagerSettings { 56 | conflictResolutionMethod: string; 57 | showSelectionStatusBar: boolean; 58 | showCopyMoveStats: boolean; 59 | showClipboardStatusBar: boolean; 60 | newFolderName: string; 61 | newNoteName: string; 62 | duplicateSuffix: string; 63 | apps: AppCmd[]; 64 | patterns: PathPattern[]; 65 | } 66 | 67 | // Default settings for the FileManager plugin 68 | export const DEFAULT_SETTINGS: FileManagerSettings = { 69 | conflictResolutionMethod: FileConflictOption.SKIP, 70 | showSelectionStatusBar: true, 71 | showCopyMoveStats: true, 72 | showClipboardStatusBar: true, 73 | newFolderName: "New Folder", 74 | newNoteName: "New Note.md", 75 | duplicateSuffix: " - Copy", 76 | apps: [], 77 | patterns: [], 78 | }; 79 | 80 | const DEFAULT_ICON = "circle-dashed"; 81 | 82 | /** 83 | * Converts plain html text to a html DocumetFragment. 84 | */ 85 | function htmlToFragment(html: string): DocumentFragment { 86 | const range = document.createRange(); 87 | return range.createContextualFragment(html); 88 | } 89 | 90 | /** 91 | * FileManagerPlugin settings tab 92 | */ 93 | export class FileManagerSettingTab extends PluginSettingTab { 94 | plugin: FileManagerPlugin; 95 | 96 | constructor(app: App, plugin: FileManagerPlugin) { 97 | super(app, plugin); 98 | this.plugin = plugin; 99 | } 100 | 101 | /** 102 | SECTION: General settings 103 | **/ 104 | generalSection(containerEl: HTMLElement): void { 105 | new Setting(containerEl) 106 | .setName("Conflict resolution method") 107 | .setDesc("Method to resolve conflicts in copy/move operations") 108 | .addDropdown((dropdown) => 109 | dropdown 110 | .addOptions(FileConflictOptionDescription) 111 | .setValue(this.plugin.settings.conflictResolutionMethod) 112 | .onChange(async (value) => { 113 | this.plugin.settings.conflictResolutionMethod = value; 114 | await this.plugin.saveSettings(); 115 | }) 116 | ); 117 | new Setting(containerEl) 118 | .setName("Show copy/move stats") 119 | .setDesc("When a copy/move operation is done, show the stats") 120 | .addToggle((toggle) => 121 | toggle 122 | .setValue(this.plugin.settings.showCopyMoveStats) 123 | .onChange(async (value) => { 124 | this.plugin.settings.showCopyMoveStats = value; 125 | await this.plugin.saveSettings(); 126 | }) 127 | ); 128 | new Setting(containerEl) 129 | .setName("Show selection info in status bar") 130 | .setDesc( 131 | "Show, in the status bar, the number of selected files/folders" 132 | ) 133 | .addToggle((toggle) => 134 | toggle 135 | .setValue(this.plugin.settings.showSelectionStatusBar) 136 | .onChange(async (value) => { 137 | this.plugin.settings.showSelectionStatusBar = value; 138 | await this.plugin.saveSettings(); 139 | }) 140 | ); 141 | new Setting(containerEl) 142 | .setName("Show clipboard info in status bar") 143 | .setDesc( 144 | "Show, in the status bar, the number of files/folders in the clipboard" 145 | ) 146 | .addToggle((toggle) => 147 | toggle 148 | .setValue(this.plugin.settings.showClipboardStatusBar) 149 | .onChange(async (value) => { 150 | this.plugin.settings.showClipboardStatusBar = value; 151 | await this.plugin.saveSettings(); 152 | }) 153 | ); 154 | new Setting(containerEl) 155 | .setName("Duplicate suffix") 156 | .setDesc("Suffix to add when file/folder is duplicated") 157 | .addText((text) => 158 | text 159 | .setPlaceholder("Enter your suffix") 160 | .setValue(this.plugin.settings.duplicateSuffix) 161 | .onChange(async (value) => { 162 | if (value) this.plugin.settings.duplicateSuffix = value; 163 | await this.plugin.saveSettings(); 164 | }) 165 | ); 166 | new Setting(containerEl) 167 | .setName("New folder name") 168 | .setDesc("Default name of the folder to create") 169 | .addText((text) => 170 | text 171 | .setPlaceholder("Enter folder name") 172 | .setValue(this.plugin.settings.newFolderName) 173 | .onChange(async (value) => { 174 | if (value) this.plugin.settings.newFolderName = value; 175 | await this.plugin.saveSettings(); 176 | }) 177 | ); 178 | new Setting(containerEl) 179 | .setName("New note name") 180 | .setDesc("Default name of the note to create") 181 | .addText((text) => 182 | text 183 | .setPlaceholder("Enter note name") 184 | .setValue(this.plugin.settings.newNoteName) 185 | .onChange(async (value) => { 186 | if (value) this.plugin.settings.newNoteName = value; 187 | await this.plugin.saveSettings(); 188 | }) 189 | ); 190 | } 191 | 192 | /** 193 | SECTION: Open with... 194 | **/ 195 | openWithSection(containerEl: HTMLElement): void { 196 | new Setting(containerEl).setName("Open with...").setHeading(); 197 | const setting = new Setting(containerEl); 198 | setting.setName("Add new application to open with"); 199 | const div = containerEl.createDiv({ cls: "setting-item-description" }); 200 | const helpMsg = `Use full path to command or just command if it is globally available 201 | (for example c:\\Program Files\\myTool\\myTool.exe or code for VSCode).

202 | 203 | Arguments are optional and can include any of these predefined variables:

204 | 205 | ${VAR_FILE_PATH} - File path
206 | ${VAR_FOLDER_PATH} - Folder path
207 | ${VAR_FILE_NAME} - File name
208 | ${VAR_FOLDER_NAME} - Folder name

209 | 210 | Sometimes the command needs the arguments to be send independently, 211 | you can separate arguments using commas in those cases.

212 | 213 | Example 1: Command: /usr/bin/chromium-browser | Arguments: ${VAR_FILE_PATH}
214 | Example 2: Command: c:\\Program files\\OneCommander\\OneCommander.exe | Arguments: ${VAR_FILE_PATH}
215 | Example 3: Command: cmd | Arguments: /K cd ${VAR_FOLDER_PATH}
216 | Example 4: Command: wt | Arguments: -p, Ubuntu, wsl, --cd, ${VAR_FOLDER_PATH}

217 | 218 | NOTE: No need to add double quotes for paths with spaces.

219 | 220 | New from 1.3.1: You can also define an App URL Schema as a command (for example 221 | ulysses://x-callback-url/open?path=${VAR_FILE_PATH}). When 222 | defining an App URL Schema, arguments will be ignored.

`; 223 | div.appendChild(htmlToFragment(helpMsg)); 224 | 225 | // Create input boxes for the new command. 226 | const inputContainer = containerEl.createDiv({ 227 | cls: "settings-open-with-container", 228 | }); 229 | 230 | let icon = DEFAULT_ICON; 231 | const iconBtn = new ButtonComponent(inputContainer) 232 | .setIcon(icon) 233 | .setTooltip("Choose icon") 234 | .setClass("open-with-icon-btn") 235 | .onClick(() => { 236 | new LucideIconPickerModal(this.app, (iconSelected) => { 237 | icon = iconSelected; 238 | if (icon) iconBtn.setIcon(icon); 239 | }).open(); 240 | }); 241 | 242 | const nameInput = inputContainer.createEl("input", { 243 | attr: { type: "text", placeholder: "Display name" }, 244 | cls: "settings-open-with-name-inputbox", 245 | }); 246 | const cmdInput = inputContainer.createEl("input", { 247 | attr: { type: "text", placeholder: "Command or full path command" }, 248 | cls: "settings-open-with-cmd-inputbox", 249 | }); 250 | const argsInput = inputContainer.createEl("input", { 251 | attr: { type: "text", placeholder: "Arguments (optional)" }, 252 | cls: "settings-open-with-args-inputbox", 253 | }); 254 | 255 | // Create buttons to reset and add the new command. 256 | new ButtonComponent(inputContainer) 257 | .setIcon("rotate-ccw") 258 | .setTooltip("Reset fields") 259 | .setClass("open-with-reset-btn") 260 | .onClick(() => { 261 | nameInput.value = ""; 262 | cmdInput.value = ""; 263 | argsInput.value = ""; 264 | icon = DEFAULT_ICON; 265 | iconBtn.setIcon(icon); 266 | }); 267 | new ButtonComponent(inputContainer) 268 | .setIcon("circle-plus") 269 | .setTooltip("Add command to open with...") 270 | .setClass("open-with-add-btn") 271 | .onClick(async () => { 272 | const name = nameInput.value.trim(); 273 | const cmd = cmdInput.value.trim(); 274 | let args = argsInput.value.trim(); 275 | if (!(name && cmd)) { 276 | return new Notice( 277 | "Display name & path/command are always neccessary." 278 | ); 279 | } 280 | 281 | // Add command to open with the app. If there was an obsidian 282 | // command in the plugin with the same name, it will be replaced. 283 | this.plugin.addCommand( 284 | this.plugin.createOpenWithCmd(name, cmd, args) 285 | ); 286 | 287 | const newApp = { name, cmd, args, showInMenu: false, icon }; 288 | const apps = this.plugin.settings.apps; 289 | const app = apps.find((app) => app.name === newApp.name); 290 | if (app) { 291 | new Notice(`Modifying ${newApp.name} command.`); 292 | app.args = newApp.args; 293 | app.cmd = newApp.cmd; 294 | app.icon = newApp.icon; 295 | } else { 296 | apps.push(newApp); 297 | new Notice(`Adding ${newApp.name} command.`); 298 | } 299 | 300 | await this.plugin.saveSettings(); 301 | this.display(); 302 | }); 303 | 304 | // Display all saved commands in the settings. 305 | this.plugin.settings.apps.forEach((app) => { 306 | if (!app.icon) app.icon = DEFAULT_ICON; 307 | new Setting(containerEl) 308 | .setName(app.name) 309 | .setDesc( 310 | `Command: ${app.cmd}${ 311 | app.args ? ` | Arguments: ${app.args}` : "" 312 | }` 313 | ) 314 | .addToggle((toggle) => { 315 | const showText = document.createElement("span"); 316 | showText.textContent = "Show in File-Menu "; 317 | showText.classList.add("settings-open-with-show-text"); 318 | // @ts-ignore 319 | toggle.toggleEl.parentElement.prepend(showText); 320 | toggle.setValue(app.showInMenu).onChange(async (value) => { 321 | app.showInMenu = value; 322 | await this.plugin.saveSettings(); 323 | }); 324 | }) 325 | .addButton((btn) => { 326 | btn.setIcon(app.icon) 327 | .setTooltip("Icon for command") 328 | .onClick(() => { 329 | new LucideIconPickerModal( 330 | this.app, 331 | async (iconSelected) => { 332 | app.icon = iconSelected; 333 | if (app.icon) btn.setIcon(app.icon); 334 | await this.plugin.saveSettings(); 335 | } 336 | ).open(); 337 | }); 338 | }) 339 | // Button to fill the commands fields with this command. 340 | .addButton((btn) => { 341 | btn.setIcon("pen") 342 | .setTooltip("Edit command") 343 | .onClick(async () => { 344 | nameInput.value = app.name; 345 | cmdInput.value = app.cmd; 346 | argsInput.value = app.args; 347 | icon = app.icon; 348 | iconBtn.setIcon(icon); 349 | }); 350 | }) 351 | // Button to remove the command.0 352 | .addButton((btn) => { 353 | btn.setIcon("trash") 354 | .setTooltip("Remove command") 355 | .onClick(async () => { 356 | this.plugin.settings.apps.remove(app); 357 | await this.plugin.saveSettings(); 358 | this.display(); 359 | }); 360 | }); 361 | }); 362 | } 363 | 364 | /** 365 | SECTION: Path Explorer settings 366 | **/ 367 | pathExplorerSection(containerEl: HTMLElement): void { 368 | new Setting(containerEl).setName("Path explorer").setHeading(); 369 | let setting = new Setting(containerEl); 370 | setting.setName( 371 | "Define patterns to match files and folders and open them with an Open with... command." 372 | ); 373 | const div = containerEl.createDiv({ cls: "setting-item-description" }); 374 | const helpMsg = `The pathexplore codeblock displays files and folders 375 | from a specified path or multiple paths. You can define patterns to 376 | match the names of files and/or folders, and bind an Open with... 377 | command to open them. For files and folders that match the patterns, an 378 | icon will appear next to their name (and they will also appear in the link 379 | context menu). Clicking the icon will open the file or folder using the 380 | associated command.

`; 381 | 382 | div.appendChild(htmlToFragment(helpMsg)); 383 | 384 | // Create input boxes for the new command. 385 | const container = containerEl.createDiv({ 386 | cls: "settings-path-explorer-container", 387 | }); 388 | const regexInput = container.createEl("input", { 389 | attr: { type: "text", placeholder: "Enter regular expression" }, 390 | cls: "settings-path-explorer-regex", 391 | }); 392 | container.createEl("div", { 393 | text: "Apply to: ", 394 | cls: "settings-path-explorer-label", 395 | }); 396 | const applyToDropdown = container.createEl("select", { 397 | cls: "settings-path-explorer-dropdown dropdown", 398 | }); 399 | APPLY_TO.map((applyTo) => applyToDropdown.createEl("option", applyTo)); 400 | 401 | container.createEl("div", { 402 | text: "Open with: ", 403 | cls: "settings-path-explorer-label", 404 | }); 405 | const cmdDropdown = container.createEl("select", { 406 | cls: "settings-path-explorer-dropdown dropdown", 407 | }); 408 | const apps = this.plugin.settings.apps; 409 | for (const app of apps.values()) { 410 | cmdDropdown.createEl("option", { value: app.name, text: app.name }); 411 | } 412 | new ButtonComponent(container) 413 | .setIcon("rotate-ccw") 414 | .setTooltip("Reset fields") 415 | .setClass("settings-path-explorer-btn") 416 | .onClick(() => { 417 | regexInput.value = ""; 418 | applyToDropdown.selectedIndex = 0; 419 | cmdDropdown.selectedIndex = 0; 420 | }); 421 | new ButtonComponent(container) 422 | .setIcon("circle-plus") 423 | .setTooltip("Add pattern to open with...") 424 | .setClass("settings-path-explorer-btn") 425 | .onClick(async () => { 426 | const regex = regexInput.value.trim(); 427 | const applyTo = applyToDropdown.value; 428 | const appName = cmdDropdown.value; 429 | const app = apps.find((app) => app.name === appName); 430 | const icon = app?.icon || DEFAULT_ICON; 431 | if (!regex || !appName) 432 | return new Notice( 433 | "Regular expression and Open with app are always neccessary." 434 | ); 435 | 436 | const newPattern: PathPattern = { 437 | regex, 438 | applyTo, 439 | appName, 440 | icon, 441 | }; 442 | const patterns = this.plugin.settings.patterns; 443 | const pattern = patterns.find( 444 | (pattern) => 445 | pattern.regex === newPattern.regex && 446 | pattern.appName === newPattern.appName 447 | ); 448 | if (pattern) { 449 | new Notice(`Modifiying ${newPattern.regex} pattern.`); 450 | pattern.applyTo = newPattern.applyTo; 451 | } else { 452 | patterns.push(newPattern); 453 | new Notice(`Adding ${newPattern.regex} pattern.`); 454 | } 455 | await this.plugin.saveSettings(); 456 | this.display(); 457 | }); 458 | 459 | // Display all saved commands in the settings. 460 | this.plugin.settings.patterns.forEach((pattern) => { 461 | const setting = new Setting(containerEl) 462 | .setName(`${pattern.regex}`) 463 | .setDesc( 464 | `All matching ${pattern.applyTo} will 465 | be able to be open with ${pattern.appName}` 466 | ) 467 | // Button to fill the commands fields with this command. 468 | .addButton((btn) => { 469 | btn.setIcon("pen") 470 | .setTooltip("Edit command") 471 | .onClick(async () => { 472 | regexInput.value = pattern.regex; 473 | applyToDropdown.value = pattern.applyTo; 474 | cmdDropdown.value = pattern.appName; 475 | }); 476 | }) 477 | // Button to remove the command.0 478 | .addButton((btn) => { 479 | btn.setIcon("trash") 480 | .setTooltip("Remove command") 481 | .onClick(async () => { 482 | this.plugin.settings.patterns.remove(pattern); 483 | await this.plugin.saveSettings(); 484 | this.display(); 485 | }); 486 | }); 487 | const icon = document.createElement("span"); 488 | setIcon(icon, pattern.icon); 489 | setting.settingEl.prepend(icon); 490 | }); 491 | } 492 | 493 | display(): void { 494 | const { containerEl } = this; 495 | 496 | containerEl.empty(); 497 | 498 | this.generalSection(containerEl); 499 | this.openWithSection(containerEl); 500 | this.pathExplorerSection(containerEl); 501 | } 502 | } 503 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | .settings-open-with-container { 10 | display: flex; 11 | width: 100%; 12 | margin: 0 0 10px 0; 13 | } 14 | 15 | .settings-open-with-name-inputbox { 16 | flex: 1; 17 | margin: 0 5px 0 0; 18 | } 19 | 20 | .settings-open-with-cmd-inputbox { 21 | flex: 2; 22 | margin: 0 5px 0 0; 23 | } 24 | 25 | .settings-open-with-args-inputbox { 26 | flex: 2; 27 | margin: 0 5px 0 0; 28 | } 29 | 30 | .settings-open-with-btn { 31 | flex: none; 32 | margin: 0 0 0 10px; 33 | } 34 | 35 | .settings-open-with-show-text { 36 | font-size: 0.8em; 37 | color: var(--color-base-60); 38 | } 39 | 40 | .settings-path-explorer-container { 41 | display: flex; 42 | align-items: center; 43 | width: 100%; 44 | margin: 0 0 10px 0; 45 | } 46 | 47 | .settings-path-explorer-regex { 48 | flex: 1; 49 | margin: 0 10px 0 0; 50 | } 51 | 52 | .settings-path-explorer-label { 53 | font-size: 0.8em; 54 | color: var(--color-base-60); 55 | margin: 0 10px 0 0; 56 | } 57 | 58 | .settings-path-explorer-dropdown { 59 | flex: 1; 60 | margin: 0 10px 0 0; 61 | } 62 | 63 | .settings-path-explorer-btn { 64 | flex: none; 65 | margin: 0 0 0 10px; 66 | } 67 | 68 | .path-explorer { 69 | position: relative; 70 | border: 1px solid var(--color-base-40); 71 | padding: 10px; 72 | font-family: Arial, sans-serif; 73 | } 74 | 75 | .path-explorer ul { 76 | list-style-type: none; 77 | padding: 0; 78 | } 79 | 80 | .path-explorer ul::before { 81 | --indentation-guide-reading-indent: 0; 82 | } 83 | 84 | .path-explorer ul:scope>li { 85 | --list-indent: 0; 86 | } 87 | 88 | .path-explorer li { 89 | margin-left: 0; 90 | padding: 0; 91 | } 92 | 93 | .path-explorer svg { 94 | stroke: var(--color-base-40); 95 | margin: 0 6px 0 0; 96 | vertical-align: middle; 97 | } 98 | 99 | .path-explorer svg:hover { 100 | cursor: pointer; 101 | stroke: var(--interactive-accent); 102 | } 103 | 104 | .path-explorer-error { 105 | padding: 10px; 106 | background-color: var(--color-red); 107 | } 108 | 109 | .path-explorer a { 110 | color: var(--text-normal); 111 | text-decoration: none; 112 | background-image: none; 113 | padding: 0 10px 0 0; 114 | } 115 | 116 | .path-explorer a:hover { 117 | color: var(--interactive-accent); 118 | text-decoration: none; 119 | } 120 | 121 | .path-explorer-refresh-button { 122 | position: absolute; 123 | top: 8px; 124 | right: 8px; 125 | cursor: pointer; 126 | padding: 10px; 127 | } 128 | 129 | .path-explorer-refresh-button svg { 130 | stroke: var(--interactive-accent); 131 | margin: 0; 132 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "allowSyntheticDefaultImports": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "strictNullChecks": true, 15 | "lib": ["DOM", "ES5", "ES6", "ES7"] 16 | }, 17 | "include": ["**/*.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 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.4.0": "0.15.0", 3 | "1.3.1": "0.15.0", 4 | "1.2.2": "0.15.0", 5 | "1.2.1": "0.15.0", 6 | "1.2.0": "0.15.0", 7 | "1.1.2": "0.15.0", 8 | "1.1.1": "0.15.0", 9 | "1.0.1": "0.15.0", 10 | "1.0.0": "0.15.0" 11 | } 12 | --------------------------------------------------------------------------------