diff --git a/README.md b/README.md index f3cc4d1..c692430 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Under the hood, it uses the excellent [MiniSearch](https://github.com/lucaong/mi - Resistance to typos - Switch between Vault and In-file search to quickly skim multiple results in a single note - Supports `"expressions in quotes"` and `-exclusions` +- Filters file types with '.jpg' or '.md' - Directly Insert a `[[link]]` from the search results - Supports Vim navigation keys @@ -74,9 +75,15 @@ object `omnisearch` (`window.omnisearch`) ```ts // API: -{ +type OmnisearchApi = { // Returns a promise that will contain the same results as the Vault modal - search: (query: string) => Promise + search: (query: string) => Promise, + // Refreshes the index + refreshIndex: () => Promise + // Register a callback that will be called when the indexing is done + registerOnIndexed: (callback: () => void) => void, + // Unregister a callback that was previously registered + unregisterOnIndexed: (callback: () => void) => void, } type ResultNoteApi = { diff --git a/assets/styles.css b/assets/styles.css index b9c3ac7..95fddc4 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -4,14 +4,41 @@ .omnisearch-result { white-space: normal; display: flex; - flex-direction: column; + flex-direction: row; + justify-content: space-between; + flex-wrap: nowrap; +} + +.omnisearch-result__title-container { + display: flex; + align-items: center; + justify-content: space-between; + column-gap: 5px; + flex-wrap: wrap; } .omnisearch-result__title { + align-items: center; + display: flex; + gap: 5px; } +.omnisearch-result__folder-path { + font-size: 0.75rem; + align-items: center; + display: flex; + gap: 5px; + color: var(--text-muted); +} + +.omnisearch-result__extension { + font-size: 0.7rem; + color: var(--text-muted); + } + .omnisearch-result__counter { font-size: 0.7rem; + color: var(--text-muted); } .omnisearch-result__body { @@ -25,14 +52,23 @@ -webkit-box-orient: vertical; color: var(--text-muted); + margin-left: 0.5em; +} + +.omnisearch-result__image-container { + flex-basis: 20%; + text-align: right } .omnisearch-highlight { } .omnisearch-default-highlight { - color: var(--text-normal); - background-color: var(--text-highlight-bg); + text-decoration: underline; + text-decoration-color: var(--text-highlight-bg); + text-decoration-thickness: 3px; + text-underline-offset: -1px; + text-decoration-skip-ink: none; } .omnisearch-input-container { diff --git a/package.json b/package.json index 7c33c81..4d604b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scambier.obsidian-search", - "version": "1.11.1", + "version": "1.12.1-beta.1", "description": "A search engine for Obsidian", "main": "dist/main.js", "scripts": { diff --git a/src/components/ResultItemInFile.svelte b/src/components/ResultItemInFile.svelte index 3f35c51..3dcba2e 100644 --- a/src/components/ResultItemInFile.svelte +++ b/src/components/ResultItemInFile.svelte @@ -1,6 +1,10 @@ - +
- {@html cleanedContent.replace(reg, highlighter)} + {@html cleanedContent.replace(reg, highlighterGroups)}
diff --git a/src/components/ResultItemVault.svelte b/src/components/ResultItemVault.svelte index 5e55589..13a287b 100644 --- a/src/components/ResultItemVault.svelte +++ b/src/components/ResultItemVault.svelte @@ -2,19 +2,27 @@ import { settings, showExcerpt } from 'src/settings' import type { ResultNote } from '../globals' import { - highlighter, + getExtension, + highlighterGroups, + isFileCanvas, isFileImage, + isFilePDF, makeExcerpt, + pathWithoutFilename, removeDiacritics, stringsToRegex, } from '../tools/utils' import ResultItemContainer from './ResultItemContainer.svelte' + import { setIcon } from 'obsidian' export let selected = false export let note: ResultNote let imagePath: string | null = null let title = '' + let notePath = '' + let elFolderPathIcon: HTMLElement + let elFilePathIcon: HTMLElement $: { imagePath = null @@ -31,10 +39,23 @@ $: cleanedContent = makeExcerpt(note.content, note.matches[0]?.offset ?? -1) $: glyph = false //cacheManager.getLiveDocument(note.path)?.doesNotExist $: { - title = settings.showShortName ? note.basename : note.path + title = note.basename + notePath = pathWithoutFilename(note.path) if (settings.ignoreDiacritics) { title = removeDiacritics(title) } + + // Icons + if (elFolderPathIcon) { + setIcon(elFolderPathIcon, 'folder-open') + } + if (elFilePathIcon) { + if (isFileImage(note.path)) setIcon(elFilePathIcon, 'image') + else if (isFilePDF(note.path)) setIcon(elFilePathIcon, 'file-text') + else if (isFileCanvas(note.path)) + setIcon(elFilePathIcon, 'layout-dashboard') + else setIcon(elFilePathIcon, 'file') + } } @@ -44,13 +65,15 @@ on:click on:mousemove selected="{selected}"> -
-
-
- - {@html title.replace(reg, highlighter)} - +
+
+ + + {@html title.replace(reg, highlighterGroups)} + .{getExtension(note.path)} + {#if note.matches.length > 0} {note.matches.length} {note.matches.length > 1 @@ -58,17 +81,30 @@ : 'match'} {/if} -
+ +
+ + {#if notePath} +
+ + {notePath} +
+ {/if} + +
{#if $showExcerpt}
- {@html cleanedContent.replace(reg, highlighter)} + {@html cleanedContent.replace(reg, highlighterGroups)} +
+ {/if} + + + {#if imagePath} +
+
{/if}
- - {#if imagePath} - - {/if}
diff --git a/src/globals.ts b/src/globals.ts index 29c7013..a9e77fc 100644 --- a/src/globals.ts +++ b/src/globals.ts @@ -8,6 +8,7 @@ export const regexLineSplit = /\r?\n|\r|((\.|\?|!)( |\r?\n|\r))/g export const regexYaml = /^---\s*\n(.*?)\n?^---\s?/ms export const regexStripQuotes = /^"|"$|^'|'$/g export const chsRegex = /[\u4e00-\u9fa5]/ +export const regexExtensions = /(?:^|\s)\.(\w+)/g export const excerptBefore = 100 export const excerptAfter = 300 diff --git a/src/main.ts b/src/main.ts index 524bb73..039ea86 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { IndexingStepType, isCacheEnabled, } from './globals' -import api from './tools/api' +import api, { notifyOnIndexed } from './tools/api' import { isFileIndexable } from './tools/utils' import { database, OmnisearchCache } from './database' import * as NotesIndex from './notes-index' @@ -210,6 +210,7 @@ export default class OmnisearchPlugin extends Plugin { new Notice(`Omnisearch - Your files have been indexed.`) } indexingStep.set(IndexingStepType.Done) + notifyOnIndexed() } } diff --git a/src/search/omnisearch.ts b/src/search/omnisearch.ts index fad8c51..c9a3421 100644 --- a/src/search/omnisearch.ts +++ b/src/search/omnisearch.ts @@ -31,11 +31,22 @@ const tokenize = (text: string): string[] => { export class Omnisearch { public static readonly options: Options = { tokenize, + extractField: (doc, fieldName) => { + if (fieldName === 'directory') { + // return path without the filename + const parts = doc.path.split('/') + parts.pop() + return parts.join('/') + } + return (doc as any)[fieldName] + }, processTerm: (term: string) => (settings.ignoreDiacritics ? removeDiacritics(term) : term).toLowerCase(), idField: 'path', fields: [ 'basename', + // Different from `path`, since `path` is the unique index and needs to include the filename + 'directory', 'aliases', 'content', 'headings1', @@ -168,6 +179,7 @@ export class Omnisearch { combineWith: 'AND', boost: { basename: settings.weightBasename, + directory: settings.weightDirectory, aliases: settings.weightBasename, headings1: settings.weightH1, headings2: settings.weightH2, @@ -175,6 +187,13 @@ export class Omnisearch { }, }) + // Filter query results to only keep files that match query.extensions (if any) + if (query.extensions.length) { + results = results.filter(r => + query.extensions.some(e => r.id.endsWith(e)) + ) + } + // If the query does not return any result, // retry but with a shorter prefix limit if (!results.length) { @@ -348,7 +367,7 @@ export class Omnisearch { // Tags, starting with # ...tags, - ].filter(w => w.length > 1) + ].filter(w => w.length > 1 || /\p{Emoji}/u.test(w)) // console.log(foundWords) const matches = this.getMatches( diff --git a/src/search/query.ts b/src/search/query.ts index bbe595c..c11701b 100644 --- a/src/search/query.ts +++ b/src/search/query.ts @@ -1,6 +1,7 @@ import { settings } from '../settings' import { removeDiacritics, stripSurroundingQuotes } from '../tools/utils' import { parseQuery } from '../vendor/parse-query' +import { regexExtensions } from '../globals' type QueryToken = { /** @@ -20,8 +21,13 @@ type QueryToken = { export class Query { public segments: QueryToken[] = [] public exclusions: QueryToken[] = [] + public extensions: string[] = [] constructor(text = '') { + // Extract & remove extensions from the query + this.extensions = this.extractExtensions(text) + text = this.removeExtensions(text) + if (settings.ignoreDiacritics) text = removeDiacritics(text) const tokens = parseQuery(text.toLowerCase(), { tokenize: true }) this.exclusions = tokens.exclude.text @@ -59,4 +65,19 @@ export class Query { exact: stripped !== str, } } + + /** + * Extracts an array of extensions like ".png" from a string + */ + private extractExtensions(str: string): string[] { + const extensions = (str.match(regexExtensions) ?? []).map(o => o.trim()) + if (extensions) { + return extensions.map(ext => ext.toLowerCase()) + } + return [] + } + + private removeExtensions(str: string): string { + return str.replace(regexExtensions, '') + } } diff --git a/src/settings.ts b/src/settings.ts index 120c1e0..f3778a0 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,7 @@ import type OmnisearchPlugin from './main' interface WeightingSettings { weightBasename: number + weightDirectory: number weightH1: number weightH2: number weightH3: number @@ -32,8 +33,6 @@ export interface OmnisearchSettings extends WeightingSettings { imagesIndexing: boolean /** Activate the small 🔍 button on Obsidian's ribbon */ ribbonIcon: boolean - /** Display short filenames in search results, instead of the full path */ - showShortName: boolean /** Display the small contextual excerpt in search results */ showExcerpt: boolean /** Render line returns with
in excerpts */ @@ -295,19 +294,6 @@ export class SettingsTab extends PluginSettingTab { }) ) - // Display note names without the full path - new Setting(containerEl) - .setName('Hide full path in results list') - .setDesc( - 'In the search results, only show the note name, without the full path.' - ) - .addToggle(toggle => - toggle.setValue(settings.showShortName).onChange(async v => { - settings.showShortName = v - await saveSettings(this.plugin) - }) - ) - // Highlight results new Setting(containerEl) .setName('Highlight matching words in results') @@ -333,6 +319,10 @@ export class SettingsTab extends PluginSettingTab { ) .addSlider(cb => this.weightSlider(cb, 'weightBasename')) + new Setting(containerEl) + .setName(`File directory (default: ${DEFAULT_SETTINGS.weightDirectory})`) + .addSlider(cb => this.weightSlider(cb, 'weightDirectory')) + new Setting(containerEl) .setName(`Headings level 1 (default: ${DEFAULT_SETTINGS.weightH1})`) .addSlider(cb => this.weightSlider(cb, 'weightH1')) @@ -390,7 +380,6 @@ export const DEFAULT_SETTINGS: OmnisearchSettings = { PDFIndexing: false, imagesIndexing: false, - showShortName: false, ribbonIcon: true, showExcerpt: true, renderLineReturnInExcerpts: true, @@ -399,7 +388,8 @@ export const DEFAULT_SETTINGS: OmnisearchSettings = { showPreviousQueryResults: true, simpleSearch: false, - weightBasename: 2, + weightBasename: 3, + weightDirectory: 2, weightH1: 1.5, weightH2: 1.3, weightH3: 1.1, diff --git a/src/tools/api.ts b/src/tools/api.ts index 577e90e..440f6e8 100644 --- a/src/tools/api.ts +++ b/src/tools/api.ts @@ -2,6 +2,7 @@ import type { ResultNote } from '../globals' import { Query } from '../search/query' import { searchEngine } from '../search/omnisearch' import { makeExcerpt } from './utils' +import { refreshIndex } from '../notes-index' type ResultNoteApi = { score: number @@ -17,6 +18,13 @@ export type SearchMatchApi = { offset: number } +let notified = false + +/** + * Callbacks to be called when the search index is ready + */ +let onIndexedCallbacks: Array<() => void> = [] + function mapResults(results: ResultNote[]): ResultNoteApi[] { return results.map(result => { const { score, path, basename, foundWords, matches, content } = result @@ -39,13 +47,27 @@ function mapResults(results: ResultNote[]): ResultNoteApi[] { }) } -async function search( - q: string, - options: Partial<{ excerpt: boolean }> = {} -): Promise { +async function search(q: string): Promise { const query = new Query(q) const raw = await searchEngine.getSuggestions(query) return mapResults(raw) } -export default { search } +function registerOnIndexed(cb: () => void): void { + onIndexedCallbacks.push(cb) + // Immediately call the callback if the indexing is already ready done + if (notified) { + cb() + } +} + +function unregisterOnIndexed(cb: () => void): void { + onIndexedCallbacks = onIndexedCallbacks.filter(o => o !== cb) +} + +export function notifyOnIndexed(): void { + notified = true + onIndexedCallbacks.forEach(cb => cb()) +} + +export default { search, registerOnIndexed, unregisterOnIndexed, refreshIndex } diff --git a/src/tools/utils.ts b/src/tools/utils.ts index fb392c9..9254408 100644 --- a/src/tools/utils.ts +++ b/src/tools/utils.ts @@ -15,6 +15,7 @@ import { regexLineSplit, regexStripQuotes, regexYaml, + SPACE_OR_PUNCTUATION, type SearchMatch, } from '../globals' import { settings } from '../settings' @@ -25,6 +26,12 @@ export function highlighter(str: string): string { return `${str}` } +export function highlighterGroups(...args: any[]) { + if (args[1] && args[2]) + return `${args[1]}${args[2]}` + return '<no content>' +} + export function escapeHTML(html: string): string { return html .replaceAll('&', '&') @@ -43,6 +50,12 @@ export function removeFrontMatter(text: string): string { return text.replace(regexYaml, '') } +export function pathWithoutFilename(path: string): string { + const split = path.split('/') + split.pop() + return split.join('/') +} + export function wait(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms) @@ -72,12 +85,16 @@ export function getAllIndices(text: string, regex: RegExp): SearchMatch[] { */ export function stringsToRegex(strings: string[]): RegExp { if (!strings.length) return /^$/g - // \\b is "word boundary", and is not applied if the user uses the cm-chs-patch plugin - const joined = strings - .map(s => (getChsSegmenter() ? '' : '\\b') + escapeRegex(s)) - .join('|') - const reg = new RegExp(`(${joined})`, 'gi') - // console.log(reg) + // Default word split is not applied if the user uses the cm-chs-patch plugin + const joined = + '(' + + (getChsSegmenter() ? '' : SPACE_OR_PUNCTUATION.source) + + ')' + + '(' + + strings.map(s => escapeRegex(s)).join('|') + + ')' + + const reg = new RegExp(`${joined}`, 'giu') return reg } @@ -249,13 +266,12 @@ export function isFileIndexable(path: string): boolean { } export function isFileImage(path: string): boolean { - return ( - path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.jpeg') - ) + const ext = getExtension(path) + return ext === 'png' || ext === 'jpg' || ext === 'jpeg' } export function isFilePDF(path: string): boolean { - return path.endsWith('.pdf') + return getExtension(path) === 'pdf' } export function isFilePlaintext(path: string): boolean {