├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── CI.yml │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── .npmrc ├── .prettierrc.json ├── LICENCE ├── README.md ├── babel.config.js ├── documentation └── assets │ └── Animation.gif ├── esbuild.config.mjs ├── jest-preset.js ├── jest.config.js ├── main.ts ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── components │ ├── DonateButton.ts │ ├── PreviewElement.ts │ ├── RegExpBackslash.ts │ └── RenderPreviewFiles.ts ├── constants │ ├── RegExpFlags.ts │ └── folders.ts ├── services │ ├── file.service.ts │ ├── file.services.test.ts │ ├── obsidian.service.test.ts │ ├── obsidian.service.ts │ └── settings.service.ts └── suggestions │ ├── RegExpFlagsSuggest.ts │ ├── folderSuggest.ts │ └── suggest.ts ├── styles.css ├── tests └── __mocks__ │ └── obsidian.ts ├── tsconfig.json ├── version-bump.mjs └── versions.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "jest/globals": true 8 | }, 9 | "plugins": ["@typescript-eslint", "jest"], 10 | "extends": [ 11 | "eslint:recommended", 12 | "plugin:@typescript-eslint/eslint-recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "parserOptions": { 16 | "sourceType": "module" 17 | }, 18 | "rules": { 19 | "no-unused-vars": "off", 20 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 21 | "@typescript-eslint/ban-ts-comment": "off", 22 | "no-prototype-builtins": "off", 23 | "@typescript-eslint/no-empty-function": "off" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | env: 12 | PLUGIN_NAME: obsidian-bulk-rename-plugin 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 16 24 | 25 | - name: Build 26 | id: build 27 | run: | 28 | npm install 29 | npm run build 30 | npm run test 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-bulk-rename-plugin # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Use Node.js 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: "14.x" 21 | 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | 45 | - name: Upload zip file 46 | id: upload-zip 47 | uses: actions/upload-release-asset@v1 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | with: 51 | upload_url: ${{ steps.create_release.outputs.upload_url }} 52 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 53 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 54 | asset_content_type: application/zip 55 | 56 | - name: Upload main.js 57 | id: upload-main 58 | uses: actions/upload-release-asset@v1 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | with: 62 | upload_url: ${{ steps.create_release.outputs.upload_url }} 63 | asset_path: ./main.js 64 | asset_name: main.js 65 | asset_content_type: text/javascript 66 | 67 | - name: Upload manifest.json 68 | id: upload-manifest 69 | uses: actions/upload-release-asset@v1 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | with: 73 | upload_url: ${{ steps.create_release.outputs.upload_url }} 74 | asset_path: ./manifest.json 75 | asset_name: manifest.json 76 | asset_content_type: application/json 77 | 78 | - name: Upload styles.css 79 | id: upload-css 80 | uses: actions/upload-release-asset@v1 81 | env: 82 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 83 | with: 84 | upload_url: ${{ steps.create_release.outputs.upload_url }} 85 | asset_path: ./styles.css 86 | asset_name: styles.css 87 | asset_content_type: text/css 88 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | tasks: 6 | - init: npm install && npm run build 7 | command: npm run dev 8 | 9 | 10 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "auto", 5 | "htmlWhitespaceSensitivity": "css", 6 | "jsxBracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 80, 9 | "proseWrap": "never", 10 | "quoteProps": "preserve", 11 | "semi": true, 12 | "singleQuote": true, 13 | "tabWidth": 2, 14 | "trailingComma": "all" 15 | } 16 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Oleg Lustenko 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 | # Obsidian Bulk Rename Plugin 2 | [![](https://github.com/OlegLustenko/obsidian-bulk-rename/actions/workflows/CI.yml/badge.svg)](https://github.com/OlegLustenko/obsidian-bulk-rename/actions/workflows/CI.yml) 3 | [![Release Obsidian plugin](https://github.com/OlegLustenko/obsidian-bulk-rename/actions/workflows/release.yml/badge.svg)](https://github.com/OlegLustenko/obsidian-bulk-rename/actions/workflows/release.yml) 4 | [![GitHub license](https://img.shields.io/github/license/OlegLustenko/obsidian-bulk-rename)](https://https://github.com/OlegLustenko/obsidian-bulk-rename/master/LICENSE) 5 | [![Github all releases](https://img.shields.io/github/downloads/OlegLustenko/obsidian-bulk-rename/total.svg)](https://github.com/OlegLustenko/obsidian-bulk-rename/releases/) 6 | [![GitLab latest release](https://badgen.net/github/release/OlegLustenko/obsidian-bulk-rename/)](https://github.com/OlegLustenko/obsidian-bulk-rename/releases) 7 | ## Introduction 8 | Now you can rename a bunch of files from the directory and all references also will be updated across the vault. 9 | 10 | ![](documentation/assets/Animation.gif) 11 | 12 | > Under the hood this plugin is using obsidian API for renaming, but we just applying it for many files 13 | 14 | # Features 15 | 16 | > Whenever we're updating **Replacement Symbols** you can set new _Directory Location_ too 17 | > so, you can also move files to _another directory_ 18 | 19 | 20 | ## Rename/Move files based on folder location 21 | Click _Search By Folder_ 22 | 23 | Update **Folder Location** where are you wanting to get files from, put **Existing Characters** from the file path 24 | later on update **Replacement Symbols** those symbols will be used for in a new path. 25 | 26 | 27 | ## Rename/Move files based on tags 28 | Click _Search By Tags_ 29 | 30 | Put tags in **Tags names** field search will be completed across the vault, use coma separator if you need more than 1 tag 31 | Click Refresh 32 | Update **Existing Characters** field with a pattern you are looking for in existing notes, set **Replacement Symbols** 33 | 34 | ## Search By RegExp 35 | Usage of Search By RegExp 36 | You have two fields, RegExp pattern, and RegExp Flags 37 | 38 | RegExp pattern will be wrapped into `/ /` 39 | 40 | ## Supported flags: 41 | 42 | - **g** - global 43 | - **i** - ignore case 44 | - **m** - multiline anchors 45 | - **s** - dot matches all (aka singleline) - works even when not natively supported 46 | - **u** - unicode (ES6) 47 | - **y** - sticky (Firefox 3+, ES6) 48 | - **n** - explicit capture 49 | - **x** - free-spacing and line comments (aka extended) 50 | - **A** - astral (requires the Unicode Base addon) 51 | 52 | --- 53 | 54 | Click Preview or `Enter` to see intermediate results(nothing will be changed/moved/renamed). 55 | 56 | Click `Rename` whenever you're done 57 | 58 | ## API 59 | - **folder location** - Files from which folder you need to rename 60 | - **Symbols in existing files** - the symbols/characters that we have in the files 61 | - **Replacement Symbols** - a new symbols that will be pasted instead 62 | - **Files within the folder** - this is for information purpose 63 | - **RegExp pattern** - pattern of RegExp to match 64 | - **RegExp flags** - flags that will be applied to RegExp pattern 65 | 66 | Rename Button will start renaming all files by respective path. 67 | 68 | 69 | ## Motivation 70 | This plugin was developed to cover my needs. Originally I've started to use my daily files with **_** delimiter. 71 | So my files looked like this: 2022_01_01, over the time I realized other platform out of the box configured to prefer dashes, like this 2022-01-01 72 | 73 | Here is many guides on how to rename files, what kind of script we need to run, what version of python or Node.js we need to install and so on. 74 | 75 | Why Not to have this functionality build-in into Obsidian? 76 | 77 | And rename a **bunch of files** and update their reference in code base respectively. So now you can rename a bunch of files at once and all imports also will be updated in the vault 78 | 79 | # Installation 80 | Follow the steps below to install Tasks. 81 | 82 | 1) Search for "Bulk Rename" in Obsidian's community plugins browser 83 | 2) Enable the plugin in your Obsidian settings (find "Bulk Rename" under "Community plugins"). 84 | 3) Welcome on board! Follow the guides above, share your findings! 85 | 86 | ## Support development 87 | 88 | If you enjoy Bulk Rename, consider [buying me a coffee](https://www.buymeacoffee.com/oleglustenko), and following me on twitter [@oleglustenko](https://twitter.com/oleglustenko) 89 | [!["Buy Me A Coffee"](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://www.buymeacoffee.com/oleglustenko) 90 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /documentation/assets/Animation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegLustenko/obsidian-bulk-rename/dd172641ee6c7c69d3fbf8e072f889fd2f1058a3/documentation/assets/Animation.gif -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | esbuild 14 | .build({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/collab', 25 | '@codemirror/commands', 26 | '@codemirror/language', 27 | '@codemirror/lint', 28 | '@codemirror/search', 29 | '@codemirror/state', 30 | '@codemirror/view', 31 | '@lezer/common', 32 | '@lezer/highlight', 33 | '@lezer/lr', 34 | ...builtins, 35 | ], 36 | format: 'cjs', 37 | watch: !prod, 38 | target: 'es2018', 39 | logLevel: 'info', 40 | sourcemap: prod ? false : 'inline', 41 | treeShaking: true, 42 | outfile: 'main.js', 43 | }) 44 | .catch(() => process.exit(1)); 45 | -------------------------------------------------------------------------------- /jest-preset.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OlegLustenko/obsidian-bulk-rename/dd172641ee6c7c69d3fbf8e072f889fd2f1058a3/jest-preset.js -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'roots': [''], 3 | 'modulePaths': [''], 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | moduleDirectories: ['node_modules'], 7 | }; 8 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Plugin, 4 | PluginSettingTab, 5 | Setting, 6 | TFile, 7 | Platform, 8 | } from 'obsidian'; 9 | 10 | import { FolderSuggest } from './src/suggestions/folderSuggest'; 11 | import { renderDonateButton } from './src/components/DonateButton'; 12 | import { renameFilesInObsidian } from './src/services/file.service'; 13 | import { createPreviewElement } from './src/components/PreviewElement'; 14 | import { 15 | getObsidianFilesByFolderName, 16 | getObsidianFilesByRegExp, 17 | getObsidianFilesWithTagName, 18 | } from './src/services/obsidian.service'; 19 | import { renderPreviewFiles } from './src/components/RenderPreviewFiles'; 20 | import { createBackslash } from './src/components/RegExpBackslash'; 21 | import { RegExpFlag } from './src/constants/RegExpFlags'; 22 | import { RegExpFlagsSuggest } from './src/suggestions/RegExpFlagsSuggest'; 23 | import { 24 | isViewTypeRegExp, 25 | isViewTypeFolder, 26 | isViewTypeTags, 27 | } from './src/services/settings.service'; 28 | 29 | export interface BulkRenamePluginSettings { 30 | folderName: string; 31 | fileNames: TFile[]; 32 | existingSymbol: string; 33 | replacePattern: string; 34 | tags: string[]; 35 | regExpState: { 36 | regExp: string; 37 | withRegExpForReplaceSymbols: boolean; 38 | flags: RegExpFlag[]; 39 | }; 40 | viewType: 'tags' | 'folder' | 'regexp'; 41 | } 42 | 43 | const DEFAULT_SETTINGS: BulkRenamePluginSettings = { 44 | folderName: '', 45 | fileNames: [], 46 | existingSymbol: '', 47 | replacePattern: '', 48 | regExpState: { 49 | regExp: '', 50 | flags: [], 51 | withRegExpForReplaceSymbols: false, 52 | }, 53 | tags: [], 54 | viewType: 'folder', 55 | }; 56 | 57 | class BulkRenamePlugin extends Plugin { 58 | settings: BulkRenamePluginSettings; 59 | 60 | async onload() { 61 | await this.loadSettings(); 62 | this.addSettingTab(new BulkRenameSettingsTab(this.app, this)); 63 | } 64 | 65 | onunload() {} 66 | 67 | async loadSettings() { 68 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 69 | } 70 | 71 | async saveSettings() { 72 | await this.saveData(this.settings); 73 | } 74 | } 75 | 76 | export type State = { 77 | previewScroll: number; 78 | filesScroll: number; 79 | }; 80 | 81 | export class BulkRenameSettingsTab extends PluginSettingTab { 82 | plugin: BulkRenamePlugin; 83 | state: State; 84 | filesAndPreview: Setting; 85 | totalFiles: HTMLSpanElement; 86 | 87 | constructor(app: App, plugin: BulkRenamePlugin) { 88 | super(app, plugin); 89 | this.state = { 90 | previewScroll: 0, 91 | filesScroll: 0, 92 | }; 93 | 94 | this.plugin = plugin; 95 | } 96 | 97 | display() { 98 | const { containerEl } = this; 99 | containerEl.empty(); 100 | containerEl.createEl('h1', { text: 'Bulk Rename - Settings' }); 101 | containerEl.addEventListener('keyup', (event) => { 102 | if (event.key !== 'Enter') { 103 | return; 104 | } 105 | 106 | this.reRenderPreview(); 107 | }); 108 | 109 | this.containerEl.addClass('bulk_rename_plugin'); 110 | this.renderTabs(); 111 | this.renderFileLocation(); 112 | this.renderTagNames(); 113 | this.renderRegExpInput(); 114 | this.renderReplaceSymbol(); 115 | this.renderFilesAndPreview(); 116 | this.renderRenameFiles(); 117 | this.renderSupportDevelopment(); 118 | } 119 | 120 | renderTabs() { 121 | new Setting(this.containerEl) 122 | .setName('Search by: ') 123 | .addButton((button) => { 124 | button.setButtonText('Folder'); 125 | if (isViewTypeFolder(this.plugin.settings)) { 126 | button.setCta(); 127 | } 128 | button.onClick(async () => { 129 | this.plugin.settings.viewType = 'folder'; 130 | await this.plugin.saveSettings(); 131 | this.display(); 132 | }); 133 | }) 134 | .addButton((button) => { 135 | button.setButtonText('Tags'); 136 | if (isViewTypeTags(this.plugin.settings)) { 137 | button.setCta(); 138 | } 139 | button.onClick(async () => { 140 | this.plugin.settings.viewType = 'tags'; 141 | await this.plugin.saveSettings(); 142 | this.display(); 143 | }); 144 | }) 145 | .addButton((button) => { 146 | button.setButtonText('RegExp'); 147 | if (isViewTypeRegExp(this.plugin.settings)) { 148 | button.setCta(); 149 | } 150 | button.onClick(async () => { 151 | this.plugin.settings.viewType = 'regexp'; 152 | await this.plugin.saveSettings(); 153 | this.display(); 154 | }); 155 | }); 156 | } 157 | 158 | renderFileLocation() { 159 | if (!isViewTypeFolder(this.plugin.settings)) { 160 | return; 161 | } 162 | new Setting(this.containerEl).setName('Folder location').addSearch((cb) => { 163 | new FolderSuggest(this.app, cb.inputEl, this.plugin); 164 | cb.setPlaceholder('Example: folder1/') 165 | .setValue(this.plugin.settings.folderName) 166 | .onChange((newFolder) => { 167 | this.plugin.settings.folderName = newFolder; 168 | this.plugin.saveSettings(); 169 | this.getFilesByFolder(); 170 | }); 171 | // @ts-ignore 172 | cb.containerEl.addClass('bulk_rename'); 173 | cb.inputEl.addClass('bulk_input'); 174 | cb.inputEl.onblur = this.reRenderPreview; 175 | }); 176 | } 177 | 178 | renderTagNames() { 179 | if (!isViewTypeTags(this.plugin.settings)) { 180 | return; 181 | } 182 | 183 | new Setting(this.containerEl).setName('Tag names ').addSearch((cb) => { 184 | cb.inputEl.addEventListener('keydown', (event) => { 185 | if (event.key !== 'Enter') { 186 | return; 187 | } 188 | const target = event.target as HTMLInputElement; 189 | 190 | this.plugin.settings.tags = target.value.replace(/ /g, '').split(','); 191 | this.plugin.saveSettings(); 192 | }); 193 | cb.setPlaceholder('Example: #tag, #tag2') 194 | .setValue(this.plugin.settings.tags.join(',')) 195 | .onChange((newFolder) => { 196 | this.plugin.settings.tags = newFolder.replace(/ /g, '').split(','); 197 | this.plugin.saveSettings(); 198 | this.getFilesByTags(); 199 | }); 200 | // @ts-ignore 201 | cb.containerEl.addClass('bulk_rename'); 202 | cb.inputEl.addClass('bulk_input'); 203 | cb.inputEl.onblur = this.reRenderPreview; 204 | }); 205 | } 206 | 207 | renderRegExpInput() { 208 | if (!isViewTypeRegExp(this.plugin.settings)) { 209 | return; 210 | } 211 | 212 | const settings = new Setting(this.containerEl); 213 | settings.infoEl.addClass('bulk_regexp_search'); 214 | settings.setClass('bulk_regexp_container'); 215 | settings 216 | .setName('RegExp Search') 217 | .addText((cb) => { 218 | const backslash = createBackslash('/'); 219 | cb.inputEl.insertAdjacentElement('beforebegin', backslash); 220 | // @ts-ignore 221 | cb.inputEl.addEventListener('keydown', (event) => { 222 | if (event.key !== 'Enter') { 223 | return; 224 | } 225 | const target = event.target as HTMLInputElement; 226 | 227 | this.plugin.settings.regExpState.regExp = target.value; 228 | this.plugin.saveSettings(); 229 | }); 230 | cb.setPlaceholder('Put your RegExp here') 231 | .setValue(this.plugin.settings.regExpState.regExp) 232 | .onChange((newFolder) => { 233 | this.plugin.settings.regExpState.regExp = newFolder; 234 | this.plugin.saveSettings(); 235 | this.getFilesByRegExp(); 236 | }); 237 | // @ts-ignore 238 | cb.inputEl.addClass('bulk_regexp'); 239 | cb.inputEl.onblur = this.reRenderPreview; 240 | }) 241 | .addText((cb) => { 242 | new RegExpFlagsSuggest(this.app, cb.inputEl, this.plugin); 243 | const backslash = createBackslash('/'); 244 | cb.inputEl.insertAdjacentElement('beforebegin', backslash); 245 | cb.inputEl.addEventListener('keydown', (event) => { 246 | // @ts-ignore 247 | event.stopPropagation(); 248 | event.stopImmediatePropagation(); 249 | event.preventDefault(); 250 | }); 251 | cb.setPlaceholder('flags here') 252 | // .setDisabled(true) 253 | .setValue(this.plugin.settings.regExpState.flags.join('')) 254 | .onChange((flag: RegExpFlag) => { 255 | this.plugin.saveSettings(); 256 | this.getFilesByRegExp(); 257 | this.reRenderPreview(); 258 | }); 259 | cb.inputEl.addClass('bulk_regexp_flags'); 260 | }) 261 | .controlEl.addClass('bulk_regexp_control'); 262 | } 263 | 264 | renderUseRegExpForExistingAndReplacement() { 265 | if (!isViewTypeRegExp(this.plugin.settings)) { 266 | return; 267 | } 268 | 269 | const newSettings = new Setting(this.containerEl); 270 | newSettings.setClass('bulk_toggle'); 271 | newSettings 272 | .setName('Use RegExp For Existing & Replacement?') 273 | .setDesc( 274 | "Only RegExp will work now, however it doesn't prevent you to pass string", 275 | ) 276 | .addToggle((toggle) => { 277 | toggle 278 | .setValue( 279 | this.plugin.settings.regExpState.withRegExpForReplaceSymbols, 280 | ) 281 | .setTooltip('Use RegExp For Existing & Replacement?') 282 | .onChange((isRegExpForNames) => { 283 | this.plugin.settings.regExpState.withRegExpForReplaceSymbols = 284 | isRegExpForNames; 285 | this.reRenderPreview(); 286 | this.plugin.saveSettings(); 287 | }); 288 | }); 289 | } 290 | 291 | renderReplaceSymbol() { 292 | const { settings } = this.plugin; 293 | 294 | this.renderUseRegExpForExistingAndReplacement(); 295 | const newSettings = new Setting(this.containerEl); 296 | if (Platform.isDesktop) { 297 | const previewLabel = createPreviewElement('Existing'); 298 | const replacementLabel = createPreviewElement('Replacement'); 299 | newSettings.infoEl.replaceChildren(previewLabel, replacementLabel); 300 | newSettings.setClass('flex'); 301 | newSettings.setClass('flex-col'); 302 | newSettings.infoEl.addClass('bulk_info'); 303 | } 304 | newSettings.controlEl.addClass('replaceRenderSymbols'); 305 | newSettings.addTextArea((textComponent) => { 306 | textComponent.setValue(settings.existingSymbol); 307 | textComponent.setPlaceholder('existing chars'); 308 | textComponent.onChange((newValue) => { 309 | settings.existingSymbol = newValue; 310 | this.plugin.saveSettings(); 311 | }); 312 | textComponent.inputEl.addClass('bulk_input'); 313 | textComponent.inputEl.onblur = this.reRenderPreview; 314 | }); 315 | 316 | newSettings.addTextArea((textComponent) => { 317 | textComponent.setValue(settings.replacePattern); 318 | textComponent.setPlaceholder('replace with'); 319 | textComponent.onChange((newValue) => { 320 | settings.replacePattern = newValue; 321 | this.plugin.saveSettings(); 322 | this.getFilesByFolder(); 323 | }); 324 | textComponent.inputEl.addClass('bulk_input'); 325 | textComponent.inputEl.onblur = this.reRenderPreview; 326 | }); 327 | } 328 | 329 | renderFilesAndPreview = () => { 330 | this.containerEl.createEl('h2', { text: 'Preview' }, (el) => { 331 | el.className = 'bulk_preview_header'; 332 | }); 333 | 334 | this.filesAndPreview = new Setting(this.containerEl); 335 | this.totalFiles = this.containerEl.createEl('span', { 336 | text: `Total Files: ${this.plugin.settings.fileNames.length}`, 337 | }); 338 | 339 | this.filesAndPreview.infoEl.detach(); 340 | 341 | this.filesAndPreview.controlEl.addClass('bulk_rename_preview'); 342 | this.reRenderPreview(); 343 | }; 344 | 345 | renderRenameFiles() { 346 | const desc = document.createDocumentFragment(); 347 | desc.append( 348 | 'You are going to update all files from preview section', 349 | desc.createEl('br'), 350 | desc.createEl('b', { 351 | text: 'Warning: ', 352 | }), 353 | 'Make sure you verified all files in preview', 354 | ); 355 | 356 | new Setting(this.containerEl) 357 | .setDesc(desc) 358 | .setName('Replace patterns') 359 | .addButton((button) => { 360 | button.setClass('bulk_button'); 361 | button.setTooltip("Your files won't be changed"); 362 | button.setButtonText('Preview'); 363 | button.onClick(this.reRenderPreview); 364 | }) 365 | .addButton((button) => { 366 | button.setClass('bulk_button'); 367 | button.setTooltip( 368 | "We don't have undone button yet!\r\n Do we need it?", 369 | ); 370 | button.setButtonText('Rename'); 371 | button.onClick(async () => { 372 | button.setDisabled(true); 373 | await renameFilesInObsidian(this.app, this.plugin); 374 | this.reRenderPreview(); 375 | button.setDisabled(false); 376 | }); 377 | }); 378 | } 379 | 380 | renderSupportDevelopment() { 381 | renderDonateButton(this.containerEl); 382 | } 383 | 384 | reRenderPreview = () => { 385 | this.calculateFileNames(); 386 | renderPreviewFiles(this.filesAndPreview, this.plugin, this.state); 387 | this.totalFiles.setText( 388 | `Total Files: ${this.plugin.settings.fileNames.length}`, 389 | ); 390 | }; 391 | 392 | calculateFileNames() { 393 | if (isViewTypeTags(this.plugin.settings)) { 394 | this.getFilesByTags(); 395 | return; 396 | } 397 | 398 | if (isViewTypeRegExp(this.plugin.settings)) { 399 | this.getFilesByRegExp(); 400 | return; 401 | } 402 | 403 | this.getFilesByFolder(); 404 | } 405 | 406 | getFilesByFolder() { 407 | this.plugin.settings.fileNames = getObsidianFilesByFolderName( 408 | this.app, 409 | this.plugin, 410 | ); 411 | } 412 | 413 | getFilesByTags() { 414 | this.plugin.settings.fileNames = getObsidianFilesWithTagName( 415 | this.app, 416 | this.plugin, 417 | ); 418 | } 419 | 420 | getFilesByRegExp() { 421 | this.plugin.settings.fileNames = getObsidianFilesByRegExp( 422 | this.app, 423 | this.plugin, 424 | ); 425 | } 426 | } 427 | 428 | export default BulkRenamePlugin; 429 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-bulk-rename-plugin", 3 | "name": "Bulk Rename", 4 | "version": "0.5.2", 5 | "minAppVersion": "0.15.0", 6 | "description": "Purpose of this plugin rename files based on pattern", 7 | "author": "Oleg Lustenko", 8 | "authorUrl": "https://obsidian.md", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-bulk-rename", 3 | "version": "0.5.2", 4 | "description": "Purpose of this plugin rename files based on pattern", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "test": "jest ./src", 10 | "test:watch": "jest ./src --watch", 11 | "version": "node version-bump.mjs && git add manifest.json versions.json && git push origin --tags", 12 | "tags": "git push origin --tags" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@popperjs/core": "^2.11.2", 19 | "xregexp": "^5.1.1" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^28.1.8", 23 | "@types/node": "^16.11.6", 24 | "@typescript-eslint/eslint-plugin": "5.29.0", 25 | "@typescript-eslint/parser": "5.29.0", 26 | "alias-hq": "^5.4.0", 27 | "builtin-modules": "3.3.0", 28 | "esbuild": "0.15.5", 29 | "esbuild-jest": "^0.5.0", 30 | "eslint-plugin-jest": "^26.8.7", 31 | "jest": "29.0.1", 32 | "obsidian": "latest", 33 | "prettier": "2.7.1", 34 | "ts-jest": "^28.0.8", 35 | "tslib": "2.4.0", 36 | "typescript": "4.8.2" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/components/DonateButton.ts: -------------------------------------------------------------------------------- 1 | export const renderDonateButton = (containerEl: HTMLElement) => { 2 | containerEl.createEl('h2', { text: 'Support development' }); 3 | 4 | const donateText = containerEl.createEl('p'); 5 | 6 | donateText.appendChild( 7 | createEl('span', { 8 | text: 'If you enjoy Bulk Rename, consider ', 9 | }), 10 | ); 11 | donateText.appendChild( 12 | createEl('a', { 13 | text: 'buying me a coffee', 14 | href: 'https://www.buymeacoffee.com/oleglustenko', 15 | }), 16 | ); 17 | donateText.appendChild( 18 | createEl('span', { 19 | text: ', and following me on Twitter ', 20 | }), 21 | ); 22 | donateText.appendChild( 23 | createEl('a', { 24 | text: '@oleglustenko', 25 | href: 'https://twitter.com/oleglustenko', 26 | }), 27 | ); 28 | 29 | const div = containerEl.createEl('div', { 30 | cls: 'bulkrename-donation', 31 | }); 32 | 33 | const parser = new DOMParser(); 34 | div.appendChild( 35 | createDonateButton( 36 | 'https://www.buymeacoffee.com/oleglustenko', 37 | parser.parseFromString(buyMeACoffee, 'text/xml').documentElement, 38 | ), 39 | ); 40 | }; 41 | 42 | const createDonateButton = (link: string, img: HTMLElement): HTMLElement => { 43 | const a = document.createElement('a'); 44 | a.setAttribute('href', link); 45 | a.addClass('bulkrename-donate-button'); 46 | a.appendChild(img); 47 | return a; 48 | }; 49 | 50 | const buyMeACoffee = ` 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | `; 73 | -------------------------------------------------------------------------------- /src/components/PreviewElement.ts: -------------------------------------------------------------------------------- 1 | export const createPreviewElement = (textContent = '=> => => =>') => { 2 | const previewLabel = window.document.createElement('span'); 3 | previewLabel.className = 'bulk_preview_label'; 4 | previewLabel.textContent = textContent; 5 | return previewLabel; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/RegExpBackslash.ts: -------------------------------------------------------------------------------- 1 | export const createBackslash = (textContent = '/') => { 2 | const previewLabel = window.document.createElement('div'); 3 | previewLabel.className = 'bulk_regexp_slash'; 4 | previewLabel.textContent = textContent; 5 | return previewLabel; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/RenderPreviewFiles.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getFilesNamesInDirectory, 3 | getRenderedFileNamesReplaced, 4 | } from '../services/file.service'; 5 | import { createPreviewElement } from './PreviewElement'; 6 | import BulkRenamePlugin, { BulkRenameSettingsTab, State } from '../../main'; 7 | 8 | export const renderPreviewFiles = ( 9 | setting: BulkRenameSettingsTab['filesAndPreview'], 10 | plugin: BulkRenamePlugin, 11 | state: BulkRenameSettingsTab['state'], 12 | ) => { 13 | let existingFilesTextArea: HTMLTextAreaElement; 14 | let replacedPreviewTextArea: HTMLTextAreaElement; 15 | 16 | return setting 17 | .clear() 18 | .addTextArea((text) => { 19 | text.setPlaceholder('Here you will see files under folder location'); 20 | text.setDisabled(true); 21 | existingFilesTextArea = text.inputEl; 22 | 23 | const value = getFilesNamesInDirectory(plugin); 24 | text.setValue(value); 25 | 26 | const previewLabel = createPreviewElement(); 27 | text.inputEl.insertAdjacentElement('afterend', previewLabel); 28 | text.inputEl.addClass('bulk_preview_textarea'); 29 | text.inputEl.wrap = 'soft'; 30 | }) 31 | .addTextArea((text) => { 32 | text.setPlaceholder( 33 | 'How filenames will looks like after replacement(click preview first)', 34 | ); 35 | text.setDisabled(true); 36 | 37 | replacedPreviewTextArea = text.inputEl; 38 | const value = getRenderedFileNamesReplaced(plugin); 39 | text.setValue(value); 40 | text.inputEl.addClass('bulk_preview_textarea'); 41 | text.inputEl.wrap = 'soft'; 42 | }) 43 | .then((setting) => { 44 | syncScrolls(existingFilesTextArea, replacedPreviewTextArea, state); 45 | }); 46 | }; 47 | 48 | export const syncScrolls = ( 49 | existingFilesArea: HTMLTextAreaElement, 50 | previewArea: HTMLTextAreaElement, 51 | state: State, 52 | ) => { 53 | existingFilesArea.addEventListener('scroll', (event) => { 54 | const target = event.target as HTMLTextAreaElement; 55 | 56 | if (target.scrollTop !== state.previewScroll) { 57 | previewArea.scrollTop = target.scrollTop; 58 | state.previewScroll = target.scrollTop; 59 | } 60 | }); 61 | previewArea.addEventListener('scroll', (event) => { 62 | const target = event.target as HTMLTextAreaElement; 63 | if (target.scrollTop !== state.filesScroll) { 64 | existingFilesArea.scrollTop = target.scrollTop; 65 | state.filesScroll = target.scrollTop; 66 | } 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /src/constants/RegExpFlags.ts: -------------------------------------------------------------------------------- 1 | export type RegExpFlag = 'g' | 'i' | 'm' | 'u' | 'y' | 'n' | 's' | 'x' | 'A'; 2 | 3 | export const REGEXP_FLAGS = [ 4 | 'g', 5 | 'i', 6 | 'm', 7 | 'u', 8 | 'y', 9 | 'n', 10 | 's', 11 | 'x', 12 | 'A', 13 | ] as const; 14 | 15 | export type RegExpFlags = typeof REGEXP_FLAGS; 16 | -------------------------------------------------------------------------------- /src/constants/folders.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_FOLDER_NAME = '/'; 2 | -------------------------------------------------------------------------------- /src/services/file.service.ts: -------------------------------------------------------------------------------- 1 | import { App, Notice, TFile } from 'obsidian'; 2 | import XRegExp from 'xregexp'; 3 | import BulkRenamePlugin, { BulkRenamePluginSettings } from '../../main'; 4 | import { isViewTypeFolder } from './settings.service'; 5 | import { ROOT_FOLDER_NAME } from '../constants/folders'; 6 | 7 | export const getFilesNamesInDirectory = (plugin: BulkRenamePlugin) => { 8 | return getFilesAsString(plugin.settings); 9 | }; 10 | 11 | const getFilesAsString = (settings: BulkRenamePluginSettings) => { 12 | let value = ''; 13 | const { fileNames, folderName } = settings; 14 | const shouldPrependSlash = 15 | isViewTypeFolder(settings) && folderName === ROOT_FOLDER_NAME; 16 | 17 | fileNames.forEach((fileName, index) => { 18 | const isLast = index + 1 === fileNames.length; 19 | const filePath = shouldPrependSlash ? '/' + fileName.path : fileName.path; 20 | 21 | if (isLast) { 22 | return (value += filePath); 23 | } 24 | 25 | value += filePath + '\r\n'; 26 | }); 27 | 28 | return value; 29 | }; 30 | 31 | export const getRenderedFileNamesReplaced = (plugin: BulkRenamePlugin) => { 32 | const newFiles = selectFilenamesWithReplacedPath(plugin); 33 | 34 | return getFilesAsString({ 35 | ...plugin.settings, 36 | fileNames: newFiles, 37 | }); 38 | }; 39 | 40 | export const selectFilenamesWithReplacedPath = (plugin: BulkRenamePlugin) => { 41 | const { fileNames } = plugin.settings; 42 | 43 | return fileNames.map((file) => { 44 | return { 45 | ...file, 46 | path: replaceFilePath(plugin, file), 47 | }; 48 | }); 49 | }; 50 | 51 | export const replaceFilePath = (plugin: BulkRenamePlugin, file: TFile) => { 52 | const pathWithoutExtension = file.path.split('.').slice(0, -1).join('.'); 53 | const { replacePattern, existingSymbol, regExpState } = plugin.settings; 54 | 55 | if (isRootFilesSelected(plugin)) { 56 | const newPath = replacePattern + pathWithoutExtension; 57 | return `${newPath}.${file.extension}`; 58 | } 59 | 60 | let regExpExistingSymbol: RegExp | string = existingSymbol; 61 | if (regExpState.withRegExpForReplaceSymbols) { 62 | regExpExistingSymbol = XRegExp(existingSymbol, 'x'); 63 | } 64 | 65 | const newPath = XRegExp.replace( 66 | pathWithoutExtension, 67 | regExpExistingSymbol, 68 | replacePattern, 69 | 'all', 70 | ); 71 | 72 | return `${newPath}.${file.extension}`; 73 | }; 74 | 75 | export const renameFilesInObsidian = async ( 76 | app: App, 77 | plugin: BulkRenamePlugin, 78 | ) => { 79 | const { existingSymbol, fileNames } = plugin.settings; 80 | 81 | if (!existingSymbol) { 82 | new Notice('please fill Existing Symbol'); 83 | return; 84 | } 85 | 86 | if (!fileNames.length) { 87 | new Notice('Please check your results before rename!'); 88 | return; 89 | } 90 | 91 | new Notice('renaming has been started'); 92 | let success = true; 93 | for (const fileName of fileNames) { 94 | try { 95 | await app.fileManager.renameFile( 96 | fileName, 97 | replaceFilePath(plugin, fileName), 98 | ); 99 | } catch (e) { 100 | if (e.code === 'ENOENT') { 101 | new Notice('FILES NOT RENAMED!'); 102 | new Notice( 103 | 'WARNING: YOU MUST CREATE FOLDER BEFORE MOVING INTO IT', 104 | 7000, 105 | ); 106 | success = false; 107 | break; 108 | } 109 | } 110 | } 111 | success && new Notice('successfully renamed all files'); 112 | }; 113 | 114 | const isRootFilesSelected = (plugin: BulkRenamePlugin) => { 115 | const { existingSymbol, folderName } = plugin.settings; 116 | 117 | return ( 118 | existingSymbol === ROOT_FOLDER_NAME && 119 | folderName === ROOT_FOLDER_NAME && 120 | isViewTypeFolder(plugin.settings) 121 | ); 122 | }; 123 | -------------------------------------------------------------------------------- /src/services/file.services.test.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from 'obsidian'; 2 | 3 | import { 4 | replaceFilePath, 5 | selectFilenamesWithReplacedPath, 6 | } from './file.service'; 7 | import BulkRenamePlugin from '../../main'; 8 | 9 | describe('File Services', () => { 10 | describe('Notifications', () => { 11 | it.todo('should display notification before renaming'); 12 | it.todo('should display notification after renaming'); 13 | }); 14 | describe('Validation', () => { 15 | it.todo('should display notification if there an error'); 16 | it.todo("should display notification if Existing Symbol doesn't exists"); 17 | it.todo( 18 | 'should display notification if Existing Symbol match Replace Pattern', 19 | ); 20 | it.todo( 21 | "should throw an error if there files didn't reviewed before submit", 22 | ); 23 | }); 24 | 25 | describe('Renaming', () => { 26 | it('should replace symbols in naming', () => { 27 | const plugin = { 28 | settings: { 29 | replacePattern: '-', 30 | existingSymbol: '_', 31 | regExpState: { 32 | withRegExpForReplaceSymbols: false, 33 | }, 34 | }, 35 | } as unknown as BulkRenamePlugin; 36 | 37 | const file = { 38 | path: 'journals/2022_10_13.md', 39 | extension: 'md', 40 | } as unknown as TFile; 41 | 42 | const expectedResult = 'journals/2022-10-13.md'; 43 | 44 | const result = replaceFilePath(plugin, file); 45 | 46 | expect(result).toEqual(expectedResult); 47 | }); 48 | 49 | it('should not rename extensions', () => { 50 | const plugin = { 51 | settings: { 52 | replacePattern: '-', 53 | existingSymbol: '.', 54 | regExpState: { 55 | withRegExpForReplaceSymbols: false, 56 | }, 57 | }, 58 | } as unknown as BulkRenamePlugin; 59 | 60 | const file = { 61 | path: '2022.10.13.md', 62 | extension: 'md', 63 | } as unknown as TFile; 64 | 65 | const expectedResult = '2022-10-13.md'; 66 | 67 | const result = replaceFilePath(plugin, file); 68 | 69 | expect(result).toEqual(expectedResult); 70 | }); 71 | 72 | it('should update directory path', () => { 73 | const plugin = { 74 | settings: { 75 | replacePattern: 'days', 76 | existingSymbol: 'journals', 77 | regExpState: { 78 | withRegExpForReplaceSymbols: false, 79 | }, 80 | }, 81 | } as unknown as BulkRenamePlugin; 82 | 83 | const file = { 84 | path: 'journals/2022_10_13.md', 85 | extension: 'md', 86 | } as unknown as TFile; 87 | 88 | const expectedResult = 'days/2022_10_13.md'; 89 | 90 | const result = replaceFilePath(plugin, file); 91 | 92 | expect(result).toEqual(expectedResult); 93 | }); 94 | 95 | describe('selectFilenamesWithReplacedPath', () => { 96 | const files = [ 97 | { 98 | path: 'journals/2022_10_13.md', 99 | extension: 'md', 100 | }, 101 | { 102 | path: 'pages/2022_10_13.md', 103 | extension: 'md', 104 | }, 105 | { 106 | path: 'bulkRenameTets/2022_10_13.md', 107 | extension: 'md', 108 | }, 109 | { 110 | path: 'YesWecan/canWe/2022_10_13.md', 111 | extension: 'md', 112 | }, 113 | ] as unknown as TFile[]; 114 | 115 | const mockPluginPlugin = { 116 | settings: { 117 | fileNames: files, 118 | regExpState: { 119 | withRegExpForReplaceSymbols: false, 120 | }, 121 | }, 122 | } as unknown as BulkRenamePlugin; 123 | 124 | it('should rename many files with RegExp', () => { 125 | const plugin = { 126 | ...mockPluginPlugin, 127 | settings: { 128 | ...mockPluginPlugin.settings, 129 | existingSymbol: 'journals|pages|bulkRenameTets|canWe', 130 | replacePattern: 'qwe', 131 | regExpState: { 132 | withRegExpForReplaceSymbols: true, 133 | }, 134 | }, 135 | } as unknown as BulkRenamePlugin; 136 | 137 | const expectedResults = [ 138 | { 139 | path: 'qwe/2022_10_13.md', 140 | extension: 'md', 141 | }, 142 | { 143 | path: 'qwe/2022_10_13.md', 144 | extension: 'md', 145 | }, 146 | { 147 | path: 'qwe/2022_10_13.md', 148 | extension: 'md', 149 | }, 150 | { 151 | path: 'YesWecan/qwe/2022_10_13.md', 152 | extension: 'md', 153 | }, 154 | ]; 155 | 156 | const updatedFiles = selectFilenamesWithReplacedPath(plugin); 157 | 158 | expect(expectedResults).toEqual(updatedFiles); 159 | }); 160 | 161 | it('should select all files', () => { 162 | const plugin = { 163 | ...mockPluginPlugin, 164 | settings: { 165 | ...mockPluginPlugin.settings, 166 | existingSymbol: '', 167 | replacePattern: '', 168 | }, 169 | } as unknown as BulkRenamePlugin; 170 | 171 | const updatedFiles = selectFilenamesWithReplacedPath(plugin); 172 | 173 | expect(files).toEqual(updatedFiles); 174 | }); 175 | 176 | it('should rename many files using capture groups', () => { 177 | const plugin = { 178 | ...mockPluginPlugin, 179 | settings: { 180 | ...mockPluginPlugin.settings, 181 | existingSymbol: '2022_(.+)', 182 | replacePattern: '$1_2022', 183 | regExpState: { 184 | withRegExpForReplaceSymbols: true, 185 | }, 186 | }, 187 | } as unknown as BulkRenamePlugin; 188 | 189 | const expectedResults = [ 190 | { 191 | path: 'journals/10_13_2022.md', 192 | extension: 'md', 193 | }, 194 | { 195 | path: 'pages/10_13_2022.md', 196 | extension: 'md', 197 | }, 198 | { 199 | path: 'bulkRenameTets/10_13_2022.md', 200 | extension: 'md', 201 | }, 202 | { 203 | path: 'YesWecan/canWe/10_13_2022.md', 204 | extension: 'md', 205 | }, 206 | ]; 207 | 208 | const updatedFiles = selectFilenamesWithReplacedPath(plugin); 209 | 210 | expect(expectedResults).toEqual(updatedFiles); 211 | }); 212 | 213 | it('should rename many files using capture groups', () => { 214 | const files = [ 215 | { 216 | path: '2022_10_13.md', 217 | extension: 'md', 218 | }, 219 | { 220 | path: '2022_10_14.md', 221 | extension: 'md', 222 | }, 223 | { 224 | path: '2022_10_15.md', 225 | extension: 'md', 226 | }, 227 | { 228 | path: '2022_10_16.md', 229 | extension: 'md', 230 | }, 231 | ] as unknown as TFile[]; 232 | const plugin = { 233 | settings: { 234 | fileNames: files, 235 | existingSymbol: `(? [0-9]{4} ) _? # year 236 | (? [0-9]{2} ) _? # month 237 | (? [0-9]{2} ) # day`, 238 | replacePattern: '$-$-$', 239 | regExpState: { 240 | withRegExpForReplaceSymbols: true, 241 | }, 242 | }, 243 | } as unknown as BulkRenamePlugin; 244 | 245 | const expectedResults = [ 246 | { 247 | path: '10-13-2022.md', 248 | extension: 'md', 249 | }, 250 | { 251 | path: '10-14-2022.md', 252 | extension: 'md', 253 | }, 254 | { 255 | path: '10-15-2022.md', 256 | extension: 'md', 257 | }, 258 | { 259 | path: '10-16-2022.md', 260 | extension: 'md', 261 | }, 262 | ]; 263 | 264 | const updatedFiles = selectFilenamesWithReplacedPath(plugin); 265 | 266 | expect(expectedResults).toEqual(updatedFiles); 267 | }); 268 | }); 269 | }); 270 | }); 271 | -------------------------------------------------------------------------------- /src/services/obsidian.service.test.ts: -------------------------------------------------------------------------------- 1 | describe('obsidian.service', () => { 2 | describe('getObsidianFilesWithTagName', () => { 3 | it.todo('should find files by tag'); 4 | }); 5 | }); 6 | -------------------------------------------------------------------------------- /src/services/obsidian.service.ts: -------------------------------------------------------------------------------- 1 | import { App, TFile } from 'obsidian'; 2 | import BulkRenamePlugin from '../../main'; 3 | import XRegExp from 'xregexp'; 4 | 5 | export const getObsidianFilesByFolderName = ( 6 | app: App, 7 | plugin: BulkRenamePlugin, 8 | ) => { 9 | const { folderName } = plugin.settings; 10 | const abstractFiles = app.vault.getAllLoadedFiles(); 11 | 12 | const files = abstractFiles.filter( 13 | (file) => file instanceof TFile && file.parent.path.includes(folderName), 14 | ) as TFile[]; 15 | 16 | const filesSortedByName = sortFilesByName(files); 17 | 18 | return filesSortedByName; 19 | }; 20 | 21 | export const getObsidianFilesByRegExp = ( 22 | app: App, 23 | plugin: BulkRenamePlugin, 24 | ) => { 25 | const { regExpState } = plugin.settings; 26 | 27 | const regExp = XRegExp(regExpState.regExp, regExpState.flags.join('')); 28 | 29 | const abstractFiles = app.vault.getAllLoadedFiles(); 30 | 31 | const matchedFileNames = abstractFiles.filter((file) => { 32 | if (file instanceof TFile && XRegExp.exec(file.path, regExp)) { 33 | return true; 34 | } 35 | }) as TFile[]; 36 | 37 | const filesSortedByName = sortFilesByName(matchedFileNames); 38 | 39 | return filesSortedByName; 40 | }; 41 | 42 | export const getObsidianFilesWithTagName = ( 43 | app: App, 44 | plugin: BulkRenamePlugin, 45 | ) => { 46 | const { tags } = plugin.settings; 47 | const abstractFiles = app.vault.getAllLoadedFiles(); 48 | 49 | const files = abstractFiles.filter((file) => { 50 | if (!(file instanceof TFile)) { 51 | return; 52 | } 53 | 54 | const fileMetadata = app.metadataCache.getFileCache(file); 55 | if (!fileMetadata || !fileMetadata.tags) { 56 | return; 57 | } 58 | 59 | const hasTagsInTheFile = fileMetadata.tags.find((fileTags) => { 60 | return tags.includes(fileTags.tag); 61 | }); 62 | 63 | if (!hasTagsInTheFile) { 64 | return; 65 | } 66 | 67 | return file; 68 | }) as TFile[]; 69 | 70 | const filesSortedByName = sortFilesByName(files); 71 | 72 | return filesSortedByName; 73 | }; 74 | 75 | const sortFilesByName = (files: TFile[]) => { 76 | return files.sort((a, b) => a.name.localeCompare(b.name)); 77 | }; 78 | -------------------------------------------------------------------------------- /src/services/settings.service.ts: -------------------------------------------------------------------------------- 1 | import BulkRenamePlugin from '../../main'; 2 | 3 | export const isViewTypeFolder = (settings: BulkRenamePlugin['settings']) => { 4 | return settings.viewType === 'folder'; 5 | }; 6 | 7 | export const isViewTypeTags = (settings: BulkRenamePlugin['settings']) => { 8 | return settings.viewType === 'tags'; 9 | }; 10 | 11 | export const isViewTypeRegExp = (settings: BulkRenamePlugin['settings']) => { 12 | return settings.viewType === 'regexp'; 13 | }; 14 | -------------------------------------------------------------------------------- /src/suggestions/RegExpFlagsSuggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { TextInputSuggest } from './suggest'; 4 | import { REGEXP_FLAGS, RegExpFlag } from '../constants/RegExpFlags'; 5 | 6 | export class RegExpFlagsSuggest extends TextInputSuggest { 7 | // @ts-ignore TODO refactor types 8 | getSuggestions() { 9 | return REGEXP_FLAGS; 10 | } 11 | 12 | renderSuggestion = (flag: RegExpFlag, el: HTMLElement) => { 13 | const { regExpState } = this.plugin.settings; 14 | const hasFlag = regExpState.flags.includes(flag); 15 | if (hasFlag) { 16 | el.addClass('bulk-flag-selected'); 17 | } else { 18 | el.removeClass('bulk-flag-selected'); 19 | } 20 | el.setText(flag); 21 | }; 22 | 23 | selectSuggestion = (flag: RegExpFlag, event: MouseEvent | KeyboardEvent) => { 24 | const { regExpState } = this.plugin.settings; 25 | const target = event.target as HTMLDivElement; 26 | 27 | const hasFlag = regExpState.flags.includes(flag); 28 | if (hasFlag) { 29 | regExpState.flags = regExpState.flags.filter((existingFlag) => { 30 | return existingFlag !== flag; 31 | }); 32 | } else { 33 | regExpState.flags = [...regExpState.flags, flag]; 34 | } 35 | target.classList.toggle('bulk-flag-selected'); 36 | this.inputEl.value = regExpState.flags.join(''); 37 | this.inputEl.trigger('input'); 38 | }; 39 | } 40 | -------------------------------------------------------------------------------- /src/suggestions/folderSuggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { TAbstractFile, TFolder } from 'obsidian'; 4 | import { TextInputSuggest } from './suggest'; 5 | 6 | export class FolderSuggest extends TextInputSuggest { 7 | getSuggestions(inputStr: string): TFolder[] { 8 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 9 | const folders: TFolder[] = []; 10 | const lowerCaseInputStr = inputStr.toLowerCase(); 11 | 12 | abstractFiles.forEach((folder: TAbstractFile) => { 13 | if ( 14 | folder instanceof TFolder && 15 | folder.path.toLowerCase().contains(lowerCaseInputStr) 16 | ) { 17 | folders.push(folder); 18 | } 19 | }); 20 | 21 | return folders; 22 | } 23 | 24 | renderSuggestion(file: TFolder, el: HTMLElement): void { 25 | el.setText(file.path); 26 | } 27 | 28 | selectSuggestion(file: TFolder): void { 29 | this.inputEl.value = file.path; 30 | this.inputEl.trigger('input'); 31 | this.close(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/suggestions/suggest.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { App, ISuggestOwner, Scope } from 'obsidian'; 4 | import { createPopper, Instance as PopperInstance } from '@popperjs/core'; 5 | import BulkRenamePlugin from '../../main'; 6 | 7 | const wrapAround = (value: number, size: number): number => { 8 | return ((value % size) + size) % size; 9 | }; 10 | 11 | class Suggest { 12 | private owner: ISuggestOwner; 13 | private values: T[]; 14 | private suggestions: HTMLDivElement[]; 15 | private selectedItem: number; 16 | private containerEl: HTMLElement; 17 | 18 | constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { 19 | this.owner = owner; 20 | this.containerEl = containerEl; 21 | 22 | containerEl.on( 23 | 'click', 24 | '.suggestion-item', 25 | this.onSuggestionClick.bind(this), 26 | ); 27 | containerEl.on( 28 | 'mousemove', 29 | '.suggestion-item', 30 | this.onSuggestionMouseover.bind(this), 31 | ); 32 | 33 | scope.register([], 'ArrowUp', (event) => { 34 | if (!event.isComposing) { 35 | this.setSelectedItem(this.selectedItem - 1, true); 36 | return false; 37 | } 38 | }); 39 | 40 | scope.register([], 'ArrowDown', (event) => { 41 | if (!event.isComposing) { 42 | this.setSelectedItem(this.selectedItem + 1, true); 43 | return false; 44 | } 45 | }); 46 | 47 | scope.register([], 'Enter', (event) => { 48 | if (!event.isComposing) { 49 | this.useSelectedItem(event); 50 | return false; 51 | } 52 | }); 53 | } 54 | 55 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 56 | event.preventDefault(); 57 | 58 | const item = this.suggestions.indexOf(el); 59 | this.setSelectedItem(item, false); 60 | this.useSelectedItem(event); 61 | } 62 | 63 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 64 | const item = this.suggestions.indexOf(el); 65 | this.setSelectedItem(item, false); 66 | } 67 | 68 | setSuggestions(values: T[]) { 69 | this.containerEl.empty(); 70 | const suggestionEls: HTMLDivElement[] = []; 71 | 72 | values.forEach((value) => { 73 | const suggestionEl = this.containerEl.createDiv('suggestion-item'); 74 | this.owner.renderSuggestion(value, suggestionEl); 75 | suggestionEls.push(suggestionEl); 76 | }); 77 | 78 | this.values = values; 79 | this.suggestions = suggestionEls; 80 | this.setSelectedItem(0, false); 81 | } 82 | 83 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 84 | const currentValue = this.values[this.selectedItem]; 85 | if (currentValue) { 86 | this.owner.selectSuggestion(currentValue, event); 87 | } 88 | } 89 | 90 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 91 | const normalizedIndex = wrapAround(selectedIndex, this.suggestions.length); 92 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 93 | const selectedSuggestion = this.suggestions[normalizedIndex]; 94 | 95 | prevSelectedSuggestion?.removeClass('is-selected'); 96 | selectedSuggestion?.addClass('is-selected'); 97 | 98 | this.selectedItem = normalizedIndex; 99 | 100 | if (scrollIntoView) { 101 | selectedSuggestion.scrollIntoView(false); 102 | } 103 | } 104 | } 105 | 106 | export abstract class TextInputSuggest implements ISuggestOwner { 107 | protected app: App; 108 | protected plugin: BulkRenamePlugin; 109 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 110 | 111 | private popper: PopperInstance; 112 | private scope: Scope; 113 | private suggestEl: HTMLElement; 114 | private suggest: Suggest; 115 | 116 | constructor( 117 | app: App, 118 | inputEl: HTMLInputElement | HTMLTextAreaElement, 119 | plugin: BulkRenamePlugin, 120 | ) { 121 | this.plugin = plugin; 122 | this.app = app; 123 | this.inputEl = inputEl; 124 | this.scope = new Scope(); 125 | 126 | this.suggestEl = createDiv('suggestion-container'); 127 | const suggestion = this.suggestEl.createDiv('suggestion'); 128 | this.suggest = new Suggest(this, suggestion, this.scope); 129 | 130 | this.scope.register([], 'Escape', this.close.bind(this)); 131 | 132 | this.inputEl.addEventListener('input', this.onInputChanged.bind(this)); 133 | this.inputEl.addEventListener('focus', this.onInputChanged.bind(this)); 134 | this.inputEl.addEventListener('blur', this.close.bind(this)); 135 | this.suggestEl.on( 136 | 'mousedown', 137 | '.suggestion-container', 138 | (event: MouseEvent) => { 139 | event.preventDefault(); 140 | }, 141 | ); 142 | } 143 | 144 | onInputChanged(): void { 145 | const inputStr = this.inputEl.value; 146 | const suggestions = this.getSuggestions(inputStr); 147 | 148 | if (!suggestions) { 149 | this.close(); 150 | return; 151 | } 152 | 153 | if (suggestions.length > 0) { 154 | this.suggest.setSuggestions(suggestions); 155 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 156 | this.open((this.app).dom.appContainerEl, this.inputEl); 157 | } else { 158 | this.close(); 159 | } 160 | } 161 | 162 | open(container: HTMLElement, inputEl: HTMLElement): void { 163 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 164 | (this.app).keymap.pushScope(this.scope); 165 | 166 | container.appendChild(this.suggestEl); 167 | this.popper = createPopper(inputEl, this.suggestEl, { 168 | placement: 'bottom-start', 169 | modifiers: [ 170 | { 171 | name: 'sameWidth', 172 | enabled: true, 173 | fn: ({ state, instance }) => { 174 | // Note: positioning needs to be calculated twice - 175 | // first pass - positioning it according to the width of the popper 176 | // second pass - position it with the width bound to the reference element 177 | // we need to early exit to avoid an infinite loop 178 | const targetWidth = `${state.rects.reference.width}px`; 179 | if (state.styles.popper.width === targetWidth) { 180 | return; 181 | } 182 | state.styles.popper.width = targetWidth; 183 | instance.update(); 184 | }, 185 | phase: 'beforeWrite', 186 | requires: ['computeStyles'], 187 | }, 188 | ], 189 | }); 190 | } 191 | 192 | close(): void { 193 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 194 | (this.app).keymap.popScope(this.scope); 195 | 196 | this.suggest.setSuggestions([]); 197 | if (this.popper) this.popper.destroy(); 198 | this.suggestEl.detach(); 199 | } 200 | 201 | abstract getSuggestions(inputStr?: string): T[]; 202 | abstract renderSuggestion(item: T, el: HTMLElement): void; 203 | abstract selectSuggestion(item: T, evt: MouseEvent | KeyboardEvent): void; 204 | } 205 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .bulk_rename_plugin { 2 | padding-right: 1rem; 3 | } 4 | 5 | .bulk_rename { 6 | width: calc(100% - 20px); 7 | } 8 | 9 | .bulk_rename_preview { 10 | width: 80%; 11 | height: 100%; 12 | gap: 0; 13 | } 14 | 15 | @media screen and (max-width: 983px) { 16 | .bulk_rename_preview { 17 | display: flex; 18 | flex-direction: column; 19 | max-height: 300px; 20 | } 21 | } 22 | 23 | .flex { 24 | display: flex; 25 | } 26 | .flex-col { 27 | flex-direction: column; 28 | } 29 | 30 | .m-auto { 31 | margin: auto; 32 | } 33 | 34 | .bulk_info { 35 | display: flex; 36 | justify-content: space-between; 37 | margin: auto; 38 | width: 100%; 39 | } 40 | 41 | .bulk_rename_preview > textarea { 42 | height: 360px; 43 | } 44 | 45 | .replaceRenderSymbols { 46 | display: flex; 47 | width: 100%; 48 | padding-top: 0.5rem; 49 | } 50 | 51 | .setting-item-control.replaceRenderSymbols .bulk_input { 52 | min-height: 80px; 53 | } 54 | 55 | .setting-item-control .bulk_preview_textarea { 56 | min-width: 19em; 57 | } 58 | 59 | .bulk_preview_textarea { 60 | margin-left: 5px; 61 | margin-right: 5px; 62 | font-size: 14px; 63 | width: 100%; 64 | height: 400px; 65 | resize: none; 66 | } 67 | 68 | .bulk_button { 69 | width: 100%; 70 | } 71 | 72 | .bulk_preview_header { 73 | margin-top: 5px; 74 | margin-bottom: 5px; 75 | } 76 | 77 | .setting-item-control .bulk_input { 78 | width: 100%; 79 | resize: none; 80 | min-width: auto; 81 | } 82 | 83 | .setting-item-control .bulk_input:first-child { 84 | margin-right: 15px; 85 | } 86 | 87 | .bulk_regexp_search { 88 | padding-right: 1rem; 89 | } 90 | 91 | .bulk_toggle { 92 | border-top: none; 93 | } 94 | 95 | .bulk_preview_label:first-child { 96 | margin-right: 15px; 97 | } 98 | 99 | .bulk_preview_label { 100 | text-align: center; 101 | } 102 | 103 | .bulk_regexp_container { 104 | justify-content: space-between; 105 | } 106 | 107 | .bulk_regexp_control { 108 | background: var(--background-modifier-form-field); 109 | border: 1px solid var(--background-modifier-border); 110 | transition: box-shadow 0.15s ease-in-out, border 0.15s ease-in-out; 111 | font-family: inherit; 112 | border-radius: var(--input-radius); 113 | outline: none; 114 | max-width: 400px; 115 | } 116 | 117 | .bulk_regexp_control:hover, 118 | .bulk_regexp_control:focus, 119 | .bulk_regexp_control:focus-visible { 120 | box-shadow: 0 0 0 2px var(--background-modifier-border-hover); 121 | } 122 | 123 | .bulk_regexp_control > input { 124 | border: none; 125 | margin: 0; 126 | padding: 0; 127 | } 128 | 129 | .bulk_regexp, 130 | .bulk_regexp_flags { 131 | width: 100%; 132 | } 133 | 134 | .bulk_regexp:hover, 135 | .bulk_regexp:focus, 136 | .bulk_regexp:focus-visible .bulk_regexp_flags:hover, 137 | .bulk_regexp_flags:focus, 138 | .bulk_regexp_flags:focus-visible { 139 | border: none !important; 140 | box-shadow: none !important; 141 | } 142 | 143 | .bulk_regexp_flags { 144 | caret-color: transparent; 145 | max-width: 80px; 146 | } 147 | 148 | .bulk_regexp_slash { 149 | font-size: 1.5em; 150 | opacity: 0.5; 151 | } 152 | 153 | .bulk-flag-selected { 154 | background-color: lavender !important; 155 | } 156 | -------------------------------------------------------------------------------- /tests/__mocks__/obsidian.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es2018", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "noImplicitThis": true, 11 | "noUnusedLocals": true, 12 | "moduleResolution": "node", 13 | "importHelpers": true, 14 | "downlevelIteration": true, 15 | "esModuleInterop": true, 16 | "isolatedModules": false, 17 | "strictNullChecks": true, 18 | "lib": ["DOM", "ES5", "ES6", "ES7", "ES2021"] 19 | }, 20 | "typeRoots": ["node_modules/@types", "obsidian"], 21 | "include": ["**/*.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.15.0", 3 | "0.1.0": "0.15.0", 4 | "0.1.1": "0.15.0", 5 | "0.1.2": "0.15.0", 6 | "0.2.0": "0.15.0", 7 | "0.2.1": "0.15.0", 8 | "0.2.2": "0.15.0", 9 | "0.2.3": "0.15.0", 10 | "0.2.4": "0.15.0", 11 | "0.2.5": "0.15.0", 12 | "0.2.6": "0.15.0", 13 | "0.3.0": "0.15.0", 14 | "0.3.1": "0.15.0", 15 | "0.3.2": "0.15.0", 16 | "0.3.3": "0.15.0", 17 | "0.3.4": "0.15.0", 18 | "0.3.5": "0.15.0", 19 | "0.4.0": "0.15.0", 20 | "0.4.1": "0.15.0", 21 | "0.4.2": "0.15.0", 22 | "0.4.3": "0.15.0", 23 | "0.4.4": "0.15.0", 24 | "0.4.5": "0.15.0", 25 | "0.5.1": "0.15.0", 26 | "0.5.2": "0.15.0" 27 | } --------------------------------------------------------------------------------