+
+ {#if groupedOffsets.length && note}
+ {#each groupedOffsets as offset, i}
+ (selectedIndex = i)}
+ on:click={openSelection}
+ />
+ {/each}
+ {:else}
+ We found 0 result for your search here.
+ {/if}
+
+
+ (searchQuery = e.detail)}
+ on:enter={onInputEnter}
+ on:shift-enter={onInputShiftEnter}
+ on:ctrl-enter={onInputCtrlEnter}
+ on:alt-enter={onInputAltEnter}
+ on:arrow-up={() => moveIndex(-1)}
+ on:arrow-down={() => moveIndex(1)}
+/>
+
+
+
+ {#each resultNotes as result, i}
+ (selectedIndex = i)}
+ on:click={onClick}
+ />
+ {/each}
+ {#if !resultNotes.length && searchQuery}
+ We found 0 result for your search here.
+ {/if}
+
+
+
+
+ ↑↓to navigate
+
+
+ alt ↵
+ to expand in-note results
+
+
+
+ ↵to open
+
+
+ ctrl ↵
+ to open in a new pane
+
+
+ shift ↵
+ to create
+
+
+ escto dismiss
+
+
diff --git a/src/CmpResultInFile.svelte b/src/CmpResultInFile.svelte
new file mode 100644
index 0000000..4b37567
--- /dev/null
+++ b/src/CmpResultInFile.svelte
@@ -0,0 +1,38 @@
+
+
+ dispatch("hover")}
+ on:click={(e) => dispatch("click")}
+>
+
+ {@html cleanContent(note?.content ?? "").replace(reg, highlighter)}
+
+
diff --git a/src/CmpNoteResult.svelte b/src/CmpResultNote.svelte
similarity index 54%
rename from src/CmpNoteResult.svelte
rename to src/CmpResultNote.svelte
index 3f06da2..26ff29a 100644
--- a/src/CmpNoteResult.svelte
+++ b/src/CmpResultNote.svelte
@@ -1,28 +1,22 @@
dispatch("hover")}
+ on:click={(e) => dispatch("click")}
>
-
{@html note.basename}
diff --git a/src/globals.ts b/src/globals.ts
index 88fd802..b7aa738 100644
--- a/src/globals.ts
+++ b/src/globals.ts
@@ -1,10 +1,13 @@
-import type { SearchResult } from 'minisearch'
-
// Matches a wikiling that begins a string
export const regexWikilink = /^!?\[\[(?
.+?)(\|(?.+?))?\]\]/
export const regexLineSplit = /\r?\n|\r|((\.|\?|!)( |\r?\n|\r))/g
export const regexYaml = /^---\s*\n(.*?)\n?^---\s?/ms
+export const excerptBefore = 100
+export const excerptAfter = 180
+
+export const highlightClass = 'suggestion-highlight omnisearch-highlight'
+
export type SearchNote = {
path: string
basename: string
@@ -29,11 +32,13 @@ export const isSearchMatch = (o: { offset?: number }): o is SearchMatch => {
}
export type ResultNote = {
- // searchResult: SearchResult
+ score: number
path: string
basename: string
content: string
foundWords: string[]
matches: SearchMatch[]
- occurence: number
}
+
+export const SPACE_OR_PUNCTUATION =
+ /[|\n\r -#%-*,-/:;?@[-\]_{}\u00A0\u00A1\u00A7\u00AB\u00B6\u00B7\u00BB\u00BF\u037E\u0387\u055A-\u055F\u0589\u058A\u05BE\u05C0\u05C3\u05C6\u05F3\u05F4\u0609\u060A\u060C\u060D\u061B\u061E\u061F\u066A-\u066D\u06D4\u0700-\u070D\u07F7-\u07F9\u0830-\u083E\u085E\u0964\u0965\u0970\u09FD\u0A76\u0AF0\u0C77\u0C84\u0DF4\u0E4F\u0E5A\u0E5B\u0F04-\u0F12\u0F14\u0F3A-\u0F3D\u0F85\u0FD0-\u0FD4\u0FD9\u0FDA\u104A-\u104F\u10FB\u1360-\u1368\u1400\u166E\u1680\u169B\u169C\u16EB-\u16ED\u1735\u1736\u17D4-\u17D6\u17D8-\u17DA\u1800-\u180A\u1944\u1945\u1A1E\u1A1F\u1AA0-\u1AA6\u1AA8-\u1AAD\u1B5A-\u1B60\u1BFC-\u1BFF\u1C3B-\u1C3F\u1C7E\u1C7F\u1CC0-\u1CC7\u1CD3\u2000-\u200A\u2010-\u2029\u202F-\u2043\u2045-\u2051\u2053-\u205F\u207D\u207E\u208D\u208E\u2308-\u230B\u2329\u232A\u2768-\u2775\u27C5\u27C6\u27E6-\u27EF\u2983-\u2998\u29D8-\u29DB\u29FC\u29FD\u2CF9-\u2CFC\u2CFE\u2CFF\u2D70\u2E00-\u2E2E\u2E30-\u2E4F\u3000-\u3003\u3008-\u3011\u3014-\u301F\u3030\u303D\u30A0\u30FB\uA4FE\uA4FF\uA60D-\uA60F\uA673\uA67E\uA6F2-\uA6F7\uA874-\uA877\uA8CE\uA8CF\uA8F8-\uA8FA\uA8FC\uA92E\uA92F\uA95F\uA9C1-\uA9CD\uA9DE\uA9DF\uAA5C-\uAA5F\uAADE\uAADF\uAAF0\uAAF1\uABEB\uFD3E\uFD3F\uFE10-\uFE19\uFE30-\uFE52\uFE54-\uFE61\uFE63\uFE68\uFE6A\uFE6B\uFF01-\uFF03\uFF05-\uFF0A\uFF0C-\uFF0F\uFF1A\uFF1B\uFF1F\uFF20\uFF3B-\uFF3D\uFF3F\uFF5B\uFF5D\uFF5F-\uFF65]+/u
diff --git a/src/main.ts b/src/main.ts
index 0de27af..8e48598 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -1,26 +1,38 @@
-import { Plugin, TFile } from 'obsidian'
-import { OmnisearchModal } from './modal'
+import { MarkdownView, Plugin, TFile } from 'obsidian'
import { plugin } from './stores'
import {
addToIndex,
- instantiateMinisearch,
+ initGlobalSearchIndex,
removeFromIndex,
removeFromIndexByPath,
} from './search'
+import { ModalInFile, ModalVault } from './modal'
export default class OmnisearchPlugin extends Plugin {
async onload(): Promise {
plugin.set(this)
- await instantiateMinisearch()
-
- // Commands to display Omnisearch modal
+ // Commands to display Omnisearch modals
this.addCommand({
id: 'show-modal',
- name: 'Open Omnisearch',
- // hotkeys: [{ modifiers: ['Mod'], key: 'o' }],
+ name: 'Vault search',
callback: () => {
- new OmnisearchModal(this).open()
+ new ModalVault(this).open()
+ },
+ })
+
+ this.addCommand({
+ id: 'show-modal-infile',
+ name: 'In-file search',
+ checkCallback: (checking: boolean) => {
+ const view = this.app.workspace.getActiveViewOfType(MarkdownView)
+ if (view) {
+ if (!checking) {
+ new ModalInFile(this, view.file).open()
+ }
+ return true
+ }
+ return false
},
})
@@ -49,5 +61,7 @@ export default class OmnisearchPlugin extends Plugin {
}
}),
)
+
+ initGlobalSearchIndex()
}
}
diff --git a/src/modal.ts b/src/modal.ts
index faf1629..cd892fe 100644
--- a/src/modal.ts
+++ b/src/modal.ts
@@ -1,18 +1,59 @@
-import { Modal } from 'obsidian'
+import { Modal, TFile } from 'obsidian'
import type OmnisearchPlugin from './main'
-import CmpModal from './CmpModal.svelte'
-import { modal } from './stores'
+import CmpModalVault from './CmpModalVault.svelte'
+import CmpModalInFile from './CmpModalInFile.svelte'
-export class OmnisearchModal extends Modal {
+abstract class ModalOmnisearch extends Modal {
constructor(plugin: OmnisearchPlugin) {
super(plugin.app)
+
+ // Remove all the default modal's children (except the close button)
+ // so that we can more easily customize it
+ const closeEl = this.containerEl.find('.modal-close-button')
+ this.modalEl.replaceChildren()
+ this.modalEl.append(closeEl)
this.modalEl.addClass('omnisearch-modal', 'prompt')
- this.modalEl.replaceChildren() // Remove all the default Modal's children
+ }
+}
- modal.set(this)
+export class ModalVault extends ModalOmnisearch {
+ constructor(plugin: OmnisearchPlugin) {
+ super(plugin)
- new CmpModal({
+ new CmpModalVault({
target: this.modalEl,
+ props: {
+ modal: this,
+ },
+ })
+ }
+}
+
+export class ModalInFile extends ModalOmnisearch {
+ constructor(
+ plugin: OmnisearchPlugin,
+ file: TFile,
+ searchQuery: string = '',
+ parent?: ModalOmnisearch,
+ ) {
+ super(plugin)
+
+ if (parent) {
+ // Hide the parent modal
+ parent.containerEl.toggleVisibility(false)
+ this.onClose = () => {
+ parent.containerEl.toggleVisibility(true)
+ }
+ }
+
+ new CmpModalInFile({
+ target: this.modalEl,
+ props: {
+ modal: this,
+ singleFilePath: file.path,
+ parent: parent,
+ searchQuery,
+ },
})
}
}
diff --git a/src/search.ts b/src/search.ts
index 89da1ad..a8e5aa5 100644
--- a/src/search.ts
+++ b/src/search.ts
@@ -1,21 +1,27 @@
import { Notice, TFile, type TAbstractFile } from 'obsidian'
-import MiniSearch from 'minisearch'
-import type { IndexedNote, ResultNote, SearchMatch } from './globals'
-import { indexedNotes, plugin } from './stores'
-import { get } from 'svelte/store'
+import MiniSearch, { type SearchResult } from 'minisearch'
import {
- escapeRegex,
- extractHeadingsFromCache,
- getAllIndices,
- stringsToRegex,
- wait,
-} from './utils'
+ SPACE_OR_PUNCTUATION,
+ type IndexedNote,
+ type ResultNote,
+ type SearchMatch,
+} from './globals'
+import { plugin } from './stores'
+import { get } from 'svelte/store'
+import { extractHeadingsFromCache, stringsToRegex, wait } from './utils'
-let minisearch: MiniSearch
+let minisearchInstance: MiniSearch
-export async function instantiateMinisearch(): Promise {
- indexedNotes.set({})
- minisearch = new MiniSearch({
+let indexedNotes: Record = {}
+
+/**
+ * Initializes the MiniSearch instance,
+ * and adds all the notes to the index
+ */
+export async function initGlobalSearchIndex(): Promise {
+ indexedNotes = {}
+ minisearchInstance = new MiniSearch({
+ tokenize: text => text.split(SPACE_OR_PUNCTUATION),
idField: 'path',
fields: ['basename', 'content', 'headings1', 'headings2', 'headings3'],
})
@@ -42,8 +48,38 @@ export async function instantiateMinisearch(): Promise {
}ms`,
)
}
+
+ // Listen to the query input to trigger a search
+ // subscribeToQuery()
}
+/**
+ * Searches the index for the given query,
+ * and returns an array of raw results
+ * @param query
+ * @returns
+ */
+function search(query: string): SearchResult[] {
+ if (!query) return []
+ return minisearchInstance.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,
+ },
+ })
+}
+
+/**
+ * Parses a text against a regex, and returns the { string, offset } matches
+ * @param text
+ * @param reg
+ * @returns
+ */
export function getMatches(text: string, reg: RegExp): SearchMatch[] {
let match: RegExpExecArray | null = null
const matches: SearchMatch[] = []
@@ -54,58 +90,69 @@ export function getMatches(text: string, reg: RegExp): SearchMatch[] {
return matches
}
-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)
+/**
+ * Searches the index, and returns an array of ResultNote objects.
+ * If we have the singleFile option set,
+ * the array contains a single result from that file
+ * @param query
+ * @param options
+ * @returns
+ */
+export function getSuggestions(
+ query: string,
+ options?: Partial<{ singleFilePath: string | null }>,
+): ResultNote[] {
+ // Get the raw results
+ let results = search(query)
+ if (!results.length) return []
+ // Either keep the 50 first results,
+ // or the one corresponding to `singleFile`
+ if (options?.singleFilePath) {
+ const result = results.find(r => r.id === options.singleFilePath)
+ if (result) results = [result]
+ else results = []
+ }
+ else {
+ results = results.sort((a, b) => b.score - a.score).slice(0, 50)
+ }
+
+ // Map the raw results to get usable suggestions
const suggestions = results.map(result => {
- const note = indexedNotes.get(result.id)
+ const note = indexedNotes[result.id]
if (!note) {
throw new Error(`Note "${result.id}" not indexed`)
}
const words = Object.keys(result.match)
const matches = getMatches(note.content, stringsToRegex(words))
const resultNote: ResultNote = {
- // searchResult: result,
+ score: result.score,
foundWords: words,
- occurence: 0,
matches,
...note,
}
- // if (note.basename === 'Search') {
- // console.log('=======')
- // console.log(result)
- // console.log(resultNote)
- // }
return resultNote
})
return suggestions
}
+/**
+ * Adds a file to the index
+ * @param file
+ * @returns
+ */
export async function addToIndex(file: TAbstractFile): Promise {
- if (!(file instanceof TFile) || file.extension !== 'md') return
+ 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)) {
+ if (indexedNotes[file.path]) {
throw new Error(`${file.basename} is already indexed`)
}
@@ -127,8 +174,8 @@ export async function addToIndex(file: TAbstractFile): Promise {
? extractHeadingsFromCache(fileCache, 3).join(' ')
: '',
}
- minisearch.add(note)
- indexedNotes.add(note)
+ minisearchInstance.add(note)
+ indexedNotes[note.path] = note
}
catch (e) {
console.trace('Error while indexing ' + file.basename)
@@ -136,6 +183,11 @@ export async function addToIndex(file: TAbstractFile): Promise {
}
}
+/**
+ * Removes a file from the index
+ * @param file
+ * @returns
+ */
export function removeFromIndex(file: TAbstractFile): void {
if (file instanceof TFile && file.path.endsWith('.md')) {
// console.log(`Omnisearch - removing ${file.path} from index`)
@@ -143,10 +195,14 @@ export function removeFromIndex(file: TAbstractFile): void {
}
}
+/**
+ * Removes a file from the index, by its path
+ * @param path
+ */
export function removeFromIndexByPath(path: string): void {
- const note = indexedNotes.get(path)
+ const note = indexedNotes[path]
if (note) {
- minisearch.remove(note)
- indexedNotes.remove(path)
+ minisearchInstance.remove(note)
+ delete indexedNotes[path]
}
}
diff --git a/src/stores.ts b/src/stores.ts
index 872a217..2d2e4e4 100644
--- a/src/stores.ts
+++ b/src/stores.ts
@@ -1,60 +1,7 @@
-import { get, writable } from 'svelte/store'
-import type { IndexedNote, ResultNote } from './globals'
+import { writable } from 'svelte/store'
import type OmnisearchPlugin from './main'
-import type { OmnisearchModal } from './modal'
-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)
- return {
- subscribe,
- set,
- next: () =>
- update(v => {
- const notes = get(resultNotes)
- if (!notes.length) return null
- let id = notes.findIndex(n => n.path === v?.path)
- if (id === -1) return notes[0] ?? null
- id = id < notes.length - 1 ? id + 1 : 0
- return notes[id] ?? null
- }),
- previous: () =>
- update(v => {
- const notes = get(resultNotes)
- if (!notes.length) return null
- let id = notes.findIndex(n => n.path === v?.path)
- if (id === -1) return notes[0] ?? null
- id = id > 0 ? id - 1 : notes.length - 1
- return notes[id] ?? null
- }),
- }
-}
-
-export const searchQuery = writable('')
-export const resultNotes = writable([])
+/**
+ * A reference to the plugin instance
+ */
export const plugin = writable()
-export const modal = writable()
-export const selectedNote = createSelectedNote()
-export const indexedNotes = createIndexedNotes()
diff --git a/src/utils.ts b/src/utils.ts
index 53bf8f6..500930b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,14 +1,14 @@
import type { CachedMetadata } from 'obsidian'
import {
+ highlightClass,
isSearchMatch,
regexLineSplit,
regexYaml,
} from './globals'
import type { SearchMatch } from './globals'
-import { uniqBy } from 'lodash-es'
export function highlighter(str: string): string {
- return '' + str + ''
+ return `${str}`
}
export function escapeHTML(html: string): string {
@@ -53,21 +53,6 @@ export function getAllIndices(text: string, regex: RegExp): SearchMatch[] {
.filter(isSearchMatch)
}
-// export function getAllIndices(text: string, terms: string[]): SearchMatch[] {
-// let matches: SearchMatch[] = []
-// for (const term of terms) {
-// matches = [
-// ...matches,
-// ...[...text.matchAll(new RegExp(escapeRegex(term), 'gi'))]
-// .map(o => ({ match: o[0], index: o.index }))
-// .filter(isSearchMatch),
-// ]
-// }
-// return matches
-// // matches.sort((a, b) => b.match.length - a.match.length)
-// // return uniqBy(matches, 'index')
-// }
-
export function stringsToRegex(strings: string[]): RegExp {
return new RegExp(strings.map(escapeRegex).join('|'), 'gi')
}
@@ -93,3 +78,7 @@ export function extractHeadingsFromCache(
cache.headings?.filter(h => h.level === level).map(h => h.heading) ?? []
)
}
+
+export function loopIndex(index: number, nbItems: number): number {
+ return (index + nbItems) % nbItems
+}
diff --git a/versions.json b/versions.json
index b48a444..922dd33 100644
--- a/versions.json
+++ b/versions.json
@@ -7,5 +7,6 @@
"0.1.5": "0.14.2",
"0.1.6": "0.14.2",
"0.1.7": "0.14.2",
- "0.1.8": "0.14.2"
+ "0.1.8": "0.14.2",
+ "0.2.0": "0.14.2"
}
\ No newline at end of file