diff --git a/src/provider/CurrentFileWordProvider.ts b/src/provider/CurrentFileWordProvider.ts new file mode 100644 index 0000000..9e6d207 --- /dev/null +++ b/src/provider/CurrentFileWordProvider.ts @@ -0,0 +1,35 @@ +import { App, MarkdownView } from "obsidian"; +import { groupBy, uniq } from "../util/collection-helper"; +import { Word, WordsByFirstLetter } from "./suggester"; +import { Tokenizer } from "../tokenizer/tokenizer"; + +export class CurrentFileWordProvider { + private words: Word[] = []; + wordsByFirstLetter: WordsByFirstLetter; + + constructor(private app: App, private tokenizer: Tokenizer) {} + + async refreshWords(): Promise { + this.clearWords(); + + if (!this.app.workspace.getActiveViewOfType(MarkdownView)) { + return; + } + + const file = this.app.workspace.getActiveFile(); + if (!file) { + return; + } + + const content = await this.app.vault.cachedRead(file); + this.words = uniq(this.tokenizer.tokenize(content)).map((x) => ({ + value: x, + })); + this.wordsByFirstLetter = groupBy(this.words, (x) => x.value.charAt(0)); + } + + clearWords(): void { + this.words = []; + this.wordsByFirstLetter = {}; + } +} diff --git a/src/CustomDictionaryService.ts b/src/provider/CustomDictionaryWordProvider.ts similarity index 60% rename from src/CustomDictionaryService.ts rename to src/provider/CustomDictionaryWordProvider.ts index 035d019..484d445 100644 --- a/src/CustomDictionaryService.ts +++ b/src/provider/CustomDictionaryWordProvider.ts @@ -1,11 +1,6 @@ import { App, FileSystemAdapter, Notice } from "obsidian"; -import { keyBy } from "./util/collection-helper"; - -export interface Word { - value: string; - description?: string; - aliases?: string[]; -} +import { keyBy } from "../util/collection-helper"; +import { pushWord, Word, WordsByFirstLetter } from "./suggester"; function lineToWord(line: string): Word { const [value, description, ...aliases] = line.split("\t"); @@ -16,8 +11,10 @@ function lineToWord(line: string): Word { }; } -export class CustomDictionaryService { - words: Word[] = []; +export class CustomDictionaryWordProvider { + private words: Word[] = []; + wordByValue: { [value: string]: Word }; + wordsByFirstLetter: WordsByFirstLetter; private app: App; private fileSystemAdapter: FileSystemAdapter; @@ -29,23 +26,19 @@ export class CustomDictionaryService { this.paths = paths; } - get wordsByValue(): { [value: string]: Word } { - return keyBy(this.words, (x) => x.value); - } - updatePaths(paths: string[]): void { this.paths = paths; } async loadWords(path: string): Promise { return (await this.fileSystemAdapter.read(path)) - .split(/(\r\n|\n)/) + .split(/\r\n|\n/) .filter((x) => x) .map(lineToWord); } - async refreshCustomTokens(): Promise { - this.clearTokens(); + async refreshCustomWords(): Promise { + this.clearWords(); for (const path of this.paths) { try { @@ -59,9 +52,19 @@ export class CustomDictionaryService { ); } } + + this.wordByValue = keyBy(this.words, (x) => x.value); + for (const word of this.words) { + pushWord(this.wordsByFirstLetter, word.value.charAt(0), word); + word.aliases?.forEach((a) => + pushWord(this.wordsByFirstLetter, a.charAt(0), word) + ); + } } - clearTokens(): void { + clearWords(): void { this.words = []; + this.wordByValue = {}; + this.wordsByFirstLetter = {}; } } diff --git a/src/provider/InternalLinkWordProvider.ts b/src/provider/InternalLinkWordProvider.ts new file mode 100644 index 0000000..1ab1586 --- /dev/null +++ b/src/provider/InternalLinkWordProvider.ts @@ -0,0 +1,44 @@ +import { App } from "obsidian"; +import { pushWord, Word, WordsByFirstLetter } from "./suggester"; +import { AppHelper } from "../app-helper"; + +export class InternalLinkWordProvider { + private words: Word[] = []; + wordsByFirstLetter: WordsByFirstLetter; + + constructor(private app: App, private appHelper: AppHelper) {} + + refreshWords(): void { + this.clearWords(); + + const resolvedInternalLinkWords = this.app.vault + .getMarkdownFiles() + .map((x) => ({ + value: `[[${x.basename}]]`, + aliases: [x.basename, ...this.appHelper.getAliases(x)], + description: x.path, + })); + + const unresolvedInternalLinkWords = this.appHelper + .searchPhantomLinks() + .map((x) => ({ + value: `[[${x}]]`, + aliases: [x], + description: "Not created yet", + })); + + this.words = [...resolvedInternalLinkWords, ...unresolvedInternalLinkWords]; + for (const word of this.words) { + // 2 because `[[..` + pushWord(this.wordsByFirstLetter, word.value.charAt(2), word); + word.aliases?.forEach((a) => + pushWord(this.wordsByFirstLetter, a.charAt(2), word) + ); + } + } + + clearWords(): void { + this.words = []; + this.wordsByFirstLetter = {}; + } +} diff --git a/src/suggester/suggester.ts b/src/provider/suggester.ts similarity index 55% rename from src/suggester/suggester.ts rename to src/provider/suggester.ts index bda6de6..830228d 100644 --- a/src/suggester/suggester.ts +++ b/src/provider/suggester.ts @@ -1,10 +1,18 @@ -import { Word } from "../CustomDictionaryService"; import { capitalizeFirstLetter, lowerStartsWith, lowerStartsWithoutSpace, startsWithoutSpace, } from "../util/strings"; +import { IndexedWords } from "../ui/AutoCompleteSuggest"; + +export interface Word { + value: string; + description?: string; + aliases?: string[]; +} + +export type WordsByFirstLetter = { [firstLetter: string]: Word[] }; interface Judgement { word: Word; @@ -12,6 +20,19 @@ interface Judgement { alias: boolean; } +export function pushWord( + wordsByFirstLetter: WordsByFirstLetter, + key: string, + word: Word +) { + if (wordsByFirstLetter[key] === undefined) { + wordsByFirstLetter[key] = [word]; + return; + } + + wordsByFirstLetter[key].push(word); +} + function judge( word: Word, query: string, @@ -22,18 +43,18 @@ function judge( } if ( - word.value.startsWith("[[") - ? lowerStartsWithoutSpace(word.value.replace("[[", ""), query) - : startsWithoutSpace(word.value, query) + queryStartWithUpper && + startsWithoutSpace(capitalizeFirstLetter(word.value), query) ) { + word.value = capitalizeFirstLetter(word.value); return { word: word, value: word.value, alias: false }; } if ( - queryStartWithUpper && - startsWithoutSpace(capitalizeFirstLetter(word.value), query) + word.value.startsWith("[[") + ? lowerStartsWithoutSpace(word.value.replace("[[", ""), query) + : lowerStartsWithoutSpace(word.value, query) ) { - word.value = capitalizeFirstLetter(word.value); return { word: word, value: word.value, alias: false }; } @@ -48,11 +69,28 @@ function judge( } export function suggestWords( - words: Word[], + indexedWords: IndexedWords, query: string, max: number ): Word[] { const queryStartWithUpper = capitalizeFirstLetter(query) === query; + + const words = queryStartWithUpper + ? [ + ...(indexedWords.currentFile[query.charAt(0)] ?? []), + ...(indexedWords.currentFile[query.charAt(0).toLowerCase()] ?? []), + ...(indexedWords.customDictionary[query.charAt(0)] ?? []), + ...(indexedWords.customDictionary[query.charAt(0).toLowerCase()] ?? []), + ...(indexedWords.internalLink[query.charAt(0)] ?? []), + ...(indexedWords.internalLink[query.charAt(0).toLowerCase()] ?? []), + ] + : [ + ...(indexedWords.currentFile[query.charAt(0)] ?? []), + ...(indexedWords.customDictionary[query.charAt(0)] ?? []), + ...(indexedWords.internalLink[query.charAt(0)] ?? []), + ...(indexedWords.internalLink[query.charAt(0).toUpperCase()] ?? []), + ]; + return Array.from(words) .map((x) => judge(x, query, queryStartWithUpper)) .filter((x) => x.value !== undefined) diff --git a/src/ui/AutoCompleteSuggest.ts b/src/ui/AutoCompleteSuggest.ts index a5e261a..f3acfef 100644 --- a/src/ui/AutoCompleteSuggest.ts +++ b/src/ui/AutoCompleteSuggest.ts @@ -9,17 +9,23 @@ import { EditorSuggestTriggerInfo, EventRef, KeymapEventHandler, - MarkdownView, Scope, TFile, } from "obsidian"; import { createTokenizer, Tokenizer } from "../tokenizer/tokenizer"; import { TokenizeStrategy } from "../tokenizer/TokenizeStrategy"; import { Settings } from "../settings"; -import { CustomDictionaryService, Word } from "../CustomDictionaryService"; -import { uniq } from "../util/collection-helper"; import { AppHelper } from "../app-helper"; -import { suggestWords } from "../suggester/suggester"; +import { suggestWords, Word, WordsByFirstLetter } from "../provider/suggester"; +import { CustomDictionaryWordProvider } from "../provider/CustomDictionaryWordProvider"; +import { CurrentFileWordProvider } from "../provider/CurrentFileWordProvider"; +import { InternalLinkWordProvider } from "../provider/InternalLinkWordProvider"; + +export type IndexedWords = { + currentFile: WordsByFirstLetter; + customDictionary: WordsByFirstLetter; + internalLink: WordsByFirstLetter; +}; // This is an unsafe code..!! interface UnsafeEditorSuggestInterface { @@ -36,11 +42,12 @@ export class AutoCompleteSuggest { app: App; settings: Settings; - customDictionaryService: CustomDictionaryService; appHelper: AppHelper; - currentFileTokens: string[] = []; - internalLinkTokens: Word[] = []; + currentFileWordProvider: CurrentFileWordProvider; + customDictionaryWordProvider: CustomDictionaryWordProvider; + internalLinkWordProvider: InternalLinkWordProvider; + tokenizer: Tokenizer; debounceGetSuggestions: Debouncer< [EditorSuggestContext, (tokens: Word[]) => void] @@ -59,11 +66,11 @@ export class AutoCompleteSuggest private constructor( app: App, - customDictionaryService: CustomDictionaryService + customDictionarySuggester: CustomDictionaryWordProvider ) { super(app); this.appHelper = new AppHelper(app); - this.customDictionaryService = customDictionaryService; + this.customDictionaryWordProvider = customDictionarySuggester; } triggerComplete() { @@ -80,7 +87,7 @@ export class AutoCompleteSuggest static async new(app: App, settings: Settings): Promise { const ins = new AutoCompleteSuggest( app, - new CustomDictionaryService( + new CustomDictionaryWordProvider( app, settings.customDictionaryPaths.split("\n").filter((x) => x) ) @@ -124,16 +131,12 @@ export class AutoCompleteSuggest ); } - get words(): Word[] { - const currentFileWords = this.currentFileTokens - .filter((x) => !this.customDictionaryService.wordsByValue[x]) - .map((x) => ({ value: x })); - - return [ - ...currentFileWords, - ...this.customDictionaryService.words, - ...this.internalLinkTokens, - ]; + get indexedWords(): IndexedWords { + return { + currentFile: this.currentFileWordProvider.wordsByFirstLetter, + customDictionary: this.customDictionaryWordProvider.wordsByFirstLetter, + internalLink: this.internalLinkWordProvider.wordsByFirstLetter, + }; } toggleEnabled(): void { @@ -142,17 +145,25 @@ export class AutoCompleteSuggest async updateSettings(settings: Settings) { this.settings = settings; - this.customDictionaryService.updatePaths( + this.customDictionaryWordProvider.updatePaths( settings.customDictionaryPaths.split("\n").filter((x) => x) ); this.tokenizer = createTokenizer(this.tokenizerStrategy); + this.currentFileWordProvider = new CurrentFileWordProvider( + this.app, + this.tokenizer + ); + this.internalLinkWordProvider = new InternalLinkWordProvider( + this.app, + this.appHelper + ); this.debounceGetSuggestions = debounce( (context: EditorSuggestContext, cb: (words: Word[]) => void) => { const start = performance.now(); cb( suggestWords( - this.words, + this.indexedWords, context.query, this.settings.maxNumberOfSuggestions ) @@ -187,7 +198,7 @@ export class AutoCompleteSuggest const start = performance.now(); if (!this.settings.enableCurrentFileComplement) { - this.currentFileTokens = []; + this.currentFileWordProvider.clearWords(); this.showDebugLog( "👢 Skip: Index current file tokens", performance.now() - start @@ -195,7 +206,7 @@ export class AutoCompleteSuggest return; } - this.currentFileTokens = await this.pickTokens(); + await this.currentFileWordProvider.refreshWords(); this.showDebugLog("Index current file tokens", performance.now() - start); } @@ -203,7 +214,7 @@ export class AutoCompleteSuggest const start = performance.now(); if (!this.settings.enableCustomDictionaryComplement) { - this.customDictionaryService.clearTokens(); + this.customDictionaryWordProvider.clearWords(); this.showDebugLog( "👢Skip: Index custom dictionary tokens", performance.now() - start @@ -211,7 +222,7 @@ export class AutoCompleteSuggest return; } - await this.customDictionaryService.refreshCustomTokens(); + await this.customDictionaryWordProvider.refreshCustomWords(); this.showDebugLog( "Index custom dictionary tokens", performance.now() - start @@ -222,7 +233,7 @@ export class AutoCompleteSuggest const start = performance.now(); if (!this.settings.enableInternalLinkComplement) { - this.internalLinkTokens = []; + this.internalLinkWordProvider.clearWords(); this.showDebugLog( "👢Skip: Index internal link tokens", performance.now() - start @@ -230,42 +241,8 @@ export class AutoCompleteSuggest return; } - const resolvedInternalLinkTokens = this.app.vault - .getMarkdownFiles() - .map((x) => ({ - value: `[[${x.basename}]]`, - aliases: [x.basename, ...this.appHelper.getAliases(x)], - description: x.path, - })); - - const unresolvedInternalLinkTokens = this.appHelper - .searchPhantomLinks() - .map((x) => ({ - value: `[[${x}]]`, - aliases: [x], - description: "Not created yet", - })); - + this.internalLinkWordProvider.refreshWords(); this.showDebugLog("Index internal link tokens", performance.now() - start); - - this.internalLinkTokens = [ - ...resolvedInternalLinkTokens, - ...unresolvedInternalLinkTokens, - ]; - } - - async pickTokens(): Promise { - if (!this.app.workspace.getActiveViewOfType(MarkdownView)) { - return []; - } - - const file = this.app.workspace.getActiveFile(); - if (!file) { - return []; - } - - const content = await this.app.vault.cachedRead(file); - return uniq(this.tokenizer.tokenize(content)); } onTrigger( diff --git a/src/util/collection-helper.ts b/src/util/collection-helper.ts index 3589a66..6fde88f 100644 --- a/src/util/collection-helper.ts +++ b/src/util/collection-helper.ts @@ -11,6 +11,17 @@ export const keyBy = ( {} as { [key: string]: T } ); +export const groupBy = ( + values: T[], + toKey: (t: T) => string +): { [key: string]: T[] } => + values.reduce( + (prev, cur, _1, _2, k = toKey(cur)) => ( + (prev[k] || (prev[k] = [])).push(cur), prev + ), + {} as { [key: string]: T[] } + ); + export function uniqWith(arr: T[], fn: (one: T, other: T) => boolean) { return arr.filter( (element, index) => arr.findIndex((step) => fn(element, step)) === index