import { Container, Service } from "dioc" import { type SpotlightSearcher, type SpotlightSearcherResult, type SpotlightSearcherSessionState, } from "../.." import MiniSearch from "minisearch" import { Ref, computed, effectScope, ref, watch } from "vue" import { resolveUnref } from "@vueuse/core" /** * Defines a search result and additional metadata returned by a StaticSpotlightSearcher */ export type SearchResult = { id: string score: number doc: Doc } /** * Options for StaticSpotlightSearcher initialization */ export type StaticSpotlightSearcherOptions< Doc extends object & { excludeFromSearch?: boolean }, > = { /** * The array of field names in the given documents to search against */ searchFields: Array /** * The weights to apply to each field in the search, this allows for certain * fields to have more priority than others in the search and update the score */ fieldWeights?: Partial> /** * How much the score should be boosted if the search matched fuzzily. * Increasing this value generally makes the search ignore typos, but reduces performance */ fuzzyWeight?: number /** * How much the score should be boosted if the search matched by prefix. * For e.g, when searching for "hop", "hoppscotch" would match by prefix. */ prefixWeight?: number } /** * A base class for SpotlightSearcherServices that have a static set of documents * that can optionally be toggled against (via the `excludeFromSearch` property in the Doc) */ export abstract class StaticSpotlightSearcherService< Doc extends object & { excludeFromSearch?: boolean }, > extends Service implements SpotlightSearcher { public abstract readonly searcherID: string public abstract readonly searcherSectionTitle: string private minisearch: MiniSearch private loading = ref(false) private _documents: Record = {} // TODO: This pattern is no longer recommended in dioc > 3, move to something else constructor( c: Container, private opts: StaticSpotlightSearcherOptions ) { super(c) this.minisearch = new MiniSearch({ fields: opts.searchFields as string[], }) } /** * Sets the documents to search against. * NOTE: We generally expect this function to only be called once and we expect * the documents to not change generally. You can pass a reactive object, if you want to toggle * states if you want to. * @param docs The documents to search against, this is an object, with the key being the document ID */ protected setDocuments(docs: Record) { this._documents = docs this.addDocsToSearchIndex() } private async addDocsToSearchIndex() { this.loading.value = true this.minisearch = new MiniSearch({ fields: this.opts.searchFields as string[], }) await this.minisearch.addAllAsync( Object.entries(this._documents).map(([id, doc]) => ({ id, ...doc, })) ) this.loading.value = false } /** * Specifies how to convert a document into the Spotlight entry format * @param result The search result to convert */ protected abstract getSearcherResultForSearchResult( result: SearchResult ): SpotlightSearcherResult public createSearchSession( query: Readonly> ): [Ref, () => void] { const results = ref([]) const resultObj = computed(() => ({ loading: this.loading.value, results: results.value, })) const scopeHandle = effectScope() scopeHandle.run(() => { watch( [query, () => this._documents], ([query, docs]) => { const searchResults = this.minisearch.search(query, { prefix: true, boost: (this.opts.fieldWeights as any) ?? {}, weights: { fuzzy: this.opts.fuzzyWeight ?? 0.2, prefix: this.opts.prefixWeight ?? 0.6, }, }) results.value = searchResults .filter( (result) => this._documents[result.id].excludeFromSearch === undefined || this._documents[result.id].excludeFromSearch === false ) .map((result) => { return this.getSearcherResultForSearchResult({ id: result.id, score: result.score, doc: docs[result.id], }) }) }, { immediate: true } ) }) const onSessionEnd = () => { scopeHandle.stop() } return [resultObj, onSessionEnd] } /** * Called when a document is selected from the search results * @param id The ID of the document selected * @param doc The document information of the document selected */ public abstract onDocSelected(id: string, doc: Doc): void public onResultSelect(result: SpotlightSearcherResult): void { this.onDocSelected(result.id, resolveUnref(this._documents)[result.id]) } }