wrappages
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

This commit is contained in:
R3D347HR4Y 2026-06-10 12:48:11 +02:00
parent 20c4fef3c6
commit 2bdd16fa37
6 changed files with 209 additions and 12 deletions

View File

@ -53,15 +53,37 @@ type UltiDocPageBackground struct {
GradientCss string `json:"gradientCss,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 { type UltiDocPageSetup struct {
WidthMm float64 `json:"widthMm"` WidthMm float64 `json:"widthMm"`
HeightMm float64 `json:"heightMm"` HeightMm float64 `json:"heightMm"`
MarginsMm UltiDocPageMargins `json:"marginsMm"` MarginsMm UltiDocPageMargins `json:"marginsMm"`
HeaderMarginMm float64 `json:"headerMarginMm,omitempty"`
FooterMarginMm float64 `json:"footerMarginMm,omitempty"`
FormatID string `json:"formatId,omitempty"` FormatID string `json:"formatId,omitempty"`
Orientation string `json:"orientation,omitempty"` Orientation string `json:"orientation,omitempty"`
PageColor string `json:"pageColor,omitempty"` PageColor string `json:"pageColor,omitempty"`
PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"` PageBackground *UltiDocPageBackground `json:"pageBackground,omitempty"`
Borders *UltiDocPageBorders `json:"borders,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 { type UltiDocPageBorderSide struct {

View File

@ -44,6 +44,7 @@ func (h *Handler) Routes(authMiddleware func(http.Handler) http.Handler) chi.Rou
pr.With(read).Post("/session", h.CreateSession) pr.With(read).Post("/session", h.CreateSession)
pr.With(read).Post("/import", h.Import) pr.With(read).Post("/import", h.Import)
pr.With(read).Post("/export", h.Export) pr.With(read).Post("/export", h.Export)
pr.With(write).Post("/assets", h.UploadAsset)
pr.With(write).Put("/save", h.Save) pr.With(write).Put("/save", h.Save)
}) })
return r return r
@ -307,6 +308,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
doc := NewUltiDoc(payload.Document, nil) doc := NewUltiDoc(payload.Document, nil)
doc.YjsState = payload.YjsState doc.YjsState = payload.YjsState
preserveUltiDocMetadata(&doc, existing) preserveUltiDocMetadata(&doc, existing)
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
w.WriteHeader(http.StatusNoContent)
return
}
var err error var err error
raw, err = doc.Marshal() raw, err = doc.Marshal()
if err != nil { if err != nil {
@ -316,6 +321,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
} else if payload.YjsState != "" { } else if payload.YjsState != "" {
doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()} doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()}
preserveUltiDocMetadata(&doc, existing) preserveUltiDocMetadata(&doc, existing)
if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) {
w.WriteHeader(http.StatusNoContent)
return
}
var err error var err error
raw, err = doc.Marshal() raw, err = doc.Marshal()
if err != nil { if err != nil {
@ -335,3 +344,34 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
type assetUploadRequest struct {
Path string `json:"path"`
DataURL string `json:"dataUrl"`
}
func (h *Handler) UploadAsset(w http.ResponseWriter, r *http.Request) {
claims := middleware.ClaimsFromContext(r.Context())
ncUser, err := h.svc.EnsureNextcloudUser(r.Context(), claims)
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
var req assetUploadRequest
if err := apivalidate.DecodeJSON(w, r, 16<<20, &req); err != nil {
return
}
if strings.TrimSpace(req.Path) == "" || strings.TrimSpace(req.DataURL) == "" {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "path", Message: "required"},
apivalidate.FieldDetail{Field: "dataUrl", Message: "required"},
))
return
}
result, err := h.svc.UploadGraphicAsset(r.Context(), ncUser, req.Path, req.DataURL)
if err != nil {
apiresponse.WriteError(w, r, http.StatusBadRequest, apiresponse.CodeInvalidRequest, err.Error(), nil)
return
}
apiresponse.WriteJSON(w, http.StatusOK, result)
}

View File

@ -1,7 +1,9 @@
package richtext package richtext
import ( import (
"bytes"
"context" "context"
"encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@ -246,6 +248,7 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str
Path: source, Path: source,
ImportedAt: time.Now().UTC().Format(time.RFC3339), ImportedAt: time.Now().UTC().Format(time.RFC3339),
}) })
doc.YjsState = ""
if len(req.PageSetup) > 0 { if len(req.PageSetup) > 0 {
var pageSetup UltiDocPageSetup var pageSetup UltiDocPageSetup
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil { if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
@ -375,6 +378,64 @@ func htmlEscape(s string) string {
return r.Replace(s) return r.Replace(s)
} }
type GraphicAssetResult struct {
AssetID string `json:"assetId"`
URL string `json:"url"`
}
// UploadGraphicAsset stores a base64 data URL as a binary next to the document.
func (s *Service) UploadGraphicAsset(ctx context.Context, ncUser, docPath, dataURL string) (*GraphicAssetResult, error) {
docPath = normalizePath(docPath)
comma := strings.Index(dataURL, ",")
if comma < 0 {
return nil, fmt.Errorf("invalid data URL")
}
meta := dataURL[:comma]
payload := dataURL[comma+1:]
mime := "application/octet-stream"
if strings.Contains(meta, "image/png") {
mime = "image/png"
} else if strings.Contains(meta, "image/jpeg") || strings.Contains(meta, "image/jpg") {
mime = "image/jpeg"
} else if strings.Contains(meta, "image/webp") {
mime = "image/webp"
} else if strings.Contains(meta, "image/gif") {
mime = "image/gif"
}
raw, err := decodeBase64Payload(payload)
if err != nil {
return nil, err
}
assetID := hashPath(docPath + ":" + payload[:min(32, len(payload))])
dir := strings.TrimSuffix(docPath, "/")
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
dir = dir[:idx]
} else {
dir = ""
}
assetPath := normalizePath(strings.Trim(dir+"/.ultidoc-assets/"+assetID, "/"))
if err := s.nc.Upload(ctx, ncUser, assetPath, bytes.NewReader(raw), mime); err != nil {
return nil, err
}
return &GraphicAssetResult{
AssetID: assetID,
URL: "/api/v1/drive/files/" + assetPath,
}, nil
}
func decodeBase64Payload(payload string) ([]byte, error) {
return base64.StdEncoding.DecodeString(payload)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func hashPath(p string) string { func hashPath(p string) string {
h := sha256Hex([]byte(normalizePath(p))) h := sha256Hex([]byte(normalizePath(p)))
if len(h) > 16 { if len(h) > 16 {

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@hocuspocus/server": "^4.1.0", "@hocuspocus/server": "^4.1.0",
"@hocuspocus/transformer": "^4.1.0", "@hocuspocus/transformer": "^4.1.0",
"@tiptap/core": "^3.23.2",
"@tiptap/extension-highlight": "^3.23.2", "@tiptap/extension-highlight": "^3.23.2",
"@tiptap/extension-image": "^3.23.2", "@tiptap/extension-image": "^3.23.2",
"@tiptap/extension-link": "^3.23.2", "@tiptap/extension-link": "^3.23.2",

View File

@ -14,6 +14,9 @@ importers:
'@hocuspocus/transformer': '@hocuspocus/transformer':
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(y-prosemirror@1.3.7(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31) version: 4.1.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)(y-prosemirror@1.3.7(prosemirror-model@1.25.7)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)(y-protocols@1.0.7(yjs@13.6.31))(yjs@13.6.31))(yjs@13.6.31)
'@tiptap/core':
specifier: ^3.23.2
version: 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-highlight': '@tiptap/extension-highlight':
specifier: ^3.23.2 specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))

View File

@ -5,6 +5,7 @@ import { createRequire } from "node:module"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const { Node } = require("@tiptap/core")
const StarterKit = require("@tiptap/starter-kit").default const StarterKit = require("@tiptap/starter-kit").default
const Underline = require("@tiptap/extension-underline").default const Underline = require("@tiptap/extension-underline").default
const Link = require("@tiptap/extension-link").default const Link = require("@tiptap/extension-link").default
@ -14,9 +15,59 @@ const TextAlign = require("@tiptap/extension-text-align").default
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table") const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
const Image = require("@tiptap/extension-image").default const Image = require("@tiptap/extension-image").default
const graphicAttributes = {
graphicType: { default: "image" },
src: { default: null },
alt: { default: "" },
shapeType: { default: "rect" },
fill: { default: "#4285f4" },
stroke: { default: "#1a73e8" },
strokeWidth: { default: 2 },
gradientCss: { default: "" },
gradientAngle: { default: 180 },
gradientColor1: { default: "#4285f4" },
gradientColor2: { default: "#34a853" },
width: { default: 240 },
height: { default: 160 },
placement: { default: "block" },
wrap: { default: "square" },
floatSide: { default: "left" },
x: { default: 0 },
y: { default: 0 },
rotationDeg: { default: 0 },
zIndex: { default: 0 },
cropX: { default: 0 },
cropY: { default: 0 },
cropWidth: { default: 1 },
cropHeight: { default: 1 },
cropShape: { default: "rect" },
assetId: { default: null },
opacity: { default: 1 },
shadow: { default: "" },
}
const DocsGraphic = Node.create({
name: "docsGraphic",
group: "block",
atom: true,
addAttributes() {
return graphicAttributes
},
})
const DocsInlineGraphic = Node.create({
name: "docsInlineGraphic",
group: "inline",
inline: true,
atom: true,
addAttributes() {
return graphicAttributes
},
})
/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */ /** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
const transformerExtensions = [ const transformerExtensions = [
StarterKit.configure({ undoRedo: false }), StarterKit.configure({ undoRedo: false, link: false, underline: false }),
Underline, Underline,
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
TextStyle, TextStyle,
@ -27,6 +78,8 @@ const transformerExtensions = [
TableCell, TableCell,
TableHeader, TableHeader,
Image.configure({ inline: true, allowBase64: true }), Image.configure({ inline: true, allowBase64: true }),
DocsGraphic,
DocsInlineGraphic,
] ]
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234) const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
@ -61,6 +114,23 @@ function tipTapContentHasText(content) {
return walk(content) return walk(content)
} }
function tipTapContentHasBody(content) {
if (!content || typeof content !== "object") return false
if (tipTapContentHasText(content)) return true
const walk = (node) => {
if (!node || typeof node !== "object") return false
const type = node.type
if (type && type !== "doc" && type !== "paragraph") return true
if (Array.isArray(node.content)) {
if (node.content.length === 0) return false
if (node.content.length > 1) return true
return walk(node.content[0])
}
return false
}
return walk(content)
}
async function loadFromUltid(context) { async function loadFromUltid(context) {
if (!context?.path || !context?.user) return null if (!context?.path || !context?.user) return null
const params = new URLSearchParams({ user: context.user, path: context.path }) const params = new URLSearchParams({ user: context.user, path: context.path })
@ -73,12 +143,12 @@ async function loadFromUltid(context) {
if (!raw.trim()) return null if (!raw.trim()) return null
try { try {
const doc = JSON.parse(raw) const doc = JSON.parse(raw)
const hasStoredText = tipTapContentHasText(doc.content) const hasStoredBody = tipTapContentHasBody(doc.content)
if (doc.content && hasStoredText) { if (doc.content && hasStoredBody) {
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions) const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
return Buffer.from(Y.encodeStateAsUpdate(ydoc)) return Buffer.from(Y.encodeStateAsUpdate(ydoc))
} }
if (doc.yjsState) { if (doc.yjsState && !hasStoredBody) {
return Buffer.from(doc.yjsState, "base64") return Buffer.from(doc.yjsState, "base64")
} }
} catch (err) { } catch (err) {