import type { ApplogForInsertOptionalAgent, CidString, EntityID } from '@wovin/core/applog' import type { Thread, ThreadOnlyCurrentNoDeleted, ThreadWithoutFilters, WriteableThread } from '@wovin/core/thread' import type { AgentStateClass } from './agent/AgentState' import type { RelationModelDef } from './Relations' import type { AppSettingsVM } from './VMs/AppSettingsVM' import type { RelBuilder } from './VMs/RelationVM' import type { TiptapContent } from './VMs/TypeMap' import { generateJSON } from '@tiptap/core' import { renderToMarkdown } from '@tiptap/static-renderer/pm/markdown' import { dateNowIso, EntityID_LENGTH, getHashID } from '@wovin/core/applog' import { action } from '@wovin/core/mobx' import { queryAndMap } from '@wovin/core/query' import { DefaultFalse, DefaultTrue } from '@wovin/utils' import { Logger } from 'besonders-logger' import { XMLBuilder } from 'fast-xml-parser' import uniq from 'lodash-es/uniq' import stringify from 'safe-stable-stringify' import { baseExtensions, htmlToSerializedTiptap } from '../components/TipTapExtentions' import { useBlockAt, useCurrentThread, useRawThread, useRelationVM, withDS } from '../ui/reactive' import { focusBlockAsInput } from '../ui/utils-ui' import { insertApplogs, insertApplogsInAppDB } from './ApplogDB' import { compareBlockContent, serializeTiptapToVl } from './block-utils-nowin' import { BLOCK_DEF, ENTITY_DEF, REL_DEF } from './data-types' import { BlockVM, useBlk } from './VMs/BlockVM' import { REL, RelationVM, useRel } from './VMs/RelationVM' const { WARN, LOG, DEBUG, VERBOSE, ERROR } = Logger.setup(Logger.INFO) // eslint-disable-line unused-imports/no-unused-vars export const notDeletedFilter = en => !en.isDeleted // const debounceWait = 1500 export function persistBlockContent(thread: Thread, blockID: EntityID, newContent: TiptapContent, pv: CidString = null) { const currentContent = withDS(thread, () => useBlockAt(blockID, 'content').get()) // if (currentContent === null) return // not loaded yet if (!compareBlockContent(newContent, currentContent)) { DEBUG('set content', { currentContent, newContent, blockID }) insertApplogs(thread, [{ en: blockID, at: BLOCK_DEF.content, vl: serializeTiptapToVl(newContent), pv, }] /* , { focus: true, id: blockID, pos } */) // TODO: which thread? } else { VERBOSE('set content noop', { newContent, blockVM: this }) } } // export function persistBlockisDeleted(thread: Thread, blockID: EntityID, newisDeleted: boolean) { // const currentisDeleted = untracked(() => withDS(thread, () => useBlockAt(blockID, 'isDeleted').get())) // ? not needed I think // // if (currentisDeleted === null) return // not loaded yet // if (newisDeleted != currentisDeleted) { // DEBUG('set isDeleted', { currentisDeleted, newisDeleted, blockID }) // insertApplogs(thread, [{ en: blockID, at: 'block/isDeleted', vl: newisDeleted }]) // } else VERBOSE('set isDeleted noop', { newisDeleted, blockVM: this }) // } export function htmlToTiptap(html: string) { return generateJSON(html, baseExtensions) satisfies TiptapContent } export function outdentBlk(thread: Thread, relation: RelationModelDef, grandParentID = null) { // const appLogDB = getApplogDB() const blockID = relation.block const previousParent = relation.childOf if (!grandParentID) { DEBUG('outdenting up to a root node', { relation }) } DEBUG('move block for outdent', { thread, relation, previousParent, grandParentID }) moveBlock({ thread, relationID: relation.en, blockID, asChildOf: grandParentID, after: previousParent }) } export function indentBlk(thread: Thread, relation: RelationModelDef) { // check if its possible to indent (i am not the first or only kid - i must have childof and after) if (!relation.after || !relation.childOf) { return WARN('indent not possible', { relation }) } // TODO deal with it when more than one kid has the same after (actually legit if the kids were created by differnt agents) const moveOptions = { thread, relationID: relation.en, blockID: relation.block, asChildOf: relation.after } DEBUG('indent via', moveOptions) moveBlock(moveOptions) } const exampleOPMLfromWorkflowy = ` gotjoshua@gmail.com ` export function getRecursiveKidsOPML(rootBlockID, includeRoot = DefaultTrue) { const rawRS = useRawThread() const rootVM = BlockVM.get(rootBlockID, rawRS) const opmlObj = { '?xml': { _version: '1.0' }, 'opml': { // 'head': { // 'ownerEmail': 'gotjoshua@gmail.com', // }, body: { outline: {} }, _version: '2.0', }, } const outlineNodeFromBVM = (bVM: BlockVM) => { return { _text: bVM.contentPlaintext, _tiptap: stringify(bVM.content), } } const addContent = (bVM: BlockVM, objToMutate) => { Object.assign(objToMutate, outlineNodeFromBVM(bVM)) if (bVM.kidVMs.length) { objToMutate.outline = bVM.kidVMs.map((eachKidKidVM) => { const eachKidOutlineNode = outlineNodeFromBVM(eachKidKidVM) if (eachKidKidVM.kidVMs.length) { addContent(eachKidKidVM, eachKidOutlineNode) } else { VERBOSE(eachKidKidVM.en, 'has no kids', eachKidKidVM.contentPlaintext) } return eachKidOutlineNode }) DEBUG('addContent', { opmlObj, bVM }) } } addContent(rootVM, opmlObj.opml.body.outline) const opml = new XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '_', format: true, }) const opmlResult = opml.build(opmlObj) DEBUG('opml', { opml, opmlResult, opmlObj }) return opmlResult } /** * Convert Tiptap JSON content to markdown with formatting preserved */ export function tiptapToMarkdown(tiptapContent: TiptapContent): string { if (!tiptapContent) return '' return renderToMarkdown({ extensions: baseExtensions, content: tiptapContent, }).trim() } export function getRecursiveKidsJSON(rootBlockID, format: 'md' | 'html') { const rawRS = useRawThread() const rootVM = BlockVM.get(rootBlockID, rawRS) // For markdown: build list directly to avoid double-escaping if (format === 'md') { const lines: string[] = [] const addMarkdownContent = (bVM: BlockVM, indent: number) => { const prefix = `${' '.repeat(indent)}- ` lines.push(prefix + tiptapToMarkdown(bVM.content)) for (const kidVM of bVM.kidVMs) { addMarkdownContent(kidVM, indent + 1) } } addMarkdownContent(rootVM, 0) return lines.join('\n') } // For HTML: use XML builder approach const rootUL = [] const baseObj = { ul: rootUL } const addContent = (bVM: BlockVM, objToMutate) => { const thisRootLI = [{ '#text': bVM.contentPlaintext, }] objToMutate.push({ li: thisRootLI }) if (bVM.kidVMs.length) { const kidsLIs = [] const kidsUL = { ul: kidsLIs } for (const eachBlkVM of bVM.kidVMs) { if (eachBlkVM.kidVMs.length) { addContent(eachBlkVM, kidsLIs) } else { kidsLIs.push({ li: eachBlkVM.contentPlaintext }) } } thisRootLI.push(kidsUL) } VERBOSE({ baseObj, bVM }) } addContent(rootVM, rootUL) const opml = new XMLBuilder({ ignoreAttributes: false, attributeNamePrefix: '_', format: true, // preserveOrder: true, oneListGroup: true, }) const htmlResult = opml.build(baseObj) DEBUG('copy node content', rootBlockID, { opml, htmlResult, baseObj }) return htmlResult } interface AddBlockRelationOptions { thread: Thread asChildOf: EntityID | null after?: EntityID | null blockID: EntityID | null focus?: boolean bottom?: boolean } type MoveBlockOptions = AddBlockRelationOptions & { relationID: EntityID | null } export async function moveBlock({ thread, asChildOf, after, blockID, relationID, focus = true, bottom = true }: MoveBlockOptions) { const newApplogs = [ // TODO: use insertBlockIntoRelChain ...getAddBlockRelationLogs({ thread, asChildOf, after, blockID, focus: true, bottom }), ] if (relationID) { // TODO: use removeBlockFromRelChain? newApplogs.push(...getDeleteRelationAtoms(relationID)) } // TODO update after situations of leftovers insertAndMaybeFocus(thread, newApplogs, { inputFocus: blockID, id: blockID, end: true }) } export function getAddBlockRelationLogs({ thread, asChildOf, after, blockID, focus = true, bottom = true }: AddBlockRelationOptions) { // TODO: use insertBlockIntoRelChain? const newRelationID = getHashID([asChildOf, blockID, dateNowIso()], EntityID_LENGTH) const blockToMoveVM = BlockVM.get(blockID, thread) const newParentBlockVM = asChildOf && BlockVM.get(asChildOf, thread) let newApplogs: ApplogForInsertOptionalAgent[] if (!after) { if (bottom) { const currentBottomBlockID = after = (newParentBlockVM.kidRelations.slice(-1)[0] ?? {}).block ?? null // explicit null if there are no kids to be at the bottom of VERBOSE('using bottom as after', { currentBottomBlockID, newParentBlockVM, blockToMoveVM }) } else { WARN('a bit odd to explicitly disable bottom and not pass after') } } newApplogs = getAppLogsForNewRelation({ newRelationID, asChildOf, after, blockID }) VERBOSE('getAddBlockRelationLogs returning', { newApplogs }) return newApplogs } export function getAppLogsForNewRelation({ asChildOf, after, newRelationID, blockID }: { newRelationID: EntityID | null asChildOf: EntityID | null blockID: EntityID | null after?: EntityID | null }) { // TODO: use insertBlockIntoRelChain? const newApplogs = [] if (asChildOf) { newApplogs.push( { en: newRelationID, at: 'relation/childOf', vl: asChildOf }, { en: newRelationID, at: 'relation/block', vl: blockID }, { en: newRelationID, at: 'relation/after', vl: after ?? null }, ) // TODO get parentRelation // TODO if(!parentRelation.isExpanded) {newApplogs.push({ en: parentRelation.id, at: REL_DEF.isExpanded, vl: true })} } return newApplogs as ApplogForInsertOptionalAgent[] } export interface AddBlockOpts { thread: ThreadOnlyCurrentNoDeleted asChildOf: EntityID | null after?: EntityID | null content?: string inputFocus?: boolean } export const addBlock = action( function addBlock({ thread, asChildOf, after = null, content = '', // TODO: TIPTAP_EMPTY? inputFocus = false, }: AddBlockOpts) { DEBUG('Adding block', { asChildOf, after, content, focus: inputFocus }) const blockBuilder = BlockVM .buildNew({ content: content as any as TiptapContent }) // HACK: content type?! .ensureEn() // TODO: add salt (e.g. asChildOf)? // const relBuilder = RelationVM.buildNew({ // block: blockBuilder.en, // childOf: asChildOf, // after: after || null, // }) // if (focus) // relBuilder.update({isExpanded:true}) // ? default isExpanded is true anyways, sure we want it explicit insertApplogs(thread, [ ...blockBuilder.build(), // ...relBuilder.build(), ]) // TODO make into transaction style and offer option to return applogs without commit if (asChildOf) { var newRelID = insertBlockInRelChain(thread, blockBuilder.en, asChildOf, after ?? null) } maybeFocusAfterInsert({ inputFocus, id: blockBuilder.en, end: false }) DEBUG('Block created:', blockBuilder.en, { newRelID }) return blockBuilder.en }, ) function insertAndMaybeFocus( thread: WriteableThread, newApplogs: ApplogForInsertOptionalAgent[], { id = '', inputFocus = DefaultFalse, start = DefaultFalse, end = DefaultFalse, pos = 0 } = {}, ) { const _insertResult = insertApplogs(thread, newApplogs) maybeFocusAfterInsert({ inputFocus, id, end, pos, start }) } function maybeFocusAfterInsert( { id = '', inputFocus = DefaultFalse, start = DefaultFalse, end = DefaultFalse, pos = 0 } = {}, ) { if (!id) return if (inputFocus) { setTimeout(() => { focusBlockAsInput({ id, end, pos, start }) }) } } export function getDeleteRelationAtoms(en: EntityID) { return [{ at: 'isDeleted', en, vl: true }] } // export function deleteRelation(en: EntityID) { // const atoms = getDeleteRelationAtoms(en) // VERBOSE('deleting relation', en, { atoms }) // insertApplogsIDB(atoms) // } export function removeBlockRelAndMaybeDelete(thread: Thread, blockID: EntityID, relationID: EntityID | null) { const blockVM = useBlk(blockID, thread) const appLogsToInsert = relationID ? getDeleteRelationAtoms(relationID) : [] if (!relationID || blockVM.parentRelations.length <= 1) { appLogsToInsert.push({ en: blockID, at: 'isDeleted', vl: true }) } DEBUG(`[removeBlockRelAndMaybeDelete]`, blockID, { relationID, parents: blockVM.parentRelations }) if (!appLogsToInsert.length) { WARN('somethings off, removeBlockRelAndMaybeDelete did nothing') } else { insertApplogsInAppDB(appLogsToInsert) } } export function deleteAndReplaceBlock(relationID: EntityID, newBlockID: string, isMirror = false) { // TODO BUG What if it is a "top level" note without a relation const relation = useRelationVM(relationID) // TODO what if its not the only reference of the block?? // i think only after and isExpanded ought to be changable... // otherwise (i thought we already agreed that) deleting the relation and making a new one is better if (relation) { insertApplogsInAppDB([ { en: relation.block, at: ENTITY_DEF.isDeleted, vl: true }, // delete block { en: relation.en, at: REL_DEF.block, vl: newBlockID }, // update relation to point at pasted block isMirror ? { en: relation.en, at: REL_DEF.isMirror, vl: true } : null, ]) } } export function removeBlockFromRelChain(thread: ThreadOnlyCurrentNoDeleted, blockID: EntityID, relationToParentID: EntityID) { DEBUG(`[removeBlockFromRelChain]`, { blockID, relationToParentID }) const relationToParent = useRel(relationToParentID, thread) const wasAfterRelID = relationToParent.after const relationsAfterMe = queryAndMap(thread, [ { en: '?relID', at: REL.childOf, vl: relationToParent.childOf }, // relations of siblings { en: '?relID', at: REL_DEF.after, vl: blockID }, // that are 'after' this block ], 'relID') DEBUG(`[removeBlockFromRelChain] data:`, { wasAfterRelID, relationsAfterMe }) if (relationsAfterMe.length) { insertApplogsInAppDB([ // update relations referring to me ...uniq(relationsAfterMe).map(relID => ( { en: relID, at: REL_DEF.after, vl: wasAfterRelID } )), ]) } } export function insertBlockInRelChain( thread: ThreadOnlyCurrentNoDeleted, blockID: EntityID, parentID: EntityID, after: EntityID | null, relationToParentID?: EntityID, ) { DEBUG(`[insertBlockInRelChain]`, { blockID, parentID, after, relationToParentID }) let relBuilder: InstanceType if (relationToParentID) { const relToParentVM = useRel(relationToParentID, thread) relBuilder = relToParentVM.buildUpdate() if (relToParentVM.childOf !== parentID) { relBuilder.update({ childOf: parentID }) } } else { relBuilder = RelationVM.buildNew({ block: blockID, childOf: parentID }) } relBuilder.update({ after }) relationToParentID = relBuilder.commit(thread).en // update relations to refer to me const relationsAfterTarget = queryAndMap(thread, [ { en: '?rel', at: REL_DEF.childOf, vl: parentID }, // relations of siblings { en: '?rel', at: REL_DEF.after, vl: after }, // that are 'after' who we want to be after ], 'rel') .filter(rel => rel !== relationToParentID) // except my relation if (relationsAfterTarget.length) { DEBUG( `[insertBlockInRelChain] relationsAfterTarget:`, relationToParentID, blockID, relationsAfterTarget, /* , { relationsAfterTargetQuery, relationsOfParent } */ ) insertApplogsInAppDB([ ...relationsAfterTarget.map(rel => ( { en: rel, at: REL_DEF.after, vl: blockID ?? null } )), ]) } return relBuilder.en } export function getEditorForBlockID(blockID: string) { return (window.document.querySelector(`#block-${blockID} .tiptap`) as HTMLDivElement & { editor: any }).editor } export function createHomeBlockForAgent(appSettings: AppSettingsVM, appThread: ThreadWithoutFilters, agent: AgentStateClass) { DEBUG(`Creating home block`, { appSettings }) const home = addBlock({ thread: withDS(appThread, useCurrentThread), asChildOf: null, content: htmlToSerializedTiptap(`Home Block — ${agent.agentString}`), }) appSettings.update({ homeBlock: home }) LOG(`Created home block`, { home, appSettings }) }