├── .npmrc ├── .eslintignore ├── bun-fix.d.ts ├── .github ├── FUNDING.yml └── workflows │ └── release.yml ├── bun.lockb ├── .husky └── pre-push ├── .editorconfig ├── src ├── utils │ ├── createNotice.ts │ ├── shouldIgnoreFile.ts │ ├── setRealTimePreview.ts │ ├── deepRemoveNull.ts │ ├── deepInclude.ts │ ├── evalFromExpression.ts │ ├── getNewTextFromFile.ts │ ├── obsidian.ts │ ├── mdast.ts │ ├── regex.ts │ ├── ignore-types.ts │ ├── strings.ts │ └── yaml.ts ├── FrontmatterGeneratorPluginSettings.ts ├── typings │ └── obsidian-ex.d.ts ├── ui │ ├── modals │ │ └── confirmationModal.ts │ └── SettingTab.ts └── main.ts ├── manifest.json ├── .gitignore ├── versions.json ├── tsconfig.json ├── .eslintrc ├── version-bump.mjs ├── styles.css ├── LICENSE ├── esbuild.config.mjs ├── package.json ├── release.sh └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | src/typings/obsidian-dataview.d.ts -------------------------------------------------------------------------------- /bun-fix.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [hananoshikayomaru] 2 | custom: https://www.buymeacoffee.com/yomaru 3 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HananoshikaYomaru/Obsidian-Frontmatter-Generator/HEAD/bun.lockb -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | bun lint 5 | bun typecheck 6 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/utils/createNotice.ts: -------------------------------------------------------------------------------- 1 | import { Notice } from "obsidian"; 2 | 3 | export function createNotice( 4 | message: string, 5 | color: "white" | "yellow" | "red" = "white" 6 | ) { 7 | const fragment = new DocumentFragment(); 8 | const desc = document.createElement("div"); 9 | desc.setText(`Obsidian Frontmatter Generator: ${message}`); 10 | desc.style.color = color; 11 | fragment.appendChild(desc); 12 | 13 | new Notice(fragment); 14 | } 15 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "frontmatter-generator", 3 | "name": "Frontmatter generator", 4 | "version": "1.0.24", 5 | "minAppVersion": "0.15.0", 6 | "description": "Generate frontmatter for your notes from json and javascript", 7 | "author": "Hananoshika Yomaru", 8 | "authorUrl": "https://yomaru.dev", 9 | "fundingUrl": { 10 | "buymeacoffee": "https://www.buymeacoffee.com/yomaru", 11 | "Github Sponsor": "https://github.com/sponsors/HananoshikaYomaru" 12 | }, 13 | "isDesktopOnly": false 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | 24 | obsidian-dataview.d.ts 25 | 26 | dist 27 | test.ts 28 | test.js 29 | test/ 30 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0.0": "0.15.0", 3 | "1.0.1": "0.15.0", 4 | "1.0.2": "0.15.0", 5 | "1.0.3": "0.15.0", 6 | "1.0.4": "0.15.0", 7 | "1.0.5": "0.15.0", 8 | "1.0.7": "0.15.0", 9 | "1.0.8": "0.15.0", 10 | "1.0.9": "0.15.0", 11 | "1.0.10": "0.15.0", 12 | "1.0.11": "0.15.0", 13 | "1.0.12": "0.15.0", 14 | "1.0.13": "0.15.0", 15 | "1.0.14": "0.15.0", 16 | "1.0.15": "0.15.0", 17 | "1.0.16": "0.15.0", 18 | "1.0.17": "0.15.0", 19 | "1.0.18": "0.15.0", 20 | "1.0.19": "0.15.0", 21 | "1.0.20": "0.15.0", 22 | "1.0.21": "0.15.0", 23 | "1.0.22": "0.15.0", 24 | "1.0.23": "0.15.0", 25 | "1.0.24": "0.15.0" 26 | } -------------------------------------------------------------------------------- /src/utils/shouldIgnoreFile.ts: -------------------------------------------------------------------------------- 1 | import { TFile } from "obsidian"; 2 | import { FrontmatterGeneratorPluginSettings } from "../FrontmatterGeneratorPluginSettings"; 3 | import { Data } from "./obsidian"; 4 | import { isIgnoredByFolder, YamlKey } from "../main"; 5 | 6 | export function shouldIgnoreFile( 7 | settings: FrontmatterGeneratorPluginSettings, 8 | file: TFile, 9 | data?: Data 10 | ) { 11 | // if file path is in ignoredFolders, return true 12 | if (isIgnoredByFolder(settings, file)) return true; 13 | 14 | // check if there is a yaml ignore key 15 | if (data) { 16 | if (data.yamlObj && data.yamlObj[YamlKey.IGNORE]) return true; 17 | } 18 | 19 | return false; 20 | } 21 | -------------------------------------------------------------------------------- /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 | "allowSyntheticDefaultImports": true, 15 | "noUncheckedIndexedAccess": true, 16 | "skipLibCheck": true, 17 | "lib": ["DOM", "ES5", "ES6", "ES7", "ESNext"], 18 | "types": ["bun-types"], 19 | "paths": { 20 | "@/*": ["./src/*"] 21 | } 22 | }, 23 | "include": ["**/*.ts", "**/*.js"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /src/FrontmatterGeneratorPluginSettings.ts: -------------------------------------------------------------------------------- 1 | export interface FrontmatterGeneratorPluginSettings { 2 | template: string; 3 | folderToIgnore: string; 4 | internal: { 5 | ignoredFolders: string[]; 6 | }; 7 | /** 8 | * run on modify when user is not in the file 9 | */ 10 | runOnModifyNotInFile: boolean; 11 | /** 12 | * run on modify when user is in the file 13 | */ 14 | runOnModifyInFile: boolean; 15 | sortYamlKey: boolean; 16 | } 17 | export const DEFAULT_SETTINGS: FrontmatterGeneratorPluginSettings = { 18 | template: "{}", 19 | folderToIgnore: "", 20 | internal: { 21 | ignoredFolders: [], 22 | }, 23 | runOnModifyNotInFile: false, 24 | runOnModifyInFile: false, 25 | sortYamlKey: true, 26 | }; 27 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": ["@typescript-eslint"], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parserOptions": { 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 17 | "@typescript-eslint/ban-ts-comment": "off", 18 | "no-prototype-builtins": "off", 19 | "no-mixed-spaces-and-tabs": "off", 20 | "@typescript-eslint/no-explicit-any": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = JSON.parse(readFileSync("package.json", "utf8")).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 | -------------------------------------------------------------------------------- /src/utils/setRealTimePreview.ts: -------------------------------------------------------------------------------- 1 | import { EvalResult } from "./evalFromExpression"; 2 | 3 | export const setRealTimePreview = ( 4 | element: HTMLElement, 5 | result: EvalResult, 6 | context?: { 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | [x: string]: any; 9 | } 10 | ) => { 11 | if (!result.success) { 12 | console.error(result.error.cause); 13 | // this is needed so that it is easier to debug 14 | if (context) console.log(context); 15 | element.setText(result.error.message); 16 | element.style.color = "red"; 17 | } else { 18 | // there is object 19 | // set the real time preview 20 | element.setText(JSON.stringify(result.object, null, 2)); 21 | element.style.color = "white"; 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .frontmatter-generator-settings-real-time-preview { 11 | text-align: left; 12 | max-width: 300px; 13 | white-space: pre-wrap; 14 | color: white; 15 | } 16 | 17 | .frontmatter-generator-settings-input { 18 | max-width: 300px; 19 | min-width: 300px; 20 | min-height: 200px; 21 | } 22 | 23 | .frontmatter-generator-settings-input-outer { 24 | flex-direction: column; 25 | align-items: flex-start; 26 | max-width: 300px; 27 | } 28 | 29 | .setting-item.frontmatter-generator-settings-template-setting, 30 | .setting-item.frontmatter-generator-settings-ignored-folders-setting { 31 | align-items: flex-start; 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | 15 | - uses: oven-sh/setup-bun@v1 16 | with: 17 | bun-version: latest 18 | 19 | - name: Build plugin 20 | run: | 21 | bun install 22 | bun run build 23 | 24 | - name: Create release 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | run: | 28 | tag="${GITHUB_REF#refs/tags/}" 29 | 30 | gh release create "$tag" \ 31 | --title="$tag" \ 32 | --draft \ 33 | main.js manifest.json styles.css 34 | -------------------------------------------------------------------------------- /src/typings/obsidian-ex.d.ts: -------------------------------------------------------------------------------- 1 | // this file copied from : https://github.com/platers/obsidian-linter/blob/eca9027abb34d564eb4a670cd4474691eda699da/src/typings/obsidian-ex.d.ts 2 | 3 | import { Command } from "obsidian"; 4 | 5 | export interface ObsidianCommandInterface { 6 | executeCommandById(id: string): void; 7 | commands: { 8 | "editor:save-file": { 9 | callback(): void; 10 | }; 11 | }; 12 | listCommands(): Command[]; 13 | } 14 | 15 | // allows for the removal of the any cast by defining some extra properties for Typescript so it knows these properties exist 16 | declare module "obsidian" { 17 | interface App { 18 | commands: ObsidianCommandInterface; 19 | dom: { 20 | appContainerEl: HTMLElement; 21 | }; 22 | } 23 | 24 | interface Vault { 25 | getConfig(id: string): boolean; 26 | } 27 | } 28 | 29 | declare global { 30 | interface Window { 31 | CodeMirrorAdapter: { 32 | commands: { 33 | save(): void; 34 | }; 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Yeung Man Lung Ken 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 | -------------------------------------------------------------------------------- /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 | const context = await esbuild.context({ 14 | banner: { 15 | js: banner, 16 | }, 17 | entryPoints: ["src/main.ts"], 18 | bundle: true, 19 | external: [ 20 | "obsidian", 21 | "electron", 22 | "@codemirror/autocomplete", 23 | "@codemirror/collab", 24 | "@codemirror/commands", 25 | "@codemirror/language", 26 | "@codemirror/lint", 27 | "@codemirror/search", 28 | "@codemirror/state", 29 | "@codemirror/view", 30 | "@lezer/common", 31 | "@lezer/highlight", 32 | "@lezer/lr", 33 | ...builtins, 34 | ], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/deepRemoveNull.ts: -------------------------------------------------------------------------------- 1 | export function deepRemoveNull(obj: T, obj2: Partial): Partial { 2 | if (typeof obj !== "object" || obj === null) { 3 | return obj; 4 | } 5 | 6 | // Initialize the result as a copy to avoid modifying the original 7 | const result = Array.isArray(obj) ? [] : ({} as Partial); 8 | 9 | for (const key in obj) { 10 | if (obj.hasOwnProperty(key)) { 11 | // Check if obj2 is an object and has the key; if obj2 or its key is undefined, treat it as having a non-null value 12 | const keyExistsInObj2 = 13 | typeof obj2 === "object" && obj2 !== null && key in obj2; 14 | const shouldBeRemoved = keyExistsInObj2 && obj2[key] === null; 15 | 16 | if (!shouldBeRemoved) { 17 | if (typeof obj[key] === "object" && obj[key] !== null) { 18 | // Recursively call deepRemoveNull, passing the corresponding nested object from obj2 or undefined if it doesn't exist 19 | // @ts-ignore 20 | result[key] = deepRemoveNull( 21 | obj[key], 22 | // @ts-ignore 23 | keyExistsInObj2 ? obj2[key] : undefined 24 | ); 25 | } else { 26 | // Copy the value from obj 27 | // @ts-ignore 28 | result[key] = obj[key]; 29 | } 30 | } 31 | } 32 | } 33 | 34 | return result as T; 35 | } 36 | -------------------------------------------------------------------------------- /src/utils/deepInclude.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * deep compare two objects, return true if obj2 is a subset of obj1. null value will be treated as different 3 | */ 4 | // https://www.typescriptlang.org/play?#code/GYVwdgxgLglg9mABDSAbEATApgZwPIBGAVltABRzECMAXIgIZgCeANIpUQEx2NMCUdAnDiosjRAG8AUAEgYwRGShMADljgKOVRAEIAvHsQAiDqShHEAH0vtqiA4bAhUqPpNkyATlighPSLXsDWy4AblkAX1l5RWU1DRDOXWCTYjMLa0Sgx2dXdxkvHz8kYHpUHCxwmSiZYDhPRQgEHChEAGssJmQA4k43aQKYsh0UCHRsfDTyLQBtDqYAXTYOTjnOhb5+j0Lff0RS8sqPGprvXaQoTxAjqKkmsBbEHBVUGCgATXoAW1QAITgMF1DEosAAPKB0FqeFAAczcegAfPl7o8VPRPFAcPZEFAwVAAHTPV5QMgAegAegBaakAElJXz4VRRIiw+NQcBhZEQaIxODYPMxbKwYBhUAAFnxogouTpceDCVB0ZiAOpvMVkIzUylGNx8M7FHF4xBSsgCnBCkXi7KIbR8CTGgr6vb27ZMb6oRB0cDYYAoLAYNjbISAz2G8HHSJSxRmi2isWIAA8iAAzJKZB4XU6kAMCm6fnQzTMqAsGFiobCWEGAUwC0qcDNOCWAPxN4wWSsFCLhCOOorO13u2u8otLKuAoeCnCvCBYMh9fFEOAoDVanUd6pSLtSbd3ZqtOVQbSGAAGUi1UgPdG0Z+pUmDXW359xLRvlKkx+7KP3eKShiM96MbcvzDKBk2xU8nzxK9X0fW9nygV9T13B5v3BAAWcDtwAdywTwsJgy8bWNc972NAjcAQ88PyA5oWTZDkuSJN5Ph+f5ARBcFbUQNgmI+d02KYDioD6binheZj+OrITUx48S+NYqSDzQzYgA 5 | export function deepInclude(obj1: any, obj2: any) { 6 | if (obj1 === null && obj2 === null) { 7 | return false; 8 | } 9 | if (typeof obj1 !== "object" || obj1 === null) { 10 | return obj1 === obj2; 11 | } 12 | if (typeof obj2 !== "object" || obj2 === null) { 13 | return false; 14 | } 15 | for (const key in obj2) { 16 | if (!deepInclude(obj1[key], obj2[key])) { 17 | return false; 18 | } 19 | } 20 | return true; 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/modals/confirmationModal.ts: -------------------------------------------------------------------------------- 1 | import { Notice, Modal, App } from "obsidian"; 2 | 3 | // https://github.com/nothingislost/obsidian-workspaces-plus/blob/bbba928ec64b30b8dec7fe8fc9e5d2d96543f1f3/src/modal.ts#L68 4 | export class ConfirmationModal extends Modal { 5 | constructor( 6 | app: App, 7 | startModalMessageText: string, 8 | submitBtnText: string, 9 | submitBtnNoticeText: string, 10 | btnSubmitAction: () => Promise 11 | ) { 12 | super(app); 13 | this.modalEl.addClass("confirm-modal"); 14 | 15 | this.contentEl.createEl("h3", { 16 | // text: getTextInLanguage("warning-text"), 17 | text: "Warning: this action cannot be undone.", 18 | }).style.textAlign = "center"; 19 | 20 | this.contentEl.createEl("p", { 21 | text: 22 | startModalMessageText + 23 | " " + 24 | "Please backup your files before proceeding.", 25 | }).id = "confirm-dialog"; 26 | 27 | this.contentEl.createDiv("modal-button-container", (buttonsEl) => { 28 | buttonsEl 29 | .createEl("button", { text: "Cancel" }) 30 | .addEventListener("click", () => this.close()); 31 | 32 | const btnSubmit = buttonsEl.createEl("button", { 33 | attr: { type: "submit" }, 34 | cls: "mod-cta", 35 | text: submitBtnText, 36 | }); 37 | btnSubmit.addEventListener("click", async (_e) => { 38 | new Notice(submitBtnNoticeText); 39 | this.close(); 40 | await btnSubmitAction(); 41 | }); 42 | setTimeout(() => { 43 | btnSubmit.focus(); 44 | }, 50); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-frontmatter-generator", 3 | "version": "1.0.24", 4 | "description": "A plugin for Obsidian that generates frontmatter for notes.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "bun esbuild.config.mjs", 8 | "build": "bun esbuild.config.mjs production", 9 | "version": "bun version-bump.mjs && git add manifest.json versions.json", 10 | "prepare": "husky install", 11 | "release": "bash release.sh", 12 | "typecheck": "tsc -noEmit -skipLibCheck", 13 | "lint": "eslint . --ext .ts --fix" 14 | }, 15 | "keywords": [ 16 | "obsidian", 17 | "plugin", 18 | "frontmatter", 19 | "generator", 20 | "yaml", 21 | "dataview" 22 | ], 23 | "author": "Hananoshika Yomaru", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@types/diff-match-patch": "^1.0.34", 27 | "@types/js-yaml": "^4.0.9", 28 | "@types/node": "^16.11.6", 29 | "@typescript-eslint/eslint-plugin": "^6.0.0", 30 | "@typescript-eslint/parser": "^6.0.0", 31 | "builtin-modules": "3.3.0", 32 | "bun-types": "^1.0.3", 33 | "esbuild": "0.17.3", 34 | "eslint": "^8.54.0", 35 | "husky": "^8.0.3", 36 | "obsidian": "latest", 37 | "tslib": "2.4.0", 38 | "typescript": "^5.0.5" 39 | }, 40 | "dependencies": { 41 | "@total-typescript/ts-reset": "^0.5.1", 42 | "diff-match-patch": "^1.0.5", 43 | "js-yaml": "^4.1.0", 44 | "mdast-util-from-markdown": "^1.2.0", 45 | "mdast-util-gfm-footnote": "^1.0.1", 46 | "mdast-util-gfm-task-list-item": "^1.0.1", 47 | "mdast-util-math": "^2.0.1", 48 | "micromark-extension-gfm-footnote": "^1.0.4", 49 | "micromark-extension-gfm-task-list-item": "^1.0.3", 50 | "micromark-extension-math": "^2.0.2", 51 | "micromark-util-combine-extensions": "^1.0.0", 52 | "obsidian-dataview": "^0.5.61", 53 | "quick-lru": "^6.1.1", 54 | "ts-dedent": "^2.2.0", 55 | "ts-pattern": "^5.0.5", 56 | "unist-util-visit": "^4.1.2", 57 | "zod": "^3.22.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/utils/evalFromExpression.ts: -------------------------------------------------------------------------------- 1 | import dedent from "ts-dedent"; 2 | import { z } from "zod"; 3 | 4 | const primativeSchema = z 5 | .string() 6 | .or(z.number()) 7 | .or(z.boolean()) 8 | .or(z.bigint()) 9 | .or(z.date()) 10 | .or(z.undefined()) 11 | .or(z.null()); 12 | 13 | const recursivePrmitiveSchema: z.ZodType = z.lazy(() => 14 | z.record( 15 | z.union([ 16 | primativeSchema, 17 | recursivePrmitiveSchema, 18 | z.array( 19 | primativeSchema 20 | .or(recursivePrmitiveSchema) 21 | .or(z.array(primativeSchema)) 22 | ), 23 | ]) 24 | ) 25 | ); 26 | 27 | type Schema = z.infer; 28 | 29 | export type SanitizedObject = { [key: string]: Schema }; 30 | 31 | /** 32 | * given an expression and context, evaluate the expression and return the object 33 | */ 34 | export function evalFromExpression( 35 | expression: string, 36 | context: { 37 | [x: string]: any; 38 | } 39 | ): 40 | | { 41 | success: false; 42 | error: { 43 | cause?: Error; 44 | message: string; 45 | }; 46 | } 47 | | { success: true; object: SanitizedObject } { 48 | try { 49 | const object = new Function( 50 | ...Object.keys(context).sort(), 51 | dedent` 52 | return ${expression} 53 | ` 54 | )( 55 | ...Object.keys(context) 56 | .sort() 57 | .map((key) => context[key]) 58 | ); 59 | if (typeof object !== "object") { 60 | return { 61 | success: false, 62 | error: { 63 | cause: new Error("The expression must return an object"), 64 | message: "The expression must return an object", 65 | }, 66 | } as const; 67 | } 68 | // for each value in object, make sure it pass the schema, if not, assign error message to the key in sanitizedObject 69 | const sanitizedObject: SanitizedObject = 70 | recursivePrmitiveSchema.parse(object); 71 | 72 | return { 73 | success: true, 74 | object: sanitizedObject, 75 | } as const; 76 | } catch (e) { 77 | return { 78 | success: false as const, 79 | error: { 80 | cause: e as Error, 81 | message: e.message as string, 82 | }, 83 | }; 84 | } 85 | } 86 | 87 | export type EvalResult = ReturnType; 88 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Set the default update type 4 | UPDATE_TYPE="patch" 5 | 6 | # Parse command-line arguments 7 | while [[ $# -gt 0 ]]; do 8 | key="$1" 9 | case $key in 10 | -m | --minor) 11 | UPDATE_TYPE="minor" 12 | shift 13 | ;; 14 | -M | --major) 15 | UPDATE_TYPE="major" 16 | shift 17 | ;; 18 | *) 19 | echo "Unknown option: $key" 20 | exit 1 21 | ;; 22 | esac 23 | done 24 | 25 | # Get the version number from manifest.json 26 | MANIFEST_VERSION=$(jq -r '.version' manifest.json) 27 | 28 | # Get the version number from package.json 29 | PACKAGE_VERSION=$(node -p -e "require('./package.json').version") 30 | 31 | # Ensure the version from package.json matches the version in manifest.json 32 | if [ "$PACKAGE_VERSION" != "$MANIFEST_VERSION" ]; then 33 | echo "Version mismatch between package.json and manifest.json" 34 | exit 1 35 | fi 36 | 37 | # Increment the version based on the specified update type 38 | if [ "$UPDATE_TYPE" = "minor" ]; then 39 | NEW_VERSION=$(semver $PACKAGE_VERSION -i minor) 40 | elif [ "$UPDATE_TYPE" = "major" ]; then 41 | NEW_VERSION=$(semver $PACKAGE_VERSION -i major) 42 | else 43 | NEW_VERSION=$(semver $PACKAGE_VERSION -i patch) 44 | fi 45 | 46 | echo "Current version: $PACKAGE_VERSION" 47 | echo "New version: $NEW_VERSION" 48 | 49 | # Update the version in package.json 50 | jq --arg version "$NEW_VERSION" '.version = $version' package.json >tmp.json && mv tmp.json package.json 51 | echo "Changed package.json version to $NEW_VERSION" 52 | 53 | # Print the updated version of manifest.json using 'bun' 54 | bun run version 55 | echo "Updated version of manifest using bun. The current version of manifest.json is $(jq -r '.version' manifest.json)" 56 | 57 | # Create a git commit and tag 58 | git add . && git commit -m "release: $NEW_VERSION" 59 | git tag -a "$NEW_VERSION" -m "release: $NEW_VERSION" 60 | echo "Created tag $NEW_VERSION" 61 | 62 | # Push the commit and tag to the remote repository 63 | git push origin "$NEW_VERSION" 64 | echo "Pushed tag $NEW_VERSION to the origin branch $NEW_VERSION" 65 | git push 66 | echo "Pushed to the origin master branch" 67 | -------------------------------------------------------------------------------- /src/utils/getNewTextFromFile.ts: -------------------------------------------------------------------------------- 1 | import { TFile, stringifyYaml } from "obsidian"; 2 | import { 3 | SanitizedObject, 4 | evalFromExpression, 5 | } from "@/utils/evalFromExpression"; 6 | import { deepInclude } from "@/utils/deepInclude"; 7 | import { Data } from "@/utils/obsidian"; 8 | import { getAPI } from "obsidian-dataview"; 9 | import { deepRemoveNull } from "@/utils/deepRemoveNull"; 10 | import { createNotice } from "@/utils/createNotice"; 11 | import FrontmatterGeneratorPlugin, { isObjectEmpty } from "@/main"; 12 | import { z } from "zod"; 13 | 14 | /** 15 | * 16 | * @param settings 17 | * @param file 18 | * @param data 19 | * @returns if there is no change, return undefined, else return the new text 20 | */ 21 | export function getNewTextFromFile( 22 | template: string, 23 | file: TFile, 24 | data: Data, 25 | plugin: FrontmatterGeneratorPlugin 26 | ) { 27 | const app = plugin.app; 28 | const dv = getAPI(app); 29 | 30 | const result = evalFromExpression(template, { 31 | file: { 32 | ...file, 33 | tags: data.tags, 34 | properties: data.yamlObj, 35 | }, 36 | dv, 37 | z, 38 | }); 39 | 40 | if (!result.success) { 41 | createNotice( 42 | `Invalid template, please check the developer tools for detailed error`, 43 | "red" 44 | ); 45 | console.error(result.error.cause); 46 | return; 47 | } 48 | // if there is no object, or the object is empty, do nothing 49 | if (isObjectEmpty(result.object)) return; 50 | 51 | // check the yaml object, if the yaml object includes all keys of the result object 52 | // and the corresponding values are the same, do nothing 53 | if (data.yamlObj && deepInclude(data.yamlObj, result.object)) { 54 | return; 55 | } 56 | 57 | // now you have the yaml object, combine it with the result object 58 | // combine them 59 | const yamlObj = { 60 | ...(data.yamlObj ?? {}), 61 | ...result.object, 62 | }; 63 | 64 | Object.assign(yamlObj, result.object); 65 | 66 | // remove null values and sort keys 67 | const noNull = deepRemoveNull(yamlObj, result.object); 68 | // sort keys 69 | const sortedYamlObj = Object.keys(noNull) 70 | .sort() 71 | .reduce((acc, key) => { 72 | acc[key] = noNull[key]; 73 | return acc; 74 | }, {} as SanitizedObject); 75 | 76 | // set the yaml section 77 | const yamlText = stringifyYaml( 78 | plugin.settings.sortYamlKey ? sortedYamlObj : noNull 79 | ); 80 | 81 | // if old string and new string are the same, do nothing 82 | const newText = `---\n${yamlText}---\n\n${data.body.trim()}`; 83 | // if the new yaml text is the same as the old one, do nothing 84 | if (yamlText === data.yamlText || newText === data.text) { 85 | // createNotice("No changes made", "yellow"); 86 | return; 87 | } 88 | 89 | return newText; 90 | } 91 | -------------------------------------------------------------------------------- /src/utils/obsidian.ts: -------------------------------------------------------------------------------- 1 | import { TFolder, TFile, parseYaml, Plugin, Editor } from "obsidian"; 2 | import { stripCr } from "./strings"; 3 | import { getYAMLText, splitYamlAndBody } from "./yaml"; 4 | import { diff_match_patch, DIFF_INSERT, DIFF_DELETE } from "diff-match-patch"; 5 | import { IgnoreTypes, ignoreListOfTypes } from "./ignore-types"; 6 | import { matchTagRegex } from "./regex"; 7 | 8 | export function isMarkdownFile(file: TFile) { 9 | return file && file.extension === "md"; 10 | } 11 | 12 | /** 13 | * recursively get all files in a folder 14 | */ 15 | export function getAllFilesInFolder(startingFolder: TFolder): TFile[] { 16 | const filesInFolder = [] as TFile[]; 17 | const foldersToIterateOver = [startingFolder] as TFolder[]; 18 | for (const folder of foldersToIterateOver) { 19 | for (const child of folder.children) { 20 | if (child instanceof TFile && isMarkdownFile(child)) { 21 | filesInFolder.push(child); 22 | } else if (child instanceof TFolder) { 23 | foldersToIterateOver.push(child); 24 | } 25 | } 26 | } 27 | 28 | return filesInFolder; 29 | } 30 | 31 | /** 32 | * this is the sync version of getDataFromFile 33 | * @param plugin 34 | * @param text 35 | * @returns 36 | */ 37 | export const getDataFromTextSync = (text: string) => { 38 | const yamlText = getYAMLText(text); 39 | 40 | const yamlObj = yamlText 41 | ? (parseYaml(yamlText) as { [x: string]: any }) 42 | : null; 43 | 44 | const { body } = splitYamlAndBody(text); 45 | 46 | const yamlTags = yamlObj?.tags as string | string[] | undefined; 47 | 48 | // if tags is a string, convert it to an array 49 | const _tags = typeof yamlTags === "string" ? [yamlTags] : yamlTags; 50 | 51 | const tags: string[] = _tags ? _tags.map((t) => `#${t}`) : []; 52 | ignoreListOfTypes([IgnoreTypes.yaml], text, (text) => { 53 | // get all the tags except the generated ones 54 | tags.push(...matchTagRegex(text)); 55 | 56 | return text; 57 | }); 58 | 59 | console.log(tags); 60 | 61 | return { 62 | text, 63 | yamlText, 64 | yamlObj, 65 | tags, 66 | body, 67 | }; 68 | }; 69 | 70 | export const getDataFromFile = async (plugin: Plugin, file: TFile) => { 71 | const text = stripCr(await plugin.app.vault.read(file)); 72 | return getDataFromTextSync(text); 73 | }; 74 | 75 | export type Data = Awaited>; 76 | 77 | export function writeFile(editor: Editor, oldText: string, newText: string) { 78 | const dmp = new diff_match_patch(); 79 | const changes = dmp.diff_main(oldText, newText); 80 | let curText = ""; 81 | changes.forEach((change) => { 82 | function endOfDocument(doc: string) { 83 | const lines = doc.split("\n"); 84 | return { 85 | line: lines.length - 1, 86 | // @ts-ignore 87 | ch: lines[lines.length - 1].length, 88 | }; 89 | } 90 | 91 | const [type, value] = change; 92 | 93 | if (type == DIFF_INSERT) { 94 | editor.replaceRange(value, endOfDocument(curText)); 95 | curText += value; 96 | } else if (type == DIFF_DELETE) { 97 | const start = endOfDocument(curText); 98 | let tempText = curText; 99 | tempText += value; 100 | const end = endOfDocument(tempText); 101 | editor.replaceRange("", start, end); 102 | } else { 103 | curText += value; 104 | } 105 | }); 106 | } 107 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian frontmatter generator 2 | 3 | Generate your frontmatter on save. 4 | 5 | ✅ Powerful, dead simple 6 | 7 | ## Usage 8 | 9 | 1. after install the plugin, visit the setting of the plugin 10 | 2. change the frontmatter template 11 | 12 | for example, the following frontmatter template 13 | 14 | ```ts 15 | { 16 | folder: file.parent.path, 17 | title: file.basename, 18 | test: ["1", "2"] 19 | } 20 | ``` 21 | 22 | will generate this in the file `Good recipes/scrambled egg.md` on save. 23 | 24 | ```yaml 25 | folder: Good recipes 26 | title: scrambled egg 27 | test: 28 | - '1' 29 | - '2' 30 | ``` 31 | 32 | 3. install [obsidian-custom-save](https://github.com/HananoshikaYomaru/obsidian-custom-save) and add the `frontmatter-generator: run file` command to the custom save actions 33 | 34 | - Basic Demo: 35 | - Tag properties demo: 36 | 37 | ## Advanced usage 38 | 39 | ### conditional expression 40 | 41 | ```ts 42 | file.properties?.type === 'kanban' 43 | ? { 44 | folder: file.parent.path, 45 | title: file.basename 46 | } 47 | : {} 48 | ``` 49 | 50 | ### function 51 | 52 | ```ts 53 | { 54 | test: (() => { 55 | return { test: 'test' } 56 | })() 57 | } 58 | ``` 59 | 60 | ### Dataview 61 | 62 | ```ts 63 | { 64 | numberOfPages: dv.pages('#ai').length 65 | } 66 | ``` 67 | 68 | ## Syntax of the frontmatter template 69 | 70 | It could be a json or a javascript expression that return an object. 71 | 72 | ![](https://share.cleanshot.com/nfW5nV8L+) 73 | 74 | ^ even functions work 75 | 76 | ## Variable that it can access 77 | 78 | - `file`, the [`TFile`](https://docs.obsidian.md/Reference/TypeScript+API/TFile/TFile) object 79 | - `file.properties` will access the yaml object of the current file 80 | - `file.tags` , a `string[]` which will access the tags of the current file. For example `["#book", "#movie"]` 81 | - `dv`, the [dataview](https://blacksmithgu.github.io/obsidian-dataview/) object (you can only access this if you install and enable the dataview plugin) 82 | - `z`, the zod object 83 | 84 | ## Installation 85 | 86 | ### Install on obsidian plugin marketplace 87 | 88 | You can find it on obsidian plugin marketplace. 89 | 90 | ### Manual Install 91 | 92 | 1. cd to `.obsidian/plugins` 93 | 2. git clone this repo 94 | 3. `cd obsidian-frontmatter-generator && bun install && bun run build` 95 | 4. there you go 🎉 96 | 97 | ## Note 98 | 99 | 1. to stop generate on a file, you can put `yaml-gen-ignore: true` on the frontmatter. You can also ignore the whole folder in the seting. 100 | 2. the context that you can access is [TFile](https://docs.obsidian.md/Reference/TypeScript+API/TFile/TFile). This can be update in the future. It is extremely flexible. 101 | 3. This plugin also comes with some command to run in folder and in the whole vault. 102 | 4. If you want to contribute, first open an issue. 103 | 5. 🚨 This plugin is still under development, don't try to hack it by using weird keywords or accessing global variables in the template. It should not work but if you figure out a way to hack it, it will just break your own vault. 104 | 105 | 120 | -------------------------------------------------------------------------------- /src/utils/mdast.ts: -------------------------------------------------------------------------------- 1 | import { visit } from "unist-util-visit"; 2 | import type { Position } from "unist"; 3 | import type { Root } from "mdast"; 4 | import { hashString53Bit } from "./strings"; 5 | import { 6 | customIgnoreAllStartIndicator, 7 | customIgnoreAllEndIndicator, 8 | } from "./regex"; 9 | import { gfmFootnote } from "micromark-extension-gfm-footnote"; 10 | import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item"; 11 | import { combineExtensions } from "micromark-util-combine-extensions"; 12 | import { math } from "micromark-extension-math"; 13 | import { mathFromMarkdown } from "mdast-util-math"; 14 | import { fromMarkdown } from "mdast-util-from-markdown"; 15 | import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote"; 16 | import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item"; 17 | import QuickLRU from "quick-lru"; 18 | 19 | const LRU = new QuickLRU({ maxSize: 200 }); 20 | 21 | export enum MDAstTypes { 22 | Link = "link", 23 | Footnote = "footnoteDefinition", 24 | Paragraph = "paragraph", 25 | Italics = "emphasis", 26 | Bold = "strong", 27 | ListItem = "listItem", 28 | Code = "code", 29 | InlineCode = "inlineCode", 30 | Image = "image", 31 | List = "list", 32 | Blockquote = "blockquote", 33 | HorizontalRule = "thematicBreak", 34 | Html = "html", 35 | // math types 36 | Math = "math", 37 | InlineMath = "inlineMath", 38 | } 39 | 40 | function parseTextToAST(text: string): Root { 41 | const textHash = hashString53Bit(text); 42 | if (LRU.has(textHash)) { 43 | return LRU.get(textHash) as Root; 44 | } 45 | 46 | const ast = fromMarkdown(text, { 47 | extensions: [ 48 | combineExtensions([gfmFootnote(), gfmTaskListItem]), 49 | math(), 50 | ], 51 | mdastExtensions: [ 52 | [gfmFootnoteFromMarkdown(), gfmTaskListItemFromMarkdown], 53 | mathFromMarkdown(), 54 | ], 55 | }); 56 | 57 | LRU.set(textHash, ast); 58 | 59 | return ast; 60 | } 61 | 62 | /** 63 | * Gets the positions of the given element type in the given text. 64 | * @param {string} type - The element type to get positions for 65 | * @param {string} text - The markdown text 66 | * @return {Position[]} The positions of the given element type in the given text 67 | */ 68 | export function getPositions(type: MDAstTypes, text: string): Position[] { 69 | const ast = parseTextToAST(text); 70 | const positions: Position[] = []; 71 | visit(ast, type as string, (node) => { 72 | // @ts-ignore 73 | positions.push(node.position); 74 | }); 75 | 76 | // Sort positions by start position in reverse order 77 | // @ts-ignore 78 | positions.sort((a, b) => b.start.offset - a.start.offset); 79 | return positions; 80 | } 81 | 82 | export function getAllCustomIgnoreSectionsInText( 83 | text: string 84 | ): { startIndex: number; endIndex: number }[] { 85 | const positions: { startIndex: number; endIndex: number }[] = []; 86 | const startMatches = [...text.matchAll(customIgnoreAllStartIndicator)]; 87 | if (!startMatches || startMatches.length === 0) { 88 | return positions; 89 | } 90 | 91 | const endMatches = [...text.matchAll(customIgnoreAllEndIndicator)]; 92 | 93 | let iteratorIndex = 0; 94 | startMatches.forEach((startMatch) => { 95 | // @ts-ignore 96 | iteratorIndex = startMatch.index; 97 | 98 | let foundEndingIndicator = false; 99 | let endingPosition = text.length - 1; 100 | // eslint-disable-next-line no-unmodified-loop-condition -- endMatches does not need to be modified with regards to being undefined or null 101 | while (endMatches && endMatches.length !== 0 && !foundEndingIndicator) { 102 | // @ts-ignore 103 | if (endMatches[0].index <= iteratorIndex) { 104 | endMatches.shift(); 105 | } else { 106 | foundEndingIndicator = true; 107 | 108 | const endingIndicator = endMatches[0]; 109 | endingPosition = 110 | // @ts-ignore 111 | endingIndicator.index + endingIndicator[0].length; 112 | } 113 | } 114 | 115 | positions.push({ 116 | startIndex: iteratorIndex, 117 | endIndex: endingPosition, 118 | }); 119 | 120 | if (!endMatches || endMatches.length === 0) { 121 | return; 122 | } 123 | }); 124 | 125 | return positions.reverse(); 126 | } 127 | -------------------------------------------------------------------------------- /src/ui/SettingTab.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting, TFile } from "obsidian"; 2 | import FrontmatterGeneratorPlugin from "../main"; 3 | import { setRealTimePreview } from "../utils/setRealTimePreview"; 4 | import { evalFromExpression } from "../utils/evalFromExpression"; 5 | import { Data, getDataFromFile } from "src/utils/obsidian"; 6 | import { getAPI } from "obsidian-dataview"; 7 | import { z } from "zod"; 8 | 9 | export class SettingTab extends PluginSettingTab { 10 | plugin: FrontmatterGeneratorPlugin; 11 | 12 | constructor(app: App, plugin: FrontmatterGeneratorPlugin) { 13 | super(app, plugin); 14 | this.plugin = plugin; 15 | } 16 | 17 | updatePreview( 18 | file: TFile, 19 | data: Data | undefined, 20 | realTimePreviewElement: HTMLElement 21 | ) { 22 | const context = { 23 | file: { 24 | ...file, 25 | tags: data?.tags, 26 | properties: data?.yamlObj, 27 | }, 28 | dv: getAPI(this.app), 29 | z, 30 | }; 31 | const result = evalFromExpression( 32 | this.plugin.settings.template, 33 | context 34 | ); 35 | setRealTimePreview(realTimePreviewElement, result, context); 36 | } 37 | 38 | getSampleFile() { 39 | const allFiles = this.app.vault.getMarkdownFiles(); 40 | const filesInRoot = allFiles.filter( 41 | (file) => file.parent?.path === "/" 42 | ); 43 | const filesInFolder = allFiles 44 | .filter((file) => file.parent?.path !== "/") 45 | .sort((a, b) => { 46 | const aDepth = a.path.split("/").length - 1; 47 | const bDepth = b.path.split("/").length - 1; 48 | if (aDepth === bDepth) return 0; 49 | return aDepth > bDepth ? 1 : -1; 50 | }); 51 | 52 | return filesInFolder[0] ?? filesInRoot[0]; 53 | } 54 | 55 | async display(): Promise { 56 | const { containerEl } = this; 57 | 58 | containerEl.empty(); 59 | 60 | const sampleFile = this.getSampleFile(); 61 | const data = sampleFile 62 | ? await getDataFromFile(this.plugin, sampleFile) 63 | : undefined; 64 | // const fragment = new DocumentFragment(); 65 | // const desc = document.createElement("div"); 66 | 67 | const templateSetting = new Setting(containerEl) 68 | .setName("Frontmatter template") 69 | .setDesc(`Current Demo file: ${sampleFile?.path}`) 70 | .addTextArea((text) => { 71 | const realTimePreview = document.createElement("pre"); 72 | realTimePreview.classList.add( 73 | "frontmatter-generator-settings-real-time-preview" 74 | ); 75 | 76 | if (sampleFile) { 77 | this.updatePreview(sampleFile, data, realTimePreview); 78 | } 79 | text.setPlaceholder("Enter your template") 80 | .setValue(this.plugin.settings.template) 81 | .onChange(async (value) => { 82 | this.plugin.settings.template = value; 83 | await this.plugin.saveSettings(); 84 | 85 | if (!sampleFile) return; 86 | // try to update the real time preview 87 | this.updatePreview(sampleFile, data, realTimePreview); 88 | }); 89 | text.inputEl.addClass("frontmatter-generator-settings-input"); 90 | 91 | if (text.inputEl.parentElement) { 92 | text.inputEl.parentElement.addClass( 93 | "frontmatter-generator-settings-input-outer" 94 | ); 95 | } 96 | text.inputEl.insertAdjacentElement("afterend", realTimePreview); 97 | return text; 98 | }); 99 | 100 | templateSetting.setClass( 101 | "frontmatter-generator-settings-template-setting" 102 | ); 103 | 104 | const ignoredFoldersSetting = new Setting(containerEl) 105 | .setName("Ignore folders") 106 | .setDesc("Folders to ignore. One folder per line.") 107 | .addTextArea((text) => { 108 | const realTimePreview = document.createElement("pre"); 109 | realTimePreview.classList.add( 110 | "frontmatter-generator-settings-real-time-preview" 111 | ); 112 | 113 | realTimePreview.setText( 114 | JSON.stringify( 115 | this.plugin.settings.internal.ignoredFolders, 116 | null, 117 | 2 118 | ) 119 | ); 120 | text.setPlaceholder("Enter folders to ignore") 121 | .setValue(this.plugin.settings.folderToIgnore) 122 | .onChange(async (value) => { 123 | this.plugin.settings.folderToIgnore = value; 124 | this.plugin.settings.internal.ignoredFolders = value 125 | .split("\n") 126 | .map((folder) => folder.trim()) 127 | .filter((folder) => folder !== ""); 128 | await this.plugin.saveSettings(); 129 | if (!sampleFile) return; 130 | realTimePreview.setText( 131 | JSON.stringify( 132 | this.plugin.settings.internal.ignoredFolders, 133 | null, 134 | 2 135 | ) 136 | ); 137 | }); 138 | text.inputEl.addClass("frontmatter-generator-settings-input"); 139 | 140 | if (text.inputEl.parentElement) { 141 | text.inputEl.parentElement.addClass( 142 | "frontmatter-generator-settings-input-outer" 143 | ); 144 | } 145 | text.inputEl.insertAdjacentElement("afterend", realTimePreview); 146 | 147 | return text; 148 | }); 149 | ignoredFoldersSetting.setClass( 150 | "frontmatter-generator-settings-ignored-folders-setting" 151 | ); 152 | 153 | new Setting(containerEl) 154 | .setName("Sort Yaml key") 155 | .addToggle((toggle) => { 156 | toggle 157 | .setValue(this.plugin.settings.sortYamlKey) 158 | .onChange(async (value) => { 159 | this.plugin.settings.sortYamlKey = value; 160 | await this.plugin.saveSettings(); 161 | }); 162 | }); 163 | 164 | new Setting(containerEl) 165 | .setName("Run on modify not in file") 166 | .setDesc( 167 | "Run the plugin when a file is modified and the file is not in active markdown view" 168 | ) 169 | .addToggle((toggle) => { 170 | toggle 171 | .setValue(this.plugin.settings.runOnModifyNotInFile) 172 | .onChange(async (value) => { 173 | this.plugin.settings.runOnModifyNotInFile = value; 174 | await this.plugin.saveSettings(); 175 | }); 176 | }); 177 | 178 | new Setting(containerEl) 179 | .setName("Run on modify in file") 180 | .setDesc( 181 | "Run the plugin when a file is modified and the file is in active markdown view" 182 | ) 183 | .addToggle((toggle) => { 184 | toggle 185 | .setValue(this.plugin.settings.runOnModifyInFile) 186 | .onChange(async (value) => { 187 | this.plugin.settings.runOnModifyInFile = value; 188 | await this.plugin.saveSettings(); 189 | }); 190 | }); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/utils/regex.ts: -------------------------------------------------------------------------------- 1 | // Useful regexes 2 | export const allHeadersRegex = /^([ \t]*)(#+)([ \t]+)([^\n\r]*?)([ \t]+#+)?$/gm; 3 | export const fencedRegexTemplate = 4 | "^XXX\\.*?\n(?:((?:.|\n)*?)\n)?XXX(?=\\s|$)$"; 5 | export const yamlRegex = /^---\n((?:(((?!---)(?:.|\n)*?)\n)?))---(?=\n|$)/; 6 | export const backtickBlockRegexTemplate = fencedRegexTemplate.replaceAll( 7 | "X", 8 | "`" 9 | ); 10 | export const tildeBlockRegexTemplate = fencedRegexTemplate.replaceAll("X", "~"); 11 | export const indentedBlockRegex = "^((\t|( {4})).*\n)+"; 12 | export const codeBlockRegex = new RegExp( 13 | `${backtickBlockRegexTemplate}|${tildeBlockRegexTemplate}|${indentedBlockRegex}`, 14 | "gm" 15 | ); 16 | // based on https://stackoverflow.com/a/26010910/8353749 17 | export const wikiLinkRegex = 18 | /(!?)\[{2}([^\][\n|]+)(\|([^\][\n|]+))?(\|([^\][\n|]+))?\]{2}/g; 19 | // based on https://davidwells.io/snippets/regex-match-markdown-links 20 | export const genericLinkRegex = /(!?)\[([^[]*)\](\(.*\))/g; 21 | export const tagWithLeadingWhitespaceRegex = /(\s|^)(#[^\s#;.,>\\s*)*`; 26 | export const tableSeparator = 27 | /(\|? *:?-{1,}:? *\|?)(\| *:?-{1,}:? *\|?)*( |\t)*$/gm; 28 | export const tableStartingPipe = /^(((>[ ]?)*)|([ ]{0,3}))\|/m; 29 | export const tableRow = /[^\n]*?\|[^\n]*?(\n|$)/m; 30 | // based on https://gist.github.com/skeller88/5eb73dc0090d4ff1249a 31 | export const simpleURIRegex = 32 | /(([a-z\-0-9]+:)\/{2})([^\s/?#]*[^\s")'.?!/]|[/])?(([/?#][^\s")']*[^\s")'.?!])|[/])?/gi; 33 | // generated from https://github.com/spamscanner/url-regex-safe using strict: true, returnString: true, and re2: false as options 34 | export const urlRegex = 35 | /(?:(?:(?:[a-z]+:)?\/\/)|www\.)(?:localhost|(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?:(?:[a-fA-F\d]{1,4}:){7}(?:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){6}(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|:[a-fA-F\d]{1,4}|:)|(?:[a-fA-F\d]{1,4}:){5}(?::(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,2}|:)|(?:[a-fA-F\d]{1,4}:){4}(?:(?::[a-fA-F\d]{1,4}){0,1}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,3}|:)|(?:[a-fA-F\d]{1,4}:){3}(?:(?::[a-fA-F\d]{1,4}){0,2}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,4}|:)|(?:[a-fA-F\d]{1,4}:){2}(?:(?::[a-fA-F\d]{1,4}){0,3}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,5}|:)|(?:[a-fA-F\d]{1,4}:){1}(?:(?::[a-fA-F\d]{1,4}){0,4}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,6}|:)|(?::(?:(?::[a-fA-F\d]{1,4}){0,5}:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]\d|\d)){3}|(?::[a-fA-F\d]{1,4}){1,7}|:)))(?:%[0-9a-zA-Z]{1,})?|(?:(?:[a-z\u00a1-\uffff0-9][-_]*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,})))(?::\d{2,5})?(?:(?:[/?#][^\s")']*[^\s")'.?!])|[/])?/gi; 36 | export const anchorTagRegex = /]+)>((?:.(?!<\/a>))*.)<\/a>/g; 37 | export const wordRegex = /[\p{L}\p{N}\p{Pc}\p{M}\-'’`]+/gu; 38 | // regex from https://stackoverflow.com/a/26128757/8353749 39 | export const htmlEntitiesRegex = /&[^\s]+;$/im; 40 | 41 | export const customIgnoreAllStartIndicator = 42 | generateHTMLLinterCommentWithSpecificTextAndWhitespaceRegexMatch(true); 43 | export const customIgnoreAllEndIndicator = 44 | generateHTMLLinterCommentWithSpecificTextAndWhitespaceRegexMatch(false); 45 | 46 | export const smartDoubleQuoteRegex = /[“”„«»]/g; 47 | export const smartSingleQuoteRegex = /[‘’‚‹›]/g; 48 | 49 | export const templaterCommandRegex = /<%[^]*?%>/g; 50 | // checklist regex 51 | export const checklistBoxIndicator = "\\[.\\]"; 52 | export const checklistBoxStartsTextRegex = new RegExp( 53 | `^${checklistBoxIndicator}` 54 | ); 55 | export const indentedOrBlockquoteNestedChecklistIndicatorRegex = new RegExp( 56 | `^${lineStartingWithWhitespaceOrBlockquoteTemplate}- ${checklistBoxIndicator} ` 57 | ); 58 | export const nonBlockquoteChecklistRegex = new RegExp( 59 | `^\\s*- ${checklistBoxIndicator} ` 60 | ); 61 | 62 | export const footnoteDefinitionIndicatorAtStartOfLine = 63 | /^(\[\^\w+\]) ?([,.;!:?])/gm; 64 | export const calloutRegex = /^(>\s*)+\[![^\s]*\]/m; 65 | 66 | // https://stackoverflow.com/questions/38866071/javascript-replace-method-dollar-signs 67 | // Important to use this for any regex replacements where the replacement string 68 | // could have user constructed dollar signs in it 69 | export function escapeDollarSigns(str: string): string { 70 | return str.replace(/\$/g, "$$$$"); 71 | } 72 | 73 | // https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex 74 | export function escapeRegExp(string: string): string { 75 | return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 76 | } 77 | 78 | /** 79 | * Removes spaces from around the wiki link text 80 | * @param {string} text The text to remove the space from around wiki link text 81 | * @return {string} The text without space around wiki link link text 82 | */ 83 | export function removeSpacesInWikiLinkText(text: string): string { 84 | const linkMatches = text.match(wikiLinkRegex); 85 | if (linkMatches) { 86 | for (const link of linkMatches) { 87 | // wiki link with link text 88 | if (link.includes("|")) { 89 | const startLinkTextPosition = link.indexOf("|"); 90 | const newLink = 91 | link.substring(0, startLinkTextPosition + 1) + 92 | link 93 | .substring(startLinkTextPosition + 1, link.length - 2) 94 | .trim() + 95 | "]]"; 96 | text = text.replace(link, newLink); 97 | } 98 | } 99 | } 100 | 101 | return text; 102 | } 103 | 104 | /** 105 | * Gets the first header one's text from the string provided making sure to convert any links to their display text. 106 | * @param {string} text - The text to have get the first header one's text from. 107 | * @return {string} The text for the first header one if present or an empty string. 108 | */ 109 | export function getFirstHeaderOneText(text: string): string { 110 | const result = text.match(/^#\s+(.*)/m); 111 | if (result && result[1]) { 112 | let headerText = result[1]; 113 | headerText = headerText.replaceAll( 114 | wikiLinkRegex, 115 | (_, _2, $2: string, $3: string) => { 116 | if ($3 != null) { 117 | return $3.replace("|", ""); 118 | } 119 | 120 | return $2; 121 | } 122 | ); 123 | 124 | return headerText.replaceAll(genericLinkRegex, "$2"); 125 | } 126 | 127 | return ""; 128 | } 129 | 130 | export function matchTagRegex(text: string): string[] { 131 | // @ts-ignore 132 | return [...text.matchAll(tagWithLeadingWhitespaceRegex)].map( 133 | (match) => match[2] 134 | ); 135 | } 136 | 137 | export function generateHTMLLinterCommentWithSpecificTextAndWhitespaceRegexMatch( 138 | isStart: boolean 139 | ): RegExp { 140 | const regexTemplate = ""; 141 | let endingText = ""; 142 | 143 | if (isStart) { 144 | endingText += "disable"; 145 | } else { 146 | endingText += "enable"; 147 | } 148 | 149 | return new RegExp(regexTemplate.replace("{ENDING_TEXT}", endingText), "g"); 150 | } 151 | -------------------------------------------------------------------------------- /src/utils/ignore-types.ts: -------------------------------------------------------------------------------- 1 | // copied from https://github.com/platers/obsidian-linter/blob/master/src/utils/ignore-types.ts#L39 2 | 3 | import { 4 | obsidianMultilineCommentRegex, 5 | tagWithLeadingWhitespaceRegex, 6 | wikiLinkRegex, 7 | yamlRegex, 8 | escapeDollarSigns, 9 | genericLinkRegex, 10 | urlRegex, 11 | anchorTagRegex, 12 | templaterCommandRegex, 13 | footnoteDefinitionIndicatorAtStartOfLine, 14 | } from "./regex"; 15 | import { 16 | getAllCustomIgnoreSectionsInText, 17 | getPositions, 18 | MDAstTypes, 19 | } from "./mdast"; 20 | import type { Position } from "unist"; 21 | import { replaceTextBetweenStartAndEndWithNewValue } from "./strings"; 22 | import { match, P } from "ts-pattern"; 23 | 24 | export type IgnoreResults = { replacedValues: string[]; newText: string }; 25 | export type IgnoreFunction = ( 26 | text: string, 27 | placeholder: string 28 | ) => IgnoreResults; 29 | export type IgnoreType = { 30 | replaceAction: MDAstTypes | RegExp | IgnoreFunction; 31 | placeholder: string; 32 | }; 33 | 34 | export const IgnoreTypes = { 35 | // mdast node types 36 | code: { 37 | replaceAction: MDAstTypes.Code, 38 | placeholder: "{CODE_BLOCK_PLACEHOLDER}", 39 | }, 40 | inlineCode: { 41 | replaceAction: MDAstTypes.InlineCode, 42 | placeholder: "{INLINE_CODE_BLOCK_PLACEHOLDER}", 43 | }, 44 | image: { 45 | replaceAction: MDAstTypes.Image, 46 | placeholder: "{IMAGE_PLACEHOLDER}", 47 | }, 48 | thematicBreak: { 49 | replaceAction: MDAstTypes.HorizontalRule, 50 | placeholder: "{HORIZONTAL_RULE_PLACEHOLDER}", 51 | }, 52 | italics: { 53 | replaceAction: MDAstTypes.Italics, 54 | placeholder: "{ITALICS_PLACEHOLDER}", 55 | }, 56 | bold: { 57 | replaceAction: MDAstTypes.Bold, 58 | placeholder: "{STRONG_PLACEHOLDER}", 59 | }, 60 | list: { replaceAction: MDAstTypes.List, placeholder: "{LIST_PLACEHOLDER}" }, 61 | blockquote: { 62 | replaceAction: MDAstTypes.Blockquote, 63 | placeholder: "{BLOCKQUOTE_PLACEHOLDER}", 64 | }, 65 | math: { replaceAction: MDAstTypes.Math, placeholder: "{MATH_PLACEHOLDER}" }, 66 | inlineMath: { 67 | replaceAction: MDAstTypes.InlineMath, 68 | placeholder: "{INLINE_MATH_PLACEHOLDER}", 69 | }, 70 | html: { replaceAction: MDAstTypes.Html, placeholder: "{HTML_PLACEHOLDER}" }, 71 | // RegExp 72 | yaml: { 73 | replaceAction: yamlRegex, 74 | placeholder: escapeDollarSigns("---\n---"), 75 | }, 76 | wikiLink: { 77 | replaceAction: wikiLinkRegex, 78 | placeholder: "{WIKI_LINK_PLACEHOLDER}", 79 | }, 80 | obsidianMultiLineComments: { 81 | replaceAction: obsidianMultilineCommentRegex, 82 | placeholder: "{OBSIDIAN_COMMENT_PLACEHOLDER}", 83 | }, 84 | footnoteAtStartOfLine: { 85 | replaceAction: footnoteDefinitionIndicatorAtStartOfLine, 86 | placeholder: "{FOOTNOTE_AT_START_OF_LINE_PLACEHOLDER}", 87 | }, 88 | footnoteAfterATask: { 89 | replaceAction: /- \[.] (\[\^\w+\]) ?([,.;!:?])/gm, 90 | placeholder: "{FOOTNOTE_AFTER_A_TASK_PLACEHOLDER}", 91 | }, 92 | url: { replaceAction: urlRegex, placeholder: "{URL_PLACEHOLDER}" }, 93 | anchorTag: { 94 | replaceAction: anchorTagRegex, 95 | placeholder: "{ANCHOR_PLACEHOLDER}", 96 | }, 97 | templaterCommand: { 98 | replaceAction: templaterCommandRegex, 99 | placeholder: "{TEMPLATER_PLACEHOLDER}", 100 | }, 101 | // custom functions 102 | link: { 103 | replaceAction: replaceMarkdownLinks, 104 | placeholder: "{REGULAR_LINK_PLACEHOLDER}", 105 | }, 106 | tag: { replaceAction: replaceTags, placeholder: "#tag-placeholder" }, 107 | customIgnore: { 108 | replaceAction: replaceCustomIgnore, 109 | placeholder: "{CUSTOM_IGNORE_PLACEHOLDER}", 110 | }, 111 | } as const; 112 | 113 | const isIgnoreFunction = ( 114 | replaceAction: unknown 115 | ): replaceAction is IgnoreFunction => typeof replaceAction === "function"; 116 | 117 | export function ignoreListOfTypes( 118 | ignoreTypes: IgnoreType[], 119 | text: string, 120 | func: (text: string) => string 121 | ): string { 122 | let setOfPlaceholders: { placeholder: string; replacedValues: string[] }[] = 123 | []; 124 | 125 | // replace ignore blocks with their placeholders 126 | for (const ignoreType of ignoreTypes) { 127 | const ignoredResult = match(ignoreType.replaceAction) 128 | .with(P.string, (replaceAction) => { 129 | return replaceMdastType( 130 | text, 131 | ignoreType.placeholder, 132 | replaceAction 133 | ); 134 | }) 135 | .with(P.instanceOf(RegExp), (replaceAction) => { 136 | return replaceRegex( 137 | text, 138 | ignoreType.placeholder, 139 | replaceAction 140 | ); 141 | }) 142 | .with(P.when(isIgnoreFunction), (replaceAction) => { 143 | const ignoreFunc: IgnoreFunction = replaceAction; 144 | return ignoreFunc(text, ignoreType.placeholder); 145 | }) 146 | .exhaustive(); 147 | 148 | text = ignoredResult.newText; 149 | setOfPlaceholders.push({ 150 | replacedValues: ignoredResult.replacedValues, 151 | placeholder: ignoreType.placeholder, 152 | }); 153 | } 154 | 155 | text = func(text); 156 | 157 | setOfPlaceholders = setOfPlaceholders.reverse(); 158 | // add back values that were replaced with their placeholders 159 | if (setOfPlaceholders != null && setOfPlaceholders.length > 0) { 160 | setOfPlaceholders.forEach( 161 | (replacedInfo: { 162 | placeholder: string; 163 | replacedValues: string[]; 164 | replaceDollarSigns: boolean; 165 | }) => { 166 | replacedInfo.replacedValues.forEach((replacedValue: string) => { 167 | // Regex was added to fix capitalization issue where another rule made the text not match the original place holder's case 168 | // see https://github.com/platers/obsidian-linter/issues/201 169 | text = text.replace( 170 | new RegExp(replacedInfo.placeholder, "i"), 171 | escapeDollarSigns(replacedValue) 172 | ); 173 | }); 174 | } 175 | ); 176 | } 177 | 178 | return text; 179 | } 180 | 181 | /** 182 | * Replaces all mdast type instances in the given text with a placeholder. 183 | * @param {string} text The text to replace the given mdast node type in 184 | * @param {string} placeholder The placeholder to use 185 | * @param {MDAstTypes} type The type of node to ignore by replacing with the specified placeholder 186 | * @return {string} The text with mdast nodes types specified replaced 187 | * @return {string[]} The mdast nodes values replaced 188 | */ 189 | function replaceMdastType( 190 | text: string, 191 | placeholder: string, 192 | type: MDAstTypes 193 | ): IgnoreResults { 194 | const positions: Position[] = getPositions(type, text); 195 | const replacedValues: string[] = []; 196 | 197 | for (const position of positions) { 198 | const valueToReplace = text.substring( 199 | // @ts-ignore 200 | position.start.offset, 201 | position.end.offset 202 | ); 203 | replacedValues.push(valueToReplace); 204 | text = replaceTextBetweenStartAndEndWithNewValue( 205 | text, 206 | // @ts-ignore 207 | position.start.offset, 208 | position.end.offset, 209 | placeholder 210 | ); 211 | } 212 | 213 | // Reverse the replaced values so that they are in the same order as the original text 214 | replacedValues.reverse(); 215 | 216 | return { newText: text, replacedValues }; 217 | } 218 | 219 | /** 220 | * Replaces all regex matches in the given text with a placeholder. 221 | * @param {string} text The text to replace the regex matches in 222 | * @param {string} placeholder The placeholder to use 223 | * @param {RegExp} regex The regex to use to find what to replace with the placeholder 224 | * @return {string} The text with regex matches replaced 225 | * @return {string[]} The regex matches replaced 226 | */ 227 | function replaceRegex( 228 | text: string, 229 | placeholder: string, 230 | regex: RegExp 231 | ): IgnoreResults { 232 | const regexMatches = text.match(regex); 233 | const textMatches: string[] = []; 234 | if (regex.flags.includes("g")) { 235 | text = text.replaceAll(regex, placeholder); 236 | 237 | if (regexMatches) { 238 | for (const matchText of regexMatches) { 239 | textMatches.push(matchText); 240 | } 241 | } 242 | } else { 243 | text = text.replace(regex, placeholder); 244 | 245 | if (regexMatches) { 246 | textMatches.push(regexMatches[0]); 247 | } 248 | } 249 | 250 | return { newText: text, replacedValues: textMatches }; 251 | } 252 | 253 | /** 254 | * Replaces all markdown links in the given text with a placeholder. 255 | * @param {string} text The text to replace links in 256 | * @param {string} regularLinkPlaceholder The placeholder to use for regular markdown links 257 | * @return {string} The text with links replaced 258 | * @return {string[]} The regular markdown links replaced 259 | */ 260 | function replaceMarkdownLinks( 261 | text: string, 262 | regularLinkPlaceholder: string 263 | ): IgnoreResults { 264 | const positions: Position[] = getPositions(MDAstTypes.Link, text); 265 | const replacedRegularLinks: string[] = []; 266 | 267 | for (const position of positions) { 268 | if (position == undefined) { 269 | continue; 270 | } 271 | 272 | const regularLink = text.substring( 273 | // @ts-ignore 274 | position.start.offset, 275 | position.end.offset 276 | ); 277 | // skip links that are not in markdown format 278 | if (!regularLink.match(genericLinkRegex)) { 279 | continue; 280 | } 281 | 282 | replacedRegularLinks.push(regularLink); 283 | text = replaceTextBetweenStartAndEndWithNewValue( 284 | text, 285 | // @ts-ignore 286 | position.start.offset, 287 | position.end.offset, 288 | regularLinkPlaceholder 289 | ); 290 | } 291 | 292 | // Reverse the regular links so that they are in the same order as the original text 293 | replacedRegularLinks.reverse(); 294 | 295 | return { newText: text, replacedValues: replacedRegularLinks }; 296 | } 297 | 298 | function replaceTags(text: string, placeholder: string): IgnoreResults { 299 | const replacedValues: string[] = []; 300 | 301 | text = text.replace(tagWithLeadingWhitespaceRegex, (_, whitespace, tag) => { 302 | replacedValues.push(tag); 303 | return whitespace + placeholder; 304 | }); 305 | 306 | return { newText: text, replacedValues: replacedValues }; 307 | } 308 | 309 | function replaceCustomIgnore( 310 | text: string, 311 | customIgnorePlaceholder: string 312 | ): IgnoreResults { 313 | const customIgnorePositions = getAllCustomIgnoreSectionsInText(text); 314 | 315 | const replacedSections: string[] = new Array(customIgnorePositions.length); 316 | let index = 0; 317 | const length = replacedSections.length; 318 | for (const customIgnorePosition of customIgnorePositions) { 319 | replacedSections[length - 1 - index++] = text.substring( 320 | customIgnorePosition.startIndex, 321 | customIgnorePosition.endIndex 322 | ); 323 | text = replaceTextBetweenStartAndEndWithNewValue( 324 | text, 325 | customIgnorePosition.startIndex, 326 | customIgnorePosition.endIndex, 327 | customIgnorePlaceholder 328 | ); 329 | } 330 | 331 | return { newText: text, replacedValues: replacedSections }; 332 | } 333 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Editor, 4 | MarkdownView, 5 | Notice, 6 | Plugin, 7 | TFile, 8 | TFolder, 9 | } from "obsidian"; 10 | import "@total-typescript/ts-reset"; 11 | import { SanitizedObject } from "@/utils/evalFromExpression"; 12 | import { writeFile } from "./utils/obsidian"; 13 | import { ConfirmationModal } from "./ui/modals/confirmationModal"; 14 | import { SettingTab } from "./ui/SettingTab"; 15 | import { 16 | FrontmatterGeneratorPluginSettings, 17 | DEFAULT_SETTINGS, 18 | } from "./FrontmatterGeneratorPluginSettings"; 19 | import { 20 | getAllFilesInFolder, 21 | getDataFromFile, 22 | getDataFromTextSync, 23 | isMarkdownFile, 24 | } from "./utils/obsidian"; 25 | import { shouldIgnoreFile } from "./utils/shouldIgnoreFile"; 26 | import { getNewTextFromFile } from "./utils/getNewTextFromFile"; 27 | import { isValidFrontmatter } from "@/utils/yaml"; 28 | 29 | const userClickTimeout = 5000; 30 | 31 | export enum YamlKey { 32 | IGNORE = "yaml-gen-ignore", 33 | } 34 | 35 | export const isIgnoredByFolder = ( 36 | settings: FrontmatterGeneratorPluginSettings, 37 | file: TFile 38 | ) => { 39 | return settings.internal.ignoredFolders.includes( 40 | file.parent?.path as string 41 | ); 42 | }; 43 | 44 | export function isObjectEmpty(obj: SanitizedObject) { 45 | return obj && typeof obj === "object" && Object.keys(obj).length === 0; 46 | } 47 | 48 | export default class FrontmatterGeneratorPlugin extends Plugin { 49 | settings: FrontmatterGeneratorPluginSettings; 50 | private lock = false; 51 | 52 | addCommands() { 53 | // eslint-disable-next-line @typescript-eslint/no-this-alias 54 | const that = this; 55 | 56 | this.addCommand({ 57 | id: "run-file", 58 | name: "run file", 59 | editorCheckCallback(checking, editor, ctx) { 60 | if (!ctx.file) return; 61 | if (checking) { 62 | return isMarkdownFile(ctx.file); 63 | } 64 | that.runFileSync(ctx.file, editor); 65 | }, 66 | }); 67 | this.addCommand({ 68 | id: "run-all-files", 69 | name: "Run all files", 70 | callback: () => { 71 | const startMessage = 72 | "Are you sure you want to run all files in your vault? This may take a while."; 73 | const submitBtnText = "Run all files"; 74 | const submitBtnNoticeText = "Runing all files..."; 75 | new ConfirmationModal( 76 | this.app, 77 | startMessage, 78 | submitBtnText, 79 | submitBtnNoticeText, 80 | () => { 81 | return this.runAllFiles(this.app); 82 | } 83 | ).open(); 84 | }, 85 | }); 86 | 87 | this.addCommand({ 88 | id: "run-all-files-in-folder", 89 | name: "Run all files in folder", 90 | editorCheckCallback: (checking: boolean, _, ctx) => { 91 | if (checking) { 92 | return !ctx.file?.parent?.isRoot(); 93 | } 94 | 95 | if (ctx.file?.parent) 96 | this.createFolderRunModal(ctx.file.parent); 97 | }, 98 | }); 99 | } 100 | 101 | // handles the creation of the folder linting modal since this happens in multiple places and it should be consistent 102 | createFolderRunModal(folder: TFolder) { 103 | const startMessage = `Are you sure you want to run all files in the folder ${folder.name}? This may take a while.`; 104 | // const submitBtnText = getTextInLanguage('commands.run-all-files-in-folder.submit-button-text').replace('{FOLDER_NAME}', folder.name); 105 | const submitBtnText = `Run all files in ${folder.name}`; 106 | 107 | const submitBtnNoticeText = `Runing all files in ${folder.name}...`; 108 | new ConfirmationModal( 109 | this.app, 110 | startMessage, 111 | submitBtnText, 112 | submitBtnNoticeText, 113 | () => this.runAllFilesInFolder(folder) 114 | ).open(); 115 | } 116 | 117 | async onload() { 118 | await this.loadSettings(); 119 | this.registerEventsAndSaveCallback(); 120 | // create a command that generate frontmatter on the whole vault 121 | this.addCommands(); 122 | // This adds a settings tab so the user can configure various aspects of the plugin 123 | this.addSettingTab(new SettingTab(this.app, this)); 124 | } 125 | 126 | async loadSettings() { 127 | this.settings = Object.assign( 128 | {}, 129 | DEFAULT_SETTINGS, 130 | await this.loadData() 131 | ); 132 | } 133 | 134 | async saveSettings() { 135 | await this.saveData(this.settings); 136 | } 137 | 138 | /** 139 | * 1. check the file is ignored 140 | * 2. 141 | * @param file 142 | * @param editor 143 | */ 144 | runFileSync(file: TFile, editor: Editor) { 145 | const data = getDataFromTextSync(editor.getValue()); 146 | if (shouldIgnoreFile(this.settings, file, data)) return; 147 | if (!isValidFrontmatter(data)) return; 148 | const newText = getNewTextFromFile( 149 | this.settings.template, 150 | file, 151 | data, 152 | this 153 | ); 154 | 155 | if (newText) { 156 | writeFile(editor, data.text, newText); 157 | // update the metadata editor 158 | } 159 | } 160 | 161 | async runFile(file: TFile) { 162 | // remove the selction of the current editor 163 | 164 | const data = await getDataFromFile(this, file); 165 | if (shouldIgnoreFile(this.settings, file, data)) return; 166 | if (!isValidFrontmatter(data)) return; 167 | // from the frontmatter template and the file, generate some new properties 168 | const newText = getNewTextFromFile( 169 | this.settings.template, 170 | file, 171 | data, 172 | this 173 | ); 174 | // replace the yaml section 175 | if (newText) await this.app.vault.modify(file, newText); 176 | } 177 | 178 | registerEventsAndSaveCallback() { 179 | // add file menu item 180 | this.registerEvent( 181 | this.app.workspace.on("file-menu", async (menu, file) => { 182 | if (file instanceof TFile && isMarkdownFile(file)) { 183 | menu.addItem((item) => { 184 | item.setIcon("file-cog") 185 | .setTitle("Generate frontmatter for this file") 186 | .onClick(async () => { 187 | const activeFile = 188 | this.app.workspace.getActiveFile(); 189 | const view = 190 | this.app.workspace.getActiveViewOfType( 191 | MarkdownView 192 | ); 193 | const editor = view?.editor; 194 | const isUsingPropertiesEditor = 195 | view?.getMode() === "source" && 196 | // @ts-ignore 197 | !view.currentMode.sourceMode; 198 | if ( 199 | activeFile === file && 200 | editor && 201 | !isUsingPropertiesEditor 202 | ) { 203 | this.runFileSync(file, editor); 204 | } else if ( 205 | activeFile === file && 206 | editor && 207 | isUsingPropertiesEditor 208 | ) { 209 | await this.runFile(file); 210 | } 211 | }); 212 | }); 213 | } else if (file instanceof TFolder) { 214 | menu.addItem((item) => { 215 | item.setIcon("file-cog") 216 | .setTitle("Generate frontmatter in this folder") 217 | .onClick(() => this.runAllFilesInFolder(file)); 218 | }); 219 | } 220 | }) 221 | ); 222 | 223 | this.registerEvent( 224 | this.app.vault.on("modify", async (file) => { 225 | const view = 226 | this.app.workspace.getActiveViewOfType(MarkdownView); 227 | const isUsingPropertiesEditor = 228 | view?.getMode() === "preview" || 229 | (view?.getMode() === "source" && 230 | // @ts-ignore 231 | !view.currentMode.sourceMode); 232 | // the markdown preview type is not complete 233 | // view.currentMode.type === "source" / "preview" 234 | const editor = view?.editor; 235 | 236 | // get current file from the active view 237 | const activeFile = this.app.workspace.getActiveFile(); 238 | 239 | // even if the active file is the target file, we still need to check if the user is using the properties editor 240 | if ( 241 | !this.settings.runOnModifyInFile && 242 | activeFile === file && 243 | editor && 244 | !isUsingPropertiesEditor 245 | ) 246 | return; 247 | 248 | if (!this.settings.runOnModifyNotInFile && activeFile !== file) 249 | return; 250 | if (this.lock) return; 251 | try { 252 | if (file instanceof TFile && isMarkdownFile(file)) { 253 | if (activeFile === file && editor) { 254 | if (isUsingPropertiesEditor) 255 | await this.runFile(file); 256 | } else { 257 | await this.runFile(file); 258 | } 259 | } 260 | } catch (e) { 261 | console.error(e); 262 | } finally { 263 | this.lock = false; 264 | } 265 | }) 266 | ); 267 | 268 | this.registerEvent( 269 | this.app.workspace.on("editor-change", async (editor) => { 270 | if (!this.settings.runOnModifyInFile) return; 271 | this.lock = true; 272 | try { 273 | const file = this.app.workspace.getActiveFile(); 274 | const view = 275 | this.app.workspace.getActiveViewOfType(MarkdownView); 276 | if (file instanceof TFile && isMarkdownFile(file)) { 277 | const isUsingPropertiesEditor = 278 | view?.getMode() === "preview" || 279 | (view?.getMode() === "source" && 280 | // @ts-ignore 281 | !view.currentMode.sourceMode); 282 | 283 | if (editor) { 284 | if (isUsingPropertiesEditor) 285 | await this.runFile(file); 286 | else { 287 | this.runFileSync(file, editor); 288 | } 289 | } else { 290 | await this.runFile(file); 291 | } 292 | } 293 | } catch (e) { 294 | console.error(e); 295 | } finally { 296 | this.lock = false; 297 | } 298 | }) 299 | ); 300 | } 301 | 302 | async runAllFiles(app: App) { 303 | const errorFiles: { file: TFile; error: Error }[] = []; 304 | let lintedFiles = 0; 305 | await Promise.all( 306 | app.vault.getMarkdownFiles().map(async (file) => { 307 | if (!shouldIgnoreFile(this.settings, file)) { 308 | try { 309 | await this.runFile(file); 310 | } catch (e) { 311 | errorFiles.push({ file, error: e }); 312 | } 313 | lintedFiles++; 314 | } 315 | }) 316 | ); 317 | 318 | if (errorFiles.length === 0) { 319 | new Notice( 320 | `Obsidian Frontmatter Generator: ${lintedFiles} files are successfully updated.`, 321 | userClickTimeout 322 | ); 323 | } else { 324 | new Notice( 325 | `Obsidian Frontmatter Generator: ${errorFiles.length} files have errors. See the developer console for more details.`, 326 | userClickTimeout 327 | ); 328 | console.error( 329 | "[Frontmatter generator]: The problematic files are", 330 | errorFiles 331 | ); 332 | } 333 | } 334 | 335 | async runAllFilesInFolder(folder: TFolder) { 336 | let numberOfErrors = 0; 337 | let lintedFiles = 0; 338 | const filesInFolder = getAllFilesInFolder(folder); 339 | await Promise.all( 340 | filesInFolder.map(async (file) => { 341 | if (!shouldIgnoreFile(this.settings, file)) { 342 | try { 343 | await this.runFile(file); 344 | } catch (e) { 345 | numberOfErrors += 1; 346 | } 347 | } 348 | lintedFiles++; 349 | }) 350 | ); 351 | 352 | new Notice( 353 | `Obsidian Frontmatter Generator: ${ 354 | lintedFiles - numberOfErrors 355 | } out of ${lintedFiles} files are successfully updated. Errors: ${numberOfErrors}`, 356 | userClickTimeout 357 | ); 358 | } 359 | } 360 | -------------------------------------------------------------------------------- /src/utils/strings.ts: -------------------------------------------------------------------------------- 1 | import { calloutRegex } from "./regex"; 2 | 3 | /** 4 | * Replaces a string by inserting it between the start and end positions provided for a string. 5 | * @param {string} str - The string to replace a value from 6 | * @param {number} start - The position to insert at 7 | * @param {number} end - The position to stop text replacement at 8 | * @param {string} value - The string to insert 9 | * @return {string} The string with the replacement string added over the specified start and stop 10 | */ 11 | export function replaceTextBetweenStartAndEndWithNewValue( 12 | str: string, 13 | start: number, 14 | end: number, 15 | value: string 16 | ): string { 17 | return str.substring(0, start) + value + str.substring(end); 18 | } 19 | 20 | function getStartOfLineWhitespaceOrBlockquoteLevel( 21 | text: string, 22 | startPosition: number 23 | ): [string, number] { 24 | if (startPosition === 0) { 25 | return ["", 0]; 26 | } 27 | 28 | let startOfLine = ""; 29 | let index = startPosition; 30 | while (index >= 0) { 31 | const char = text.charAt(index); 32 | if (char === "\n") { 33 | break; 34 | } else if (char.trim() === "" || char === ">") { 35 | startOfLine = char + startOfLine; 36 | } else { 37 | startOfLine = ""; 38 | } 39 | 40 | index--; 41 | } 42 | 43 | return [startOfLine, index]; 44 | } 45 | 46 | function getEmptyLine(priorLine: string = ""): string { 47 | const [priorLineStart] = getStartOfLineWhitespaceOrBlockquoteLevel( 48 | priorLine, 49 | priorLine.length 50 | ); 51 | 52 | return "\n" + priorLineStart.trim(); 53 | } 54 | 55 | function getEmptyLineForBlockqute( 56 | priorLine: string = "", 57 | isCallout: boolean = false, 58 | blockquoteLevel: number = 1 59 | ): string { 60 | const potentialEmptyLine = getEmptyLine(priorLine); 61 | const previousBlockquoteLevel = countInstances(potentialEmptyLine, ">"); 62 | const dealingWithACallout = isCallout || calloutRegex.test(priorLine); 63 | if (dealingWithACallout && blockquoteLevel === previousBlockquoteLevel) { 64 | return potentialEmptyLine.substring( 65 | 0, 66 | potentialEmptyLine.lastIndexOf(">") 67 | ); 68 | } 69 | 70 | return potentialEmptyLine; 71 | } 72 | 73 | function makeSureContentHasASingleEmptyLineBeforeItUnlessItStartsAFile( 74 | text: string, 75 | startOfContent: number 76 | ): string { 77 | if (startOfContent === 0) { 78 | return text; 79 | } 80 | 81 | let index = startOfContent; 82 | let startOfNewContent = startOfContent; 83 | while (index >= 0) { 84 | const currentChar = text.charAt(index); 85 | if (currentChar.trim() !== "") { 86 | break; // if non-whitespace is encountered, then the line has content 87 | } else if (currentChar === "\n") { 88 | startOfNewContent = index; 89 | } 90 | index--; 91 | } 92 | 93 | if (index < 0 || startOfNewContent === 0) { 94 | return text.substring(startOfContent + 1); 95 | } 96 | 97 | return ( 98 | text.substring(0, startOfNewContent) + 99 | "\n" + 100 | text.substring(startOfContent) 101 | ); 102 | } 103 | 104 | function makeSureContentHasASingleEmptyLineBeforeItUnlessItStartsAFileForBlockquote( 105 | text: string, 106 | startOfLine: string, 107 | startOfContent: number, 108 | isCallout: boolean = false, 109 | addingEmptyLinesAroundBlockquotes: boolean = false 110 | ): string { 111 | if (startOfContent === 0) { 112 | return text; 113 | } 114 | 115 | const nestingLevel = startOfLine.split(">").length - 1; 116 | let index = startOfContent; 117 | let startOfNewContent = startOfContent; 118 | let lineNestingLevel = 0; 119 | let foundABlankLine = false; 120 | let previousChar = ""; 121 | while (index >= 0) { 122 | const currentChar = text.charAt(index); 123 | if (currentChar.trim() !== "" && currentChar !== ">") { 124 | break; // if non-whitespace, non-gt-bracket is encountered, then the line has content 125 | } else if (currentChar === ">") { 126 | // if we go from having a blank line at any point to then having more blockquote content we know we have encountered another blockquote 127 | if (foundABlankLine) { 128 | break; 129 | } 130 | 131 | lineNestingLevel++; 132 | } else if (currentChar === "\n") { 133 | if ( 134 | lineNestingLevel === 0 || 135 | lineNestingLevel === nestingLevel || 136 | lineNestingLevel + 1 === nestingLevel 137 | ) { 138 | startOfNewContent = index; 139 | lineNestingLevel = 0; 140 | 141 | if (previousChar === "\n") { 142 | foundABlankLine = true; 143 | } 144 | } else { 145 | break; 146 | } 147 | } 148 | index--; 149 | previousChar = currentChar; 150 | } 151 | 152 | if (index < 0 || startOfNewContent === 0) { 153 | return text.substring(startOfContent + 1); 154 | } 155 | 156 | const startingEmptyLines = text.substring( 157 | startOfNewContent, 158 | startOfContent 159 | ); 160 | const startsWithEmptyLine = 161 | startingEmptyLines === "\n" || startingEmptyLines.startsWith("\n\n"); 162 | if (startsWithEmptyLine) { 163 | return ( 164 | text.substring(0, startOfNewContent) + 165 | "\n" + 166 | text.substring(startOfContent) 167 | ); 168 | } 169 | 170 | const indexOfLastNewLine = text.lastIndexOf("\n", startOfNewContent - 1); 171 | let priorLine = ""; 172 | if (indexOfLastNewLine === -1) { 173 | priorLine = text.substring(0, startOfNewContent); 174 | } else { 175 | priorLine = text.substring(indexOfLastNewLine, startOfNewContent); 176 | } 177 | 178 | const emptyLine = addingEmptyLinesAroundBlockquotes 179 | ? getEmptyLineForBlockqute(priorLine, isCallout, nestingLevel) 180 | : getEmptyLine(priorLine); 181 | 182 | return ( 183 | text.substring(0, startOfNewContent) + 184 | emptyLine + 185 | text.substring(startOfContent) 186 | ); 187 | } 188 | 189 | function makeSureContentHasASingleEmptyLineAfterItUnlessItEndsAFile( 190 | text: string, 191 | endOfContent: number 192 | ): string { 193 | if (endOfContent === text.length - 1) { 194 | return text; 195 | } 196 | 197 | let index = endOfContent; 198 | let endOfNewContent = endOfContent; 199 | let isFirstNewLine = true; 200 | while (index < text.length) { 201 | const currentChar = text.charAt(index); 202 | if (currentChar.trim() !== "") { 203 | break; // if non-whitespace is encountered, then the line has content 204 | } else if (currentChar === "\n") { 205 | if (isFirstNewLine) { 206 | isFirstNewLine = false; 207 | } else { 208 | endOfNewContent = index; 209 | } 210 | } 211 | index++; 212 | } 213 | 214 | if (index === text.length || endOfNewContent === text.length - 1) { 215 | return text.substring(0, endOfContent); 216 | } 217 | 218 | return ( 219 | text.substring(0, endOfContent) + "\n" + text.substring(endOfNewContent) 220 | ); 221 | } 222 | 223 | function makeSureContentHasASingleEmptyLineAfterItUnlessItEndsAFileForBlockquote( 224 | text: string, 225 | startOfLine: string, 226 | endOfContent: number, 227 | isCallout: boolean = false, 228 | addingEmptyLinesAroundBlockquotes: boolean = false 229 | ): string { 230 | if (endOfContent === text.length - 1) { 231 | return text; 232 | } 233 | 234 | const nestingLevel = startOfLine.split(">").length - 1; 235 | let index = endOfContent; 236 | let endOfNewContent = endOfContent; 237 | let isFirstNewLine = true; 238 | let lineNestingLevel = 0; 239 | let foundABlankLine = false; 240 | let previousChar = ""; 241 | while (index < text.length) { 242 | const currentChar = text.charAt(index); 243 | if (currentChar.trim() !== "" && currentChar !== ">") { 244 | break; // if non-whitespace is encountered, then the line has content 245 | } else if (currentChar === ">") { 246 | // if we go from having a blank line at any point to then having more blockquote content we know we have encountered another blockquote 247 | if (foundABlankLine) { 248 | break; 249 | } 250 | 251 | lineNestingLevel++; 252 | } else if (currentChar === "\n") { 253 | if ( 254 | lineNestingLevel === 0 || 255 | lineNestingLevel === nestingLevel || 256 | lineNestingLevel + 1 === nestingLevel 257 | ) { 258 | lineNestingLevel = 0; 259 | if (isFirstNewLine) { 260 | isFirstNewLine = false; 261 | } else { 262 | endOfNewContent = index; 263 | } 264 | 265 | if (previousChar === "\n") { 266 | foundABlankLine = true; 267 | } 268 | } else { 269 | break; 270 | } 271 | } 272 | index++; 273 | 274 | previousChar = currentChar; 275 | } 276 | 277 | if (index === text.length || endOfNewContent === text.length - 1) { 278 | return text.substring(0, endOfContent); 279 | } 280 | 281 | const endingEmptyLines = text.substring(endOfContent, endOfNewContent); 282 | const endsInEmptyLine = 283 | endingEmptyLines === "\n" || endingEmptyLines.endsWith("\n\n"); 284 | if (endsInEmptyLine) { 285 | return ( 286 | text.substring(0, endOfContent) + 287 | "\n" + 288 | text.substring(endOfNewContent) 289 | ); 290 | } 291 | 292 | const indexOfSecondNewLineAfterContent = text.indexOf( 293 | "\n", 294 | endOfNewContent + 1 295 | ); 296 | let nextLine = ""; 297 | if (indexOfSecondNewLineAfterContent === -1) { 298 | nextLine = text.substring(endOfNewContent); 299 | } else { 300 | nextLine = text.substring( 301 | endOfNewContent + 1, 302 | indexOfSecondNewLineAfterContent 303 | ); 304 | } 305 | 306 | const emptyLine = addingEmptyLinesAroundBlockquotes 307 | ? getEmptyLineForBlockqute(nextLine, isCallout, nestingLevel) 308 | : getEmptyLine(nextLine); 309 | 310 | return ( 311 | text.substring(0, endOfContent) + 312 | emptyLine + 313 | text.substring(endOfNewContent) 314 | ); 315 | } 316 | 317 | /** 318 | * Makes sure that the specified content has an empty line around it so long as it does not start or end a file. 319 | * @param {string} text - The entire file's contents 320 | * @param {number} start - The starting index of the content to escape 321 | * @param {number} end - The ending index of the content to escape 322 | * @param {boolean} addingEmptyLinesAroundBlockquotes - Whether or not the logic is meant to add empty lines around blockquotes. This is something meant to better help with spacing around blockquotes. 323 | * @return {string} The new file contents after the empty lines have been added 324 | */ 325 | export function makeSureContentHasEmptyLinesAddedBeforeAndAfter( 326 | text: string, 327 | start: number, 328 | end: number, 329 | addingEmptyLinesAroundBlockquotes: boolean = false 330 | ): string { 331 | const [startOfLine, startOfLineIndex] = 332 | getStartOfLineWhitespaceOrBlockquoteLevel(text, start); 333 | if (startOfLine.trim() !== "") { 334 | const isCallout = calloutRegex.test(text.substring(start, end)); 335 | const newText = 336 | makeSureContentHasASingleEmptyLineAfterItUnlessItEndsAFileForBlockquote( 337 | text, 338 | startOfLine, 339 | end, 340 | isCallout, 341 | addingEmptyLinesAroundBlockquotes 342 | ); 343 | 344 | return makeSureContentHasASingleEmptyLineBeforeItUnlessItStartsAFileForBlockquote( 345 | newText, 346 | startOfLine, 347 | startOfLineIndex, 348 | isCallout, 349 | addingEmptyLinesAroundBlockquotes 350 | ); 351 | } 352 | 353 | const newText = makeSureContentHasASingleEmptyLineAfterItUnlessItEndsAFile( 354 | text, 355 | end 356 | ); 357 | 358 | return makeSureContentHasASingleEmptyLineBeforeItUnlessItStartsAFile( 359 | newText, 360 | startOfLineIndex 361 | ); 362 | } 363 | 364 | // from https://stackoverflow.com/a/52171480/8353749 365 | export function hashString53Bit(str: string, seed: number = 0): number { 366 | let h1 = 0xdeadbeef ^ seed; 367 | let h2 = 0x41c6ce57 ^ seed; 368 | for (let i = 0, ch; i < str.length; i++) { 369 | ch = str.charCodeAt(i); 370 | h1 = Math.imul(h1 ^ ch, 2654435761); 371 | h2 = Math.imul(h2 ^ ch, 1597334677); 372 | } 373 | 374 | h1 = 375 | Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ 376 | Math.imul(h2 ^ (h2 >>> 13), 3266489909); 377 | h2 = 378 | Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ 379 | Math.imul(h1 ^ (h1 >>> 13), 3266489909); 380 | 381 | return 4294967296 * (2097151 & h2) + (h1 >>> 0); 382 | } 383 | 384 | export function getStartOfLineIndex( 385 | text: string, 386 | indexToStartFrom: number 387 | ): number { 388 | if (indexToStartFrom == 0) { 389 | return indexToStartFrom; 390 | } 391 | 392 | let startOfLineIndex = indexToStartFrom; 393 | while (startOfLineIndex > 0 && text.charAt(startOfLineIndex - 1) !== "\n") { 394 | startOfLineIndex--; 395 | } 396 | 397 | return startOfLineIndex; 398 | } 399 | 400 | /** 401 | * Replace the first instance of the matching search string in the text after the provided starting position. 402 | * @param {string} text - The text in which to do the find and replace given the starting position. 403 | * @param {string} search - The text to search for and replace in the provided string. 404 | * @param {string} replace - The text to replace the search text with in the provided string. 405 | * @param {number} start - The position to start the replace search at. 406 | * @return {string} The new string after replacing the value if found. 407 | */ 408 | export function replaceAt( 409 | text: string, 410 | search: string, 411 | replace: string, 412 | start: number 413 | ): string { 414 | if (start > text.length - 1) { 415 | return text; 416 | } 417 | 418 | return ( 419 | text.slice(0, start) + 420 | text.slice(start, text.length).replace(search, replace) 421 | ); 422 | } 423 | 424 | // based on https://stackoverflow.com/a/21730166/8353749 425 | export function countInstances(text: string, instancesOf: string): number { 426 | let counter = 0; 427 | 428 | for (let i = 0, input_length = text.length; i < input_length; i++) { 429 | const index_of_sub = text.indexOf(instancesOf, i); 430 | 431 | if (index_of_sub > -1) { 432 | counter++; 433 | i = index_of_sub; 434 | } 435 | } 436 | 437 | return counter; 438 | } 439 | 440 | // based on https://stackoverflow.com/a/175787/8353749 441 | export function isNumeric(str: string) { 442 | const type = typeof str; 443 | if (type != "string") return type === "number"; // we only process strings so if the value is not already a number the result is false 444 | return ( 445 | !isNaN(str as unknown as number) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... 446 | !isNaN(parseFloat(str)) 447 | ); // ...and ensure strings of whitespace fail 448 | } 449 | 450 | export function stripCr(text: string) { 451 | return text.replace(/\r/g, ""); 452 | } 453 | 454 | export function trimFirstSpaces(input: string) { 455 | // Get the index of the last space of the leading space group 456 | const leadingSpaces = input.match(/^[ \t]+/); 457 | const start = leadingSpaces ? leadingSpaces[0].length : 0; 458 | // Replace start and end spaces using the indices 459 | return input.substring(start); 460 | } 461 | -------------------------------------------------------------------------------- /src/utils/yaml.ts: -------------------------------------------------------------------------------- 1 | import { load, dump } from "js-yaml"; 2 | import { escapeDollarSigns, yamlRegex } from "./regex"; 3 | import { isNumeric, trimFirstSpaces } from "./strings"; 4 | import { Data } from "@/utils/obsidian"; 5 | 6 | export const OBSIDIAN_TAG_KEY_SINGULAR = "tag"; 7 | export const OBSIDIAN_TAG_KEY_PLURAL = "tags"; 8 | export const OBSIDIAN_TAG_KEYS = [ 9 | OBSIDIAN_TAG_KEY_SINGULAR, 10 | OBSIDIAN_TAG_KEY_PLURAL, 11 | ]; 12 | export const OBSIDIAN_ALIAS_KEY_SINGULAR = "alias"; 13 | export const OBSIDIAN_ALIAS_KEY_PLURAL = "aliases"; 14 | export const OBSIDIAN_ALIASES_KEYS = [ 15 | OBSIDIAN_ALIAS_KEY_SINGULAR, 16 | OBSIDIAN_ALIAS_KEY_PLURAL, 17 | ]; 18 | export const LINTER_ALIASES_HELPER_KEY = "linter-yaml-title-alias"; 19 | export const DISABLED_RULES_KEY = "disabled rules"; 20 | 21 | export function initYAML(text: string) { 22 | if (text.match(yamlRegex) === null) { 23 | text = "---\n---\n" + text; 24 | } 25 | return text; 26 | } 27 | 28 | export function getYAMLText(text: string) { 29 | const yaml = text.match(yamlRegex); 30 | if (!yaml) { 31 | return null; 32 | } 33 | return yaml[1]; 34 | } 35 | 36 | export function formatYAML(text: string, func: (text: string) => string) { 37 | if (!text.match(yamlRegex)) { 38 | return text; 39 | } 40 | 41 | const oldYaml = text.match(yamlRegex)?.[0]; 42 | if (!oldYaml) return text; 43 | 44 | const newYaml = func(oldYaml); 45 | text = text.replace(oldYaml, escapeDollarSigns(newYaml)); 46 | 47 | return text; 48 | } 49 | 50 | function getYamlSectionRegExp(rawKey: string) { 51 | return new RegExp( 52 | `^([\\t ]*)${rawKey}:[ \\t]*(\\S.*|(?:(?:\\n *- \\S.*)|((?:\\n *- *))*|(\\n([ \\t]+[^\\n]*))*)*)\\n`, 53 | "m" 54 | ); 55 | } 56 | 57 | export function setYamlSection( 58 | yaml: string, 59 | rawKey: string, 60 | rawValue: string 61 | ): string { 62 | const yamlSectionEscaped = `${rawKey}:${rawValue}\n`; 63 | let isReplaced = false; 64 | let result = yaml.replace(getYamlSectionRegExp(rawKey), (_, $1: string) => { 65 | isReplaced = true; 66 | return $1 + yamlSectionEscaped; 67 | }); 68 | if (!isReplaced) { 69 | result = `${yaml}${yamlSectionEscaped}`; 70 | } 71 | return result; 72 | } 73 | 74 | export function getYamlSectionValue( 75 | yaml: string, 76 | rawKey: string 77 | ): string | null { 78 | const match = yaml.match(getYamlSectionRegExp(rawKey)); 79 | const result = match == null ? null : match[2]; 80 | // @ts-ignore 81 | return result; 82 | } 83 | 84 | export function removeYamlSection(yaml: string, rawKey: string): string { 85 | const result = yaml.replace(getYamlSectionRegExp(rawKey), ""); 86 | return result; 87 | } 88 | 89 | export enum TagSpecificArrayFormats { 90 | SingleStringSpaceDelimited = "single string space delimited", 91 | SingleLineSpaceDelimited = "single-line space delimited", 92 | } 93 | 94 | export enum SpecialArrayFormats { 95 | SingleStringToSingleLine = "single string to single-line", 96 | SingleStringToMultiLine = "single string to multi-line", 97 | SingleStringCommaDelimited = "single string comma delimited", 98 | } 99 | 100 | export enum NormalArrayFormats { 101 | SingleLine = "single-line", 102 | MultiLine = "multi-line", 103 | } 104 | 105 | export type QuoteCharacter = "'" | '"'; 106 | 107 | /** 108 | * Formats the YAML array value passed in with the specified format. 109 | * @param {string | string[]} value The value(s) that will be used as the parts of the array that is assumed to already be broken down into the appropriate format to be put in the array. 110 | * @param {NormalArrayFormats | SpecialArrayFormats | TagSpecificArrayFormats} format The format that the array should be converted into. 111 | * @param {string} defaultEscapeCharacter The character escape to use around the value if a specific escape character is not needed. 112 | * @param {boolean} removeEscapeCharactersIfPossibleWhenGoingToMultiLine Whether or not to remove no longer needed escape values when converting to a multi-line format. 113 | * @param {boolean} escapeNumericValues Whether or not to escape any numeric values found in the array. 114 | * @return {string} The formatted array in the specified YAML/obsidian YAML format. 115 | */ 116 | export function formatYamlArrayValue( 117 | value: string | string[], 118 | format: NormalArrayFormats | SpecialArrayFormats | TagSpecificArrayFormats, 119 | defaultEscapeCharacter: QuoteCharacter, 120 | removeEscapeCharactersIfPossibleWhenGoingToMultiLine: boolean, 121 | escapeNumericValues: boolean = false 122 | ): string { 123 | if (typeof value === "string") { 124 | value = [value]; 125 | } 126 | 127 | // handle default values here 128 | if (value == null || value.length === 0) { 129 | return getDefaultYAMLArrayValue(format); 130 | } 131 | 132 | // handle escaping numeric values and the removal of escape characters where applicable for multiline arrays 133 | const shouldRemoveEscapeCharactersIfPossible = 134 | removeEscapeCharactersIfPossibleWhenGoingToMultiLine && 135 | (format == NormalArrayFormats.MultiLine || 136 | (format == SpecialArrayFormats.SingleStringToMultiLine && 137 | value.length > 1)); 138 | if (escapeNumericValues || shouldRemoveEscapeCharactersIfPossible) { 139 | for (let i = 0; i < value.length; i++) { 140 | let currentValue = value[i]; 141 | // @ts-ignore 142 | const valueIsEscaped = isValueEscapedAlready(currentValue); 143 | if (valueIsEscaped) { 144 | // @ts-ignore 145 | currentValue = currentValue.substring( 146 | 1, 147 | // @ts-ignore 148 | currentValue.length - 1 149 | ); 150 | } 151 | 152 | const shouldRequireEscapeOfCurrentValue = 153 | // @ts-ignore 154 | escapeNumericValues && isNumeric(currentValue); 155 | if (valueIsEscaped && shouldRequireEscapeOfCurrentValue) { 156 | continue; // when dealing with numbers that we need escaped, we don't want to remove that escaping for multiline arrays 157 | } else if ( 158 | shouldRequireEscapeOfCurrentValue || 159 | (valueIsEscaped && shouldRemoveEscapeCharactersIfPossible) 160 | ) { 161 | value[i] = escapeStringIfNecessaryAndPossible( 162 | // @ts-ignore 163 | currentValue, 164 | defaultEscapeCharacter, 165 | shouldRequireEscapeOfCurrentValue 166 | ); 167 | } 168 | } 169 | } 170 | 171 | // handle the values that are present based on the format of the array 172 | /* eslint-disable no-fallthrough -- we are falling through here because it makes the most sense for the cases below */ 173 | switch (format) { 174 | case SpecialArrayFormats.SingleStringToSingleLine: 175 | if (value.length === 1) { 176 | return " " + value[0]; 177 | } 178 | case NormalArrayFormats.SingleLine: 179 | return " " + convertStringArrayToSingleLineArray(value); 180 | case SpecialArrayFormats.SingleStringToMultiLine: 181 | if (value.length === 1) { 182 | return " " + value[0]; 183 | } 184 | case NormalArrayFormats.MultiLine: 185 | return convertStringArrayToMultilineArray(value); 186 | case TagSpecificArrayFormats.SingleStringSpaceDelimited: 187 | if (value.length === 1) { 188 | return " " + value[0]; 189 | } 190 | 191 | return " " + value.join(" "); 192 | case SpecialArrayFormats.SingleStringCommaDelimited: 193 | if (value.length === 1) { 194 | return " " + value[0]; 195 | } 196 | 197 | return " " + value.join(", "); 198 | case TagSpecificArrayFormats.SingleLineSpaceDelimited: 199 | if (value.length === 1) { 200 | return " " + value[0]; 201 | } 202 | 203 | return ( 204 | " " + 205 | convertStringArrayToSingleLineArray(value).replaceAll(", ", " ") 206 | ); 207 | } 208 | /* eslint-enable no-fallthrough */ 209 | } 210 | 211 | function getDefaultYAMLArrayValue( 212 | format: NormalArrayFormats | SpecialArrayFormats | TagSpecificArrayFormats 213 | ): string { 214 | /* eslint-disable no-fallthrough */ 215 | switch (format) { 216 | case NormalArrayFormats.SingleLine: 217 | case TagSpecificArrayFormats.SingleLineSpaceDelimited: 218 | case NormalArrayFormats.MultiLine: 219 | return " []"; 220 | case SpecialArrayFormats.SingleStringToSingleLine: 221 | case SpecialArrayFormats.SingleStringToMultiLine: 222 | case TagSpecificArrayFormats.SingleStringSpaceDelimited: 223 | case SpecialArrayFormats.SingleStringCommaDelimited: 224 | return " "; 225 | } 226 | /* eslint-enable no-fallthrough */ 227 | } 228 | 229 | function convertStringArrayToSingleLineArray(arrayItems: string[]): string { 230 | if (arrayItems == null || arrayItems.length === 0) { 231 | return "[]"; 232 | } 233 | 234 | return "[" + arrayItems.join(", ") + "]"; 235 | } 236 | 237 | function convertStringArrayToMultilineArray(arrayItems: string[]): string { 238 | if (arrayItems == null || arrayItems.length === 0) { 239 | return "[]"; 240 | } 241 | 242 | return "\n - " + arrayItems.join("\n - "); 243 | } 244 | 245 | /** 246 | * Parses single-line and multi-line arrays into an array that can be used for formatting down the line 247 | * @param {string} value The value to see about parsing if it is a sing-line or multi-line array 248 | * @return The original value if it was not a single or multi-line array or the an array of the values from the array (multi-line arrays will have empty values removed) 249 | */ 250 | export function splitValueIfSingleOrMultilineArray(value: string) { 251 | if (value == null || value.length === 0) { 252 | return null; 253 | } 254 | 255 | value = value.trimEnd(); 256 | if (value.startsWith("[")) { 257 | value = value.substring(1); 258 | 259 | if (value.endsWith("]")) { 260 | value = value.substring(0, value.length - 1); 261 | } 262 | 263 | // accounts for an empty single line array which can then be converted as needed later on 264 | if (value.length === 0) { 265 | return null; 266 | } 267 | 268 | const arrayItems = convertYAMLStringToArray(value, ","); 269 | if (!arrayItems) return null; 270 | return arrayItems.filter((el: string) => { 271 | return el != ""; 272 | }); 273 | } 274 | 275 | if (value.includes("\n")) { 276 | let arrayItems = value.split(/[ \t]*\n[ \t]*-[ \t]*/); 277 | arrayItems.splice(0, 1); 278 | 279 | arrayItems = arrayItems.filter((el: string) => { 280 | return el != ""; 281 | }); 282 | 283 | if (arrayItems == null || arrayItems.length === 0) { 284 | return null; 285 | } 286 | 287 | return arrayItems; 288 | } 289 | 290 | return value; 291 | } 292 | 293 | /** 294 | * Converts the tag string to the proper split up values based on whether or not it is already an array and if it has delimiters. 295 | * @param {string | string[]} value The value that is already good to go or needs to be split on a comma or spaces. 296 | * @return {string} The converted tag key value that should account for its obsidian formats. 297 | */ 298 | export function convertTagValueToStringOrStringArray(value: string | string[]) { 299 | if (value == null) { 300 | return []; 301 | } 302 | 303 | const tags: string[] = []; 304 | let originalTagValues: string[] = []; 305 | if (Array.isArray(value)) { 306 | originalTagValues = value; 307 | } else if (value.includes(",")) { 308 | originalTagValues = convertYAMLStringToArray(value, ",") ?? []; 309 | } else { 310 | originalTagValues = convertYAMLStringToArray(value, " ") ?? []; 311 | } 312 | 313 | for (const tagValue of originalTagValues) { 314 | tags.push(tagValue.trim()); 315 | } 316 | 317 | return tags; 318 | } 319 | 320 | /** 321 | * Converts the alias over to the appropriate array items for formatting taking into account obsidian formats. 322 | * @param {string | string[]} value The value of the aliases key that may need to be split into the appropriate parts. 323 | */ 324 | export function convertAliasValueToStringOrStringArray( 325 | value: string | string[] 326 | ) { 327 | if (typeof value === "string") { 328 | return convertYAMLStringToArray(value, ","); 329 | } 330 | 331 | return value; 332 | } 333 | 334 | export function convertYAMLStringToArray( 335 | value: string, 336 | delimiter: string = "," 337 | ) { 338 | if (value == "" || value == null) { 339 | return null; 340 | } 341 | 342 | if (delimiter.length > 1) { 343 | throw new Error( 344 | `The delimiter provided is not a single character: ${delimiter}` 345 | ); 346 | } 347 | 348 | const arrayItems: string[] = []; 349 | let currentItem = ""; 350 | let index = 0; 351 | while (index < value.length) { 352 | const currentChar = value.charAt(index); 353 | 354 | if (currentChar === delimiter) { 355 | // case where you find a delimiter 356 | arrayItems.push(currentItem.trim()); 357 | currentItem = ""; 358 | } else if (currentChar === '"' || currentChar === "'") { 359 | // if there is an escape character check to see if there is a closing escape character and if so, skip to it as the next part of the value 360 | const endOfEscapedValue = value.indexOf(currentChar, index + 1); 361 | if (endOfEscapedValue != -1) { 362 | currentItem += value.substring(index, endOfEscapedValue + 1); 363 | index = endOfEscapedValue; 364 | } else { 365 | currentItem += currentChar; 366 | } 367 | } else { 368 | currentItem += currentChar; 369 | } 370 | 371 | index++; 372 | } 373 | 374 | if (currentItem.trim() != "") { 375 | arrayItems.push(currentItem.trim()); 376 | } 377 | 378 | return arrayItems; 379 | } 380 | 381 | /** 382 | * Returns whether or not the YAML string value is already escaped 383 | * @param {string} value The YAML string to check if it is already escaped 384 | * @return {boolean} Whether or not the YAML string value is already escaped 385 | */ 386 | export function isValueEscapedAlready(value: string): boolean { 387 | return ( 388 | value.length > 1 && 389 | ((value.startsWith("'") && value.endsWith("'")) || 390 | (value.startsWith('"') && value.endsWith('"'))) 391 | ); 392 | } 393 | 394 | /** 395 | * Escapes the provided string value if it has a colon with a space after it, a single quote, or a double quote, but not a single and double quote. 396 | * @param {string} value The value to escape if possible 397 | * @param {string} defaultEscapeCharacter The character escape to use around the value if a specific escape character is not needed. 398 | * @param {boolean} forceEscape Whether or not to force the escaping of the value provided. 399 | * @param {boolean} skipValidation Whether or not to ensure that the result string could be unescaped back to the value. 400 | * @return {string} The escaped value if it is either necessary or forced and the provided value if it cannot be escaped, is escaped, 401 | * or does not need escaping and the force escape is not used. 402 | */ 403 | export function escapeStringIfNecessaryAndPossible( 404 | value: string, 405 | defaultEscapeCharacter: QuoteCharacter, 406 | forceEscape: boolean = false, 407 | skipValidation: boolean = false 408 | ): string { 409 | const basicEscape = basicEscapeString( 410 | value, 411 | defaultEscapeCharacter, 412 | forceEscape 413 | ); 414 | if (skipValidation) { 415 | return basicEscape; 416 | } 417 | 418 | try { 419 | const unescaped = load(basicEscape) as string; 420 | if (unescaped === value) { 421 | return basicEscape; 422 | } 423 | } catch { 424 | // invalid YAML 425 | } 426 | 427 | const escapeWithDefaultCharacter = dump(value, { 428 | lineWidth: -1, 429 | quotingType: defaultEscapeCharacter, 430 | forceQuotes: forceEscape, 431 | }).slice(0, -1); 432 | 433 | const escapeWithOtherCharacter = dump(value, { 434 | lineWidth: -1, 435 | quotingType: defaultEscapeCharacter == '"' ? "'" : '"', 436 | forceQuotes: forceEscape, 437 | }).slice(0, -1); 438 | 439 | if ( 440 | escapeWithOtherCharacter === value || 441 | escapeWithOtherCharacter.length < escapeWithDefaultCharacter.length 442 | ) { 443 | return escapeWithOtherCharacter; 444 | } 445 | 446 | return escapeWithDefaultCharacter; 447 | } 448 | 449 | function basicEscapeString( 450 | value: string, 451 | defaultEscapeCharacter: QuoteCharacter, 452 | forceEscape: boolean = false 453 | ): string { 454 | if (isValueEscapedAlready(value)) { 455 | return value; 456 | } 457 | 458 | // if there is no single quote, double quote, or colon to escape, skip this substring 459 | const substringHasSingleQuote = value.includes("'"); 460 | const substringHasDoubleQuote = value.includes('"'); 461 | const substringHasColonWithSpaceAfterIt = value.includes(": "); 462 | if ( 463 | !substringHasSingleQuote && 464 | !substringHasDoubleQuote && 465 | !substringHasColonWithSpaceAfterIt && 466 | !forceEscape 467 | ) { 468 | return value; 469 | } 470 | 471 | // if the substring already has a single quote and a double quote, there is nothing that can be done to escape the substring 472 | if (substringHasSingleQuote && substringHasDoubleQuote) { 473 | return value; 474 | } 475 | 476 | if (substringHasSingleQuote) { 477 | return `"${value}"`; 478 | } else if (substringHasDoubleQuote) { 479 | return `'${value}'`; 480 | } 481 | 482 | // the line must have a colon with a space 483 | return `${defaultEscapeCharacter}${value}${defaultEscapeCharacter}`; 484 | } 485 | 486 | export const splitYamlAndBody = (markdown: string) => { 487 | const parts = markdown.split(/^---$/m); 488 | if (!markdown.startsWith("---") || parts.length === 1) { 489 | return { 490 | yaml: undefined, 491 | body: markdown, 492 | }; 493 | } 494 | if (parts.length < 3) { 495 | return { 496 | yaml: parts[1] as string, 497 | body: parts[2] ?? "", 498 | }; 499 | } 500 | return { 501 | yaml: parts[1], 502 | body: parts.slice(2).join("---"), 503 | }; 504 | }; 505 | 506 | export const isValidFrontmatter = (data: Data) => { 507 | const { yamlText, text } = data; 508 | 509 | if (!yamlText) { 510 | const textAfterTrimFirstSpace = trimFirstSpaces(text); 511 | // try to get new yaml text 512 | const yamlText = getYAMLText(textAfterTrimFirstSpace); 513 | if (yamlText) { 514 | // console.log( 515 | // "frontmatter is not valid because there is a space before the yaml" 516 | // ); 517 | return false; 518 | } 519 | } 520 | if (data.text && !data.yamlText && !data.body) return false; 521 | 522 | return true; 523 | }; 524 | --------------------------------------------------------------------------------