Input: {"content":"export type TiptapMark = {\n  type: string;\n  attrs?: Record<string, unknown>;\n};\n\nexport type TiptapNode = {\n  type: string;\n  attrs?: Record<string, unknown>;\n  content?: TiptapNode[];\n  text?: string;\n  marks?: TiptapMark[];\n};\n\nexport type TiptapDoc = {\n  type: \"doc\";\n  content: TiptapNode[];\n};\n\nexport type EditorTextMark = \"bold\" | \"italic\" | \"underline\" | \"strike\" | \"code\";\n\nexport type EditorBlockType =\n  | \"paragraph\"\n  | \"heading\"\n  | \"bullet_list_item\"\n  | \"numbered_list_item\"\n  | \"todo\"\n  | \"quote\"\n  | \"code_block\";\n\nexport type EditorContentNode = {\n  type: \"text\";\n  text: string;\n  marks?: EditorTextMark[];\n};\n\nexport type EditorBlock = {\n  blockId: string;\n  blockType: EditorBlockType;\n  props?: {\n    headingLevel?: number | null;\n    checked?: boolean | null;\n    language?: string | null;\n  };\n  contentNodes?: EditorContentNode[];\n  childBlockIds?: string[];\n};\n\nexport type EditorBlockDocument = {\n  documentId: string;\n  rootBlockIds: string[];\n  blocks: EditorBlock[];\n};\n\ntype Json = unknown;\n\ntype LegacyBlockLike = {\n  id?: string;\n  blockId?: string;\n  type?: string;\n  blockType?: string;\n  content?: unknown;\n  contentNodes?: unknown;\n  props?: Record<string, unknown>;\n  children?: unknown;\n};\n\nconst EMPTY_DOC: TiptapDoc = {\n  type: \"doc\",\n  content: [{ type: \"paragraph\", attrs: {}, content: [] }],\n};\n\nconst MARK_NAME_MAP: Record<string, EditorTextMark> = {\n  bold: \"bold\",\n  italic: \"italic\",\n  underline: \"underline\",\n  strike: \"strike\",\n  code: \"code\",\n};\n\nconst BLOCK_ID_ATTR = \"blockId\";\n\nfunction coerceTextMark(value: unknown): EditorTextMark | null {\n  const normalized = String(value ?? \"\").trim().toLowerCase();\n  return MARK_NAME_MAP[normalized] ?? null;\n}\n\nfunction readMarks(marks: unknown): EditorTextMark[] {\n  if (!Array.isArray(marks)) return [];\n  const normalized = marks\n    .map((mark) => {\n      if (typeof mark === \"string\") return coerceTextMark(mark);\n      if (mark && typeof mark === \"object\") return coerceTextMark((mark as { type?: unknown }).type);\n      return null;\n    })\n    .filter((mark): mark is EditorTextMark => Boolean(mark));\n  return Array.from(new Set(normalized));\n}\n\nfunction toText(value: unknown): string {\n  if (typeof value === \"string\") return value;\n  if (Array.isArray(value)) return value.map(toText).join(\"\");\n  if (value && typeof value === \"object\") {\n    const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };\n    return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;\n  }\n  return \"\";\n}\n\nfunction clampHeadingLevel(value: unknown): number {\n  const level = Number(value);\n  return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;\n}\n\nfunction normalizeBlockId(value: unknown, fallback: string): string {\n  const raw = typeof value === \"string\" ? value.trim() : \"\";\n  return raw || fallback;\n}\n\nfunction normalizeEditorContentNodes(input: unknown): EditorContentNode[] {\n  if (Array.isArray(input)) {\n    const nodes = input.flatMap((item) => {\n      if (!item || typeof item !== \"object\") return [];\n      const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };\n      if (record.type === \"text\") {\n        const text = toText(record.text);\n        return text ? [{ type: \"text\" as const, text, marks: readMarks(record.marks) }] : [];\n      }\n      if (record.payload && typeof record.payload === \"object\") {\n        const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };\n        if (payload.type === \"text\") {\n          const text = toText(payload.text);\n          return text ? [{ type: \"text\" as const, text, marks: readMarks(payload.marks) }] : [];\n        }\n      }\n      const text = toText(item);\n      return text ? [{ type: \"text\" as const, text, marks: [] }] : [];\n    });\n    return nodes;\n  }\n  const text = toText(input);\n  return text ? [{ type: \"text\", text, marks: [] }] : [];\n}\n\nfunction normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {\n  const blockType = String(block.blockType ?? block.type ?? \"paragraph\").trim().toLowerCase();\n  const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);\n  const props = block.props ?? {};\n  const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);\n\n  switch (blockType) {\n    case \"heading\":\n      return {\n        blockId,\n        blockType: \"heading\",\n        props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"bullet_list_item\":\n    case \"bullet_list\":\n    case \"bullet-list\":\n      return { blockId, blockType: \"bullet_list_item\", props: {}, contentNodes, childBlockIds: [] };\n    case \"numbered_list_item\":\n    case \"ordered_list\":\n    case \"ordered-list\":\n      return { blockId, blockType: \"numbered_list_item\", props: {}, contentNodes, childBlockIds: [] };\n    case \"todo\":\n    case \"task\":\n      return {\n        blockId,\n        blockType: \"todo\",\n        props: { checked: Boolean(props.checked) },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"quote\":\n    case \"blockquote\":\n      return { blockId, blockType: \"quote\", props: {}, contentNodes, childBlockIds: [] };\n    case \"code\":\n    case \"code_block\":\n    case \"code-block\":\n      return {\n        blockId,\n        blockType: \"code_block\",\n        props: { language: typeof props.language === \"string\" ? props.language : null },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"paragraph\":\n    case \"text\":\n    default:\n      return { blockId, blockType: \"paragraph\", props: {}, contentNodes, childBlockIds: [] };\n  }\n}\n\nfunction normalizeInputToEditorDocument(value: Json): EditorBlockDocument {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    const maybe = value as Partial<EditorBlockDocument>;\n    if (typeof maybe.documentId === \"string\" && Array.isArray(maybe.blocks)) {\n      const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));\n      const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0\n        ? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))\n        : normalizedBlocks.map((block) => block.blockId);\n      return {\n        documentId: maybe.documentId,\n        rootBlockIds,\n        blocks: normalizedBlocks,\n      };\n    }\n    if (Array.isArray((value as { blocks?: unknown }).blocks)) {\n      const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>\n        normalizeLegacyBlock(block as LegacyBlockLike, index),\n      );\n      return {\n        documentId: typeof (value as { documentId?: unknown }).documentId === \"string\"\n          ? String((value as { documentId?: unknown }).documentId)\n          : \"document\",\n        rootBlockIds: blocks.map((block) => block.blockId),\n        blocks,\n      };\n    }\n  }\n\n  const blocks = Array.isArray(value)\n    ? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))\n    : [];\n\n  return {\n    documentId: \"document\",\n    rootBlockIds: blocks.map((block) => block.blockId),\n    blocks,\n  };\n}\n\nfunction textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {\n  return (contentNodes ?? []).flatMap((node) => {\n    const text = toText(node.text);\n    if (!text) return [];\n    const marks = (node.marks ?? [])\n      .map((mark) => ({ type: mark }))\n      .filter((mark) => Boolean(mark.type));\n    return [{ type: \"text\", text, marks: marks.length > 0 ? marks : undefined }];\n  });\n}\n\nfunction blockToTiptapNode(block: EditorBlock): TiptapNode {\n  const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };\n  switch (block.blockType) {\n    case \"heading\":\n      return {\n        type: \"heading\",\n        attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },\n        content: textNodesToInline(block.contentNodes),\n      };\n    case \"bullet_list_item\":\n      return {\n        type: \"bulletList\",\n        attrs: commonAttrs,\n        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"numbered_list_item\":\n      return {\n        type: \"orderedList\",\n        attrs: commonAttrs,\n        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"todo\":\n      return {\n        type: \"taskList\",\n        attrs: commonAttrs,\n        content: [{ type: \"taskItem\", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"quote\":\n      return {\n        type: \"blockquote\",\n        attrs: commonAttrs,\n        content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],\n      };\n    case \"code_block\":\n      return {\n        type: \"codeBlock\",\n        attrs: { ...commonAttrs, language: block.props?.language ?? null },\n        content: textNodesToInline(block.contentNodes),\n      };\n    case \"paragraph\":\n    default:\n      return {\n        type: \"paragraph\",\n        attrs: commonAttrs,\n        content: textNodesToInline(block.contentNodes),\n      };\n  }\n}\n\nfunction extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {\n  if (!node) return [];\n  if (Array.isArray(node.content)) {\n    return node.content.flatMap((child) => {\n      if (child.type === \"text\") {\n        const text = toText(child.text);\n        if (!text) return [];\n        return [{ type: \"text\" as const, text, marks: readMarks(child.marks) }];\n      }\n      if (child.type === \"hardBreak\") {\n        return [{ type: \"text\" as const, text: \"\\n\", marks: [] }];\n      }\n      return extractInlineContent(child);\n    });\n  }\n  if (node.type === \"text\") {\n    const text = toText(node.text);\n    return text ? [{ type: \"text\", text, marks: readMarks(node.marks) }] : [];\n  }\n  return [];\n}\n\nfunction firstChild(node: TiptapNode | undefined): TiptapNode | undefined {\n  return Array.isArray(node?.content) ? node?.content?.[0] : undefined;\n}\n\nfunction nodeBlockId(node: TiptapNode, index: number): string {\n  return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);\n}\n\nfunction tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {\n  const blockId = nodeBlockId(node, index);\n  switch (node.type) {\n    case \"paragraph\":\n      return { blockId, blockType: \"paragraph\", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };\n    case \"heading\":\n      return {\n        blockId,\n        blockType: \"heading\",\n        props: { headingLevel: clampHeadingLevel(node.attrs?.level) },\n        contentNodes: extractInlineContent(node),\n        childBlockIds: [],\n      };\n    case \"bulletList\": {\n      const paragraph = firstChild(firstChild(node));\n      return {\n        blockId,\n        blockType: \"bullet_list_item\",\n        props: {},\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"orderedList\": {\n      const paragraph = firstChild(firstChild(node));\n      return {\n        blockId,\n        blockType: \"numbered_list_item\",\n        props: {},\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"taskList\": {\n      const taskItem = firstChild(node);\n      const paragraph = firstChild(taskItem);\n      return {\n        blockId,\n        blockType: \"todo\",\n        props: { checked: Boolean(taskItem?.attrs?.checked) },\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"blockquote\":\n      return { blockId, blockType: \"quote\", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };\n    case \"codeBlock\":\n      return {\n        blockId,\n        blockType: \"code_block\",\n        props: { language: typeof node.attrs?.language === \"string\" ? String(node.attrs.language) : null },\n        contentNodes: extractInlineContent(node),\n        childBlockIds: [],\n      };\n    default:\n      return null;\n  }\n}\n\nexport function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {\n  return normalizeInputToEditorDocument(content);\n}\n\nexport function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {\n  const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));\n  const nodes = document.rootBlockIds\n    .map((blockId) => blockById.get(blockId))\n    .filter((block): block is EditorBlock => Boolean(block))\n    .map(blockToTiptapNode);\n  return {\n    type: \"doc\",\n    content: nodes.length > 0 ? nodes : EMPTY_DOC.content,\n  };\n}\n\nexport function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = \"document\"): EditorBlockDocument {\n  if (!doc || typeof doc !== \"object\") {\n    return { documentId, rootBlockIds: [], blocks: [] };\n  }\n  const root = doc as { type?: unknown; content?: unknown };\n  if (root.type !== \"doc\" || !Array.isArray(root.content)) {\n    return { documentId, rootBlockIds: [], blocks: [] };\n  }\n  const blocks = root.content.flatMap((node, index) => {\n    if (!node || typeof node !== \"object\") return [];\n    const block = tiptapNodeToBlock(node as TiptapNode, index);\n    return block ? [block] : [];\n  });\n  return {\n    documentId,\n    rootBlockIds: blocks.map((block) => block.blockId),\n    blocks,\n  };\n}\n\nexport function tiptapDocFromBlocks(blocks: Json): TiptapDoc {\n  return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));\n}\n\nexport function blocksFromTiptapDoc(doc: unknown): Json {\n  return editorBlockDocumentFromTiptapDoc(doc).blocks.map((block) => ({\n    id: block.blockId,\n    type: block.blockType,\n    props:\n      block.blockType === \"heading\"\n        ? { level: block.props?.headingLevel ?? 1 }\n        : block.blockType === \"todo\"\n          ? { checked: Boolean(block.props?.checked) }\n          : block.blockType === \"code_block\"\n            ? { language: block.props?.language ?? null }\n            : undefined,\n    content: (block.contentNodes ?? []).map((node) => node.text).join(\"\"),\n  }));\n}\n\nexport const blocksToTiptapDoc = tiptapDocFromBlocks;\nexport const tiptapDocToBlocks = blocksFromTiptapDoc;\n","path":"/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.ts"}


Output:
{
  "type": "update",
  "filePath": "/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.ts",
  "content": "export type TiptapMark = {\n  type: string;\n  attrs?: Record<string, unknown>;\n};\n\nexport type TiptapNode = {\n  type: string;\n  attrs?: Record<string, unknown>;\n  content?: TiptapNode[];\n  text?: string;\n  marks?: TiptapMark[];\n};\n\nexport type TiptapDoc = {\n  type: \"doc\";\n  content: TiptapNode[];\n};\n\nexport type EditorTextMark = \"bold\" | \"italic\" | \"underline\" | \"strike\" | \"code\";\n\nexport type EditorBlockType =\n  | \"paragraph\"\n  | \"heading\"\n  | \"bullet_list_item\"\n  | \"numbered_list_item\"\n  | \"todo\"\n  | \"quote\"\n  | \"code_block\";\n\nexport type EditorContentNode = {\n  type: \"text\";\n  text: string;\n  marks?: EditorTextMark[];\n};\n\nexport type EditorBlock = {\n  blockId: string;\n  blockType: EditorBlockType;\n  props?: {\n    headingLevel?: number | null;\n    checked?: boolean | null;\n    language?: string | null;\n  };\n  contentNodes?: EditorContentNode[];\n  childBlockIds?: string[];\n};\n\nexport type EditorBlockDocument = {\n  documentId: string;\n  rootBlockIds: string[];\n  blocks: EditorBlock[];\n};\n\ntype Json = unknown;\n\ntype LegacyBlockLike = {\n  id?: string;\n  blockId?: string;\n  type?: string;\n  blockType?: string;\n  content?: unknown;\n  contentNodes?: unknown;\n  props?: Record<string, unknown>;\n  children?: unknown;\n};\n\nconst EMPTY_DOC: TiptapDoc = {\n  type: \"doc\",\n  content: [{ type: \"paragraph\", attrs: {}, content: [] }],\n};\n\nconst MARK_NAME_MAP: Record<string, EditorTextMark> = {\n  bold: \"bold\",\n  italic: \"italic\",\n  underline: \"underline\",\n  strike: \"strike\",\n  code: \"code\",\n};\n\nconst BLOCK_ID_ATTR = \"blockId\";\n\nfunction coerceTextMark(value: unknown): EditorTextMark | null {\n  const normalized = String(value ?? \"\").trim().toLowerCase();\n  return MARK_NAME_MAP[normalized] ?? null;\n}\n\nfunction readMarks(marks: unknown): EditorTextMark[] {\n  if (!Array.isArray(marks)) return [];\n  const normalized = marks\n    .map((mark) => {\n      if (typeof mark === \"string\") return coerceTextMark(mark);\n      if (mark && typeof mark === \"object\") return coerceTextMark((mark as { type?: unknown }).type);\n      return null;\n    })\n    .filter((mark): mark is EditorTextMark => Boolean(mark));\n  return Array.from(new Set(normalized));\n}\n\nfunction toText(value: unknown): string {\n  if (typeof value === \"string\") return value;\n  if (Array.isArray(value)) return value.map(toText).join(\"\");\n  if (value && typeof value === \"object\") {\n    const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };\n    return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;\n  }\n  return \"\";\n}\n\nfunction clampHeadingLevel(value: unknown): number {\n  const level = Number(value);\n  return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;\n}\n\nfunction normalizeBlockId(value: unknown, fallback: string): string {\n  const raw = typeof value === \"string\" ? value.trim() : \"\";\n  return raw || fallback;\n}\n\nfunction normalizeEditorContentNodes(input: unknown): EditorContentNode[] {\n  if (Array.isArray(input)) {\n    const nodes = input.flatMap((item) => {\n      if (!item || typeof item !== \"object\") return [];\n      const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };\n      if (record.type === \"text\") {\n        const text = toText(record.text);\n        return text ? [{ type: \"text\" as const, text, marks: readMarks(record.marks) }] : [];\n      }\n      if (record.payload && typeof record.payload === \"object\") {\n        const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };\n        if (payload.type === \"text\") {\n          const text = toText(payload.text);\n          return text ? [{ type: \"text\" as const, text, marks: readMarks(payload.marks) }] : [];\n        }\n      }\n      const text = toText(item);\n      return text ? [{ type: \"text\" as const, text, marks: [] }] : [];\n    });\n    return nodes;\n  }\n  const text = toText(input);\n  return text ? [{ type: \"text\", text, marks: [] }] : [];\n}\n\nfunction normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {\n  const blockType = String(block.blockType ?? block.type ?? \"paragraph\").trim().toLowerCase();\n  const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);\n  const props = block.props ?? {};\n  const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);\n\n  switch (blockType) {\n    case \"heading\":\n      return {\n        blockId,\n        blockType: \"heading\",\n        props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"bullet_list_item\":\n    case \"bullet_list\":\n    case \"bullet-list\":\n      return { blockId, blockType: \"bullet_list_item\", props: {}, contentNodes, childBlockIds: [] };\n    case \"numbered_list_item\":\n    case \"ordered_list\":\n    case \"ordered-list\":\n      return { blockId, blockType: \"numbered_list_item\", props: {}, contentNodes, childBlockIds: [] };\n    case \"todo\":\n    case \"task\":\n      return {\n        blockId,\n        blockType: \"todo\",\n        props: { checked: Boolean(props.checked) },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"quote\":\n    case \"blockquote\":\n      return { blockId, blockType: \"quote\", props: {}, contentNodes, childBlockIds: [] };\n    case \"code\":\n    case \"code_block\":\n    case \"code-block\":\n      return {\n        blockId,\n        blockType: \"code_block\",\n        props: { language: typeof props.language === \"string\" ? props.language : null },\n        contentNodes,\n        childBlockIds: [],\n      };\n    case \"paragraph\":\n    case \"text\":\n    default:\n      return { blockId, blockType: \"paragraph\", props: {}, contentNodes, childBlockIds: [] };\n  }\n}\n\nfunction normalizeInputToEditorDocument(value: Json): EditorBlockDocument {\n  if (value && typeof value === \"object\" && !Array.isArray(value)) {\n    const maybe = value as Partial<EditorBlockDocument>;\n    if (typeof maybe.documentId === \"string\" && Array.isArray(maybe.blocks)) {\n      const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));\n      const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0\n        ? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))\n        : normalizedBlocks.map((block) => block.blockId);\n      return {\n        documentId: maybe.documentId,\n        rootBlockIds,\n        blocks: normalizedBlocks,\n      };\n    }\n    if (Array.isArray((value as { blocks?: unknown }).blocks)) {\n      const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>\n        normalizeLegacyBlock(block as LegacyBlockLike, index),\n      );\n      return {\n        documentId: typeof (value as { documentId?: unknown }).documentId === \"string\"\n          ? String((value as { documentId?: unknown }).documentId)\n          : \"document\",\n        rootBlockIds: blocks.map((block) => block.blockId),\n        blocks,\n      };\n    }\n  }\n\n  const blocks = Array.isArray(value)\n    ? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))\n    : [];\n\n  return {\n    documentId: \"document\",\n    rootBlockIds: blocks.map((block) => block.blockId),\n    blocks,\n  };\n}\n\nfunction textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {\n  return (contentNodes ?? []).flatMap((node) => {\n    const text = toText(node.text);\n    if (!text) return [];\n    const marks = (node.marks ?? [])\n      .map((mark) => ({ type: mark }))\n      .filter((mark) => Boolean(mark.type));\n    return [{ type: \"text\", text, marks: marks.length > 0 ? marks : undefined }];\n  });\n}\n\nfunction blockToTiptapNode(block: EditorBlock): TiptapNode {\n  const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };\n  switch (block.blockType) {\n    case \"heading\":\n      return {\n        type: \"heading\",\n        attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },\n        content: textNodesToInline(block.contentNodes),\n      };\n    case \"bullet_list_item\":\n      return {\n        type: \"bulletList\",\n        attrs: commonAttrs,\n        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"numbered_list_item\":\n      return {\n        type: \"orderedList\",\n        attrs: commonAttrs,\n        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"todo\":\n      return {\n        type: \"taskList\",\n        attrs: commonAttrs,\n        content: [{ type: \"taskItem\", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],\n      };\n    case \"quote\":\n      return {\n        type: \"blockquote\",\n        attrs: commonAttrs,\n        content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],\n      };\n    case \"code_block\":\n      return {\n        type: \"codeBlock\",\n        attrs: { ...commonAttrs, language: block.props?.language ?? null },\n        content: textNodesToInline(block.contentNodes),\n      };\n    case \"paragraph\":\n    default:\n      return {\n        type: \"paragraph\",\n        attrs: commonAttrs,\n        content: textNodesToInline(block.contentNodes),\n      };\n  }\n}\n\nfunction extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {\n  if (!node) return [];\n  if (Array.isArray(node.content)) {\n    return node.content.flatMap((child) => {\n      if (child.type === \"text\") {\n        const text = toText(child.text);\n        if (!text) return [];\n        return [{ type: \"text\" as const, text, marks: readMarks(child.marks) }];\n      }\n      if (child.type === \"hardBreak\") {\n        return [{ type: \"text\" as const, text: \"\\n\", marks: [] }];\n      }\n      return extractInlineContent(child);\n    });\n  }\n  if (node.type === \"text\") {\n    const text = toText(node.text);\n    return text ? [{ type: \"text\", text, marks: readMarks(node.marks) }] : [];\n  }\n  return [];\n}\n\nfunction firstChild(node: TiptapNode | undefined): TiptapNode | undefined {\n  return Array.isArray(node?.content) ? node?.content?.[0] : undefined;\n}\n\nfunction nodeBlockId(node: TiptapNode, index: number): string {\n  return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);\n}\n\nfunction tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {\n  const blockId = nodeBlockId(node, index);\n  switch (node.type) {\n    case \"paragraph\":\n      return { blockId, blockType: \"paragraph\", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };\n    case \"heading\":\n      return {\n        blockId,\n        blockType: \"heading\",\n        props: { headingLevel: clampHeadingLevel(node.attrs?.level) },\n        contentNodes: extractInlineContent(node),\n        childBlockIds: [],\n      };\n    case \"bulletList\": {\n      const paragraph = firstChild(firstChild(node));\n      return {\n        blockId,\n        blockType: \"bullet_list_item\",\n        props: {},\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"orderedList\": {\n      const paragraph = firstChild(firstChild(node));\n      return {\n        blockId,\n        blockType: \"numbered_list_item\",\n        props: {},\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"taskList\": {\n      const taskItem = firstChild(node);\n      const paragraph = firstChild(taskItem);\n      return {\n        blockId,\n        blockType: \"todo\",\n        props: { checked: Boolean(taskItem?.attrs?.checked) },\n        contentNodes: extractInlineContent(paragraph),\n        childBlockIds: [],\n      };\n    }\n    case \"blockquote\":\n      return { blockId, blockType: \"quote\", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };\n    case \"codeBlock\":\n      return {\n        blockId,\n        blockType: \"code_block\",\n        props: { language: typeof node.attrs?.language === \"string\" ? String(node.attrs.language) : null },\n        contentNodes: extractInlineContent(node),\n        childBlockIds: [],\n      };\n    default:\n      return null;\n  }\n}\n\nexport function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {\n  return normalizeInputToEditorDocument(content);\n}\n\nexport function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {\n  const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));\n  const nodes = document.rootBlockIds\n    .map((blockId) => blockById.get(blockId))\n    .filter((block): block is EditorBlock => Boolean(block))\n    .map(blockToTiptapNode);\n  return {\n    type: \"doc\",\n    content: nodes.length > 0 ? nodes : EMPTY_DOC.content,\n  };\n}\n\nexport function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = \"document\"): EditorBlockDocument {\n  if (!doc || typeof doc !== \"object\") {\n    return { documentId, rootBlockIds: [], blocks: [] };\n  }\n  const root = doc as { type?: unknown; content?: unknown };\n  if (root.type !== \"doc\" || !Array.isArray(root.content)) {\n    return { documentId, rootBlockIds: [], blocks: [] };\n  }\n  const blocks = root.content.flatMap((node, index) => {\n    if (!node || typeof node !== \"object\") return [];\n    const block = tiptapNodeToBlock(node as TiptapNode, index);\n    return block ? [block] : [];\n  });\n  return {\n    documentId,\n    rootBlockIds: blocks.map((block) => block.blockId),\n    blocks,\n  };\n}\n\nexport function tiptapDocFromBlocks(blocks: Json): TiptapDoc {\n  return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));\n}\n\nexport function blocksFromTiptapDoc(doc: unknown): Json {\n  return editorBlockDocumentFromTiptapDoc(doc).blocks.map((block) => ({\n    id: block.blockId,\n    type: block.blockType,\n    props:\n      block.blockType === \"heading\"\n        ? { level: block.props?.headingLevel ?? 1 }\n        : block.blockType === \"todo\"\n          ? { checked: Boolean(block.props?.checked) }\n          : block.blockType === \"code_block\"\n            ? { language: block.props?.language ?? null }\n            : undefined,\n    content: (block.contentNodes ?? []).map((node) => node.text).join(\"\"),\n  }));\n}\n\nexport const blocksToTiptapDoc = tiptapDocFromBlocks;\nexport const tiptapDocToBlocks = blocksFromTiptapDoc;\n",
  "structuredPatch": [
    {
      "oldStart": 1,
      "oldLines": 92,
      "newStart": 1,
      "newLines": 441,
      "lines": [
        "-export type TiptapNode = {",
        "-  type: string;",
        "-  attrs?: Record<string, unknown>;",
        "-  content?: TiptapNode[];",
        "-  text?: string;",
        "-  marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;",
        "-};",
        "-",
        "-export type TiptapDoc = {",
        "-  type: \"doc\";",
        "-  content: TiptapNode[];",
        "-};",
        "-",
        "-type Json = unknown;",
        "-",
        "-type BlockLike = {",
        "-  id?: string;",
        "-  type?: string;",
        "-  content?: unknown;",
        "-  props?: Record<string, unknown>;",
        "-  children?: unknown;",
        "-};",
        "-",
        "-const emptyDoc: TiptapDoc = { type: \"doc\", content: [] };",
        "-",
        "-const toText = (value: unknown): string => {",
        "-  if (typeof value === \"string\") return value;",
        "-  if (Array.isArray(value)) return value.map(toText).join(\"\");",
        "-  if (value && typeof value === \"object\") {",
        "-    const obj = value as { text?: unknown; content?: unknown; value?: unknown };",
        "-    return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}`;",
        "-  }",
        "-  return \"\";",
        "-};",
        "-",
        "-const toTiptapInline = (value: unknown): TiptapNode[] => {",
        "-  const text = toText(value);",
        "-  return text ? [{ type: \"text\", text }] : [];",
        "-};",
        "-",
        "-const toHeadingLevel = (value: unknown): number => {",
        "-  const level = Number(value);",
        "-  return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;",
        "-};",
        "-",
        "-const blockToNode = (block: BlockLike): TiptapNode | null => {",
        "-  const type = String(block.type ?? \"\").toLowerCase();",
        "-  const content = block.content ?? block.props?.content ?? block.props?.text ?? block.props?.title;",
        "-  if (type === \"paragraph\" || type === \"text\") return { type: \"paragraph\", content: toTiptapInline(content) };",
        "-  if (type === \"heading\") return { type: \"heading\", attrs: { level: toHeadingLevel(block.props?.level) }, content: toTiptapInline(content) };",
        "-  if (type === \"bullet_list\" || type === \"bullet-list\") return { type: \"bulletList\", content: [] };",
        "-  if (type === \"ordered_list\" || type === \"ordered-list\") return { type: \"orderedList\", content: [] };",
        "-  if (type === \"todo\" || type === \"task\") return { type: \"taskItem\", attrs: { checked: Boolean(block.props?.checked) }, content: toTiptapInline(content) };",
        "-  if (type === \"quote\" || type === \"blockquote\") return { type: \"blockquote\", content: [{ type: \"paragraph\", content: toTiptapInline(content) }] };",
        "-  if (type === \"code\" || type === \"code_block\" || type === \"code-block\") return { type: \"codeBlock\", content: toTiptapInline(content) };",
        "-  return null;",
        "-};",
        "-",
        "-export function blocksToTiptapDoc(blocks: Json): TiptapDoc {",
        "-  if (!Array.isArray(blocks) || blocks.length === 0) return { ...emptyDoc };",
        "-  const content = blocks.flatMap((item) => {",
        "-    if (!item || typeof item !== \"object\") return [];",
        "-    const node = blockToNode(item as BlockLike);",
        "-    return node ? [node] : [];",
        "-  });",
        "-  return { type: \"doc\", content: content.length > 0 ? content : [{ type: \"paragraph\" }] };",
        "-}",
        "-",
        "-const nodeToBlock = (node: TiptapNode, index: number): Record<string, unknown> | null => {",
        "-  if (node.type === \"paragraph\") return { id: `block-${index}`, type: \"paragraph\", content: toText(node.content) };",
        "-  if (node.type === \"heading\") return { id: `block-${index}`, type: \"heading\", props: { level: Number(node.attrs?.level ?? 1) }, content: toText(node.content) };",
        "-  if (node.type === \"bulletList\") return { id: `block-${index}`, type: \"bullet_list\" };",
        "-  if (node.type === \"orderedList\") return { id: `block-${index}`, type: \"ordered_list\" };",
        "-  if (node.type === \"taskItem\") return { id: `block-${index}`, type: \"todo\", props: { checked: Boolean(node.attrs?.checked) }, content: toText(node.content) };",
        "-  if (node.type === \"blockquote\") return { id: `block-${index}`, type: \"quote\", content: toText(node.content) };",
        "-  if (node.type === \"codeBlock\") return { id: `block-${index}`, type: \"code_block\", content: toText(node.content) };",
        "-  return null;",
        "-};",
        "-",
        "-export function tiptapDocToBlocks(doc: unknown): Json {",
        "-  if (!doc || typeof doc !== \"object\") return [];",
        "-  const root = doc as { type?: string; content?: TiptapNode[] };",
        "-  if (root.type !== \"doc\" || !Array.isArray(root.content) || root.content.length === 0) return [];",
        "-  return root.content.flatMap((node, index) => {",
        "-    if (!node || typeof node !== \"object\") return [];",
        "-    const block = nodeToBlock(node, index);",
        "-    return block ? [block] : [];",
        "-  }) as Json;",
        "-}",
        "-",
        "-export const tiptapDocFromBlocks = blocksToTiptapDoc;",
        "-export const blocksFromTiptapDoc = tiptapDocToBlocks;",
        "+export type TiptapMark = {",
        "+  type: string;",
        "+  attrs?: Record<string, unknown>;",
        "+};",
        "+",
        "+export type TiptapNode = {",
        "+  type: string;",
        "+  attrs?: Record<string, unknown>;",
        "+  content?: TiptapNode[];",
        "+  text?: string;",
        "+  marks?: TiptapMark[];",
        "+};",
        "+",
        "+export type TiptapDoc = {",
        "+  type: \"doc\";",
        "+  content: TiptapNode[];",
        "+};",
        "+",
        "+export type EditorTextMark = \"bold\" | \"italic\" | \"underline\" | \"strike\" | \"code\";",
        "+",
        "+export type EditorBlockType =",
        "+  | \"paragraph\"",
        "+  | \"heading\"",
        "+  | \"bullet_list_item\"",
        "+  | \"numbered_list_item\"",
        "+  | \"todo\"",
        "+  | \"quote\"",
        "+  | \"code_block\";",
        "+",
        "+export type EditorContentNode = {",
        "+  type: \"text\";",
        "+  text: string;",
        "+  marks?: EditorTextMark[];",
        "+};",
        "+",
        "+export type EditorBlock = {",
        "+  blockId: string;",
        "+  blockType: EditorBlockType;",
        "+  props?: {",
        "+    headingLevel?: number | null;",
        "+    checked?: boolean | null;",
        "+    language?: string | null;",
        "+  };",
        "+  contentNodes?: EditorContentNode[];",
        "+  childBlockIds?: string[];",
        "+};",
        "+",
        "+export type EditorBlockDocument = {",
        "+  documentId: string;",
        "+  rootBlockIds: string[];",
        "+  blocks: EditorBlock[];",
        "+};",
        "+",
        "+type Json = unknown;",
        "+",
        "+type LegacyBlockLike = {",
        "+  id?: string;",
        "+  blockId?: string;",
        "+  type?: string;",
        "+  blockType?: string;",
        "+  content?: unknown;",
        "+  contentNodes?: unknown;",
        "+  props?: Record<string, unknown>;",
        "+  children?: unknown;",
        "+};",
        "+",
        "+const EMPTY_DOC: TiptapDoc = {",
        "+  type: \"doc\",",
        "+  content: [{ type: \"paragraph\", attrs: {}, content: [] }],",
        "+};",
        "+",
        "+const MARK_NAME_MAP: Record<string, EditorTextMark> = {",
        "+  bold: \"bold\",",
        "+  italic: \"italic\",",
        "+  underline: \"underline\",",
        "+  strike: \"strike\",",
        "+  code: \"code\",",
        "+};",
        "+",
        "+const BLOCK_ID_ATTR = \"blockId\";",
        "+",
        "+function coerceTextMark(value: unknown): EditorTextMark | null {",
        "+  const normalized = String(value ?? \"\").trim().toLowerCase();",
        "+  return MARK_NAME_MAP[normalized] ?? null;",
        "+}",
        "+",
        "+function readMarks(marks: unknown): EditorTextMark[] {",
        "+  if (!Array.isArray(marks)) return [];",
        "+  const normalized = marks",
        "+    .map((mark) => {",
        "+      if (typeof mark === \"string\") return coerceTextMark(mark);",
        "+      if (mark && typeof mark === \"object\") return coerceTextMark((mark as { type?: unknown }).type);",
        "+      return null;",
        "+    })",
        "+    .filter((mark): mark is EditorTextMark => Boolean(mark));",
        "+  return Array.from(new Set(normalized));",
        "+}",
        "+",
        "+function toText(value: unknown): string {",
        "+  if (typeof value === \"string\") return value;",
        "+  if (Array.isArray(value)) return value.map(toText).join(\"\");",
        "+  if (value && typeof value === \"object\") {",
        "+    const obj = value as { text?: unknown; content?: unknown; value?: unknown; contentNodes?: unknown };",
        "+    return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}${toText(obj.contentNodes)}`;",
        "+  }",
        "+  return \"\";",
        "+}",
        "+",
        "+function clampHeadingLevel(value: unknown): number {",
        "+  const level = Number(value);",
        "+  return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;",
        "+}",
        "+",
        "+function normalizeBlockId(value: unknown, fallback: string): string {",
        "+  const raw = typeof value === \"string\" ? value.trim() : \"\";",
        "+  return raw || fallback;",
        "+}",
        "+",
        "+function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {",
        "+  if (Array.isArray(input)) {",
        "+    const nodes = input.flatMap((item) => {",
        "+      if (!item || typeof item !== \"object\") return [];",
        "+      const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };",
        "+      if (record.type === \"text\") {",
        "+        const text = toText(record.text);",
        "+        return text ? [{ type: \"text\" as const, text, marks: readMarks(record.marks) }] : [];",
        "+      }",
        "+      if (record.payload && typeof record.payload === \"object\") {",
        "+        const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };",
        "+        if (payload.type === \"text\") {",
        "+          const text = toText(payload.text);",
        "+          return text ? [{ type: \"text\" as const, text, marks: readMarks(payload.marks) }] : [];",
        "+        }",
        "+      }",
        "+      const text = toText(item);",
        "+      return text ? [{ type: \"text\" as const, text, marks: [] }] : [];",
        "+    });",
        "+    return nodes;",
        "+  }",
        "+  const text = toText(input);",
        "+  return text ? [{ type: \"text\", text, marks: [] }] : [];",
        "+}",
        "+",
        "+function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBlock {",
        "+  const blockType = String(block.blockType ?? block.type ?? \"paragraph\").trim().toLowerCase();",
        "+  const blockId = normalizeBlockId(block.blockId ?? block.id, `block-${index + 1}`);",
        "+  const props = block.props ?? {};",
        "+  const contentNodes = normalizeEditorContentNodes(block.contentNodes ?? block.content ?? props.content ?? props.text ?? props.title);",
        "+",
        "+  switch (blockType) {",
        "+    case \"heading\":",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"heading\",",
        "+        props: { headingLevel: clampHeadingLevel(props.level ?? props.headingLevel) },",
        "+        contentNodes,",
        "+        childBlockIds: [],",
        "+      };",
        "+    case \"bullet_list_item\":",
        "+    case \"bullet_list\":",
        "+    case \"bullet-list\":",
        "+      return { blockId, blockType: \"bullet_list_item\", props: {}, contentNodes, childBlockIds: [] };",
        "+    case \"numbered_list_item\":",
        "+    case \"ordered_list\":",
        "+    case \"ordered-list\":",
        "+      return { blockId, blockType: \"numbered_list_item\", props: {}, contentNodes, childBlockIds: [] };",
        "+    case \"todo\":",
        "+    case \"task\":",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"todo\",",
        "+        props: { checked: Boolean(props.checked) },",
        "+        contentNodes,",
        "+        childBlockIds: [],",
        "+      };",
        "+    case \"quote\":",
        "+    case \"blockquote\":",
        "+      return { blockId, blockType: \"quote\", props: {}, contentNodes, childBlockIds: [] };",
        "+    case \"code\":",
        "+    case \"code_block\":",
        "+    case \"code-block\":",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"code_block\",",
        "+        props: { language: typeof props.language === \"string\" ? props.language : null },",
        "+        contentNodes,",
        "+        childBlockIds: [],",
        "+      };",
        "+    case \"paragraph\":",
        "+    case \"text\":",
        "+    default:",
        "+      return { blockId, blockType: \"paragraph\", props: {}, contentNodes, childBlockIds: [] };",
        "+  }",
        "+}",
        "+",
        "+function normalizeInputToEditorDocument(value: Json): EditorBlockDocument {",
        "+  if (value && typeof value === \"object\" && !Array.isArray(value)) {",
        "+    const maybe = value as Partial<EditorBlockDocument>;",
        "+    if (typeof maybe.documentId === \"string\" && Array.isArray(maybe.blocks)) {",
        "+      const normalizedBlocks = maybe.blocks.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index));",
        "+      const rootBlockIds = Array.isArray(maybe.rootBlockIds) && maybe.rootBlockIds.length > 0",
        "+        ? maybe.rootBlockIds.map((id, index) => normalizeBlockId(id, normalizedBlocks[index]?.blockId ?? `block-${index + 1}`))",
        "+        : normalizedBlocks.map((block) => block.blockId);",
        "+      return {",
        "+        documentId: maybe.documentId,",
        "+        rootBlockIds,",
        "+        blocks: normalizedBlocks,",
        "+      };",
        "+    }",
        "+    if (Array.isArray((value as { blocks?: unknown }).blocks)) {",
        "+      const blocks = ((value as { blocks: unknown[] }).blocks ?? []).map((block, index) =>",
        "+        normalizeLegacyBlock(block as LegacyBlockLike, index),",
        "+      );",
        "+      return {",
        "+        documentId: typeof (value as { documentId?: unknown }).documentId === \"string\"",
        "+          ? String((value as { documentId?: unknown }).documentId)",
        "+          : \"document\",",
        "+        rootBlockIds: blocks.map((block) => block.blockId),",
        "+        blocks,",
        "+      };",
        "+    }",
        "+  }",
        "+",
        "+  const blocks = Array.isArray(value)",
        "+    ? value.map((block, index) => normalizeLegacyBlock(block as LegacyBlockLike, index))",
        "+    : [];",
        "+",
        "+  return {",
        "+    documentId: \"document\",",
        "+    rootBlockIds: blocks.map((block) => block.blockId),",
        "+    blocks,",
        "+  };",
        "+}",
        "+",
        "+function textNodesToInline(contentNodes: EditorContentNode[] | undefined): TiptapNode[] {",
        "+  return (contentNodes ?? []).flatMap((node) => {",
        "+    const text = toText(node.text);",
        "+    if (!text) return [];",
        "+    const marks = (node.marks ?? [])",
        "+      .map((mark) => ({ type: mark }))",
        "+      .filter((mark) => Boolean(mark.type));",
        "+    return [{ type: \"text\", text, marks: marks.length > 0 ? marks : undefined }];",
        "+  });",
        "+}",
        "+",
        "+function blockToTiptapNode(block: EditorBlock): TiptapNode {",
        "+  const commonAttrs: Record<string, unknown> = { [BLOCK_ID_ATTR]: block.blockId };",
        "+  switch (block.blockType) {",
        "+    case \"heading\":",
        "+      return {",
        "+        type: \"heading\",",
        "+        attrs: { ...commonAttrs, level: clampHeadingLevel(block.props?.headingLevel) },",
        "+        content: textNodesToInline(block.contentNodes),",
        "+      };",
        "+    case \"bullet_list_item\":",
        "+      return {",
        "+        type: \"bulletList\",",
        "+        attrs: commonAttrs,",
        "+        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],",
        "+      };",
        "+    case \"numbered_list_item\":",
        "+      return {",
        "+        type: \"orderedList\",",
        "+        attrs: commonAttrs,",
        "+        content: [{ type: \"listItem\", attrs: commonAttrs, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],",
        "+      };",
        "+    case \"todo\":",
        "+      return {",
        "+        type: \"taskList\",",
        "+        attrs: commonAttrs,",
        "+        content: [{ type: \"taskItem\", attrs: { ...commonAttrs, checked: Boolean(block.props?.checked) }, content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }] }],",
        "+      };",
        "+    case \"quote\":",
        "+      return {",
        "+        type: \"blockquote\",",
        "+        attrs: commonAttrs,",
        "+        content: [{ type: \"paragraph\", attrs: commonAttrs, content: textNodesToInline(block.contentNodes) }],",
        "+      };",
        "+    case \"code_block\":",
        "+      return {",
        "+        type: \"codeBlock\",",
        "+        attrs: { ...commonAttrs, language: block.props?.language ?? null },",
        "+        content: textNodesToInline(block.contentNodes),",
        "+      };",
        "+    case \"paragraph\":",
        "+    default:",
        "+      return {",
        "+        type: \"paragraph\",",
        "+        attrs: commonAttrs,",
        "+        content: textNodesToInline(block.contentNodes),",
        "+      };",
        "+  }",
        "+}",
        "+",
        "+function extractInlineContent(node: TiptapNode | undefined): EditorContentNode[] {",
        "+  if (!node) return [];",
        "+  if (Array.isArray(node.content)) {",
        "+    return node.content.flatMap((child) => {",
        "+      if (child.type === \"text\") {",
        "+        const text = toText(child.text);",
        "+        if (!text) return [];",
        "+        return [{ type: \"text\" as const, text, marks: readMarks(child.marks) }];",
        "+      }",
        "+      if (child.type === \"hardBreak\") {",
        "+        return [{ type: \"text\" as const, text: \"\\n\", marks: [] }];",
        "+      }",
        "+      return extractInlineContent(child);",
        "+    });",
        "+  }",
        "+  if (node.type === \"text\") {",
        "+    const text = toText(node.text);",
        "+    return text ? [{ type: \"text\", text, marks: readMarks(node.marks) }] : [];",
        "+  }",
        "+  return [];",
        "+}",
        "+",
        "+function firstChild(node: TiptapNode | undefined): TiptapNode | undefined {",
        "+  return Array.isArray(node?.content) ? node?.content?.[0] : undefined;",
        "+}",
        "+",
        "+function nodeBlockId(node: TiptapNode, index: number): string {",
        "+  return normalizeBlockId(node.attrs?.[BLOCK_ID_ATTR], `block-${index + 1}`);",
        "+}",
        "+",
        "+function tiptapNodeToBlock(node: TiptapNode, index: number): EditorBlock | null {",
        "+  const blockId = nodeBlockId(node, index);",
        "+  switch (node.type) {",
        "+    case \"paragraph\":",
        "+      return { blockId, blockType: \"paragraph\", props: {}, contentNodes: extractInlineContent(node), childBlockIds: [] };",
        "+    case \"heading\":",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"heading\",",
        "+        props: { headingLevel: clampHeadingLevel(node.attrs?.level) },",
        "+        contentNodes: extractInlineContent(node),",
        "+        childBlockIds: [],",
        "+      };",
        "+    case \"bulletList\": {",
        "+      const paragraph = firstChild(firstChild(node));",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"bullet_list_item\",",
        "+        props: {},",
        "+        contentNodes: extractInlineContent(paragraph),",
        "+        childBlockIds: [],",
        "+      };",
        "+    }",
        "+    case \"orderedList\": {",
        "+      const paragraph = firstChild(firstChild(node));",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"numbered_list_item\",",
        "+        props: {},",
        "+        contentNodes: extractInlineContent(paragraph),",
        "+        childBlockIds: [],",
        "+      };",
        "+    }",
        "+    case \"taskList\": {",
        "+      const taskItem = firstChild(node);",
        "+      const paragraph = firstChild(taskItem);",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"todo\",",
        "+        props: { checked: Boolean(taskItem?.attrs?.checked) },",
        "+        contentNodes: extractInlineContent(paragraph),",
        "+        childBlockIds: [],",
        "+      };",
        "+    }",
        "+    case \"blockquote\":",
        "+      return { blockId, blockType: \"quote\", props: {}, contentNodes: extractInlineContent(firstChild(node)), childBlockIds: [] };",
        "+    case \"codeBlock\":",
        "+      return {",
        "+        blockId,",
        "+        blockType: \"code_block\",",
        "+        props: { language: typeof node.attrs?.language === \"string\" ? String(node.attrs.language) : null },",
        "+        contentNodes: extractInlineContent(node),",
        "+        childBlockIds: [],",
        "+      };",
        "+    default:",
        "+      return null;",
        "+  }",
        "+}",
        "+",
        "+export function editorBlockDocumentFromContent(content: Json): EditorBlockDocument {",
        "+  return normalizeInputToEditorDocument(content);",
        "+}",
        "+",
        "+export function tiptapDocFromEditorBlockDocument(document: EditorBlockDocument): TiptapDoc {",
        "+  const blockById = new Map(document.blocks.map((block) => [block.blockId, block]));",
        "+  const nodes = document.rootBlockIds",
        "+    .map((blockId) => blockById.get(blockId))",
        "+    .filter((block): block is EditorBlock => Boolean(block))",
        "+    .map(blockToTiptapNode);",
        "+  return {",
        "+    type: \"doc\",",
        "+    content: nodes.length > 0 ? nodes : EMPTY_DOC.content,",
        "+  };",
        "+}",
        "+",
        "+export function editorBlockDocumentFromTiptapDoc(doc: unknown, documentId = \"document\"): EditorBlockDocument {",
        "+  if (!doc || typeof doc !== \"object\") {",
        "+    return { documentId, rootBlockIds: [], blocks: [] };",
        "+  }",
        "+  const root = doc as { type?: unknown; content?: unknown };",
        "+  if (root.type !== \"doc\" || !Array.isArray(root.content)) {",
        "+    return { documentId, rootBlockIds: [], blocks: [] };",
        "+  }",
        "+  const blocks = root.content.flatMap((node, index) => {",
        "+    if (!node || typeof node !== \"object\") return [];",
        "+    const block = tiptapNodeToBlock(node as TiptapNode, index);",
        "+    return block ? [block] : [];",
        "+  });",
        "+  return {",
        "+    documentId,",
        "+    rootBlockIds: blocks.map((block) => block.blockId),",
        "+    blocks,",
        "+  };",
        "+}",
        "+",
        "+export function tiptapDocFromBlocks(blocks: Json): TiptapDoc {",
        "+  return tiptapDocFromEditorBlockDocument(editorBlockDocumentFromContent(blocks));",
        "+}",
        "+",
        "+export function blocksFromTiptapDoc(doc: unknown): Json {",
        "+  return editorBlockDocumentFromTiptapDoc(doc).blocks.map((block) => ({",
        "+    id: block.blockId,",
        "+    type: block.blockType,",
        "+    props:",
        "+      block.blockType === \"heading\"",
        "+        ? { level: block.props?.headingLevel ?? 1 }",
        "+        : block.blockType === \"todo\"",
        "+          ? { checked: Boolean(block.props?.checked) }",
        "+          : block.blockType === \"code_block\"",
        "+            ? { language: block.props?.language ?? null }",
        "+            : undefined,",
        "+    content: (block.contentNodes ?? []).map((node) => node.text).join(\"\"),",
        "+  }));",
        "+}",
        "+",
        "+export const blocksToTiptapDoc = tiptapDocFromBlocks;",
        "+export const tiptapDocToBlocks = blocksFromTiptapDoc;"
      ]
    }
  ],
  "originalFile": "export type TiptapNode = {\n  type: string;\n  attrs?: Record<string, unknown>;\n  content?: TiptapNode[];\n  text?: string;\n  marks?: Array<{ type: string; attrs?: Record<string, unknown> }>;\n};\n\nexport type TiptapDoc = {\n  type: \"doc\";\n  content: TiptapNode[];\n};\n\ntype Json = unknown;\n\ntype BlockLike = {\n  id?: string;\n  type?: string;\n  content?: unknown;\n  props?: Record<string, unknown>;\n  children?: unknown;\n};\n\nconst emptyDoc: TiptapDoc = { type: \"doc\", content: [] };\n\nconst toText = (value: unknown): string => {\n  if (typeof value === \"string\") return value;\n  if (Array.isArray(value)) return value.map(toText).join(\"\");\n  if (value && typeof value === \"object\") {\n    const obj = value as { text?: unknown; content?: unknown; value?: unknown };\n    return `${toText(obj.text)}${toText(obj.value)}${toText(obj.content)}`;\n  }\n  return \"\";\n};\n\nconst toTiptapInline = (value: unknown): TiptapNode[] => {\n  const text = toText(value);\n  return text ? [{ type: \"text\", text }] : [];\n};\n\nconst toHeadingLevel = (value: unknown): number => {\n  const level = Number(value);\n  return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;\n};\n\nconst blockToNode = (block: BlockLike): TiptapNode | null => {\n  const type = String(block.type ?? \"\").toLowerCase();\n  const content = block.content ?? block.props?.content ?? block.props?.text ?? block.props?.title;\n  if (type === \"paragraph\" || type === \"text\") return { type: \"paragraph\", content: toTiptapInline(content) };\n  if (type === \"heading\") return { type: \"heading\", attrs: { level: toHeadingLevel(block.props?.level) }, content: toTiptapInline(content) };\n  if (type === \"bullet_list\" || type === \"bullet-list\") return { type: \"bulletList\", content: [] };\n  if (type === \"ordered_list\" || type === \"ordered-list\") return { type: \"orderedList\", content: [] };\n  if (type === \"todo\" || type === \"task\") return { type: \"taskItem\", attrs: { checked: Boolean(block.props?.checked) }, content: toTiptapInline(content) };\n  if (type === \"quote\" || type === \"blockquote\") return { type: \"blockquote\", content: [{ type: \"paragraph\", content: toTiptapInline(content) }] };\n  if (type === \"code\" || type === \"code_block\" || type === \"code-block\") return { type: \"codeBlock\", content: toTiptapInline(content) };\n  return null;\n};\n\nexport function blocksToTiptapDoc(blocks: Json): TiptapDoc {\n  if (!Array.isArray(blocks) || blocks.length === 0) return { ...emptyDoc };\n  const content = blocks.flatMap((item) => {\n    if (!item || typeof item !== \"object\") return [];\n    const node = blockToNode(item as BlockLike);\n    return node ? [node] : [];\n  });\n  return { type: \"doc\", content: content.length > 0 ? content : [{ type: \"paragraph\" }] };\n}\n\nconst nodeToBlock = (node: TiptapNode, index: number): Record<string, unknown> | null => {\n  if (node.type === \"paragraph\") return { id: `block-${index}`, type: \"paragraph\", content: toText(node.content) };\n  if (node.type === \"heading\") return { id: `block-${index}`, type: \"heading\", props: { level: Number(node.attrs?.level ?? 1) }, content: toText(node.content) };\n  if (node.type === \"bulletList\") return { id: `block-${index}`, type: \"bullet_list\" };\n  if (node.type === \"orderedList\") return { id: `block-${index}`, type: \"ordered_list\" };\n  if (node.type === \"taskItem\") return { id: `block-${index}`, type: \"todo\", props: { checked: Boolean(node.attrs?.checked) }, content: toText(node.content) };\n  if (node.type === \"blockquote\") return { id: `block-${index}`, type: \"quote\", content: toText(node.content) };\n  if (node.type === \"codeBlock\") return { id: `block-${index}`, type: \"code_block\", content: toText(node.content) };\n  return null;\n};\n\nexport function tiptapDocToBlocks(doc: unknown): Json {\n  if (!doc || typeof doc !== \"object\") return [];\n  const root = doc as { type?: string; content?: TiptapNode[] };\n  if (root.type !== \"doc\" || !Array.isArray(root.content) || root.content.length === 0) return [];\n  return root.content.flatMap((node, index) => {\n    if (!node || typeof node !== \"object\") return [];\n    const block = nodeToBlock(node, index);\n    return block ? [block] : [];\n  }) as Json;\n}\n\nexport const tiptapDocFromBlocks = blocksToTiptapDoc;\nexport const blocksFromTiptapDoc = tiptapDocToBlocks;\n",
  "gitDiff": null
}


Input: {"content":"import {\n  blocksFromTiptapDoc,\n  editorBlockDocumentFromContent,\n  editorBlockDocumentFromTiptapDoc,\n  tiptapDocFromBlocks,\n  tiptapDocFromEditorBlockDocument,\n} from \"@/lib/documents/tiptap-content-converter\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"tiptap-content-converter\", () => {\n  it(\"keeps empty content editable\", () => {\n    expect(tiptapDocFromBlocks([])).toEqual({\n      type: \"doc\",\n      content: [{ type: \"paragraph\", attrs: {}, content: [] }],\n    });\n    expect(blocksFromTiptapDoc({ type: \"doc\", content: [] })).toEqual([]);\n  });\n\n  it(\"preserves stable block ids through paragraph round-trip\", () => {\n    const blocks = [{ id: \"p1\", type: \"paragraph\", content: \"你好，世界\" }];\n    expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([\n      { id: \"p1\", type: \"paragraph\", props: undefined, content: \"你好，世界\" },\n    ]);\n  });\n\n  it(\"round-trips p0.5 block families through editor block document\", () => {\n    const document = editorBlockDocumentFromContent([\n      { id: \"p1\", type: \"paragraph\", content: \"段落\" },\n      { id: \"h1\", type: \"heading\", props: { level: 2 }, content: \"标题\" },\n      { id: \"b1\", type: \"bullet_list_item\", content: \"项目\" },\n      { id: \"n1\", type: \"numbered_list_item\", content: \"序号\" },\n      { id: \"t1\", type: \"todo\", props: { checked: true }, content: \"待办\" },\n      { id: \"q1\", type: \"quote\", content: \"引用\" },\n      { id: \"c1\", type: \"code_block\", props: { language: \"ts\" }, content: \"const x = 1\" },\n    ] as never);\n\n    const tiptapDoc = tiptapDocFromEditorBlockDocument(document);\n    const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, \"doc-1\");\n\n    expect(roundTrip.rootBlockIds).toEqual([\"p1\", \"h1\", \"b1\", \"n1\", \"t1\", \"q1\", \"c1\"]);\n    expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([\n      [\"p1\", \"paragraph\"],\n      [\"h1\", \"heading\"],\n      [\"b1\", \"bullet_list_item\"],\n      [\"n1\", \"numbered_list_item\"],\n      [\"t1\", \"todo\"],\n      [\"q1\", \"quote\"],\n      [\"c1\", \"code_block\"],\n    ]);\n    expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);\n    expect(roundTrip.blocks[4].props?.checked).toBe(true);\n    expect(roundTrip.blocks[6].props?.language).toBe(\"ts\");\n  });\n});\n","path":"/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts"}


Output:
{
  "type": "update",
  "filePath": "/mnt/Data1T/mnote/wolai-frontend/src/lib/documents/tiptap-content-converter.test.ts",
  "content": "import {\n  blocksFromTiptapDoc,\n  editorBlockDocumentFromContent,\n  editorBlockDocumentFromTiptapDoc,\n  tiptapDocFromBlocks,\n  tiptapDocFromEditorBlockDocument,\n} from \"@/lib/documents/tiptap-content-converter\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"tiptap-content-converter\", () => {\n  it(\"keeps empty content editable\", () => {\n    expect(tiptapDocFromBlocks([])).toEqual({\n      type: \"doc\",\n      content: [{ type: \"paragraph\", attrs: {}, content: [] }],\n    });\n    expect(blocksFromTiptapDoc({ type: \"doc\", content: [] })).toEqual([]);\n  });\n\n  it(\"preserves stable block ids through paragraph round-trip\", () => {\n    const blocks = [{ id: \"p1\", type: \"paragraph\", content: \"你好，世界\" }];\n    expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([\n      { id: \"p1\", type: \"paragraph\", props: undefined, content: \"你好，世界\" },\n    ]);\n  });\n\n  it(\"round-trips p0.5 block families through editor block document\", () => {\n    const document = editorBlockDocumentFromContent([\n      { id: \"p1\", type: \"paragraph\", content: \"段落\" },\n      { id: \"h1\", type: \"heading\", props: { level: 2 }, content: \"标题\" },\n      { id: \"b1\", type: \"bullet_list_item\", content: \"项目\" },\n      { id: \"n1\", type: \"numbered_list_item\", content: \"序号\" },\n      { id: \"t1\", type: \"todo\", props: { checked: true }, content: \"待办\" },\n      { id: \"q1\", type: \"quote\", content: \"引用\" },\n      { id: \"c1\", type: \"code_block\", props: { language: \"ts\" }, content: \"const x = 1\" },\n    ] as never);\n\n    const tiptapDoc = tiptapDocFromEditorBlockDocument(document);\n    const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, \"doc-1\");\n\n    expect(roundTrip.rootBlockIds).toEqual([\"p1\", \"h1\", \"b1\", \"n1\", \"t1\", \"q1\", \"c1\"]);\n    expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([\n      [\"p1\", \"paragraph\"],\n      [\"h1\", \"heading\"],\n      [\"b1\", \"bullet_list_item\"],\n      [\"n1\", \"numbered_list_item\"],\n      [\"t1\", \"todo\"],\n      [\"q1\", \"quote\"],\n      [\"c1\", \"code_block\"],\n    ]);\n    expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);\n    expect(roundTrip.blocks[4].props?.checked).toBe(true);\n    expect(roundTrip.blocks[6].props?.language).toBe(\"ts\");\n  });\n});\n",
  "structuredPatch": [
    {
      "oldStart": 1,
      "oldLines": 16,
      "newStart": 1,
      "newLines": 54,
      "lines": [
        "-import { blocksFromTiptapDoc, tiptapDocFromBlocks } from \"@/lib/documents/tiptap-content-converter\";",
        "-import { describe, expect, it } from \"vitest\";",
        "-",
        "-describe(\"tiptap-content-converter\", () => {",
        "-  it(\"keeps empty content empty\", () => {",
        "-    expect(tiptapDocFromBlocks([])).toEqual({ type: \"doc\", content: [] });",
        "-    expect(blocksFromTiptapDoc({ type: \"doc\", content: [] })).toEqual([]);",
        "-  });",
        "-",
        "-  it(\"round-trips basic paragraph content\", () => {",
        "-    const blocks = [{ id: \"1\", type: \"paragraph\", content: \"你好，世界\" }];",
        "-    expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([",
        "-      { id: \"block-0\", type: \"paragraph\", content: \"你好，世界\" },",
        "-    ]);",
        "-  });",
        "-});",
        "+import {",
        "+  blocksFromTiptapDoc,",
        "+  editorBlockDocumentFromContent,",
        "+  editorBlockDocumentFromTiptapDoc,",
        "+  tiptapDocFromBlocks,",
        "+  tiptapDocFromEditorBlockDocument,",
        "+} from \"@/lib/documents/tiptap-content-converter\";",
        "+import { describe, expect, it } from \"vitest\";",
        "+",
        "+describe(\"tiptap-content-converter\", () => {",
        "+  it(\"keeps empty content editable\", () => {",
        "+    expect(tiptapDocFromBlocks([])).toEqual({",
        "+      type: \"doc\",",
        "+      content: [{ type: \"paragraph\", attrs: {}, content: [] }],",
        "+    });",
        "+    expect(blocksFromTiptapDoc({ type: \"doc\", content: [] })).toEqual([]);",
        "+  });",
        "+",
        "+  it(\"preserves stable block ids through paragraph round-trip\", () => {",
        "+    const blocks = [{ id: \"p1\", type: \"paragraph\", content: \"你好，世界\" }];",
        "+    expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([",
        "+      { id: \"p1\", type: \"paragraph\", props: undefined, content: \"你好，世界\" },",
        "+    ]);",
        "+  });",
        "+",
        "+  it(\"round-trips p0.5 block families through editor block document\", () => {",
        "+    const document = editorBlockDocumentFromContent([",
        "+      { id: \"p1\", type: \"paragraph\", content: \"段落\" },",
        "+      { id: \"h1\", type: \"heading\", props: { level: 2 }, content: \"标题\" },",
        "+      { id: \"b1\", type: \"bullet_list_item\", content: \"项目\" },",
        "+      { id: \"n1\", type: \"numbered_list_item\", content: \"序号\" },",
        "+      { id: \"t1\", type: \"todo\", props: { checked: true }, content: \"待办\" },",
        "+      { id: \"q1\", type: \"quote\", content: \"引用\" },",
        "+      { id: \"c1\", type: \"code_block\", props: { language: \"ts\" }, content: \"const x = 1\" },",
        "+    ] as never);",
        "+",
        "+    const tiptapDoc = tiptapDocFromEditorBlockDocument(document);",
        "+    const roundTrip = editorBlockDocumentFromTiptapDoc(tiptapDoc, \"doc-1\");",
        "+",
        "+    expect(roundTrip.rootBlockIds).toEqual([\"p1\", \"h1\", \"b1\", \"n1\", \"t1\", \"q1\", \"c1\"]);",
        "+    expect(roundTrip.blocks.map((block) => [block.blockId, block.blockType])).toEqual([",
        "+      [\"p1\", \"paragraph\"],",
        "+      [\"h1\", \"heading\"],",
        "+      [\"b1\", \"bullet_list_item\"],",
        "+      [\"n1\", \"numbered_list_item\"],",
        "+      [\"t1\", \"todo\"],",
        "+      [\"q1\", \"quote\"],",
        "+      [\"c1\", \"code_block\"],",
        "+    ]);",
        "+    expect(roundTrip.blocks[1].props?.headingLevel).toBe(2);",
        "+    expect(roundTrip.blocks[4].props?.checked).toBe(true);",
        "+    expect(roundTrip.blocks[6].props?.language).toBe(\"ts\");",
        "+  });",
        "+});"
      ]
    }
  ],
  "originalFile": "import { blocksFromTiptapDoc, tiptapDocFromBlocks } from \"@/lib/documents/tiptap-content-converter\";\nimport { describe, expect, it } from \"vitest\";\n\ndescribe(\"tiptap-content-converter\", () => {\n  it(\"keeps empty content empty\", () => {\n    expect(tiptapDocFromBlocks([])).toEqual({ type: \"doc\", content: [] });\n    expect(blocksFromTiptapDoc({ type: \"doc\", content: [] })).toEqual([]);\n  });\n\n  it(\"round-trips basic paragraph content\", () => {\n    const blocks = [{ id: \"1\", type: \"paragraph\", content: \"你好，世界\" }];\n    expect(blocksFromTiptapDoc(tiptapDocFromBlocks(blocks as never))).toEqual([\n      { id: \"block-0\", type: \"paragraph\", content: \"你好，世界\" },\n    ]);\n  });\n});\n",
  "gitDiff": null
}


Input: {"content":"use crate::editor::model::{\n    BlockProps, ContentNode, ContentNodePayload, EditorBlock, EditorBlockDocument, EditorBlockType,\n    TextMark,\n};\nuse serde::{Deserialize, Serialize};\nuse std::collections::BTreeMap;\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum TiptapBlockType {\n    Paragraph,\n    Heading,\n    BulletList,\n    OrderedList,\n    TaskList,\n    ListItem,\n    TaskItem,\n    Blockquote,\n    CodeBlock,\n    Text,\n    HardBreak,\n    Doc,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapHeadingAttrs {\n    pub level: u8,\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapTaskItemAttrs {\n    #[serde(default)]\n    pub checked: bool,\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapParagraphAttrs {\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapListAttrs {\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapListItemAttrs {\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapBlockquoteAttrs {\n    #[serde(default)]\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapCodeBlockAttrs {\n    pub language: Option<String>,\n    pub block_id: Option<String>,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\npub struct TiptapMark {\n    pub r#type: String,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n#[serde(tag = \"type\", rename_all = \"camelCase\")]\npub enum TiptapNode {\n    Doc { content: Vec<TiptapNode> },\n    Paragraph { #[serde(default)] attrs: TiptapParagraphAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    Heading { attrs: TiptapHeadingAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    BulletList { #[serde(default)] attrs: TiptapListAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    OrderedList { #[serde(default)] attrs: TiptapListAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    TaskList { #[serde(default)] attrs: TiptapListAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    ListItem { #[serde(default)] attrs: TiptapListItemAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    TaskItem { attrs: TiptapTaskItemAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    Blockquote { #[serde(default)] attrs: TiptapBlockquoteAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    CodeBlock { attrs: TiptapCodeBlockAttrs, #[serde(default)] content: Vec<TiptapNode> },\n    Text { text: String, #[serde(default)] marks: Vec<TiptapMark> },\n    HardBreak,\n}\n\n#[derive(Debug, Clone, PartialEq, Eq)]\npub enum EditorBlockDocumentTiptapError {\n    UnsupportedNode(&'static str),\n    InvalidDocument(&'static str),\n}\n\npub struct EditorBlockDocumentTiptapBridge;\n\nimpl EditorBlockDocumentTiptapBridge {\n    pub fn to_tiptap_doc(document: &EditorBlockDocument) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {\n        let mut content = Vec::new();\n        for block_id in &document.root_block_ids {\n            let block = document\n                .blocks\n                .iter()\n                .find(|block| &block.block_id == block_id)\n                .ok_or(EditorBlockDocumentTiptapError::InvalidDocument(\"根块不存在\"))?;\n            content.push(block_to_tiptap_node(block)?);\n        }\n        Ok(TiptapNode::Doc { content })\n    }\n\n    pub fn from_tiptap_doc(\n        document_id: impl Into<String>,\n        doc: &TiptapNode,\n    ) -> Result<EditorBlockDocument, EditorBlockDocumentTiptapError> {\n        match doc {\n            TiptapNode::Doc { content } => {\n                let mut blocks = Vec::new();\n                let mut root_block_ids = Vec::new();\n                for (index, node) in content.iter().enumerate() {\n                    let block = node_to_block(node, index)?;\n                    root_block_ids.push(block.block_id.clone());\n                    blocks.push(block);\n                }\n                Ok(EditorBlockDocument {\n                    document_id: document_id.into(),\n                    root_block_ids,\n                    blocks,\n                })\n            }\n            _ => Err(EditorBlockDocumentTiptapError::InvalidDocument(\"顶层必须是 doc\")),\n        }\n    }\n}\n\nfn block_to_tiptap_node(block: &EditorBlock) -> Result<TiptapNode, EditorBlockDocumentTiptapError> {\n    let content = text_nodes_to_tiptap(&block.content_nodes)?;\n    match block.block_type {\n        EditorBlockType::Paragraph => Ok(TiptapNode::Paragraph {\n            attrs: TiptapParagraphAttrs {\n                block_id: Some(block.block_id.clone()),\n            },\n            content,\n        }),\n        EditorBlockType::Heading => Ok(TiptapNode::Heading {\n            attrs: TiptapHeadingAttrs {\n                level: block.props.heading_level.unwrap_or(1),\n                block_id: Some(block.block_id.clone()),\n            },\n            content,\n        }),\n        EditorBlockType::BulletListItem => Ok(TiptapNode::BulletList {\n            attrs: TiptapListAttrs {\n                block_id: Some(block.block_id.clone()),\n            },\n            content: vec![TiptapNode::ListItem {\n                attrs: TiptapListItemAttrs {\n                    block_id: Some(block.block_id.clone()),\n                },\n                content: vec![TiptapNode::Paragraph {\n                    attrs: TiptapParagraphAttrs {\n                        block_id: Some(block.block_id.clone()),\n                    },\n                    content,\n                }],\n            }],\n        }),\n        EditorBlockType::NumberedListItem => Ok(TiptapNode::OrderedList {\n            attrs: TiptapListAttrs {\n                block_id: Some(block.block_id.clone()),\n            },\n            content: vec![TiptapNode::ListItem {\n                attrs: TiptapListItemAttrs {\n                    block_id: Some(block.block_id.clone()),\n                },\n                content: vec![TiptapNode::Paragraph {\n                    attrs: TiptapParagraphAttrs {\n                        block_id: Some(block.block_id.clone()),\n                    },\n                    content,\n                }],\n            }],\n        }),\n        EditorBlockType::Todo => Ok(TiptapNode::TaskList {\n            attrs: TiptapListAttrs {\n                block_id: Some(block.block_id.clone()),\n            },\n            content: vec![TiptapNode::TaskItem {\n                attrs: TiptapTaskItemAttrs {\n                    checked: block.props.checked.unwrap_or(false),\n                    block_id: Some(block.block_id.clone()),\n                },\n                content: vec![TiptapNode::Paragraph {\n                    attrs: TiptapParagraphAttrs {\n                        block_id: Some(block.block_id.clone()),\n                    },\n                    content,\n                }],\n            }],\n        }),\n        EditorBlockType::Quote => Ok(TiptapNode::Blockquote {\n            attrs: TiptapBlockquoteAttrs {\n                block_id: Some(block.block_id.clone()),\n            },\n            content: vec![TiptapNode::Paragraph {\n                attrs: TiptapParagraphAttrs {\n                    block_id: Some(block.block_id.clone()),\n                },\n                content,\n            }],\n        }),\n        EditorBlockType::CodeBlock => Ok(TiptapNode::CodeBlock {\n            attrs: TiptapCodeBlockAttrs {\n                language: block.props.language.clone(),\n                block_id: Some(block.block_id.clone()),\n            },\n            content,\n        }),\n        _ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(\n            \"当前块型暂不支持导出到 Tiptap\",\n        )),\n    }\n}\n\nfn node_to_block(node: &TiptapNode, index: usize) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {\n    let fallback_block_id = format!(\"block_{}\", index + 1);\n    match node {\n        TiptapNode::Paragraph { attrs, content } => Ok(EditorBlock {\n            block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),\n            block_type: EditorBlockType::Paragraph,\n            props: BlockProps::default(),\n            content_nodes: text_nodes_from_tiptap(content)?,\n            child_block_ids: vec![],\n        }),\n        TiptapNode::Heading { attrs, content } => Ok(EditorBlock {\n            block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),\n            block_type: EditorBlockType::Heading,\n            props: BlockProps {\n                heading_level: Some(attrs.level),\n                ..BlockProps::default()\n            },\n            content_nodes: text_nodes_from_tiptap(content)?,\n            child_block_ids: vec![],\n        }),\n        TiptapNode::BulletList { attrs, content } => list_node_to_block(\n            attrs.block_id.clone().unwrap_or(fallback_block_id),\n            EditorBlockType::BulletListItem,\n            content,\n        ),\n        TiptapNode::OrderedList { attrs, content } => list_node_to_block(\n            attrs.block_id.clone().unwrap_or(fallback_block_id),\n            EditorBlockType::NumberedListItem,\n            content,\n        ),\n        TiptapNode::TaskList { attrs, content } => list_task_node_to_block(\n            attrs.block_id.clone().unwrap_or(fallback_block_id),\n            content,\n        ),\n        TiptapNode::Blockquote { attrs, content } => Ok(EditorBlock {\n            block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),\n            block_type: EditorBlockType::Quote,\n            props: BlockProps::default(),\n            content_nodes: text_nodes_from_tiptap(extract_block_container_content(content)?)?,\n            child_block_ids: vec![],\n        }),\n        TiptapNode::CodeBlock { attrs, content } => Ok(EditorBlock {\n            block_id: attrs.block_id.clone().unwrap_or(fallback_block_id),\n            block_type: EditorBlockType::CodeBlock,\n            props: BlockProps {\n                language: attrs.language.clone(),\n                ..BlockProps::default()\n            },\n            content_nodes: text_nodes_from_tiptap(content)?,\n            child_block_ids: vec![],\n        }),\n        _ => Err(EditorBlockDocumentTiptapError::UnsupportedNode(\n            \"当前节点暂不支持导入为 EditorBlockDocument\",\n        )),\n    }\n}\n\nfn list_node_to_block(\n    block_id: String,\n    block_type: EditorBlockType,\n    content: &[TiptapNode],\n) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {\n    Ok(EditorBlock {\n        block_id,\n        block_type,\n        props: BlockProps::default(),\n        content_nodes: text_nodes_from_tiptap(extract_list_item_inline_content(content)?)?,\n        child_block_ids: vec![],\n    })\n}\n\nfn list_task_node_to_block(\n    block_id: String,\n    content: &[TiptapNode],\n) -> Result<EditorBlock, EditorBlockDocumentTiptapError> {\n    let Some(first) = content.first() else {\n        return Err(EditorBlockDocumentTiptapError::InvalidDocument(\n            \"任务列表节点缺少 task_item\",\n        ));\n    };\n    let (checked, item_content) = match first {\n        TiptapNode::TaskItem { attrs, content } => (Some(attrs.checked), extract_block_container_content(content)?),\n        _ => {\n            return Err(EditorBlockDocumentTiptapError::InvalidDocument(\n                \"任务列表节点首子节点类型不正确\",\n            ))\n        }\n    };\n    Ok(EditorBlock {\n        block_id,\n        block_type: EditorBlockType::Todo,\n        props: BlockProps {\n            checked,\n            ..BlockProps::default()\n        },\n        content_nodes: text_nodes_from_tiptap(item_content)?,\n        child_block_ids: vec![],\n    })\n}\n\nfn extract_list_item_inline_content(content: &[TiptapNode]) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {\n    let Some(first) = content.first() else {\n        return Err(EditorBlockDocumentTiptapError::InvalidDocument(\n            \"列表节点缺少 list_item/task_item\",\n        ));\n    };\n    match first {\n        TiptapNode::ListItem { content, .. } | TiptapNode::TaskItem { content, .. } => {\n            extract_block_container_content(content)\n        }\n        _ => Err(EditorBlockDocumentTiptapError::InvalidDocument(\n            \"列表节点首子节点类型不正确\",\n        )),\n    }\n}\n\nfn extract_block_container_content(content: &[TiptapNode]) -> Result<&[TiptapNode], EditorBlockDocumentTiptapError> {\n    let Some(first) = content.first() else {\n        return Ok(content);\n    };\n    match first {\n        TiptapNode::Paragraph { content, .. } => Ok(content),\n        _ => Ok(content),\n    }\n}\n\nfn text_nodes_to_tiptap(nodes: &[ContentNode]) -> Result<Vec<TiptapNode>, EditorBlockDocumentTiptapError> {\n    let mut out = Vec::new();\n    for node in nodes {\n        match &node.payload {\n            ContentNodePayload::Text { text, marks } => out.push(TiptapNode::Text {\n                text: text.clone(),\n                marks: marks.iter().map(text_mark_to_tiptap).collect(),\n            }),\n            ContentNodePayload::HardBreak => out.push(TiptapNode::HardBreak),\n            ContentNodePayload::ReferenceToken { .. } => {\n                return Err(EditorBlockDocumentTiptapError::UnsupportedNode(\n                    \"引用 token 暂不映射到 Tiptap 文本合同\",\n                ))\n            }\n        }\n    }\n    Ok(out)\n}\n\nfn text_nodes_from_tiptap(nodes: &[TiptapNode]) -> Result<Vec<ContentNode>, EditorBlockDocumentTiptapError> {\n    let mut out = Vec::new();\n    for node in nodes {\n        match node {\n            TiptapNode::Text { text, marks } => out.push(ContentNode {\n                payload: ContentNodePayload::Text {\n                    text: text.clone(),\n                    marks: marks.iter().filter_map(tiptap_mark_to_text_mark).collect(),\n                },\n                attrs: BTreeMap::new(),\n            }),\n            TiptapNode::HardBreak => out.push(ContentNode {\n                payload: ContentNodePayload::HardBreak,\n                attrs: BTreeMap::new(),\n            }),\n            _ => {\n                return Err(EditorBlockDocumentTiptapError::UnsupportedNode(\n                    \"当前只支持文本节点内容\",\n                ))\n            }\n        }\n    }\n    Ok(out)\n}\n\nfn text_mark_to_tiptap(mark: &TextMark) -> TiptapMark {\n    let r#type = match mark {\n        TextMark::Bold => \"bold\",\n        TextMark::Italic => \"italic\",\n        TextMark::Underline => \"underline\",\n        TextMark::Strike => \"strike\",\n        TextMark::Code => \"code\",\n    };\n    TiptapMark {\n        r#type: r#type.to_string(),\n    }\n}\n\nfn tiptap_mark_to_text_mark(mark: &TiptapMark) -> Option<TextMark> {\n    match mark.r#type.as_str() {\n        \"bold\" => Some(TextMark::Bold),\n        \"italic\" => Some(TextMark::Italic),\n        \"underline\" => Some(TextMark::Underline),\n        \"strike\" => Some(TextMark::Strike),\n        \"code\" => Some(TextMark::Code),\n        _ => None,\n    }\n}\n","path":"/mnt/Data1T/mnote/rust/crates/core-protocol/src/editor/tiptap.rs"}