├── .npmrc ├── .eslintignore ├── assets └── quick-demo.gif ├── .editorconfig ├── versions.json ├── manifest.json ├── .gitignore ├── src ├── utils │ ├── Utils.ts │ ├── FolderSuggester.ts │ ├── SuggesterModal.ts │ ├── PromptModal.ts │ └── suggest.ts ├── types.ts └── main.ts ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── styles.css ├── esbuild.config.mjs ├── .github └── workflows │ └── release.yml └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /assets/quick-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/valteriomon/obsidian-rapid-notes/HEAD/assets/quick-demo.gif -------------------------------------------------------------------------------- /.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 = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.2.0": "0.15.0", 5 | "1.2.1": "0.15.0", 6 | "1.2.2": "0.15.0", 7 | "1.2.3": "0.15.0", 8 | "1.2.4": "0.15.0", 9 | "1.2.5": "0.15.0", 10 | "1.2.6": "0.15.0" 11 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "obsidian-rapid-notes", 3 | "name": "Rapid Notes", 4 | "version": "1.2.6", 5 | "minAppVersion": "0.15.0", 6 | "description": "Create and place notes quickly in specific folders based on predefined prefixes.", 7 | "author": "valteriomon", 8 | "authorUrl": "https://github.com/valteriomon", 9 | "isDesktopOnly": false 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | build 11 | 12 | # Don't include the compiled main.js file in the repo. 13 | # They should be uploaded to GitHub releases instead. 14 | main.js 15 | 16 | # Exclude sourcemaps 17 | *.map 18 | 19 | # obsidian 20 | data.json 21 | 22 | # Exclude macOS Finder (System Explorer) View States 23 | .DS_Store 24 | -------------------------------------------------------------------------------- /src/utils/Utils.ts: -------------------------------------------------------------------------------- 1 | // Credits go to SilentVoid13's Templater Plugin: https://github.com/SilentVoid13/Templater 2 | 3 | export function arraymove( 4 | arr: T[], 5 | fromIndex: number, 6 | toIndex: number 7 | ): void { 8 | if (toIndex < 0 || toIndex === arr.length) { 9 | return; 10 | } 11 | const element = arr[fromIndex]; 12 | arr[fromIndex] = arr[toIndex]; 13 | arr[toIndex] = element; 14 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ] 20 | }, 21 | "include": [ 22 | "src/**/*.ts" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | declare module "obsidian" { 2 | interface App { 3 | dom: { 4 | appContainerEl: HTMLElement; 5 | } 6 | } 7 | 8 | interface SearchComponent { 9 | containerEl: HTMLElement; 10 | } 11 | 12 | interface FileManager { 13 | createNewMarkdownFile: ( 14 | folder: TFolder | undefined, 15 | filename: string 16 | ) => Promise; 17 | } 18 | 19 | interface Vault { 20 | createFolder(path: string): Promise; 21 | getFolderByPath(path: string): TFolder | null; 22 | } 23 | } 24 | 25 | export {}; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-rapid-notes", 3 | "version": "1.2.6", 4 | "description": "Place notes in specific folders at the moment of creation using the prefixes defined in settings. Optionally add custom prefixes to the filenames and trigger the creation in specific folders with shortcuts or from the editor while typing links.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/node": "^16.11.6", 16 | "@typescript-eslint/eslint-plugin": "5.29.0", 17 | "@typescript-eslint/parser": "5.29.0", 18 | "builtin-modules": "3.3.0", 19 | "esbuild": "0.14.47", 20 | "esbuild-plugin-copy": "^2.1.1", 21 | "obsidian": "latest", 22 | "tslib": "2.4.0", 23 | "typescript": "4.7.4" 24 | }, 25 | "dependencies": { 26 | "@popperjs/core": "^2.11.6" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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. -------------------------------------------------------------------------------- /src/utils/FolderSuggester.ts: -------------------------------------------------------------------------------- 1 | // Credits go to Liam's Periodic Notes Plugin: https://github.com/liamcain/obsidian-periodic-notes 2 | 3 | import { App, TAbstractFile, TFolder } from "obsidian"; 4 | import { TextInputSuggest } from "./suggest"; 5 | 6 | export class FolderSuggest extends TextInputSuggest { 7 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 8 | super(app, inputEl); 9 | } 10 | 11 | getSuggestions(inputStr: string): TFolder[] { 12 | const abstractFiles = this.app.vault.getAllLoadedFiles(); 13 | const folders: TFolder[] = []; 14 | const lowerCaseInputStr = inputStr.toLowerCase(); 15 | 16 | abstractFiles.forEach((folder: TAbstractFile) => { 17 | if ( 18 | folder instanceof TFolder && 19 | folder.path.toLowerCase().contains(lowerCaseInputStr) 20 | ) { 21 | folders.push(folder); 22 | } 23 | }); 24 | 25 | return folders; 26 | } 27 | 28 | renderSuggestion(file: TFolder, el: HTMLElement): void { 29 | el.setText(file.path); 30 | } 31 | 32 | selectSuggestion(file: TFolder): void { 33 | this.inputEl.value = file.path; 34 | this.inputEl.trigger("input"); 35 | this.close(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* Prompt */ 2 | .rapid-notes-modal .prompt-instructions { 3 | border-top: 0; 4 | } 5 | 6 | .rapid-notes-modal .prompt-instructions .prompt-instruction-command { 7 | color: var(--interactive-accent); 8 | } 9 | 10 | .rapid-notes-modal .prompt-instructions-heading { 11 | font-weight: 700; 12 | } 13 | 14 | .is-phone .rapid-notes-modal .prompt-instructions { 15 | display: block; 16 | } 17 | 18 | /* Settings */ 19 | .rapid-notes_search { 20 | width: 100%; 21 | } 22 | 23 | .rapid-notes-add-prefix-entry .setting-item-control { 24 | align-self: flex-start; 25 | } 26 | 27 | .rapid-notes-settings-entry { 28 | margin-top: 0!important; 29 | padding-top: 0; 30 | } 31 | 32 | @media screen and (min-width: 780px) { 33 | .rapid-notes-settings-entry input[type="text"]:first-child { 34 | max-width: 60px; 35 | } 36 | .rapid-notes-settings-entry input[type="text"]:nth-child(2) { 37 | max-width: 90px; 38 | } 39 | } 40 | 41 | @media screen and (max-width: 780px) { 42 | .rapid-notes-settings-entry .setting-item-control { 43 | flex-wrap: wrap; 44 | justify-content: space-between; 45 | } 46 | 47 | .rapid-notes-settings-entry input[type="text"]:first-child, 48 | .rapid-notes-settings-entry input[type="text"]:nth-child(2) { 49 | flex-grow: 1; 50 | flex-basis: 44%; 51 | width: 100%; 52 | } 53 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from 'builtin-modules'; 4 | import { copy } from "esbuild-plugin-copy"; 5 | 6 | const banner = 7 | `/* 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 | esbuild.build({ 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 | format: 'cjs', 37 | watch: !prod, 38 | target: 'es2018', 39 | plugins: [ 40 | copy({ 41 | assets: { 42 | from: ['main.js'], 43 | to: ['./build/main.js'], 44 | }, 45 | }), 46 | copy({ 47 | assets: { 48 | from: ['manifest.json'], 49 | to: ['./build/manifest.json'], 50 | }, 51 | }), 52 | copy({ 53 | assets: { 54 | from: ['styles.css'], 55 | to: ['./build/styles.css'], 56 | }, 57 | }) 58 | ], 59 | logLevel: "info", 60 | sourcemap: prod ? false : 'inline', 61 | treeShaking: true, 62 | outfile: 'main.js', 63 | }).catch(() => process.exit(1)); 64 | -------------------------------------------------------------------------------- /src/utils/SuggesterModal.ts: -------------------------------------------------------------------------------- 1 | // Credits go to SilentVoid13's Templater Plugin: https://github.com/SilentVoid13/Templater 2 | 3 | import { App, FuzzyMatch, FuzzySuggestModal } from "obsidian"; 4 | 5 | export class SuggesterModal extends FuzzySuggestModal { 6 | private resolve: (value: T) => void; 7 | private reject: () => void; 8 | private submitted = false; 9 | 10 | constructor( 11 | app: App, 12 | private text_items: string[] | ((item: T) => string), 13 | private items: T[], 14 | placeholder: string, 15 | limit?: number 16 | ) { 17 | super(app); 18 | this.setPlaceholder(placeholder); 19 | limit && (this.limit = limit); 20 | } 21 | 22 | getItems(): T[] { 23 | return this.items; 24 | } 25 | 26 | onClose(): void { 27 | if (!this.submitted) { 28 | this.reject(); 29 | } 30 | } 31 | 32 | selectSuggestion( 33 | value: FuzzyMatch, 34 | evt: MouseEvent | KeyboardEvent 35 | ): void { 36 | this.submitted = true; 37 | this.close(); 38 | this.onChooseSuggestion(value, evt); 39 | } 40 | 41 | getItemText(item: T): string { 42 | if (this.text_items instanceof Function) { 43 | return this.text_items(item); 44 | } 45 | return ( 46 | this.text_items[this.items.indexOf(item)] || "Undefined Text Item" 47 | ); 48 | } 49 | 50 | onChooseItem(item: T): void { 51 | this.resolve(item); 52 | } 53 | 54 | async openAndGetValue( 55 | resolve: (value: T) => void, 56 | reject: () => void 57 | ): Promise { 58 | this.resolve = resolve; 59 | this.reject = reject; 60 | this.open(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | # Sequence of patterns matched against refs/tags 6 | tags: 7 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 8 | 9 | env: 10 | PLUGIN_NAME: obsidian-rapid-notes # Change this to the name of your plugin-id folder 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Use Node.js 19 | uses: actions/setup-node@v1 20 | with: 21 | node-version: "18.x" # You might need to adjust this value to your own version 22 | - name: Build 23 | id: build 24 | run: | 25 | yarn 26 | yarn run build --if-present 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 "tag_name=$(git tag --sort version:refname | tail -n 1)" >> $GITHUB_OUTPUT 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | - name: Upload zip file 44 | id: upload-zip 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 51 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 52 | asset_content_type: application/zip 53 | - name: Upload main.js 54 | id: upload-main 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | - name: Upload manifest.json 64 | id: upload-manifest 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./manifest.json 71 | asset_name: manifest.json 72 | asset_content_type: application/json 73 | - name: Upload styles.css 74 | id: upload-css 75 | uses: actions/upload-release-asset@v1 76 | env: 77 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 78 | with: 79 | upload_url: ${{ steps.create_release.outputs.upload_url }} 80 | asset_path: ./styles.css 81 | asset_name: styles.css 82 | asset_content_type: text/css 83 | -------------------------------------------------------------------------------- /src/utils/PromptModal.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Modal, 4 | Instruction, 5 | } from "obsidian"; 6 | 7 | export class PromptModal extends Modal { 8 | private resolve: (value: string) => void; 9 | private reject: () => void; 10 | private submitted = false; 11 | 12 | inputEl: HTMLInputElement; 13 | inputListener: EventListener; 14 | 15 | constructor( 16 | app: App, 17 | private placeholder: string, 18 | private promptClass: string, 19 | private escapeSymbol: string, 20 | private instructions: Instruction[] 21 | ) { 22 | super(app); 23 | 24 | // Create input 25 | this.inputEl = document.createElement('input'); 26 | this.inputEl.type = 'text'; 27 | this.inputEl.placeholder = placeholder; 28 | this.inputEl.className = 'prompt-input'; 29 | 30 | this.modalEl.className = `prompt ${this.promptClass}`; 31 | this.modalEl.innerHTML = ''; 32 | this.modalEl.appendChild(this.inputEl); 33 | 34 | if(instructions.length) { 35 | // Suggestions block 36 | const instructionsHeadingEl = document.createElement('div'); 37 | instructionsHeadingEl.className = 'prompt-instructions prompt-instructions-heading'; 38 | instructionsHeadingEl.innerText = "Prefixed folders:"; 39 | 40 | const instructionsFooterEl = document.createElement('div'); 41 | instructionsFooterEl.className = 'prompt-instructions'; 42 | instructionsFooterEl.innerHTML = `Use ${this.escapeSymbol} to escape the prefix.`; 43 | 44 | const instructionsListEl = document.createElement('div'); 45 | instructionsListEl.addClass('prompt-instructions'); 46 | const children = instructions.map((instruction) => { 47 | const child = document.createElement('div'); 48 | child.addClass('prompt-instruction'); 49 | 50 | const command = document.createElement('span'); 51 | command.addClass('prompt-instruction-command'); 52 | command.innerText = instruction.command; 53 | child.appendChild(command); 54 | 55 | const purpose = document.createElement('span'); 56 | purpose.innerText = instruction.purpose; 57 | child.appendChild(purpose); 58 | 59 | return child; 60 | }); 61 | for (const child of children) { 62 | instructionsListEl.appendChild(child); 63 | } 64 | this.modalEl.appendChild(instructionsHeadingEl); 65 | this.modalEl.appendChild(instructionsListEl); 66 | this.modalEl.appendChild(instructionsFooterEl); 67 | } 68 | 69 | this.inputListener = this.listenInput.bind(this); 70 | } 71 | 72 | listenInput(evt: KeyboardEvent) { 73 | if (evt.key === 'Enter') { 74 | // prevent enter after note creation 75 | evt.preventDefault(); 76 | this.enterCallback(evt); 77 | } 78 | } 79 | 80 | onOpen(): void { 81 | this.inputEl.focus(); 82 | this.inputEl.addEventListener('keydown', this.inputListener); 83 | } 84 | 85 | onClose(): void { 86 | this.inputEl.removeEventListener('keydown', this.inputListener); 87 | this.contentEl.empty(); 88 | if (!this.submitted) { 89 | // TOFIX: for some reason throwing Error on iOS causes the app to freeze. 90 | this.reject(); 91 | } 92 | } 93 | 94 | private enterCallback(evt: KeyboardEvent) { 95 | if (evt.key === "Enter") { 96 | this.resolveAndClose(evt); 97 | } 98 | } 99 | 100 | private resolveAndClose(evt: Event | KeyboardEvent) { 101 | this.submitted = true; 102 | evt.preventDefault(); 103 | this.resolve(this.inputEl.value); 104 | this.close(); 105 | } 106 | 107 | async openAndGetValue( 108 | resolve: (value: string) => void, 109 | reject: () => void 110 | ): Promise { 111 | this.resolve = resolve; 112 | this.reject = reject; 113 | this.open(); 114 | } 115 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rapid Notes Plugin 2 | 3 | Place notes in specific folders at the moment of creation using the prefixes defined in settings. Optionally add custom prefixes to the filenames and trigger the creation in specific folders with shortcuts or from the editor while typing links. 4 | 5 | ## New in version 1.2 6 | 7 | ### Create notes into your folders right from the editor 8 | - You can trigger the rapid note functionality from the editor, calling the command with the cursor placed within a text in double brackets, or selecting text, with alias support incorporated. 9 | ### New features with settings available 10 | - You can add an actual prefix to the name of the created note. 11 | - Customize the escape symbol used to avoid moving the note based on the prefix ("/" by default). 12 | - Add a common separator between the prefix and the filename. 13 | - If the new file about to be created already exists, you can choose to open the existent file or create a new one with the same name followed by a number. 14 | - Updated modal with the list of prefixes as memory help. Can be disabled through the settings. 15 | - Option to capitalize the folder and file names. 16 | - Add commands (which can be binded to hotkeys) that allow you to trigger any prefix entry and create the file directly into a folder (same as the Rapid Note command, it can be opened in same tab, new tab, background tab, new pane or new window). 17 | 18 | ### New commands 19 | - Instead of a single "Rapid Note" command, now you can open the file in the same tab, new tab, background tab, new pane or new window. 20 | 21 | ### New hidden features 22 | - If a used folder is renamed/deleted, a warning is shown. In case it's renamed, the entry is updated. 23 | - In the folder suggester, show first the preferred saving location according to the Obsidian settings: Vault folder, an specified location, or the same folder as the current active file. 24 | - If the filename contains "/" the full path is created: folders and filename following the last slash character. 25 | 26 | ### More 27 | - Plugin styles improved for the mobile experience. 28 | - Lots of bug fixes and code improvements. Removed lookbehind in regular expressions which could lead to issues in some iOS versions. Tested in iOS 16.4.1. 29 | - Newly introduced bugs to be fixed. 30 | 31 | # Quick demo video 32 | 33 | A couple of notes the video may not be too clear about: when creating a new note from the editor from a selection, the link to it is created. The escaping character ("/" by default) works both inline and on the prompt. 34 | 35 | ![Example of basic usage](./assets/quick-demo.gif) 36 | 37 | ## How to use 38 | 39 | In the plugin settings add prefix/folder pairs, considering prefixes must be single words and are case sensitive. Each prefix and folder can be used a single time. When you run the `Rapid Notes: New note` command (which can be binded to a new hotkey or replace the default "Create New Note" hotkey) if you input the prefix previously set, it's going used to create a new note using the input value without the prefix as name. If no prefix matches, a folder suggest is open. If your input begins with a slash `/` then the prefix will be ignored and you will always be prompted with a folder suggester. 40 | 41 | ## Example of basic usage 42 | 43 | If you have a folder named `JavaScript` in your vault where you save all notes regarding JavaScript, you could add in the Rapid Notes settings the prefix `js` and assign it to said folder. Upon triggering the command to create a new note, you could enter into the prompt `js Promises` and a new file named `Promises` will be saved into the `JavaScript` folder. 44 | 45 | 46 | ## Example of escaped prefix 47 | 48 | If you have your `js` prefix set, but you wish to create a new file named `js rulez`, then you can simply input `/js rulez` into the prompt and you will be prompted to select where to create the new file. 49 | 50 | 51 | ## Considerations 52 | 53 | - You can combine Rapid Notes with [Templater plugin](https://github.com/SilentVoid13/Templater) to speed up your workflow even further, assigning templates for folders and enabling the setting to trigger Templater on file creation. 54 | 55 | ## Installation 56 | 57 | Find "Rapid Notes" in the Community plugins through the Obsidian app! 58 | 59 | ## Development 60 | 61 | 1. Clone this repo and place it in a new vault for development inside `.obsidian/plugins/` folder. 62 | 2. Install NodeJS, then run `npm i` in the command line under the repo folder. 63 | 3. Run `npm run dev` to compile your plugin from `main.ts` to `main.js`. 64 | 4. Make changes to `main.ts` (or create new `.ts` files). Those changes should be automatically compiled into `main.js`. 65 | 5. Reload Obsidian to load the new version of your plugin ("Reload app without saving" in the command palette for refreshing). 66 | 6. Enable plugin in settings window. 67 | 68 | ## Credits 69 | 70 | This plugin is a fork of [Obsidian Sample Plugin](https://github.com/obsidianmd/obsidian-sample-plugin) and the modules used for prompts and suggesters are based on [Liam's Periodic Notes Plugin](https://github.com/liamcain/obsidian-periodic-notes) and [SilentVoid13's Templater Plugin](https://github.com/SilentVoid13/Templater). All the credits go to the original authors. 71 | -------------------------------------------------------------------------------- /src/utils/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 | 6 | const wrapAround = (value: number, size: number): number => { 7 | return ((value % size) + size) % size; 8 | }; 9 | 10 | class Suggest { 11 | private owner: ISuggestOwner; 12 | private values: T[]; 13 | private suggestions: HTMLDivElement[]; 14 | private selectedItem: number; 15 | private containerEl: HTMLElement; 16 | 17 | constructor( 18 | owner: ISuggestOwner, 19 | containerEl: HTMLElement, 20 | scope: Scope 21 | ) { 22 | this.owner = owner; 23 | this.containerEl = containerEl; 24 | 25 | containerEl.on( 26 | "click", 27 | ".suggestion-item", 28 | this.onSuggestionClick.bind(this) 29 | ); 30 | containerEl.on( 31 | "mousemove", 32 | ".suggestion-item", 33 | this.onSuggestionMouseover.bind(this) 34 | ); 35 | 36 | scope.register([], "ArrowUp", (event) => { 37 | if (!event.isComposing) { 38 | this.setSelectedItem(this.selectedItem - 1, true); 39 | return false; 40 | } 41 | }); 42 | 43 | scope.register([], "ArrowDown", (event) => { 44 | if (!event.isComposing) { 45 | this.setSelectedItem(this.selectedItem + 1, true); 46 | return false; 47 | } 48 | }); 49 | 50 | scope.register([], "Enter", (event) => { 51 | if (!event.isComposing) { 52 | this.useSelectedItem(event); 53 | return false; 54 | } 55 | }); 56 | } 57 | 58 | onSuggestionClick(event: MouseEvent, el: HTMLDivElement): void { 59 | event.preventDefault(); 60 | 61 | const item = this.suggestions.indexOf(el); 62 | this.setSelectedItem(item, false); 63 | this.useSelectedItem(event); 64 | } 65 | 66 | onSuggestionMouseover(_event: MouseEvent, el: HTMLDivElement): void { 67 | const item = this.suggestions.indexOf(el); 68 | this.setSelectedItem(item, false); 69 | } 70 | 71 | setSuggestions(values: T[]) { 72 | this.containerEl.empty(); 73 | const suggestionEls: HTMLDivElement[] = []; 74 | 75 | values.forEach((value) => { 76 | const suggestionEl = this.containerEl.createDiv("suggestion-item"); 77 | this.owner.renderSuggestion(value, suggestionEl); 78 | suggestionEls.push(suggestionEl); 79 | }); 80 | 81 | this.values = values; 82 | this.suggestions = suggestionEls; 83 | this.setSelectedItem(0, false); 84 | } 85 | 86 | useSelectedItem(event: MouseEvent | KeyboardEvent) { 87 | const currentValue = this.values[this.selectedItem]; 88 | if (currentValue) { 89 | this.owner.selectSuggestion(currentValue, event); 90 | } 91 | } 92 | 93 | setSelectedItem(selectedIndex: number, scrollIntoView: boolean) { 94 | const normalizedIndex = wrapAround( 95 | selectedIndex, 96 | this.suggestions.length 97 | ); 98 | const prevSelectedSuggestion = this.suggestions[this.selectedItem]; 99 | const selectedSuggestion = this.suggestions[normalizedIndex]; 100 | 101 | prevSelectedSuggestion?.removeClass("is-selected"); 102 | selectedSuggestion?.addClass("is-selected"); 103 | 104 | this.selectedItem = normalizedIndex; 105 | 106 | if (scrollIntoView) { 107 | selectedSuggestion.scrollIntoView(false); 108 | } 109 | } 110 | } 111 | 112 | export abstract class TextInputSuggest implements ISuggestOwner { 113 | protected app: App; 114 | protected inputEl: HTMLInputElement | HTMLTextAreaElement; 115 | 116 | private popper: PopperInstance; 117 | private scope: Scope; 118 | private suggestEl: HTMLElement; 119 | private suggest: Suggest; 120 | 121 | constructor(app: App, inputEl: HTMLInputElement | HTMLTextAreaElement) { 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 | this.open(this.app.dom.appContainerEl, this.inputEl); 156 | } else { 157 | this.close(); 158 | } 159 | } 160 | 161 | open(container: HTMLElement, inputEl: HTMLElement): void { 162 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 163 | this.app.keymap.pushScope(this.scope); 164 | 165 | container.appendChild(this.suggestEl); 166 | this.popper = createPopper(inputEl, this.suggestEl, { 167 | placement: "bottom-start", 168 | modifiers: [ 169 | { 170 | name: "sameWidth", 171 | enabled: true, 172 | fn: ({ state, instance }) => { 173 | // Note: positioning needs to be calculated twice - 174 | // first pass - positioning it according to the width of the popper 175 | // second pass - position it with the width bound to the reference element 176 | // we need to early exit to avoid an infinite loop 177 | const targetWidth = `${state.rects.reference.width}px`; 178 | if (state.styles.popper.width === targetWidth) { 179 | return; 180 | } 181 | state.styles.popper.width = targetWidth; 182 | instance.update(); 183 | }, 184 | phase: "beforeWrite", 185 | requires: ["computeStyles"], 186 | }, 187 | ], 188 | }); 189 | } 190 | 191 | close(): void { 192 | this.app.keymap.popScope(this.scope); 193 | 194 | this.suggest.setSuggestions([]); 195 | if (this.popper) this.popper.destroy(); 196 | this.suggestEl.detach(); 197 | } 198 | 199 | abstract getSuggestions(inputStr: string): T[]; 200 | abstract renderSuggestion(item: T, el: HTMLElement): void; 201 | abstract selectSuggestion(item: T): void; 202 | } 203 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Notice, 4 | Plugin, 5 | PluginSettingTab, 6 | Setting, 7 | TFile, 8 | TFolder, 9 | Vault, 10 | normalizePath, 11 | Editor, 12 | EditorPosition 13 | } from 'obsidian'; 14 | import { FolderSuggest } from './utils/FolderSuggester'; 15 | import { PromptModal } from './utils/PromptModal'; 16 | import { SuggesterModal } from './utils/SuggesterModal'; 17 | import { arraymove } from './utils/Utils'; 18 | 19 | export enum NotePlacement { 20 | sameTab, 21 | newTab = "tab", 22 | newPane = "split", 23 | newWindow = "window" 24 | } 25 | export interface PrefixFolderTuple { 26 | prefix: string; 27 | filenamePrefix: string; 28 | folder: string; 29 | addCommand: boolean; 30 | } 31 | 32 | export interface FoldersByPath { 33 | [path: string]: TFolder; 34 | } 35 | 36 | export interface FoldersByPrefix { 37 | [prefix: string]: PrefixFolderTuple; 38 | } 39 | 40 | export interface RapidNotesSettings { 41 | prefixedFolders: Array; 42 | forceFileCreation: boolean; 43 | showModalSuggestions: boolean; 44 | capitalizeFilename: boolean; 45 | escapeSymbol: string; 46 | realPrefixSeparator: string; 47 | } 48 | 49 | const DEFAULT_SETTINGS = { 50 | prefixedFolders: [], 51 | forceFileCreation: false, 52 | showModalSuggestions: true, 53 | capitalizeFilename: true, 54 | escapeSymbol: "/", 55 | realPrefixSeparator: " " 56 | }; 57 | 58 | const PLACEHOLDER_RESOLVERS = [ 59 | (string: string) => string.replace( 60 | /\{\{date:([^\}]+)\}\}/gi, 61 | (_, format) => { 62 | return window.moment().format(format); 63 | } 64 | ) 65 | ]; 66 | 67 | export default class RapidNotes extends Plugin { 68 | settings: RapidNotesSettings; 69 | 70 | async onload() { 71 | console.log(`Loading ${this.manifest.name} plugin`); 72 | await this.loadSettings(); 73 | 74 | this.addCommands(this); 75 | 76 | this.app.vault.on("rename", (file, oldPath) => { 77 | const oldItemIndex = this.settings.prefixedFolders.findIndex(prefixedFolder => prefixedFolder.folder === oldPath); 78 | if (oldItemIndex >= 0) { 79 | this.settings.prefixedFolders[oldItemIndex].folder = file.path; 80 | new Notice(`Rapid notes: ${oldPath} was being used as a prefixed folder, path was updated.`); 81 | if(this.settings.prefixedFolders[oldItemIndex].addCommand) { 82 | new Notice(`Rapid notes: The custom command needs an Obsidian relaunch to work properly.`); 83 | } 84 | this.saveSettings(); 85 | }; 86 | }); 87 | 88 | this.app.vault.on("delete", file => { 89 | const oldItemIndex = this.settings.prefixedFolders.findIndex(prefixedFolder => prefixedFolder.folder === file.path); 90 | if (oldItemIndex >= 0) { 91 | new Notice(`Rapid notes: ${file.path} was being used as a prefixed folder. The entry will no longer work, remove or update manually.`); 92 | if(this.settings.prefixedFolders[oldItemIndex].addCommand) { 93 | this.settings.prefixedFolders[oldItemIndex].addCommand = false; 94 | new Notice(`Rapid notes: The custom command will be removed after Obsidian relaunches.`); 95 | } 96 | this.saveSettings(); 97 | }; 98 | }); 99 | 100 | this.addSettingTab(new RapidNotesSettingsTab(this.app, this)); 101 | } 102 | 103 | onunload() { 104 | console.log(`Unloading ${this.manifest.name} plugin`); 105 | } 106 | 107 | async loadSettings() { 108 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 109 | } 110 | 111 | async saveSettings() { 112 | await this.saveData(this.settings); 113 | } 114 | 115 | addCommands(plugin: RapidNotes) { 116 | plugin.addCommand({ 117 | id: "new-prefixed-note", 118 | name: "New note in current tab", 119 | callback: async () => { 120 | const promptValue = await this.promptNewNote(); 121 | if(promptValue) { 122 | const { folderPath, filename } = await this.parseFilename(promptValue); 123 | this.openNote(folderPath, filename, NotePlacement.sameTab); 124 | } 125 | } 126 | }); 127 | plugin.addCommand({ 128 | id: "new-prefixed-note-new-tab", 129 | name: "New note in new tab", 130 | callback: async () => { 131 | const promptValue = await this.promptNewNote(); 132 | if(promptValue) { 133 | const { folderPath, filename } = await this.parseFilename(promptValue); 134 | this.openNote(folderPath, filename, NotePlacement.newTab); 135 | } 136 | } 137 | }); 138 | plugin.addCommand({ 139 | id: "new-prefixed-note-new-background-tab", 140 | name: "New note in background tab", 141 | callback: async () => { 142 | const promptValue = await this.promptNewNote(); 143 | if(promptValue) { 144 | const { folderPath, filename } = await this.parseFilename(promptValue); 145 | this.openNote(folderPath, filename, NotePlacement.newTab, false); 146 | } 147 | } 148 | }); 149 | plugin.addCommand({ 150 | id: "new-prefixed-note-new-pane", 151 | name: "New note in new pane", 152 | callback: async () => { 153 | const promptValue = await this.promptNewNote(); 154 | if(promptValue) { 155 | const { folderPath, filename } = await this.parseFilename(promptValue); 156 | this.openNote(folderPath, filename, NotePlacement.newPane); 157 | } 158 | } 159 | }); 160 | plugin.addCommand({ 161 | id: "new-prefixed-note-new-window", 162 | name: "New note in new window", 163 | callback: async () => { 164 | const promptValue = await this.promptNewNote(); 165 | if(promptValue) { 166 | const { folderPath, filename } = await this.parseFilename(promptValue); 167 | this.openNote(folderPath, filename, NotePlacement.newWindow); 168 | } 169 | } 170 | }); 171 | plugin.settings.prefixedFolders.forEach((prefixedFolder) => { 172 | let fullPrefix = prefixedFolder.filenamePrefix; 173 | if(fullPrefix) { 174 | fullPrefix += plugin.settings.realPrefixSeparator; 175 | } 176 | 177 | if(prefixedFolder.addCommand && prefixedFolder.folder) { 178 | plugin.addCommand({ 179 | id: "new-prefixed-note-" + prefixedFolder.folder, 180 | name: "New note in " + prefixedFolder.folder, 181 | callback: async () => { 182 | const promptValue = await this.promptNewNote(prefixedFolder.folder); 183 | this.openNote(prefixedFolder.folder, fullPrefix + promptValue, NotePlacement.sameTab); 184 | } 185 | }); 186 | plugin.addCommand({ 187 | id: "new-prefixed-note-" + prefixedFolder.folder + "-new-tab", 188 | name: "New note in " + prefixedFolder.folder + " (open in new tab)", 189 | callback: async () => { 190 | const promptValue = await this.promptNewNote(prefixedFolder.folder); 191 | this.openNote(prefixedFolder.folder, fullPrefix + promptValue, NotePlacement.newTab); 192 | } 193 | }); 194 | plugin.addCommand({ 195 | id: "new-prefixed-note-" + prefixedFolder.folder + "-new-background-tab", 196 | name: "New note in " + prefixedFolder.folder + " (open in new background tab)", 197 | callback: async () => { 198 | const promptValue = await this.promptNewNote(prefixedFolder.folder); 199 | this.openNote(prefixedFolder.folder, fullPrefix + promptValue, NotePlacement.newTab, false); 200 | } 201 | }); 202 | plugin.addCommand({ 203 | id: "new-prefixed-note-" + prefixedFolder.folder + "-new-pane", 204 | name: "New note in " + prefixedFolder.folder + " (open in new pane)", 205 | callback: async () => { 206 | const promptValue = await this.promptNewNote(prefixedFolder.folder); 207 | this.openNote(prefixedFolder.folder, fullPrefix + promptValue, NotePlacement.newPane); 208 | } 209 | }); 210 | plugin.addCommand({ 211 | id: "new-prefixed-note-" + prefixedFolder.folder + "-new-window", 212 | name: "New note in " + prefixedFolder.folder + " (open in new window)", 213 | callback: async () => { 214 | const promptValue = await this.promptNewNote(prefixedFolder.folder); 215 | this.openNote(prefixedFolder.folder, fullPrefix + promptValue, NotePlacement.newWindow); 216 | } 217 | }); 218 | } 219 | }); 220 | 221 | plugin.addCommand({ 222 | id: "new-prefixed-note-inline-new-tab", 223 | name: "New inline note (open in new tab)", 224 | editorCallback: async (editor: Editor) => { 225 | this.triggerInlineReplacement(editor, NotePlacement.newTab); 226 | }, 227 | }); 228 | plugin.addCommand({ 229 | id: "new-prefixed-note-inline-background-tab", 230 | name: "New inline note (open in background tab)", 231 | editorCallback: async (editor: Editor) => { 232 | this.triggerInlineReplacement(editor, NotePlacement.newTab, false); 233 | }, 234 | }); 235 | plugin.addCommand({ 236 | id: "new-prefixed-note-inline-new-pane", 237 | name: "New inline note (open in new pane)", 238 | editorCallback: async (editor: Editor) => { 239 | this.triggerInlineReplacement(editor, NotePlacement.newPane); 240 | }, 241 | }); 242 | plugin.addCommand({ 243 | id: "new-prefixed-note-inline-new-window", 244 | name: "New inline note (open in new window)", 245 | editorCallback: async (editor: Editor) => { 246 | this.triggerInlineReplacement(editor, NotePlacement.newWindow); 247 | }, 248 | }); 249 | } 250 | 251 | async promptNewNote(folder: string = "") { 252 | let placeholder = "New note"; 253 | let modalSuggestions = Array(); 254 | let showSuggestions = this.settings.showModalSuggestions; 255 | 256 | if(folder) { 257 | placeholder += ` in ${folder}`; 258 | showSuggestions = false; 259 | } 260 | if(showSuggestions) { 261 | modalSuggestions = this.settings.prefixedFolders.map((item)=>{ return {"command": item.prefix, "purpose": this.resolvePlaceholderValues(item.folder) } }); 262 | } 263 | const escapeSymbol = this.settings.escapeSymbol || "/"; 264 | const prompt = new PromptModal(this.app, placeholder, "rapid-notes-modal", escapeSymbol, modalSuggestions); 265 | const promptValue: string = await new Promise((resolve) => prompt.openAndGetValue((resolve), ()=>{})); 266 | return promptValue.trim(); 267 | } 268 | 269 | checkPrefix(filename: string) { 270 | let folderPath = ""; 271 | const prefixedFolders = this.getFoldersByPrefix(this.settings.prefixedFolders); 272 | const firstSpaceIndex = filename.indexOf(" "); 273 | if (firstSpaceIndex >= 0) { 274 | // Prompt value has a space 275 | const prefix = filename.substring(0, firstSpaceIndex); 276 | if (prefix in prefixedFolders) { 277 | // Prefix match found 278 | folderPath = prefixedFolders[prefix].folder; 279 | filename = filename.substring(firstSpaceIndex + 1); 280 | 281 | // Check if a prefix needs to be added to the note, and add it correctly if the value is a path 282 | const filenamePrefix = prefixedFolders[prefix].filenamePrefix?.trim(); 283 | if(filenamePrefix) { 284 | const lastSlashIndex = filename.lastIndexOf("/"); 285 | if (lastSlashIndex >= 0) { 286 | filename = filename.slice(0, lastSlashIndex + 1) + filenamePrefix + this.settings.realPrefixSeparator + filename.slice(lastSlashIndex + 1); 287 | } else { 288 | filename = filenamePrefix + " " + filename; 289 | } 290 | } 291 | } 292 | } 293 | return { 294 | folderPath: folderPath, 295 | filename: filename 296 | } 297 | } 298 | 299 | resolvePlaceholderValues(string: string): string { 300 | return PLACEHOLDER_RESOLVERS.reduce( 301 | (resolved, resolver) => resolver(resolved), 302 | string 303 | ); 304 | } 305 | 306 | async parseFilename(filename: string) { 307 | var folderPath = ""; 308 | const escapeSymbol = this.settings.escapeSymbol || "/"; 309 | if (filename.charAt(0) === escapeSymbol) { 310 | // Prompt value is escaped, no prefix check needed 311 | filename = filename.substring(1); 312 | } else { 313 | ({ folderPath, filename } = this.checkPrefix(filename)); 314 | } 315 | folderPath = this.resolvePlaceholderValues(folderPath); 316 | filename = this.resolvePlaceholderValues(filename); 317 | if (!folderPath) { 318 | let folders:TFolder[] = this.getFolders(); 319 | const activeFile:TFile|null = this.app.workspace.getActiveFile(); 320 | const preferredFolder:TFolder = this.app.fileManager.getNewFileParent(activeFile?.path || ""); 321 | 322 | folders = folders.filter((folder) => folder.path !== preferredFolder.path); 323 | folders.unshift(preferredFolder); 324 | const folderPaths = folders.map((folder) => folder.path); 325 | const suggester = new SuggesterModal(this.app, folderPaths, folderPaths, "Choose folder"); 326 | folderPath = await new Promise((resolve) => suggester.openAndGetValue(resolve, ()=>{})); 327 | } 328 | return { 329 | folderPath: folderPath, 330 | filename: filename 331 | } 332 | } 333 | 334 | async openNote(path: string, filename: string, placement: NotePlacement, active:boolean=true) { 335 | const folder:TFolder = this.getFolders().find(folder => folder.path === path) || await this.app.vault.createFolder(path); 336 | const fullFilePath = normalizePath(path + "/" + filename + ".md"); 337 | 338 | let file = this.app.vault.getAbstractFileByPath(fullFilePath) as TFile; 339 | if (file instanceof TFolder) { 340 | new Notice(`${fullFilePath} found but it's a folder`); 341 | return; 342 | } else if(file === null || this.settings.forceFileCreation) { 343 | // Create note if it doesn't exist 344 | if(this.settings.capitalizeFilename) { 345 | filename = filename.split('/').map(substring => substring.charAt(0).toUpperCase() + substring.slice(1)).join('/'); 346 | } 347 | file = await this.app.fileManager.createNewMarkdownFile(folder, filename); 348 | } 349 | this.app.workspace.getLeaf(placement || false).openFile(file, { 350 | state: { mode: "source" }, 351 | active: active 352 | }); 353 | return file; 354 | } 355 | 356 | getFoldersByPrefix(foldersArray: PrefixFolderTuple[]): FoldersByPrefix { 357 | return foldersArray.reduce((acc: FoldersByPrefix, tuple: PrefixFolderTuple) => ({...acc, [tuple.prefix]: tuple}), {}); 358 | } 359 | 360 | getFolders(): TFolder[] { 361 | const folders: Set = new Set(); 362 | Vault.recurseChildren(this.app.vault.getRoot(), (file) => { 363 | if (file instanceof TFolder) { 364 | folders.add(file); 365 | } 366 | }); 367 | return Array.from(folders); 368 | } 369 | 370 | async triggerInlineReplacement(editor: Editor, notePlacement: NotePlacement, active?: boolean) { 371 | if (editor.somethingSelected()) { 372 | const selection = editor.getSelection().trim(); 373 | const [selectionFilename, alias] = selection.split("|"); 374 | const {folderPath, filename} = await this.parseFilename(selectionFilename); 375 | const file = await this.openNote(folderPath, filename, notePlacement, active); 376 | if(file instanceof TFile) { 377 | const replaceText = this.app.fileManager.generateMarkdownLink(file, "", "", alias || filename); 378 | editor.replaceSelection(replaceText); 379 | } 380 | 381 | } else { 382 | const range = editor.getCursor(); 383 | const line = editor.getLine(range.line); 384 | const match = this.getLinkAtCurrentPosition(line, range.ch); 385 | 386 | if(match) { 387 | const {folderPath, filename} = await this.parseFilename(match.filename); 388 | const file = await this.openNote(folderPath, filename, notePlacement, active); 389 | if(file instanceof TFile) { 390 | const replaceText = this.app.fileManager.generateMarkdownLink(file, "", "", match.alias || filename); 391 | // Replace text in editor 392 | const editorPositionStart: EditorPosition = { 393 | line: range.line, 394 | ch: match.start 395 | }; 396 | const editorPositionEnd: EditorPosition = { 397 | line: range.line, 398 | ch: match.end 399 | }; 400 | editor.replaceRange(replaceText, editorPositionStart, editorPositionEnd); 401 | editor.setCursor({ ch: match.start + replaceText.length, line: range.line }); 402 | } 403 | } 404 | } 405 | } 406 | 407 | getLinkAtCurrentPosition(line: string, position: number) { 408 | const matches = []; 409 | const regex = /\[{2}(.+?)(\|(.*?))?\]{2}/g; 410 | let match; 411 | while ((match = regex.exec(line)) !== null) { 412 | matches.push({ 413 | fullMatch: match[0], 414 | filename: match[1], 415 | alias: match[3], 416 | start: match.index, 417 | end: regex.lastIndex 418 | }); 419 | } 420 | return matches.find(match => position >= match.start && position <= match.end) || null; 421 | } 422 | 423 | cleanEmptyEntries() { 424 | this.settings.prefixedFolders = this.settings.prefixedFolders.filter((entry) => { 425 | return entry.folder !== '' || entry.prefix !== '' || entry.filenamePrefix !== ''; 426 | }); 427 | } 428 | } 429 | 430 | class RapidNotesSettingsTab extends PluginSettingTab { 431 | plugin: RapidNotes; 432 | 433 | constructor(app: App, plugin: RapidNotes) { 434 | super(app, plugin); 435 | this.plugin = plugin; 436 | } 437 | 438 | hide(): void { 439 | this.plugin.cleanEmptyEntries(); 440 | } 441 | 442 | display(): void { 443 | const {containerEl} = this; 444 | containerEl.empty(); 445 | containerEl.createEl('h2', {text: 'Rapid Notes settings'}); 446 | containerEl.createEl('p', {text: '[New!] Now you can also create notes and link to them directly from the editor while typing using the plugin inline commands. You can trigger the command while the cursor is inside the link in double brackets, or just by selecting any text in the editor.'}); 447 | 448 | new Setting(this.containerEl) 449 | .setName("Force file creation adding a number at the end if the folder/filename is already in use. Default behavior will open the existing file.") 450 | .addToggle((toggle) => { 451 | toggle 452 | .setValue(this.plugin.settings.forceFileCreation) 453 | .onChange((forceFileCreation) => { 454 | this.plugin.settings.forceFileCreation = forceFileCreation; 455 | this.plugin.saveSettings(); 456 | }); 457 | }); 458 | 459 | new Setting(this.containerEl) 460 | .setName("Show prefix list in note creation modal.") 461 | .addToggle((toggle) => { 462 | toggle 463 | .setValue(this.plugin.settings.showModalSuggestions) 464 | .onChange((showModalSuggestions) => { 465 | this.plugin.settings.showModalSuggestions = showModalSuggestions; 466 | this.plugin.saveSettings(); 467 | }); 468 | }); 469 | 470 | new Setting(this.containerEl) 471 | .setName("Escape symbol to avoid checking the prefix and moving the note.") 472 | .addText((cb) => { 473 | cb 474 | .setPlaceholder("/") 475 | .setValue(this.plugin.settings.escapeSymbol) 476 | .onChange((escapeSymbol) => { 477 | this.plugin.settings.escapeSymbol = escapeSymbol.trim() || "/"; 478 | this.plugin.saveSettings(); 479 | }); 480 | }); 481 | 482 | new Setting(this.containerEl) 483 | .setName("Optional separator between the prefix and the filename (space character by default).") 484 | .addText((cb) => { 485 | cb 486 | .setValue(this.plugin.settings.realPrefixSeparator) 487 | .onChange((realPrefixSeparator) => { 488 | this.plugin.settings.realPrefixSeparator = realPrefixSeparator; 489 | this.plugin.saveSettings(); 490 | }); 491 | }); 492 | 493 | new Setting(this.containerEl) 494 | .setName("Capitalize note name and new folders.") 495 | .addToggle((toggle) => { 496 | toggle 497 | .setValue(this.plugin.settings.capitalizeFilename) 498 | .onChange((capitalizeFilename) => { 499 | this.plugin.settings.capitalizeFilename = capitalizeFilename; 500 | this.plugin.saveSettings(); 501 | }); 502 | }); 503 | 504 | new Setting(this.containerEl) 505 | .setClass("rapid-notes-add-prefix-entry") 506 | .setName("Add new prefixes or create command shortcuts for saving directly into folders.") 507 | .setDesc( 508 | createFragment((el) => { 509 | el.createEl("br"); 510 | el.createEl("b", {text: "Prefix: "}); 511 | el.appendText("Keyword that will trigger the action (single words, case sensitive)."); 512 | el.createEl("br"); 513 | el.createEl("b", {text: "Real prefix (optional): "}); 514 | el.appendText("Text that will be prepended to the filename. Can use placeholders such as {{date:YYYY-MM-DD}} (Moment.js formatting)."); 515 | el.createEl("br"); 516 | el.createEl("b", {text: "Folder: "}); 517 | el.appendText("Location for the saved note. Can use placeholders such as {{date:YYYY-MM-DD}} (Moment.js formatting)."); 518 | el.createEl("br"); 519 | el.createEl("b", {text: "Toggle: "}); 520 | el.appendText("Create a command to save directly into the folder."); 521 | el.createEl("br"); 522 | el.createEl("br"); 523 | el.appendText("Important: Command changes will show up in the command palette after an app relaunch or reenabling the plugin."); 524 | }) 525 | ) 526 | .addButton((button) => { 527 | button 528 | .setTooltip("Add additional prefix") 529 | .setButtonText("+") 530 | .setCta() 531 | .onClick(() => { 532 | this.plugin.cleanEmptyEntries(); 533 | this.plugin.settings.prefixedFolders.unshift({ 534 | folder: "", 535 | prefix: "", 536 | filenamePrefix: "", 537 | addCommand: false 538 | }); 539 | this.display(); 540 | }); 541 | }); 542 | 543 | this.plugin.settings.prefixedFolders.forEach((prefixedFolder, index) => { 544 | const s = new Setting(this.containerEl) 545 | .setClass("rapid-notes-settings-entry") 546 | .setHeading() 547 | .addText((cb) => { 548 | cb 549 | .setPlaceholder("Prefix") 550 | .setValue(prefixedFolder.prefix) 551 | .onChange((newPrefix) => { 552 | if (newPrefix && this.plugin.settings.prefixedFolders.some((e) => e.prefix == newPrefix)) { 553 | new Notice("Prefix already used!"); 554 | return; 555 | } 556 | 557 | if(newPrefix && /\s/.test(newPrefix)) { 558 | new Notice("Prefixes can't contain spaces!"); 559 | return; 560 | } 561 | this.plugin.settings.prefixedFolders[index].prefix = newPrefix; 562 | this.plugin.saveSettings(); 563 | }); 564 | }) 565 | .addText((cb) => { 566 | cb 567 | .setPlaceholder("Real prefix") 568 | .setValue(prefixedFolder.filenamePrefix).onChange((newNotePrefix) => { 569 | this.plugin.settings.prefixedFolders[index].filenamePrefix = newNotePrefix.trim(); 570 | this.plugin.saveSettings(); 571 | }); 572 | }) 573 | .addSearch((cb) => { 574 | new FolderSuggest(this.app, cb.inputEl); 575 | cb 576 | .setPlaceholder("Folder") 577 | .setValue(prefixedFolder.folder) 578 | .onChange((newFolder) => { 579 | if (newFolder && this.plugin.settings.prefixedFolders.some((e) => e.folder == newFolder)) { 580 | new Notice("This folder already has a prefix associated with it."); 581 | return; 582 | } 583 | this.plugin.settings.prefixedFolders[index].folder = newFolder; 584 | this.plugin.saveSettings(); 585 | }); 586 | cb.containerEl.addClass("rapid-notes_search"); 587 | }) 588 | .addToggle((toggle) => { 589 | toggle 590 | .setValue(this.plugin.settings.prefixedFolders[index].addCommand) 591 | .onChange((addCommand) => { 592 | this.plugin.settings.prefixedFolders[index].addCommand = addCommand; 593 | this.plugin.saveSettings(); 594 | }); 595 | }) 596 | .addExtraButton((cb) => { 597 | cb 598 | .setIcon("up-chevron-glyph") 599 | .setTooltip("Move up") 600 | .onClick(() => { 601 | arraymove(this.plugin.settings.prefixedFolders, index, index - 1); 602 | this.plugin.saveSettings(); 603 | this.display(); 604 | }); 605 | }) 606 | .addExtraButton((cb) => { 607 | cb.setIcon("down-chevron-glyph").setTooltip("Move down").onClick(() => { 608 | arraymove(this.plugin.settings.prefixedFolders, index, index + 1); 609 | this.plugin.saveSettings(); 610 | this.display(); 611 | }); 612 | }) 613 | .addExtraButton((cb) => { 614 | cb.setIcon("cross").setTooltip("Delete").onClick(() => { 615 | this.plugin.settings.prefixedFolders.splice(index, 1); 616 | this.plugin.saveSettings(); 617 | this.display(); 618 | }); 619 | }); 620 | s.infoEl.remove(); 621 | 622 | }); 623 | } 624 | } 625 | --------------------------------------------------------------------------------