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 })
}