First commit
This commit is contained in:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<div class="prompt-results" on:mousedown={e => e.preventDefault()}>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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} {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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(' ')
|
||||
: '',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 : ''
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
})()
|
||||
Vendored
+23
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user