ultisuite-backend/internal/api/richtext/document.go
R3D347HR4Y 20c4fef3c6
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
docxi import lol
2026-06-10 00:27:21 +02:00

287 lines
7.8 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"`
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)
}