From 02c19026b74e528253aa1ca02dd0788cdd2c4b5a Mon Sep 17 00:00:00 2001 From: Simon Cambier Date: Tue, 12 Apr 2022 19:34:56 +0200 Subject: [PATCH] Refacto & displaying results more in line with Obsidian's usage --- src/globals.ts | 18 ++++ src/main.ts | 226 ++++++------------------------------------------- src/modal.ts | 153 +++++++++++++++++++++++++++++++++ 3 files changed, 199 insertions(+), 198 deletions(-) create mode 100644 src/modal.ts diff --git a/src/globals.ts b/src/globals.ts index e69a5c7..b5e9e8b 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -2,3 +2,21 @@ export const regexWikilink = /^!?\[\[(?.+?)(\|(?.+?))?\]\]/ export const regexLineSplit = /\r?\n|\r|((\.|\?|!)( |\r?\n|\r))/g export const regexYaml = /^---\s*\n(.*?)\n?^---\s?/ms + +export type SearchNote = { + path: string + basename: string + content: string +} + +export type IndexedNote = { + path: string + basename: string + content: string +} + +export type ResultNote = { + path: string + basename: string + content: string +} diff --git a/src/main.ts b/src/main.ts index b6c4769..95ef887 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,25 +1,13 @@ -import { Notice, Plugin, SuggestModal, TAbstractFile, TFile } from 'obsidian' +import { Notice, Plugin, TAbstractFile, TFile } from 'obsidian' import MiniSearch from 'minisearch' -import { - clearContent, - escapeRegex, - getTitleLine, - highlighter, - removeTitleLine, - wait, -} from './utils' - -type OmniNote = { - path: string - basename: string - title: string - body: string -} +import { clearContent, getTitleLine, removeTitleLine, wait } from './utils' +import { IndexedNote } from './globals' +import { OmnisearchModal } from './modal' export default class OmnisearchPlugin extends Plugin { - minisearch: MiniSearch + minisearch: MiniSearch lastSearch?: string - notes: Record + indexedNotes: Record async onload(): Promise { await this.instantiateMinisearch() @@ -35,7 +23,11 @@ export default class OmnisearchPlugin extends Plugin { }) // Listeners to keep the search index up-to-date - this.registerEvent(this.app.vault.on('create', this.addToIndex.bind(this))) + this.registerEvent( + this.app.vault.on('create', file => { + this.addToIndex(file) + }), + ) this.registerEvent( this.app.vault.on('delete', file => { this.removeFromIndex(file) @@ -58,10 +50,14 @@ export default class OmnisearchPlugin extends Plugin { } async instantiateMinisearch(): Promise { - this.notes = {} - this.minisearch = new MiniSearch({ + this.indexedNotes = {} + this.minisearch = new MiniSearch({ idField: 'path', - fields: ['body', 'title', 'basename'], + fields: ['content', 'title', 'basename'], + extractField: (document, fieldname: 'content' | 'title' | 'basename') => { + if (fieldname === 'title') return getTitleLine(document.content) + return document[fieldname] + }, }) // Index files that are already present @@ -70,7 +66,7 @@ export default class OmnisearchPlugin extends Plugin { // This is basically the same behavior as MiniSearch's `addAllAsync()`. // We index files by batches of 10 - console.log('Omnisearch - indexing ' + files.length + ' files') + 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] @@ -90,21 +86,21 @@ export default class OmnisearchPlugin extends Plugin { async addToIndex(file: TAbstractFile): Promise { if (!(file instanceof TFile) || file.extension !== 'md') return try { - if (this.notes[file.path]) { + 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 = clearContent(await this.app.vault.cachedRead(file)) - // Split the "title" (the first line/sentence) from the rest of the content - const title = getTitleLine(content) - const body = removeTitleLine(content) - // Make the document and index it - const note = { basename: file.basename, title, body, path: file.path } + const note: IndexedNote = { + basename: file.basename, + content, + path: file.path, + } this.minisearch.add(note) - this.notes[file.path] = note + this.indexedNotes[file.path] = note } catch (e) { console.trace('Error while indexing ' + file.basename) @@ -119,174 +115,8 @@ export default class OmnisearchPlugin extends Plugin { } removeFromIndexByPath(path: string): void { - const note = this.notes[path] + const note = this.indexedNotes[path] this.minisearch.remove(note) - delete this.notes[path] - } -} - -class OmnisearchModal extends SuggestModal { - private plugin: OmnisearchPlugin - private selectedNoteId?: string - private mutationObserver?: MutationObserver - - constructor(plugin: OmnisearchPlugin) { - super(plugin.app) - this.plugin = plugin - - this.setPlaceholder('Type to search through your notes') - - this.setInstructions([ - { command: '↑↓', purpose: 'to navigate' }, - { command: '↵', purpose: 'to open' }, - { command: 'ctrl ↵', purpose: 'to open in a new pane' }, - { command: 'shift ↵', purpose: 'to create' }, - { command: 'esc', purpose: 'to dismiss' }, - ]) - } - - async onKeydown(ev: KeyboardEvent): Promise { - const noteId = this.selectedNoteId - if (ev.key !== 'Enter' || !noteId) return - - if (ev.ctrlKey) { - // Open in a new pane - await this.app.workspace.openLinkText(noteId, '', true) - } - else if (ev.shiftKey) { - // Create a note - try { - const file = await this.app.vault.create( - this.inputEl.value + '.md', - '# ' + this.inputEl.value, - ) - await this.app.workspace.openLinkText(file.path, '') - } - catch (e) { - if (e.message === 'File already exists.') { - await this.app.workspace.openLinkText(this.inputEl.value, '') - } - } - } - this.close() - } - - /** - * Observes the modal element to keep track of which search result is currently selected - * @param modalEl - */ - setupObserver(modalEl: HTMLElement): void { - this.mutationObserver = new MutationObserver(events => { - const record = events.find(event => - (event.target as HTMLDivElement).classList.contains('is-selected'), - ) - const id = (record?.target as HTMLElement).getAttribute('data-note-id') - if (id) { - this.selectedNoteId = id - } - }) - this.mutationObserver.observe(modalEl, { - attributes: true, - subtree: true, - }) - } - - onOpen(): void { - this.inputEl.focus() - this.setupObserver(this.modalEl) - - // Reload last search, if any - if (this.plugin.lastSearch) { - const event = new Event('input', { - bubbles: true, - cancelable: true, - }) - this.inputEl.value = this.plugin.lastSearch - this.inputEl.dispatchEvent(event) - this.inputEl.select() - } - - this.inputEl.onkeydown = this.onKeydown.bind(this) - } - - onClose(): void { - this.mutationObserver.disconnect() - } - - getSuggestions(query: string): OmniNote[] { - this.plugin.lastSearch = query - - const results = this.plugin.minisearch - .search(query, { - prefix: true, - fuzzy: term => (term.length > 4 ? 0.2 : false), - combineWith: 'AND', - boost: { basename: 2, title: 1.5 }, - }) - .sort((a, b) => b.score - a.score) - .slice(0, 50) - // console.log('Omnisearch - Results:') - // console.log(results) - - return results.map(result => { - const note = this.plugin.notes[result.id] - // result.id == the file's path - let basename = note.basename - let title = note.title - let body = note.body - - // If the body contains a searched term, find its position - // and trim the text around it - const pos = body.toLowerCase().indexOf(result.terms[0]) - const surroundLen = 180 - if (pos > -1) { - const from = Math.max(0, pos - surroundLen) - const to = Math.min(body.length - 1, pos + surroundLen) - body = - (from > 0 ? '…' : '') + - body.slice(from, to).trim() + - (to < body.length - 1 ? '…' : '') - } - - // 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') - body = body.replace(reg, highlighter) - title = title.replace(reg, highlighter) - basename = basename.replace(reg, highlighter) - - return { - path: result.id, - basename, - title, - body, - } - }) - } - - renderSuggestion(value: OmniNote, el: HTMLElement): void { - el.setAttribute('data-note-id', value.path) - // title - const title = el.createEl('div', { cls: 'osresult__title' }) - title.innerHTML = value.title - - // filename - const name = document.createElement('span') - name.className = 'osresult__name' - name.innerHTML = value.basename - - // body - const body = document.createElement('span') - body.innerHTML = value.body - - // body container - const bodyContainer = el.createEl('div', { cls: 'osresult__body' }) - bodyContainer.appendChild(name) - bodyContainer.appendChild(body) - } - - onChooseSuggestion(item: OmniNote): void { - this.app.workspace.openLinkText(item.path, '') + delete this.indexedNotes[path] } } diff --git a/src/modal.ts b/src/modal.ts new file mode 100644 index 0000000..a6d73de --- /dev/null +++ b/src/modal.ts @@ -0,0 +1,153 @@ +import { SuggestModal } from 'obsidian' +import { ResultNote } from './globals' +import OmnisearchPlugin from './main' +import { escapeRegex, highlighter } from './utils' + +export class OmnisearchModal extends SuggestModal { + private plugin: OmnisearchPlugin + private selectedNoteId?: string + private mutationObserver?: MutationObserver + + constructor(plugin: OmnisearchPlugin) { + super(plugin.app) + this.plugin = plugin + + this.setPlaceholder('Type to search through your notes') + + this.setInstructions([ + { command: '↑↓', purpose: 'to navigate' }, + { command: '↵', purpose: 'to open' }, + { command: 'ctrl ↵', purpose: 'to open in a new pane' }, + { command: 'shift ↵', purpose: 'to create' }, + { command: 'esc', purpose: 'to dismiss' }, + ]) + } + + async onKeydown(ev: KeyboardEvent): Promise { + const noteId = this.selectedNoteId + if (ev.key !== 'Enter' || !noteId) return + + if (ev.ctrlKey) { + // Open in a new pane + await this.app.workspace.openLinkText(noteId, '', true) + } + else if (ev.shiftKey) { + // Create a note + try { + const file = await this.app.vault.create( + this.inputEl.value + '.md', + '# ' + this.inputEl.value, + ) + await this.app.workspace.openLinkText(file.path, '') + } + catch (e) { + if (e.message === 'File already exists.') { + await this.app.workspace.openLinkText(this.inputEl.value, '') + } + } + } + this.close() + } + + /** + * Observes the modal element to keep track of which search result is currently selected + * @param modalEl + */ + setupObserver(modalEl: HTMLElement): void { + this.mutationObserver = new MutationObserver(events => { + const record = events.find(event => + (event.target as HTMLDivElement).classList.contains('is-selected'), + ) + const id = (record?.target as HTMLElement).getAttribute('data-note-id') + if (id) { + this.selectedNoteId = id + } + }) + this.mutationObserver.observe(modalEl, { + attributes: true, + subtree: true, + }) + } + + onOpen(): void { + this.inputEl.focus() + this.setupObserver(this.modalEl) + + // Reload last search, if any + if (this.plugin.lastSearch) { + const event = new Event('input', { + bubbles: true, + cancelable: true, + }) + this.inputEl.value = this.plugin.lastSearch + this.inputEl.dispatchEvent(event) + this.inputEl.select() + } + + this.inputEl.onkeydown = this.onKeydown.bind(this) + } + + onClose(): void { + this.mutationObserver.disconnect() + } + + async getSuggestions(query: string): Promise { + this.plugin.lastSearch = query + + const results = this.plugin.minisearch + .search(query, { + prefix: true, + fuzzy: term => (term.length > 4 ? 0.2 : false), + combineWith: 'AND', + boost: { basename: 2, title: 1.5 }, + }) + .sort((a, b) => b.score - a.score) + .slice(0, 50) + // console.log('Omnisearch - Results:') + // console.log(results) + + return results.map(result => { + const note = this.plugin.indexedNotes[result.id] + let content = note.content + let basename = note.basename + + // 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 ? '…' : '') + } + + // 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') + content = content.replace(reg, highlighter) + basename = basename.replace(reg, highlighter) + + return { content, basename, path: note.path } + }) + } + + renderSuggestion(value: ResultNote, el: HTMLElement): void { + el.setAttribute('data-note-id', value.path) + + // title + const title = el.createEl('div', { cls: 'osresult__title' }) + title.innerHTML = value.basename + + // body + const body = el.createEl('div', { cls: 'osresult__body' }) + body.innerHTML = value.content + } + + onChooseSuggestion(item: ResultNote): void { + this.app.workspace.openLinkText(item.path, '') + } +}