├── .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 | 
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#;.,>!=+]+)/g;
22 | export const obsidianMultilineCommentRegex = /^%%\n[^%]*\n%%/gm;
23 | export const wordSplitterRegex = /[,\s]+/;
24 | export const ellipsisRegex = /(\. ?){2}\./g;
25 | export const lineStartingWithWhitespaceOrBlockquoteTemplate = `\\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 |
--------------------------------------------------------------------------------