From a2fe71c8294ec05d8522b43744644cf495a7d102 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Sat, 6 Dec 2025 16:41:27 +0900 Subject: [PATCH] chore: use update-editor from obsidian-linter for more reliable editor updates --- README.md | 12 ++-- package.json | 5 +- pnpm-lock.yaml | 19 +++++ src/main.ts | 167 +++++++++++++++++++++---------------------- src/update-editor.ts | 58 +++++++++++++++ 5 files changed, 170 insertions(+), 91 deletions(-) create mode 100644 src/update-editor.ts diff --git a/README.md b/README.md index fba27fb..8942f89 100644 --- a/README.md +++ b/README.md @@ -58,10 +58,10 @@ Automatic Linker Plugin automatically converts plain text file references into O - **Replace Bare URLs with Page Titles:** Finds bare URLs (like `https://example.com`) in your notes, fetches the title of the web page, and replaces the URL with a Markdown link `[Page Title](URL)`. - - Triggered manually via a command. - - Fetched titles are cached to minimize network requests. - - Ignores URLs already in Markdown links, angle brackets, or code blocks. - - Supports an internal list of domains to ignore (not yet configurable via UI). + - Triggered manually via a command. + - Fetched titles are cached to minimize network requests. + - Ignores URLs already in Markdown links, angle brackets, or code blocks. + - Supports an internal list of domains to ignore (not yet configurable via UI). ## Usage with Obsidian Linter @@ -70,3 +70,7 @@ To use this plugin with Obsidian Linter, ensure that the "Lint on Save" option i 1. Disable "Lint on Save" in Obsidian Linter. 2. Enable "Format on Save" in Automatic Linker settings. 3. Enable "Run Obsidian Linter before formatting" in Automatic Linker settings. + +## Thanks + +- `updateEditor` function comes from https://github.com/platers/obsidian-linter. diff --git a/package.json b/package.json index c80e6ad..627f953 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "author": "", "license": "Appache-2.0", "devDependencies": { + "@types/diff-match-patch": "^1.0.36", "@types/node": "^22.12.0", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", @@ -31,7 +32,9 @@ "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "dependencies": { + "@codemirror/state": "^6.5.2", "@types/async-lock": "^1.4.2", - "async-lock": "^1.4.1" + "async-lock": "^1.4.1", + "diff-match-patch": "^1.0.5" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a24af6..d26a497 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,13 +8,22 @@ importers: .: dependencies: + '@codemirror/state': + specifier: ^6.5.2 + version: 6.5.2 '@types/async-lock': specifier: ^1.4.2 version: 1.4.2 async-lock: specifier: ^1.4.1 version: 1.4.1 + diff-match-patch: + specifier: ^1.0.5 + version: 1.0.5 devDependencies: + '@types/diff-match-patch': + specifier: ^1.0.36 + version: 1.0.36 '@types/node': specifier: ^22.12.0 version: 22.15.17 @@ -925,6 +934,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/diff-match-patch@1.0.36': + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + '@types/earcut@2.1.4': resolution: {integrity: sha512-qp3m9PPz4gULB9MhjGID7wpo3gJ4bTGXm7ltNDsmOvsPduTeHp8wSW9YckBj3mljeOh4F0m2z/0JKAALRKbmLQ==} @@ -1397,6 +1409,9 @@ packages: detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -3057,6 +3072,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/diff-match-patch@1.0.36': {} + '@types/earcut@2.1.4': {} '@types/estree@1.0.7': {} @@ -3578,6 +3595,8 @@ snapshots: detect-node@2.1.0: optional: true + diff-match-patch@1.0.5: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 diff --git a/src/main.ts b/src/main.ts index dfd4b43..e4d65a9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,8 @@ import { App, + Editor, getFrontMatterInfo, + MarkdownView, Notice, parseFrontMatterAliases, Plugin, @@ -23,6 +25,7 @@ import { DEFAULT_SETTINGS, } from "./settings/settings-info"; import { buildCandidateTrie, CandidateData, TrieNode } from "./trie"; +import { updateEditor } from "./update-editor"; const sleep = (ms: number) => { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -41,6 +44,12 @@ export default class AutomaticLinkerPlugin extends Plugin { super(app, pluginManifest); } + private getEditor(): Editor | null { + const activeLeaf = this.app.workspace.getActiveViewOfType(MarkdownView); + if (!activeLeaf) return null; + return activeLeaf.editor; + } + async modifyLinks() { const activeFile = this.app.workspace.getActiveFile(); if (!activeFile) { @@ -54,85 +63,89 @@ export default class AutomaticLinkerPlugin extends Plugin { return; } - await this.app.vault.process(activeFile, (fileContent) => { - if (this.settings.formatGitHubURLs) { - fileContent = replaceURLs( - fileContent, - this.settings, - formatGitHubURL, - ); - } + const editor = this.getEditor(); + if (!editor) return; - if (this.settings.formatJiraURLs) { - fileContent = replaceURLs( - fileContent, - this.settings, - formatJiraURL, - ); - } + let fileContent = editor.getValue(); + const oldText = fileContent; + if (this.settings.formatGitHubURLs) { + fileContent = replaceURLs( + fileContent, + this.settings, + formatGitHubURL, + ); + } - if (this.settings.formatLinearURLs) { - fileContent = replaceURLs( - fileContent, - this.settings, - formatLinearURL, - ); - } + if (this.settings.formatJiraURLs) { + fileContent = replaceURLs( + fileContent, + this.settings, + formatJiraURL, + ); + } - if (this.settings.replaceUrlWithTitle) { - const { contentStart } = getFrontMatterInfo(fileContent); - const frontmatter = fileContent.slice(0, contentStart); - const body = fileContent.slice(contentStart); - const updatedBody = replaceUrlWithTitle({ - body, - urlTitleMap: this.urlTitleMap, - }); - fileContent = frontmatter + updatedBody; - } - - if (!this.trie || !this.candidateMap) { - return fileContent; - } - - if (this.settings.debug) { - console.log("this.trie: ", this.trie); - console.log("this.candidateMap: ", this.candidateMap); - console.log(new Date().toISOString(), "modifyLinks started"); - new Notice( - `Automatic Linker: ${new Date().toISOString()} modifyLinks started.`, - ); - } + if (this.settings.formatLinearURLs) { + fileContent = replaceURLs( + fileContent, + this.settings, + formatLinearURL, + ); + } + if (this.settings.replaceUrlWithTitle) { const { contentStart } = getFrontMatterInfo(fileContent); const frontmatter = fileContent.slice(0, contentStart); - const updatedBody = replaceLinks({ - body: fileContent.slice(contentStart), - linkResolverContext: { - filePath: activeFile.path.replace(/\.md$/, ""), - trie: this.trie, - candidateMap: this.candidateMap, - }, - settings: { - minCharCount: this.settings.minCharCount, - namespaceResolution: this.settings.namespaceResolution, - baseDir: this.settings.baseDir, - ignoreDateFormats: this.settings.ignoreDateFormats, - ignoreCase: this.settings.ignoreCase, - preventSelfLinking: this.settings.preventSelfLinking, - removeAliasInDirs: this.settings.removeAliasInDirs, - }, + const body = fileContent.slice(contentStart); + const updatedBody = replaceUrlWithTitle({ + body, + urlTitleMap: this.urlTitleMap, }); fileContent = frontmatter + updatedBody; + } - if (this.settings.debug) { - console.log(new Date().toISOString(), "modifyLinks finished"); - new Notice( - `Automatic Linker: ${new Date().toISOString()} modifyLinks finished.`, - ); - } - + if (!this.trie || !this.candidateMap) { return fileContent; + } + + if (this.settings.debug) { + console.log("this.trie: ", this.trie); + console.log("this.candidateMap: ", this.candidateMap); + console.log(new Date().toISOString(), "modifyLinks started"); + new Notice( + `Automatic Linker: ${new Date().toISOString()} modifyLinks started.`, + ); + } + + const { contentStart } = getFrontMatterInfo(fileContent); + const frontmatter = fileContent.slice(0, contentStart); + const updatedBody = replaceLinks({ + body: fileContent.slice(contentStart), + linkResolverContext: { + filePath: activeFile.path.replace(/\.md$/, ""), + trie: this.trie, + candidateMap: this.candidateMap, + }, + settings: { + minCharCount: this.settings.minCharCount, + namespaceResolution: this.settings.namespaceResolution, + baseDir: this.settings.baseDir, + ignoreDateFormats: this.settings.ignoreDateFormats, + ignoreCase: this.settings.ignoreCase, + preventSelfLinking: this.settings.preventSelfLinking, + removeAliasInDirs: this.settings.removeAliasInDirs, + }, }); + fileContent = frontmatter + updatedBody; + + if (this.settings.debug) { + console.log(new Date().toISOString(), "modifyLinks finished"); + new Notice( + `Automatic Linker: ${new Date().toISOString()} modifyLinks finished.`, + ); + } + + const newText = fileContent; + updateEditor(oldText, newText, editor); } async buildUrlTitleMap() { @@ -379,24 +392,6 @@ export default class AutomaticLinkerPlugin extends Plugin { this.originalSaveCallback = saveCallback; } - const getUrls = async () => { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) return; - const fileContent = await this.app.vault.read(activeFile); - const { contentStart } = getFrontMatterInfo(fileContent); - const body = fileContent.slice(contentStart); - - const urls = listupAllUrls( - body, - this.settings.replaceUrlWithTitleIgnoreDomains, - ); - for (const url of urls) { - const response = await request(url); - const title = getTitleFromHtml(response); - this.urlTitleMap.set(url, title); - } - }; - saveCommandDefinition.checkCallback = async (checking: boolean) => { if (checking) { return saveCallback?.(checking); diff --git a/src/update-editor.ts b/src/update-editor.ts new file mode 100644 index 0000000..293a264 --- /dev/null +++ b/src/update-editor.ts @@ -0,0 +1,58 @@ +import { ChangeSpec } from "@codemirror/state"; +import { Editor } from "obsidian"; +import DiffMatchPatch from "diff-match-patch"; + +function endOfDocument(doc: string) { + const lines = doc.split("\n"); + return { line: lines.length - 1, ch: lines[lines.length - 1].length }; +} + +export function updateEditor( + oldText: string, + newText: string, + editor: Editor, +): DiffMatchPatch.Diff[] { + const dmp = new DiffMatchPatch.diff_match_patch(); // eslint-disable-line new-cap + const changes = dmp.diff_main(oldText, newText); + let curText = ""; + changes.forEach((change) => { + const [type, value] = change; + + if (type == DiffMatchPatch.DIFF_INSERT) { + // use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview + //@ts-expect-error + editor.cm.dispatch({ + changes: [ + { + from: editor.posToOffset(endOfDocument(curText)), + insert: value, + } as ChangeSpec, + ], + filter: false, + }); + curText += value; + } else if (type == DiffMatchPatch.DIFF_DELETE) { + const start = endOfDocument(curText); + let tempText = curText; + tempText += value; + const end = endOfDocument(tempText); + + // use codemirror dispatch in order to bypass the filter on transactions that causes editor.replaceRange not to not work in Live Preview + //@ts-expect-error + editor.cm.dispatch({ + changes: [ + { + from: editor.posToOffset(start), + to: editor.posToOffset(end), + insert: "", + } as ChangeSpec, + ], + filter: false, + }); + } else { + curText += value; + } + }); + + return changes; +}