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"` 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"` } func emptyDocContent() json.RawMessage { return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`) } 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 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) }