package richtext import ( "encoding/json" "fmt" "strings" "time" ) const schemaVersion = 1 // UltiDoc is the canonical on-disk format for TipTap documents. type UltiDoc struct { SchemaVersion int `json:"schemaVersion"` Editor string `json:"editor"` Source *UltiDocSource `json:"source,omitempty"` Content json.RawMessage `json:"content"` PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"` YjsState string `json:"yjsState,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` } type UltiDocSource struct { Path string `json:"path"` Mime string `json:"mime,omitempty"` ImportedAt string `json:"importedAt,omitempty"` } type UltiDocPageMargins struct { Top float64 `json:"top"` Right float64 `json:"right"` Bottom float64 `json:"bottom"` Left float64 `json:"left"` } type UltiDocPageFillImage struct { Src string `json:"src"` Mode string `json:"mode"` } type UltiDocPageWatermark struct { Kind string `json:"kind"` Text string `json:"text,omitempty"` Src string `json:"src,omitempty"` Color string `json:"color,omitempty"` Opacity float64 `json:"opacity,omitempty"` RotationDeg float64 `json:"rotationDeg,omitempty"` } type UltiDocPageBackground struct { FillImage *UltiDocPageFillImage `json:"fillImage,omitempty"` Watermark *UltiDocPageWatermark `json:"watermark,omitempty"` GradientCss string `json:"gradientCss,omitempty"` } type UltiDocPageSetup struct { WidthMm float64 `json:"widthMm"` HeightMm float64 `json:"heightMm"` MarginsMm UltiDocPageMargins `json:"marginsMm"` FormatID string `json:"formatId,omitempty"` Orientation string `json:"orientation,omitempty"` PageColor string `json:"pageColor,omitempty"` PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"` Borders *UltiDocPageBorders `json:"borders,omitempty"` } type UltiDocPageBorderSide struct { Style string `json:"style"` WidthPx float64 `json:"widthPx"` Color string `json:"color"` } type UltiDocPageBorders struct { Top UltiDocPageBorderSide `json:"top"` Right UltiDocPageBorderSide `json:"right"` Bottom UltiDocPageBorderSide `json:"bottom"` Left UltiDocPageBorderSide `json:"left"` OffsetFrom string `json:"offsetFrom,omitempty"` } func emptyDocContent() json.RawMessage { return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`) } func isEmptyDocContent(content json.RawMessage) bool { return !docContentHasText(content) } func docContentHasText(content json.RawMessage) bool { if len(content) == 0 { return false } var root any if err := json.Unmarshal(content, &root); err != nil { return false } return tipTapNodeHasText(root) } func tipTapNodeHasText(v any) bool { switch node := v.(type) { case map[string]any: if text, ok := node["text"].(string); ok && strings.TrimSpace(text) != "" { return true } if children, ok := node["content"]; ok { return tipTapNodeHasText(children) } case []any: for _, child := range node { if tipTapNodeHasText(child) { return true } } } return false } func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc { if len(content) == 0 { content = emptyDocContent() } return UltiDoc{ SchemaVersion: schemaVersion, Editor: "tiptap", Source: source, Content: content, UpdatedAt: time.Now().UTC().Format(time.RFC3339), } } func preserveUltiDocMetadata(dst *UltiDoc, existing UltiDoc) { if dst.Source == nil && existing.Source != nil { dst.Source = existing.Source } if dst.PageSetup == nil && existing.PageSetup != nil { dst.PageSetup = existing.PageSetup } if dst.PageSetup != nil && existing.PageSetup != nil { if dst.PageSetup.PageBackground == nil && existing.PageSetup.PageBackground != nil { dst.PageSetup.PageBackground = existing.PageSetup.PageBackground } } if len(dst.Content) == 0 || isEmptyDocContent(dst.Content) { if len(existing.Content) > 0 && !isEmptyDocContent(existing.Content) { dst.Content = existing.Content } } if dst.YjsState == "" && existing.YjsState != "" { dst.YjsState = existing.YjsState } } type ultiDocPatch struct { SchemaVersion int `json:"schemaVersion"` Editor string `json:"editor"` Content json.RawMessage `json:"content"` Document json.RawMessage `json:"document"` PageSetup *UltiDocPageSetup `json:"pageSetup"` YjsState string `json:"yjsState"` } // ApplyUltiDocPatch merges a partial JSON payload into an existing UltiDoc. func ApplyUltiDocPatch(existing UltiDoc, raw json.RawMessage) (UltiDoc, error) { var patch ultiDocPatch if err := json.Unmarshal(raw, &patch); err != nil { return UltiDoc{}, err } doc := existing if doc.SchemaVersion == 0 { doc = NewUltiDoc(nil, nil) if len(existing.Content) > 0 { doc.Content = existing.Content } } content := patch.Content if len(content) == 0 && len(patch.Document) > 0 { content = patch.Document } if len(content) > 0 { doc.Content = content } if patch.PageSetup != nil { doc.PageSetup = patch.PageSetup } if patch.YjsState != "" { doc.YjsState = patch.YjsState } if patch.SchemaVersion > 0 { doc.SchemaVersion = patch.SchemaVersion } if patch.Editor != "" { doc.Editor = patch.Editor } preserveUltiDocMetadata(&doc, existing) return doc, nil } func ParseUltiDoc(raw []byte) (UltiDoc, error) { var doc UltiDoc if err := json.Unmarshal(raw, &doc); err != nil { return UltiDoc{}, err } if doc.SchemaVersion == 0 { doc.SchemaVersion = schemaVersion } if doc.Editor == "" { doc.Editor = "tiptap" } if len(doc.Content) == 0 { doc.Content = emptyDocContent() } return doc, nil } func (d UltiDoc) Marshal() ([]byte, error) { d.UpdatedAt = time.Now().UTC().Format(time.RFC3339) if d.SchemaVersion == 0 { d.SchemaVersion = schemaVersion } if d.Editor == "" { d.Editor = "tiptap" } return json.MarshalIndent(d, "", " ") } func textToDocContent(text string) json.RawMessage { paragraphs := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") nodes := make([]map[string]any, 0, len(paragraphs)) for _, para := range paragraphs { node := map[string]any{"type": "paragraph"} if strings.TrimSpace(para) != "" { node["content"] = []map[string]any{ {"type": "text", "text": para}, } } nodes = append(nodes, node) } if len(nodes) == 0 { nodes = append(nodes, map[string]any{"type": "paragraph"}) } raw, _ := json.Marshal(map[string]any{"type": "doc", "content": nodes}) return raw } func htmlToDocContent(html string) json.RawMessage { // Minimal bridge: strip tags naively for bootstrap; rich import uses client/docx pipeline. text := strings.TrimSpace(html) if text == "" { return emptyDocContent() } return textToDocContent(text) } func ImportBytes(name, mime string, body []byte) (json.RawMessage, error) { ext := strings.ToLower(name) if dot := strings.LastIndex(ext, "."); dot >= 0 { ext = ext[dot+1:] } switch ext { case "ultidoc", "json": if strings.HasSuffix(strings.ToLower(name), UltidocExtension) || strings.Contains(name, "ultidoc") { doc, err := ParseUltiDoc(body) if err != nil { return nil, err } return doc.Content, nil } var generic map[string]any if err := json.Unmarshal(body, &generic); err == nil { if t, _ := generic["type"].(string); t == "doc" { return json.RawMessage(body), nil } } case "txt", "log", "ini", "conf", "cfg", "env": return textToDocContent(string(body)), nil case "md", "markdown": return textToDocContent(string(body)), nil case "html", "htm": return htmlToDocContent(string(body)), nil } _ = mime return nil, fmt.Errorf("import requires client conversion for %q", name) }