First commit

This commit is contained in:
2025-06-21 13:22:54 +02:00
commit 42df9465cf
75 changed files with 14291 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
import { EventBus } from '../tools/event-bus'
describe('EventBus', () => {
it('should refuse the registering of invalid ctx/event names', () => {
const eventBus = new EventBus()
expect(() => eventBus.on('@', 'event', () => {})).toThrowError(
'Invalid context/event name - Cannot contain @'
)
expect(() => eventBus.on('context', '@', () => {})).toThrowError(
'Invalid context/event name - Cannot contain @'
)
})
it('should emit different events to the same context', () => {
// Arrange
const bus = new EventBus()
const cb = jest.fn()
bus.on('context', 'event1', cb)
bus.on('context', 'event2', cb)
// Act
bus.emit('event1', 'PARAM_1')
bus.emit('event2', 'PARAM_2')
// Assert
expect(cb).toHaveBeenCalledTimes(2)
expect(cb).toHaveBeenNthCalledWith(1, 'PARAM_1')
expect(cb).toHaveBeenNthCalledWith(2, 'PARAM_2')
})
it('should emit the same events to different contexts', () => {
// Arrange
const bus = new EventBus()
const cb1 = jest.fn()
const cb2 = jest.fn()
bus.on('context1', 'event', cb1)
bus.on('context2', 'event', cb2)
// Act
bus.emit('event', 'PARAM_1')
// Assert
expect(cb1).toHaveBeenCalledTimes(1)
expect(cb1).toHaveBeenNthCalledWith(1, 'PARAM_1')
expect(cb2).toHaveBeenCalledTimes(1)
expect(cb2).toHaveBeenNthCalledWith(1, 'PARAM_1')
})
it('should forward multiple arguments', () => {
// Arrange
const bus = new EventBus()
const cb = jest.fn()
bus.on('context', 'event', cb)
// Act
bus.emit('event', 'foo', 'bar')
// Assert
expect(cb).toHaveBeenCalledWith('foo', 'bar')
})
it('should not emit events for disabled contexts', () => {
// Arrange
const bus = new EventBus()
const cb = jest.fn()
bus.on('context', 'event', cb)
bus.disable('context')
// Act
bus.emit('event', 'foo', 'bar')
// Assert
expect(cb).not.toHaveBeenCalled()
})
it('should emit events for enabled contexts', () => {
// Arrange
const bus = new EventBus()
const cb = jest.fn()
bus.on('context', 'event', cb)
bus.disable('context')
bus.enable('context')
// Act
bus.emit('event', 'foo', 'bar')
// Assert
expect(cb).toHaveBeenCalledWith('foo', 'bar')
})
it('should unregister contexts', () => {
// Arrange
const bus = new EventBus()
const cb = jest.fn()
bus.on('context1', 'event', cb)
bus.on('context2', 'event', cb)
bus.off('context1')
// Act
bus.emit('event', 'foo', 'bar')
// Assert
expect(cb).toHaveBeenCalledTimes(1)
})
it('should unregister single events', () => {
// Arrange
const bus = new EventBus()
const cb1 = jest.fn()
const cb2 = jest.fn()
bus.on('context', 'event1', cb1)
bus.on('context', 'event2', cb2)
bus.off('context', 'event2')
// Act
bus.emit('event1')
bus.emit('event2')
// Assert
expect(cb1).toHaveBeenCalled()
expect(cb2).not.toHaveBeenCalled()
})
})
+52
View File
@@ -0,0 +1,52 @@
import { Query } from '../search/query'
describe('The Query class', () => {
const stringQuery =
"foo bar 'lorem ipsum' -baz dolor \"sit amet\" -'quoted exclusion'"
it('should correctly parse string queries', () => {
// Act
const query = new Query(stringQuery, {
ignoreDiacritics: true,
ignoreArabicDiacritics: true,
})
// Assert
const segments = query.query.text
expect(segments).toHaveLength(5)
expect(segments).toContain('foo')
expect(segments).toContain('bar')
expect(segments).toContain('lorem ipsum')
expect(segments).toContain('dolor')
expect(segments).toContain('sit amet')
const exclusions = query.query.exclude.text
expect(exclusions).toHaveLength(2)
expect(exclusions).toContain('baz')
expect(exclusions).toContain('quoted exclusion')
})
it('should not exclude words when there is no space before', () => {
// Act
const query = new Query('foo bar-baz', {
ignoreDiacritics: true,
ignoreArabicDiacritics: true,
})
// Assert
expect(query.query.exclude.text).toHaveLength(0)
})
describe('.getExactTerms()', () => {
it('should an array of strings containg "exact" values', () => {
// Act
const query = new Query(stringQuery, {
ignoreDiacritics: true,
ignoreArabicDiacritics: true,
})
// Assert
expect(query.getExactTerms()).toEqual(['lorem ipsum', 'sit amet'])
})
})
})
+49
View File
@@ -0,0 +1,49 @@
import type { CachedMetadata } from 'obsidian'
import { getAliasesFromMetadata } from '../tools/utils'
describe('Utils', () => {
describe('getAliasesFromMetadata', () => {
it('should return an empty array if no metadata is provided', () => {
// Act
const actual = getAliasesFromMetadata(null)
// Assert
expect(actual).toEqual([])
})
it('should return an empty array if no aliases are provided', () => {
// Act
const actual = getAliasesFromMetadata({})
// Assert
expect(actual).toEqual([])
})
it('should return the aliases array as-is', () => {
// Arrange
const metadata = {
frontmatter: { aliases: ['foo', 'bar'] },
} as unknown as CachedMetadata
// Act
const actual = getAliasesFromMetadata(metadata)
// Assert
expect(actual).toEqual(['foo', 'bar'])
})
it('should convert the aliases string into an array', () => {
// Arrange
const metadata = {
frontmatter: { aliases: 'foo, bar' },
} as unknown as CachedMetadata
// Act
const actual = getAliasesFromMetadata(metadata)
// Assert
expect(actual).toEqual(['foo', 'bar'])
})
it('should return an empty array if the aliases field is an empty string', () => {
// Arrange
const metadata = {
frontmatter: { aliases: '' },
} as unknown as CachedMetadata
// Act
const actual = getAliasesFromMetadata(metadata)
// Assert
expect(actual).toEqual([])
})
})
})
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts"></script>
<span class="suggestion-flair" aria-label="Not created yet, select to create"
><svg viewBox="0 0 100 100" class="add-note-glyph" width="16" height="16"
><path
fill="currentColor"
stroke="currentColor"
d="M23.3,6.7c-3.7,0-6.7,3-6.7,6.7v73.3c0,3.7,3,6.7,6.7,6.7h28.4c-3.2-4.8-5.1-10.5-5.1-16.7c0-16.6,13.4-30,30-30 c2.3,0,4.5,0.3,6.7,0.8V31.7c0-0.9-0.3-1.7-1-2.4L60.7,7.6c-0.6-0.6-1.5-1-2.4-1L23.3,6.7z M56.7,13L77,33.3H60 c-1.8,0-3.3-1.5-3.3-3.3L56.7,13z M76.7,53.3c-12.9,0-23.3,10.4-23.3,23.3S63.8,100,76.7,100S100,89.6,100,76.7 S89.6,53.3,76.7,53.3z M76.7,63.3c1.8,0,3.3,1.5,3.3,3.3v6.7h6.7c1.8,0,3.3,1.5,3.3,3.3c0,1.8-1.5,3.3-3.3,3.3H80v6.7 c0,1.8-1.5,3.3-3.3,3.3c-1.8,0-3.3-1.5-3.3-3.3V80h-6.7c-1.8,0-3.3-1.5-3.3-3.3s1.5-3.3,3.3-3.3h6.7v-6.7 C73.3,64.8,74.8,63.3,76.7,63.3L76.7,63.3z" /></svg
></span>
+66
View File
@@ -0,0 +1,66 @@
<script lang="ts">
import { debounce, Platform } from 'obsidian'
import { toggleInputComposition } from '../globals'
import { createEventDispatcher, tick } from 'svelte'
import type LocatorPlugin from '../main'
import { wait } from '../tools/utils'
export let initialValue = ''
export let placeholder = ''
export let plugin: LocatorPlugin
let initialSet = false
let value = ''
let elInput: HTMLInputElement
const dispatch = createEventDispatcher()
export function setInputValue(v: string): void {
value = v
}
function watchInitialValue(v: string): void {
if (v && !initialSet && !value) {
initialSet = true
value = v
selectInput()
}
}
$: watchInitialValue(initialValue)
function selectInput(_?: HTMLElement): void {
tick()
.then(async () => {
if (Platform.isMobileApp) await wait(200)
elInput.focus()
return tick()
})
.then(async () => {
if (Platform.isMobileApp) await wait(200)
elInput.select()
})
}
const debouncedOnInput = debounce(() => {
// If typing a query and not executing it,
// the next time we open the modal, the search field will be empty
plugin.searchHistory.addToHistory('')
dispatch('input', value)
}, 300)
</script>
<div class="omnisearch-input-container">
<div class="omnisearch-input-field">
<input
bind:this="{elInput}"
bind:value="{value}"
class="prompt-input"
on:compositionend="{_ => toggleInputComposition(false)}"
on:compositionstart="{_ => toggleInputComposition(true)}"
on:input="{debouncedOnInput}"
placeholder="{placeholder}"
spellcheck="false"
type="text"
use:selectInput />
</div>
<slot />
</div>
+6
View File
@@ -0,0 +1,6 @@
<script lang="ts">
</script>
<div class="prompt-results" on:mousedown={e => e.preventDefault()}>
<slot />
</div>
+223
View File
@@ -0,0 +1,223 @@
<script lang="ts">
import InputSearch from './InputSearch.svelte'
import {
Action,
eventBus,
excerptAfter,
type ResultNote,
type SearchMatch,
} from '../globals'
import { getCtrlKeyLabel, loopIndex } from '../tools/utils'
import { onDestroy, onMount, tick } from 'svelte'
import { MarkdownView, Platform } from 'obsidian'
import ModalContainer from './ModalContainer.svelte'
import {
LocatorInFileModal,
LocatorVaultModal,
} from '../components/modals'
import ResultItemInFile from './ResultItemInFile.svelte'
import { Query } from '../search/query'
import { openNote } from '../tools/notes'
import type LocatorPlugin from '../main'
export let plugin: LocatorPlugin
export let modal: LocatorInFileModal
export let parent: LocatorVaultModal | null = null
export let singleFilePath = ''
export let previousQuery: string | undefined
let searchQuery: string
let groupedOffsets: number[] = []
let selectedIndex = 0
let note: ResultNote | undefined
let query: Query
$: searchQuery = previousQuery ?? ''
onMount(() => {
eventBus.enable('infile')
eventBus.on('infile', Action.Enter, openSelection)
eventBus.on('infile', Action.OpenInNewPane, openSelectionInNewTab)
eventBus.on('infile', Action.ArrowUp, () => moveIndex(-1))
eventBus.on('infile', Action.ArrowDown, () => moveIndex(1))
eventBus.on('infile', Action.Tab, switchToVaultModal)
})
onDestroy(() => {
eventBus.disable('infile')
})
$: (async () => {
if (searchQuery) {
query = new Query(searchQuery, {
ignoreDiacritics: plugin.settings.ignoreDiacritics,
ignoreArabicDiacritics: plugin.settings.ignoreArabicDiacritics,
})
note =
(
await plugin.searchEngine.getSuggestions(query, {
singleFilePath,
})
)[0] ?? null
}
selectedIndex = 0
await scrollIntoView()
})()
$: {
if (note) {
let groups = getGroups(note.matches)
// If there are quotes in the search,
// only show results that match at least one of the quotes
const exactTerms = query.getExactTerms()
if (exactTerms.length) {
groups = groups.filter(group =>
exactTerms.every(exact =>
group.some(match => match.match.includes(exact))
)
)
}
groupedOffsets = groups.map(group => Math.round(group.first()!.offset))
}
}
/**
* Group together close matches to reduce the number of results
*/
function getGroups(matches: SearchMatch[]): SearchMatch[][] {
const groups: SearchMatch[][] = []
let lastOffset = -1
let count = 0 // Avoid infinite loops
while (++count < 100) {
const group = getGroupedMatches(matches, lastOffset, excerptAfter)
if (!group.length) break
lastOffset = group.last()!.offset
groups.push(group)
}
return groups
}
function getGroupedMatches(
matches: SearchMatch[],
offsetFrom: number,
maxLen: number
): SearchMatch[] {
const first = matches.find(m => m.offset > offsetFrom)
if (!first) return []
return matches.filter(
m => m.offset > offsetFrom && m.offset <= first.offset + maxLen
)
}
function moveIndex(dir: 1 | -1): void {
selectedIndex = loopIndex(selectedIndex + dir, groupedOffsets.length)
scrollIntoView()
}
async function scrollIntoView(): Promise<void> {
await tick()
const elem = document.querySelector(`[data-result-id="${selectedIndex}"]`)
elem?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
async function openSelectionInNewTab(): Promise<void> {
return openSelection(true)
}
async function openSelection(newTab = false): Promise<void> {
if (note) {
modal.close()
if (parent) parent.close()
// Open (or switch focus to) the note
const reg = plugin.textProcessor.stringsToRegex(note.foundWords)
reg.exec(note.content)
await openNote(plugin.app, note, reg.lastIndex, newTab)
// Move cursor to the match
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (!view) {
// Not an editable document, so no cursor to place
return
// throw new Error('OmniSearch - No active MarkdownView')
}
const offset = groupedOffsets[selectedIndex] ?? 0
const pos = view.editor.offsetToPos(offset)
pos.ch = 0
view.editor.setCursor(pos)
view.editor.scrollIntoView({
from: { line: pos.line - 10, ch: 0 },
to: { line: pos.line + 10, ch: 0 },
})
}
}
function switchToVaultModal(): void {
new LocatorVaultModal(plugin, searchQuery ?? previousQuery).open()
modal.close()
}
</script>
<InputSearch
plugin="{plugin}"
on:input="{e => (searchQuery = e.detail)}"
placeholder="Locator - File"
initialValue="{previousQuery}">
<div class="omnisearch-input-container__buttons">
{#if Platform.isMobile}
<button on:click="{switchToVaultModal}">Vault search</button>
{/if}
</div>
</InputSearch>
<ModalContainer>
{#if groupedOffsets.length && note}
{#each groupedOffsets as offset, i}
<ResultItemInFile
{plugin}
offset="{offset}"
note="{note}"
index="{i}"
selected="{i === selectedIndex}"
on:mousemove="{_e => (selectedIndex = i)}"
on:click="{evt => openSelection(evt.ctrlKey)}"
on:auxclick="{evt => {
if (evt.button == 1) openSelection(true)
}}" />
{/each}
{:else}
<div style="text-align: center;">
We found 0 results for your search here.
</div>
{/if}
</ModalContainer>
<div class="prompt-instructions">
<div class="prompt-instruction">
<span class="prompt-instruction-command">↑↓</span><span>to navigate</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">↵</span><span>to open</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">tab</span>
<span>to switch to Vault Search</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">esc</span>
{#if !!parent}
<span>to go back to Vault Search</span>
{:else}
<span>to close</span>
{/if}
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} ↵</span>
<span>to open in a new pane</span>
</div>
</div>
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { MarkdownView, Notice, Platform, TFile } from 'obsidian'
import { onDestroy, onMount, tick } from 'svelte'
import InputSearch from './InputSearch.svelte'
import ModalContainer from './ModalContainer.svelte'
import {
eventBus,
indexingStep,
IndexingStepType,
type ResultNote,
SPACE_OR_PUNCTUATION,
Action,
} from '../globals'
import { createNote, openNote } from '../tools/notes'
import {
getCtrlKeyLabel,
getAltKeyLabel,
getExtension,
isFilePDF,
loopIndex,
} from '../tools/utils'
import {
LocatorInFileModal,
type LocatorVaultModal,
} from '../components/modals'
import ResultItemVault from './ResultItemVault.svelte'
import { Query } from '../search/query'
import { cancelable, CancelablePromise } from 'cancelable-promise'
import { debounce } from 'lodash-es'
import type LocatorPlugin from '../main'
import LazyLoader from './lazy-loader/LazyLoader.svelte'
let {
modal,
previousQuery,
plugin,
}: {
modal: LocatorVaultModal
previousQuery?: string | undefined
plugin: LocatorPlugin
} = $props()
let selectedIndex = $state(0)
let historySearchIndex = 0
let searchQuery = $state(previousQuery ?? '')
let resultNotes: ResultNote[] = $state([])
let query: Query
let indexingStepDesc = $state('')
let searching = $state(true)
let refInput: InputSearch | undefined
let openInNewPaneKey: string = $state('')
let openInCurrentPaneKey: string = $state('')
let createInNewPaneKey: string = $state('')
let createInCurrentPaneKey: string = $state('')
let openInNewLeafKey: string = `${getCtrlKeyLabel()} ${getAltKeyLabel()} ↵`
const selectedNote = $derived(resultNotes[selectedIndex])
$effect(() => {
if (plugin.settings.openInNewPane) {
openInNewPaneKey = '↵'
openInCurrentPaneKey = getCtrlKeyLabel() + ' ↵'
createInNewPaneKey = 'Shift ↵'
createInCurrentPaneKey = getCtrlKeyLabel() + ' Shift ↵'
} else {
openInNewPaneKey = getCtrlKeyLabel() + ' ↵'
openInCurrentPaneKey = '↵'
createInNewPaneKey = getCtrlKeyLabel() + ' Shift ↵'
createInCurrentPaneKey = 'Shift ↵'
}
})
$effect(() => {
if (searchQuery) {
updateResultsDebounced()
} else {
searching = false
resultNotes = []
}
})
$effect(() => {
switch ($indexingStep) {
case IndexingStepType.LoadingCache:
indexingStepDesc = 'Loading cache...'
break
case IndexingStepType.ReadingFiles:
indexingStepDesc = 'Reading files...'
break
case IndexingStepType.IndexingFiles:
indexingStepDesc = 'Indexing files...'
break
case IndexingStepType.WritingCache:
updateResultsDebounced()
indexingStepDesc = 'Updating cache...'
break
default:
updateResultsDebounced()
indexingStepDesc = ''
break
}
})
onMount(async () => {
eventBus.enable('vault')
eventBus.on('vault', Action.Enter, openNoteAndCloseModal)
eventBus.on('vault', Action.OpenInBackground, openNoteInBackground)
eventBus.on('vault', Action.CreateNote, createNoteAndCloseModal)
eventBus.on('vault', Action.OpenInNewPane, openNoteInNewPane)
eventBus.on('vault', Action.InsertLink, insertLink)
eventBus.on('vault', Action.Tab, switchToInFileModal)
eventBus.on('vault', Action.ArrowUp, () => moveIndex(-1))
eventBus.on('vault', Action.ArrowDown, () => moveIndex(1))
eventBus.on('vault', Action.PrevSearchHistory, prevSearchHistory)
eventBus.on('vault', Action.NextSearchHistory, nextSearchHistory)
eventBus.on('vault', Action.OpenInNewLeaf, openNoteInNewLeaf)
await plugin.notesIndexer.refreshIndex()
await updateResultsDebounced()
})
onDestroy(() => {
eventBus.disable('vault')
})
async function prevSearchHistory() {
// Filter out the empty string, if it's there
const history = (await plugin.searchHistory.getHistory()).filter(s => s)
if (++historySearchIndex >= history.length) {
historySearchIndex = 0
}
searchQuery = history[historySearchIndex]
refInput?.setInputValue(searchQuery ?? '')
}
async function nextSearchHistory() {
const history = (await plugin.searchHistory.getHistory()).filter(s => s)
if (--historySearchIndex < 0) {
historySearchIndex = history.length ? history.length - 1 : 0
}
searchQuery = history[historySearchIndex]
refInput?.setInputValue(searchQuery ?? '')
}
let cancelableQuery: CancelablePromise<ResultNote[]> | null = null
async function updateResults() {
searching = true
// If search is already in progress, cancel it and start a new one
if (cancelableQuery) {
cancelableQuery.cancel()
cancelableQuery = null
}
query = new Query(searchQuery, {
ignoreDiacritics: plugin.settings.ignoreDiacritics,
ignoreArabicDiacritics: plugin.settings.ignoreArabicDiacritics,
})
cancelableQuery = cancelable(
new Promise(resolve => {
resolve(plugin.searchEngine.getSuggestions(query))
})
)
resultNotes = await cancelableQuery
selectedIndex = 0
await scrollIntoView()
searching = false
}
// Debounce this function to avoid multiple calls caused by Svelte reactivity
const updateResultsDebounced = debounce(updateResults, 0)
function onClick(evt?: MouseEvent | KeyboardEvent) {
if (!selectedNote) return
if (evt?.ctrlKey) {
openNoteInNewPane()
} else {
openNoteAndCloseModal()
}
modal.close()
}
function openNoteAndCloseModal(): void {
if (!selectedNote) return
openSearchResult(selectedNote)
modal.close()
}
function openNoteInBackground(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true)
}
function openNoteInNewPane(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true)
modal.close()
}
function openNoteInNewLeaf(): void {
if (!selectedNote) return
openSearchResult(selectedNote, true, true)
modal.close()
}
function saveCurrentQuery() {
if (searchQuery) {
plugin.searchHistory.addToHistory(searchQuery)
}
}
function openSearchResult(
note: ResultNote,
newPane = false,
newLeaf = false
) {
saveCurrentQuery()
const offset = note.matches?.[0]?.offset ?? 0
openNote(plugin.app, note, offset, newPane, newLeaf)
}
async function onClickCreateNote(_e: MouseEvent) {
await createNoteAndCloseModal()
}
async function createNoteAndCloseModal(opt?: {
newLeaf: boolean
}): Promise<void> {
if (searchQuery) {
try {
await createNote(plugin.app, searchQuery, opt?.newLeaf)
} catch (e) {
new Notice((e as Error).message)
return
}
modal.close()
}
}
function insertLink(): void {
if (!selectedNote) return
const file = plugin.app.vault
.getMarkdownFiles()
.find(f => f.path === selectedNote.path)
const active = plugin.app.workspace.getActiveFile()
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (!view?.editor) {
new Notice('Locator - Error - No active editor', 3000)
return
}
// Generate link
let link: string
if (file && active) {
link = plugin.app.fileManager.generateMarkdownLink(
file,
active.path,
'',
selectedNote.displayTitle
)
} else {
const maybeDisplayTitle =
selectedNote.displayTitle === '' ? '' : `|${selectedNote.displayTitle}`
link = `[[${selectedNote.basename}.${getExtension(
selectedNote.path
)}${maybeDisplayTitle}]]`
}
// Inject link
const cursor = view.editor.getCursor()
view.editor.replaceRange(link, cursor, cursor)
cursor.ch += link.length
view.editor.setCursor(cursor)
modal.close()
}
function switchToInFileModal(): void {
// Do nothing if the selectedNote is a PDF,
// or if there is 0 match (e.g indexing in progress)
if (
selectedNote &&
(isFilePDF(selectedNote?.path) || !selectedNote?.matches.length)
) {
return
}
saveCurrentQuery()
modal.close()
if (selectedNote) {
// Open in-file modal for selected search result
const file = plugin.app.vault.getAbstractFileByPath(selectedNote.path)
if (file && file instanceof TFile) {
new LocatorInFileModal(plugin, file, searchQuery).open()
}
} else {
// Open in-file modal for active file
const view = plugin.app.workspace.getActiveViewOfType(MarkdownView)
if (view?.file) {
new LocatorInFileModal(plugin, view.file, searchQuery).open()
}
}
}
function moveIndex(dir: 1 | -1): void {
selectedIndex = loopIndex(selectedIndex + dir, resultNotes.length)
scrollIntoView()
}
async function scrollIntoView(): Promise<void> {
await tick()
if (selectedNote) {
const elem = activeWindow.document.querySelector(
`[data-result-id="${selectedNote.path}"]`
)
elem?.scrollIntoView({ behavior: 'auto', block: 'nearest' })
}
}
</script>
<InputSearch
bind:this={refInput}
{plugin}
initialValue={searchQuery}
on:input={e => (searchQuery = e.detail)}
placeholder="Locator - Vault">
<div class="omnisearch-input-container__buttons">
{#if plugin.settings.showCreateButton}
<button on:click={onClickCreateNote}>Create note</button>
{/if}
{#if Platform.isMobile}
<button on:click={switchToInFileModal}>In-File search</button>
{/if}
</div>
</InputSearch>
{#if indexingStepDesc}
<div style="text-align: center; color: var(--text-accent); margin-top: 10px">
⏳ Work in progress: {indexingStepDesc}
</div>
{/if}
<ModalContainer>
{#each resultNotes as result, i}
<LazyLoader
height={100}
offset={500}
keep={true}
fadeOption={{ delay: 0, duration: 0 }}>
<ResultItemVault
{plugin}
selected={i === selectedIndex}
note={result}
on:mousemove={_ => (selectedIndex = i)}
on:click={onClick}
on:auxclick={evt => {
if (evt.button == 1) openNoteInNewPane()
}} />
</LazyLoader>
{/each}
<div style="text-align: center;">
{#if !resultNotes.length && searchQuery && !searching}
We found 0 results for your search here.
{#if plugin.settings.simpleSearch && searchQuery
.split(SPACE_OR_PUNCTUATION)
.some(w => w.length < 3)}
<br />
<span style="color: var(--text-accent); font-size: small">
You have enabled "Simpler Search" in the settings, try to type more
characters.
</span>
{/if}
{:else if searching}
Searching...
{/if}
</div>
</ModalContainer>
<div class="prompt-instructions">
<div class="prompt-instruction">
<span class="prompt-instruction-command">↑↓</span><span>to navigate</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getAltKeyLabel()} ↑↓</span>
<span>to cycle history</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInCurrentPaneKey}</span>
<span>to open</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">Tab</span>
<span>to switch to In-File Search</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInNewPaneKey}</span>
<span>to open in a new pane</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{openInNewLeafKey}</span>
<span>to open in a new split</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} o</span>
<span>to open in the background</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{createInCurrentPaneKey}</span>
<span>to create</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{createInNewPaneKey}</span>
<span>to create in a new pane</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getAltKeyLabel()} ↵</span>
<span>to insert a link</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">{getCtrlKeyLabel()} g</span>
<span>to toggle excerpts</span>
</div>
<div class="prompt-instruction">
<span class="prompt-instruction-command">Esc</span><span>to close</span>
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import GlyphAddNote from './GlyphAddNote.svelte'
export let id: string
export let selected = false
export let glyph = false
export let cssClass = ''
</script>
<div
data-result-id={id}
class="suggestion-item omnisearch-result {cssClass}"
class:is-selected={selected}
on:mousemove
on:click
on:keypress
on:auxclick>
{#if glyph}
<GlyphAddNote />
{/if}
<slot />
</div>
+24
View File
@@ -0,0 +1,24 @@
<script lang="ts">
import type { ResultNote } from '../globals'
import ResultItemContainer from './ResultItemContainer.svelte'
import type LocatorPlugin from '../main'
export let plugin: LocatorPlugin
export let offset: number
export let note: ResultNote
export let index = 0
export let selected = false
$: cleanedContent = plugin.textProcessor.makeExcerpt(note?.content ?? '', offset)
</script>
<ResultItemContainer
id="{index.toString()}"
on:auxclick
on:click
on:mousemove
selected="{selected}">
<div class="omnisearch-result__body">
{@html plugin.textProcessor.highlightText(cleanedContent, note.matches)}
</div>
</ResultItemContainer>
+227
View File
@@ -0,0 +1,227 @@
<script lang="ts">
import { showExcerpt } from '../settings/index'
import type { ResultNote } from '../globals'
import {
getExtension,
isFileCanvas,
isFileExcalidraw,
isFileImage,
isFilePDF,
pathWithoutFilename,
} from '../tools/utils'
import ResultItemContainer from './ResultItemContainer.svelte'
import type LocatorPlugin from '../main'
import { setIcon, TFile } from 'obsidian'
import { onMount } from 'svelte'
// Import icon utility functions
import {
loadIconData,
initializeIconPacks,
getIconNameForPath,
loadIconSVG,
getDefaultIconSVG,
} from '../tools/icon-utils'
export let selected = false
export let note: ResultNote
export let plugin: LocatorPlugin
let imagePath: string | null = null
let title = ''
let notePath = ''
let iconData = {}
let folderIconSVG: string | null = null
let fileIconSVG: string | null = null
let prefixToIconPack: { [prefix: string]: string } = {}
let iconsPath: string
let iconDataLoaded = false // Flag to indicate iconData is loaded
// Initialize icon data and icon packs once when the component mounts
onMount(async () => {
iconData = await loadIconData(plugin)
const iconPacks = await initializeIconPacks(plugin)
prefixToIconPack = iconPacks.prefixToIconPack
iconsPath = iconPacks.iconsPath
iconDataLoaded = true // Set the flag after iconData is loaded
})
// Reactive statement to call loadIcons() whenever the note changes and iconData is loaded
$: if (note && note.path && iconDataLoaded) {
;(async () => {
// Update title and notePath before loading icons
title = note.displayTitle || note.basename
notePath = pathWithoutFilename(note.path)
await loadIcons()
})()
}
async function loadIcons() {
// Load folder icon
const folderIconName = getIconNameForPath(notePath, iconData)
if (folderIconName) {
folderIconSVG = await loadIconSVG(
folderIconName,
plugin,
iconsPath,
prefixToIconPack
)
} else {
// Fallback to default folder icon
folderIconSVG = getDefaultIconSVG('folder', plugin)
}
// Load file icon
const fileIconName = getIconNameForPath(note.path, iconData)
if (fileIconName) {
fileIconSVG = await loadIconSVG(
fileIconName,
plugin,
iconsPath,
prefixToIconPack
)
} else {
// Fallback to default icons based on file type
fileIconSVG = getDefaultIconSVG(note.path, plugin)
}
}
// Svelte action to render SVG content with dynamic updates
function renderSVG(node: HTMLElement, svgContent: string) {
node.innerHTML = svgContent
return {
update(newSvgContent: string) {
node.innerHTML = newSvgContent
},
destroy() {
node.innerHTML = ''
},
}
}
let elFolderPathIcon: HTMLElement | null = null
let elFilePathIcon: HTMLElement | null = null
let elEmbedIcon: HTMLElement | null = null
$: {
imagePath = null
if (isFileImage(note.path)) {
const file = plugin.app.vault.getAbstractFileByPath(note.path)
if (file instanceof TFile) {
imagePath = plugin.app.vault.getResourcePath(file)
}
}
}
$: matchesTitle = plugin.textProcessor.getMatches(title, note.foundWords)
$: matchesNotePath = plugin.textProcessor.getMatches(
notePath,
note.foundWords
)
$: cleanedContent = plugin.textProcessor.makeExcerpt(
note.content,
note.matches[0]?.offset ?? -1
)
$: glyph = false //cacheManager.getLiveDocument(note.path)?.doesNotExist
$: {
title = note.displayTitle || note.basename
notePath = pathWithoutFilename(note.path)
// 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) || isFileExcalidraw(note.path)) {
setIcon(elFilePathIcon, 'layout-dashboard')
} else {
setIcon(elFilePathIcon, 'file')
}
}
if (elEmbedIcon) {
setIcon(elEmbedIcon, 'corner-down-right')
}
}
</script>
<ResultItemContainer
glyph="{glyph}"
id="{note.path}"
cssClass=" {note.isEmbed ? 'omnisearch-result__embed' : ''}"
on:auxclick
on:click
on:mousemove
selected="{selected}">
<div>
<div class="omnisearch-result__title-container">
<span class="omnisearch-result__title">
{#if note.isEmbed}
<span
bind:this="{elEmbedIcon}"
title="The document above is embedded in this note"></span>
{:else}
<!-- File Icon -->
{#if fileIconSVG}
<span class="omnisearch-result__icon" use:renderSVG="{fileIconSVG}"
></span>
{/if}
{/if}
<span>
{@html plugin.textProcessor.highlightText(title, matchesTitle)}
</span>
{#if !note.displayTitle}
<span class="omnisearch-result__extension">
.{getExtension(note.path)}
</span>
{/if}
<!-- Counter -->
{#if note.matches.length > 0}
<span class="omnisearch-result__counter">
{note.matches.length}&nbsp;{note.matches.length > 1
? 'matches'
: 'match'}
</span>
{/if}
</span>
</div>
<!-- Folder path -->
{#if notePath}
<div class="omnisearch-result__folder-path">
<!-- Folder Icon -->
{#if folderIconSVG}
<span class="omnisearch-result__icon" use:renderSVG="{folderIconSVG}"
></span>
{/if}
<span>
{@html plugin.textProcessor.highlightText(notePath, matchesNotePath)}
</span>
</div>
{/if}
<!-- Do not display the excerpt for embedding references -->
{#if !note.isEmbed}
<div style="display: flex; flex-direction: row;">
{#if $showExcerpt}
<div class="omnisearch-result__body">
{@html plugin.textProcessor.highlightText(
cleanedContent,
note.matches
)}
</div>
{/if}
<!-- Image -->
{#if imagePath}
<div class="omnisearch-result__image-container">
<img style="width: 100px" src="{imagePath}" alt="" />
</div>
{/if}
</div>
{/if}
</div>
</ResultItemContainer>
@@ -0,0 +1,208 @@
<div use:load class={rootClass} style="height: {rootInitialHeight}">
{#if loaded}
<div
in:fade={fadeOption || {}}
class={contentClass}
style={contentStyle}
>
<slot>Lazy load content</slot>
</div>
{#if !contentShow && placeholder}
<Placeholder {placeholder} {placeholderProps} />
{/if}
{:else if placeholder}
<Placeholder {placeholder} {placeholderProps} />
{/if}
</div>
<script>
// https://github.com/leafOfTree/svelte-lazy
import { fade } from 'svelte/transition';
import Placeholder from './Placeholder.svelte';
export let keep = false;
export let height = 0;
export let offset = 150;
export let fadeOption = {
delay: 0,
duration: 400,
};
export let resetHeightDelay = 0;
export let onload = null;
export let placeholder = null;
export let placeholderProps = null;
let className = '';
export { className as class };
const rootClass = 'svelte-lazy'
+ (className ? ' ' + className : '');
const contentClass = 'svelte-lazy-content';
const rootInitialHeight = getStyleHeight();
let loaded = false;
let contentShow = true;
$: contentStyle = !contentShow ? 'display: none' : '';
function load(node) {
setHeight(node);
const handler = createHandler(node);
addListeners(handler);
setTimeout(() => {
handler();
});
const observer = observeNode(node, handler);
return {
destroy: () => {
removeListeners(handler);
observer.unobserve(node);
},
};
}
function createHandler(node) {
const handler = throttle(e => {
const nodeTop = node.getBoundingClientRect().top;
const nodeBottom = node.getBoundingClientRect().bottom;
const expectedTop = getContainerHeight(e) + offset;
if (nodeTop <= expectedTop && nodeBottom > 0) {
loadNode(node);
} else if (!keep) {
unload(node)
}
}, 200);
return handler;
}
function observeNode(node, handler) {
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadNode(node);
}
})
observer.observe(node);
return observer;
}
function unload(node) {
setHeight(node);
loaded = false
}
function loadNode(node, handler) {
if (loaded) {
return;
}
loaded = true;
resetHeight(node);
if (onload) {
onload(node);
}
}
function addListeners(handler) {
document.addEventListener('scroll', handler, true);
window.addEventListener('resize', handler);
}
function removeListeners(handler) {
document.removeEventListener('scroll', handler, true);
window.removeEventListener('resize', handler);
}
function getStyleHeight() {
return (typeof height === 'number')
? height + 'px'
: height;
}
function setHeight(node) {
if (height) {
node.style.height = getStyleHeight();
}
}
function resetHeight(node) {
setTimeout(() => {
const isLoading = checkImgLoadingStatus(node);
if (!isLoading) {
node.style.height = 'auto';
}
// Add a delay to wait for remote resources like images to load
}, resetHeightDelay);
}
function checkImgLoadingStatus(node) {
const img = node.querySelector('img');
if (!img) {
return false
}
if (!img.complete) {
contentShow = false;
node.addEventListener('load', () => {
// Use auto height if loading successfully
contentShow = true;
node.style.height = 'auto';
}, { capture: true, once: true });
node.addEventListener('error', () => {
// Show content with fixed height if there is error
contentShow = true;
}, { capture: true, once: true });
return true;
}
if (img.naturalHeight === 0) {
// Use fixed height if img has zero height
return true;
}
return false;
}
function getContainerHeight(e) {
if (e?.target?.getBoundingClientRect) {
return e.target.getBoundingClientRect().bottom;
} else {
return window.innerHeight;
}
}
// From underscore souce code
function throttle(func, wait, options) {
let context, args, result;
let timeout = null;
let previous = 0;
if (!options) options = {};
const later = function() {
previous = options.leading === false ? 0 : new Date();
timeout = null;
result = func.apply(context, args);
if (!timeout) context = args = null;
};
return function(event) {
const now = new Date();
if (!previous && options.leading === false) previous = now;
const remaining = wait - (now - previous);
context = this;
args = arguments;
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
previous = now;
result = func.apply(context, args);
if (!timeout) context = args = null;
} else if (!timeout && options.trailing !== false) {
timeout = setTimeout(later, remaining);
}
return result;
};
}
</script>
@@ -0,0 +1,15 @@
{#if placeholder}
<div class={placeholderClass}>
{#if typeof placeholder === 'string'}
<div>{placeholder}</div>
{:else if ['function', 'object'].includes(typeof placeholder)}
<svelte:component this={placeholder} {...placeholderProps} />
{/if}
</div>
{/if}
<script>
export let placeholder = null;
export let placeholderProps = null;
const placeholderClass = 'svelte-lazy-placeholder';
</script>
+223
View File
@@ -0,0 +1,223 @@
import { MarkdownView, Modal, TFile } from 'obsidian'
import type { Modifier } from 'obsidian'
import ModalVault from './ModalVault.svelte'
import ModalInFile from './ModalInFile.svelte'
import { Action, eventBus, EventNames, isInputComposition } from '../globals'
import type LocatorPlugin from '../main'
import { mount, unmount } from 'svelte'
abstract class LocatorModal extends Modal {
protected constructor(plugin: LocatorPlugin) {
super(plugin.app)
const settings = plugin.settings
// Remove all the default modal's children
// 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('locator-modal', 'prompt')
this.modalEl.removeClass('modal')
this.modalEl.tabIndex = -1
// Setup events that can be listened through the event bus
// #region Up/Down navigation
this.scope.register([], 'ArrowDown', e => {
e.preventDefault()
eventBus.emit(Action.ArrowDown)
})
this.scope.register([], 'ArrowUp', e => {
e.preventDefault()
eventBus.emit(Action.ArrowUp)
})
// Ctrl+j/k
for (const key of [
{ k: 'J', dir: 'down' },
{ k: 'K', dir: 'up' },
] as const) {
for (const modifier of ['Ctrl', 'Mod'] as const) {
this.scope.register([modifier], key.k, _e => {
if (settings.vimLikeNavigationShortcut) {
// e.preventDefault()
eventBus.emit('arrow-' + key.dir)
}
})
}
}
// Ctrl+n/p
for (const key of [
{ k: 'N', dir: 'down' },
{ k: 'P', dir: 'up' },
] as const) {
for (const modifier of ['Ctrl', 'Mod'] as const) {
this.scope.register([modifier], key.k, _e => {
if (settings.vimLikeNavigationShortcut) {
// e.preventDefault()
eventBus.emit('arrow-' + key.dir)
}
})
}
}
// #endregion Up/Down navigation
let openInCurrentPaneKey: Modifier[]
let openInNewPaneKey: Modifier[]
let createInCurrentPaneKey: Modifier[]
let createInNewPaneKey: Modifier[]
let openInNewLeafKey: Modifier[] = ['Mod', 'Alt']
if (settings.openInNewPane) {
openInCurrentPaneKey = ['Mod']
openInNewPaneKey = []
createInCurrentPaneKey = ['Mod', 'Shift']
createInNewPaneKey = ['Shift']
} else {
openInCurrentPaneKey = []
openInNewPaneKey = ['Mod']
createInCurrentPaneKey = ['Shift']
createInNewPaneKey = ['Mod', 'Shift']
}
// Open in new pane
this.scope.register(openInNewPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.OpenInNewPane)
})
// Open in a new leaf
this.scope.register(openInNewLeafKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.OpenInNewLeaf)
})
// Insert link
this.scope.register(['Alt'], 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.InsertLink)
})
// Create a new note
this.scope.register(createInCurrentPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.CreateNote)
})
this.scope.register(createInNewPaneKey, 'Enter', e => {
e.preventDefault()
eventBus.emit(Action.CreateNote, { newLeaf: true })
})
// Open in current pane
this.scope.register(openInCurrentPaneKey, 'Enter', e => {
if (!isInputComposition()) {
// Check if the user is still typing
e.preventDefault()
eventBus.emit(Action.Enter)
}
})
// Open in background
this.scope.register(['Mod'], 'O', e => {
if (!isInputComposition()) {
// Check if the user is still typing
e.preventDefault()
eventBus.emit(Action.OpenInBackground)
}
})
this.scope.register([], 'Tab', e => {
e.preventDefault()
eventBus.emit(Action.Tab) // Switch context
})
// Search history
this.scope.register(['Alt'], 'ArrowDown', e => {
e.preventDefault()
eventBus.emit(Action.NextSearchHistory)
})
this.scope.register(['Alt'], 'ArrowUp', e => {
e.preventDefault()
eventBus.emit(Action.PrevSearchHistory)
})
// Context
this.scope.register(['Mod'], 'G', _e => {
eventBus.emit(EventNames.ToggleExcerpts)
})
}
}
export class LocatorVaultModal extends LocatorModal {
/**
* Instanciate the Locator vault modal
* @param plugin
* @param query The query to pre-fill the search field with
*/
constructor(plugin: LocatorPlugin, query?: string) {
super(plugin)
// Selected text in the editor
const selectedText = plugin.app.workspace
.getActiveViewOfType(MarkdownView)
?.editor.getSelection()
plugin.searchHistory.getHistory().then(history => {
// Previously searched query (if enabled in settings)
const previous = plugin.settings.showPreviousQueryResults
? history[0]
: null
// Instantiate and display the Svelte component
const cmp = mount(ModalVault, {
target: this.modalEl,
props: {
plugin,
modal: this,
previousQuery: query || selectedText || previous || '',
},
})
this.onClose = () => {
// Since the component is manually created,
// we also need to manually destroy it
unmount(cmp)
}
})
}
}
export class LocatorInFileModal extends LocatorModal {
constructor(
plugin: LocatorPlugin,
file: TFile,
searchQuery: string = '',
parent?: LocatorModal
) {
super(plugin)
const cmp = mount(ModalInFile, {
target: this.modalEl,
props: {
plugin,
modal: this,
singleFilePath: file.path,
parent: parent,
previousQuery: searchQuery,
},
})
if (parent) {
// Hide the parent vault modal, and show it back when this one is closed
parent.containerEl.toggleVisibility(false)
}
this.onClose = () => {
if (parent) {
parent.containerEl.toggleVisibility(true)
}
unmount(cmp)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import Dexie from 'dexie'
import type { AsPlainObject } from 'minisearch'
import type { DocumentRef } from './globals'
import { Notice } from 'obsidian'
import type LocatorPlugin from './main'
export class Database extends Dexie {
public static readonly dbVersion = 10
searchHistory!: Dexie.Table<{ id?: number; query: string }, number>
minisearch!: Dexie.Table<
{
date: string
paths: DocumentRef[]
data: AsPlainObject
},
string
>
embeds!: Dexie.Table<{ embedded: string; referencedBy: string[] }, string>
constructor(private plugin: LocatorPlugin) {
super(Database.getDbName(plugin.app.appId))
// Database structure
this.version(Database.dbVersion).stores({
searchHistory: '++id',
minisearch: 'date',
embeds: 'embedded',
})
}
private static getDbName(appId: string) {
return 'locator/cache/' + appId
}
//#endregion Table declarations
public async getMinisearchCache(): Promise<{
paths: DocumentRef[]
data: AsPlainObject
} | null> {
try {
const cachedIndex = (await this.plugin.database.minisearch.toArray())[0]
return cachedIndex
} catch (e) {
new Notice(
'Locator - Cache missing or invalid. Some freezes may occur while Locator indexes your vault.'
)
console.error('Locator - Error while loading Minisearch cache')
console.error(e)
return null
}
}
public async writeMinisearchCache(): Promise<void> {
const minisearchJson = this.plugin.searchEngine.getSerializedMiniSearch()
const paths = this.plugin.searchEngine.getSerializedIndexedDocuments()
const database = this.plugin.database
await database.minisearch.clear()
await database.minisearch.add({
date: new Date().toISOString(),
paths,
data: minisearchJson,
})
console.debug('Locator - Search cache written')
}
/**
* Deletes Locator databases that have an older version than the current one
*/
public async clearOldDatabases(): Promise<void> {
const toDelete = (await indexedDB.databases()).filter(
db =>
db.name === Database.getDbName(this.plugin.app.appId) &&
// version multiplied by 10 https://github.com/dexie/Dexie.js/issues/59
db.version !== Database.dbVersion * 10
)
if (toDelete.length) {
console.debug('Locator - Those IndexedDb databases will be deleted:')
for (const db of toDelete) {
if (db.name) {
indexedDB.deleteDatabase(db.name)
}
}
}
}
public async clearCache() {
await this.minisearch.clear()
await this.embeds.clear()
new Notice('Locator - Cache cleared. Please restart Obsidian.')
}
}
+119
View File
@@ -0,0 +1,119 @@
import { EventBus } from './tools/event-bus'
import { writable } from 'svelte/store'
import type { TFile } from 'obsidian'
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
export const K_DISABLE_OMNISEARCH = 'locator-disabled'
export const eventBus = new EventBus()
export const EventNames = {
ToggleExcerpts: 'toggle-excerpts',
} as const
export const enum IndexingStepType {
Done,
LoadingCache,
ReadingFiles,
IndexingFiles,
WritingCache,
}
export const enum Action {
Enter = 'enter',
OpenInBackground = 'open-in-background',
CreateNote = 'create-note',
OpenInNewPane = 'open-in-new-pane',
InsertLink = 'insert-link',
Tab = 'tab',
ArrowUp = 'arrow-up',
ArrowDown = 'arrow-down',
PrevSearchHistory = 'prev-search-history',
NextSearchHistory = 'next-search-history',
OpenInNewLeaf = 'open-in-new-leaf',
}
export const enum RecencyCutoff {
Disabled = '0',
Day = '1',
Week = '2',
Month = '3',
}
export type DocumentRef = { path: string; mtime: number }
export type IndexedDocument = {
path: string
basename: string
displayTitle: string
mtime: number
content: string
cleanedContent: string
aliases: string
tags: string[]
unmarkedTags: string[]
headings1: string
headings2: string
headings3: string
// TODO: reimplement this
doesNotExist?: boolean
parent?: string
}
export type SearchMatch = {
match: string
offset: number
}
export const isSearchMatch = (o: { offset?: number }): o is SearchMatch => {
return o.offset !== undefined
}
export const indexingStep = writable(IndexingStepType.Done)
export type ResultNote = {
score: number
path: string
basename: string
displayTitle: string
content: string
foundWords: string[]
matches: SearchMatch[]
isEmbed: boolean
}
let inComposition = false
export function toggleInputComposition(toggle: boolean): void {
inComposition = toggle
}
export function isInputComposition(): boolean {
return inComposition
}
export type TextExtractorApi = {
extractText: (file: TFile) => Promise<string>
canFileBeExtracted: (filePath: string) => boolean
}
export type AIImageAnalyzerAPI = {
analyzeImage: (file: TFile) => Promise<string>
canBeAnalyzed: (file: TFile) => boolean
}
export const SEPARATORS =
/[|\t\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]/
.toString()
.slice(1, -1)
export const SPACE_OR_PUNCTUATION = new RegExp(`${SEPARATORS}+`, 'u')
export const BRACKETS_AND_SPACE = /[|\[\]\(\)<>\{\} \t\n\r]/u
+339
View File
@@ -0,0 +1,339 @@
import {
App,
Notice,
Platform,
Plugin,
type PluginManifest,
TFile,
} from 'obsidian'
import {
LocatorInFileModal,
LocatorVaultModal,
} from './components/modals'
import {
getDefaultSettings,
loadSettings,
SettingsTab,
showExcerpt,
} from './settings'
import type { LocatorSettings } from './settings/utils'
import { isCacheEnabled } from './settings/utils'
import { saveSettings } from './settings/utils'
import { isPluginDisabled } from './settings/utils'
import {
eventBus,
EventNames,
indexingStep,
IndexingStepType,
type TextExtractorApi,
type AIImageAnalyzerAPI,
} from './globals'
import { notifyOnIndexed, registerAPI } from './tools/api'
import { Database } from './database'
import { SearchEngine } from './search/search-engine'
import { DocumentsRepository } from './repositories/documents-repository'
import { logVerbose } from './tools/utils'
import { NotesIndexer } from './notes-indexer'
import { TextProcessor } from './tools/text-processing'
import { EmbedsRepository } from './repositories/embeds-repository'
import { SearchHistory } from './search/search-history'
export default class LocatorPlugin extends Plugin {
// FIXME: fix the type
public apiHttpServer: null | any = null
public settings: LocatorSettings = getDefaultSettings(this.app)
public readonly documentsRepository: DocumentsRepository
public readonly embedsRepository = new EmbedsRepository(this)
public readonly database = new Database(this)
public readonly notesIndexer = new NotesIndexer(this)
public readonly textProcessor = new TextProcessor(this)
public readonly searchEngine = new SearchEngine(this)
public readonly searchHistory = new SearchHistory(this)
private ribbonButton?: HTMLElement
private refreshIndexCallback?: (ev: FocusEvent) => any
constructor(app: App, manifest: PluginManifest) {
super(app, manifest)
this.documentsRepository = new DocumentsRepository(this)
}
async onload(): Promise<void> {
this.settings = await loadSettings(this)
this.addSettingTab(new SettingsTab(this))
if (!Platform.isMobile) {
import('./tools/api-server').then(
m => (this.apiHttpServer = m.getServer(this))
)
}
if (isPluginDisabled(this.app)) {
console.debug('Plugin disabled')
return
}
await cleanOldCacheFiles(this.app)
await this.database.clearOldDatabases()
registerAPI(this)
const settings = this.settings
if (settings.ribbonIcon) {
this.addRibbonButton()
}
eventBus.disable('vault')
eventBus.disable('infile')
eventBus.on('global', EventNames.ToggleExcerpts, () => {
showExcerpt.set(!settings.showExcerpt)
})
// Commands to display Locator modals
this.addCommand({
id: 'show-modal',
name: 'Vault search',
callback: () => {
new LocatorVaultModal(this).open()
},
})
this.addCommand({
id: 'show-modal-infile',
name: 'In-file search',
editorCallback: (_editor, view) => {
if (view.file) {
new LocatorInFileModal(this, view.file).open()
}
},
})
const searchEngine = this.searchEngine
this.app.workspace.onLayoutReady(async () => {
// Listeners to keep the search index up-to-date
this.registerEvent(
this.app.vault.on('create', file => {
if (!(file instanceof TFile)) return
if (this.notesIndexer.isFileIndexable(file.path)) {
logVerbose('Indexing new file', file.path)
searchEngine.addFromPaths([file.path])
this.embedsRepository.refreshEmbedsForNote(file.path)
}
})
)
this.registerEvent(
this.app.vault.on('delete', file => {
if (!(file instanceof TFile)) return
logVerbose('Removing file', file.path)
this.documentsRepository.removeDocument(file.path)
searchEngine.removeFromPaths([file.path])
this.embedsRepository.removeFile(file.path)
})
)
this.registerEvent(
this.app.vault.on('modify', async file => {
if (!(file instanceof TFile)) return
if (this.notesIndexer.isFileIndexable(file.path)) {
this.notesIndexer.flagNoteForReindex(file)
}
this.embedsRepository.refreshEmbedsForNote(file.path)
})
)
this.registerEvent(
this.app.vault.on('rename', async (file, oldPath) => {
if (!(file instanceof TFile)) return
if (this.notesIndexer.isFileIndexable(file.path)) {
logVerbose('Renaming file', file.path)
this.documentsRepository.removeDocument(oldPath)
await this.documentsRepository.addDocument(file.path)
searchEngine.removeFromPaths([oldPath])
await searchEngine.addFromPaths([file.path])
this.embedsRepository.renameFile(oldPath, file.path)
}
})
)
this.refreshIndexCallback = this.notesIndexer.refreshIndex.bind(
this.notesIndexer
)
addEventListener('blur', this.refreshIndexCallback!)
removeEventListener
await this.executeFirstLaunchTasks()
await this.populateIndex()
if (this.apiHttpServer && settings.httpApiEnabled) {
this.apiHttpServer.listen(settings.httpApiPort)
}
})
}
async executeFirstLaunchTasks(): Promise<void> {
const code = '1.21.0'
// if (settings.welcomeMessage !== code && getTextExtractor()) {
// const welcome = new DocumentFragment()
// welcome.createSpan({}, span => {
// span.innerHTML = `🔎 Locator can now index .docx and .xlsx documents. Don't forget to update Text Extractor and enable the toggle in Locator settings.`
// })
// new Notice(welcome, 20_000)
// }
this.settings.welcomeMessage = code
await this.saveData(this.settings)
}
async onunload(): Promise<void> {
// @ts-ignore
delete globalThis['locator']
if (this.refreshIndexCallback) {
removeEventListener('blur', this.refreshIndexCallback)
}
// Clear cache when disabling Locator
if (process.env.NODE_ENV === 'production') {
await this.database.clearCache()
}
this.apiHttpServer.close()
}
addRibbonButton(): void {
this.ribbonButton = this.addRibbonIcon('search', 'Locator', _evt => {
new LocatorVaultModal(this).open()
})
}
removeRibbonButton(): void {
if (this.ribbonButton) {
this.ribbonButton.parentNode?.removeChild(this.ribbonButton)
}
}
/**
* Plugin dependency - Chs Patch for Chinese word segmentation
* @returns
*/
public getChsSegmenter(): any | undefined {
return (this.app as any).plugins.plugins['cm-chs-patch']
}
/**
* Plugin dependency - Text Extractor
* @returns
*/
public getTextExtractor(): TextExtractorApi | undefined {
return (this.app as any).plugins?.plugins?.['text-extractor']?.api
}
/**
* Plugin dependency - Ai Image Analyzer
* @returns
*/
public getAIImageAnalyzer(): AIImageAnalyzerAPI | undefined {
return (this.app as any).plugins?.plugins?.['ai-image-analyzer']?.api
}
private async populateIndex(): Promise<void> {
console.time('Indexing total time')
indexingStep.set(IndexingStepType.ReadingFiles)
const files = this.app.vault
.getFiles()
.filter(f => this.notesIndexer.isFileIndexable(f.path))
console.debug(`${files.length} files total`)
console.debug(`Cache is ${isCacheEnabled() ? 'enabled' : 'disabled'}`)
// Map documents in the background
// Promise.all(files.map(f => cacheManager.addToLiveCache(f.path)))
const searchEngine = this.searchEngine
if (isCacheEnabled()) {
console.time('Loading index from cache')
indexingStep.set(IndexingStepType.LoadingCache)
const hasCache = await searchEngine.loadCache()
if (hasCache) {
console.timeEnd('Loading index from cache')
}
}
const diff = searchEngine.getDocumentsToReindex(
files.map(f => ({ path: f.path, mtime: f.stat.mtime }))
)
if (isCacheEnabled()) {
if (diff.toAdd.length) {
console.debug(
'Total number of files to add/update: ' + diff.toAdd.length
)
}
if (diff.toRemove.length) {
console.debug(
'Total number of files to remove: ' + diff.toRemove.length
)
}
}
if (diff.toAdd.length >= 1000 && isCacheEnabled()) {
new Notice(
`${diff.toAdd.length} files need to be indexed. Obsidian may experience stutters and freezes during the process`,
10_000
)
}
indexingStep.set(IndexingStepType.IndexingFiles)
searchEngine.removeFromPaths(diff.toRemove.map(o => o.path))
await searchEngine.addFromPaths(diff.toAdd.map(o => o.path))
if ((diff.toRemove.length || diff.toAdd.length) && isCacheEnabled()) {
indexingStep.set(IndexingStepType.WritingCache)
// Disable settings.useCache while writing the cache, in case it freezes
const cacheEnabled = this.settings.useCache
if (cacheEnabled && !this.settings.DANGER_forceSaveCache) {
this.settings.useCache = false
await saveSettings(this)
}
// Write the cache
await this.database.writeMinisearchCache()
await this.embedsRepository.writeToCache()
// Re-enable settings.caching
if (cacheEnabled) {
this.settings.useCache = true
await saveSettings(this)
}
}
console.timeEnd('Indexing total time')
if (diff.toAdd.length >= 1000 && isCacheEnabled()) {
new Notice(`Your files have been indexed.`)
}
indexingStep.set(IndexingStepType.Done)
notifyOnIndexed()
}
}
/**
* Read the files and feed them to Minisearch
*/
async function cleanOldCacheFiles(app: App) {
const toDelete = [
`${app.vault.configDir}/plugins/locator/searchIndex.json`,
`${app.vault.configDir}/plugins/locator/notesCache.json`,
`${app.vault.configDir}/plugins/locator/notesCache.data`,
`${app.vault.configDir}/plugins/locator/searchIndex.data`,
`${app.vault.configDir}/plugins/locator/historyCache.json`,
`${app.vault.configDir}/plugins/locator/pdfCache.data`,
]
for (const item of toDelete) {
if (await app.vault.adapter.exists(item)) {
try {
await app.vault.adapter.remove(item)
} catch (e) {}
}
}
}
+116
View File
@@ -0,0 +1,116 @@
import type { TAbstractFile } from 'obsidian'
import type LocatorPlugin from './main'
import { removeAnchors } from './tools/notes'
import type { IndexedDocument } from './globals'
import {
isFileCanvas,
isFileFromDataloom,
isFileImage,
isFilePDF,
logVerbose,
} from './tools/utils'
export class NotesIndexer {
private notesToReindex = new Set<TAbstractFile>()
constructor(private plugin: LocatorPlugin) {}
/**
* Updated notes are not reindexed immediately for performance reasons.
* They're added to a list, and reindex is done the next time we open Locator.
*/
public flagNoteForReindex(note: TAbstractFile): void {
this.notesToReindex.add(note)
}
public async refreshIndex(): Promise<void> {
for (const file of this.notesToReindex) {
logVerbose('Updating file', file.path)
await this.plugin.documentsRepository.addDocument(file.path)
}
const paths = [...this.notesToReindex].map(n => n.path)
if (paths.length) {
this.plugin.searchEngine.removeFromPaths(paths)
await this.plugin.searchEngine.addFromPaths(paths)
this.notesToReindex.clear()
}
}
public isFileIndexable(path: string): boolean {
return this.isFilenameIndexable(path) || this.isContentIndexable(path)
}
public isContentIndexable(path: string): boolean {
const settings = this.plugin.settings
const hasTextExtractor = !!this.plugin.getTextExtractor()
const hasAIImageAnalyzer = !!this.plugin.getAIImageAnalyzer()
const canIndexPDF = hasTextExtractor && settings.PDFIndexing
const canIndexImages = hasTextExtractor && settings.imagesIndexing
const canIndexImagesAI = hasAIImageAnalyzer && settings.aiImageIndexing
return (
this.isFilePlaintext(path) ||
isFileCanvas(path) ||
isFileFromDataloom(path) ||
(canIndexPDF && isFilePDF(path)) ||
(canIndexImages && isFileImage(path)) ||
(canIndexImagesAI && isFileImage(path))
)
}
public isFilenameIndexable(path: string): boolean {
return (
this.canIndexUnsupportedFiles() ||
this.isFilePlaintext(path) ||
isFileCanvas(path) ||
isFileFromDataloom(path)
)
}
public canIndexUnsupportedFiles(): boolean {
return (
this.plugin.settings.unsupportedFilesIndexing === 'yes' ||
(this.plugin.settings.unsupportedFilesIndexing === 'default' &&
!!this.plugin.app.vault.getConfig('showUnsupportedFiles'))
)
}
/**
* Index a non-existing note.
* Useful to find internal links that lead (yet) to nowhere
* @param name
* @param parent The note referencing the
*/
public generateIndexableNonexistingDocument(
name: string,
parent: string
): IndexedDocument {
name = removeAnchors(name)
const filename = name + (name.endsWith('.md') ? '' : '.md')
return {
path: filename,
basename: name,
displayTitle: '',
mtime: 0,
content: '',
cleanedContent: '',
tags: [],
unmarkedTags: [],
aliases: '',
headings1: '',
headings2: '',
headings3: '',
doesNotExist: true,
parent,
}
}
public isFilePlaintext(path: string): boolean {
return [...this.plugin.settings.indexedFileTypes, 'md'].some(t =>
path.endsWith(`.${t}`)
)
}
}
+261
View File
@@ -0,0 +1,261 @@
import { normalizePath, Notice, TFile } from 'obsidian'
import type { IndexedDocument } from '../globals'
import {
countError,
extractHeadingsFromCache,
getAliasesFromMetadata,
getTagsFromMetadata,
isFileCanvas,
isFileFromDataloom,
isFileImage,
isFileOffice,
isFilePDF,
logVerbose,
removeDiacritics,
stripMarkdownCharacters,
} from '../tools/utils'
import type { CanvasData } from 'obsidian/canvas'
import type LocatorPlugin from '../main'
import { getNonExistingNotes } from '../tools/notes'
export class DocumentsRepository {
/**
* The "live cache", containing all indexed vault files
* in the form of IndexedDocuments
*/
private documents: Map<string, IndexedDocument> = new Map()
private errorsCount = 0
private errorsWarned = false
constructor(private plugin: LocatorPlugin) {
setInterval(() => {
if (this.errorsCount > 0) {
--this.errorsCount
}
}, 1000)
}
/**
* Set or update the live cache with the content of the given file.
* @param path
*/
public async addDocument(path: string): Promise<void> {
try {
const doc = await this.getAndMapIndexedDocument(path)
if (!doc.path) {
console.error(
`Missing .path field in IndexedDocument "${doc.basename}", skipping`
)
return
}
this.documents.set(path, doc)
this.plugin.embedsRepository.refreshEmbedsForNote(path)
} catch (e) {
console.warn(`Locator: Error while adding "${path}" to live cache`, e)
// Shouldn't be needed, but...
this.removeDocument(path)
countError()
}
}
public removeDocument(path: string): void {
this.documents.delete(path)
}
public async getDocument(path: string): Promise<IndexedDocument> {
if (this.documents.has(path)) {
return this.documents.get(path)!
}
logVerbose('Generating IndexedDocument from', path)
await this.addDocument(path)
const document = this.documents.get(path)
// Only happens if the cache is corrupted
if (!document) {
console.error('Locator', path, 'cannot be read')
countError()
}
// The document might be undefined, but this shouldn't stop the search from mostly working
return document!
}
/**
* This function is responsible for extracting the text from a file and
* returning it as an `IndexedDocument` object.
* @param path
*/
private async getAndMapIndexedDocument(
path: string
): Promise<IndexedDocument> {
path = normalizePath(path)
const app = this.plugin.app
const file = app.vault.getAbstractFileByPath(path)
if (!file) throw new Error(`Invalid file path: "${path}"`)
if (!(file instanceof TFile)) throw new Error(`Not a TFile: "${path}"`)
let content: string | null = null
const extractor = this.plugin.getTextExtractor()
const aiImageAnalyzer = this.plugin.getAIImageAnalyzer()
// ** Plain text **
// Just read the file content
if (this.plugin.notesIndexer.isFilePlaintext(path)) {
content = await app.vault.cachedRead(file)
}
// ** Canvas **
// Extract the text fields from the json
else if (isFileCanvas(path)) {
const fileContents = await app.vault.cachedRead(file)
const canvas: CanvasData = fileContents ? JSON.parse(fileContents) : {}
let texts: string[] = []
// Concatenate text from the canvas fields
for (const node of canvas.nodes ?? []) {
if (node.type === 'text') {
texts.push(node.text)
} else if (node.type === 'file') {
texts.push(node.file)
}
}
for (const edge of (canvas.edges ?? []).filter(e => !!e.label)) {
texts.push(edge.label!)
}
content = texts.join('\r\n')
}
// ** Dataloom plugin **
else if (isFileFromDataloom(path)) {
try {
const data = JSON.parse(await app.vault.cachedRead(file))
// data is a json object, we recursively iterate the keys
// and concatenate the values if the key is "markdown"
const texts: string[] = []
const iterate = (obj: any) => {
for (const key in obj) {
if (typeof obj[key] === 'object') {
iterate(obj[key])
} else if (key === 'content') {
texts.push(obj[key])
}
}
}
iterate(data)
content = texts.join('\r\n')
} catch (e) {
console.error('Locator: Error while parsing Dataloom file', path)
console.error(e)
}
}
// ** Image **
else if (
isFileImage(path) &&
((this.plugin.settings.imagesIndexing &&
extractor?.canFileBeExtracted(path)) ||
(this.plugin.settings.aiImageIndexing &&
aiImageAnalyzer?.canBeAnalyzed(file)))
) {
if (
this.plugin.settings.imagesIndexing &&
extractor?.canFileBeExtracted(path)
) {
content = await extractor.extractText(file)
}
if (
this.plugin.settings.aiImageIndexing &&
aiImageAnalyzer?.canBeAnalyzed(file)
) {
content = (await aiImageAnalyzer.analyzeImage(file)) + (content ?? '')
}
}
// ** PDF **
else if (
isFilePDF(path) &&
this.plugin.settings.PDFIndexing &&
extractor?.canFileBeExtracted(path)
) {
content = await extractor.extractText(file)
}
// ** Office document **
else if (
isFileOffice(path) &&
this.plugin.settings.officeIndexing &&
extractor?.canFileBeExtracted(path)
) {
content = await extractor.extractText(file)
}
// ** Unsupported files **
else if (this.plugin.notesIndexer.isFilenameIndexable(path)) {
content = file.path
}
if (content === null || content === undefined) {
// This shouldn't happen
console.warn(`Locator: ${content} content for file`, file.path)
content = ''
}
const metadata = app.metadataCache.getFileCache(file)
// Look for links that lead to non-existing files,
// and add them to the index.
if (metadata) {
const nonExisting = getNonExistingNotes(this.plugin.app, file, metadata)
for (const name of nonExisting.filter(o => !this.documents.has(o))) {
const doc =
this.plugin.notesIndexer.generateIndexableNonexistingDocument(
name,
file.path
)
// TODO: index non-existing note
}
// EXCALIDRAW
// Remove the json code
if (metadata.frontmatter?.['excalidraw-plugin']) {
const comments =
metadata.sections?.filter(s => s.type === 'comment') ?? []
for (const { start, end } of comments.map(c => c.position)) {
content =
content.substring(0, start.offset - 1) +
content.substring(end.offset)
}
}
}
let displayTitle: string
if (this.plugin.settings.displayTitle === '#heading') {
displayTitle = metadata?.headings?.find(h => h.level === 1)?.heading ?? ''
} else {
displayTitle =
metadata?.frontmatter?.[this.plugin.settings.displayTitle] ?? ''
}
const tags = getTagsFromMetadata(metadata)
return {
basename: file.basename,
displayTitle,
content,
/** Content without diacritics and markdown chars */
cleanedContent: stripMarkdownCharacters(removeDiacritics(content)),
path: file.path,
mtime: file.stat.mtime,
tags: tags,
unmarkedTags: tags.map(t => t.replace('#', '')),
aliases: getAliasesFromMetadata(metadata).join(''),
headings1: metadata
? extractHeadingsFromCache(metadata, 1).join(' ')
: '',
headings2: metadata
? extractHeadingsFromCache(metadata, 2).join(' ')
: '',
headings3: metadata
? extractHeadingsFromCache(metadata, 3).join(' ')
: '',
}
}
}
+106
View File
@@ -0,0 +1,106 @@
import { getLinkpath, Notice } from 'obsidian'
import type LocatorPlugin from '../main'
import { logVerbose } from '../tools/utils'
export class EmbedsRepository {
/** Map<embedded file, notes where the embed is referenced> */
private embeds: Map<string, Set<string>> = new Map()
constructor(private plugin: LocatorPlugin) {}
public addEmbed(embed: string, notePath: string): void {
if (!this.embeds.has(embed)) {
this.embeds.set(embed, new Set())
}
this.embeds.get(embed)!.add(notePath)
}
public removeFile(filePath: string): void {
// If the file is embedded
this.embeds.delete(filePath)
// If the file is a note referencing other files
this.refreshEmbedsForNote(filePath)
}
public renameFile(oldPath: string, newPath: string): void {
// If the file is embedded
if (this.embeds.has(oldPath)) {
this.embeds.set(newPath, this.embeds.get(oldPath)!)
this.embeds.delete(oldPath)
}
// If the file is a note referencing other files
this.embeds.forEach((referencedBy, _key) => {
if (referencedBy.has(oldPath)) {
referencedBy.delete(oldPath)
referencedBy.add(newPath)
}
})
}
public refreshEmbedsForNote(filePath: string): void {
this.embeds.forEach((referencedBy, _key) => {
if (referencedBy.has(filePath)) {
referencedBy.delete(filePath)
}
})
this.addEmbedsForNote(filePath)
}
public getEmbeds(pathEmbedded: string): string[] {
const embeds = this.embeds.has(pathEmbedded)
? [...this.embeds.get(pathEmbedded)!]
: []
return embeds
}
public async writeToCache(): Promise<void> {
logVerbose('Writing embeds to cache')
const database = this.plugin.database
const data: { embedded: string; referencedBy: string[] }[] = []
for (const [path, embedsList] of this.embeds) {
data.push({ embedded: path, referencedBy: [...embedsList] })
}
await database.embeds.clear()
await database.embeds.bulkAdd(data)
}
public async loadFromCache(): Promise<void> {
try {
const database = this.plugin.database
if (!database.embeds) {
logVerbose('No embeds in cache')
return
}
logVerbose('Loading embeds from cache')
const embedsArr = await database.embeds.toArray()
for (const { embedded: path, referencedBy: embeds } of embedsArr) {
for (const embed of embeds) {
this.addEmbed(path, embed)
}
}
} catch (e) {
this.plugin.database.clearCache()
console.error('Locator - Error while loading embeds cache')
new Notice('Locator - There was an error while loading the cache. Please restart Obsidian.')
}
}
private addEmbedsForNote(notePath: string): void {
// Get all embeds from the note
// and map them to TFiles to get the real path
const embeds = (
this.plugin.app.metadataCache.getCache(notePath)?.embeds ?? []
)
.map(embed =>
this.plugin.app.metadataCache.getFirstLinkpathDest(
getLinkpath(embed.link),
notePath
)
)
.filter(o => !!o)
for (const embed of embeds) {
this.addEmbed(embed!.path, notePath)
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import { removeDiacritics } from '../tools/utils'
import { parse } from 'search-query-parser'
const keywords = ['ext', 'path'] as const
type Keywords = {
[K in typeof keywords[number]]?: string[]
} & { text: string[] }
export class Query {
query: Keywords & {
exclude: Keywords
}
#inQuotes: string[]
constructor(text = '', options: { ignoreDiacritics: boolean, ignoreArabicDiacritics: boolean}) {
if (options.ignoreDiacritics) {
text = removeDiacritics(text, options.ignoreArabicDiacritics)
}
const parsed = parse(text.toLowerCase(), {
tokenize: true,
keywords: keywords as unknown as string[],
}) as unknown as typeof this.query
// Default values
parsed.text = parsed.text ?? []
parsed.exclude = parsed.exclude ?? {}
parsed.exclude.text = parsed.exclude.text ?? []
if (!Array.isArray(parsed.exclude.text)) {
parsed.exclude.text = [parsed.exclude.text]
}
// Remove empty excluded strings
parsed.exclude.text = parsed.exclude.text.filter(o => o.length)
// Make sure that all fields are string[]
for (const k of keywords) {
const v = parsed[k]
if (v) {
parsed[k] = Array.isArray(v) ? v : [v]
}
const e = parsed.exclude[k]
if (e) {
parsed.exclude[k] = Array.isArray(e) ? e : [e]
}
}
this.query = parsed
// Extract keywords starting with a dot...
const ext = this.query.text
.filter(o => o.startsWith('.'))
.map(o => o.slice(1))
// add them to the ext field...
this.query.ext = [...new Set([...ext, ...(this.query.ext ?? [])])]
// and remove them from the text field
this.query.text = this.query.text.filter(o => !o.startsWith('.'))
// Get strings in quotes, and remove the quotes
this.#inQuotes =
text.match(/"([^"]+)"/g)?.map(o => o.replace(/"/g, '')) ?? []
}
public isEmpty(): boolean {
for (const k of keywords) {
if (this.query[k]?.length) {
return false
}
if (this.query.text.length) {
return false
}
}
return true
}
public segmentsToStr(): string {
return this.query.text.join(' ')
}
public getTags(): string[] {
return this.query.text.filter(o => o.startsWith('#'))
}
public getTagsWithoutHashtag(): string[] {
return this.getTags().map(o => o.replace(/^#/, ''))
}
/**
*
* @returns An array of strings that are in quotes
*/
public getExactTerms(): string[] {
return [
...new Set(
[
...this.query.text.filter(o => o.split(' ').length > 1),
...this.#inQuotes,
].map(str => str.toLowerCase())
),
]
}
public getBestStringForExcerpt(): string {
// If we have quoted expressions, return the longest one
if (this.#inQuotes.length) {
return this.#inQuotes.sort((a, b) => b.length - a.length)[0] ?? ''
}
// Otherwise, just return the query as is
return this.segmentsToStr()
}
}
+554
View File
@@ -0,0 +1,554 @@
import MiniSearch, {
type AsPlainObject,
type Options,
type SearchResult,
} from 'minisearch'
import {
RecencyCutoff,
type DocumentRef,
type IndexedDocument,
type ResultNote,
} from '../globals'
import {
chunkArray,
countError,
logVerbose,
removeDiacritics,
} from '../tools/utils'
import { Notice } from 'obsidian'
import type { Query } from './query'
import { sortBy } from 'lodash-es'
import type LocatorPlugin from '../main'
import { Tokenizer } from './tokenizer'
export class SearchEngine {
private tokenizer: Tokenizer
private minisearch: MiniSearch
/** Map<path, mtime> */
private indexedDocuments: Map<string, number> = new Map()
// private previousResults: SearchResult[] = []
// private previousQuery: Query | null = null
constructor(protected plugin: LocatorPlugin) {
this.tokenizer = new Tokenizer(plugin)
this.minisearch = new MiniSearch(this.getOptions())
}
/**
* Return true if the cache is valid
*/
async loadCache(): Promise<boolean> {
await this.plugin.embedsRepository.loadFromCache()
const cache = await this.plugin.database.getMinisearchCache()
if (cache) {
this.minisearch = await MiniSearch.loadJSAsync(
cache.data,
this.getOptions()
)
this.indexedDocuments = new Map(cache.paths.map(o => [o.path, o.mtime]))
return true
}
console.log('Locator - No cache found')
return false
}
/**
* Returns the list of documents that need to be reindexed or removed,
* either because they are new, have been modified, or have been deleted
* @param docs
*/
getDocumentsToReindex(docs: DocumentRef[]): {
toAdd: DocumentRef[]
toRemove: DocumentRef[]
} {
const docsMap = new Map(docs.map(d => [d.path, d.mtime]))
const toAdd = docs.filter(
d =>
!this.indexedDocuments.has(d.path) ||
this.indexedDocuments.get(d.path) !== d.mtime
)
const toRemove = [...this.indexedDocuments]
.filter(
([path, mtime]) => !docsMap.has(path) || docsMap.get(path) !== mtime
)
.map(o => ({ path: o[0], mtime: o[1] }))
return { toAdd, toRemove }
}
/**
* Add notes/PDFs/images to the search index
* @param paths
*/
public async addFromPaths(paths: string[]): Promise<void> {
logVerbose('Adding files', paths)
let documents = (
await Promise.all(
paths.map(
async path => await this.plugin.documentsRepository.getDocument(path)
)
)
).filter(d => !!d?.path)
logVerbose('Sorting documents to first index markdown')
// Index markdown files first
documents = sortBy(documents, d => (d.path.endsWith('.md') ? 0 : 1))
// If a document is already added, discard it
this.removeFromPaths(
documents.filter(d => this.indexedDocuments.has(d.path)).map(d => d.path)
)
// Split the documents in smaller chunks to add them to minisearch
const chunkedDocs = chunkArray(documents, 500)
for (const docs of chunkedDocs) {
logVerbose('Indexing into search engine', docs)
// Update the list of indexed docs
docs.forEach(doc => this.indexedDocuments.set(doc.path, doc.mtime))
// Discard files that may have been already added (though it shouldn't happen)
const alreadyAdded = docs.filter(doc => this.minisearch.has(doc.path))
this.removeFromPaths(alreadyAdded.map(o => o.path))
// Add docs to minisearch
await this.minisearch.addAllAsync(docs)
}
}
/**
* Discard a document from minisearch
* @param paths
*/
public removeFromPaths(paths: string[]): void {
paths.forEach(p => this.indexedDocuments.delete(p))
// Make sure to not discard a file that we don't have
const existing = paths.filter(p => this.minisearch.has(p))
this.minisearch.discardAll(existing)
}
/**
* Searches the index for the given query,
* and returns an array of raw results
*/
public async search(
query: Query,
options: { prefixLength: number; singleFilePath?: string }
): Promise<SearchResult[]> {
const settings = this.plugin.settings
if (query.isEmpty()) {
// this.previousResults = []
// this.previousQuery = null
return []
}
logVerbose('=== New search ===')
logVerbose('Starting search for', query)
let fuzziness: number
switch (settings.fuzziness) {
case '0':
fuzziness = 0
break
case '1':
fuzziness = 0.1
break
default:
fuzziness = 0.2
break
}
const searchTokens = this.tokenizer.tokenizeForSearch(query.segmentsToStr())
logVerbose(JSON.stringify(searchTokens, null, 1))
let results = this.minisearch.search(searchTokens, {
prefix: term => term.length >= options.prefixLength,
// length <= 3: no fuzziness
// length <= 5: fuzziness of 10%
// length > 5: fuzziness of 20%
fuzzy: term =>
term.length <= 3 ? 0 : term.length <= 5 ? fuzziness / 2 : fuzziness,
boost: {
basename: settings.weightBasename,
aliases: settings.weightBasename,
displayTitle: settings.weightBasename,
directory: settings.weightDirectory,
headings1: settings.weightH1,
headings2: settings.weightH2,
headings3: settings.weightH3,
tags: settings.weightUnmarkedTags,
unmarkedTags: settings.weightUnmarkedTags,
},
// The query is already tokenized, don't tokenize again
tokenize: text => [text],
boostDocument(_id, _term, storedFields) {
if (
!storedFields?.mtime ||
settings.recencyBoost === RecencyCutoff.Disabled
) {
return 1
}
const mtime = storedFields?.mtime as number
const now = new Date().valueOf()
const daysElapsed = (now - mtime) / (24 * 3600)
// Documents boost
const cutoff = {
[RecencyCutoff.Day]: -3,
[RecencyCutoff.Week]: -0.3,
[RecencyCutoff.Month]: -0.1,
} as const
return 1 + Math.exp(cutoff[settings.recencyBoost] * daysElapsed)
},
})
logVerbose(`Found ${results.length} results`, results)
// Filter query results to only keep files that match query.query.ext (if any)
if (query.query.ext?.length) {
results = results.filter(r => {
// ".can" should match ".canvas"
const ext = '.' + r.id.split('.').pop()
return query.query.ext?.some(e =>
ext.startsWith(e.startsWith('.') ? e : '.' + e)
)
})
}
// Filter query results that match the path
if (query.query.path) {
results = results.filter(r =>
query.query.path?.some(p =>
(r.id as string).toLowerCase().includes(p.toLowerCase())
)
)
}
if (query.query.exclude.path) {
results = results.filter(
r =>
!query.query.exclude.path?.some(p =>
(r.id as string).toLowerCase().includes(p.toLowerCase())
)
)
}
if (!results.length) {
return []
}
if (options.singleFilePath) {
return results.filter(r => r.id === options.singleFilePath)
}
logVerbose(
'searching with downranked folders',
settings.downrankedFoldersFilters
)
// Hide or downrank files that are in Obsidian's excluded list
if (settings.hideExcluded) {
// Filter the files out
results = results.filter(
result =>
!(
this.plugin.app.metadataCache.isUserIgnored &&
this.plugin.app.metadataCache.isUserIgnored(result.id)
)
)
} else {
// Just downrank them
results.forEach(result => {
if (
this.plugin.app.metadataCache.isUserIgnored &&
this.plugin.app.metadataCache.isUserIgnored(result.id)
) {
result.score /= 10
}
})
}
// Extract tags from the query
const tags = query.getTags()
for (const result of results) {
const path = result.id
if (settings.downrankedFoldersFilters.length > 0) {
// downrank files that are in folders listed in the downrankedFoldersFilters
let downrankingFolder = false
settings.downrankedFoldersFilters.forEach(filter => {
if (path.startsWith(filter)) {
// we don't want the filter to match the folder sources, e.g.
// it needs to match a whole folder name
if (path === filter || path.startsWith(filter + '/')) {
logVerbose('searching with downranked folders in path: ', path)
downrankingFolder = true
}
}
})
if (downrankingFolder) {
result.score /= 10
}
const pathParts = path.split('/')
const pathPartsLength = pathParts.length
for (let i = 0; i < pathPartsLength; i++) {
const pathPart = pathParts[i]
if (settings.downrankedFoldersFilters.includes(pathPart)) {
result.score /= 10
break
}
}
}
const metadata = this.plugin.app.metadataCache.getCache(path)
if (metadata) {
// Boost custom properties
for (const { name, weight } of settings.weightCustomProperties) {
const values = metadata?.frontmatter?.[name]
if (values && result.terms.some(t => values.includes(t))) {
logVerbose(`Boosting field "${name}" x${weight} for ${path}`)
result.score *= weight
}
}
}
// Put the results with tags on top
for (const tag of tags) {
if ((result.tags ?? []).includes(tag)) {
result.score *= 100
}
}
}
logVerbose('Sorting and limiting results')
// Sort results and keep the 50 best
results = results.sort((a, b) => b.score - a.score).slice(0, 50)
logVerbose('Filtered results:', results)
if (results.length) logVerbose('First result:', results[0])
const documents = await Promise.all(
results.map(async result => {
const doc = await this.plugin.documentsRepository.getDocument(result.id)
if (!doc) {
console.warn(
`Locator - Note "${result.id}" not in the live cache`
)
countError(true)
}
return doc
})
)
// If the search query contains quotes, filter out results that don't have the exact match
const exactTerms = query.getExactTerms()
if (exactTerms.length) {
logVerbose('Filtering with quoted terms: ', exactTerms)
results = results.filter(r => {
const document = documents.find(d => d.path === r.id)
const title = document?.path.toLowerCase() ?? ''
const content = (document?.cleanedContent ?? '').toLowerCase()
return exactTerms.every(
q =>
content.includes(q) ||
removeDiacritics(
title,
this.plugin.settings.ignoreArabicDiacritics
).includes(q)
)
})
}
// If the search query contains exclude terms, filter out results that have them
const exclusions = query.query.exclude.text
if (exclusions.length) {
logVerbose('Filtering with exclusions')
results = results.filter(r => {
const content = (
documents.find(d => d.path === r.id)?.content ?? ''
).toLowerCase()
return exclusions.every(q => !content.includes(q))
})
}
logVerbose('Deduping')
// FIXME:
// Dedupe results - clutch for https://github.com/scambier/obsidian-locator/issues/129
results = results.filter(
(result, index, arr) => arr.findIndex(t => t.id === result.id) === index
)
// this.previousQuery = query
// this.previousResults = results
return 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
*/
public async getSuggestions(
query: Query,
options?: Partial<{ singleFilePath?: string }>
): Promise<ResultNote[]> {
// Get the raw results
let results: SearchResult[]
if (this.plugin.settings.simpleSearch) {
results = await this.search(query, {
prefixLength: 3,
singleFilePath: options?.singleFilePath,
})
} else {
results = await this.search(query, {
prefixLength: 1,
singleFilePath: options?.singleFilePath,
})
}
const documents = await Promise.all(
results.map(
async result =>
await this.plugin.documentsRepository.getDocument(result.id)
)
)
// Inject embeds for images, documents, and PDFs
let total = documents.length
for (let i = 0; i < total; i++) {
const doc = documents[i]
if (!doc) continue
const embeds = this.plugin.embedsRepository
.getEmbeds(doc.path)
.slice(0, this.plugin.settings.maxEmbeds)
// Inject embeds in the results
for (const embed of embeds) {
total++
const newDoc = await this.plugin.documentsRepository.getDocument(embed)
documents.splice(i + 1, 0, newDoc)
results.splice(i + 1, 0, {
id: newDoc.path,
score: 0,
terms: [],
queryTerms: [],
match: {},
isEmbed: true,
})
i++ // Increment i to skip the newly inserted document
}
}
// Map the raw results to get usable suggestions
const resultNotes = results.map(result => {
logVerbose('Locating matches for', result.id)
let note = documents.find(d => d.path === result.id)
if (!note) {
// throw new Error(`Locator - Note "${result.id}" not indexed`)
console.warn(`Locator - Note "${result.id}" not in the live cache`)
note = {
content: '',
basename: result.id,
path: result.id,
} as IndexedDocument
}
// Clean search matches that match quoted expressions,
// and inject those expressions instead
const foundWords = [
// Matching terms from the result,
// do not necessarily match the query
...result.terms,
// Quoted expressions
...query.getExactTerms(),
// Tags, starting with #
...query.getTags(),
]
logVerbose('Matching tokens:', foundWords)
logVerbose('Getting matches locations...')
const matches = this.plugin.textProcessor.getMatches(
note.content,
foundWords,
query
)
logVerbose(`Matches for note "${note.path}"`, matches)
const resultNote: ResultNote = {
score: result.score,
foundWords,
matches,
isEmbed: result.isEmbed,
...note,
}
return resultNote
})
logVerbose('Suggestions:', resultNotes)
return resultNotes
}
/**
* For cache saving
*/
public getSerializedMiniSearch(): AsPlainObject {
return this.minisearch.toJSON()
}
/**
* For cache saving
*/
public getSerializedIndexedDocuments(): { path: string; mtime: number }[] {
return Array.from(this.indexedDocuments).map(([path, mtime]) => ({
path,
mtime,
}))
}
private getOptions(): Options<IndexedDocument> {
return {
tokenize: this.tokenizer.tokenizeForIndexing.bind(this.tokenizer),
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) =>
(this.plugin.settings.ignoreDiacritics
? removeDiacritics(term, this.plugin.settings.ignoreArabicDiacritics)
: 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',
'headings2',
'headings3',
],
storeFields: ['tags', 'mtime'],
logger(_level, _message, code) {
if (code === 'version_conflict') {
new Notice(
'Locator - Your index cache may be incorrect or corrupted. If this message keeps appearing, go to Settings to clear the cache.',
5000
)
}
},
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import type LocatorPlugin from '../main'
export class SearchHistory {
/**
* Show an empty input field next time the user opens Locator modal
*/
private nextQueryIsEmpty = false
constructor(private plugin: LocatorPlugin) {}
public async addToHistory(query: string): Promise<void> {
if (!query) {
this.nextQueryIsEmpty = true
return
}
this.nextQueryIsEmpty = false
const database = this.plugin.database
let history = await database.searchHistory.toArray()
history = history.filter(s => s.query !== query).reverse()
history.unshift({ query })
history = history.slice(0, 10)
await database.searchHistory.clear()
await database.searchHistory.bulkAdd(history)
}
/**
* @returns The search history, in reverse chronological order
*/
public async getHistory(): Promise<ReadonlyArray<string>> {
const data = (await this.plugin.database.searchHistory.toArray())
.reverse()
.map(o => o.query)
if (this.nextQueryIsEmpty) {
data.unshift('')
}
return data
}
}
+97
View File
@@ -0,0 +1,97 @@
import type { QueryCombination } from 'minisearch'
import { BRACKETS_AND_SPACE, chsRegex, SPACE_OR_PUNCTUATION } from '../globals'
import { logVerbose, splitCamelCase, splitHyphens } from '../tools/utils'
import type LocatorPlugin from '../main'
const markdownLinkExtractor = require('markdown-link-extractor')
export class Tokenizer {
constructor(private plugin: LocatorPlugin) {}
/**
* Tokenization for indexing will possibly return more tokens than the original text.
* This is because we combine different methods of tokenization to get the best results.
* @param text
* @returns
*/
public tokenizeForIndexing(text: string): string[] {
try {
const words = this.tokenizeWords(text)
let urls: string[] = []
if (this.plugin.settings.tokenizeUrls) {
try {
urls = markdownLinkExtractor(text)
} catch (e) {
logVerbose('Error extracting urls', e)
}
}
let tokens = this.tokenizeTokens(text, { skipChs: true })
tokens = [...tokens.flatMap(token => [
token,
...splitHyphens(token),
...splitCamelCase(token),
]), ...words]
// Add urls
if (urls.length) {
tokens = [...tokens, ...urls]
}
// Remove duplicates
tokens = [...new Set(tokens)]
return tokens
} catch (e) {
console.error('Error tokenizing text, skipping document', e)
return []
}
}
/**
* Search tokenization will use the same tokenization methods as indexing,
* but will combine each group with "OR" operators
* @param text
* @returns
*/
public tokenizeForSearch(text: string): QueryCombination {
// Extract urls and remove them from the query
const urls: string[] = markdownLinkExtractor(text)
text = urls.reduce((acc, url) => acc.replace(url, ''), text)
const tokens = [...this.tokenizeTokens(text), ...urls].filter(Boolean)
return {
combineWith: 'OR',
queries: [
{ combineWith: 'AND', queries: tokens },
{
combineWith: 'AND',
queries: this.tokenizeWords(text).filter(Boolean),
},
{ combineWith: 'AND', queries: tokens.flatMap(splitHyphens) },
{ combineWith: 'AND', queries: tokens.flatMap(splitCamelCase) },
],
}
}
private tokenizeWords(text: string, { skipChs = false } = {}): string[] {
const tokens = text.split(BRACKETS_AND_SPACE)
if (skipChs) return tokens
return this.tokenizeChsWord(tokens)
}
private tokenizeTokens(text: string, { skipChs = false } = {}): string[] {
const tokens = text.split(SPACE_OR_PUNCTUATION)
if (skipChs) return tokens
return this.tokenizeChsWord(tokens)
}
private tokenizeChsWord(tokens: string[]): string[] {
const segmenter = this.plugin.getChsSegmenter()
if (!segmenter) return tokens
return tokens.flatMap(word =>
chsRegex.test(word) ? segmenter.cut(word, { search: true }) : [word]
)
}
}
+170
View File
@@ -0,0 +1,170 @@
// noinspection CssUnresolvedCustomProperty
import {
App,
Plugin,
PluginSettingTab,
Setting,
} from 'obsidian'
import { writable } from 'svelte/store'
import { K_DISABLE_OMNISEARCH, RecencyCutoff } from '../globals'
import type LocatorPlugin from '../main'
import { enableVerboseLogging } from '../tools/utils'
import { injectSettingsIndexing } from './settings-indexing'
import { type LocatorSettings, saveSettings } from './utils'
import { injectSettingsBehavior } from './settings-behavior'
import { injectSettingsUserInterface } from './settings-ui'
import { injectSettingsWeighting } from './settings-weighting'
import { injectSettingsHttp } from './settings-http'
import { injectSettingsDanger } from './settings-danger'
/**
* A store to reactively toggle the `showExcerpt` setting on the fly
*/
export const showExcerpt = writable(false)
export class SettingsTab extends PluginSettingTab {
plugin: LocatorPlugin
constructor(plugin: LocatorPlugin) {
super(plugin.app, plugin)
this.plugin = plugin
showExcerpt.subscribe(async v => {
settings.showExcerpt = v
await saveSettings(this.plugin)
})
}
display(): void {
const { containerEl } = this
const database = this.plugin.database
containerEl.empty()
if (this.app.loadLocalStorage(K_DISABLE_OMNISEARCH) == '1') {
const span = containerEl.createEl('span')
span.innerHTML = `<strong style="color: var(--text-accent)">⚠️ OMNISEARCH IS DISABLED ⚠️</strong>`
}
// Settings main title
containerEl.createEl('h1', { text: 'Locator' })
// Sponsor link - Thank you!
const divSponsor = containerEl.createDiv()
divSponsor.innerHTML = `
<iframe sandbox="allow-top-navigation-by-user-activation" src="https://github.com/sponsors/scambier/button" title="Sponsor scambier" height="35" width="116" style="border: 0;"></iframe>
<a href='https://ko-fi.com/B0B6LQ2C' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi2.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
`
injectSettingsIndexing(this.plugin, settings, containerEl)
containerEl.createEl('hr')
injectSettingsBehavior(this.plugin, settings, containerEl)
containerEl.createEl('hr')
injectSettingsUserInterface(this.plugin, settings, containerEl)
containerEl.createEl('hr')
injectSettingsWeighting(this.plugin, settings, containerEl, this.display)
containerEl.createEl('hr')
injectSettingsHttp(this.plugin, settings, containerEl)
containerEl.createEl('hr')
injectSettingsDanger(this.plugin, settings, containerEl)
containerEl.createEl('hr')
//#region Debugging
new Setting(containerEl).setName('Debugging').setHeading()
new Setting(containerEl)
.setName('Enable verbose logging')
.setDesc(
'Adds a LOT of logs for debugging purposes. You also need to enable "Verbose" logging in the console to see these logs.'
)
.addToggle(toggle =>
toggle.setValue(settings.verboseLogging).onChange(async v => {
settings.verboseLogging = v
enableVerboseLogging(v)
await saveSettings(this.plugin)
})
)
//#endregion Debugging
//#region Danger Zone
//#endregion Danger Zone
}
}
export function getDefaultSettings(app: App): LocatorSettings {
return {
useCache: true,
hideExcluded: false,
recencyBoost: RecencyCutoff.Disabled,
downrankedFoldersFilters: [] as string[],
ignoreDiacritics: true,
ignoreArabicDiacritics: false,
indexedFileTypes: [] as string[],
displayTitle: '',
PDFIndexing: false,
officeIndexing: false,
imagesIndexing: false,
aiImageIndexing: false,
unsupportedFilesIndexing: 'default',
splitCamelCase: false,
openInNewPane: false,
vimLikeNavigationShortcut: app.vault.getConfig('vimMode') as boolean,
ribbonIcon: true,
showExcerpt: true,
maxEmbeds: 5,
renderLineReturnInExcerpts: true,
showCreateButton: false,
highlight: true,
showPreviousQueryResults: true,
simpleSearch: false,
tokenizeUrls: false,
fuzziness: '1',
weightBasename: 10,
weightDirectory: 7,
weightH1: 6,
weightH2: 5,
weightH3: 4,
weightUnmarkedTags: 2,
weightCustomProperties: [] as { name: string; weight: number }[],
httpApiEnabled: false,
httpApiPort: '51361',
httpApiNotice: true,
welcomeMessage: '',
verboseLogging: false,
DANGER_httpHost: null,
DANGER_forceSaveCache: false,
}
}
export let settings: LocatorSettings
// /**
// * @deprecated
// */
// export function getSettings(): LocatorSettings {
// if (!settings) {
// settings = Object.assign({}, getDefaultSettings()) as LocatorSettings
// }
// return settings
// }
export async function loadSettings(
plugin: Plugin
): Promise<LocatorSettings> {
settings = Object.assign(
{},
getDefaultSettings(plugin.app),
await plugin.loadData()
)
showExcerpt.set(settings.showExcerpt)
enableVerboseLogging(settings.verboseLogging)
return settings
}
+166
View File
@@ -0,0 +1,166 @@
import { Platform, Setting } from 'obsidian'
import type { LocatorSettings } from './utils'
import { saveSettings } from './utils'
import { htmlDescription, needsARestart } from './utils'
import type LocatorPlugin from 'src/main'
import { getCtrlKeyLabel } from 'src/tools/utils'
export function injectSettingsBehavior(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement
) {
const database = plugin.database
new Setting(containerEl).setName('Behavior').setHeading()
// Caching
new Setting(containerEl)
.setName('Save index to cache')
.setDesc(
'Enable caching to speed up indexing time. In rare cases, the cache write may cause a crash in Obsidian. This option will disable itself if it happens.'
)
.addToggle(toggle =>
toggle.setValue(settings.useCache).onChange(async v => {
settings.useCache = v
await saveSettings(plugin)
})
)
// Show previous query results
new Setting(containerEl)
.setName('Show previous query results')
.setDesc('Re-executes the previous query when opening Locator.')
.addToggle(toggle =>
toggle.setValue(settings.showPreviousQueryResults).onChange(async v => {
settings.showPreviousQueryResults = v
await saveSettings(plugin)
})
)
// Respect excluded files
new Setting(containerEl)
.setName('Respect Obsidian\'s "Excluded Files"')
.setDesc(
`By default, files that are in Obsidian\'s "Options > Files & Links > Excluded Files" list are downranked in results.
Enable this option to completely hide them.`
)
.addToggle(toggle =>
toggle.setValue(settings.hideExcluded).onChange(async v => {
settings.hideExcluded = v
await saveSettings(plugin)
})
)
// Downranked files
new Setting(containerEl)
.setName('Folders to downrank in search results')
.setDesc(
`Folders to downrank in search results. Files in these folders will be downranked in results. They will still be indexed for tags, unlike excluded files. Folders should be comma delimited.`
)
.addText(component => {
component
.setValue(settings.downrankedFoldersFilters.join(','))
.setPlaceholder('Example: src,p2/dir')
.onChange(async v => {
let folders = v.split(',')
folders = folders.map(f => f.trim())
settings.downrankedFoldersFilters = folders
await saveSettings(plugin)
})
})
// Split CamelCaseWords
new Setting(containerEl)
.setName('Split CamelCaseWords')
.setDesc(
htmlDescription(`Enable this if you want to be able to search for CamelCaseWords as separate words.<br/>
⚠️ <span style="color: var(--text-accent)">Changing this setting will clear the cache.</span><br>
${needsARestart}`)
)
.addToggle(toggle =>
toggle.setValue(settings.splitCamelCase).onChange(async v => {
await database.clearCache()
settings.splitCamelCase = v
await saveSettings(plugin)
})
)
// Simpler search
new Setting(containerEl)
.setName('Simpler search')
.setDesc(
`Enable this if Obsidian often freezes while making searches.
Words shorter than 3 characters won't be used as prefixes; this can reduce search delay but will return fewer results.`
)
.addToggle(toggle =>
toggle.setValue(settings.simpleSearch).onChange(async v => {
settings.simpleSearch = v
await saveSettings(plugin)
})
)
// Extract URLs
// Crashes on iOS
if (!Platform.isIosApp) {
new Setting(containerEl)
.setName('Tokenize URLs')
.setDesc(
`Enable this if you want to be able to search for URLs as separate words.
This setting has a strong impact on indexing performance, and can crash Obsidian under certain conditions.`
)
.addToggle(toggle =>
toggle.setValue(settings.tokenizeUrls).onChange(async v => {
settings.tokenizeUrls = v
await saveSettings(plugin)
})
)
}
// Open in new pane
new Setting(containerEl)
.setName('Open in new pane')
.setDesc('Open and create files in a new pane instead of the current pane.')
.addToggle(toggle =>
toggle.setValue(settings.openInNewPane).onChange(async v => {
settings.openInNewPane = v
await saveSettings(plugin)
})
)
// Set Vim like navigation keys
new Setting(containerEl)
.setName('Set Vim like navigation keys')
.setDesc(
`Navigate down the results with ${getCtrlKeyLabel()} + J/N, or navigate up with ${getCtrlKeyLabel()} + K/P.`
)
.addToggle(toggle =>
toggle.setValue(settings.vimLikeNavigationShortcut).onChange(async v => {
settings.vimLikeNavigationShortcut = v
await saveSettings(plugin)
})
)
// Fuzziness
new Setting(containerEl)
.setName('Fuzziness')
.setDesc(
"Define the level of fuzziness for the search. The higher the fuzziness, the more results you'll get."
)
.addDropdown(dropdown =>
dropdown
.addOptions({
0: 'Exact match',
1: 'Not too fuzzy',
2: 'Fuzzy enough',
})
.setValue(settings.fuzziness)
.onChange(async v => {
if (!['0', '1', '2'].includes(v)) {
v = '2'
}
settings.fuzziness = v as '0' | '1' | '2'
await saveSettings(plugin)
})
)
}
+97
View File
@@ -0,0 +1,97 @@
import { Notice, Setting } from 'obsidian'
import type { LocatorSettings } from './utils'
import { isCacheEnabled } from './utils'
import { saveSettings } from './utils'
import { htmlDescription, isPluginDisabled, needsARestart } from './utils'
import type LocatorPlugin from 'src/main'
import { K_DISABLE_OMNISEARCH } from 'src/globals'
export function injectSettingsDanger(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement
) {
const database = plugin.database
new Setting(containerEl).setName('Danger Zone').setHeading()
// Ignore diacritics
new Setting(containerEl)
.setName('Ignore diacritics')
.setDesc(
htmlDescription(`Normalize diacritics in search terms. Words like "brûlée" or "žluťoučký" will be indexed as "brulee" and "zlutoucky".<br/>
⚠️ <span style="color: var(--text-accent)">You probably should <strong>NOT</strong> disable this.</span><br>
⚠️ <span style="color: var(--text-accent)">Changing this setting will clear the cache.</span><br>
${needsARestart}`)
)
.addToggle(toggle =>
toggle.setValue(settings.ignoreDiacritics).onChange(async v => {
await database.clearCache()
settings.ignoreDiacritics = v
await saveSettings(plugin)
})
)
new Setting(containerEl)
.setName('Ignore Arabic diacritics (beta)')
.addToggle(toggle =>
toggle.setValue(settings.ignoreArabicDiacritics).onChange(async v => {
await database.clearCache()
settings.ignoreArabicDiacritics = v
await saveSettings(plugin)
})
)
// Disable Locator
const disableDesc = new DocumentFragment()
disableDesc.createSpan({}, span => {
span.innerHTML = `Disable Locator on this device only.<br>
${needsARestart}`
})
new Setting(containerEl)
.setName('Disable on this device')
.setDesc(disableDesc)
.addToggle(toggle =>
toggle.setValue(isPluginDisabled(plugin.app)).onChange(async v => {
if (v) {
plugin.app.saveLocalStorage(K_DISABLE_OMNISEARCH, '1')
new Notice('Locator - Disabled. Please restart Obsidian.')
} else {
plugin.app.saveLocalStorage(K_DISABLE_OMNISEARCH) // No value = unset
new Notice('Locator - Enabled. Please restart Obsidian.')
}
})
)
// Force save cache
new Setting(containerEl)
.setName('Force save the cache')
.setDesc(
htmlDescription(`Locator has a security feature that automatically disables cache writing if it cannot fully perform the operation.<br>
Use this option to force the cache to be saved, even if it causes a crash.<br>
⚠️ <span style="color: var(--text-accent)">Enabling this setting could lead to crash loops</span>`)
)
.addToggle(toggle =>
toggle.setValue(settings.DANGER_forceSaveCache).onChange(async v => {
settings.DANGER_forceSaveCache = v
await saveSettings(plugin)
})
)
// Clear cache data
if (isCacheEnabled()) {
new Setting(containerEl)
.setName('Clear cache data')
.setDesc(
htmlDescription(`Erase all Locator cache data.
Use this if Locator results are inconsistent, missing, or appear outdated.<br>
${needsARestart}`)
)
.addButton(btn => {
btn.setButtonText('Clear cache')
btn.onClick(async () => {
await database.clearCache()
})
})
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Platform, Setting } from 'obsidian'
import type { LocatorSettings } from './utils'
import { saveSettings } from './utils'
import { htmlDescription } from './utils'
import type LocatorPlugin from 'src/main'
export function injectSettingsHttp(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement
) {
if (!Platform.isMobile) {
new Setting(containerEl)
.setName('API Access Through HTTP')
.setHeading()
.setDesc(
htmlDescription(
`Locator can be used through a simple HTTP server (<a href="https://publish.obsidian.md/locator/Public+API+%26+URL+Scheme#HTTP+Server">more information</a>).`
)
)
new Setting(containerEl)
.setName('Enable the HTTP server')
.addToggle(toggle =>
toggle.setValue(settings.httpApiEnabled).onChange(async v => {
settings.httpApiEnabled = v
if (v) {
plugin.apiHttpServer.listen(settings.httpApiPort)
} else {
plugin.apiHttpServer.close()
}
await saveSettings(plugin)
})
)
new Setting(containerEl).setName('HTTP Port').addText(component => {
component
.setValue(settings.httpApiPort)
.setPlaceholder('51361')
.onChange(async v => {
if (parseInt(v) > 65535) {
v = settings.httpApiPort
component.setValue(settings.httpApiPort)
}
settings.httpApiPort = v
if (settings.httpApiEnabled) {
plugin.apiHttpServer.close()
plugin.apiHttpServer.listen(settings.httpApiPort)
}
await saveSettings(plugin)
})
})
new Setting(containerEl)
.setName('Show a notification when the server starts')
.setDesc(
'Will display a notification if the server is enabled, at Obsidian startup.'
)
.addToggle(toggle =>
toggle.setValue(settings.httpApiNotice).onChange(async v => {
settings.httpApiNotice = v
await saveSettings(plugin)
})
)
}
}
+161
View File
@@ -0,0 +1,161 @@
import { Setting } from 'obsidian'
import type { LocatorSettings } from './utils'
import { saveSettings } from './utils'
import { htmlDescription } from './utils'
import type LocatorPlugin from 'src/main'
import { debounce } from 'lodash-es'
export function injectSettingsIndexing(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement
) {
const textExtractor = plugin.getTextExtractor()
const aiImageAnalyzer = plugin.getAIImageAnalyzer()
const database = plugin.database
const clearCacheDebounced = debounce(async () => {
await database.clearCache()
}, 1000)
new Setting(containerEl)
.setName('Indexing')
.setHeading()
.setDesc(
htmlDescription(`⚠️ <span style="color: var(--text-accent)">Changing indexing settings will clear the cache, and requires a restart of Obsidian.</span><br/><br/>
${
textExtractor
? `👍 You have installed <a href="https://github.com/scambier/obsidian-text-extractor">Text Extractor</a>, Locator can use it to index PDFs and images contents.
<br />Text extraction only works on desktop, but the cache can be synchronized with your mobile device.`
: `⚠️ Locator requires <a href="https://github.com/scambier/obsidian-text-extractor">Text Extractor</a> to index PDFs and images.`
}
${
aiImageAnalyzer
? `<br/>👍 You have installed <a href="https://github.com/Swaggeroo/obsidian-ai-image-analyzer">AI Image Analyzer</a>, Locator can use it to index images contents with ai.`
: `<br/>⚠️ Locator requires <a href="https://github.com/Swaggeroo/obsidian-ai-image-analyzer">AI Image Analyzer</a> to index images with ai.`
}`)
)
// PDF Indexing
new Setting(containerEl)
.setName(`PDFs content indexing ${textExtractor ? '' : '⚠️ Disabled'}`)
.setDesc(
htmlDescription(
`Locator will use Text Extractor to index the content of your PDFs.`
)
)
.addToggle(toggle =>
toggle.setValue(settings.PDFIndexing).onChange(async v => {
await database.clearCache()
settings.PDFIndexing = v
await saveSettings(plugin)
})
)
.setDisabled(!textExtractor)
// Images Indexing
new Setting(containerEl)
.setName(`Images OCR indexing ${textExtractor ? '' : '⚠️ Disabled'}`)
.setDesc(
htmlDescription(
`Locator will use Text Extractor to OCR your images and index their content.`
)
)
.addToggle(toggle =>
toggle.setValue(settings.imagesIndexing).onChange(async v => {
await database.clearCache()
settings.imagesIndexing = v
await saveSettings(plugin)
})
)
.setDisabled(!textExtractor)
// Office Documents Indexing
const indexOfficesDesc = new DocumentFragment()
indexOfficesDesc.createSpan({}, span => {
span.innerHTML = `Locator will use Text Extractor to index the content of your office documents (currently <pre style="display:inline">.docx</pre> and <pre style="display:inline">.xlsx</pre>).`
})
new Setting(containerEl)
.setName(`Documents content indexing ${textExtractor ? '' : '⚠️ Disabled'}`)
.setDesc(indexOfficesDesc)
.addToggle(toggle =>
toggle.setValue(settings.officeIndexing).onChange(async v => {
await database.clearCache()
settings.officeIndexing = v
await saveSettings(plugin)
})
)
.setDisabled(!textExtractor)
// AI Images Indexing
const aiIndexImagesDesc = new DocumentFragment()
aiIndexImagesDesc.createSpan({}, span => {
span.innerHTML = `Locator will use AI Image Analyzer to index the content of your images with ai.`
})
new Setting(containerEl)
.setName(`Images AI indexing ${aiImageAnalyzer ? '' : '⚠️ Disabled'}`)
.setDesc(aiIndexImagesDesc)
.addToggle(toggle =>
toggle.setValue(settings.aiImageIndexing).onChange(async v => {
await database.clearCache()
settings.aiImageIndexing = v
await saveSettings(plugin)
})
)
.setDisabled(!aiImageAnalyzer)
// Index filenames of unsupported files
new Setting(containerEl)
.setName('Index paths of unsupported files')
.setDesc(
htmlDescription(`
Locator can index file<strong>names</strong> of "unsupported" files, such as e.g. <pre style="display:inline">.mp4</pre>
or non-extracted PDFs & images.<br/>
"Obsidian setting" will respect the value of "Files & Links > Detect all file extensions".`)
)
.addDropdown(dropdown => {
dropdown
.addOptions({ yes: 'Yes', no: 'No', default: 'Obsidian setting' })
.setValue(settings.unsupportedFilesIndexing)
.onChange(async v => {
await clearCacheDebounced()
;(settings.unsupportedFilesIndexing as any) = v
await saveSettings(plugin)
})
})
// Custom display title
new Setting(containerEl)
.setName('Set frontmatter property key as title')
.setDesc(
htmlDescription(`If you have a custom property in your notes that you want to use as the title in search results. If you set this to '#heading', then use the first heading from a file as the title.<br>
Leave empty to disable.`)
)
.addText(component => {
component.setValue(settings.displayTitle).onChange(async v => {
await clearCacheDebounced()
settings.displayTitle = v
await saveSettings(plugin)
})
})
// Additional text files to index
new Setting(containerEl)
.setName('Additional TEXT files to index')
.setDesc(
htmlDescription(`In addition to standard <code>md</code> files, Locator can also index other <strong style="color: var(--text-accent)">PLAINTEXT</strong> files.<br/>
Add extensions separated by a space, without the dot. Example: "<code>txt org csv</code>".<br />
⚠️ <span style="color: var(--text-accent)">Using extensions of non-plaintext files (like .pptx) WILL cause crashes,
because Locator will try to index their content.</span>`)
)
.addText(component => {
component
.setValue(settings.indexedFileTypes.join(' '))
.setPlaceholder('Example: txt org csv')
.onChange(async v => {
await database.clearCache()
settings.indexedFileTypes = v.split(' ')
await saveSettings(plugin)
})
})
}
+98
View File
@@ -0,0 +1,98 @@
import { Setting } from 'obsidian'
import type LocatorPlugin from 'src/main'
import { showExcerpt } from '.'
import type { LocatorSettings } from './utils'
import { saveSettings } from './utils'
import { htmlDescription } from './utils'
export function injectSettingsUserInterface(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement
) {
new Setting(containerEl).setName('User Interface').setHeading()
// Show Ribbon Icon
new Setting(containerEl)
.setName('Show ribbon button')
.setDesc('Add a button on the sidebar to open the Vault search modal.')
.addToggle(toggle =>
toggle.setValue(settings.ribbonIcon).onChange(async v => {
settings.ribbonIcon = v
await saveSettings(plugin)
if (v) {
plugin.addRibbonButton()
} else {
plugin.removeRibbonButton()
}
})
)
// Show context excerpt
new Setting(containerEl)
.setName('Show excerpts')
.setDesc(
'Shows the contextual part of the note that matches the search. Disable this to only show filenames in results.'
)
.addToggle(toggle =>
toggle.setValue(settings.showExcerpt).onChange(async v => {
showExcerpt.set(v)
})
)
// Show embeds
new Setting(containerEl)
.setName('Show embed references')
.setDesc(
htmlDescription(`Some results are <a href="https://help.obsidian.md/Linking+notes+and+files/Embed+files">embedded</a> in other notes.<br>
This setting controls the maximum number of embeds to show in the search results. Set to 0 to disable.<br>
Also works with Text Extractor for embedded images and documents.`)
)
.addSlider(cb => {
cb.setLimits(0, 10, 1)
.setValue(settings.maxEmbeds)
.setDynamicTooltip()
.onChange(async v => {
settings.maxEmbeds = v
await saveSettings(plugin)
})
})
// Keep line returns in excerpts
new Setting(containerEl)
.setName('Render line return in excerpts')
.setDesc('Activate this option to render line returns in result excerpts.')
.addToggle(toggle =>
toggle.setValue(settings.renderLineReturnInExcerpts).onChange(async v => {
settings.renderLineReturnInExcerpts = v
await saveSettings(plugin)
})
)
// Show "Create note" button
new Setting(containerEl)
.setName('Show "Create note" button')
.setDesc(
htmlDescription(`Shows a button next to the search input, to create a note.
Acts the same as the <code>shift ↵</code> shortcut, can be useful for mobile device users.`)
)
.addToggle(toggle =>
toggle.setValue(settings.showCreateButton).onChange(async v => {
settings.showCreateButton = v
await saveSettings(plugin)
})
)
// Highlight results
new Setting(containerEl)
.setName('Highlight matching words in results')
.setDesc(
'Will highlight matching results when enabled. See README for more customization options.'
)
.addToggle(toggle =>
toggle.setValue(settings.highlight).onChange(async v => {
settings.highlight = v
await saveSettings(plugin)
})
)
}
+127
View File
@@ -0,0 +1,127 @@
import { Setting, SliderComponent } from 'obsidian'
import { getDefaultSettings } from 'src/settings'
import type { LocatorSettings } from './utils'
import { saveSettings } from './utils'
import type { WeightingSettings } from './utils'
import type LocatorPlugin from 'src/main'
import { RecencyCutoff } from 'src/globals'
export function injectSettingsWeighting(
plugin: LocatorPlugin,
settings: LocatorSettings,
containerEl: HTMLElement,
refreshDisplay: () => void
) {
function weightSlider(
cb: SliderComponent,
key: keyof WeightingSettings
): void {
cb.setLimits(1, 10, 0.5)
.setValue(settings[key])
.setDynamicTooltip()
.onChange(async v => {
settings[key] = v
await saveSettings(plugin)
})
}
const defaultSettings = getDefaultSettings(plugin.app)
new Setting(containerEl).setName('Results weighting').setHeading()
new Setting(containerEl)
.setName(
`File name & declared aliases (default: ${defaultSettings.weightBasename})`
)
.addSlider(cb => weightSlider(cb, 'weightBasename'))
new Setting(containerEl)
.setName(`File directory (default: ${defaultSettings.weightDirectory})`)
.addSlider(cb => weightSlider(cb, 'weightDirectory'))
new Setting(containerEl)
.setName(`Headings level 1 (default: ${defaultSettings.weightH1})`)
.addSlider(cb => weightSlider(cb, 'weightH1'))
new Setting(containerEl)
.setName(`Headings level 2 (default: ${defaultSettings.weightH2})`)
.addSlider(cb => weightSlider(cb, 'weightH2'))
new Setting(containerEl)
.setName(`Headings level 3 (default: ${defaultSettings.weightH3})`)
.addSlider(cb => weightSlider(cb, 'weightH3'))
new Setting(containerEl)
.setName(`Tags (default: ${defaultSettings.weightUnmarkedTags})`)
.addSlider(cb => weightSlider(cb, 'weightUnmarkedTags'))
new Setting(containerEl)
.setName('Header properties fields')
.setDesc(
'You can set custom weights for values of header properties (e.g. "keywords"). Weights under 1.0 will downrank the results.'
)
for (let i = 0; i < settings.weightCustomProperties.length; i++) {
const item = settings.weightCustomProperties[i]
const el = new Setting(containerEl).setName((i + 1).toString() + '.')
el.settingEl.style.paddingLeft = '2em'
// TODO: add autocompletion from app.metadataCache.getAllPropertyInfos()
el.addText(text => {
text
.setPlaceholder('Property name')
.setValue(item.name)
.onChange(async v => {
item.name = v
await saveSettings(plugin)
})
})
.addSlider(cb => {
cb.setLimits(0.1, 5, 0.1)
.setValue(item.weight)
.setDynamicTooltip()
.onChange(async v => {
item.weight = v
await saveSettings(plugin)
})
})
// Remove the tag
.addButton(btn => {
btn.setButtonText('Remove')
btn.onClick(async () => {
settings.weightCustomProperties.splice(i, 1)
await saveSettings(plugin)
refreshDisplay()
})
})
}
// Add a new custom tag
new Setting(containerEl).addButton(btn => {
btn.setButtonText('Add a new property')
btn.onClick(_cb => {
settings.weightCustomProperties.push({ name: '', weight: 1 })
refreshDisplay()
})
})
new Setting(containerEl)
.setName('Recency boost (experimental)')
.setDesc(
'Files that have been modified more recently than [selected cutoff] are given a higher rank.'
)
.addDropdown(dropdown =>
dropdown
.addOptions({
[RecencyCutoff.Disabled]: 'Disabled',
[RecencyCutoff.Day]: '24 hours',
[RecencyCutoff.Week]: '7 days',
[RecencyCutoff.Month]: '30 days',
})
.setValue(settings.recencyBoost)
.onChange(async v => {
settings.recencyBoost = v as RecencyCutoff
await saveSettings(plugin)
})
)
}
+92
View File
@@ -0,0 +1,92 @@
import { App, Platform, Plugin } from 'obsidian'
import { K_DISABLE_OMNISEARCH, RecencyCutoff } from 'src/globals'
import { settings } from '.'
export function htmlDescription(innerHTML: string): DocumentFragment {
const desc = new DocumentFragment()
desc.createSpan({}, span => {
span.innerHTML = innerHTML
})
return desc
}
export const needsARestart = `<strong style="color: var(--text-accent)">Needs a restart to fully take effect.</strong>`
export interface WeightingSettings {
weightBasename: number
weightDirectory: number
weightH1: number
weightH2: number
weightH3: number
weightUnmarkedTags: number
}
export function isPluginDisabled(app: App): boolean {
return app.loadLocalStorage(K_DISABLE_OMNISEARCH) === '1'
}
export async function saveSettings(plugin: Plugin): Promise<void> {
await plugin.saveData(settings)
}
export function isCacheEnabled(): boolean {
return !Platform.isIosApp && settings.useCache
}
export interface LocatorSettings extends WeightingSettings {
weightCustomProperties: { name: string; weight: number }[]
/** Enables caching to speed up indexing */
useCache: boolean
/** Respect the "excluded files" Obsidian setting by downranking results ignored files */
hideExcluded: boolean
/** Boost more recent files */
recencyBoost: RecencyCutoff
/** downrank files in the given folders */
downrankedFoldersFilters: string[]
/** Ignore diacritics when indexing files */
ignoreDiacritics: boolean
ignoreArabicDiacritics: boolean
/** Extensions of plain text files to index, in addition to .md */
indexedFileTypes: string[]
/** Custom title field */
displayTitle: string
/** Enable PDF indexing */
PDFIndexing: boolean
/** Enable Images indexing */
imagesIndexing: boolean
/** Enable Office documents indexing */
officeIndexing: boolean
/** Enable image ai indexing */
aiImageIndexing: boolean
/** Enable indexing of unknown files */
unsupportedFilesIndexing: 'yes' | 'no' | 'default'
/** Activate the small 🔍 button on Obsidian's ribbon */
ribbonIcon: boolean
/** Display the small contextual excerpt in search results */
showExcerpt: boolean
/** Number of embeds references to display in search results */
maxEmbeds: number
/** Render line returns with <br> in excerpts */
renderLineReturnInExcerpts: boolean
/** Enable a "create note" button in the Vault Search modal */
showCreateButton: boolean
/** Re-execute the last query when opening Locator */
showPreviousQueryResults: boolean
/** Key for the welcome message when Obsidian is updated. A message is only shown once. */
welcomeMessage: string
/** If a query returns 0 result, try again with more relax conditions */
simpleSearch: boolean
tokenizeUrls: boolean
highlight: boolean
splitCamelCase: boolean
openInNewPane: boolean
verboseLogging: boolean
vimLikeNavigationShortcut: boolean
fuzziness: '0' | '1' | '2'
httpApiEnabled: boolean
httpApiPort: string
httpApiNotice: boolean
DANGER_httpHost: string | null
DANGER_forceSaveCache: boolean
}
+78
View File
@@ -0,0 +1,78 @@
import * as http from 'http'
import * as url from 'url'
import { Notice } from 'obsidian'
import type LocatorPlugin from '../main'
import { getApi } from './api'
export function getServer(plugin: LocatorPlugin) {
const api = getApi(plugin)
const server = http.createServer(async function (req, res) {
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader(
'Access-Control-Allow-Methods',
'GET, HEAD, POST, OPTIONS, PUT, PATCH, DELETE'
)
res.setHeader(
'Access-Control-Allow-Headers',
'Access-Control-Allow-Headers, Origin, Authorization,Accept,x-client-id, X-Requested-With, Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers, hypothesis-client-version'
)
res.setHeader('Access-Control-Allow-Credentials', 'true')
try {
if (req.url) {
// parse URL
const parsedUrl = url.parse(req.url, true)
if (parsedUrl.pathname === '/search') {
const q = parsedUrl.query.q as string
const results = await api.search(q)
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify(results))
} else {
res.end()
}
}
} catch (e) {
res.statusCode = 500
res.end(e)
}
})
return {
listen(port: string) {
console.debug(`Locator - Starting HTTP server on port ${port}`)
server.listen(
{
port: parseInt(port),
host: plugin.settings.DANGER_httpHost ?? 'localhost',
},
() => {
console.log(`Locator - Started HTTP server on port ${port}`)
if (plugin.settings.DANGER_httpHost && plugin.settings.DANGER_httpHost !== 'localhost') {
new Notice(`Locator - Started non-localhost HTTP server at ${plugin.settings.DANGER_httpHost}:${port}`, 120_000)
}
else if (plugin.settings.httpApiNotice) {
new Notice(`Locator - Started HTTP server on port ${port}`)
}
}
)
server.on('error', e => {
console.error(e)
new Notice(
`Locator - Cannot start HTTP server on ${port}. See console for more details.`
)
})
},
close() {
server.close()
console.log(`Locator - Terminated HTTP server`)
if (plugin.settings.httpApiEnabled && plugin.settings.httpApiNotice) {
new Notice(`Locator - Terminated HTTP server`)
}
},
}
}
export default getServer
export type StaticServer = ReturnType<typeof getServer>
+108
View File
@@ -0,0 +1,108 @@
import type { ResultNote } from '../globals'
import { Query } from '../search/query'
import type LocatorPlugin from '../main'
import { LocatorVaultModal } from '../components/modals'
type ResultNoteApi = {
score: number
vault: string
path: string
basename: string
foundWords: string[]
matches: SearchMatchApi[]
excerpt: string
}
export type SearchMatchApi = {
match: string
offset: number
}
let notified = false
/**
* Callbacks to be called when the search index is ready
*/
let onIndexedCallbacks: Array<() => void> = []
function mapResults(
plugin: LocatorPlugin,
results: ResultNote[]
): ResultNoteApi[] {
return results.map(result => {
const { score, path, basename, foundWords, matches, content } = result
const excerpt = plugin.textProcessor.makeExcerpt(
content,
matches[0]?.offset ?? -1
)
const res: ResultNoteApi = {
score,
vault: plugin.app.vault.getName(),
path,
basename,
foundWords,
matches: matches.map(match => {
return {
match: match.match,
offset: match.offset,
}
}),
excerpt: excerpt,
}
return res
})
}
export function notifyOnIndexed(): void {
notified = true
onIndexedCallbacks.forEach(cb => cb())
}
let registed = false
export function registerAPI(plugin: LocatorPlugin): void {
if (registed) {
return
}
registed = true
// Url scheme for obsidian://locator?query=foobar
plugin.registerObsidianProtocolHandler('locator', params => {
new LocatorVaultModal(plugin, params.query).open()
})
const api = getApi(plugin)
// Public api
// @ts-ignore
globalThis['locator'] = api
// Deprecated
;(plugin.app as any).plugins.plugins.locator.api = api
}
export function getApi(plugin: LocatorPlugin) {
return {
async search(q: string): Promise<ResultNoteApi[]> {
const query = new Query(q, {
ignoreDiacritics: plugin.settings.ignoreDiacritics,
ignoreArabicDiacritics: plugin.settings.ignoreArabicDiacritics,
})
const raw = await plugin.searchEngine.getSuggestions(query)
return mapResults(plugin, raw)
},
registerOnIndexed(cb: () => void): void {
onIndexedCallbacks.push(cb)
// Immediately call the callback if the indexing is already ready done
if (notified) {
cb()
}
},
unregisterOnIndexed(cb: () => void): void {
onIndexedCallbacks = onIndexedCallbacks.filter(o => o !== cb)
},
refreshIndex: plugin.notesIndexer.refreshIndex,
}
}
+66
View File
@@ -0,0 +1,66 @@
export type EventBusCallback = (...args: any[]) => any
export class EventBus {
private handlers: Map<string, EventBusCallback> = new Map()
private disabled: string[] = []
/**
* Adds a subscription for `event`, for the specified `context`.
* If a subscription for the same event in the same context already exists, this will overwrite it.
* @param context
* @param event
* @param callback
*/
public on(context: string, event: string, callback: EventBusCallback): void {
if (context.includes('@') || event.includes('@')) {
throw new Error('Invalid context/event name - Cannot contain @')
}
this.handlers.set(`${context}@${event}`, callback)
}
/**
* Removes the subscription for an `event` in the `context`.
* If `event` is left empty, removes all subscriptions.
* @param context
* @param event
*/
public off(context: string, event?: string): void {
if (event) {
this.handlers.delete(`${context}@${event}`)
} else {
for (const [key] of this.handlers.entries()) {
if (key.startsWith(`${context}@`)) {
this.handlers.delete(key)
}
}
}
}
/**
* Disables a `context`. Does not remove subscriptions, but all events for related listeners will be ignored.
* @param context
*/
public disable(context: string): void {
this.enable(context)
this.disabled.push(context)
}
/**
* Re-enables a `context`.
* @param context
*/
public enable(context: string): void {
this.disabled = this.disabled.filter(v => v !== context)
}
public emit(event: string, ...args: any[]): void {
const entries = [...this.handlers.entries()].filter(
([k, _]) => !this.disabled.includes(k.split('@')[0])
)
for (const [key, handler] of entries) {
if (key.endsWith(`@${event}`)) {
handler(...args)
}
}
}
}
+190
View File
@@ -0,0 +1,190 @@
import { getIcon, normalizePath } from 'obsidian'
import type LocatorPlugin from '../main'
import {
isFileImage,
isFilePDF,
isFileCanvas,
isFileExcalidraw,
warnVerbose,
} from './utils'
import { escapeHTML } from './text-processing'
export interface IconPacks {
prefixToIconPack: { [prefix: string]: string }
iconsPath: string
}
export async function loadIconData(plugin: LocatorPlugin): Promise<any> {
const app = plugin.app
// Check if the 'obsidian-icon-folder' plugin is installed and enabled
// Casting 'app' to 'any' here to avoid TypeScript errors since 'plugins' might not be defined on 'App'
const iconFolderPlugin = (app as any).plugins.getPlugin(
'obsidian-icon-folder'
)
if (!iconFolderPlugin) {
return {}
}
const dataJsonPath = `${app.vault.configDir}/plugins/obsidian-icon-folder/data.json`
try {
const dataJsonContent = await app.vault.adapter.read(dataJsonPath)
const rawIconData = JSON.parse(dataJsonContent)
// Normalize keys
const iconData: any = {}
for (const key in rawIconData) {
const normalizedKey = normalizePath(key)
iconData[normalizedKey] = rawIconData[key]
}
return iconData
} catch (e) {
warnVerbose('Failed to read data.json:', e)
return {}
}
}
export async function initializeIconPacks(
plugin: LocatorPlugin
): Promise<IconPacks> {
// Add 'Li' prefix for Lucide icons
const prefixToIconPack: { [prefix: string]: string } = { Li: 'lucide-icons' }
let iconsPath = 'icons'
const app = plugin.app
// Access the obsidian-icon-folder plugin
const iconFolderPlugin = (app as any).plugins.getPlugin(
'obsidian-icon-folder'
)
if (iconFolderPlugin) {
// Get the icons path from the plugin's settings
const iconFolderSettings = iconFolderPlugin.settings
iconsPath = iconFolderSettings?.iconPacksPath || 'icons'
const iconsDir = `${app.vault.configDir}/${iconsPath}`
try {
const iconPackDirs = await app.vault.adapter.list(iconsDir)
if (iconPackDirs.folders && iconPackDirs.folders.length > 0) {
for (const folderPath of iconPackDirs.folders) {
const pathParts = folderPath.split('/')
const iconPackName = pathParts[pathParts.length - 1]
const prefix = createIconPackPrefix(iconPackName)
prefixToIconPack[prefix] = iconPackName
}
}
} catch (e) {
warnVerbose('Failed to list icon packs:', e)
}
}
return { prefixToIconPack, iconsPath }
}
function createIconPackPrefix(iconPackName: string): string {
if (iconPackName.includes('-')) {
const splitted = iconPackName.split('-')
let result = splitted[0].charAt(0).toUpperCase()
for (let i = 1; i < splitted.length; i++) {
result += splitted[i].charAt(0).toLowerCase()
}
return result
}
return (
iconPackName.charAt(0).toUpperCase() + iconPackName.charAt(1).toLowerCase()
)
}
export function getIconNameForPath(path: string, iconData: any): string | null {
const normalizedPath = normalizePath(path)
const iconEntry = iconData[normalizedPath]
if (iconEntry) {
if (typeof iconEntry === 'string') {
return iconEntry
} else if (typeof iconEntry === 'object' && iconEntry.iconName) {
return iconEntry.iconName
}
}
return null
}
export function parseIconName(iconName: string): {
prefix: string
name: string
} {
const prefixMatch = iconName.match(/^[A-Z][a-z]*/)
if (prefixMatch) {
const prefix = prefixMatch[0]
const name = iconName.substring(prefix.length)
return { prefix, name }
} else {
// No prefix, treat the entire iconName as the name
return { prefix: '', name: iconName }
}
}
export async function loadIconSVG(
iconName: string,
plugin: LocatorPlugin,
iconsPath: string,
prefixToIconPack: { [prefix: string]: string }
): Promise<string | null> {
const parsed = parseIconName(iconName)
const { prefix, name } = parsed
if (!prefix) {
// No prefix, assume it's an emoji or text
return `<span class="locator-result__icon--emoji">${escapeHTML(
name
)}</span>`
}
const iconPackName = prefixToIconPack[prefix]
if (!iconPackName) {
warnVerbose(`No icon pack found for prefix: ${prefix}`)
return null
}
if (iconPackName === 'lucide-icons') {
// Convert CamelCase to dash-case for Lucide icons
const dashedName = name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
const iconEl = getIcon(dashedName)
if (iconEl) {
return iconEl.outerHTML
} else {
warnVerbose(`Lucide icon not found: ${dashedName}`)
return null
}
} else {
if (!iconsPath) {
warnVerbose('Icons path is not set. Cannot load icon SVG.')
return null
}
const iconPath = `${plugin.app.vault.configDir}/${iconsPath}/${iconPackName}/${name}.svg`
try {
const svgContent = await plugin.app.vault.adapter.read(iconPath)
return svgContent
} catch (e) {
warnVerbose(`Failed to load icon SVG for ${iconName} at ${iconPath}:`, e)
return null
}
}
}
export function getDefaultIconSVG(
notePath: string,
plugin: LocatorPlugin
): string {
// Return SVG content for default icons based on file type
let iconName = 'file'
if (isFileImage(notePath)) {
iconName = 'image'
} else if (isFilePDF(notePath)) {
iconName = 'file-text'
} else if (isFileCanvas(notePath) || isFileExcalidraw(notePath)) {
iconName = 'layout-dashboard'
}
const iconEl = getIcon(iconName)
return iconEl ? iconEl.outerHTML : ''
}
+103
View File
@@ -0,0 +1,103 @@
import { type App, type CachedMetadata, MarkdownView, TFile } from 'obsidian'
import type { ResultNote } from '../globals'
export async function openNote(
app: App,
item: ResultNote,
offset = 0,
newPane = false,
newLeaf = false
): Promise<void> {
// Check if the note is already open,
// to avoid opening it twice if the first one is pinned
let alreadyOpenAndPinned = false
app.workspace.iterateAllLeaves(leaf => {
if (leaf.view instanceof MarkdownView) {
if (
!newPane &&
leaf.getViewState().state?.file === item.path &&
leaf.getViewState()?.pinned
) {
app.workspace.setActiveLeaf(leaf, { focus: true })
alreadyOpenAndPinned = true
}
}
})
if (!alreadyOpenAndPinned) {
// Open the note normally
await app.workspace.openLinkText(item.path, '', newLeaf ? 'split' : newPane)
}
const view = app.workspace.getActiveViewOfType(MarkdownView)
if (!view) {
// Not an editable document, so no cursor to place
// throw new Error('OmniSearch - No active MarkdownView')
return
}
const pos = view.editor.offsetToPos(offset)
// pos.ch = 0
view.editor.setCursor(pos)
view.editor.scrollIntoView({
from: { line: pos.line - 10, ch: 0 },
to: { line: pos.line + 10, ch: 0 },
})
}
export async function createNote(
app: App,
name: string,
newLeaf = false
): Promise<void> {
try {
let pathPrefix: string
switch (app.vault.getConfig('newFileLocation')) {
case 'current':
pathPrefix = (app.workspace.getActiveFile()?.parent?.path ?? '') + '/'
break
case 'folder':
pathPrefix = app.vault.getConfig('newFileFolderPath') + '/'
break
default: // 'root'
pathPrefix = ''
break
}
await app.workspace.openLinkText(`${pathPrefix}${name}.md`, '', newLeaf)
} catch (e) {
;(e as any).message =
'OmniSearch - Could not create note: ' + (e as any).message
console.error(e)
throw e
}
}
/**
* For a given file, returns a list of links leading to notes that don't exist
* @param file
* @param metadata
* @returns
*/
export function getNonExistingNotes(
app: App,
file: TFile,
metadata: CachedMetadata
): string[] {
return (metadata.links ?? [])
.map(l => {
const path = removeAnchors(l.link)
return app.metadataCache.getFirstLinkpathDest(path, file.path)
? ''
: l.link
})
.filter(l => !!l)
}
/**
* Removes anchors and headings
* @param name
* @returns
*/
export function removeAnchors(name: string): string {
return name.split(/[\^#]+/)[0]
}
+164
View File
@@ -0,0 +1,164 @@
import { excerptAfter, excerptBefore, type SearchMatch } from '../globals'
import { removeDiacritics, warnVerbose } from './utils'
import type { Query } from '../search/query'
import { Notice } from 'obsidian'
import { escapeRegExp } from 'lodash-es'
import type LocatorPlugin from '../main'
export class TextProcessor {
constructor(private plugin: LocatorPlugin) {}
/**
* Wraps the matches in the text with a <span> element and a highlight class
* @param text
* @param matches
* @returns The html string with the matches highlighted
*/
public highlightText(text: string, matches: SearchMatch[]): string {
const highlightClass = `suggestion-highlight locator-highlight ${
this.plugin.settings.highlight ? 'locator-default-highlight' : ''
}`
if (!matches.length) {
return text
}
try {
return text.replace(
new RegExp(
`(${matches.map(item => escapeRegExp(item.match)).join('|')})`,
'giu'
),
`<span class="${highlightClass}">$1</span>`
)
} catch (e) {
console.error('Locator - Error in highlightText()', e)
return text
}
}
/**
* Converts a list of strings to a list of words, using the \b word boundary.
* Used to find excerpts in a note body, or select which words to highlight.
*/
public stringsToRegex(strings: string[]): RegExp {
if (!strings.length) return /^$/g
// sort strings by decreasing length, so that longer strings are matched first
strings.sort((a, b) => b.length - a.length)
const joined = `(${strings
.map(s => `\\b${escapeRegExp(s)}\\b|${escapeRegExp(s)}`)
.join('|')})`
return new RegExp(`${joined}`, 'gui')
}
/**
* Returns an array of matches in the text, using the provided regex
* @param text
* @param reg
* @param query
*/
public getMatches(
text: string,
words: string[],
query?: Query
): SearchMatch[] {
words = words.map(escapeHTML)
const reg = this.stringsToRegex(words)
const originalText = text
// text = text.toLowerCase().replace(new RegExp(SEPARATORS, 'gu'), ' ')
if (this.plugin.settings.ignoreDiacritics) {
text = removeDiacritics(text, this.plugin.settings.ignoreArabicDiacritics)
}
const startTime = new Date().getTime()
let match: RegExpExecArray | null = null
let matches: SearchMatch[] = []
let count = 0
while ((match = reg.exec(text)) !== null) {
// Avoid infinite loops, stop looking after 100 matches or if we're taking too much time
if (++count >= 100 || new Date().getTime() - startTime > 50) {
warnVerbose('Stopped getMatches at', count, 'results')
break
}
const matchStartIndex = match.index
const matchEndIndex = matchStartIndex + match[0].length
const originalMatch = originalText
.substring(matchStartIndex, matchEndIndex)
.trim()
if (originalMatch && match.index >= 0) {
matches.push({ match: originalMatch, offset: match.index })
}
}
// If the query is more than 1 token and can be found "as is" in the text, put this match first
if (
query &&
(query.query.text.length > 1 || query.getExactTerms().length > 0)
) {
const best = text.indexOf(query.getBestStringForExcerpt())
if (best > -1 && matches.find(m => m.offset === best)) {
matches.unshift({
offset: best,
match: query.getBestStringForExcerpt(),
})
}
}
return matches
}
public makeExcerpt(content: string, offset: number): string {
const settings = this.plugin.settings
try {
const pos = offset ?? -1
const from = Math.max(0, pos - excerptBefore)
const to = Math.min(content.length, pos + excerptAfter)
if (pos > -1) {
content =
(from > 0 ? '…' : '') +
content.slice(from, to).trim() +
(to < content.length - 1 ? '…' : '')
} else {
content = content.slice(0, excerptAfter)
}
if (settings.renderLineReturnInExcerpts) {
const lineReturn = new RegExp(/(?:\r\n|\r|\n)/g)
// Remove multiple line returns
content = content
.split(lineReturn)
.filter(l => l)
.join('\n')
const last = content.lastIndexOf('\n', pos - from)
if (last > 0) {
content = content.slice(last)
}
}
content = escapeHTML(content)
if (settings.renderLineReturnInExcerpts) {
content = content.trim().replaceAll('\n', '<br>')
}
return content
} catch (e) {
new Notice(
'Locator - Error while creating excerpt, see developer console'
)
console.error(`Locator - Error while creating excerpt`)
console.error(e)
return ''
}
}
}
export function escapeHTML(html: string): string {
return html
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;')
}
+281
View File
@@ -0,0 +1,281 @@
import {
type CachedMetadata,
getAllTags,
Notice,
parseFrontMatterAliases,
Platform,
} from 'obsidian'
import { isSearchMatch, type SearchMatch } from '../globals'
import { type BinaryLike, createHash } from 'crypto'
import { md5 } from 'pure-md5'
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)
})
}
/**
* Returns the positions of all occurences of `val` inside of `text`
* https://stackoverflow.com/a/58828841
* @param text
* @param regex
* @returns
*/
export function getAllIndices(text: string, regex: RegExp): SearchMatch[] {
return [...text.matchAll(regex)]
.map(o => ({ match: o[0], offset: o.index }))
.filter(isSearchMatch)
}
export function extractHeadingsFromCache(
cache: CachedMetadata,
level: number
): string[] {
return (
cache.headings?.filter(h => h.level === level).map(h => h.heading) ?? []
)
}
export function loopIndex(index: number, nbItems: number): number {
return (index + nbItems) % nbItems
}
function mapAsync<T, U>(
array: T[],
callbackfn: (value: T, index: number, array: T[]) => Promise<U>
): Promise<U[]> {
return Promise.all(array.map(callbackfn))
}
/**
* https://stackoverflow.com/a/53508547
* @param array
* @param callbackfn
* @returns
*/
export async function filterAsync<T>(
array: T[],
callbackfn: (value: T, index: number, array: T[]) => Promise<boolean>
): Promise<T[]> {
const filterMap = await mapAsync(array, callbackfn)
return array.filter((_value, index) => filterMap[index])
}
/**
* A simple function to strip bold and italic markdown chars from a string
* @param text
* @returns
*/
export function stripMarkdownCharacters(text: string): string {
return text.replace(/(\*|_)+(.+?)(\*|_)+/g, (_match, _p1, p2) => p2)
}
export function getAliasesFromMetadata(
metadata: CachedMetadata | null
): string[] {
return metadata?.frontmatter
? parseFrontMatterAliases(metadata.frontmatter) ?? []
: []
}
export function getTagsFromMetadata(metadata: CachedMetadata | null): string[] {
let tags = metadata ? getAllTags(metadata) ?? [] : []
// This will "un-nest" tags that are in the form of "#tag/subtag"
// A tag like "#tag/subtag" will be split into 3 tags: '#tag/subtag", "#tag" and "#subtag"
// https://github.com/scambier/obsidian-locator/issues/146
tags = [
...new Set(
tags.reduce((acc, tag) => {
return [
...acc,
...tag
.split('/')
.filter(t => t)
.map(t => (t.startsWith('#') ? t : `#${t}`)),
tag,
]
}, [] as string[])
),
]
return tags
}
// Define cached diacritics regex once outside the function
const japaneseDiacritics = ['\\u30FC', '\\u309A', '\\u3099']
const regexpExclude = japaneseDiacritics.join('|')
const diacriticsRegex = new RegExp(`(?!${regexpExclude})\\p{Diacritic}`, 'gu')
/**
* https://stackoverflow.com/a/37511463
*/
export function removeDiacritics(str: string, arabic = false): string {
if (str === null || str === undefined) {
return ''
}
if (arabic) {
// Arabic diacritics
// https://stackoverflow.com/a/40959537
str = str
.replace(/([^\u0621-\u063A\u0641-\u064A\u0660-\u0669a-zA-Z 0-9])/g, '')
.replace(/(آ|إ|أ)/g, 'ا')
.replace(/(ة)/g, 'ه')
.replace(/(ئ|ؤ)/g, 'ء')
.replace(/(ى)/g, 'ي')
for (let i = 0; i < 10; i++) {
str.replace(String.fromCharCode(0x660 + i), String.fromCharCode(48 + i))
}
}
// Keep backticks for code blocks, because otherwise they are removed by the .normalize() function
// https://stackoverflow.com/a/36100275
str = str.replaceAll('`', '[__locator__backtick__]')
// Keep caret same as above
str = str.replaceAll('^', '[__locator__caret__]')
// To keep right form of Korean character, NFC normalization is necessary
str = str.normalize('NFD').replace(diacriticsRegex, '').normalize('NFC')
str = str.replaceAll('[__locator__backtick__]', '`')
str = str.replaceAll('[__locator__caret__]', '^')
return str
}
export function getCtrlKeyLabel(): 'Ctrl' | '⌘' {
return Platform.isMacOS ? '⌘' : 'Ctrl'
}
export function getAltKeyLabel(): 'Alt' | '⌥' {
return Platform.isMacOS ? '⌥' : 'Alt'
}
export function isFileImage(path: string): boolean {
const ext = getExtension(path)
return (
ext === 'png' ||
ext === 'jpg' ||
ext === 'jpeg' ||
ext === 'webp' ||
ext === 'gif'
)
}
export function isFilePDF(path: string): boolean {
return getExtension(path) === 'pdf'
}
export function isFileOffice(path: string): boolean {
const ext = getExtension(path)
return ext === 'docx' || ext === 'xlsx'
}
export function isFileCanvas(path: string): boolean {
return path.endsWith('.canvas')
}
export function isFileExcalidraw(path: string): boolean {
return path.endsWith('.excalidraw')
}
export function isFileFromDataloom(path: string): boolean {
return path.endsWith('.loom')
}
export function getExtension(path: string): string {
const split = path.split('.')
return split[split.length - 1] ?? ''
}
export function makeMD5(data: BinaryLike): string {
if (Platform.isMobileApp) {
// A node-less implementation, but since we're not hashing the same data
// (arrayBuffer vs stringified array) the hash will be different
return md5(data.toString())
}
return createHash('md5').update(data).digest('hex')
}
export function chunkArray<T>(arr: T[], len: number): T[][] {
const chunks = []
let i = 0
const n = arr.length
while (i < n) {
chunks.push(arr.slice(i, (i += len)))
}
return chunks
}
/**
* Converts a 'fooBarBAZLorem' into ['foo', 'Bar', 'BAZ', 'Lorem']
* If the string isn't camelCase, returns an empty array
* @param text
*/
export function splitCamelCase(text: string): string[] {
// if no camel case found, do nothing
if (!/[a-z][A-Z]/.test(text)) {
return []
}
const splittedText = text
.replace(/([a-z](?=[A-Z]))/g, '$1 ')
.split(' ')
.filter(t => t)
return splittedText
}
/**
* Converts a 'foo-bar-baz' into ['foo', 'bar', 'baz']
* If the string isn't hyphenated, returns an empty array
* @param text
*/
export function splitHyphens(text: string): string[] {
if (!text.includes('-')) {
return []
}
return text.split('-').filter(t => t)
}
export function logVerbose(...args: any[]): void {
printVerbose(console.debug, ...args)
}
export function warnVerbose(...args: any[]): void {
printVerbose(console.warn, ...args)
}
let verboseLoggingEnabled = false
export function enableVerboseLogging(enable: boolean): void {
verboseLoggingEnabled = enable
}
function printVerbose(fn: (...args: any[]) => any, ...args: any[]): void {
if (verboseLoggingEnabled) {
fn(...args)
}
}
export const countError = (() => {
let counter = 0
let alreadyWarned = false
setTimeout(() => {
if (counter > 0) {
--counter
}
}, 1000)
return (immediate = false) => {
// 3 errors in 1 second, there's probably something wrong
if ((++counter >= 5 || immediate) && !alreadyWarned) {
alreadyWarned = true
new Notice(
'Locator ⚠️ There might be an issue with your cache. You should clean it in Locator settings and restart Obsidian.',
5000
)
}
}
})()
+23
View File
@@ -0,0 +1,23 @@
import type { MetadataCache, ViewState, Vault } from 'obsidian'
declare module 'obsidian' {
interface MetadataCache {
isUserIgnored?(path: string): boolean
}
interface ViewState {
state?: {
file?: string
}
}
interface Vault {
getConfig(string): unknown
}
interface App {
appId: string
loadLocalStorage(key: string): string | null
saveLocalStorage(key: string, value?: string): void
}
}