131 lines
3.4 KiB
Go
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)
|
|
}
|