Merge branch 'develop'

This commit is contained in:
Simon Cambier
2023-02-24 12:17:01 +01:00
12 changed files with 214 additions and 57 deletions

View File

@@ -29,6 +29,7 @@ Under the hood, it uses the excellent [MiniSearch](https://github.com/lucaong/mi
- Resistance to typos - Resistance to typos
- Switch between Vault and In-file search to quickly skim multiple results in a single note - Switch between Vault and In-file search to quickly skim multiple results in a single note
- Supports `"expressions in quotes"` and `-exclusions` - Supports `"expressions in quotes"` and `-exclusions`
- Filters file types with '.jpg' or '.md'
- Directly Insert a `[[link]]` from the search results - Directly Insert a `[[link]]` from the search results
- Supports Vim navigation keys - Supports Vim navigation keys
@@ -74,9 +75,15 @@ object `omnisearch` (`window.omnisearch`)
```ts ```ts
// API: // API:
{ type OmnisearchApi = {
// Returns a promise that will contain the same results as the Vault modal // Returns a promise that will contain the same results as the Vault modal
search: (query: string) => Promise<ResultNoteApi[]> search: (query: string) => Promise<ResultNoteApi[]>,
// Refreshes the index
refreshIndex: () => Promise<void>
// 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 = { type ResultNoteApi = {

View File

@@ -4,14 +4,41 @@
.omnisearch-result { .omnisearch-result {
white-space: normal; white-space: normal;
display: flex; 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 { .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 { .omnisearch-result__counter {
font-size: 0.7rem; font-size: 0.7rem;
color: var(--text-muted);
} }
.omnisearch-result__body { .omnisearch-result__body {
@@ -25,14 +52,23 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
color: var(--text-muted); color: var(--text-muted);
margin-left: 0.5em;
}
.omnisearch-result__image-container {
flex-basis: 20%;
text-align: right
} }
.omnisearch-highlight { .omnisearch-highlight {
} }
.omnisearch-default-highlight { .omnisearch-default-highlight {
color: var(--text-normal); text-decoration: underline;
background-color: var(--text-highlight-bg); text-decoration-color: var(--text-highlight-bg);
text-decoration-thickness: 3px;
text-underline-offset: -1px;
text-decoration-skip-ink: none;
} }
.omnisearch-input-container { .omnisearch-input-container {

View File

@@ -1,6 +1,6 @@
{ {
"name": "scambier.obsidian-search", "name": "scambier.obsidian-search",
"version": "1.11.1", "version": "1.12.1-beta.1",
"description": "A search engine for Obsidian", "description": "A search engine for Obsidian",
"main": "dist/main.js", "main": "dist/main.js",
"scripts": { "scripts": {

View File

@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { ResultNote } from '../globals' import type { ResultNote } from '../globals'
import { highlighter, makeExcerpt, stringsToRegex } from '../tools/utils' import {
highlighterGroups,
makeExcerpt,
stringsToRegex,
} from '../tools/utils'
import ResultItemContainer from './ResultItemContainer.svelte' import ResultItemContainer from './ResultItemContainer.svelte'
export let offset: number export let offset: number
@@ -12,8 +16,12 @@
$: cleanedContent = makeExcerpt(note?.content ?? '', offset) $: cleanedContent = makeExcerpt(note?.content ?? '', offset)
</script> </script>
<ResultItemContainer id={index.toString()} {selected} on:mousemove on:click> <ResultItemContainer
id="{index.toString()}"
selected="{selected}"
on:mousemove
on:click>
<div class="omnisearch-result__body"> <div class="omnisearch-result__body">
{@html cleanedContent.replace(reg, highlighter)} {@html cleanedContent.replace(reg, highlighterGroups)}
</div> </div>
</ResultItemContainer> </ResultItemContainer>

View File

@@ -2,19 +2,27 @@
import { settings, showExcerpt } from 'src/settings' import { settings, showExcerpt } from 'src/settings'
import type { ResultNote } from '../globals' import type { ResultNote } from '../globals'
import { import {
highlighter, getExtension,
highlighterGroups,
isFileCanvas,
isFileImage, isFileImage,
isFilePDF,
makeExcerpt, makeExcerpt,
pathWithoutFilename,
removeDiacritics, removeDiacritics,
stringsToRegex, stringsToRegex,
} from '../tools/utils' } from '../tools/utils'
import ResultItemContainer from './ResultItemContainer.svelte' import ResultItemContainer from './ResultItemContainer.svelte'
import { setIcon } from 'obsidian'
export let selected = false export let selected = false
export let note: ResultNote export let note: ResultNote
let imagePath: string | null = null let imagePath: string | null = null
let title = '' let title = ''
let notePath = ''
let elFolderPathIcon: HTMLElement
let elFilePathIcon: HTMLElement
$: { $: {
imagePath = null imagePath = null
@@ -31,10 +39,23 @@
$: cleanedContent = makeExcerpt(note.content, note.matches[0]?.offset ?? -1) $: cleanedContent = makeExcerpt(note.content, note.matches[0]?.offset ?? -1)
$: glyph = false //cacheManager.getLiveDocument(note.path)?.doesNotExist $: glyph = false //cacheManager.getLiveDocument(note.path)?.doesNotExist
$: { $: {
title = settings.showShortName ? note.basename : note.path title = note.basename
notePath = pathWithoutFilename(note.path)
if (settings.ignoreDiacritics) { if (settings.ignoreDiacritics) {
title = removeDiacritics(title) 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')
}
} }
</script> </script>
@@ -44,13 +65,15 @@
on:click on:click
on:mousemove on:mousemove
selected="{selected}"> selected="{selected}">
<div style="display:flex">
<div>
<div> <div>
<div class="omnisearch-result__title-container">
<span class="omnisearch-result__title"> <span class="omnisearch-result__title">
{@html title.replace(reg, highlighter)} <span bind:this="{elFilePathIcon}"></span>
</span> <span>{@html title.replace(reg, highlighterGroups)}</span>
<span class="omnisearch-result__extension"
>.{getExtension(note.path)}</span>
<!-- Counter -->
{#if note.matches.length > 0} {#if note.matches.length > 0}
<span class="omnisearch-result__counter"> <span class="omnisearch-result__counter">
{note.matches.length}&nbsp;{note.matches.length > 1 {note.matches.length}&nbsp;{note.matches.length > 1
@@ -58,17 +81,30 @@
: 'match'} : 'match'}
</span> </span>
{/if} {/if}
</span>
</div> </div>
<!-- Folder path -->
{#if notePath}
<div class="omnisearch-result__folder-path">
<span bind:this="{elFolderPathIcon}"></span>
<span>{notePath}</span>
</div>
{/if}
<div style="display: flex; flex-direction: row;">
{#if $showExcerpt} {#if $showExcerpt}
<div class="omnisearch-result__body"> <div class="omnisearch-result__body">
{@html cleanedContent.replace(reg, highlighter)} {@html cleanedContent.replace(reg, highlighterGroups)}
</div> </div>
{/if} {/if}
</div>
<!-- Image -->
{#if imagePath} {#if imagePath}
<div class="omnisearch-result__image-container">
<img style="width: 100px" src="{imagePath}" alt="" /> <img style="width: 100px" src="{imagePath}" alt="" />
</div>
{/if} {/if}
</div> </div>
</div>
</ResultItemContainer> </ResultItemContainer>

View File

@@ -8,6 +8,7 @@ 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 const regexStripQuotes = /^"|"$|^'|'$/g export const regexStripQuotes = /^"|"$|^'|'$/g
export const chsRegex = /[\u4e00-\u9fa5]/ export const chsRegex = /[\u4e00-\u9fa5]/
export const regexExtensions = /(?:^|\s)\.(\w+)/g
export const excerptBefore = 100 export const excerptBefore = 100
export const excerptAfter = 300 export const excerptAfter = 300

View File

@@ -17,7 +17,7 @@ import {
IndexingStepType, IndexingStepType,
isCacheEnabled, isCacheEnabled,
} from './globals' } from './globals'
import api from './tools/api' import api, { notifyOnIndexed } from './tools/api'
import { isFileIndexable } from './tools/utils' import { isFileIndexable } from './tools/utils'
import { database, OmnisearchCache } from './database' import { database, OmnisearchCache } from './database'
import * as NotesIndex from './notes-index' import * as NotesIndex from './notes-index'
@@ -210,6 +210,7 @@ export default class OmnisearchPlugin extends Plugin {
new Notice(`Omnisearch - Your files have been indexed.`) new Notice(`Omnisearch - Your files have been indexed.`)
} }
indexingStep.set(IndexingStepType.Done) indexingStep.set(IndexingStepType.Done)
notifyOnIndexed()
} }
} }

View File

@@ -31,11 +31,22 @@ const tokenize = (text: string): string[] => {
export class Omnisearch { export class Omnisearch {
public static readonly options: Options<IndexedDocument> = { public static readonly options: Options<IndexedDocument> = {
tokenize, 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) => processTerm: (term: string) =>
(settings.ignoreDiacritics ? removeDiacritics(term) : term).toLowerCase(), (settings.ignoreDiacritics ? removeDiacritics(term) : term).toLowerCase(),
idField: 'path', idField: 'path',
fields: [ fields: [
'basename', 'basename',
// Different from `path`, since `path` is the unique index and needs to include the filename
'directory',
'aliases', 'aliases',
'content', 'content',
'headings1', 'headings1',
@@ -168,6 +179,7 @@ export class Omnisearch {
combineWith: 'AND', combineWith: 'AND',
boost: { boost: {
basename: settings.weightBasename, basename: settings.weightBasename,
directory: settings.weightDirectory,
aliases: settings.weightBasename, aliases: settings.weightBasename,
headings1: settings.weightH1, headings1: settings.weightH1,
headings2: settings.weightH2, 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, // If the query does not return any result,
// retry but with a shorter prefix limit // retry but with a shorter prefix limit
if (!results.length) { if (!results.length) {
@@ -348,7 +367,7 @@ export class Omnisearch {
// Tags, starting with # // Tags, starting with #
...tags, ...tags,
].filter(w => w.length > 1) ].filter(w => w.length > 1 || /\p{Emoji}/u.test(w))
// console.log(foundWords) // console.log(foundWords)
const matches = this.getMatches( const matches = this.getMatches(

View File

@@ -1,6 +1,7 @@
import { settings } from '../settings' import { settings } from '../settings'
import { removeDiacritics, stripSurroundingQuotes } from '../tools/utils' import { removeDiacritics, stripSurroundingQuotes } from '../tools/utils'
import { parseQuery } from '../vendor/parse-query' import { parseQuery } from '../vendor/parse-query'
import { regexExtensions } from '../globals'
type QueryToken = { type QueryToken = {
/** /**
@@ -20,8 +21,13 @@ type QueryToken = {
export class Query { export class Query {
public segments: QueryToken[] = [] public segments: QueryToken[] = []
public exclusions: QueryToken[] = [] public exclusions: QueryToken[] = []
public extensions: string[] = []
constructor(text = '') { constructor(text = '') {
// Extract & remove extensions from the query
this.extensions = this.extractExtensions(text)
text = this.removeExtensions(text)
if (settings.ignoreDiacritics) text = removeDiacritics(text) if (settings.ignoreDiacritics) text = removeDiacritics(text)
const tokens = parseQuery(text.toLowerCase(), { tokenize: true }) const tokens = parseQuery(text.toLowerCase(), { tokenize: true })
this.exclusions = tokens.exclude.text this.exclusions = tokens.exclude.text
@@ -59,4 +65,19 @@ export class Query {
exact: stripped !== str, 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, '')
}
} }

View File

@@ -12,6 +12,7 @@ import type OmnisearchPlugin from './main'
interface WeightingSettings { interface WeightingSettings {
weightBasename: number weightBasename: number
weightDirectory: number
weightH1: number weightH1: number
weightH2: number weightH2: number
weightH3: number weightH3: number
@@ -32,8 +33,6 @@ export interface OmnisearchSettings extends WeightingSettings {
imagesIndexing: boolean imagesIndexing: boolean
/** Activate the small 🔍 button on Obsidian's ribbon */ /** Activate the small 🔍 button on Obsidian's ribbon */
ribbonIcon: boolean ribbonIcon: boolean
/** Display short filenames in search results, instead of the full path */
showShortName: boolean
/** Display the small contextual excerpt in search results */ /** Display the small contextual excerpt in search results */
showExcerpt: boolean showExcerpt: boolean
/** Render line returns with <br> in excerpts */ /** Render line returns with <br> 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 // Highlight results
new Setting(containerEl) new Setting(containerEl)
.setName('Highlight matching words in results') .setName('Highlight matching words in results')
@@ -333,6 +319,10 @@ export class SettingsTab extends PluginSettingTab {
) )
.addSlider(cb => this.weightSlider(cb, 'weightBasename')) .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) new Setting(containerEl)
.setName(`Headings level 1 (default: ${DEFAULT_SETTINGS.weightH1})`) .setName(`Headings level 1 (default: ${DEFAULT_SETTINGS.weightH1})`)
.addSlider(cb => this.weightSlider(cb, 'weightH1')) .addSlider(cb => this.weightSlider(cb, 'weightH1'))
@@ -390,7 +380,6 @@ export const DEFAULT_SETTINGS: OmnisearchSettings = {
PDFIndexing: false, PDFIndexing: false,
imagesIndexing: false, imagesIndexing: false,
showShortName: false,
ribbonIcon: true, ribbonIcon: true,
showExcerpt: true, showExcerpt: true,
renderLineReturnInExcerpts: true, renderLineReturnInExcerpts: true,
@@ -399,7 +388,8 @@ export const DEFAULT_SETTINGS: OmnisearchSettings = {
showPreviousQueryResults: true, showPreviousQueryResults: true,
simpleSearch: false, simpleSearch: false,
weightBasename: 2, weightBasename: 3,
weightDirectory: 2,
weightH1: 1.5, weightH1: 1.5,
weightH2: 1.3, weightH2: 1.3,
weightH3: 1.1, weightH3: 1.1,

View File

@@ -2,6 +2,7 @@ import type { ResultNote } from '../globals'
import { Query } from '../search/query' import { Query } from '../search/query'
import { searchEngine } from '../search/omnisearch' import { searchEngine } from '../search/omnisearch'
import { makeExcerpt } from './utils' import { makeExcerpt } from './utils'
import { refreshIndex } from '../notes-index'
type ResultNoteApi = { type ResultNoteApi = {
score: number score: number
@@ -17,6 +18,13 @@ export type SearchMatchApi = {
offset: number offset: number
} }
let notified = false
/**
* Callbacks to be called when the search index is ready
*/
let onIndexedCallbacks: Array<() => void> = []
function mapResults(results: ResultNote[]): ResultNoteApi[] { function mapResults(results: ResultNote[]): ResultNoteApi[] {
return results.map(result => { return results.map(result => {
const { score, path, basename, foundWords, matches, content } = result const { score, path, basename, foundWords, matches, content } = result
@@ -39,13 +47,27 @@ function mapResults(results: ResultNote[]): ResultNoteApi[] {
}) })
} }
async function search( async function search(q: string): Promise<ResultNoteApi[]> {
q: string,
options: Partial<{ excerpt: boolean }> = {}
): Promise<ResultNoteApi[]> {
const query = new Query(q) const query = new Query(q)
const raw = await searchEngine.getSuggestions(query) const raw = await searchEngine.getSuggestions(query)
return mapResults(raw) 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 }

View File

@@ -15,6 +15,7 @@ import {
regexLineSplit, regexLineSplit,
regexStripQuotes, regexStripQuotes,
regexYaml, regexYaml,
SPACE_OR_PUNCTUATION,
type SearchMatch, type SearchMatch,
} from '../globals' } from '../globals'
import { settings } from '../settings' import { settings } from '../settings'
@@ -25,6 +26,12 @@ export function highlighter(str: string): string {
return `<span class="${highlightClass}">${str}</span>` return `<span class="${highlightClass}">${str}</span>`
} }
export function highlighterGroups(...args: any[]) {
if (args[1] && args[2])
return `${args[1]}<span class="${highlightClass}">${args[2]}</span>`
return '&lt;no content&gt;'
}
export function escapeHTML(html: string): string { export function escapeHTML(html: string): string {
return html return html
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@@ -43,6 +50,12 @@ export function removeFrontMatter(text: string): string {
return text.replace(regexYaml, '') 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<void> { export function wait(ms: number): Promise<void> {
return new Promise(resolve => { return new Promise(resolve => {
setTimeout(resolve, ms) setTimeout(resolve, ms)
@@ -72,12 +85,16 @@ export function getAllIndices(text: string, regex: RegExp): SearchMatch[] {
*/ */
export function stringsToRegex(strings: string[]): RegExp { export function stringsToRegex(strings: string[]): RegExp {
if (!strings.length) return /^$/g if (!strings.length) return /^$/g
// \\b is "word boundary", and is not applied if the user uses the cm-chs-patch plugin // Default word split is not applied if the user uses the cm-chs-patch plugin
const joined = strings const joined =
.map(s => (getChsSegmenter() ? '' : '\\b') + escapeRegex(s)) '(' +
.join('|') (getChsSegmenter() ? '' : SPACE_OR_PUNCTUATION.source) +
const reg = new RegExp(`(${joined})`, 'gi') ')' +
// console.log(reg) '(' +
strings.map(s => escapeRegex(s)).join('|') +
')'
const reg = new RegExp(`${joined}`, 'giu')
return reg return reg
} }
@@ -249,13 +266,12 @@ export function isFileIndexable(path: string): boolean {
} }
export function isFileImage(path: string): boolean { export function isFileImage(path: string): boolean {
return ( const ext = getExtension(path)
path.endsWith('.png') || path.endsWith('.jpg') || path.endsWith('.jpeg') return ext === 'png' || ext === 'jpg' || ext === 'jpeg'
)
} }
export function isFilePDF(path: string): boolean { export function isFilePDF(path: string): boolean {
return path.endsWith('.pdf') return getExtension(path) === 'pdf'
} }
export function isFilePlaintext(path: string): boolean { export function isFilePlaintext(path: string): boolean {