ultisuite-backend/internal/api/richtext/document.go
R3D347HR4Y d4ccf7eb6e
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
hocuspocus lol 2
2026-06-09 14:30:34 +02:00

131 lines
3.4 KiB
Go

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