├── .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 |
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 |
--------------------------------------------------------------------------------