mirror of
https://github.com/c-degni/text-autocomplete.git
synced 2026-07-22 05:49:39 +00:00
Frequency based suggestions
This commit is contained in:
parent
2c9523cb8b
commit
ebb6fa6782
8 changed files with 131 additions and 48 deletions
13
src/constants.ts
Normal file
13
src/constants.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Result } from "./utils";
|
||||
|
||||
export const DEFAULT_WORD_SCORE: number = 1;
|
||||
export const IMPORT_WORD_SCORE: number = 2;
|
||||
export const CUSTOM_WORD_SCORE: number = 5;
|
||||
export const ACCEPTED_SUGGESTION_BUMP: number = 3;
|
||||
|
||||
export const OK: Result = { ok: true };
|
||||
export const NOT_OK: Result = { ok: false };
|
||||
export const EMPTY_FILE: Result = { ok: false, reason: 'emptyFile' };
|
||||
export const DUPLICATE_FILE: Result = { ok: false, reason: 'duplicateFile' };
|
||||
export const EMPTY_WORD: Result = { ok: false, reason: 'emptyWord' };
|
||||
export const DUPLICATE_WORD: Result = { ok: false, reason: 'duplicateWord'};
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { Trie } from './trie';
|
||||
import { DEFAULT_WORDS, CS_WORDS } from './words'
|
||||
|
||||
let DEFAULT_TRIE = new Trie();
|
||||
DEFAULT_WORDS.forEach(word => DEFAULT_TRIE.insert(word));
|
||||
CS_WORDS.forEach(word => DEFAULT_TRIE.insert(word));
|
||||
export type Dictionary = {
|
||||
name: string,
|
||||
words: string[],
|
||||
baseScore: number
|
||||
};
|
||||
|
||||
export { DEFAULT_TRIE };
|
||||
export const DEFAULT_DICTIONARIES: Dictionary[] = [
|
||||
{ name: 'default', words: DEFAULT_WORDS, baseScore: 1 },
|
||||
{ name: 'computer-science', words: CS_WORDS, baseScore: 1 },
|
||||
];
|
||||
|
|
@ -58,16 +58,7 @@ export default class Trie {
|
|||
}
|
||||
|
||||
bumpWordScore(word: string, delta: number = 1): void {
|
||||
if (!this.wordScores.has(word)) return;
|
||||
|
||||
this.wordScores.set(word, (this.getScore(word) ?? 0) + delta);
|
||||
|
||||
const path: TrieNode[] | null = this.getPathNodes(word);
|
||||
if (!path) return;
|
||||
|
||||
for (const node of path) {
|
||||
this.refreshCache(node);
|
||||
}
|
||||
this.setWordScore(word, (this.getScore(word) ?? 0) + delta);
|
||||
}
|
||||
|
||||
setWordScore(word: string, score: number): void {
|
||||
|
|
@ -96,6 +87,7 @@ export default class Trie {
|
|||
|
||||
for (const word of tmp.cache) {
|
||||
if (results.length >= limit) break;
|
||||
if (!this.shouldSuggestWord(prefix, word)) continue;
|
||||
if (caseSensitive && !word.startsWith(prefix)) continue;
|
||||
if (seen.has(word)) continue;
|
||||
|
||||
|
|
@ -157,6 +149,7 @@ export default class Trie {
|
|||
|
||||
for (const word of terminalWords) {
|
||||
if (results.length >= limit) return;
|
||||
if (!this.shouldSuggestWord(prefix, word)) continue;
|
||||
if (seen.has(word)) continue;
|
||||
if (caseSensitive && !word.startsWith(prefix)) continue;
|
||||
|
||||
|
|
@ -167,8 +160,8 @@ export default class Trie {
|
|||
|
||||
const childEntries: Array<[string, TrieNode]> = Object.entries(node.children);
|
||||
childEntries.sort(([, childA], [, childB]) => {
|
||||
const bestA: string | undefined = childA.cache[0];
|
||||
const bestB: string | undefined = childB.cache[0];
|
||||
const bestA: string | undefined = this.getBestCandidate(childA);
|
||||
const bestB: string | undefined = this.getBestCandidate(childB);
|
||||
|
||||
if (!bestA && !bestB) return 0;
|
||||
if (!bestA) return 1;
|
||||
|
|
@ -220,8 +213,14 @@ export default class Trie {
|
|||
}
|
||||
}
|
||||
|
||||
for (const char in node.children) {
|
||||
for (const word of node.children[char].cache) {
|
||||
for (const child of Object.values(node.children)) {
|
||||
for (const word of child.words) {
|
||||
if (this.wordScores.has(word) && this.shouldCacheWord(node, word)) {
|
||||
candidates.add(word);
|
||||
}
|
||||
}
|
||||
|
||||
for (const word of child.cache) {
|
||||
if (this.wordScores.has(word) && this.shouldCacheWord(node, word)) {
|
||||
candidates.add(word);
|
||||
}
|
||||
|
|
@ -233,10 +232,34 @@ export default class Trie {
|
|||
.slice(0, this.maxCacheSize);
|
||||
}
|
||||
|
||||
private getBestCandidate(node: TrieNode): string | undefined {
|
||||
let best: string | undefined;
|
||||
|
||||
for (const word of node.words) {
|
||||
if (!this.wordScores.has(word)) continue;
|
||||
if (!best || this.compareWords(word, best) < 0) {
|
||||
best = word;
|
||||
}
|
||||
}
|
||||
|
||||
for (const word of node.cache) {
|
||||
if (!this.wordScores.has(word)) continue;
|
||||
if (!best || this.compareWords(word, best) < 0) {
|
||||
best = word;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private shouldCacheWord(node: TrieNode, word: string): boolean {
|
||||
return word !== node.prefix;
|
||||
}
|
||||
|
||||
private shouldSuggestWord(prefix: string, word: string): boolean {
|
||||
return word !== prefix;
|
||||
}
|
||||
|
||||
private compareWords(a: string, b: string): number {
|
||||
const scoreA: number = this.getScore(a) ?? 0;
|
||||
const scoreB: number = this.getScore(b) ?? 0;
|
||||
|
|
|
|||
|
|
@ -7470,7 +7470,6 @@ const DEFAULT_WORDS : string[] = [
|
|||
"cheers",
|
||||
"idiot",
|
||||
"morbid",
|
||||
"e-mail",
|
||||
"outcome",
|
||||
"gilt",
|
||||
"coldness",
|
||||
|
|
|
|||
70
src/main.ts
70
src/main.ts
|
|
@ -1,9 +1,10 @@
|
|||
import { Editor, EditorPosition, Plugin, Menu, MenuItem, Notice } from 'obsidian';
|
||||
import { TASettingsTab, DEFAULT_SETTINGS, TASettings, DictionaryFile } from './settings/settings';
|
||||
import { createTAUI, destroyTAUI, updateSuggestions } from './settings/ui';
|
||||
import { DUPLICATE_FILE, DUPLICATE_WORD, EMPTY_FILE, inCodeBlock, NOT_OK, OK, Result, stringInWordOrBeforePunctuation, wordBeforeString } from './utils';
|
||||
import { DEFAULT_TRIE } from './dictionary/dictionary';
|
||||
import { inCodeBlock, Result, stringInWordOrBeforePunctuation, wordBeforeString } from './utils';
|
||||
import { DEFAULT_DICTIONARIES, Dictionary } from './dictionary/dictionary';
|
||||
import { Trie } from './dictionary/trie';
|
||||
import { ACCEPTED_SUGGESTION_BUMP, CUSTOM_WORD_SCORE, DEFAULT_WORD_SCORE, DUPLICATE_FILE, DUPLICATE_WORD, EMPTY_FILE, IMPORT_WORD_SCORE, NOT_OK, OK } from './constants';
|
||||
|
||||
export default class TAPlugin extends Plugin {
|
||||
settings: TASettings;
|
||||
|
|
@ -33,11 +34,31 @@ export default class TAPlugin extends Plugin {
|
|||
}
|
||||
|
||||
async loadWordTrie() {
|
||||
this.wordTrie = DEFAULT_TRIE;
|
||||
this.settings.customDict.forEach(word => this.wordTrie.insert(word));
|
||||
this.settings.dictFiles.forEach(file =>
|
||||
file.words.forEach(word => this.wordTrie.insert(word))
|
||||
);
|
||||
this.wordTrie = new Trie(this.settings.maxSuggestions);
|
||||
|
||||
DEFAULT_DICTIONARIES.forEach((dict: Dictionary) => {
|
||||
dict.words.forEach((word: string) => {
|
||||
const score: number = this.settings.wordScores[word] ?? DEFAULT_WORD_SCORE;
|
||||
this.settings.wordScores[word] = score;
|
||||
this.wordTrie.insert(word, score);
|
||||
});
|
||||
});
|
||||
|
||||
this.settings.customDict.forEach((word: string) => {
|
||||
const score: number = this.settings.wordScores[word] ?? CUSTOM_WORD_SCORE;
|
||||
this.settings.wordScores[word] = score;
|
||||
this.wordTrie.insert(word, score);
|
||||
});
|
||||
|
||||
this.settings.dictFiles.forEach((file: DictionaryFile) => {
|
||||
file.words.forEach((word: string) => {
|
||||
const score: number = this.settings.wordScores[word] ?? IMPORT_WORD_SCORE;
|
||||
this.settings.wordScores[word] = score;
|
||||
this.wordTrie.insert(word)
|
||||
});
|
||||
});
|
||||
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
|
|
@ -73,11 +94,14 @@ export default class TAPlugin extends Plugin {
|
|||
}
|
||||
|
||||
const word: string = match[1];
|
||||
const suggestions: string[] = this.wordTrie.findWordsWithPrefix(word, this.settings.maxSuggestions, this.settings.caseSensitive)
|
||||
.filter((w: string) => w !== word);
|
||||
const suggestions: string[] = this.wordTrie.findWordsWithPrefix(
|
||||
word,
|
||||
this.settings.maxSuggestions,
|
||||
this.settings.caseSensitive
|
||||
);
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
updateSuggestions(suggestions, editor, this.settings);
|
||||
updateSuggestions(suggestions, editor, this);
|
||||
} else {
|
||||
destroyTAUI();
|
||||
}
|
||||
|
|
@ -122,7 +146,9 @@ export default class TAPlugin extends Plugin {
|
|||
|
||||
if (evt.key === 'Enter' || evt.key === 'Tab') {
|
||||
const selected = active || items[0];
|
||||
if (selected) selected.dispatchEvent(new Event('mousedown'));
|
||||
if (selected) {
|
||||
selected.dispatchEvent(new Event('mousedown'));
|
||||
}
|
||||
destroyTAUI();
|
||||
return;
|
||||
}
|
||||
|
|
@ -137,12 +163,22 @@ export default class TAPlugin extends Plugin {
|
|||
if (this.settingsTab) this.settingsTab.display();
|
||||
}
|
||||
|
||||
async bumpAcceptedWord(word: string): Promise<void> {
|
||||
this.wordTrie.bumpWordScore(word, ACCEPTED_SUGGESTION_BUMP);
|
||||
this.settings.wordScores[word] = this.wordTrie.getScore(word);
|
||||
await this.saveSettings();
|
||||
}
|
||||
|
||||
async addCustomWord(word: string): Promise<Result> {
|
||||
if (!word) return EMPTY_FILE;
|
||||
if (this.settings.customDict.includes(word)) return DUPLICATE_WORD;
|
||||
|
||||
this.settings.customDict.push(word);
|
||||
this.wordTrie.insert(word);
|
||||
|
||||
const score: number = this.settings.wordScores[word] ?? CUSTOM_WORD_SCORE;
|
||||
this.settings.wordScores[word] = score;
|
||||
|
||||
this.wordTrie.insert(word, score);
|
||||
await this.saveSettings();
|
||||
return OK;
|
||||
}
|
||||
|
|
@ -152,8 +188,10 @@ export default class TAPlugin extends Plugin {
|
|||
if (!word) return NOT_OK;
|
||||
|
||||
this.settings.customDict.splice(index, 1);
|
||||
|
||||
if (!this.wordExistsInAnotherDict(word, -1)) {
|
||||
this.wordTrie.remove(word);
|
||||
delete this.settings.wordScores[word];
|
||||
}
|
||||
|
||||
await this.saveSettings();
|
||||
|
|
@ -164,6 +202,7 @@ export default class TAPlugin extends Plugin {
|
|||
this.settings.customDict.forEach((word: string) => {
|
||||
if (!this.wordExistsInAnotherDict(word, -1)) {
|
||||
this.wordTrie.remove(word);
|
||||
delete this.settings.wordScores[word];
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -185,7 +224,11 @@ export default class TAPlugin extends Plugin {
|
|||
|
||||
if (words.length === 0) return { result: EMPTY_FILE };
|
||||
|
||||
words.forEach((word: string) => this.wordTrie.insert(word));
|
||||
words.forEach((word: string) => {
|
||||
const score: number = this.settings.wordScores[word] ?? IMPORT_WORD_SCORE;
|
||||
this.settings.wordScores[word] = score;
|
||||
this.wordTrie.insert(word, score);
|
||||
});
|
||||
|
||||
const dictFile: DictionaryFile = {
|
||||
filename: file.name,
|
||||
|
|
@ -204,6 +247,7 @@ export default class TAPlugin extends Plugin {
|
|||
dictFile.words.forEach((word: string) => {
|
||||
if (!this.wordExistsInAnotherDict(word, index)) {
|
||||
this.wordTrie.remove(word);
|
||||
delete this.settings.wordScores[word];
|
||||
}
|
||||
});
|
||||
this.settings.dictFiles.splice(index, 1);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { PluginSettingTab, App, Setting, Notice, DropdownComponent, ToggleComponent, SliderComponent, TextComponent, ButtonComponent } from 'obsidian';
|
||||
import type TAPlugin from 'src/main';
|
||||
import { destroyTAUI } from './ui';
|
||||
import { DUPLICATE_FILE, DUPLICATE_WORD, EMPTY_FILE, OK, Result } from 'src/utils';
|
||||
import { DUPLICATE_FILE, DUPLICATE_WORD, EMPTY_FILE } from 'src/constants';
|
||||
import { Result } from 'src/utils';
|
||||
|
||||
export interface DictionaryFile {
|
||||
filename: string;
|
||||
|
|
@ -16,6 +17,7 @@ export interface TASettings {
|
|||
caseSensitive: boolean;
|
||||
customDict: string[];
|
||||
dictFiles: DictionaryFile[];
|
||||
wordScores: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: TASettings = {
|
||||
|
|
@ -25,7 +27,8 @@ export const DEFAULT_SETTINGS: TASettings = {
|
|||
addSpace: false,
|
||||
caseSensitive: true,
|
||||
customDict: [],
|
||||
dictFiles: []
|
||||
dictFiles: [],
|
||||
wordScores: {}
|
||||
}
|
||||
|
||||
export class TASettingsTab extends PluginSettingTab {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ViewPlugin, ViewUpdate, PluginValue } from '@codemirror/view';
|
||||
import { Editor } from 'obsidian';
|
||||
import { TASettings } from './settings';
|
||||
import { stringInWordOrBeforePunctuation, wordBeforeString } from 'src/utils';
|
||||
import TAPlugin from 'src/main';
|
||||
|
||||
let dropdownEl: HTMLUListElement | null = null;
|
||||
|
||||
|
|
@ -57,7 +57,7 @@ export function destroyTAUI() {
|
|||
dropdownEl = null;
|
||||
}
|
||||
|
||||
export function updateSuggestions(suggestions: string[], editor: Editor, settings: TASettings) {
|
||||
export function updateSuggestions(suggestions: string[], editor: Editor, plugin: TAPlugin) {
|
||||
destroyTAUI();
|
||||
|
||||
const cm = (editor as CodeMirrorEditor).cm;
|
||||
|
|
@ -68,22 +68,26 @@ export function updateSuggestions(suggestions: string[], editor: Editor, setting
|
|||
dropdownEl = createEl('ul');
|
||||
dropdownEl!.className = 'autocomplete-dropdown';
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
suggestions.forEach((suggestion: string) => {
|
||||
const li = createEl('li');
|
||||
li.textContent = suggestion;
|
||||
li.addEventListener('mousedown', () => {
|
||||
li.addEventListener('mousedown', async () => {
|
||||
const cursor = editor.getCursor();
|
||||
const line = editor.getLine(cursor.line);
|
||||
const beforeCursor = line.substring(0, cursor.ch);
|
||||
const match = wordBeforeString(beforeCursor);
|
||||
|
||||
if (match) {
|
||||
if (settings.addSpace) suggestion += " ";
|
||||
let insert: string = `${suggestion}`;
|
||||
if (plugin.settings.addSpace) insert += " ";
|
||||
editor.replaceRange(
|
||||
suggestion,
|
||||
insert,
|
||||
{ line: cursor.line, ch: cursor.ch - match[1].length }, // start position (line, position in line)
|
||||
cursor // end position (line, position in line)
|
||||
);
|
||||
await plugin.bumpAcceptedWord(suggestion);
|
||||
}
|
||||
|
||||
destroyTAUI();
|
||||
});
|
||||
dropdownEl?.appendChild(li);
|
||||
|
|
|
|||
|
|
@ -5,13 +5,6 @@ export interface Result {
|
|||
reason?: string;
|
||||
};
|
||||
|
||||
export const OK: Result = { ok: true };
|
||||
export const NOT_OK: Result = { ok: false };
|
||||
export const EMPTY_FILE: Result = { ok: false, reason: 'emptyFile' };
|
||||
export const DUPLICATE_FILE: Result = { ok: false, reason: 'duplicateFile' };
|
||||
export const EMPTY_WORD: Result = { ok: false, reason: 'emptyWord' };
|
||||
export const DUPLICATE_WORD: Result = { ok: false, reason: 'duplicateWord'};
|
||||
|
||||
export const inCodeBlock = (editor: Editor, cursor: EditorPosition): boolean => {
|
||||
const lines = editor.getValue().split('\n');
|
||||
let inCodeBlock = false;
|
||||
|
|
|
|||
Loading…
Reference in a new issue