perf: improve performance for namespace resolution

This commit is contained in:
Kodai Nakamura 2025-02-09 15:38:20 +09:00
parent ee70bf5617
commit e27aef025a
4 changed files with 135 additions and 145 deletions

22
package-lock.json generated
View file

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

View file

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

View file

@ -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<void>(
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<void>,
): Promise<void> => {
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?.();
});
};
}
}

View file

@ -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<string, Array<[string, CandidateData]>>();
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 candidates 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++;
}