Refacto & displaying results more in line with Obsidian's usage
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
226
src/main.ts
226
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 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
153
src/modal.ts
Normal 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, '')
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user