import type { EntityID, ReadonlyObservableArray } from '@wovin/core' import type { Accessor, Setter } from 'solid-js' import type { SearchGrammarType } from '../search/grammar-types' import type { SmartQuery } from './smart-list' import { isShare, observableArrayMap, query } from '@wovin/core' import { tryParseCID } from '@wovin/core/ipfs' import { autorun } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import intersection from 'lodash-es/intersection' import uniq from 'lodash-es/uniq' import { createContext, createMemo, useContext } from 'solid-js' import { useBlocksMatchingVl, useCurrentThread, useParents } from '../ui/reactive' import { tryParseNote3URL } from '../ui/utils-ui' import { getSubOrShare } from './agent/utils-agent' import { BLOCK_DEF } from './data-types' import { RE_ANY_TAG_WITHCONTEXT, RE_AT_TAG_WITHCONTEXT, RE_HASH_TAG_WITHCONTEXT, RE_PLUS_TAG_WITHCONTEXT, } from './note3-regex-constants' import { contentVlToPlaintext, plainContentMatchHashTag, plainContentMatchPlusTag, } from './note3-utils-nodeps' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.DEBUG) // eslint-disable-line unused-imports/no-unused-vars export type ExtraSearchResult = | { type: 'block', id: EntityID, content: string } | { type: 'subscription' | 'share', id: string } | { type: 'preview', id: string, /* link: string; */ focus: EntityID | null } | { type: 'block-not-found', id: EntityID, message: string } export const SearchContext = createContext<{ search: Accessor, setSearch: Setter }>({}) // const [s, setS] = createSignal('') export function useSearchContext() { const searchContext = useContext(SearchContext) return [searchContext.search, searchContext.setSearch] as const // const [urlParams, setUrlParams] = useSearchParams() // (i) has weird suspense behaviour - https://github.com/solidjs/solid-router/discussions/296 // const search = createMemo(() => urlParams.search) // const setSearch = newSearch => setUrlParams({ search: newSearch }, { replace: !!search() == !!newSearch }) // return [search, setSearch] as const } /** * Search for special matches (blockID, pub, link, ...) */ export function useExtraSearchResults({ search$ }: { search$: Accessor }) { const currentDS = useCurrentThread() // createEffect(() => DEBUG(`[extraSearch] search changed (solid)`, search$())) // autorun(() => DEBUG(`[extraSearch] search changed (mobx)`, search$())) // Recalculate results in case blocksQuery or publications/subs changes // const searchMobx = trackSolidAsMobxComputed(search$) // createEffect(() => DEBUG(`[extraSearch] searchMobx changed (solid)`, searchMobx.get())) // autorun(() => DEBUG(`[extraSearch] searchMobx changed (mobx)`, searchMobx.get())) // ! don't use observableArrayMap here return createMemo(() => { const search = search$() DEBUG(`[extraSearch]:`, search) const results = [] as ExtraSearchResult[] if (!search) return results const parsedUrl = tryParseNote3URL(search) // Handle block-only URLs (has focus but no share) if (parsedUrl?.focus && !parsedUrl?.share) { const blockQuery = query(currentDS, [{ en: parsedUrl.focus, at: BLOCK_DEF.content }]) DEBUG(`[extraSearch] block-only URL query:`, { focus: parsedUrl.focus, found: !blockQuery.isEmpty }) if (!blockQuery.isEmpty) { results.push({ type: 'block' as const, id: parsedUrl.focus, content: blockQuery.allApplogs[0].vl as string, }) } else { results.push({ type: 'block-not-found' as const, id: parsedUrl.focus, message: 'Block not found in current thread', }) } } const subOrShare = getSubOrShare(parsedUrl ? parsedUrl.share : search.trim()) DEBUG(`url/subOrShare?`, { subOrShare, parsedUrl }) if (subOrShare) { results.push({ type: isShare(subOrShare) ? 'share' : 'subscription', id: subOrShare.id }) } else if (parsedUrl?.share) { // if (parsedUrl.focus) // const block = BlockVM.get(parsedUrl.focus) results.push({ type: 'preview', id: parsedUrl.share, focus: parsedUrl.focus }) } else { // Try to parse as a CID directly const cidResult = tryParseCID(search.trim()) DEBUG(`CID parse result:`, { cidResult, search: search.trim() }) if (cidResult.cid) { results.push({ type: 'preview', id: search.trim(), focus: null }) } } const blocksByIDQuery = query(currentDS, [ { en: search, at: BLOCK_DEF.content }, ]) // const blocksQuery = query(currentDS, [ // // HACK: how to properly scan structured tiptap content as plaintext? // { at: BLOCK_DEF.content, vl: v => typeof v === 'string' && v.toLocaleLowerCase().includes(search.toLocaleLowerCase()) }, // ]) // autorun(() => DEBUG(`Search result computation updated:`, { applogSets: result.applogSets, logs: result.threadOfAllTrails })) // autorun(() => DEBUG(`Search result applogSets updated:`, { applogSets: result.applogSets })) // autorun(() => DEBUG(`Search result threadOfAllTrails updated:`, { logs: result.threadOfAllTrails })) if (DEBUG.isEnabled) autorun(() => DEBUG(`Search result by ID:`, blocksByIDQuery.allApplogs)) DEBUG(`Adding block results`, { blocksByIDQuery }) if (!blocksByIDQuery.isEmpty) { results.push({ type: 'block' as const, id: blocksByIDQuery.allApplogs[0].en, content: blocksByIDQuery.allApplogs[0].vl as string, }) } // results.push(...blocksQuery.applogSets.map(logs => { // if (logs.length != 1) WARN(`Block matching search returned node with logs count != 1:`, { logs, blocksQuery }) // return { // type: 'block' as const, // id: logs[0].en, // content: logs[0].vl as string, // } // })) DEBUG(`[extraSearch] results:`, results) return results }, { name: 'extraSearchResults' }) } export function useBlocksMatchingQuery(searchDef: SearchGrammarType): ReadonlyObservableArray { if (searchDef.type === 'OR') { return observableArrayMap(() => uniq(searchDef.parts.flatMap(part => useBlocksMatchingQuery(part)))) } else if (searchDef.type === 'AND') { return observableArrayMap(() => { const matchesPerPart = searchDef.parts.map(part => useBlocksMatchingQuery(part) as EntityID[]) return intersection(...matchesPerPart) }) // return useBlocksMatchingVl(content => { // if (!content) return false // const plaintextContent = contentVlToPlaintext(content) // return searchDef.parts.every(part => doesContentMatchSearchToken(plaintextContent, part)) // } } else if (searchDef.type === 'HIERARCHY' || searchDef.type === 'DEEP_HIERARCHY') { return observableArrayMap(() => { // HACK this is horribly inefficient, but I want a working prototype first const potentialChildren = useBlocksMatchingQuery(searchDef.child) const potentialParents = useBlocksMatchingQuery(searchDef.parent) VERBOSE(searchDef.type, searchDef, { potentialParents, potentialChildren }) return potentialChildren.filter((child) => { const alreadySeenParents = new Set() function anyParentMatches(currentChild: EntityID) { if (alreadySeenParents.has(currentChild)) return false alreadySeenParents.add(currentChild) const parents = [...useParents(currentChild)] VERBOSE(searchDef.type, 'checking', currentChild, { alreadySeenParents, parents }) if (potentialParents.some(p => parents.includes(p))) { return true } if (searchDef.type === 'DEEP_HIERARCHY') { if (alreadySeenParents.size > 1000) return WARN(`MAX SIZE`, { alreadySeenParents, child, currentChild }) return parents.some(p => anyParentMatches(p)) } return false } return anyParentMatches(child) }) }) } else if (searchDef.type === 'TAG') { return useBlocksMatchingVl((content) => { if (!content) return false const plaintextContent = contentVlToPlaintext(content) return doesContentMatchSearchToken(plaintextContent, searchDef.symbol + searchDef.name) // HACK }) } else { throw ERROR('Unknown search token', searchDef) } } export function doesContentMatchSmartQuery(content: string | null, smartQuery: SmartQuery) { return WARN('smart lists are broken', { content, smartQuery }) // if (!content) return false // const plaintextContent = contentVlToPlaintext(content) // for (const tag of smartQuery.tags) { // const match = plainContentMatchSpecificTag(plaintextContent, tag) // VERBOSE(`[search] for '${tag}' => ${match}`, { content: plaintextContent }) // if (!match) return false // } // for (const textToken of smartQuery.text.split(/\\s+/)) { // const match = doesContentMatchSearchToken(plaintextContent, textToken) // VERBOSE(`[search] for '${textToken}' => ${match}`, { content: plaintextContent }) // if (!match) return false // } // return true } export function doesContentMatchSearch(plaintextContent: string, search: string | null) { // const search = searchContext.search() if (!search) return true for (const searchToken of search.split(' ')) { if (!searchToken.length) continue // HACK double space produces empty entry const match = doesContentMatchSearchToken(plaintextContent, searchToken) VERBOSE(`[search] for '${searchToken}' => ${match}`, { content: plaintextContent }) if (!match) return false } return true } export function doesContentMatchSearchToken(plaintextContent: string, searchToken: string) { // FIXME: this only matches the first tag, doesn't it? const hashTag = RE_HASH_TAG_WITHCONTEXT.exec(searchToken)?.[1] if (hashTag && hashTag !== plainContentMatchHashTag(plaintextContent)) { return false } const plusTag = RE_PLUS_TAG_WITHCONTEXT.exec(searchToken)?.[1] if (plusTag && plusTag !== plainContentMatchPlusTag(plaintextContent)) { return false } const atTag = RE_AT_TAG_WITHCONTEXT.exec(searchToken)?.[1] if (atTag && atTag !== plainContentMatchPlusTag(plaintextContent)) { return false } return plaintextContent?.toLocaleLowerCase().includes(searchToken.toLocaleLowerCase()) } export function doesSearchContainAnyTag(search: string): boolean { return RE_ANY_TAG_WITHCONTEXT.test(search) }