├── .editorconfig ├── .github └── workflows │ └── release.yml ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── esbuild.config.mjs ├── manifest.json ├── package.json ├── src ├── deleteFilesModal.ts ├── main.ts ├── settingsTab.ts └── utils.ts └── tsconfig.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 = space 9 | indent_size = 4 10 | tab_width = 4 -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian Plugin 2 | on: 3 | push: 4 | tags: 5 | - '*' 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | with: 12 | fetch-depth: 0 # otherwise, you will failed to push refs to dest repo 13 | - name: Use Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '14.x' 17 | - name: Get Version 18 | id: version 19 | run: | 20 | echo "::set-output name=tag::$(git describe --abbrev=0)" 21 | # Build the plugin 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build --if-present 27 | # Package the required files into a zip 28 | - name: Package 29 | run: | 30 | mkdir ${{ github.event.repository.name }} 31 | cp main.js manifest.json README.md ${{ github.event.repository.name }} 32 | zip -r ${{ github.event.repository.name }}.zip ${{ github.event.repository.name }} 33 | # Create the release on github 34 | - name: Create Release 35 | id: create_release 36 | uses: actions/create-release@v1 37 | env: 38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 39 | VERSION: ${{ github.ref }} 40 | with: 41 | tag_name: ${{ github.ref }} 42 | release_name: ${{ github.ref }} 43 | draft: false 44 | prerelease: false 45 | # Upload the packaged release file 46 | - name: Upload zip file 47 | id: upload-zip 48 | uses: actions/upload-release-asset@v1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | upload_url: ${{ steps.create_release.outputs.upload_url }} 53 | asset_path: ./${{ github.event.repository.name }}.zip 54 | asset_name: ${{ github.event.repository.name }}-${{ steps.version.outputs.tag }}.zip 55 | asset_content_type: application/zip 56 | # Upload the main.js 57 | - name: Upload main.js 58 | id: upload-main 59 | uses: actions/upload-release-asset@v1 60 | env: 61 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 62 | with: 63 | upload_url: ${{ steps.create_release.outputs.upload_url }} 64 | asset_path: ./main.js 65 | asset_name: main.js 66 | asset_content_type: text/javascript 67 | # Upload the manifest.json 68 | - name: Upload manifest.json 69 | id: upload-manifest 70 | uses: actions/upload-release-asset@v1 71 | env: 72 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 73 | with: 74 | upload_url: ${{ steps.create_release.outputs.upload_url }} 75 | asset_path: ./manifest.json 76 | asset_name: manifest.json 77 | asset_content_type: application/json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules/ 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | data.json 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true 5 | } 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.10.1](https://github.com/Vinzent03/find-unlinked-files/compare/1.10.0...1.10.1) (2024-08-12) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * handle empty canvas file ([af44099](https://github.com/Vinzent03/find-unlinked-files/commit/af4409990682ebb7152cfa33d90befa1fe9715e9)), closes [#66](https://github.com/Vinzent03/find-unlinked-files/issues/66) 11 | 12 | ## [1.10.0](https://github.com/Vinzent03/find-unlinked-files/compare/1.9.1...1.10.0) (2024-03-13) 13 | 14 | 15 | ### Features 16 | 17 | * Include canvas files when determining orphans ([#53](https://github.com/Vinzent03/find-unlinked-files/issues/53)) ([56900fe](https://github.com/Vinzent03/find-unlinked-files/commit/56900fe43f34a15405e629318eda563b64217db3)) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * respect frontmatter links ([bb1b21b](https://github.com/Vinzent03/find-unlinked-files/commit/bb1b21b7725b7bc3f8ad1cc2817fcfde9002f15e)) 23 | 24 | ### [1.9.1](https://github.com/Vinzent03/find-unlinked-files/compare/1.9.0...1.9.1) (2023-08-24) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * typo in README ([118d54e](https://github.com/Vinzent03/find-unlinked-files/commit/118d54ef2c66c5521fc0cfb5654d2e864f7ce512)) 30 | * work in Obsidian 1.4.0 ([3b7df5d](https://github.com/Vinzent03/find-unlinked-files/commit/3b7df5dd15200ecb7b080d1cb66b6d9baa0dd7f8)), closes [#51](https://github.com/Vinzent03/find-unlinked-files/issues/51) 31 | 32 | ## [1.9.0](https://github.com/Vinzent03/find-unlinked-files/compare/1.8.1...1.9.0) (2023-03-27) 33 | 34 | 35 | ### Features 36 | 37 | * sort orphaned files by size ([aa2aa0b](https://github.com/Vinzent03/find-unlinked-files/commit/aa2aa0b39c884119efbc8c6d78e9d56c15fc8330)), closes [#40](https://github.com/Vinzent03/find-unlinked-files/issues/40) 38 | 39 | ## [1.8.0](https://github.com/Vinzent03/find-unlinked-files/compare/1.7.0...1.8.0) (2022-09-26) 40 | 41 | 42 | ### Features 43 | 44 | * directory whitelist for broken links ([3c633a7](https://github.com/Vinzent03/find-unlinked-files/commit/3c633a70e27755e5da7c920f4c514b017f6a0bca)), closes [#34](https://github.com/Vinzent03/find-unlinked-files/issues/34) 45 | 46 | ## [1.7.0](https://github.com/Vinzent03/find-unlinked-files/compare/1.6.1...1.7.0) (2022-09-25) 47 | 48 | 49 | ### Features 50 | 51 | * create files of broken links ([a86d795](https://github.com/Vinzent03/find-unlinked-files/commit/a86d795f2d4540c5d1094eeb5a4249cf1b69b35f)), closes [#34](https://github.com/Vinzent03/find-unlinked-files/issues/34) 52 | * find empty files ([cf7a058](https://github.com/Vinzent03/find-unlinked-files/commit/cf7a058286f2f7e1e22d8d2fb5c83152d23e013f)), closes [#30](https://github.com/Vinzent03/find-unlinked-files/issues/30) 53 | 54 | ### [1.6.1](https://github.com/Vinzent03/find-unlinked-files/compare/1.6.0...1.6.1) (2022-08-08) 55 | 56 | ### Bug Fixes 57 | 58 | - reuse an empty leaf if available -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Vinzent03 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 | # Find orphaned files (files with no backlinks) and broken links 2 | A Plugin for [Obsidian](https://obsidian.md) 3 | 4 | ## How does it work? 5 | 6 | ### Find orphaned files 7 | 8 | This plugin goes through your whole vault and searches for files, which are linked nowhere. In other words: Files with no backlinks. 9 | 10 | In the end, it will create a file with a list of links to these orphaned files. Now you can either delete these unused files or link them somewhere in your vault. 11 | 12 | ### Find broken links 13 | 14 | Creates a file with a list of links, which linked file has not been created yet. 15 | 16 | In addition, there is a command to create those linked files. 17 | 18 | ### Find empty files 19 | 20 | Creates a file with a list of empty files. Files with just frontmatter are considered empty as well. 21 | 22 | ## How to use 23 | Call the command `Find orphaned files` and the file `Find orphaned files plugin output.md` will be created in your vault root and opened in a new pane. 24 | 25 | ## Additional features: 26 | - add files to ignore 27 | - add directories to ignore 28 | - add tags to ignore files with one of these tags 29 | - add files to ignore files with links to one of these files 30 | - add specific file types to ignore 31 | - change output file name 32 | 33 | ## Move files with certain extension in output file to system trash (extra command) 34 | Goes through every link in the output file. If the extension of the link is in the list (can be set in settings), it moves the file to system trash. Is useful to delete many unused media files. 35 | 36 | **Please note that the setting "Disable working links" needs to be disabled.** 37 | 38 | ## Compatibility 39 | Custom plugins are only available for Obsidian v0.9.7+. 40 | 41 | ## Installing 42 | 43 | ### From Obsidian 44 | 1. Open settings -> Third party plugin 45 | 2. Disable Safe mode 46 | 3. Click Browse community plugins 47 | 4. Search for "Find orphaned files and broken links" 48 | 5. Install it 49 | 6. Activate it under Installed plugins 50 | 51 | 52 | ### From GitHub 53 | 1. Download the [latest release](https://github.com/Vinzent03/find-unlinked-files/releases/latest) 54 | 2. Move `manifest.json` and `main.js` to `/.obsidian/plugins/find-unlinked-files` 55 | 3. Reload Obsidian (Str + r) 56 | 4. Go to settings and disable safe mode 57 | 5. Enable `Find orphaned files and broken links` 58 | 59 | If you find this plugin useful and would like to support its development, you can support me on [Ko-fi](https://Ko-fi.com/Vinzent). 60 | 61 | [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/F1F195IQ5) 62 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | 4 | const banner = `/* 5 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 6 | if you want to view the source visit the plugins github repository (https://github.com/Vinzent03/obsidian-advanced-uri) 7 | */ 8 | `; 9 | 10 | const prod = process.argv[2] === "production"; 11 | 12 | const context = await esbuild.context({ 13 | banner: { 14 | js: banner, 15 | }, 16 | entryPoints: ["src/main.ts"], 17 | bundle: true, 18 | external: ["obsidian"], 19 | format: "cjs", 20 | target: "es2018", 21 | logLevel: "info", 22 | sourcemap: prod ? false : "inline", 23 | treeShaking: true, 24 | platform: "browser", 25 | 26 | outfile: "main.js", 27 | }); 28 | 29 | if (prod) { 30 | await context.rebuild(); 31 | process.exit(0); 32 | } else { 33 | await context.watch(); 34 | } 35 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "find-unlinked-files", 3 | "name": "Find orphaned files and broken links", 4 | "version": "1.10.1", 5 | "description": "Find files that are not linked anywhere and would otherwise be lost in your vault. In other words: files with no backlinks.", 6 | "author": "Vinzent", 7 | "fundingUrl": "https://ko-fi.com/vinzent", 8 | "authorUrl": "https://github.com/Vinzent03", 9 | "isDesktopOnly": false 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "find-unlinked-files", 3 | "version": "1.10.1", 4 | "description": "Find files that are not linked anywhere and would otherwise be lost in your vault. In other words: files with no backlinks.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs dev", 8 | "build": "node esbuild.config.mjs production", 9 | "release": "standard-version", 10 | "format": "prettier src --write" 11 | }, 12 | "license": "MIT", 13 | "author": "Vinzent", 14 | "standard-version": { 15 | "t": "" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^14.14.2", 19 | "esbuild": "^0.18.10", 20 | "obsidian": "^1.4.4", 21 | "prettier": "^3.2.4", 22 | "standard-version": "^9.0.0", 23 | "tslib": "^2.0.3", 24 | "typescript": "^5.1.6" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/deleteFilesModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, TFile } from "obsidian"; 2 | 3 | export class DeleteFilesModal extends Modal { 4 | filesToDelete: TFile[]; 5 | constructor(app: App, filesToDelete: TFile[]) { 6 | super(app); 7 | this.filesToDelete = filesToDelete; 8 | } 9 | 10 | onOpen() { 11 | let { contentEl, titleEl } = this; 12 | titleEl.setText( 13 | "Move " + this.filesToDelete.length + " files to system trash?" 14 | ); 15 | contentEl 16 | .createEl("button", { text: "Cancel" }) 17 | .addEventListener("click", () => this.close()); 18 | contentEl.setAttr("margin", "auto"); 19 | 20 | contentEl 21 | .createEl("button", { 22 | cls: "mod-cta", 23 | text: "Confirm", 24 | }) 25 | .addEventListener("click", async () => { 26 | for (const file of this.filesToDelete) { 27 | await this.app.vault.trash(file, true); 28 | } 29 | this.close(); 30 | }); 31 | } 32 | 33 | onClose() { 34 | let { contentEl } = this; 35 | contentEl.empty(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getAllTags, 3 | getLinkpath, 4 | Notice, 5 | Plugin, 6 | TFile, 7 | TFolder, 8 | } from "obsidian"; 9 | import { CanvasData } from "obsidian/canvas"; 10 | import { DeleteFilesModal } from "./deleteFilesModal"; 11 | import { SettingsTab } from "./settingsTab"; 12 | import { Utils } from "./utils"; 13 | 14 | export interface Settings { 15 | outputFileName: string; 16 | disableWorkingLinks: boolean; 17 | directoriesToIgnore: string[]; 18 | filesToIgnore: string[]; 19 | fileTypesToIgnore: string[]; 20 | linksToIgnore: string[]; 21 | tagsToIgnore: string[]; 22 | fileTypesToDelete: string[]; 23 | ignoreFileTypes: boolean; 24 | ignoreDirectories: boolean; 25 | unresolvedLinksIgnoreDirectories: boolean; 26 | unresolvedLinksDirectoriesToIgnore: string[]; 27 | unresolvedLinksFilesToIgnore: string[]; 28 | unresolvedLinksFileTypesToIgnore: string[]; 29 | unresolvedLinksLinksToIgnore: string[]; 30 | unresolvedLinksTagsToIgnore: string[]; 31 | unresolvedLinksOutputFileName: string; 32 | withoutTagsDirectoriesToIgnore: string[]; 33 | withoutTagsFilesToIgnore: string[]; 34 | withoutTagsOutputFileName: string; 35 | emptyFilesOutputFileName: string; 36 | emptyFilesDirectories: string[]; 37 | emptyFilesFilesToIgnore: string[]; 38 | emptyFilesIgnoreDirectories: boolean; 39 | openOutputFile: boolean; 40 | } 41 | const DEFAULT_SETTINGS: Settings = { 42 | outputFileName: "orphaned files output", 43 | disableWorkingLinks: false, 44 | directoriesToIgnore: [], 45 | filesToIgnore: [], 46 | fileTypesToIgnore: [], 47 | linksToIgnore: [], 48 | tagsToIgnore: [], 49 | fileTypesToDelete: [], 50 | ignoreFileTypes: true, 51 | ignoreDirectories: true, 52 | unresolvedLinksIgnoreDirectories: true, 53 | unresolvedLinksOutputFileName: "broken links output", 54 | unresolvedLinksDirectoriesToIgnore: [], 55 | unresolvedLinksFilesToIgnore: [], 56 | unresolvedLinksFileTypesToIgnore: [], 57 | unresolvedLinksLinksToIgnore: [], 58 | unresolvedLinksTagsToIgnore: [], 59 | withoutTagsDirectoriesToIgnore: [], 60 | withoutTagsFilesToIgnore: [], 61 | withoutTagsOutputFileName: "files without tags", 62 | emptyFilesOutputFileName: "empty files", 63 | emptyFilesDirectories: [], 64 | emptyFilesFilesToIgnore: [], 65 | emptyFilesIgnoreDirectories: true, 66 | openOutputFile: true, 67 | }; 68 | 69 | interface BrokenLink { 70 | link: string; 71 | files: string[]; 72 | } 73 | 74 | export default class FindOrphanedFilesPlugin extends Plugin { 75 | settings: Settings; 76 | findExtensionRegex = /(\.[^.]+)$/; 77 | async onload() { 78 | console.log("loading " + this.manifest.name + " plugin"); 79 | await this.loadSettings(); 80 | this.addCommand({ 81 | id: "find-unlinked-files", 82 | name: "Find orphaned files", 83 | callback: () => this.findOrphanedFiles(), 84 | }); 85 | this.addCommand({ 86 | id: "find-unresolved-link", 87 | name: "Find broken links", 88 | callback: () => this.findBrokenLinks(), 89 | }); 90 | this.addCommand({ 91 | id: "delete-unlinked-files", 92 | name: "Delete orphaned files with certain extension. See README", 93 | callback: () => this.deleteOrphanedFiles(), 94 | }); 95 | this.addCommand({ 96 | id: "create-files-of-broken-links", 97 | name: "Create files of broken links", 98 | callback: () => this.createFilesOfBrokenLinks(), 99 | }); 100 | this.addCommand({ 101 | id: "find-files-without-tags", 102 | name: "Find files without tags", 103 | callback: () => this.findFilesWithoutTags(), 104 | }); 105 | this.addCommand({ 106 | id: "find-empty-files", 107 | name: "Find empty files", 108 | callback: () => this.findEmptyFiles(), 109 | }); 110 | this.addCommand({ 111 | id: "delete-empty-files", 112 | name: "Delete empty files", 113 | callback: () => this.deleteEmptyFiles(), 114 | }); 115 | this.addSettingTab(new SettingsTab(this.app, this, DEFAULT_SETTINGS)); 116 | 117 | this.app.workspace.on("file-menu", (menu, file, _, __) => { 118 | if (file instanceof TFolder) { 119 | menu.addItem((cb) => { 120 | cb.setIcon("search"); 121 | cb.setTitle("Find orphaned files"); 122 | // Add trailing slash to catch files named like the directory. See https://github.com/Vinzent03/find-unlinked-files/issues/24 123 | cb.onClick((_) => { 124 | this.findOrphanedFiles(file.path + "/"); 125 | }); 126 | }); 127 | } 128 | }); 129 | } 130 | 131 | async createFilesOfBrokenLinks() { 132 | if ( 133 | !(await this.app.vault.adapter.exists( 134 | this.settings.unresolvedLinksOutputFileName + ".md" 135 | )) 136 | ) { 137 | new Notice( 138 | "Can't find file - Please run the `Find broken files' command before" 139 | ); 140 | return; 141 | } 142 | const links = this.app.metadataCache.getCache( 143 | this.settings.unresolvedLinksOutputFileName + ".md" 144 | )?.links; 145 | if (!links) { 146 | new Notice("No broken links found"); 147 | return; 148 | } 149 | const filesToCreate: string[] = []; 150 | 151 | for (const link of links) { 152 | const file = this.app.metadataCache.getFirstLinkpathDest( 153 | link.link, 154 | "/" 155 | ); 156 | if (file) continue; 157 | const foundType = this.findExtensionRegex.exec(link.link)?.[0]; 158 | if ((foundType ?? ".md") == ".md") { 159 | if (foundType) { 160 | filesToCreate.push(link.link); 161 | } else { 162 | filesToCreate.push(link.link + ".md"); 163 | } 164 | } 165 | } 166 | 167 | if (filesToCreate) { 168 | for (const file of filesToCreate) { 169 | await this.app.vault.create(file, ""); 170 | } 171 | } 172 | } 173 | 174 | async findEmptyFiles() { 175 | const files = this.app.vault.getFiles(); 176 | const emptyFiles: TFile[] = []; 177 | for (const file of files) { 178 | if ( 179 | new Utils( 180 | this.app, 181 | file.path, 182 | [], 183 | [], 184 | this.settings.emptyFilesDirectories, 185 | this.settings.emptyFilesFilesToIgnore, 186 | this.settings.emptyFilesIgnoreDirectories 187 | ).shouldIgnoreFile() 188 | ) { 189 | continue; 190 | } 191 | const content = await this.app.vault.read(file); 192 | const trimmedContent = content.trim(); 193 | if (!trimmedContent) { 194 | emptyFiles.push(file); 195 | } 196 | const cache = this.app.metadataCache.getFileCache(file); 197 | const frontmatter = cache?.frontmatter; 198 | if (frontmatter) { 199 | const lines = content.trimRight().split("\n").length; 200 | if ( 201 | (cache.frontmatterPosition ?? frontmatter.position).end 202 | .line == 203 | lines - 1 204 | ) { 205 | emptyFiles.push(file); 206 | } 207 | } 208 | } 209 | let prefix: string; 210 | if (this.settings.disableWorkingLinks) prefix = " "; 211 | else prefix = ""; 212 | const text = emptyFiles 213 | .map((file) => `${prefix}- [[${file.path}]]`) 214 | .join("\n"); 215 | Utils.writeAndOpenFile( 216 | this.app, 217 | this.settings.emptyFilesOutputFileName + ".md", 218 | text, 219 | this.settings.openOutputFile 220 | ); 221 | } 222 | 223 | async findOrphanedFiles(dir?: string) { 224 | const startTime = Date.now(); 225 | const outFileName = this.settings.outputFileName + ".md"; 226 | let outFile: TFile | null = null; 227 | const allFiles = this.app.vault.getFiles(); 228 | const markdownFiles = this.app.vault.getMarkdownFiles(); 229 | const canvasFiles = allFiles.filter( 230 | (file) => file.extension === "canvas" 231 | ); 232 | const links: Set = new Set(); 233 | const findLinkInTextRegex = /\[\[(.*?)\]\]|\[.*?\]\((.*?)\)/g; 234 | 235 | // get a list of all links within canvas files 236 | const canvasParsingPromises = canvasFiles.map( 237 | async (canvasFile: TFile) => { 238 | // Read the canvas file as JSON 239 | const canvasFileContent: CanvasData = JSON.parse( 240 | (await this.app.vault.cachedRead(canvasFile)) || "{}" 241 | ); 242 | // Get a list of all links within the canvas file 243 | canvasFileContent.nodes?.forEach((node) => { 244 | let linkTexts: string[] = []; 245 | 246 | if (node.type === "file") { 247 | linkTexts.push(node.file); 248 | } else if (node.type === "text") { 249 | // There could be zero or more links in the text. Use a regex to extract all the text between "[[" and "]]" 250 | let match; 251 | while ( 252 | (match = findLinkInTextRegex.exec(node.text)) !== 253 | null 254 | ) { 255 | linkTexts.push(match[1] ?? match[2]); 256 | } 257 | } else { 258 | return; // Skip other types (e.g. "group") 259 | } 260 | 261 | linkTexts.forEach((linkText: string) => { 262 | const targetFile = 263 | this.app.metadataCache.getFirstLinkpathDest( 264 | linkText.split("|")[0].split("#")[0], 265 | canvasFile.path 266 | ); 267 | if (targetFile != null) links.add(targetFile.path); 268 | }); 269 | }); 270 | } 271 | ); 272 | 273 | // Get a list of all links within markdown files 274 | markdownFiles.forEach((mdFile: TFile) => { 275 | if (outFile === null && mdFile.path == outFileName) { 276 | outFile = mdFile; 277 | return; 278 | } 279 | const cache = this.app.metadataCache.getFileCache(mdFile); 280 | for (const ref of [ 281 | ...(cache.embeds ?? []), 282 | ...(cache.links ?? []), 283 | ...(cache.frontmatterLinks ?? []), 284 | ]) { 285 | const txt = this.app.metadataCache.getFirstLinkpathDest( 286 | getLinkpath(ref.link), 287 | mdFile.path 288 | ); 289 | if (txt != null) links.add(txt.path); 290 | } 291 | }); 292 | 293 | // Ensure the canvas files have all been parsed before continuing. 294 | await Promise.all(canvasParsingPromises); 295 | 296 | const notLinkedFiles = allFiles.filter((file) => 297 | this.isFileAnOrphan(file, links, dir) 298 | ); 299 | notLinkedFiles.remove(outFile); 300 | 301 | let text = ""; 302 | let prefix: string; 303 | if (this.settings.disableWorkingLinks) prefix = " "; 304 | else prefix = ""; 305 | 306 | notLinkedFiles.sort((a, b) => b.stat.size - a.stat.size); 307 | 308 | notLinkedFiles.forEach((file) => { 309 | text += 310 | prefix + 311 | "- [[" + 312 | this.app.metadataCache.fileToLinktext(file, "/", false) + 313 | "]]\n"; 314 | }); 315 | Utils.writeAndOpenFile( 316 | this.app, 317 | outFileName, 318 | text, 319 | this.settings.openOutputFile 320 | ); 321 | const endTime = Date.now(); 322 | const diff = endTime - startTime; 323 | if (diff > 1000) { 324 | new Notice( 325 | `Found ${notLinkedFiles.length} orphaned files in ${diff}ms` 326 | ); 327 | } 328 | } 329 | async deleteOrphanedFiles() { 330 | if ( 331 | !(await this.app.vault.adapter.exists( 332 | this.settings.outputFileName + ".md" 333 | )) 334 | ) { 335 | new Notice( 336 | "Can't find file - Please run the `Find orphaned files' command before" 337 | ); 338 | return; 339 | } 340 | const links = 341 | this.app.metadataCache.getCache( 342 | this.settings.outputFileName + ".md" 343 | )?.links ?? []; 344 | const filesToDelete: TFile[] = []; 345 | links.forEach((link) => { 346 | const file = this.app.metadataCache.getFirstLinkpathDest( 347 | link.link, 348 | "/" 349 | ); 350 | if (!file) return; 351 | 352 | if ( 353 | this.settings.fileTypesToDelete[0] == "*" || 354 | this.settings.fileTypesToDelete.contains(file.extension) 355 | ) { 356 | filesToDelete.push(file); 357 | } 358 | }); 359 | if (filesToDelete.length > 0) 360 | new DeleteFilesModal(this.app, filesToDelete).open(); 361 | } 362 | 363 | async deleteEmptyFiles() { 364 | if ( 365 | !(await this.app.vault.adapter.exists( 366 | this.settings.emptyFilesOutputFileName + ".md" 367 | )) 368 | ) { 369 | new Notice( 370 | "Can't find file - Please run the `Find orphaned files' command before" 371 | ); 372 | return; 373 | } 374 | const links = 375 | this.app.metadataCache.getCache( 376 | this.settings.emptyFilesOutputFileName + ".md" 377 | )?.links ?? []; 378 | const filesToDelete: TFile[] = []; 379 | for (const link of links) { 380 | const file = this.app.metadataCache.getFirstLinkpathDest( 381 | link.link, 382 | "/" 383 | ); 384 | if (!file) return; 385 | 386 | filesToDelete.push(file); 387 | } 388 | if (filesToDelete.length > 0) 389 | new DeleteFilesModal(this.app, filesToDelete).open(); 390 | } 391 | 392 | findBrokenLinks() { 393 | const outFileName = this.settings.unresolvedLinksOutputFileName + ".md"; 394 | const links: BrokenLink[] = []; 395 | const brokenLinks = this.app.metadataCache.unresolvedLinks; 396 | 397 | for (const sourceFilepath in brokenLinks) { 398 | if ( 399 | sourceFilepath == 400 | this.settings.unresolvedLinksOutputFileName + ".md" 401 | ) 402 | continue; 403 | 404 | const fileType = sourceFilepath.substring( 405 | sourceFilepath.lastIndexOf(".") + 1 406 | ); 407 | 408 | const utils = new Utils( 409 | this.app, 410 | sourceFilepath, 411 | this.settings.unresolvedLinksTagsToIgnore, 412 | this.settings.unresolvedLinksLinksToIgnore, 413 | this.settings.unresolvedLinksDirectoriesToIgnore, 414 | this.settings.unresolvedLinksFilesToIgnore, 415 | this.settings.unresolvedLinksIgnoreDirectories 416 | ); 417 | if (utils.shouldIgnoreFile()) continue; 418 | 419 | for (const link in brokenLinks[sourceFilepath]) { 420 | const linkFileType = link.substring(link.lastIndexOf(".") + 1); 421 | 422 | if ( 423 | this.settings.unresolvedLinksFileTypesToIgnore.contains( 424 | linkFileType 425 | ) 426 | ) 427 | continue; 428 | 429 | let formattedFilePath = sourceFilepath; 430 | if (fileType == "md") { 431 | formattedFilePath = sourceFilepath.substring( 432 | 0, 433 | sourceFilepath.lastIndexOf(".md") 434 | ); 435 | } 436 | const brokenLink: BrokenLink = { 437 | files: [formattedFilePath], 438 | link: link, 439 | }; 440 | if (links.contains(brokenLink)) continue; 441 | const duplication = links.find((e) => e.link == link); 442 | if (duplication) { 443 | duplication.files.push(formattedFilePath); 444 | } else { 445 | links.push(brokenLink); 446 | } 447 | } 448 | } 449 | Utils.writeAndOpenFile( 450 | this.app, 451 | outFileName, 452 | [ 453 | "Don't forget that creating the file from here may create the file in the wrong directory!", 454 | ...links.map( 455 | (e) => `- [[${e.link}]] in [[${e.files.join("]], [[")}]]` 456 | ), 457 | ].join("\n"), 458 | this.settings.openOutputFile 459 | ); 460 | } 461 | 462 | findFilesWithoutTags() { 463 | const outFileName = this.settings.withoutTagsOutputFileName + ".md"; 464 | let outFile: TFile; 465 | const files = this.app.vault.getMarkdownFiles(); 466 | let withoutFiles = files.filter((file) => { 467 | const utils = new Utils( 468 | this.app, 469 | file.path, 470 | [], 471 | [], 472 | this.settings.withoutTagsDirectoriesToIgnore, 473 | this.settings.withoutTagsFilesToIgnore, 474 | true 475 | ); 476 | 477 | if (utils.shouldIgnoreFile()) { 478 | return false; 479 | } 480 | return ( 481 | (getAllTags(this.app.metadataCache.getFileCache(file)).length ?? 482 | 0) <= 0 483 | ); 484 | }); 485 | withoutFiles.remove(outFile); 486 | 487 | let prefix: string; 488 | if (this.settings.disableWorkingLinks) prefix = " "; 489 | else prefix = ""; 490 | const text = withoutFiles 491 | .map((file) => `${prefix}- [[${file.path}]]`) 492 | .join("\n"); 493 | Utils.writeAndOpenFile( 494 | this.app, 495 | outFileName, 496 | text, 497 | this.settings.openOutputFile 498 | ); 499 | } 500 | 501 | /** 502 | * Checks if the given file in an orphaned file 503 | * 504 | * @param file file to check 505 | * @param links all links in the vault 506 | */ 507 | isFileAnOrphan(file: TFile, links: Set, dir: string): boolean { 508 | if (links.has(file.path)) return false; 509 | 510 | //filetypes to ignore by default 511 | if (file.extension == "css") return false; 512 | 513 | if (this.settings.fileTypesToIgnore[0] !== "") { 514 | const containsFileType = this.settings.fileTypesToIgnore.contains( 515 | file.extension 516 | ); 517 | if (this.settings.ignoreFileTypes) { 518 | if (containsFileType) return; 519 | } else { 520 | if (!containsFileType) return; 521 | } 522 | } 523 | 524 | const utils = new Utils( 525 | this.app, 526 | file.path, 527 | this.settings.tagsToIgnore, 528 | this.settings.linksToIgnore, 529 | this.settings.directoriesToIgnore, 530 | this.settings.filesToIgnore, 531 | this.settings.ignoreDirectories, 532 | dir 533 | ); 534 | if (utils.shouldIgnoreFile()) return false; 535 | 536 | return true; 537 | } 538 | 539 | onunload() { 540 | console.log("unloading " + this.manifest.name + " plugin"); 541 | } 542 | async loadSettings() { 543 | this.settings = Object.assign(DEFAULT_SETTINGS, await this.loadData()); 544 | } 545 | 546 | async saveSettings() { 547 | await this.saveData(this.settings); 548 | } 549 | } 550 | -------------------------------------------------------------------------------- /src/settingsTab.ts: -------------------------------------------------------------------------------- 1 | import { App, normalizePath, PluginSettingTab, Setting } from "obsidian"; 2 | import FindOrphanedFilesPlugin, { Settings } from "./main"; 3 | 4 | export class SettingsTab extends PluginSettingTab { 5 | plugin: FindOrphanedFilesPlugin; 6 | constructor( 7 | app: App, 8 | plugin: FindOrphanedFilesPlugin, 9 | private defaultSettings: Settings 10 | ) { 11 | super(app, plugin); 12 | this.plugin = plugin; 13 | } 14 | 15 | // Add trailing slash to catch files named like the directory. See https://github.com/Vinzent03/find-unlinked-files/issues/24 16 | formatPath(path: string, addDirectorySlash: boolean): string { 17 | if (path.length == 0) return path; 18 | path = normalizePath(path); 19 | if (addDirectorySlash) return path + "/"; 20 | else return path; 21 | } 22 | 23 | display(): void { 24 | let { containerEl } = this; 25 | containerEl.empty(); 26 | containerEl.createEl("h2", { text: this.plugin.manifest.name }); 27 | 28 | containerEl.createEl("h4", { 29 | text: "Settings for finding orphaned files", 30 | }); 31 | 32 | new Setting(containerEl).setName("Open output file").addToggle((cb) => 33 | cb 34 | .setValue(this.plugin.settings.openOutputFile) 35 | .onChange((value) => { 36 | this.plugin.settings.openOutputFile = value; 37 | this.plugin.saveSettings(); 38 | }) 39 | ); 40 | 41 | new Setting(containerEl) 42 | .setName("Output file name") 43 | .setDesc( 44 | "Set name of output file (without file extension). Make sure no file exists with this name because it will be overwritten! If the name is empty, the default name is set." 45 | ) 46 | .addText((cb) => 47 | cb 48 | .onChange((value) => { 49 | if (value.length == 0) { 50 | this.plugin.settings.outputFileName = 51 | this.defaultSettings.outputFileName; 52 | } else { 53 | this.plugin.settings.outputFileName = value; 54 | } 55 | this.plugin.saveSettings(); 56 | }) 57 | .setValue(this.plugin.settings.outputFileName) 58 | ); 59 | 60 | new Setting(containerEl) 61 | .setName("Disable working links") 62 | .setDesc( 63 | "Indent lines to disable the link and to clean up the graph view" 64 | ) 65 | .addToggle((cb) => 66 | cb 67 | .onChange((value) => { 68 | this.plugin.settings.disableWorkingLinks = value; 69 | this.plugin.saveSettings(); 70 | }) 71 | .setValue(this.plugin.settings.disableWorkingLinks) 72 | ); 73 | 74 | new Setting(containerEl) 75 | .setName("Exclude files in the given directories") 76 | .setDesc( 77 | "Enable to exclude files in the given directories. Disable to only include files in the given directories" 78 | ) 79 | .addToggle((cb) => 80 | cb 81 | .setValue(this.plugin.settings.ignoreDirectories) 82 | .onChange((value) => { 83 | this.plugin.settings.ignoreDirectories = value; 84 | this.plugin.saveSettings(); 85 | }) 86 | ); 87 | 88 | new Setting(containerEl) 89 | .setName("Directories") 90 | .setDesc("Add each directory path in a new line") 91 | .addTextArea((cb) => 92 | cb 93 | .setPlaceholder("Directory/Subdirectory") 94 | .setValue( 95 | this.plugin.settings.directoriesToIgnore.join("\n") 96 | ) 97 | .onChange((value) => { 98 | let paths = value 99 | .trim() 100 | .split("\n") 101 | .map((value) => this.formatPath(value, true)); 102 | this.plugin.settings.directoriesToIgnore = paths; 103 | this.plugin.saveSettings(); 104 | }) 105 | ); 106 | new Setting(containerEl) 107 | .setName("Exclude files") 108 | .setDesc("Add each file path in a new line (with file extension!)") 109 | .addTextArea((cb) => 110 | cb 111 | .setPlaceholder("Directory/file.md") 112 | .setValue(this.plugin.settings.filesToIgnore.join("\n")) 113 | .onChange((value) => { 114 | let paths = value 115 | .trim() 116 | .split("\n") 117 | .map((value) => this.formatPath(value, false)); 118 | this.plugin.settings.filesToIgnore = paths; 119 | this.plugin.saveSettings(); 120 | }) 121 | ); 122 | new Setting(containerEl) 123 | .setName("Exclude links") 124 | .setDesc( 125 | "Exclude files, which contain the given file as link. Add each file path in a new line (with file extension!). Set it to `*` to exclude files with links." 126 | ) 127 | .addTextArea((cb) => 128 | cb 129 | .setPlaceholder("Directory/file.md") 130 | .setValue(this.plugin.settings.linksToIgnore.join("\n")) 131 | .onChange((value) => { 132 | let paths = value 133 | .trim() 134 | .split("\n") 135 | .map((value) => this.formatPath(value, false)); 136 | this.plugin.settings.linksToIgnore = paths; 137 | this.plugin.saveSettings(); 138 | }) 139 | ); 140 | new Setting(containerEl) 141 | .setName("Exclude files with the given filetypes") 142 | .setDesc( 143 | "Enable to exclude files with the given filetypes. Disable to only include files with the given filetypes" 144 | ) 145 | .addToggle((cb) => 146 | cb 147 | .setValue(this.plugin.settings.ignoreFileTypes) 148 | .onChange((value) => { 149 | this.plugin.settings.ignoreFileTypes = value; 150 | this.plugin.saveSettings(); 151 | }) 152 | ); 153 | new Setting(containerEl) 154 | .setName("File types") 155 | .setDesc("Effect depends on toggle above") 156 | .addTextArea((cb) => 157 | cb 158 | .setPlaceholder("docx,txt") 159 | .setValue(this.plugin.settings.fileTypesToIgnore.join(",")) 160 | .onChange((value) => { 161 | let extensions = value.trim().split(","); 162 | this.plugin.settings.fileTypesToIgnore = extensions; 163 | this.plugin.saveSettings(); 164 | }) 165 | ); 166 | new Setting(containerEl) 167 | .setName("Exclude tags") 168 | .setDesc( 169 | "Exclude files, which contain the given tag. Add each tag separated by comma (without `#`)" 170 | ) 171 | .addTextArea((cb) => 172 | cb 173 | .setPlaceholder("todo,unfinished") 174 | .setValue(this.plugin.settings.tagsToIgnore.join(",")) 175 | .onChange((value) => { 176 | let tags = value.trim().split(","); 177 | this.plugin.settings.tagsToIgnore = tags; 178 | this.plugin.saveSettings(); 179 | }) 180 | ); 181 | new Setting(containerEl) 182 | .setName("Filetypes to delete per command. See README.") 183 | .setDesc( 184 | "Add each filetype separated by comma. Set to `*` to delete all files." 185 | ) 186 | .addTextArea((cb) => 187 | cb 188 | .setPlaceholder("jpg,png") 189 | .setValue(this.plugin.settings.fileTypesToDelete.join(",")) 190 | .onChange((value) => { 191 | let extensions = value.trim().split(","); 192 | this.plugin.settings.fileTypesToDelete = extensions; 193 | this.plugin.saveSettings(); 194 | }) 195 | ); 196 | 197 | /// Settings for find brokenLinks 198 | containerEl.createEl("h4", { 199 | text: "Settings for finding broken links", 200 | }); 201 | 202 | new Setting(containerEl) 203 | .setName("Output file name") 204 | .setDesc( 205 | "Set name of output file (without file extension). Make sure no file exists with this name because it will be overwritten! If the name is empty, the default name is set." 206 | ) 207 | .addText((cb) => 208 | cb 209 | .onChange((value) => { 210 | if (value.length == 0) { 211 | this.plugin.settings.unresolvedLinksOutputFileName = 212 | this.defaultSettings.unresolvedLinksOutputFileName; 213 | } else { 214 | this.plugin.settings.unresolvedLinksOutputFileName = 215 | value; 216 | } 217 | this.plugin.saveSettings(); 218 | }) 219 | .setValue( 220 | this.plugin.settings.unresolvedLinksOutputFileName 221 | ) 222 | ); 223 | 224 | new Setting(containerEl) 225 | .setName("Exclude files in the given directories") 226 | .setDesc( 227 | "Enable to exclude files in the given directories. Disable to only include files in the given directories" 228 | ) 229 | .addToggle((cb) => 230 | cb 231 | .setValue( 232 | this.plugin.settings.unresolvedLinksIgnoreDirectories 233 | ) 234 | .onChange((value) => { 235 | this.plugin.settings.unresolvedLinksIgnoreDirectories = 236 | value; 237 | this.plugin.saveSettings(); 238 | }) 239 | ); 240 | 241 | new Setting(containerEl) 242 | .setName("Directories") 243 | .setDesc("Add each directory path in a new line") 244 | .addTextArea((cb) => 245 | cb 246 | .setPlaceholder("Directory/Subdirectory") 247 | .setValue( 248 | this.plugin.settings.unresolvedLinksDirectoriesToIgnore.join( 249 | "\n" 250 | ) 251 | ) 252 | .onChange((value) => { 253 | let paths = value 254 | .trim() 255 | .split("\n") 256 | .map((value) => this.formatPath(value, true)); 257 | this.plugin.settings.unresolvedLinksDirectoriesToIgnore = 258 | paths; 259 | this.plugin.saveSettings(); 260 | }) 261 | ); 262 | 263 | new Setting(containerEl) 264 | .setName("Exclude files") 265 | .setDesc( 266 | "Exclude links in the specified file. Add each file path in a new line (with file extension!)" 267 | ) 268 | .addTextArea((cb) => 269 | cb 270 | .setPlaceholder("Directory/file.md") 271 | .setValue( 272 | this.plugin.settings.unresolvedLinksFilesToIgnore.join( 273 | "\n" 274 | ) 275 | ) 276 | .onChange((value) => { 277 | let paths = value 278 | .trim() 279 | .split("\n") 280 | .map((value) => this.formatPath(value, false)); 281 | this.plugin.settings.unresolvedLinksFilesToIgnore = 282 | paths; 283 | this.plugin.saveSettings(); 284 | }) 285 | ); 286 | new Setting(containerEl) 287 | .setName("Exclude links") 288 | .setDesc( 289 | "Exclude files, which contain the given file as link. Add each file path in a new line (with file extension!). Set it to `*` to exclude files with links." 290 | ) 291 | .addTextArea((cb) => 292 | cb 293 | .setPlaceholder("Directory/file.md") 294 | .setValue( 295 | this.plugin.settings.unresolvedLinksLinksToIgnore.join( 296 | "\n" 297 | ) 298 | ) 299 | .onChange((value) => { 300 | let paths = value 301 | .trim() 302 | .split("\n") 303 | .map((value) => this.formatPath(value, false)); 304 | this.plugin.settings.unresolvedLinksLinksToIgnore = 305 | paths; 306 | this.plugin.saveSettings(); 307 | }) 308 | ); 309 | new Setting(containerEl) 310 | .setName("Exclude filetypes") 311 | .setDesc( 312 | "Exclude links with the specified filetype. Add each filetype separated by comma" 313 | ) 314 | .addTextArea((cb) => 315 | cb 316 | .setPlaceholder("docx,txt") 317 | .setValue( 318 | this.plugin.settings.unresolvedLinksFileTypesToIgnore.join( 319 | "," 320 | ) 321 | ) 322 | .onChange((value) => { 323 | let extensions = value.trim().split(","); 324 | this.plugin.settings.unresolvedLinksFileTypesToIgnore = 325 | extensions; 326 | this.plugin.saveSettings(); 327 | }) 328 | ); 329 | new Setting(containerEl) 330 | .setName("Exclude tags") 331 | .setDesc( 332 | "Exclude links in files, which contain the given tag. Add each tag separated by comma (without `#`)" 333 | ) 334 | .addTextArea((cb) => 335 | cb 336 | .setPlaceholder("todo,unfinished") 337 | .setValue( 338 | this.plugin.settings.unresolvedLinksTagsToIgnore.join( 339 | "," 340 | ) 341 | ) 342 | .onChange((value) => { 343 | let tags = value.trim().split(","); 344 | this.plugin.settings.unresolvedLinksTagsToIgnore = tags; 345 | this.plugin.saveSettings(); 346 | }) 347 | ); 348 | 349 | containerEl.createEl("h4", { 350 | text: "Settings for finding files without tags", 351 | }); 352 | 353 | new Setting(containerEl) 354 | .setName("Output file name") 355 | .setDesc( 356 | "Set name of output file (without file extension). Make sure no file exists with this name because it will be overwritten! If the name is empty, the default name is set." 357 | ) 358 | .addText((cb) => 359 | cb 360 | .onChange((value) => { 361 | if (value.length == 0) { 362 | this.plugin.settings.withoutTagsOutputFileName = 363 | this.defaultSettings.withoutTagsOutputFileName; 364 | } else { 365 | this.plugin.settings.withoutTagsOutputFileName = 366 | value; 367 | } 368 | this.plugin.saveSettings(); 369 | }) 370 | .setValue(this.plugin.settings.withoutTagsOutputFileName) 371 | ); 372 | 373 | new Setting(containerEl) 374 | .setName("Exclude files") 375 | .setDesc( 376 | "Exclude the specific files. Add each file path in a new line (with file extension!)" 377 | ) 378 | .addTextArea((cb) => 379 | cb 380 | .setPlaceholder("Directory/file.md") 381 | .setValue( 382 | this.plugin.settings.withoutTagsFilesToIgnore.join("\n") 383 | ) 384 | .onChange((value) => { 385 | let paths = value 386 | .trim() 387 | .split("\n") 388 | .map((value) => this.formatPath(value, false)); 389 | this.plugin.settings.withoutTagsFilesToIgnore = paths; 390 | this.plugin.saveSettings(); 391 | }) 392 | ); 393 | 394 | new Setting(containerEl) 395 | .setName("Exclude directories") 396 | .setDesc( 397 | "Exclude files in the specified directories. Add each directory path in a new line" 398 | ) 399 | .addTextArea((cb) => 400 | cb 401 | .setPlaceholder("Directory/Subdirectory") 402 | .setValue( 403 | this.plugin.settings.withoutTagsDirectoriesToIgnore.join( 404 | "\n" 405 | ) 406 | ) 407 | .onChange((value) => { 408 | let paths = value 409 | .trim() 410 | .split("\n") 411 | .map((value) => this.formatPath(value, true)); 412 | this.plugin.settings.withoutTagsDirectoriesToIgnore = 413 | paths; 414 | this.plugin.saveSettings(); 415 | }) 416 | ); 417 | 418 | /// Settings for empty files 419 | containerEl.createEl("h4", { 420 | text: "Settings for finding empty files", 421 | }); 422 | 423 | new Setting(containerEl) 424 | .setName("Output file name") 425 | .setDesc( 426 | "Set name of output file (without file extension). Make sure no file exists with this name because it will be overwritten! If the name is empty, the default name is set." 427 | ) 428 | .addText((cb) => 429 | cb 430 | .onChange((value) => { 431 | if (value.length == 0) { 432 | this.plugin.settings.emptyFilesOutputFileName = 433 | this.defaultSettings.emptyFilesOutputFileName; 434 | } else { 435 | this.plugin.settings.emptyFilesOutputFileName = 436 | value; 437 | } 438 | this.plugin.saveSettings(); 439 | }) 440 | .setValue(this.plugin.settings.emptyFilesOutputFileName) 441 | ); 442 | 443 | new Setting(containerEl) 444 | .setName("Exclude files in the given directories") 445 | .setDesc( 446 | "Enable to exclude files in the given directories. Disable to only include files in the given directories" 447 | ) 448 | .addToggle((cb) => 449 | cb 450 | .setValue(this.plugin.settings.emptyFilesIgnoreDirectories) 451 | .onChange((value) => { 452 | this.plugin.settings.emptyFilesIgnoreDirectories = 453 | value; 454 | this.plugin.saveSettings(); 455 | }) 456 | ); 457 | 458 | new Setting(containerEl) 459 | .setName("Directories") 460 | .setDesc("Add each directory path in a new line") 461 | .addTextArea((cb) => 462 | cb 463 | .setPlaceholder("Directory/Subdirectory") 464 | .setValue( 465 | this.plugin.settings.emptyFilesDirectories.join("\n") 466 | ) 467 | .onChange((value) => { 468 | let paths = value 469 | .trim() 470 | .split("\n") 471 | .map((value) => this.formatPath(value, true)); 472 | this.plugin.settings.emptyFilesDirectories = paths; 473 | this.plugin.saveSettings(); 474 | }) 475 | ); 476 | new Setting(containerEl) 477 | .setName("Exclude files") 478 | .setDesc("Add each file path in a new line (with file extension!)") 479 | .addTextArea((cb) => 480 | cb 481 | .setPlaceholder("Directory/file.md") 482 | .setValue( 483 | this.plugin.settings.emptyFilesFilesToIgnore.join("\n") 484 | ) 485 | .onChange((value) => { 486 | let paths = value 487 | .trim() 488 | .split("\n") 489 | .map((value) => this.formatPath(value, false)); 490 | this.plugin.settings.emptyFilesFilesToIgnore = paths; 491 | this.plugin.saveSettings(); 492 | }) 493 | ); 494 | 495 | new Setting(containerEl) 496 | .setName("Donate") 497 | .setDesc( 498 | "If you like this Plugin, consider donating to support continued development." 499 | ) 500 | .addButton((bt) => { 501 | bt.buttonEl.outerHTML = 502 | "Buy Me a Coffee at ko-fi.com"; 503 | }); 504 | } 505 | } 506 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | CachedMetadata, 4 | getAllTags, 5 | iterateCacheRefs, 6 | TFile, 7 | } from "obsidian"; 8 | 9 | export class Utils { 10 | private fileCache: CachedMetadata; 11 | 12 | /** 13 | * Checks for the given settings. Is used for `Find orphaned files` and `Find broken links` 14 | * @param app 15 | * @param filePath 16 | * @param tagsToIgnore 17 | * @param linksToIgnore 18 | * @param directoriesToIgnore 19 | * @param filesToIgnore 20 | * @param ignoreDirectories 21 | */ 22 | constructor( 23 | private app: App, 24 | private filePath: string, 25 | private tagsToIgnore: string[], 26 | private linksToIgnore: string[], 27 | private directoriesToIgnore: string[], 28 | private filesToIgnore: string[], 29 | private ignoreDirectories: boolean = true, 30 | private dir?: string 31 | ) { 32 | this.fileCache = app.metadataCache.getCache(filePath); 33 | } 34 | 35 | private hasTagsToIgnore(): boolean { 36 | const tags = getAllTags(this.fileCache); 37 | return ( 38 | tags?.find((tag) => 39 | this.tagsToIgnore.contains(tag.substring(1)) 40 | ) !== undefined 41 | ); 42 | } 43 | private hasLinksToIgnore(): boolean { 44 | if ( 45 | (this.fileCache?.embeds != null || this.fileCache?.links != null) && 46 | this.linksToIgnore[0] == "*" 47 | ) { 48 | return true; 49 | } 50 | 51 | return iterateCacheRefs(this.fileCache, (cb) => { 52 | const link = this.app.metadataCache.getFirstLinkpathDest( 53 | cb.link, 54 | this.filePath 55 | )?.path; 56 | return this.linksToIgnore.contains(link); 57 | }); 58 | } 59 | 60 | private checkDirectory(): boolean { 61 | if (this.dir) { 62 | if (!this.filePath.startsWith(this.dir)) { 63 | return true; 64 | } 65 | } 66 | 67 | const contains = 68 | this.directoriesToIgnore.find( 69 | (value) => value.length != 0 && this.filePath.startsWith(value) 70 | ) !== undefined; 71 | if (this.ignoreDirectories) { 72 | return contains; 73 | } else { 74 | return !contains; 75 | } 76 | } 77 | 78 | private isFileToIgnore() { 79 | return this.filesToIgnore.contains(this.filePath); 80 | } 81 | 82 | public shouldIgnoreFile() { 83 | return ( 84 | this.hasTagsToIgnore() || 85 | this.hasLinksToIgnore() || 86 | this.checkDirectory() || 87 | this.isFileToIgnore() 88 | ); 89 | } 90 | 91 | /** 92 | * Writes the text to the file and opens the file in a new pane if it is not opened yet 93 | * @param app 94 | * @param outputFileName name of the output file 95 | * @param text data to be written to the file 96 | */ 97 | static async writeAndOpenFile( 98 | app: App, 99 | outputFileName: string, 100 | text: string, 101 | openFile: boolean 102 | ) { 103 | await app.vault.adapter.write(outputFileName, text); 104 | if (!openFile) return; 105 | 106 | let fileIsAlreadyOpened = false; 107 | app.workspace.iterateAllLeaves((leaf) => { 108 | if ( 109 | leaf.getDisplayText() != "" && 110 | outputFileName.startsWith(leaf.getDisplayText()) 111 | ) { 112 | fileIsAlreadyOpened = true; 113 | } 114 | }); 115 | if (!fileIsAlreadyOpened) { 116 | const newPane = app.workspace.getLeavesOfType("empty").length == 0; 117 | if (newPane) { 118 | app.workspace.openLinkText(outputFileName, "/", true); 119 | } else { 120 | const file = app.vault.getAbstractFileByPath(outputFileName); 121 | 122 | if (file instanceof TFile) { 123 | await app.workspace 124 | .getLeavesOfType("empty")[0] 125 | .openFile(file); 126 | } else { 127 | app.workspace.openLinkText(outputFileName, "/", true); 128 | } 129 | } 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es5", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } --------------------------------------------------------------------------------