mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
perf: improve performance for namespace resolution
This commit is contained in:
parent
ee70bf5617
commit
e27aef025a
4 changed files with 135 additions and 145 deletions
22
package-lock.json
generated
22
package-lock.json
generated
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
115
src/main.ts
115
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<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?.();
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 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++;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue