import type { ApplogValue, ArrayElementType, DefaultFalse, EntityID, Thread, ThreadOnlyCurrentNoDeleted, ValueOrMatcher } from '@wovin/core' import type { ObservableMap } from '@wovin/core/mobx' import type { Accessor } from 'solid-js' import type { TypeAttrOptions, TypeMapKeys } from '../data/VMs/utils-typemap' import type { RelationModelDef } from './../data/Relations' import { lazyVal } from '@note3/utils' import { useLocation, useParams } from '@solidjs/router' import { assertRaw, createDebugName, filterAndMap, isInitEvent, isoDateStrCompare, lastWriteWins, observableArrayMap, observableMapMap, observableMapToObject, query, queryAndMap, queryEntity, queryNot, rollingAcc, withoutDeleted, } from '@wovin/core' import { autorun, comparer, computed, observable, runInAction, toJS, untracked } from '@wovin/core/mobx' import { Logger } from 'besonders-logger' import { createComputed, createContext, createMemo, useContext } from 'solid-js' import { getApplogDB, insertApplogs } from '../data/ApplogDB' import { getBlocksWithTags } from '../data/block-utils-nowin' import { REL_DEF } from '../data/data-types' import { onlyFromCurrentAgent } from '../data/lazy-agent' import { RE_AT_TAG_ONLY, RE_AT_TAG_WITHCONTEXT, RE_HASH_TAG_ONLY, RE_HASH_TAG_WITHCONTEXT, RE_PLUS_TAG_ONLY, RE_PLUS_TAG_WITHCONTEXT, } from '../data/note3-regex-constants' import { contentVlToPlaintext, rawContentMatchTag, } from '../data/note3-utils-nodeps' import { orderBlockRelations } from '../data/relation-utils' import { doesContentMatchSearch } from '../data/search' import { getProviderIDs } from '../data/storage-hooks' import { BlockVM, useBlk } from '../data/VMs/BlockVM' import { RelationVM, useRel } from '../data/VMs/RelationVM' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO, { prefix: '[rx]' }) export const DBContext = createContext>(null) export function useFocus() { const params = useParams() return createMemo(() => params.blockID as EntityID | null) // (newFocus: EntityID|null)=> focusViewOnBlock()] // return useHash() // previously const [focus, setFocus] = createSignal(focusFromSearchParams) // const location = useLocation() // DEBUG(`useFocus`, location) // return createMemo(() => { // if (location.pathname === '/block') return location. // else return null // }) } export function useIsTimeline() { const location = useLocation() return createMemo(() => location.pathname === '/timeline') } // eslint-disable-next-line import/no-mutable-exports export let tmpContextThread: Thread | null = null export function withDS( ds: Thread, fn: () => R, ): R { const dsBefore = tmpContextThread tmpContextThread = ds const result = fn() tmpContextThread = dsBefore return result } export function useThreadFromContext() { let thread = tmpContextThread ?? useContext(DBContext)?.() if (!thread) { /* TODO throw */ WARN(`Empty DBContext and tmpDS - reverting to root`, { tmpDS: tmpContextThread, ds: thread }) thread = getApplogDB() } return thread } export function useRawThread() { return assertRaw(useThreadFromContext()) } export function useThreadWithFilters( { withHistory, withDeleted }: { withHistory?: DefaultFalse, withDeleted?: DefaultFalse }, // no default as useCurrentThread is meant for that purpose thread = useThreadFromContext(), ) { if (!withHistory && !thread.filters.includes('lastWriteWins')) thread = lastWriteWins(thread) // HACK: proper divergence tracking should be done if (!withDeleted && !thread.filters.includes('withoutDeleted')) thread = withoutDeleted(thread) return thread } export function useCurrentThread(thread = useThreadFromContext()) { return useThreadWithFilters({ withHistory: false, withDeleted: false }, thread) as ThreadOnlyCurrentNoDeleted } export const useReadOnlyState = () => createMemo(() => useThreadFromContext().readOnly) ///////////////// // NEW QUERIES // ///////////////// /** @DEPRECATED -> useBlk */ export function useBlockVM(blockID: EntityID) { const ds = useRawThread() DEBUG(`[useBlockVM]`, blockID, ds) return BlockVM.get(blockID, ds) } // export function useBlock(blockID: EntityID) { // const ds = useCurrentDS() // DEBUG(`[useBlock]`, blockID, ds) // return computed(() => { // const block = queryEntity(ds, 'block', blockID, BLOCK_DEF._attrs).get() // if (!block) return null // return ({ // en: blockID, // ...block, // } as unknown as BLOCK) // }) // } export function useRelationVM(relationID: EntityID) { const ds = useRawThread() DEBUG(`[useRelationVM]`, relationID, ds) return RelationVM.get(relationID, ds) } export function useRelation(relationID: EntityID) { const ds = useCurrentThread() DEBUG(`[useRelation]`, relationID, ds) return computed(() => { const relation = queryEntity(ds, 'relation', relationID, REL_DEF._attrs).get() if (!relation) return null return ({ en: relationID, ...relation, } as unknown as RelationModelDef) }) } export function useBlockAt(blockID: EntityID, at: TypeAttrOptions) { /* TODO: BLOCK._attrs */ const ds = useCurrentThread() const atStr = at.toString() DEBUG(`[useBlockAt]`, ds.name, { ds, blockID, at }) return computed(() => { const value = queryEntity(ds, 'block', blockID, [atStr]).get() VERBOSE(`[useBlockAt] query result`, value) return value?.[atStr] }) } export function useEntityAt(id: EntityID, at: string, defaultVal: T = null) { const thread = useThreadFromContext() DEBUG(`[useEntityAt]`, thread.nameAndSizeUntracked, { ds: thread, id, at }) // VERBOSE.isDisabled || autorun(() => VERBOSE(`[useEntityAt] result`, { ds, id, at }, toJS(resultArr))) // autorun(() => LOG(`[useEntityAt] result`, { ds, id, at }, toJS(resultArr))) const resultsLazy = lazyVal(() => filterAndMap(thread, { en: id, at }, 'vl')) // HACK: to avoid eager filtering (but at the cost of caching... maybe?) return [ () => { const resultArr = resultsLazy() return resultArr.length ? resultArr[0] as T : defaultVal }, // this access "registers reactivity" with mobx (newVal: T) => insertApplogs(thread, { en: id, at, vl: newVal }), ] as const } export function useEntityAttrs, ApplogValue>>( en: EntityID, attrs: ATTRS, defaultVals: Partial = {}, ) { type ATTR = ArrayElementType const thread = useCurrentThread() // HACK based on last write wins DEBUG(`[useEntityAttrs]`, thread.nameAndSizeUntracked, { ds: thread, id: en, at: attrs }) const result = query(thread, { en, at: attrs }) const mapped = rollingAcc>(thread, observable.map(), (event, map) => { if (!isInitEvent(event)) { for (const removed of event.removed) { const deleted = map.delete(removed.at as ATTR) if (!deleted) WARN(`[useEntityAttrs] event.removed did not exist in our map`, { removed, event, map: map.entries() }) } } for (const added of (isInitEvent(event) ? event.init : event.added)) { map.set(added.en as ATTR, added.ts) } }, { name: createDebugName({ thread, args: { id: en, at: attrs } }) }) if (VERBOSE.isEnabled) autorun(() => VERBOSE(`[useEntityAttrs] result`, { ds: thread, id: en, at: attrs }, toJS(result.leafNodeLogs))) return [ observableMapToObject(mapped) as Record, ApplogValue>, (at: ATTR, newVal: ApplogValue) => { return insertApplogs(thread, { en, at, vl: newVal }) }, ] as const } export function useKidRelationIDs(blockID: EntityID) { if (!blockID) throw ERROR(`[useKidRelations] Invalid blockID:`, blockID) // TS doesn't save us in all cases const thread = useCurrentThread() DEBUG(`[useKidRelationIDs#${blockID}]`, blockID, thread) return filterAndMap(thread, { at: REL_DEF.childOf, vl: blockID }, 'en') } export function useKidRelations(blockID: EntityID) { DEBUG(`[useKidRelations#${blockID}]`, blockID) const rawThread = useRawThread() const relationsQuery = useKidRelationIDs(blockID) VERBOSE(`[useKidRelations#${blockID}] relationsQuery:`, relationsQuery) return observableArrayMap(() => { const relations = relationsQuery.map(relID => useRel(relID, rawThread)) VERBOSE(`[useKidRelations#${blockID}] relations:`, relations) return orderBlockRelations(relations) }, { name: createDebugName({ caller: 'useKidRelations' }) }) } export function useKidVMs(blockID: EntityID) { DEBUG(`[useKidVMs#${blockID}]`, blockID) const rawThread = useRawThread() const relationDefs = useKidRelations(blockID) VERBOSE(`[useKidVMs#${blockID}] relationsQuery:`, relationDefs) return observableArrayMap(() => { const blockVMs = relationDefs.map(({ block }) => { return useBlk(block as string, rawThread) }) return blockVMs }, { name: createDebugName({ caller: 'useKidVMs' }) }) } export function usePlacementRelationIDs(blockID: EntityID) { DEBUG(`[useParentRelationIDs]`, blockID, REL_DEF.block) const ds = useCurrentThread() return filterAndMap(ds, { vl: blockID, at: REL_DEF.block }, 'en') } // returns RelationDefs for each Placement of blockID export function usePlacementRelations(blockID: EntityID) { DEBUG(`[usePlacementRelations]`, blockID) const ds = useCurrentThread() const relationIDs$arr = usePlacementRelationIDs(blockID) return observableArrayMap(() => { const relations = relationIDs$arr.map( relID => useRel(relID, ds), ) VERBOSE('usePlacementRelations', { relationsQuery: relationIDs$arr, relations }) return relations }, { name: createDebugName({ caller: 'usePlacementRelations', thread: ds }) }) } export function useRelAt(relID: EntityID, at: string /* TODO: BLOCK._attrs */) { DEBUG(`[useRelAt]`, relID) const db = useCurrentThread() return computed(() => queryEntity(db, 'relation', relID, [at]).get()?.[at]) } export function useParents(blockID: EntityID) { DEBUG(`[useParent]`, blockID) if (!blockID) throw ERROR(`[useParent] empty argument:`, blockID) const db = useCurrentThread() // return computed(() => { const parents = queryAndMap(db, [ { en: '?relID', at: REL_DEF.block, vl: blockID }, { en: '?relID', at: REL_DEF.childOf, vl: '?parentID' }, ], 'parentID') as string[] // HACK: types if (VERBOSE.isEnabled) autorun(() => VERBOSE(`[useParent] parents:`, parents)) // if (parents.length > 1) WARN(`Block ${blockID} has multiple parents:`, parents) return parents // .length ? parents[0] : null // }) } // export function useRoots() { // const db = useDB() // const blocks = query(db, [ // { en: '?blockID', at: 'block/content' }, // // { en: '?blockID', at: 'block/isDeleted', vl: '?isDeleted' }, // ]) // VERBOSE(`[useRoots] blocks:`, blocks, db.applogs) // const blocksWithoutParent = queryNot(db, blocks, [ // { en: '?relID', at: 'relation/block', vl: '?blockID' }, // { en: '?relID', at: 'relation/childOf', vl: null }, // // { en: '?blockID', at: 'block/isDeleted', vl: '?isDeleted' }, // ]) // const records = blocksWithoutParent.records; // const mappedRootIDs = () => records.map(({ blockID }) => blockID) as string[] // VERBOSE(`[useRoots] result:`, db.nameAndSize, records, untracked(mappedRootIDs)) // return createMemo(() => mappedRootIDs(), mappedRootIDs(), { equals: areDeduplicatedArraysEqualUnordered }) // } // TODO: computedFnDeepCompare? export function useRoots() { // const roots = observable.set([] as EntityID[], { deep: false }) // autorun(() => { const thread = useCurrentThread() DEBUG(`[useRoots] on`, thread.nameAndSizeUntracked) const blocks = query(thread, [ { en: '?blockID', at: 'block/content' }, // This is (sort of) the core of makes an entity a block // ? q.not(parent) ]) const firstLogsThread = lastWriteWins(useThreadWithFilters({ withHistory: true }), { inverseToOnlyReturnFirstLogs: true, tolerateAlreadyFiltered: true, }) // const blocksFirstLog = query(firstLogsThread, [ // { en: '?blockID', at: 'block/content' }, // ]) // @ ts-expect-error TS mobx weird - fixed by re-export const blockCreationMap = rollingAcc>( firstLogsThread, /* blocksFirstLog.threadOfAllTrails */ observable.map() as ObservableMap, function blockCreationMapRollingAcc(event, map) { if (!isInitEvent(event)) { for (const removed of event.removed) { if (removed.at !== 'block/content') continue map.delete(removed.en) } } for (const added of (isInitEvent(event) ? event.init : event.added)) { if (added.at !== 'block/content') continue map.set(added.en, added.ts) } }, ) // autorun(() => WARN(`blocksCreation`, blockCreation, firstLogsThread)) const rootsObserverFx = function rootsObserverFx() { DEBUG(`[useRoots] blocks:`, blocks, thread) // TODO: when queryNot is observable, move this out const blocksWithoutParent = queryNot(thread, blocks, [ { en: '?relID', at: 'relation/block', vl: '?blockID' }, { en: '?relID', at: 'relation/childOf', vl: null }, ]) // const firstLogs = query(firstLogsThread, [ // { en: blocksWithoutParent.records.map(({ blockID }) => blockID) as EntityID[], at: 'block/content' }, // ]).threadOfAllTrails.applogs // VERBOSE(`[useRoots] firstLogs:`, firstLogs) const sortedRecords = blocksWithoutParent.records.slice().sort(({ blockID: blockA }, { blockID: blockB }) => { const firstLogA = blockCreationMap.get(blockA as EntityID) const firstLogB = blockCreationMap.get(blockB as EntityID) return isoDateStrCompare(firstLogA, firstLogB, 'desc') // sort by first log time - i.e. creation // return blockA.localeCompare(blockB) //old: sort by entity ID }) DEBUG(`[useRoots] result:`, thread.nameAndSizeUntracked, { sortedRecords }) return sortedRecords.map(({ blockID }) => blockID as string) } const name = createDebugName({ caller: 'useRoots', thread }) const roots = observableArrayMap(rootsObserverFx, { name, equals: comparer.structural }) // if (DEBUG.isEnabled) // const debugDispose = autorun(() => { // DEBUG(`[useRoots] updated`, toJS(roots)) // }) // onBecomeUnobserved(roots as IObservableArray, () => { // // WARN: doesn't seem towork // WARN(`[useRoots] debugAutorun dispose`) // debugDispose() // }) return roots } export function useBlocksMatchingVl(vlMatch: ValueOrMatcher) { const thread = useCurrentThread() const blocks = query(thread, [ { en: '?blockID', at: 'block/content', vl: vlMatch }, ]) LOG.isEnabled && autorun(() => { LOG(`[useBlocksMatching] updated`, toJS(blocks)) }) const blockIDs = observableArrayMap(() => { return blocks.records.map(({ blockID }) => blockID as EntityID).reverse() // HACK to get newest first //TODO: actually sort }, { name: createDebugName({ caller: 'useBlocksMatching', thread }) }) return blockIDs } export function useBlocksMatchingSearch(search: string) { const thread = useCurrentThread() const blocks = query(thread, [ { en: '?blockID', at: 'block/content', vl: vl => doesContentMatchSearch(contentVlToPlaintext(vl as string), search) }, ]) LOG.isEnabled && autorun(() => { LOG(`[useBlocksMatchingSearch] updated`, toJS(blocks)) }) const blockIDs = observableArrayMap(() => { return blocks.records.map(({ blockID }) => blockID as EntityID).reverse() // HACK to get newest first //TODO: actually sort }, { name: createDebugName({ caller: 'useBlocksMatchingSearch', thread }) }) return blockIDs } export function useBlocksWithTags(tags: readonly string[]) { const thread = useCurrentThread() const blocks = getBlocksWithTags(thread, tags) return blocks } // export function useBlockAndRecursiveDS(blockID: EntityID): Thread { // if (!blockID) return null // const thread = useCurrentDS() // const blocks = query(thread, [ // { en: '?blockID', at: 'block/content', vl: vl => matchSearch(vl as string, search) }, // ]) // LOG.isEnabled && autorun(() => { // LOG(`[useBlocksMatchingSearch] updated`, toJS(blocks)) // }) // const blockIDs = observableArrayMap(() => blocks.records.map(({ blockID }) => blockID as EntityID), { // name: createDebugName({ caller: 'useBlocksMatchingSearch', thread }), // }) // return blockIDs // } export function useRootsOfMaybeNested(blocks: readonly EntityID[]) { if (!blocks) return null // const thread = useCurrentDS() const roots = new Set() const checkBlockParent = (block: EntityID, currentParent?: EntityID) => { const parents = useParents(currentParent ?? block) if (!parents.length) roots.add(block) for (const parent of parents) { if (roots.has(parent)) return checkBlockParent(block, parent) } } for (const block of blocks) { checkBlockParent(block) } return [...roots] } export function useHashTags() { return useTagsGeneric(RE_HASH_TAG_ONLY, RE_HASH_TAG_WITHCONTEXT) } export function usePlusTags() { return useTagsGeneric(RE_PLUS_TAG_ONLY, RE_PLUS_TAG_WITHCONTEXT) } export function useAtTags() { return useTagsGeneric(RE_AT_TAG_ONLY, RE_AT_TAG_WITHCONTEXT) } export function useAllTags() { const allTagsMap = observableMapMap(() => { const entries = [ ...[...useHashTags().entries()].map(([k, v]) => [`#${k}`, v] as const), ...[...usePlusTags().entries()].map(([k, v]) => [`+${k}`, v] as const), ...[...useAtTags().entries()].map(([k, v]) => [`@${k}`, v] as const), ] VERBOSE(`[useAllTags]`, entries) return entries }, { name: 'useAllTags' }) if (VERBOSE.isEnabled) autorun(() => VERBOSE(`[useAllTags]`, toJS(allTagsMap))) return allTagsMap } export function useTagsGeneric(regexCheap: RegExp, regex: RegExp): Map { const thread = useCurrentThread() DEBUG(`[useTagsGeneric] searching`, thread.nameAndSizeUntracked, { regex, regexCheap }) const blocks = query(thread, [ { en: '?blockID', at: 'block/content' }, // { en: '?blockID', at: 'block/content', vl: vl => !!regexCheap.exec(vl as string) }, //TODO: use tis ]) // untrack(() => { // DEBUG(`[useTagsGeneric] found:`, untracked(() => blocks)) // autorun(() => DEBUG(`[useTags] RX CONTENT NODES`, blocks.nodes)) // autorun(() => DEBUG(`[useTags] RX leafNodeLogs`, blocks.leafNodeLogs)) // autorun(() => DEBUG(`[useTags] RX leafNodeLogs`, blocks.threadOfAllTrails.applogs)) // }) const tagsMap = rollingAcc>( blocks.leafNodeThread, observable.map(), (event, map) => { DEBUG(`[useTagsGeneric] parent thread update:`, { event, map }) if (!isInitEvent(event)) { for (const removed of event.removed) { const tag = rawContentMatchTag(regexCheap, regex, removed.vl) if (tag) { const count = map.get(tag) if (!count) throw ERROR(`count`, count, { map, event }) VERBOSE(`[useTagsGeneric] decreasing:`, blocks) if (count > 1) { map.set(tag, count - 1) } else { map.delete(tag) } } } } for (const added of (isInitEvent(event) ? event.init : event.added)) { const tag = rawContentMatchTag(regexCheap, regex, added.vl) if (tag) { const count = map.get(tag) ?? 0 map.set(tag, count + 1) } } }, { name: `useTags{${regexCheap.source}}` }, ) // if(DEBUG.isEnabled) autorun((reaction) => { // DEBUG(`[useTags] updated`, { js: toJS(tagsMap), deps: getDependencyTree(tagsMap), tagsMap, reaction }) // }, { name: `useTags{${regexCheap.source}}.autorun` }) return tagsMap // // Now, we create a case insensitive map from it, while trying to not touch it's state if not changed // const mappedTagsMap = observable.map() // // ? tagsMap could be non-observable if we anyways map it after // autorun(() => { // const mapped = new Map() // for (const [tag, count] of tagsMap.entries()) { // mapped.set(tag.toLocaleLowerCase(), (mapped.get(tag.toLocaleLowerCase()) ?? 0) + count) // } // // entries.sort(([,a], [,b]) => a - b) - map order not changable without recreating :/ // VERBOSE(`[useTags] mapped`, toJS(mapped)) // let notSeen = new Set(mappedTagsMap.keys()) // for (const [tag, count] of mapped.entries()) { // mappedTagsMap.set(tag, count) // notSeen.delete(tag) // } // for (const tag of notSeen) { // mappedTagsMap.delete(tag) // } // }) // return mappedTagsMap } // ============================================================================ // NON-REACTIVE TAG QUERIES (Performance workaround) // ============================================================================ // These query tags once and cache forever. No reactivity = no performance hit. // Trade-off: New tags won't appear until page refresh. let cachedHashTags: Map | null = null let cachedPlusTags: Map | null = null let cachedAtTags: Map | null = null function getTagsOnce(thread: Thread, regexCheap: RegExp, regex: RegExp): Map { DEBUG(`[getTagsOnce] querying once`, thread.nameAndSizeUntracked, { regex }) // Query all blocks with content (same as useTagsGeneric, but without rollingAcc) // Wrap in untracked to prevent establishing MobX reactive dependencies const blocks = untracked(() => query(thread, [ { en: '?blockID', at: 'block/content', vl: '?content' }, ])) const records = untracked(() => blocks.records.slice()) DEBUG(`[getTagsOnce] query result`, { sample: records.slice(0, 3) }) // Iterate once through results, no reactivity const tagsMap = new Map() // Process all nodes from the query for (const record of records) { const content = (record?.content ?? record?.vl) as string if (!content) { continue } const tag = rawContentMatchTag(regexCheap, regex, content) if (tag) { const count = tagsMap.get(tag) ?? 0 tagsMap.set(tag, count + 1) } } DEBUG(`[getTagsOnce] found ${tagsMap.size} unique tags`, Array.from(tagsMap.keys()).slice(0, 20)) return tagsMap } export function getLazyHashTags(thread: Thread): Map { if (!cachedHashTags) { cachedHashTags = getTagsOnce(thread, RE_HASH_TAG_ONLY, RE_HASH_TAG_WITHCONTEXT) } else { DEBUG(`[getLazyHashTags] reused cache`, cachedHashTags.size) } return cachedHashTags } export function getLazyPlusTags(thread: Thread): Map { if (!cachedPlusTags) { cachedPlusTags = getTagsOnce(thread, RE_PLUS_TAG_ONLY, RE_PLUS_TAG_WITHCONTEXT) } else { DEBUG(`[getLazyPlusTags] reused cache`, cachedPlusTags.size) } return cachedPlusTags } export function getLazyAtTags(thread: Thread): Map { if (!cachedAtTags) { cachedAtTags = getTagsOnce(thread, RE_AT_TAG_ONLY, RE_AT_TAG_WITHCONTEXT) } else { DEBUG(`[getLazyAtTags] reused cache`, cachedAtTags.size) } return cachedAtTags } export function useProviderIDs( type: ValueOrMatcher = undefined, // = any type thread: Thread = onlyFromCurrentAgent(useCurrentThread()), ) { return getProviderIDs(type, thread) } export function createMobxObservableFromSolid(func: () => R) { const box = observable.box(undefined) createComputed(() => { VERBOSE('[createMobxObservableFromSolid] re-run', func) runInAction(() => box.set(func())) }) autorun(() => VERBOSE('[createMobxObservableFromSolid] val', box.get())) return box }