├── .eslintignore ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── __tests__ ├── addInlineTags.test.ts └── setupTests.ts ├── esbuild.config.mjs ├── jest.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── src ├── SettingTab.ts ├── TagForm.svelte ├── TagModal.ts ├── main.ts └── types │ └── custom.d.ts ├── styles.css ├── svelte.config.js ├── tsconfig.json ├── version-bump.mjs └── versions.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /.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 | main.css 15 | data.json 16 | 17 | #env 18 | .env -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 fez-github 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 | # Multi-Tag 2 | 3 | ![Multi Tag Demo](https://github.com/fez-github/obsidian-multi-tag/assets/75589254/8cb7cd25-9fd5-4105-8658-6d32e1f219b4) 4 | 5 | 6 | UPDATE: This plugin will remain functional, but I will be focusing on [obsidian-multi-properties](https://github.com/fez-github/obsidian-multi-properties) instead, which serves very similar functionality. Multi Tag will be around for anyone who wants to use inline tags. 7 | 8 | When installed, right-clicking on a folder will bring up an option to add a tag to all notes within a folder. Upon clicking this, a message will pop up asking you to add a tag. You may add your tag, and it will be appended to each note in the folder. 9 | 10 | You can also select multiple notes with Shift+Mouse, and right-click the selection to get the same efect. 11 | 12 | If you want your tags to be appended to the frontmatter via YAML/Properties, there is a toggle for this in the plugin's Settings menu. "YAML" will add the tags as a property, and "Inline" will append each tag to the bottom of the note. Note that "YAML" checks for duplicate tags, but "Inline" does not. 13 | 14 | # Installation: 15 | 16 | This project is available as a community plugin in Obsidian that can be installed directly in the app, under the name `Multi Tag`. 17 | 18 | If you wish to install it manually, 19 | 20 | 1. Download the latest release. 21 | 2. Extract the folder within the release and add it to `[yourVault]/.obsidian/plugins/`. 22 | 23 | ## Ideas for Features: 24 | 25 | - [x] Settings option for whether tag appears in YAML or bottom of file. 26 | 27 | ## Next Steps: 28 | 29 | - [x] Update obsidian typing so "files-menu" is properly implemented. 30 | - [x] Allow user to change between YAML/Inline directly from the form instead of needing to go into Settings. 31 | - [x] Clean up code. It's kind of messy right now. 32 | - [x] Try converting to front-end framework to make forms easier to manage. Will try Svelte. 33 | - [ ] Add tests. 34 | -------------------------------------------------------------------------------- /__tests__/addInlineTags.test.ts: -------------------------------------------------------------------------------- 1 | import MultiTagPlugin from "src/main"; 2 | import { App, Plugin, getAllTags } from "obsidian"; 3 | 4 | // it("Adds tags to all files in a folder", async () => {}); 5 | 6 | // it("Adds tags to all selected files", async () => { 7 | // //Check that 3rd file was not tagged. 8 | // }); 9 | 10 | // it("Adds tags to the bottom of a note", async () => {}); 11 | 12 | // it("Adds tags to the top of a note", async () => {}); 13 | 14 | // it("Trims out spaces from tags", async () => {}); 15 | 16 | it("Can run something from Obsidian.", async () => { 17 | // console.log(getAllTags()) 18 | }); 19 | 20 | export {}; 21 | -------------------------------------------------------------------------------- /__tests__/setupTests.ts: -------------------------------------------------------------------------------- 1 | // import { App, Plugin } from "obsidian"; 2 | // //Before test starts, create a new folder and 3 blank files to test on. 3 | 4 | // const app = new App(); 5 | // beforeAll(() => { 6 | // //Search for file-creating functions in Obsidian. 7 | // console.log(app.vault.adapter.getName()); 8 | // }); 9 | // //After each test, clear the files of all data. 10 | // afterEach(() => {}); 11 | // //After all tests, delete the folder and files. 12 | // afterAll(() => {}); 13 | 14 | export {}; 15 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import esbuildsvelte from "esbuild-svelte"; 3 | import sveltepreprocess from "svelte-preprocess"; 4 | import process from "process"; 5 | import builtins from "builtin-modules"; 6 | 7 | const banner = `/* 8 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 9 | if you want to view the source, please visit the github repository of this plugin 10 | */ 11 | `; 12 | 13 | const prod = process.argv[2] === "production"; 14 | 15 | const context = await esbuild.context({ 16 | banner: { 17 | js: banner, 18 | }, 19 | entryPoints: ["src/main.ts"], 20 | bundle: true, 21 | external: [ 22 | "obsidian", 23 | "electron", 24 | "@codemirror/autocomplete", 25 | "@codemirror/collab", 26 | "@codemirror/commands", 27 | "@codemirror/language", 28 | "@codemirror/lint", 29 | "@codemirror/search", 30 | "@codemirror/state", 31 | "@codemirror/view", 32 | "@lezer/common", 33 | "@lezer/highlight", 34 | "@lezer/lr", 35 | ...builtins, 36 | ], 37 | format: "cjs", 38 | target: "es2018", 39 | logLevel: "info", 40 | plugins: [ 41 | esbuildsvelte({ 42 | preprocess: sveltepreprocess({ 43 | postcss: true, 44 | }), 45 | }), 46 | ], 47 | sourcemap: prod ? false : "inline", 48 | treeShaking: true, 49 | outfile: "main.js", 50 | }); 51 | 52 | if (prod) { 53 | await context.rebuild(); 54 | process.exit(0); 55 | } else { 56 | await context.watch(); 57 | } 58 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | preset: "ts-jest", 3 | // workaround because obsidian api does not have transpiled js included in its node module 4 | // From: https://github.com/kulshekhar/ts-jest/issues/1523 5 | // forcefully map obsidian to its .d.ts file 6 | moduleNameMapper: { 7 | obsidian: "/node_modules/obsidian/obsidian.d.ts", 8 | }, 9 | // ignore patterns for obsidian or you will get import/export errors within obsidian 10 | transformIgnorePatterns: ["node_modules/(?!obsidian/.*)"], 11 | }; 12 | 13 | export default config; 14 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "multi-tag", 3 | "name": "Multi Tag", 4 | "version": "0.10.0", 5 | "minAppVersion": "1.4.13", 6 | "description": "Adds a tag to multiple notes at once. Either right-click a folder, or select multiple notes and right-click the selection.", 7 | "author": "technohiker", 8 | "authorUrl": "https://github.com/technohiker", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "multi-tag", 3 | "version": "0.10.0", 4 | "main": "main.js", 5 | "scripts": { 6 | "dev": "node esbuild.config.mjs", 7 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 8 | "version": "node version-bump.mjs && git add manifest.json versions.json", 9 | "test": "jest" 10 | }, 11 | "keywords": [], 12 | "author": "fez-github", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@tsconfig/svelte": "^5.0.2", 16 | "@types/jest": "^29.5.5", 17 | "@types/node": "^16.11.6", 18 | "@typescript-eslint/eslint-plugin": "5.29.0", 19 | "@typescript-eslint/parser": "5.29.0", 20 | "builtin-modules": "3.3.0", 21 | "esbuild": "0.17.3", 22 | "esbuild-svelte": "^0.8.0", 23 | "jest": "^29.7.0", 24 | "obsidian": "latest", 25 | "svelte": "^4.2.1", 26 | "svelte-preprocess": "^5.0.4", 27 | "ts-jest": "^29.1.1", 28 | "tslib": "2.4.0", 29 | "typescript": "^4.7.4" 30 | }, 31 | "type": "module" 32 | } 33 | -------------------------------------------------------------------------------- /src/SettingTab.ts: -------------------------------------------------------------------------------- 1 | import { PluginSettingTab, App, Setting } from "obsidian"; 2 | 3 | import MultiTagPlugin from "./main"; 4 | 5 | export class SettingTab extends PluginSettingTab { 6 | plugin: MultiTagPlugin; 7 | 8 | constructor(app: App, plugin: MultiTagPlugin) { 9 | super(app, plugin); 10 | this.plugin = plugin; 11 | } 12 | 13 | display() { 14 | let { containerEl } = this; 15 | containerEl.empty(); 16 | 17 | new Setting(containerEl) 18 | .setName("YAML or Inline") 19 | .setDesc("Choose whether to use YAML or inline tags.") 20 | .addDropdown((dropdown) => { 21 | dropdown.addOption("inline", "Inline"); 22 | dropdown.addOption("yaml", "YAML"); 23 | dropdown.setValue(this.plugin.settings.yamlOrInline); 24 | dropdown.onChange(async (value) => { 25 | this.plugin.settings.yamlOrInline = value; 26 | await this.plugin.saveSettings(); 27 | }); 28 | }); 29 | } 30 | } 31 | 32 | export interface MultiTagSettings { 33 | yamlOrInline: string; 34 | } 35 | -------------------------------------------------------------------------------- /src/TagForm.svelte: -------------------------------------------------------------------------------- 1 | 2 | 24 | 25 | 26 | If you add multiple tags, separate them with commas. Do not add '#'. 27 | 28 | 39 | -------------------------------------------------------------------------------- /src/TagModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, TAbstractFile, TFolder } from "obsidian"; 2 | import TagForm from "./TagForm.svelte"; 3 | 4 | export class TagModal extends Modal { 5 | default: string = ""; 6 | base: TFolder | TAbstractFile[]; 7 | option: string = "inline"; 8 | submission: (obj: any, string: string, setting: string) => void; 9 | component: TagForm; 10 | 11 | constructor( 12 | app: App, 13 | base: TFolder | TAbstractFile[], 14 | option: string = "inline", 15 | submission: (obj: any, string: string, setting: string) => void 16 | ) { 17 | super(app); 18 | 19 | //Removes potential spaces in file names. 20 | if (base instanceof TFolder) { 21 | this.default = `${base.name.replace(" ", "-")}`; 22 | } 23 | 24 | this.base = base; 25 | this.submission = submission; 26 | this.option = option; 27 | } 28 | 29 | async onOpen() { 30 | this.titleEl.createEl("h2", { text: "Please type in a tag." }); 31 | console.log(this.option); 32 | 33 | this.component = new TagForm({ 34 | target: this.contentEl, 35 | props: { 36 | value: this.default, 37 | option: this.option, 38 | closeModal: () => this.close(), 39 | submission: this.onSubmit.bind(this), 40 | }, 41 | }); 42 | } 43 | 44 | onSubmit(input: string, option: string): void { 45 | //Trim any spaces to prevent splits in tags. 46 | const trimmed = input.replace(/ /g, ""); 47 | 48 | this.submission(this.base, trimmed, option); 49 | this.close(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { Menu, Plugin, TFile, TFolder } from "obsidian"; 2 | 3 | import { TagModal } from "./TagModal"; 4 | import { SettingTab } from "./SettingTab"; 5 | import { MultiTagSettings } from "./SettingTab"; 6 | 7 | const defaultSettings: MultiTagSettings = { 8 | yamlOrInline: "inline", 9 | }; 10 | 11 | export default class MultiTagPlugin extends Plugin { 12 | settings: MultiTagSettings; 13 | //Set as Events to unload when needed. 14 | async onload() { 15 | await this.loadSettings(); 16 | // this.app.vault.adapter.append("test-folder/file-3","text") 17 | //Set up modal for adding tags to all files in a folder. 18 | this.registerEvent( 19 | this.app.workspace.on("file-menu", (menu, file, source) => { 20 | if (file instanceof TFolder) { 21 | menu.addItem((item) => { 22 | item 23 | .setIcon("tag") 24 | .setTitle("Tag folder's files") 25 | .onClick(() => 26 | new TagModal( 27 | this.app, 28 | file, 29 | this.settings.yamlOrInline, 30 | async (obj, string, setting) => { 31 | this.settings.yamlOrInline = setting; 32 | await this.saveSettings(); 33 | this.searchThroughFolders(obj, string); 34 | } 35 | ).open() 36 | ); 37 | }); 38 | } 39 | }) 40 | ); 41 | //Set up modal for adding tags to all selected files. 42 | this.registerEvent( 43 | this.app.workspace.on("files-menu", (menu, files, source) => { 44 | menu.addItem((item) => { 45 | item 46 | .setIcon("tag") 47 | .setTitle("Tag selected files") 48 | .onClick(() => 49 | new TagModal( 50 | this.app, 51 | files, 52 | this.settings.yamlOrInline, 53 | async (obj, string, setting) => { 54 | this.settings.yamlOrInline = setting; 55 | await this.saveSettings(); 56 | this.searchThroughFiles(obj, string); 57 | } 58 | ).open() 59 | ); 60 | }); 61 | }) 62 | ); 63 | this.registerEvent( 64 | this.app.workspace.on("search:results-menu", (menu: Menu, leaf: any) => { 65 | menu.addItem((item) => { 66 | item 67 | .setIcon("tag") 68 | .setTitle("Add tags to search results") 69 | .onClick(() => { 70 | let files: any[] = []; 71 | leaf.dom.vChildren.children.forEach((e: any) => { 72 | files.push(e.file); 73 | }); 74 | new TagModal( 75 | this.app, 76 | files, 77 | this.settings.yamlOrInline, 78 | async (obj, string, setting) => { 79 | this.settings.yamlOrInline = setting; 80 | await this.saveSettings(); 81 | this.searchThroughFiles(obj, string); 82 | } 83 | ).open() 84 | }); 85 | }); 86 | }) 87 | ); 88 | this.addSettingTab(new SettingTab(this.app, this)); 89 | } 90 | 91 | /** Get all files belonging to a folder. */ 92 | searchThroughFolders(obj: TFolder, string: string) { 93 | for (let child of obj.children) { 94 | if (child instanceof TFolder) { 95 | this.searchThroughFolders(child, string); 96 | } 97 | if (child instanceof TFile && child.extension === "md") { 98 | if (this.settings.yamlOrInline === "inline") { 99 | this.appendToFile(child, string); 100 | } else { 101 | this.addToFrontMatter(child, string); 102 | } 103 | } 104 | } 105 | } 106 | 107 | /** Iterate through a selection of files. */ 108 | searchThroughFiles(arr: (TFile | TFolder)[], string: string) { 109 | for (let el of arr) { 110 | if (el instanceof TFile && el.extension === "md") { 111 | if (this.settings.yamlOrInline === "inline") { 112 | this.appendToFile(el, string); 113 | } else { 114 | this.addToFrontMatter(el, string); 115 | } 116 | } 117 | } 118 | } 119 | 120 | /** Add a tag to the bottom of a note. */ 121 | appendToFile(file: TFile, string: string) { 122 | const tags = string.split(","); 123 | for (let tag of tags) { 124 | this.app.vault.append(file, `\n#${tag}`); 125 | } 126 | } 127 | 128 | /** Add tags to the top of a note. */ 129 | addToFrontMatter(file: TFile, string: string) { 130 | const tags = string.split(","); 131 | this.app.fileManager.processFrontMatter(file, (fm: any) => { 132 | if (!fm.tags) { 133 | fm.tags = new Set(tags); 134 | } else { 135 | let curTags = [...fm.tags] 136 | 137 | //Conditions are here in case user has spelled "tags" differently. Any other way to solve this? 138 | if(fm.TAGS){ 139 | curTags.push(...fm.TAGS); 140 | delete fm.TAGS 141 | } 142 | if(fm.Tags){ 143 | curTags.push(...fm.Tags); 144 | delete fm.Tags 145 | } 146 | 147 | fm.tags = new Set([...curTags, ...tags]); 148 | } 149 | }); 150 | } 151 | 152 | async loadSettings() { 153 | this.settings = Object.assign({}, defaultSettings, await this.loadData()); 154 | } 155 | 156 | async saveSettings() { 157 | await this.saveData(this.settings); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/types/custom.d.ts: -------------------------------------------------------------------------------- 1 | //Adding files-menu method to Obsidian's type file. 2 | import {} from "obsidian"; 3 | 4 | declare module "obsidian" { 5 | interface Workspace { 6 | on( 7 | name: "files-menu", 8 | callback: ( 9 | menu: Menu, 10 | file: TAbstractFile[], 11 | source: string, 12 | leaf?: WorkspaceLeaf 13 | ) => any, 14 | ctx?: any 15 | ): EventRef; 16 | on( 17 | name: "search:results-menu", 18 | callback: (menu: Menu, leaf: any) => any, 19 | ctx?: any 20 | ): EventRef; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | #tagInput { 2 | margin-left: 3px; 3 | } 4 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import sveltePreprocess from "svelte-preprocess"; 2 | 3 | const config = { 4 | preprocess: sveltePreprocess(), 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "esModuleInterop": true, 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "types": ["node", "svelte", "jest"], 15 | "strictNullChecks": true, 16 | "lib": ["DOM", "ES5", "ES6", "ES7"] 17 | }, 18 | "include": ["**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "0.6.0": "0.15.0", 4 | "0.6.1": "0.15.0", 5 | "0.8.0": "1.4.13", 6 | "0.8.1": "1.4.13", 7 | "0.8.2": "1.4.13", 8 | "0.9.0": "1.4.13", 9 | "0.9.1": "1.4.13", 10 | "0.10.0": "1.4.13" 11 | } 12 | --------------------------------------------------------------------------------