chore: use update-editor from obsidian-linter for more reliable editor updates

This commit is contained in:
Kodai Nakamura 2025-12-06 16:41:27 +09:00
parent 62bae4a818
commit a2fe71c829
5 changed files with 170 additions and 91 deletions

View file

@ -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.

View file

@ -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"
}
}

View file

@ -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

View file

@ -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);

58
src/update-editor.ts Normal file
View file

@ -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;
}