From 832e782ba8c899db29278e72891d0ca35f204362 Mon Sep 17 00:00:00 2001 From: Simon Cambier Date: Sat, 16 Apr 2022 17:24:04 +0200 Subject: [PATCH] Svelte refactoring ok --- .eslintrc.js | 2 +- src/CmpInput.svelte | 18 +++-- src/CmpModal.svelte | 111 ++++----------------------- src/CmpNoteResult.svelte | 23 +++++- src/main.ts | 119 ++++------------------------ src/modal.ts | 27 +++---- src/notes.ts | 32 ++++++++ src/search.ts | 162 +++++++++++++++++++++++++++++++++++++++ src/stores.ts | 36 +++++++-- 9 files changed, 300 insertions(+), 230 deletions(-) create mode 100644 src/notes.ts create mode 100644 src/search.ts diff --git a/.eslintrc.js b/.eslintrc.js index 09e6c2d..5bf7b7a 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -34,7 +34,7 @@ module.exports = { // 'simple-import-sort/exports': 'warn', '@typescript-eslint/func-call-spacing': ['error'], '@typescript-eslint/explicit-function-return-type': [ - 'error', + 'warn', { allowExpressions: true, allowTypedFunctionExpressions: true, diff --git a/src/CmpInput.svelte b/src/CmpInput.svelte index 2d58aca..4304734 100644 --- a/src/CmpInput.svelte +++ b/src/CmpInput.svelte @@ -12,21 +12,16 @@ onMount(async () => { input.select() }) -selectedNote.subscribe((note) => { - const elem = document.querySelector(`[data-note-id="${note?.path}"]`) - elem?.scrollIntoView({ behavior: "auto", block: "nearest" }) -}) - // const throttledMoveNoteSelection = throttle(moveNoteSelection, 75) function moveNoteSelection(ev: KeyboardEvent): void { switch (ev.key) { case "ArrowDown": - selectedNote.next() ev.preventDefault() + selectedNote.next() break case "ArrowUp": - selectedNote.previous() ev.preventDefault() + selectedNote.previous() break case "ArrowLeft": break @@ -34,6 +29,7 @@ function moveNoteSelection(ev: KeyboardEvent): void { break case "Enter": + ev.preventDefault() if (ev.ctrlKey || ev.metaKey) { // Open in a new pane dispatch("ctrl-enter", $selectedNote) @@ -46,6 +42,14 @@ function moveNoteSelection(ev: KeyboardEvent): void { } break } + + // Scroll selected note into view when selecting with keyboard + if ($selectedNote) { + const elem = document.querySelector( + `[data-note-id="${$selectedNote.path}"]` + ) + elem?.scrollIntoView({ behavior: "auto", block: "nearest" }) + } } diff --git a/src/CmpModal.svelte b/src/CmpModal.svelte index eebe4e5..26d06f5 100644 --- a/src/CmpModal.svelte +++ b/src/CmpModal.svelte @@ -1,16 +1,17 @@ diff --git a/src/CmpNoteResult.svelte b/src/CmpNoteResult.svelte index 6d4dcb9..778113f 100644 --- a/src/CmpNoteResult.svelte +++ b/src/CmpNoteResult.svelte @@ -1,14 +1,35 @@
{@html note.basename} @@ -17,6 +38,6 @@ export let note: ResultNote {note.matches.length} {note.matches.length > 1 ? "matches" : "match"}
- {@html note.content} + {@html cleanContent}
diff --git a/src/main.ts b/src/main.ts index 1d583d7..0de27af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,16 +1,18 @@ -import { Notice, Plugin, TAbstractFile, TFile } from 'obsidian' -import MiniSearch from 'minisearch' -import type { IndexedNote } from './globals' -import { escapeHTML, extractHeadingsFromCache, wait } from './utils' +import { Plugin, TFile } from 'obsidian' import { OmnisearchModal } from './modal' +import { plugin } from './stores' +import { + addToIndex, + instantiateMinisearch, + removeFromIndex, + removeFromIndexByPath, +} from './search' export default class OmnisearchPlugin extends Plugin { - minisearch!: MiniSearch - lastSearch?: string - indexedNotes: Record = {} - async onload(): Promise { - await this.instantiateMinisearch() + plugin.set(this) + + await instantiateMinisearch() // Commands to display Omnisearch modal this.addCommand({ @@ -25,114 +27,27 @@ export default class OmnisearchPlugin extends Plugin { // Listeners to keep the search index up-to-date this.registerEvent( this.app.vault.on('create', file => { - this.addToIndex(file) + addToIndex(file) }), ) this.registerEvent( this.app.vault.on('delete', file => { - this.removeFromIndex(file) + removeFromIndex(file) }), ) this.registerEvent( this.app.vault.on('modify', async file => { - this.removeFromIndex(file) - await this.addToIndex(file) + removeFromIndex(file) + await addToIndex(file) }), ) this.registerEvent( this.app.vault.on('rename', async (file, oldPath) => { if (file instanceof TFile && file.path.endsWith('.md')) { - this.removeFromIndexByPath(oldPath) - await this.addToIndex(file) + removeFromIndexByPath(oldPath) + await addToIndex(file) } }), ) } - - async instantiateMinisearch(): Promise { - this.indexedNotes = {} - this.minisearch = new MiniSearch({ - idField: 'path', - fields: ['basename', 'content', 'headings1', 'headings2', 'headings3'], - }) - - // Index files that are already present - const start = new Date().getTime() - const files = this.app.vault.getMarkdownFiles() - - // This is basically the same behavior as MiniSearch's `addAllAsync()`. - // We index files by batches of 10 - if (files.length) { - console.log('Omnisearch - indexing ' + files.length + ' files') - } - for (let i = 0; i < files.length; ++i) { - if (i % 10 === 0) await wait(0) - const file = files[i] - // console.log(file.path) - await this.addToIndex(file) - } - - if (files.length > 0) { - new Notice( - `Omnisearch - Indexed ${files.length} notes in ${ - new Date().getTime() - start - }ms`, - ) - } - } - - async addToIndex(file: TAbstractFile): Promise { - if (!(file instanceof TFile) || file.extension !== 'md') return - try { - // console.log(`Omnisearch - adding ${file.path} to index`) - const fileCache = this.app.metadataCache.getFileCache(file) - // console.log(fileCache) - - if (this.indexedNotes[file.path]) { - throw new Error(`${file.basename} is already indexed`) - } - // Fetch content from the cache, - // trim the markdown, remove embeds and clear wikilinks - const content = escapeHTML(await this.app.vault.cachedRead(file)) - - // Purge HTML before indexing - const tmp = document.createElement('div') - tmp.innerHTML = content - - // Make the document and index it - const note: IndexedNote = { - basename: file.basename, - content: tmp.innerText, // content, - path: file.path, - headings1: fileCache - ? extractHeadingsFromCache(fileCache, 1).join(' ') - : '', - headings2: fileCache - ? extractHeadingsFromCache(fileCache, 2).join(' ') - : '', - headings3: fileCache - ? extractHeadingsFromCache(fileCache, 3).join(' ') - : '', - } - this.minisearch.add(note) - this.indexedNotes[file.path] = note - } - catch (e) { - console.trace('Error while indexing ' + file.basename) - console.error(e) - } - } - - removeFromIndex(file: TAbstractFile): void { - if (file instanceof TFile && file.path.endsWith('.md')) { - // console.log(`Omnisearch - removing ${file.path} from index`) - return this.removeFromIndexByPath(file.path) - } - } - - removeFromIndexByPath(path: string): void { - const note = this.indexedNotes[path] - this.minisearch.remove(note) - delete this.indexedNotes[path] - } } diff --git a/src/modal.ts b/src/modal.ts index 9ea07e4..faf1629 100644 --- a/src/modal.ts +++ b/src/modal.ts @@ -1,25 +1,18 @@ -import { Modal } from "obsidian"; -import type OmnisearchPlugin from "./main"; -import CmpModal from "./CmpModal.svelte"; +import { Modal } from 'obsidian' +import type OmnisearchPlugin from './main' +import CmpModal from './CmpModal.svelte' +import { modal } from './stores' export class OmnisearchModal extends Modal { constructor(plugin: OmnisearchPlugin) { - super(plugin.app); - this.modalEl.addClass("omnisearch-modal", "prompt"); - this.modalEl.replaceChildren(); // Remove all the default Modal's children + super(plugin.app) + this.modalEl.addClass('omnisearch-modal', 'prompt') + this.modalEl.replaceChildren() // Remove all the default Modal's children + + modal.set(this) new CmpModal({ target: this.modalEl, - props: { - modal: this, - plugin, - }, - }); - } - - onOpen(): void { - // this.containerEl.style.border = '1px solid red' - // this.modalEl.style.border = '1px solid blue' - // this.contentEl.style.border = '1px solid green' + }) } } diff --git a/src/notes.ts b/src/notes.ts new file mode 100644 index 0000000..b45ac83 --- /dev/null +++ b/src/notes.ts @@ -0,0 +1,32 @@ +import { MarkdownView, TFile } from 'obsidian' +import { get } from 'svelte/store' +import type { ResultNote } from './globals' +import { plugin } from './stores' + +export async function openNote( + item: ResultNote, + newPane = false, +): Promise { + const app = get(plugin).app + const file = app.vault.getAbstractFileByPath(item.path) as TFile + // const fileCache = app.metadataCache.getFileCache(file) + // console.log(fileCache) + const content = (await app.vault.cachedRead(file)).toLowerCase() + const offset = content.indexOf( + item.matches[item.occurence].match.toLowerCase(), + ) + await app.workspace.openLinkText(item.path, '', newPane) + + const view = app.workspace.getActiveViewOfType(MarkdownView) + if (!view) { + throw new Error('OmniSearch - No active MarkdownView') + } + const pos = view.editor.offsetToPos(offset) + pos.ch = 0 + + view.editor.setCursor(pos) + view.editor.scrollIntoView({ + from: { line: pos.line - 10, ch: 0 }, + to: { line: pos.line + 10, ch: 0 }, + }) +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..b0a78d9 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,162 @@ +import { Notice, TFile, type TAbstractFile } from 'obsidian' +import MiniSearch from 'minisearch' +import type { IndexedNote, ResultNote } from './globals' +import { indexedNotes, plugin } from './stores' +import { get } from 'svelte/store' +import { + escapeHTML, + escapeRegex, + extractHeadingsFromCache, + getAllIndexes, + highlighter, + wait, +} from './utils' + +let minisearch: MiniSearch + +export async function instantiateMinisearch(): Promise { + indexedNotes.set({}) + minisearch = new MiniSearch({ + idField: 'path', + fields: ['basename', 'content', 'headings1', 'headings2', 'headings3'], + }) + + // Index files that are already present + const start = new Date().getTime() + const files = get(plugin).app.vault.getMarkdownFiles() + + // This is basically the same behavior as MiniSearch's `addAllAsync()`. + // We index files by batches of 10 + if (files.length) { + console.log('Omnisearch - indexing ' + files.length + ' files') + } + for (let i = 0; i < files.length; ++i) { + if (i % 10 === 0) await wait(0) + await addToIndex(files[i]) + } + + if (files.length > 0) { + new Notice( + `Omnisearch - Indexed ${files.length} notes in ${ + new Date().getTime() - start + }ms`, + ) + } +} + +export function getSuggestions(query: string): ResultNote[] { + const results = minisearch + .search(query, { + prefix: true, + fuzzy: term => (term.length > 4 ? 0.2 : false), + combineWith: 'AND', + boost: { + basename: 2, + headings1: 1.5, + headings2: 1.3, + headings3: 1.1, + }, + }) + .sort((a, b) => b.score - a.score) + .slice(0, 50) + // console.log(`Omnisearch - Results for "${query}"`) + // console.log(results) + + const suggestions = results.map(result => { + const note = indexedNotes.get(result.id) + if (!note) { + throw new Error(`Note "${result.id}" not indexed`) + } + let basename = escapeHTML(note.basename) + let content = escapeHTML(note.content) + + // Sort the terms from smaller to larger + // and highlight them in the title and body + const terms = result.terms.sort((a, b) => a.length - b.length) + const reg = new RegExp(terms.map(escapeRegex).join('|'), 'gi') + const matches = getAllIndexes(content, reg) + + // If the body contains a searched term, find its position + // and trim the text around it + const pos = content.toLowerCase().indexOf(result.terms[0]) + const surroundLen = 180 + if (pos > -1) { + const from = Math.max(0, pos - surroundLen) + const to = Math.min(content.length - 1, pos + surroundLen) + content = + (from > 0 ? '…' : '') + + content.slice(from, to).trim() + + (to < content.length - 1 ? '…' : '') + } + + // console.log(matches) + content = content.replace(reg, highlighter) + basename = basename.replace(reg, highlighter) + + const resultNote: ResultNote = { + content, + basename, + path: note.path, + matches, + occurence: 0, + } + + return resultNote + }) + + return suggestions +} + +export async function addToIndex(file: TAbstractFile): Promise { + if (!(file instanceof TFile) || file.extension !== 'md') return + try { + const app = get(plugin).app + // console.log(`Omnisearch - adding ${file.path} to index`) + const fileCache = app.metadataCache.getFileCache(file) + // console.log(fileCache) + + if (indexedNotes.get(file.path)) { + throw new Error(`${file.basename} is already indexed`) + } + + // Fetch content from the cache to index it as-is + const content = await app.vault.cachedRead(file) + + // Make the document and index it + const note: IndexedNote = { + basename: file.basename, + content, + path: file.path, + headings1: fileCache + ? extractHeadingsFromCache(fileCache, 1).join(' ') + : '', + headings2: fileCache + ? extractHeadingsFromCache(fileCache, 2).join(' ') + : '', + headings3: fileCache + ? extractHeadingsFromCache(fileCache, 3).join(' ') + : '', + } + minisearch.add(note) + indexedNotes.add(note) + } + catch (e) { + console.trace('Error while indexing ' + file.basename) + console.error(e) + } +} + +export function removeFromIndex(file: TAbstractFile): void { + if (file instanceof TFile && file.path.endsWith('.md')) { + // console.log(`Omnisearch - removing ${file.path} from index`) + return removeFromIndexByPath(file.path) + } +} + +export function removeFromIndexByPath(path: string): void { + const note = indexedNotes.get(path) + if (note) { + minisearch.remove(note) + indexedNotes.remove(path) + } +} diff --git a/src/stores.ts b/src/stores.ts index 7fd271c..fb3f620 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -1,12 +1,33 @@ import { get, writable } from 'svelte/store' -import type { ResultNote } from './globals' +import type { IndexedNote, ResultNote } from './globals' +import type OmnisearchPlugin from './main' +import type { OmnisearchModal } from './modal' -// export const selectedNoteId = writable('') -export const searchQuery = writable('') -export const resultNotes = writable([]) +function createIndexedNotes() { + const { subscribe, set, update } = writable>({}) + return { + subscribe, + set, + add(note: IndexedNote) { + update(notes => { + notes[note.path] = note + return notes + }) + }, + remove(path: string) { + update(notes => { + delete notes[path] + return notes + }) + }, + get(path: string): IndexedNote | undefined { + return get(indexedNotes)[path] + }, + } +} function createSelectedNote() { - const { subscribe, set, update } = writable(null) + const { subscribe, set, update } = writable(null) return { subscribe, set, @@ -31,4 +52,9 @@ function createSelectedNote() { } } +export const searchQuery = writable('') +export const resultNotes = writable([]) +export const plugin = writable() +export const modal = writable() export const selectedNote = createSelectedNote() +export const indexedNotes = createIndexedNotes()