Refacto & displaying results more in line with Obsidian's usage

This commit is contained in:
Simon Cambier
2022-04-12 19:34:56 +02:00
parent 9e885cedc4
commit 02c19026b7
3 changed files with 199 additions and 198 deletions

View File

@@ -2,3 +2,21 @@
export const regexWikilink = /^!?\[\[(?<name>.+?)(\|(?<alias>.+?))?\]\]/ export const regexWikilink = /^!?\[\[(?<name>.+?)(\|(?<alias>.+?))?\]\]/
export const regexLineSplit = /\r?\n|\r|((\.|\?|!)( |\r?\n|\r))/g export const regexLineSplit = /\r?\n|\r|((\.|\?|!)( |\r?\n|\r))/g
export const regexYaml = /^---\s*\n(.*?)\n?^---\s?/ms 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
}

View File

@@ -1,25 +1,13 @@
import { Notice, Plugin, SuggestModal, TAbstractFile, TFile } from 'obsidian' import { Notice, Plugin, TAbstractFile, TFile } from 'obsidian'
import MiniSearch from 'minisearch' import MiniSearch from 'minisearch'
import { import { clearContent, getTitleLine, removeTitleLine, wait } from './utils'
clearContent, import { IndexedNote } from './globals'
escapeRegex, import { OmnisearchModal } from './modal'
getTitleLine,
highlighter,
removeTitleLine,
wait,
} from './utils'
type OmniNote = {
path: string
basename: string
title: string
body: string
}
export default class OmnisearchPlugin extends Plugin { export default class OmnisearchPlugin extends Plugin {
minisearch: MiniSearch<OmniNote> minisearch: MiniSearch<IndexedNote>
lastSearch?: string lastSearch?: string
notes: Record<string, OmniNote> indexedNotes: Record<string, IndexedNote>
async onload(): Promise<void> { async onload(): Promise<void> {
await this.instantiateMinisearch() await this.instantiateMinisearch()
@@ -35,7 +23,11 @@ export default class OmnisearchPlugin extends Plugin {
}) })
// Listeners to keep the search index up-to-date // 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.registerEvent(
this.app.vault.on('delete', file => { this.app.vault.on('delete', file => {
this.removeFromIndex(file) this.removeFromIndex(file)
@@ -58,10 +50,14 @@ export default class OmnisearchPlugin extends Plugin {
} }
async instantiateMinisearch(): Promise<void> { async instantiateMinisearch(): Promise<void> {
this.notes = {} this.indexedNotes = {}
this.minisearch = new MiniSearch<OmniNote>({ this.minisearch = new MiniSearch({
idField: 'path', 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 // 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()`. // This is basically the same behavior as MiniSearch's `addAllAsync()`.
// We index files by batches of 10 // 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) { for (let i = 0; i < files.length; ++i) {
if (i % 10 === 0) await wait(0) if (i % 10 === 0) await wait(0)
const file = files[i] const file = files[i]
@@ -90,21 +86,21 @@ export default class OmnisearchPlugin extends Plugin {
async addToIndex(file: TAbstractFile): Promise<void> { async addToIndex(file: TAbstractFile): Promise<void> {
if (!(file instanceof TFile) || file.extension !== 'md') return if (!(file instanceof TFile) || file.extension !== 'md') return
try { try {
if (this.notes[file.path]) { if (this.indexedNotes[file.path]) {
throw new Error(`${file.basename} is already indexed`) throw new Error(`${file.basename} is already indexed`)
} }
// Fetch content from the cache, // Fetch content from the cache,
// trim the markdown, remove embeds and clear wikilinks // trim the markdown, remove embeds and clear wikilinks
const content = clearContent(await this.app.vault.cachedRead(file)) 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 // 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.minisearch.add(note)
this.notes[file.path] = note this.indexedNotes[file.path] = note
} }
catch (e) { catch (e) {
console.trace('Error while indexing ' + file.basename) console.trace('Error while indexing ' + file.basename)
@@ -119,174 +115,8 @@ export default class OmnisearchPlugin extends Plugin {
} }
removeFromIndexByPath(path: string): void { removeFromIndexByPath(path: string): void {
const note = this.notes[path] const note = this.indexedNotes[path]
this.minisearch.remove(note) this.minisearch.remove(note)
delete this.notes[path] delete this.indexedNotes[path]
}
}
class OmnisearchModal extends SuggestModal<OmniNote> {
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<void> {
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, '')
} }
} }

153
src/modal.ts Normal file
View File

@@ -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<ResultNote> {
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<void> {
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<ResultNote[]> {
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, '')
}
}