309 lines
9.2 KiB
Go
309 lines
9.2 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 UltiDocPageHeaderFooter struct {
|
|
Content json.RawMessage `json:"content"`
|
|
HeightMm float64 `json:"heightMm,omitempty"`
|
|
}
|
|
|
|
type UltiDocPageNumberSettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
Placement string `json:"placement"`
|
|
Align string `json:"align"`
|
|
StartAt int `json:"startAt"`
|
|
ShowOnFirstPage bool `json:"showOnFirstPage"`
|
|
}
|
|
|
|
type UltiDocPageSetup struct {
|
|
WidthMm float64 `json:"widthMm"`
|
|
HeightMm float64 `json:"heightMm"`
|
|
MarginsMm UltiDocPageMargins `json:"marginsMm"`
|
|
HeaderMarginMm float64 `json:"headerMarginMm,omitempty"`
|
|
FooterMarginMm float64 `json:"footerMarginMm,omitempty"`
|
|
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"`
|
|
Header *UltiDocPageHeaderFooter `json:"header,omitempty"`
|
|
Footer *UltiDocPageHeaderFooter `json:"footer,omitempty"`
|
|
HeaderFirstPage *UltiDocPageHeaderFooter `json:"headerFirstPage,omitempty"`
|
|
FooterFirstPage *UltiDocPageHeaderFooter `json:"footerFirstPage,omitempty"`
|
|
HeaderFooterDifferentFirstPage bool `json:"headerFooterDifferentFirstPage,omitempty"`
|
|
HeaderFooterDifferentOddEven bool `json:"headerFooterDifferentOddEven,omitempty"`
|
|
PageNumbers *UltiDocPageNumberSettings `json:"pageNumbers,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)
|
|
}
|