diff --git a/src/DataviewSuggester.ts b/src/DataviewSuggester.ts index f01b254..ac7b3c9 100644 --- a/src/DataviewSuggester.ts +++ b/src/DataviewSuggester.ts @@ -12,19 +12,17 @@ import { } from "obsidian"; import { getTriggerText } from "./trigger"; import uFuzzy from "@leeoniya/ufuzzy"; -import { getAPI, DataObject } from "obsidian-dataview"; import DataviewAutocompletePlugin from "./main"; +import { SuggestionIndex } from "./SuggestionIndex"; export class DataviewSuggester extends EditorSuggest { plugin: DataviewAutocompletePlugin; maxSuggestions: number; searcher: uFuzzy; + suggestionsIndex: SuggestionIndex; initialized: boolean = false; - suggestionsList: string[] = []; - suggestionsRefs: Map = new Map(); // maps file paths to included suggestions - suggestionsRefCount: Map = new Map(); // maps suggestions to number of files including them constructor( plugin: DataviewAutocompletePlugin, @@ -39,6 +37,7 @@ export class DataviewSuggester extends EditorSuggest { intraMode: singleErrorMode ? 1 : 0, intraIns: allowExtraChars ? 1 : 0, }); + this.suggestionsIndex = new SuggestionIndex(plugin); } override onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null { @@ -56,15 +55,17 @@ export class DataviewSuggester extends EditorSuggest { } override getSuggestions(context: EditorSuggestContext): string[] | Promise { - const idxs = this.searcher.filter(this.suggestionsList, context.query); + const suggestionsList = this.suggestionsIndex.suggestionsList; + + const idxs = this.searcher.filter(suggestionsList, context.query); if (idxs != null && idxs.length > 0) { - let info = this.searcher.info(idxs, this.suggestionsList, context.query); - let order = this.searcher.sort(info, this.suggestionsList, context.query); + let info = this.searcher.info(idxs, suggestionsList, context.query); + let order = this.searcher.sort(info, suggestionsList, context.query); // return top N suggestions with marks return order .slice(0, this.maxSuggestions) - .map((idx) => [idx, this.suggestionsList[info.idx[idx]]]) + .map((idx) => [idx, suggestionsList[info.idx[idx]]]) .map((suggestion: [number, string]) => uFuzzy.highlight(suggestion[1], info.ranges[suggestion[0]])); } return []; @@ -135,7 +136,7 @@ export class DataviewSuggester extends EditorSuggest { } public onDataviewIndexReady() { - this.buildNewIndex(); + this.suggestionsIndex.buildNewIndex(); this.initialized = true; } @@ -145,209 +146,13 @@ export class DataviewSuggester extends EditorSuggest { // console.warn("Dataview Autocompletion index not ready yet. Skipping index update"); return; } - this.updateIndex(type, file, oldPath); + this.suggestionsIndex.updateIndex(type, file, oldPath); } /** - * Converts a Dataview value to a string - * returns the composite value in format "key:: value" + * Trigger a full rebuild of the suggestion index */ - formatCompositeValue(key: string, value: any): string { - const dataviewAPI = getAPI(this.app); - - let stringValue: string; - - // If the value is a string, number or boolean, we can simply convert it to a string - if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { - stringValue = value.toString(); - } else if (dataviewAPI.value.typeOf(value) === "link" && value.type === "file") { - // parse wiki-style links - if (value.display !== undefined) { - stringValue = `[[${value.path.split("/").pop().replace(".md", "")}|${value.display}]]`; - } else { - stringValue = `[[${value.path.split("/").pop().replace(".md", "")}]]`; - } - } else { - stringValue = dataviewAPI.value.toString(value); - } - return `${key}:: ${stringValue}`; - } - - /** - * Filters out composite values from suggestions that match any of the ignored fields. - * @returns true if the value should be shown in suggestions, false otherwise - */ - filterCompositeValue(compositeValue: string): boolean { - for (const filterPattern of this.plugin.settings.ignoredFields) { - const regex = new RegExp(`^(${filterPattern})::.*`); - if (regex.test(compositeValue)) { - return false; - } - } - return true; - } - - filterFile(filepath: string): boolean { - for (const filterPattern of this.plugin.settings.ignoredFiles) { - const regex = new RegExp(filterPattern); - if (regex.test(filepath)) { - return false; - } - } - return true; - } - - extractCompositeValuesFromPage(page: DataObject): string[] { - const fields = Object.keys(page) - .filter((k) => k !== "file") - .map((k) => [k, page[k]]); - - const compositeValues: string[] = []; - - for (let [key, val] of fields) { - // fields can be a single value or a dict, so we need to handle both - let arrayVal; - if (!Array.isArray(val)) { - arrayVal = [val]; - } else { - arrayVal = val; - } - - // Add composite value "key:: value" to suggestions list - for (const value of arrayVal) { - if (value === null || value === undefined) continue; // skip empty fields - - let compositeValue = this.formatCompositeValue(key, value); - compositeValues.push(compositeValue); - if (!this.filterCompositeValue(compositeValue)) { - continue; - } - } - } - return compositeValues; - } - - buildNewIndex() { - console.log("Begin Rebuilding dataview suggestion index"); - const startTime = performance.now(); - const dataviewApi = getAPI(this.app); - - const newSuggestions: string[] = []; - const newSuggestionsRefs: Map = new Map(); - const newSuggestionsRefCount: Map = new Map(); - - const files = this.app.vault.getFiles(); - for (const file of files) { - if (!this.filterFile(file.path)) { - continue; - } - - const page = dataviewApi.page(file.path); - if (page === undefined) continue; // not a markdown file - - const compositeValues = this.extractCompositeValuesFromPage(page); - const pageRefs = []; - - for (let compositeValue of compositeValues) { - if (newSuggestions.indexOf(compositeValue) === -1) { - // suggestion not seen on any page yet - pageRefs.push(compositeValue); - newSuggestionsRefCount.set(compositeValue, 1); - newSuggestions.push(compositeValue); - } else if (pageRefs.indexOf(compositeValue) === -1) { - // suggestion not seen on this page, but on another - pageRefs.push(compositeValue); - newSuggestionsRefCount.set(compositeValue, newSuggestionsRefCount.get(compositeValue)! - 1); - } - } - newSuggestionsRefs.set(file.path, pageRefs); - } - - // replace old index - this.suggestionsList = newSuggestions; - this.suggestionsRefCount = newSuggestionsRefCount; - this.suggestionsRefs = newSuggestionsRefs; - - const endTime = performance.now(); - console.log( - `Rebuilt dataview autocomplete index (${this.suggestionsList.length} elements, ${(endTime - startTime).toFixed(2)}ms)`, - ); - } - - updateIndex(type: string, file: TFile, oldPath?: string) { - // also triggers on create! - if (type === "update") { - if (!this.filterFile(file.path)) { - return; - } - - const page = getAPI(this.app).page(file.path); - if (page === undefined) return; // not a markdown file - const updateCompositeValues = this.extractCompositeValuesFromPage(page); - - const oldCompositeValues = this.suggestionsRefs.get(file.path) || []; - - // deleting value from index if update reduces refcount to 0 - for (const oldCompositeValue of oldCompositeValues) { - if (updateCompositeValues.indexOf(oldCompositeValue) === -1) { - // delete value - this.suggestionsRefCount.set( - oldCompositeValue, - this.suggestionsRefCount.get(oldCompositeValue)! - 1, - ); - if (this.suggestionsRefCount.get(oldCompositeValue) === 0) { - this.suggestionsList.splice(this.suggestionsList.indexOf(oldCompositeValue), 1); - } - } - } - - // adding value to index if not present in index - for (const newCompositeValue of updateCompositeValues) { - if ( - !this.suggestionsRefCount.has(newCompositeValue) || - this.suggestionsRefCount.get(newCompositeValue) === 0 - ) { - // not seen in this or other files - this.suggestionsList.push(newCompositeValue); - this.suggestionsRefCount.set(newCompositeValue, 1); - } else if (oldCompositeValues.indexOf(newCompositeValue) === -1) { - this.suggestionsRefCount.set( - newCompositeValue, - this.suggestionsRefCount.get(newCompositeValue)! + 1, - ); - } - } - this.suggestionsRefs.set(file.path, updateCompositeValues); - } else if (type === "rename") { - if (this.filterFile(file.path) && this.filterFile(oldPath!)) { - // both not ignored -> move refs - this.suggestionsRefs.set(file.path, this.suggestionsRefs.get(oldPath!)!); - this.suggestionsRefs.delete(oldPath!); - } else if (this.filterFile(file.path)) { - // old path ignored, new path not ignored -> upsert - this.updateIndex("update", file, undefined); - } else if (this.filterFile(oldPath!)) { - // old path not ignored, new path ignored -> delete - for (const value of this.suggestionsRefs.get(oldPath!)!) { - this.suggestionsRefCount.set(value, this.suggestionsRefCount.get(value)! - 1); - if (this.suggestionsRefCount.get(value) === 0) { - this.suggestionsList.splice(this.suggestionsList.indexOf(value), 1); - } - } - this.suggestionsRefs.delete(oldPath!); - } - } else if (type === "delete") { - // iterate suggestion refs in deleted file and decrement their ref count - // if the ref count reaches 0, remove the suggestion from the list - for (const value of this.suggestionsRefs.get(file.path)!) { - this.suggestionsRefCount.set(value, this.suggestionsRefCount.get(value)! - 1); - if (this.suggestionsRefCount.get(value) === 0) { - this.suggestionsList.splice(this.suggestionsList.indexOf(value), 1); - } - } - this.suggestionsRefs.delete(file.path); - } else { - console.debug("Unknown update type:", type, file, oldPath); - } + public rebuildIndex() { + this.suggestionsIndex.buildNewIndex(); } } diff --git a/src/SettingsTab.ts b/src/SettingsTab.ts index 385b6aa..aea8502 100644 --- a/src/SettingsTab.ts +++ b/src/SettingsTab.ts @@ -33,7 +33,7 @@ export class SettingsTab extends PluginSettingTab { .map((val) => val.trim()) .filter((val) => val.length > 0); await this.plugin.saveSettings(); - this.plugin.suggester?.buildNewIndex(); + this.plugin.suggester?.rebuildIndex(); }); }); new Setting(containerEl) @@ -50,7 +50,7 @@ export class SettingsTab extends PluginSettingTab { .map((val) => val.trim()) .filter((val) => val.length > 0); await this.plugin.saveSettings(); - this.plugin.suggester?.buildNewIndex(); + this.plugin.suggester?.rebuildIndex(); }); }); } diff --git a/src/SuggestionIndex.ts b/src/SuggestionIndex.ts new file mode 100644 index 0000000..c724386 --- /dev/null +++ b/src/SuggestionIndex.ts @@ -0,0 +1,218 @@ +import { App, TFile } from "obsidian"; +import { DataObject, getAPI } from "obsidian-dataview"; +import DataviewAutocompletePlugin from "./main"; + +export class SuggestionIndex { + plugin: DataviewAutocompletePlugin; + + suggestionsList: string[] = []; + private suggestionsRefs: Map = new Map(); // maps file paths to included suggestions + private suggestionsRefCount: Map = new Map(); // maps suggestions to number of files including them + + constructor(plugin: DataviewAutocompletePlugin) { + this.plugin = plugin; + } + + public buildNewIndex() { + console.log("Begin Rebuilding dataview suggestion index"); + const startTime = performance.now(); + const dataviewApi = getAPI(this.plugin.app); + + const newSuggestions: string[] = []; + const newSuggestionsRefs: Map = new Map(); + const newSuggestionsRefCount: Map = new Map(); + + const files = this.plugin.app.vault.getFiles(); + for (const file of files) { + if (!this.filterFile(file.path)) { + continue; + } + + const page = dataviewApi.page(file.path); + if (page === undefined) continue; // not a markdown file + + const compositeValues = this.extractCompositeValuesFromPage(page); + const pageRefs = []; + + for (let compositeValue of compositeValues) { + if (newSuggestions.indexOf(compositeValue) === -1) { + // suggestion not seen on any page yet + pageRefs.push(compositeValue); + newSuggestionsRefCount.set(compositeValue, 1); + newSuggestions.push(compositeValue); + } else if (pageRefs.indexOf(compositeValue) === -1) { + // suggestion not seen on this page, but on another + pageRefs.push(compositeValue); + newSuggestionsRefCount.set(compositeValue, newSuggestionsRefCount.get(compositeValue)! - 1); + } + } + newSuggestionsRefs.set(file.path, pageRefs); + } + + // replace old index + this.suggestionsList = newSuggestions; + this.suggestionsRefCount = newSuggestionsRefCount; + this.suggestionsRefs = newSuggestionsRefs; + + const endTime = performance.now(); + console.log( + `Rebuilt dataview autocomplete index (${this.suggestionsList.length} elements, ${(endTime - startTime).toFixed(2)}ms)`, + ); + } + + public updateIndex(type: string, file: TFile, oldPath?: string) { + // also triggers on create! + if (type === "update") { + if (!this.filterFile(file.path)) { + return; + } + + const page = getAPI(this.plugin.app).page(file.path); + if (page === undefined) return; // not a markdown file + const updateCompositeValues = this.extractCompositeValuesFromPage(page); + + const oldCompositeValues = this.suggestionsRefs.get(file.path) || []; + + // deleting value from index if update reduces refcount to 0 + for (const oldCompositeValue of oldCompositeValues) { + if (updateCompositeValues.indexOf(oldCompositeValue) === -1) { + // delete value + this.suggestionsRefCount.set( + oldCompositeValue, + this.suggestionsRefCount.get(oldCompositeValue)! - 1, + ); + if (this.suggestionsRefCount.get(oldCompositeValue) === 0) { + this.suggestionsList.splice(this.suggestionsList.indexOf(oldCompositeValue), 1); + } + } + } + + // adding value to index if not present in index + for (const newCompositeValue of updateCompositeValues) { + if ( + !this.suggestionsRefCount.has(newCompositeValue) || + this.suggestionsRefCount.get(newCompositeValue) === 0 + ) { + // not seen in this or other files + this.suggestionsList.push(newCompositeValue); + this.suggestionsRefCount.set(newCompositeValue, 1); + } else if (oldCompositeValues.indexOf(newCompositeValue) === -1) { + this.suggestionsRefCount.set( + newCompositeValue, + this.suggestionsRefCount.get(newCompositeValue)! + 1, + ); + } + } + this.suggestionsRefs.set(file.path, updateCompositeValues); + } else if (type === "rename") { + if (this.filterFile(file.path) && this.filterFile(oldPath!)) { + // both not ignored -> move refs + this.suggestionsRefs.set(file.path, this.suggestionsRefs.get(oldPath!)!); + this.suggestionsRefs.delete(oldPath!); + } else if (this.filterFile(file.path)) { + // old path ignored, new path not ignored -> upsert + this.updateIndex("update", file, undefined); + } else if (this.filterFile(oldPath!)) { + // old path not ignored, new path ignored -> delete + for (const value of this.suggestionsRefs.get(oldPath!)!) { + this.suggestionsRefCount.set(value, this.suggestionsRefCount.get(value)! - 1); + if (this.suggestionsRefCount.get(value) === 0) { + this.suggestionsList.splice(this.suggestionsList.indexOf(value), 1); + } + } + this.suggestionsRefs.delete(oldPath!); + } + } else if (type === "delete") { + // iterate suggestion refs in deleted file and decrement their ref count + // if the ref count reaches 0, remove the suggestion from the list + for (const value of this.suggestionsRefs.get(file.path)!) { + this.suggestionsRefCount.set(value, this.suggestionsRefCount.get(value)! - 1); + if (this.suggestionsRefCount.get(value) === 0) { + this.suggestionsList.splice(this.suggestionsList.indexOf(value), 1); + } + } + this.suggestionsRefs.delete(file.path); + } else { + console.debug("Unknown update type:", type, file, oldPath); + } + } + + extractCompositeValuesFromPage(page: DataObject): string[] { + const fields = Object.keys(page) + .filter((k) => k !== "file") + .map((k) => [k, page[k]]); + + const compositeValues: string[] = []; + + for (let [key, val] of fields) { + // fields can be a single value or a dict, so we need to handle both + let arrayVal; + if (!Array.isArray(val)) { + arrayVal = [val]; + } else { + arrayVal = val; + } + + // Add composite value "key:: value" to suggestions list + for (const value of arrayVal) { + if (value === null || value === undefined) continue; // skip empty fields + + let compositeValue = this.formatCompositeValue(key, value); + compositeValues.push(compositeValue); + if (!this.filterCompositeValue(compositeValue)) { + continue; + } + } + } + return compositeValues; + } + + /** + * Filters out composite values from suggestions that match any of the ignored fields. + * @returns true if the value should be shown in suggestions, false otherwise + */ + filterCompositeValue(compositeValue: string): boolean { + for (const filterPattern of this.plugin.settings.ignoredFields) { + const regex = new RegExp(`^(${filterPattern})::.*`); + if (regex.test(compositeValue)) { + return false; + } + } + return true; + } + + /** + * Converts a Dataview value to a string + * returns the composite value in format "key:: value" + */ + formatCompositeValue(key: string, value: any): string { + const dataviewAPI = getAPI(this.plugin.app); + + let stringValue: string; + + // If the value is a string, number or boolean, we can simply convert it to a string + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + stringValue = value.toString(); + } else if (dataviewAPI.value.typeOf(value) === "link" && value.type === "file") { + // parse wiki-style links + if (value.display !== undefined) { + stringValue = `[[${value.path.split("/").pop().replace(".md", "")}|${value.display}]]`; + } else { + stringValue = `[[${value.path.split("/").pop().replace(".md", "")}]]`; + } + } else { + stringValue = dataviewAPI.value.toString(value); + } + return `${key}:: ${stringValue}`; + } + + filterFile(filepath: string): boolean { + for (const filterPattern of this.plugin.settings.ignoredFiles) { + const regex = new RegExp(filterPattern); + if (regex.test(filepath)) { + return false; + } + } + return true; + } +}