From e27aef025a75a2d53e8d9ff4c6bf6446ecc8c70e Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Sun, 9 Feb 2025 15:38:20 +0900 Subject: [PATCH] perf: improve performance for namespace resolution --- package-lock.json | 22 +++++++ package.json | 2 + src/main.ts | 115 +++++++++++++++-------------------- src/replace-links.ts | 141 +++++++++++++++++++------------------------ 4 files changed, 135 insertions(+), 145 deletions(-) diff --git a/package-lock.json b/package-lock.json index 726b893..a877910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.9.0", "license": "MIT", "dependencies": { + "@types/async-lock": "^1.4.2", + "async-lock": "^1.4.1", "just-throttle": "^4.2.0" }, "devDependencies": { @@ -534,6 +536,11 @@ "node": ">=10" } }, + "node_modules/@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==" + }, "node_modules/@types/cacheable-request": { "version": "6.0.3", "dev": true, @@ -991,6 +998,11 @@ "node": ">=12" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -3776,6 +3788,11 @@ "defer-to-connect": "^2.0.0" } }, + "@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==" + }, "@types/cacheable-request": { "version": "6.0.3", "dev": true, @@ -4059,6 +4076,11 @@ "version": "2.0.1", "dev": true }, + "async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==" + }, "balanced-match": { "version": "1.0.2", "dev": true, diff --git a/package.json b/package.json index f132c04..d72ecef 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "dependencies": { + "@types/async-lock": "^1.4.2", + "async-lock": "^1.4.1", "just-throttle": "^4.2.0" } } diff --git a/src/main.ts b/src/main.ts index b20c7ec..439ab9b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { Plugin, PluginManifest, } from "obsidian"; +import AsyncLock from "async-lock"; import { replaceLinks } from "./replace-links"; import { AutomaticLinkerPluginSettingsTab, @@ -37,77 +38,45 @@ export default class AutomaticLinkerPlugin extends Plugin { // Cancelable function to modify links in the active file async modifyLinks() { - // If a previous task is running, cancel it first. - if (this.currentModifyLinks) { - this.currentModifyLinks.cancel(); + const activeFile = this.app.workspace.getActiveFile(); + if (!activeFile) { + return; } - const cancelableTask = new PCancelable( - async (resolve, reject, onCancel) => { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) { - resolve(); - return; - } + try { + // Read the current file content + const fileContent = ( + await this.app.vault.read(activeFile) + ).normalize("NFC"); + // Save a backup before making any modifications + this.backupContent.set(activeFile.path, fileContent); - let canceled = false; - onCancel(() => { - canceled = true; - }); + console.log(new Date().toISOString(), "modifyLinks started"); - try { - // Read the current file content - const fileContent = ( - await this.app.vault.read(activeFile) - ).normalize("NFC"); - // Save a backup before making any modifications - this.backupContent.set(activeFile.path, fileContent); + // Use the pre-built trie and candidateMap to replace links. + // Fallback to an empty trie if not built. + const { contentStart } = getFrontMatterInfo(fileContent); + const updatedContent = await replaceLinks({ + body: fileContent.slice(contentStart), + frontmatter: fileContent.slice(0, contentStart), + linkResolverContext: { + filePath: activeFile.path.replace(/\.md$/, ""), + trie: this.trie ?? buildCandidateTrie([]).trie, + candidateMap: this.candidateMap ?? new Map(), + }, + settings: { + minCharCount: this.settings.minCharCount, + namespaceResolution: this.settings.namespaceResolution, + }, + }); - if (canceled) { - return reject(new PCancelable.CancelError()); - } - console.log( - new Date().toISOString(), - "modifyLinks started", - ); + console.log(new Date().toISOString(), "modifyLinks finished"); - // Use the pre-built trie and candidateMap to replace links. - // Fallback to an empty trie if not built. - const { contentStart } = getFrontMatterInfo(fileContent); - const updatedContent = await replaceLinks({ - body: fileContent.slice(contentStart), - frontmatter: fileContent.slice(0, contentStart), - linkResolverContext: { - filePath: activeFile.path.replace(/\.md$/, ""), - trie: this.trie ?? buildCandidateTrie([]).trie, - candidateMap: this.candidateMap ?? new Map(), - }, - settings: { - minCharCount: this.settings.minCharCount, - namespaceResolution: - this.settings.namespaceResolution, - }, - }); - - console.log( - new Date().toISOString(), - "modifyLinks finished", - ); - - if (canceled) { - return reject(new PCancelable.CancelError()); - } - // Overwrite the file with the updated content. - await this.app.vault.modify(activeFile, updatedContent); - resolve(); - } catch (error) { - reject(error); - } - }, - ); - - this.currentModifyLinks = cancelableTask; - return cancelableTask; + // Overwrite the file with the updated content. + await this.app.vault.modify(activeFile, updatedContent); + } catch (error) { + // noop + } } async onload() { @@ -220,6 +189,14 @@ export default class AutomaticLinkerPlugin extends Plugin { }, }); + const lock = new AsyncLock(); + const safeWrite = ( + key: string, + writeOperation: () => Promise, + ): Promise => { + return lock.acquire(key, writeOperation); + }; + // Optionally, override the default save command to run modifyLinks (throttled). const saveCommandDefinition = // @ts-expect-error @@ -245,8 +222,12 @@ export default class AutomaticLinkerPlugin extends Plugin { { leading: true }, ); saveCommandDefinition.callback = async () => { - await throttledModifyLinks(); - await save?.(); + safeWrite("save", async () => { + await throttledModifyLinks(); + }); + safeWrite("save", async () => { + await save?.(); + }); }; } } diff --git a/src/replace-links.ts b/src/replace-links.ts index 0a9d6f0..f3d5e6c 100644 --- a/src/replace-links.ts +++ b/src/replace-links.ts @@ -29,14 +29,13 @@ export const replaceLinks = async ({ return frontmatter + body; } - // Determines whether a character is a word boundary. - // (Returns true if the character is undefined or does not match letters, numbers, underscore, slash, or hyphen.) + // Utility: returns true if a character is a word boundary. const isWordBoundary = (char: string | undefined): boolean => { if (char === undefined) return true; return !/[\p{L}\p{N}_/-]/u.test(char); }; - // Determines if the candidate string represents a month note. + // Utility: returns true if the candidate string represents a month note. const isMonthNote = (candidate: string): boolean => !candidate.includes("/") && /^[0-9]{1,2}$/.test(candidate) && @@ -55,9 +54,24 @@ export const replaceLinks = async ({ return frontmatter + body; } + // Precompute fallbackIndex: a mapping from shorthand (the part after the last "/") + // to an array of candidateMap entries. + const fallbackIndex = new Map>(); + for (const [key, data] of candidateMap.entries()) { + const slashIndex = key.lastIndexOf("/"); + if (slashIndex === -1) continue; + const shorthand = key.slice(slashIndex + 1); + let arr = fallbackIndex.get(shorthand); + if (!arr) { + arr = []; + fallbackIndex.set(shorthand, arr); + } + arr.push([key, data]); + } + /** - * Returns the effective namespace for the given file path. - * If the filePath starts with one of the baseDirs (e.g. "pages/"), then the directory + * Returns the effective namespace for a given file path. + * If the file path starts with one of the baseDirs (e.g. "pages/"), then the directory * immediately under the baseDir is considered the effective namespace. */ const getEffectiveNamespace = ( @@ -76,7 +90,7 @@ export const replaceLinks = async ({ return segments[0] || ""; }; - // Compute current file's effective namespace using settings.baseDirs if provided. + // Compute the current file's effective namespace. const currentNamespace = settings.baseDirs ? getEffectiveNamespace(filePath, settings.baseDirs) : (function () { @@ -84,7 +98,7 @@ export const replaceLinks = async ({ return segments[0] || ""; })(); - // Helper function to process a plain text segment. + // Helper function to process an unprotected segment. const replaceInSegment = (text: string): string => { let result = ""; let i = 0; @@ -116,7 +130,6 @@ export const replaceLinks = async ({ j++; } if (lastCandidate) { - // Get the candidate string matched from the current position. const candidate = text.substring(i, i + lastCandidate.length); // Skip conversion for month notes. if (isMonthNote(candidate)) { @@ -135,7 +148,6 @@ export const replaceLinks = async ({ // For non-CJK or Korean candidates, perform word boundary checks. if (!isCjkCandidate || isKorean) { if (isKorean) { - // Check for common Korean particle suffix (e.g. "이다" optionally with a period). const remaining = text.slice(i + candidate.length); const suffixMatch = remaining.match(/^(이다\.?)/); if (suffixMatch) { @@ -157,9 +169,7 @@ export const replaceLinks = async ({ continue outer; } } - // For non-Korean CJK candidates, no boundary check is applied. - // Additionally, if namespace resolution is enabled and the candidate’s file is restricted, - // only replace if the current file's effective namespace matches. + // If namespace resolution is enabled and candidate is restricted, check namespaces. if ( settings.namespaceResolution && candidateData.restrictNamespace && @@ -176,7 +186,7 @@ export const replaceLinks = async ({ } } - // Fallback: namespace resolution if enabled. + // Fallback: if no candidate was found via the Trie. if (settings.namespaceResolution) { const fallbackRegex = /^([\p{L}\p{N}_-]+)/u; const fallbackMatch = text.slice(i).match(fallbackRegex); @@ -197,54 +207,35 @@ export const replaceLinks = async ({ continue; } - // Check for a single candidate match. - { - let count = 0; - let singleCandidate: CandidateData | undefined; - for (const [key, data] of candidateMap.entries()) { - const slashIndex = key.lastIndexOf("/"); - if (slashIndex !== -1) { - const shorthand = key.slice(slashIndex + 1); - if (shorthand === word) { - if ( - data.restrictNamespace && - data.namespace !== currentNamespace - ) { - continue; - } - count++; - singleCandidate = data; - } - } - } - if (count === 1 && singleCandidate !== undefined) { - result += `[[${singleCandidate.canonical}]]`; - i += word.length; - continue; - } - } - - // Otherwise, try to resolve namespaces by selecting the best candidate. - let bestCandidateKey: string | null = null; - let bestCandidateData: CandidateData | null = null; - let bestScore = -1; - const filePathDir = filePath.includes("/") - ? filePath.slice(0, filePath.lastIndexOf("/")) - : ""; - const useLongerCandidate = filePathDir === ""; - const filePathSegments = filePathDir - ? filePathDir.split("/") - : []; - for (const [key, data] of candidateMap.entries()) { - const slashIndex = key.lastIndexOf("/"); - if (slashIndex !== -1) { - const shorthand = key.slice(slashIndex + 1); - if (shorthand === word) { - if ( + // Quickly retrieve matching candidate entries using fallbackIndex. + const candidateList = fallbackIndex.get(word); + if (candidateList) { + const filteredCandidates = candidateList.filter( + ([, data]) => + !( data.restrictNamespace && data.namespace !== currentNamespace - ) - continue; + ), + ); + + if (filteredCandidates.length === 1) { + const candidateData = filteredCandidates[0][1]; + result += `[[${candidateData.canonical}]]`; + i += word.length; + continue outer; + } else if (filteredCandidates.length > 1) { + let bestCandidate: [string, CandidateData] | null = + null; + let bestScore = -1; + const filePathDir = filePath.includes("/") + ? filePath.slice(0, filePath.lastIndexOf("/")) + : ""; + const useLongerCandidate = filePathDir === ""; + const filePathSegments = filePathDir + ? filePathDir.split("/") + : []; + for (const [key, data] of filteredCandidates) { + const slashIndex = key.lastIndexOf("/"); const candidateDir = key.slice(0, slashIndex); const candidateSegments = candidateDir.split("/"); @@ -269,26 +260,22 @@ export const replaceLinks = async ({ } if (score > bestScore) { bestScore = score; - bestCandidateData = data; - bestCandidateKey = key; + bestCandidate = [key, data]; } else if ( score === bestScore && - bestCandidateData !== null && - bestCandidateKey !== null + bestCandidate !== null ) { - // Tie-breaker: if (useLongerCandidate) { if ( - key.length > bestCandidateKey.length + key.length > bestCandidate[0].length ) { - bestCandidateData = data; - bestCandidateKey = key; + bestCandidate = [key, data]; } } else { const currentBestDir = - bestCandidateKey.slice( + bestCandidate[0].slice( 0, - bestCandidateKey.lastIndexOf( + bestCandidate[0].lastIndexOf( "/", ), ); @@ -298,27 +285,25 @@ export const replaceLinks = async ({ candidateSegments.length < currentBestSegments.length ) { - bestCandidateData = data; - bestCandidateKey = key; + bestCandidate = [key, data]; } } } } + if (bestCandidate !== null) { + result += `[[${bestCandidate[1].canonical}]]`; + i += word.length; + continue outer; + } } } - if (bestCandidateData !== null) { - result += `[[${bestCandidateData.canonical}]]`; - i += word.length; - continue outer; - } - result += text[i]; i++; continue; } } - // If no rules apply, output the current character as is. + // If no rule applies, output the current character. result += text[i]; i++; }