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
- 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<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 = {

View File

@@ -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 {

View File

@@ -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": {

View File

@@ -1,6 +1,10 @@
<script lang="ts">
import type { ResultNote } from '../globals'
import { highlighter, makeExcerpt, stringsToRegex } from '../tools/utils'
import {
highlighterGroups,
makeExcerpt,
stringsToRegex,
} from '../tools/utils'
import ResultItemContainer from './ResultItemContainer.svelte'
export let offset: number
@@ -12,8 +16,12 @@
$: cleanedContent = makeExcerpt(note?.content ?? '', offset)
</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">
{@html cleanedContent.replace(reg, highlighter)}
{@html cleanedContent.replace(reg, highlighterGroups)}
</div>
</ResultItemContainer>

View File

@@ -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')
}
}
</script>
@@ -44,13 +65,15 @@
on:click
on:mousemove
selected="{selected}">
<div style="display:flex">
<div>
<div>
<div class="omnisearch-result__title-container">
<span class="omnisearch-result__title">
{@html title.replace(reg, highlighter)}
</span>
<span bind:this="{elFilePathIcon}"></span>
<span>{@html title.replace(reg, highlighterGroups)}</span>
<span class="omnisearch-result__extension"
>.{getExtension(note.path)}</span>
<!-- Counter -->
{#if note.matches.length > 0}
<span class="omnisearch-result__counter">
{note.matches.length}&nbsp;{note.matches.length > 1
@@ -58,17 +81,30 @@
: 'match'}
</span>
{/if}
</span>
</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}
<div class="omnisearch-result__body">
{@html cleanedContent.replace(reg, highlighter)}
{@html cleanedContent.replace(reg, highlighterGroups)}
</div>
{/if}
</div>
<!-- Image -->
{#if imagePath}
<div class="omnisearch-result__image-container">
<img style="width: 100px" src="{imagePath}" alt="" />
</div>
{/if}
</div>
</div>
</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 regexStripQuotes = /^"|"$|^'|'$/g
export const chsRegex = /[\u4e00-\u9fa5]/
export const regexExtensions = /(?:^|\s)\.(\w+)/g
export const excerptBefore = 100
export const excerptAfter = 300

View File

@@ -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()
}
}

View File

@@ -31,11 +31,22 @@ const tokenize = (text: string): string[] => {
export class Omnisearch {
public static readonly options: Options<IndexedDocument> = {
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(

View File

@@ -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, '')
}
}

View File

@@ -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 <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
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,

View File

@@ -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<ResultNoteApi[]> {
async function search(q: string): Promise<ResultNoteApi[]> {
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 }

View File

@@ -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 `<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 {
return html
.replaceAll('&', '&amp;')
@@ -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<void> {
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 {