chore: refactor suggestion index list into its own class

This commit is contained in:
daniel
2024-12-24 10:41:06 +01:00
parent ddedb4e8c1
commit 4d9673eff7
3 changed files with 234 additions and 211 deletions

View File

@@ -12,19 +12,17 @@ import {
} from "obsidian"; } from "obsidian";
import { getTriggerText } from "./trigger"; import { getTriggerText } from "./trigger";
import uFuzzy from "@leeoniya/ufuzzy"; import uFuzzy from "@leeoniya/ufuzzy";
import { getAPI, DataObject } from "obsidian-dataview";
import DataviewAutocompletePlugin from "./main"; import DataviewAutocompletePlugin from "./main";
import { SuggestionIndex } from "./SuggestionIndex";
export class DataviewSuggester extends EditorSuggest<String> { export class DataviewSuggester extends EditorSuggest<String> {
plugin: DataviewAutocompletePlugin; plugin: DataviewAutocompletePlugin;
maxSuggestions: number; maxSuggestions: number;
searcher: uFuzzy; searcher: uFuzzy;
suggestionsIndex: SuggestionIndex;
initialized: boolean = false; initialized: boolean = false;
suggestionsList: string[] = [];
suggestionsRefs: Map<string, string[]> = new Map(); // maps file paths to included suggestions
suggestionsRefCount: Map<string, number> = new Map(); // maps suggestions to number of files including them
constructor( constructor(
plugin: DataviewAutocompletePlugin, plugin: DataviewAutocompletePlugin,
@@ -39,6 +37,7 @@ export class DataviewSuggester extends EditorSuggest<String> {
intraMode: singleErrorMode ? 1 : 0, intraMode: singleErrorMode ? 1 : 0,
intraIns: allowExtraChars ? 1 : 0, intraIns: allowExtraChars ? 1 : 0,
}); });
this.suggestionsIndex = new SuggestionIndex(plugin);
} }
override onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null { override onTrigger(cursor: EditorPosition, editor: Editor, file: TFile): EditorSuggestTriggerInfo | null {
@@ -56,15 +55,17 @@ export class DataviewSuggester extends EditorSuggest<String> {
} }
override getSuggestions(context: EditorSuggestContext): string[] | Promise<string[]> { override getSuggestions(context: EditorSuggestContext): string[] | Promise<string[]> {
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) { if (idxs != null && idxs.length > 0) {
let info = this.searcher.info(idxs, this.suggestionsList, context.query); let info = this.searcher.info(idxs, suggestionsList, context.query);
let order = this.searcher.sort(info, this.suggestionsList, context.query); let order = this.searcher.sort(info, suggestionsList, context.query);
// return top N suggestions with marks // return top N suggestions with marks
return order return order
.slice(0, this.maxSuggestions) .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]])); .map((suggestion: [number, string]) => uFuzzy.highlight(suggestion[1], info.ranges[suggestion[0]]));
} }
return []; return [];
@@ -135,7 +136,7 @@ export class DataviewSuggester extends EditorSuggest<String> {
} }
public onDataviewIndexReady() { public onDataviewIndexReady() {
this.buildNewIndex(); this.suggestionsIndex.buildNewIndex();
this.initialized = true; this.initialized = true;
} }
@@ -145,209 +146,13 @@ export class DataviewSuggester extends EditorSuggest<String> {
// console.warn("Dataview Autocompletion index not ready yet. Skipping index update"); // console.warn("Dataview Autocompletion index not ready yet. Skipping index update");
return; return;
} }
this.updateIndex(type, file, oldPath); this.suggestionsIndex.updateIndex(type, file, oldPath);
} }
/** /**
* Converts a Dataview value to a string * Trigger a full rebuild of the suggestion index
* returns the composite value in format "key:: value"
*/ */
formatCompositeValue(key: string, value: any): string { public rebuildIndex() {
const dataviewAPI = getAPI(this.app); this.suggestionsIndex.buildNewIndex();
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<string, string[]> = new Map();
const newSuggestionsRefCount: Map<string, number> = 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);
}
} }
} }

View File

@@ -33,7 +33,7 @@ export class SettingsTab extends PluginSettingTab {
.map((val) => val.trim()) .map((val) => val.trim())
.filter((val) => val.length > 0); .filter((val) => val.length > 0);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.plugin.suggester?.buildNewIndex(); this.plugin.suggester?.rebuildIndex();
}); });
}); });
new Setting(containerEl) new Setting(containerEl)
@@ -50,7 +50,7 @@ export class SettingsTab extends PluginSettingTab {
.map((val) => val.trim()) .map((val) => val.trim())
.filter((val) => val.length > 0); .filter((val) => val.length > 0);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
this.plugin.suggester?.buildNewIndex(); this.plugin.suggester?.rebuildIndex();
}); });
}); });
} }

218
src/SuggestionIndex.ts Normal file
View File

@@ -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<string, string[]> = new Map(); // maps file paths to included suggestions
private suggestionsRefCount: Map<string, number> = 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<string, string[]> = new Map();
const newSuggestionsRefCount: Map<string, number> = 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;
}
}