├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── .gitignore ├── .npmrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cspell.json ├── eslint.config.mts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── InvalidCharacterAction.ts ├── Plugin.ts ├── PluginSettings.ts ├── PluginSettingsManager.ts ├── PluginSettingsTab.ts ├── PluginTypes.ts └── main.ts ├── tsconfig.json └── 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 = space 9 | indent_size = 2 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | buy_me_a_coffee: mnaoumov 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug and help improve the plugin 3 | title: "[BUG] Short description of the bug" 4 | labels: bug 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Bug report 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the bug. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Steps to Reproduce 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected Behavior 31 | description: What did you expect to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Actual Behavior 37 | description: What actually happened? Include error messages if available. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Environment Information 43 | description: Environment Information 44 | value: | 45 | - **Plugin Version**: [e.g., 1.0.0] 46 | - **Obsidian Version**: [e.g., v1.3.2] 47 | - **Operating System**: [e.g., Windows 10] 48 | validations: 49 | required: true 50 | - type: textarea 51 | attributes: 52 | label: Attachments 53 | description: Required for bug reproduction 54 | value: | 55 | - Please attach a video showing the bug. It is not mandatory, but might be very helpful to speed up the bug fix 56 | - Please attach a sample vault where the bug can be reproduced. It is not mandatory, but might be very helpful to speed up the bug fix 57 | validations: 58 | required: true 59 | - type: checkboxes 60 | attributes: 61 | label: Confirmations 62 | description: Ensure the following conditions are met 63 | options: 64 | - label: I attached a video showing the bug, or it is not necessary 65 | required: true 66 | - label: I attached a sample vault where the bug can be reproduced, or it is not necessary 67 | required: true 68 | - label: I have tested the bug with the latest version of the plugin 69 | required: true 70 | - label: I have checked GitHub for existing bugs 71 | required: true 72 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature request 2 | description: Request a feature and help improve the plugin 3 | title: "[FR] Short description of the feature" 4 | labels: enhancement 5 | assignees: mnaoumov 6 | body: 7 | - type: markdown 8 | attributes: 9 | value: | 10 | ## Feature Request 11 | - type: textarea 12 | attributes: 13 | label: Description 14 | description: A clear and concise description of the feature request. Include any relevant details. 15 | validations: 16 | required: true 17 | - type: textarea 18 | attributes: 19 | label: Details 20 | description: Provide a step-by-step description. 21 | value: | 22 | 1. Go to '...' 23 | 2. Click on '...' 24 | 3. Notice that '...' 25 | ... 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Desired Behavior 31 | description: What do you want to happen? 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Current Behavior 37 | description: What actually happens? 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Attachments 43 | description: Required for feature investigation 44 | value: | 45 | - Please attach a video showing the current behavior. It is not mandatory, but might be very helpful to speed up the feature implementation 46 | - Please attach a sample vault where the desired Feature Request could be applied. It is not mandatory, but might be very helpful to speed up the feature implementation 47 | validations: 48 | required: true 49 | - type: checkboxes 50 | attributes: 51 | label: Confirmations 52 | description: Ensure the following conditions are met 53 | options: 54 | - label: I attached a video showing the current behavior, or it is not necessary 55 | required: true 56 | - label: I attached a sample vault where the desired Feature Request could be applied, or it is not necessary 57 | required: true 58 | - label: I have tested the absence of the requested feature with the latest version of the plugin 59 | required: true 60 | - label: I have checked GitHub for existing Feature Requests 61 | required: true 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | dist 25 | .env 26 | /tsconfig.tsbuildinfo -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.0.22 4 | 5 | - Update libs 6 | 7 | ## 2.0.21 8 | 9 | - Update libs 10 | 11 | ## 2.0.20 12 | 13 | - Improve performance 14 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.15.2 15 | 16 | ## 2.0.19 17 | 18 | - Fix initialization 19 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/26.9.0 20 | 21 | ## 2.0.18 22 | 23 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.1 24 | 25 | ## 2.0.17 26 | 27 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.1.0 28 | 29 | ## 2.0.16 30 | 31 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/24.0.1 32 | 33 | ## 2.0.15 34 | 35 | - Update libs 36 | - New template 37 | - Update README 38 | - ESLint template 39 | 40 | ## 2.0.14 41 | 42 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.17.1 43 | 44 | ## 2.0.13 45 | 46 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.6.0 47 | 48 | ## 2.0.12 49 | 50 | - Update libs to make patch 51 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/19.2.1 52 | 53 | ## 2.0.11 54 | 55 | - Update template 56 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/18.4.2 57 | 58 | ## 2.0.10 59 | 60 | - Lint 61 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/17.2.2 62 | 63 | ## 2.0.9 64 | 65 | - Format 66 | 67 | ## 2.0.8 68 | 69 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.1.0 70 | 71 | ## 2.0.7 72 | 73 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/16.0.3 74 | 75 | ## 2.0.6 76 | 77 | - https://github.com/mnaoumov/obsidian-dev-utils/releases/tag/15.0.0 78 | 79 | ## 2.0.5 80 | 81 | - Update libs 82 | 83 | ## 2.0.4 84 | 85 | - Update libs 86 | 87 | ## 2.0.3 88 | 89 | - Update libs 90 | - Avoid default exports 91 | 92 | ## 2.0.2 93 | 94 | - Update libs 95 | 96 | ## 2.0.1 97 | 98 | - Update libs 99 | 100 | ## 2.0.0 101 | 102 | - Add support to case changing 103 | - Check for title starting with dot 104 | - Add file menu 105 | - Handle link from the same file 106 | - Use newer prompt 107 | 108 | # 1.1.2 109 | 110 | - Minor refactoring 111 | 112 | # 1.1.1 113 | 114 | - Minor refactoring 115 | 116 | # 1.1.0 117 | 118 | - Improve performance 119 | - Add process of invalid title characters 120 | - Store invalid title as an alias 121 | - Store title into title frontmatter key 122 | - Store title into first heading 123 | 124 | # 1.0.1 125 | 126 | - Applied suggestions after [code review](https://github.com/obsidianmd/obsidian-releases/pull/1782#issuecomment-1482613623) 127 | 128 | # 1.0.0 129 | 130 | - Initial version 131 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Michael Naumov 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 | # Smart Rename 2 | 3 | This is a plugin for [Obsidian](https://obsidian.md/) that adds the command `Smart Rename` which performs the following steps after renaming the note: 4 | 5 | 1. Adds the previous title as an alias to the renamed note 6 | 2. Preserves the backlinks to the renamed note that were using previous title as a display text. 7 | 8 | ## Detailed explanation 9 | 10 | 1. You have 11 | 12 | `OldName.md`: 13 | 14 | ```markdown 15 | This is a note `OldName.md` that is going to be renamed to `NewName.md`. 16 | ``` 17 | 18 | `OtherNote.md`: 19 | 20 | ```markdown 21 | This note references 22 | 23 | 1. Wikilink [[OldName]] 24 | 2. Wikilink with the same display text [[OldName|OldName]] 25 | 3. Wikilink with a custom display text [[OldName|Custom display text]] 26 | 4. Markdown link [OldName](OldName.md) 27 | 5. Markdown link with a custom display text [Custom display text](OldName.md) 28 | ``` 29 | 30 | 2. You invoke current plugin providing `NewName` as a new title 31 | 32 | 3. Now you have 33 | 34 | `NewName.md`: 35 | 36 | ```markdown 37 | --- 38 | aliases: 39 | - OldName 40 | --- 41 | 42 | This is a note `OldName.md` that is going to be renamed to `NewName.md`. 43 | ``` 44 | 45 | `OtherNote.md`: 46 | 47 | ```markdown 48 | This note references 49 | 50 | 1. Wikilink [[NewName|OldName]] 51 | 2. Wikilink with the same display text [[NewName|OldName]] 52 | 3. Wikilink with a custom display text [[NewName|Custom display text]] 53 | 4. Markdown link [OldName](NewName.md) 54 | 5. Markdown link with a custom display text [Custom display text](NewName.md) 55 | ``` 56 | 57 | Current plugin's aim is to preserve `OldName` display text in links 1, 2, 4 58 | 59 | ## Installation 60 | 61 | The plugin is available in [the official Community Plugins repository](https://obsidian.md/plugins?id=smart-rename). 62 | 63 | ### Beta versions 64 | 65 | To install the latest beta release of this plugin (regardless if it is available in [the official Community Plugins repository](https://obsidian.md/plugins) or not), follow these steps: 66 | 67 | 1. Ensure you have the [BRAT plugin](https://obsidian.md/plugins?id=obsidian42-brat) installed and enabled. 68 | 2. Click [Install via BRAT](https://intradeus.github.io/http-protocol-redirector?r=obsidian://brat?plugin=https://github.com/mnaoumov/obsidian-smart-rename). 69 | 3. An Obsidian pop-up window should appear. In the window, click the `Add plugin` button once and wait a few seconds for the plugin to install. 70 | 71 | ## Debugging 72 | 73 | By default, debug messages for this plugin are hidden. 74 | 75 | To show them, run the following command: 76 | 77 | ```js 78 | window.DEBUG.enable('smart-rename'); 79 | ``` 80 | 81 | For more details, refer to the [documentation](https://github.com/mnaoumov/obsidian-dev-utils/blob/main/docs/debugging.md). 82 | 83 | ## Support 84 | 85 | Buy Me A Coffee 86 | 87 | ## License 88 | 89 | © [Michael Naumov](https://github.com/mnaoumov/) 90 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "ignorePaths": [ 4 | "dist", 5 | "node_modules", 6 | "tsconfig.tsbuildinfo" 7 | ], 8 | "dictionaryDefinitions": [], 9 | "dictionaries": [], 10 | "words": [ 11 | "backlink", 12 | "backlinks", 13 | "frontmatter", 14 | "Jsons", 15 | "mnaoumov", 16 | "Naumov", 17 | "Promisable", 18 | "tsbuildinfo", 19 | "Wikilink" 20 | ], 21 | "ignoreWords": [], 22 | "import": [], 23 | "enabled": true 24 | } 25 | -------------------------------------------------------------------------------- /eslint.config.mts: -------------------------------------------------------------------------------- 1 | import { configs } from 'obsidian-dev-utils/ScriptUtils/ESLint/eslint.config'; 2 | 3 | // eslint-disable-next-line import-x/no-default-export 4 | export default configs; 5 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "smart-rename", 3 | "name": "Smart Rename", 4 | "version": "2.0.22", 5 | "minAppVersion": "1.8.10", 6 | "description": "Renames notes keeping previous title in existing links", 7 | "author": "mnaoumov", 8 | "authorUrl": "https://github.com/mnaoumov", 9 | "isDesktopOnly": false, 10 | "fundingUrl": "https://www.buymeacoffee.com/mnaoumov" 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "smart-rename", 3 | "version": "2.0.22", 4 | "description": "Renames notes keeping previous title in existing links", 5 | "scripts": { 6 | "build": "obsidian-dev-utils build", 7 | "build:clean": "obsidian-dev-utils build:clean", 8 | "build:compile": "obsidian-dev-utils build:compile", 9 | "build:compile:svelte": "obsidian-dev-utils build:compile:svelte", 10 | "build:compile:typescript": "obsidian-dev-utils build:compile:typescript", 11 | "dev": "obsidian-dev-utils dev", 12 | "format": "obsidian-dev-utils format", 13 | "format:check": "obsidian-dev-utils format:check", 14 | "lint": "obsidian-dev-utils lint", 15 | "lint:fix": "obsidian-dev-utils lint:fix", 16 | "spellcheck": "obsidian-dev-utils spellcheck", 17 | "version": "obsidian-dev-utils version" 18 | }, 19 | "keywords": [], 20 | "author": "mnaoumov", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "@tsconfig/strictest": "^2.0.5", 24 | "@types/node": "^22.15.21", 25 | "jiti": "^2.4.2", 26 | "obsidian": "^1.8.7", 27 | "obsidian-dev-utils": "^26.29.2", 28 | "obsidian-typings": "^3.9.5" 29 | }, 30 | "type": "module" 31 | } 32 | -------------------------------------------------------------------------------- /src/InvalidCharacterAction.ts: -------------------------------------------------------------------------------- 1 | export enum InvalidCharacterAction { 2 | Error = 'Error', 3 | Remove = 'Remove', 4 | Replace = 'Replace' 5 | } 6 | -------------------------------------------------------------------------------- /src/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Menu, 3 | Reference, 4 | TAbstractFile 5 | } from 'obsidian'; 6 | import type { GenerateMarkdownLinkOptions } from 'obsidian-dev-utils/obsidian/Link'; 7 | import type { CustomArrayDict } from 'obsidian-typings'; 8 | 9 | import { 10 | Notice, 11 | Platform, 12 | TFile 13 | } from 'obsidian'; 14 | import { invokeAsyncSafely } from 'obsidian-dev-utils/Async'; 15 | import { 16 | normalizeOptionalProperties, 17 | toJson 18 | } from 'obsidian-dev-utils/Object'; 19 | import { 20 | addAlias, 21 | processFrontmatter 22 | } from 'obsidian-dev-utils/obsidian/FileManager'; 23 | import { getFile } from 'obsidian-dev-utils/obsidian/FileSystem'; 24 | import { 25 | editLinks, 26 | extractLinkFile, 27 | generateMarkdownLink 28 | } from 'obsidian-dev-utils/obsidian/Link'; 29 | import { 30 | getBacklinksForFileSafe, 31 | getCacheSafe 32 | } from 'obsidian-dev-utils/obsidian/MetadataCache'; 33 | import { prompt } from 'obsidian-dev-utils/obsidian/Modals/Prompt'; 34 | import { PluginBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginBase'; 35 | import { addToQueue } from 'obsidian-dev-utils/obsidian/Queue'; 36 | import { process } from 'obsidian-dev-utils/obsidian/Vault'; 37 | import { 38 | basename, 39 | extname, 40 | join 41 | } from 'obsidian-dev-utils/Path'; 42 | import { escapeRegExp } from 'obsidian-dev-utils/RegExp'; 43 | import { insertAt } from 'obsidian-dev-utils/String'; 44 | 45 | import type { PluginTypes } from './PluginTypes.ts'; 46 | 47 | import { InvalidCharacterAction } from './InvalidCharacterAction.ts'; 48 | import { PluginSettingsManager } from './PluginSettingsManager.ts'; 49 | import { PluginSettingsTab } from './PluginSettingsTab.ts'; 50 | 51 | export class Plugin extends PluginBase { 52 | private invalidCharactersRegExp!: RegExp; 53 | 54 | public hasInvalidCharacters(str: string): boolean { 55 | return this.invalidCharactersRegExp.test(str); 56 | } 57 | 58 | protected override createSettingsManager(): PluginSettingsManager { 59 | return new PluginSettingsManager(this); 60 | } 61 | 62 | protected override createSettingsTab(): null | PluginSettingsTab { 63 | return new PluginSettingsTab(this); 64 | } 65 | 66 | protected override async onloadImpl(): Promise { 67 | const OBSIDIAN_FORBIDDEN_CHARACTERS = '#^[]|'; 68 | const SYSTEM_FORBIDDEN_CHARACTERS = Platform.isWin ? '*\\/<>:|?"' : '\0/'; 69 | const invalidCharacters = Array.from(new Set([...OBSIDIAN_FORBIDDEN_CHARACTERS.split(''), ...SYSTEM_FORBIDDEN_CHARACTERS.split('')])).join(''); 70 | this.invalidCharactersRegExp = new RegExp(`[${escapeRegExp(invalidCharacters)}]`, 'g'); 71 | 72 | await super.onloadImpl(); 73 | this.addCommand({ 74 | checkCallback: this.smartRenameCommandCheck.bind(this), 75 | id: 'smart-rename', 76 | name: 'Smart Rename' 77 | }); 78 | 79 | this.registerEvent(this.app.workspace.on('file-menu', (menu, file) => { 80 | this.fileMenuHandler(menu, file); 81 | })); 82 | } 83 | 84 | private async addAliases(newPath: string, oldTitle: string, titleToStore: string): Promise { 85 | const newTitle = basename(newPath, extname(newPath)); 86 | await addAlias(this.app, newPath, oldTitle); 87 | 88 | if (this.settings.shouldStoreInvalidTitle && titleToStore !== newTitle) { 89 | await addAlias(this.app, newPath, titleToStore); 90 | } 91 | } 92 | 93 | private fileMenuHandler(menu: Menu, file: TAbstractFile): void { 94 | if (!(file instanceof TFile)) { 95 | return; 96 | } 97 | 98 | menu.addItem((item) => 99 | item.setTitle('Smart Rename') 100 | .setIcon('edit-3') 101 | .onClick(() => { 102 | invokeAsyncSafely(() => this.smartRename(file)); 103 | }) 104 | ); 105 | } 106 | 107 | private async getValidationError(oldTitle: string, newTitle: string, newPath: string): Promise { 108 | if (!newTitle) { 109 | return 'No new title provided'; 110 | } 111 | 112 | if (newTitle === oldTitle) { 113 | return 'The title did not change'; 114 | } 115 | 116 | if (newTitle.toLowerCase() === oldTitle.toLowerCase()) { 117 | return null; 118 | } 119 | 120 | if (await this.app.vault.exists(newPath)) { 121 | return 'Note with the new title already exists'; 122 | } 123 | 124 | if (newTitle.startsWith('.')) { 125 | return 'The title cannot start with a dot'; 126 | } 127 | 128 | return null; 129 | } 130 | 131 | private async processBacklinks(oldPath: string, newPath: string, backlinks: CustomArrayDict): Promise { 132 | const newFile = getFile(this.app, newPath); 133 | const oldTitle = basename(oldPath, extname(oldPath)); 134 | const newTitle = newFile.basename; 135 | 136 | for (let backlinkNotePath of backlinks.keys()) { 137 | const links = backlinks.get(backlinkNotePath); 138 | if (!links) { 139 | continue; 140 | } 141 | 142 | if (backlinkNotePath === oldPath) { 143 | backlinkNotePath = newPath; 144 | } 145 | 146 | const linkJsons = new Set(links.map((link) => toJson(link))); 147 | 148 | await editLinks(this.app, backlinkNotePath, (link) => { 149 | if (extractLinkFile(this.app, link, backlinkNotePath) !== newFile && !linkJsons.has(toJson(link))) { 150 | return; 151 | } 152 | 153 | const alias = (link.displayText ?? '').toLowerCase() === newTitle.toLowerCase() ? oldTitle : link.displayText; 154 | 155 | return generateMarkdownLink(normalizeOptionalProperties({ 156 | alias, 157 | app: this.app, 158 | originalLink: link.original, 159 | sourcePathOrFile: backlinkNotePath, 160 | targetPathOrFile: newPath 161 | })); 162 | }); 163 | } 164 | } 165 | 166 | private async processRename(oldPath: string, newPath: string, titleToStore: string, backlinks: CustomArrayDict): Promise { 167 | const oldTitle = basename(oldPath, extname(oldPath)); 168 | await this.processBacklinks(oldPath, newPath, backlinks); 169 | await this.addAliases(newPath, oldTitle, titleToStore); 170 | await this.updateTitle(newPath, titleToStore); 171 | await this.updateFirstHeader(newPath, titleToStore); 172 | } 173 | 174 | private replaceInvalidCharacters(str: string, replacement: string): string { 175 | return str.replace(this.invalidCharactersRegExp, replacement); 176 | } 177 | 178 | private async smartRename(file: TFile): Promise { 179 | const oldTitle = file.basename; 180 | let newTitle = await prompt({ 181 | app: this.app, 182 | defaultValue: oldTitle, 183 | title: 'Enter new title' 184 | }) ?? ''; 185 | 186 | let titleToStore = newTitle; 187 | 188 | if (this.hasInvalidCharacters(newTitle)) { 189 | switch (this.settings.invalidCharacterAction) { 190 | case InvalidCharacterAction.Error: 191 | new Notice('The new title has invalid characters'); 192 | return; 193 | case InvalidCharacterAction.Remove: 194 | newTitle = this.replaceInvalidCharacters(newTitle, ''); 195 | break; 196 | case InvalidCharacterAction.Replace: 197 | newTitle = this.replaceInvalidCharacters(newTitle, this.settings.replacementCharacter); 198 | break; 199 | default: 200 | throw new Error('Invalid character action'); 201 | } 202 | } 203 | 204 | if (!this.settings.shouldStoreInvalidTitle) { 205 | titleToStore = newTitle; 206 | } 207 | 208 | const newPath = join(file.parent?.getParentPrefix() ?? '', `${newTitle}.md`); 209 | 210 | const validationError = await this.getValidationError(oldTitle, newTitle, newPath); 211 | if (validationError) { 212 | new Notice(validationError); 213 | return; 214 | } 215 | 216 | const backlinks = await getBacklinksForFileSafe(this.app, file); 217 | const oldPath = file.path; 218 | 219 | try { 220 | await this.app.vault.rename(file, newPath); 221 | } catch (error) { 222 | new Notice('Failed to rename file'); 223 | console.error(new Error('Failed to rename file', { cause: error })); 224 | return; 225 | } 226 | 227 | addToQueue(this.app, async () => { 228 | await this.processRename(oldPath, newPath, titleToStore, backlinks); 229 | }); 230 | } 231 | 232 | private smartRenameCommandCheck(checking: boolean): boolean { 233 | const activeFile = this.app.workspace.getActiveFile(); 234 | if (!activeFile) { 235 | return false; 236 | } 237 | 238 | if (!checking) { 239 | invokeAsyncSafely(() => this.smartRename(activeFile)); 240 | } 241 | return true; 242 | } 243 | 244 | private async updateFirstHeader(newPath: string, titleToStore: string): Promise { 245 | if (!this.settings.shouldUpdateFirstHeader) { 246 | return; 247 | } 248 | 249 | await process(this.app, newPath, async (content) => { 250 | const cache = await getCacheSafe(this.app, newPath); 251 | if (cache === null) { 252 | return null; 253 | } 254 | 255 | const firstHeading = cache.headings?.filter((h) => h.level === 1).sort((a, b) => a.position.start.offset - b.position.start.offset)[0]; 256 | if (!firstHeading) { 257 | return content; 258 | } 259 | 260 | return insertAt(content, `# ${titleToStore}`, firstHeading.position.start.offset, firstHeading.position.end.offset); 261 | }); 262 | } 263 | 264 | private async updateTitle(newPath: string, titleToStore: string): Promise { 265 | if (!this.settings.shouldUpdateTitleKey) { 266 | return; 267 | } 268 | await processFrontmatter(this.app, newPath, (frontMatter) => { 269 | frontMatter['title'] = titleToStore; 270 | }); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /src/PluginSettings.ts: -------------------------------------------------------------------------------- 1 | import { InvalidCharacterAction } from './InvalidCharacterAction.ts'; 2 | 3 | export class PluginSettings { 4 | public invalidCharacterAction = InvalidCharacterAction.Error; 5 | 6 | public replacementCharacter = '_'; 7 | public shouldStoreInvalidTitle = true; 8 | public shouldUpdateFirstHeader = false; 9 | public shouldUpdateTitleKey = false; 10 | } 11 | -------------------------------------------------------------------------------- /src/PluginSettingsManager.ts: -------------------------------------------------------------------------------- 1 | import type { MaybeReturn } from 'obsidian-dev-utils/Type'; 2 | 3 | import { PluginSettingsManagerBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsManagerBase'; 4 | 5 | import type { PluginTypes } from './PluginTypes.ts'; 6 | 7 | import { PluginSettings } from './PluginSettings.ts'; 8 | 9 | export class PluginSettingsManager extends PluginSettingsManagerBase { 10 | protected override createDefaultSettings(): PluginSettings { 11 | return new PluginSettings(); 12 | } 13 | 14 | protected override registerValidators(): void { 15 | this.registerValidator('replacementCharacter', (value): MaybeReturn => { 16 | if (this.plugin.hasInvalidCharacters(value)) { 17 | return 'Invalid replacement character'; 18 | } 19 | }); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/PluginSettingsTab.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingsTabBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginSettingsTabBase'; 2 | import { SettingEx } from 'obsidian-dev-utils/obsidian/SettingEx'; 3 | 4 | import type { PluginTypes } from './PluginTypes.ts'; 5 | 6 | import { InvalidCharacterAction } from './InvalidCharacterAction.ts'; 7 | 8 | export class PluginSettingsTab extends PluginSettingsTabBase { 9 | public override display(): void { 10 | super.display(); 11 | this.containerEl.empty(); 12 | 13 | new SettingEx(this.containerEl) 14 | .setName('Invalid characters action') 15 | .setDesc('How to process invalid characters in the new title') 16 | .addDropdown((dropdown) => { 17 | dropdown.addOptions({ 18 | Error: 'Show error', 19 | Remove: 'Remove invalid characters', 20 | Replace: 'Replace invalid character with' 21 | }); 22 | this.bind(dropdown, 'invalidCharacterAction', { 23 | onChanged: () => { 24 | this.display(); 25 | } 26 | }); 27 | }); 28 | 29 | if (this.plugin.settings.invalidCharacterAction === InvalidCharacterAction.Replace) { 30 | new SettingEx(this.containerEl) 31 | .setName('Replacement character') 32 | .setDesc('Character to replace invalid character with') 33 | .addText((text) => { 34 | text.inputEl.maxLength = 1; 35 | text.inputEl.required = true; 36 | 37 | this.bind(text, 'replacementCharacter'); 38 | }); 39 | } 40 | 41 | if (this.plugin.settings.invalidCharacterAction !== InvalidCharacterAction.Error) { 42 | new SettingEx(this.containerEl) 43 | .setName('Store invalid title') 44 | .setDesc('If enabled, stores title with invalid characters. If disabled, stores the sanitized version') 45 | .addToggle((toggle) => { 46 | this.bind(toggle, 'shouldStoreInvalidTitle'); 47 | }); 48 | } 49 | 50 | new SettingEx(this.containerEl) 51 | .setName('Update title key') 52 | .setDesc('Update title key in frontmatter') 53 | .addToggle((toggle) => { 54 | this.bind(toggle, 'shouldUpdateTitleKey'); 55 | }); 56 | 57 | new SettingEx(this.containerEl) 58 | .setName('Update first header') 59 | .setDesc(createFragment((f) => { 60 | f.appendText('Update first header if it is present in the document. May conflict with the '); 61 | f.createEl('a', { 62 | attr: { 63 | href: 'https://obsidian.md/plugins?id=obsidian-filename-heading-sync' 64 | }, 65 | text: 'Filename Heading Sync' 66 | }); 67 | f.appendText(' plugin.'); 68 | })) 69 | .addToggle((toggle) => { 70 | this.bind(toggle, 'shouldUpdateFirstHeader'); 71 | }); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/PluginTypes.ts: -------------------------------------------------------------------------------- 1 | import type { PluginTypesBase } from 'obsidian-dev-utils/obsidian/Plugin/PluginTypesBase'; 2 | 3 | import type { Plugin } from './Plugin.ts'; 4 | import type { PluginSettings } from './PluginSettings.ts'; 5 | import type { PluginSettingsManager } from './PluginSettingsManager.ts'; 6 | import type { PluginSettingsTab } from './PluginSettingsTab.ts'; 7 | 8 | export interface PluginTypes extends PluginTypesBase { 9 | plugin: Plugin; 10 | pluginSettings: PluginSettings; 11 | pluginSettingsManager: PluginSettingsManager; 12 | pluginSettingsTab: PluginSettingsTab; 13 | } 14 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Plugin } from './Plugin.ts'; 2 | 3 | // eslint-disable-next-line import-x/no-default-export 4 | export default Plugin; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/strictest/tsconfig.json", 3 | "compilerOptions": { 4 | "allowImportingTsExtensions": true, 5 | "allowJs": true, 6 | "allowSyntheticDefaultImports": true, 7 | "baseUrl": ".", 8 | "forceConsistentCasingInFileNames": true, 9 | "importHelpers": true, 10 | "inlineSourceMap": true, 11 | "inlineSources": true, 12 | "lib": [ 13 | "DOM", 14 | "ESNext" 15 | ], 16 | "module": "NodeNext", 17 | "moduleResolution": "NodeNext", 18 | "noEmit": true, 19 | "target": "ESNext", 20 | "skipLibCheck": false, 21 | "types": [ 22 | "node", 23 | "obsidian-typings" 24 | ], 25 | "verbatimModuleSyntax": true 26 | }, 27 | "include": [ 28 | "./eslint.config.*ts", 29 | "./src/**/*.svelte", 30 | "./src/**/*.ts", 31 | "./src/**/*.tsx", 32 | "./scripts/**/*.ts" 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "1.1.0", 3 | "1.0.1": "1.1.0", 4 | "1.1.0": "1.1.0", 5 | "1.1.1": "1.1.0", 6 | "1.1.2": "1.1.0", 7 | "2.0.0": "1.7.5", 8 | "2.0.1": "1.7.6", 9 | "2.0.2": "1.7.7", 10 | "2.0.3": "1.7.7", 11 | "2.0.4": "1.7.7", 12 | "2.0.5": "1.7.7", 13 | "2.0.6": "1.7.7", 14 | "2.0.7": "1.7.7", 15 | "2.0.8": "1.7.7", 16 | "2.0.9": "1.7.7", 17 | "2.0.10": "1.8.3", 18 | "2.0.11": "1.8.4", 19 | "2.0.12": "1.8.4", 20 | "2.0.13": "1.8.7", 21 | "2.0.14": "1.8.9", 22 | "2.0.15": "1.8.9", 23 | "2.0.16": "1.8.9", 24 | "2.0.17": "1.8.9", 25 | "2.0.18": "1.8.9", 26 | "2.0.19": "1.8.9", 27 | "2.0.20": "1.8.10", 28 | "2.0.21": "1.8.10", 29 | "2.0.22": "1.8.10" 30 | } 31 | --------------------------------------------------------------------------------