diff --git a/internal/api/richtext/document.go b/internal/api/richtext/document.go index bef4867..c5f1409 100644 --- a/internal/api/richtext/document.go +++ b/internal/api/richtext/document.go @@ -53,15 +53,37 @@ type UltiDocPageBackground struct { 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"` - 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"` + 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 { diff --git a/internal/api/richtext/handlers.go b/internal/api/richtext/handlers.go index aff0abe..7cfeda4 100644 --- a/internal/api/richtext/handlers.go +++ b/internal/api/richtext/handlers.go @@ -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("/import", h.Import) pr.With(read).Post("/export", h.Export) + pr.With(write).Post("/assets", h.UploadAsset) pr.With(write).Put("/save", h.Save) }) return r @@ -307,6 +308,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { doc := NewUltiDoc(payload.Document, nil) doc.YjsState = payload.YjsState preserveUltiDocMetadata(&doc, existing) + if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) { + w.WriteHeader(http.StatusNoContent) + return + } var err error raw, err = doc.Marshal() if err != nil { @@ -316,6 +321,10 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { } else if payload.YjsState != "" { doc := UltiDoc{SchemaVersion: schemaVersion, Editor: "tiptap", YjsState: payload.YjsState, Content: emptyDocContent()} preserveUltiDocMetadata(&doc, existing) + if isEmptyDocContent(doc.Content) && len(existingRaw) > 0 && !isEmptyDocContent(existing.Content) { + w.WriteHeader(http.StatusNoContent) + return + } var err error raw, err = doc.Marshal() if err != nil { @@ -335,3 +344,34 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) { } 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) +} diff --git a/internal/api/richtext/service.go b/internal/api/richtext/service.go index 12c4f6c..137d653 100644 --- a/internal/api/richtext/service.go +++ b/internal/api/richtext/service.go @@ -1,7 +1,9 @@ package richtext import ( + "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -246,6 +248,7 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str Path: source, ImportedAt: time.Now().UTC().Format(time.RFC3339), }) + doc.YjsState = "" if len(req.PageSetup) > 0 { var pageSetup UltiDocPageSetup if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil { @@ -375,6 +378,64 @@ func htmlEscape(s string) string { 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 { h := sha256Hex([]byte(normalizePath(p))) if len(h) > 16 { diff --git a/services/hocuspocus/package.json b/services/hocuspocus/package.json index 414ad42..763bb85 100644 --- a/services/hocuspocus/package.json +++ b/services/hocuspocus/package.json @@ -10,6 +10,7 @@ "dependencies": { "@hocuspocus/server": "^4.1.0", "@hocuspocus/transformer": "^4.1.0", + "@tiptap/core": "^3.23.2", "@tiptap/extension-highlight": "^3.23.2", "@tiptap/extension-image": "^3.23.2", "@tiptap/extension-link": "^3.23.2", diff --git a/services/hocuspocus/pnpm-lock.yaml b/services/hocuspocus/pnpm-lock.yaml index 159d0d4..756b383 100644 --- a/services/hocuspocus/pnpm-lock.yaml +++ b/services/hocuspocus/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@hocuspocus/transformer': 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) + '@tiptap/core': + specifier: ^3.23.2 + version: 3.26.0(@tiptap/pm@3.26.0) '@tiptap/extension-highlight': specifier: ^3.23.2 version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0)) diff --git a/services/hocuspocus/server.mjs b/services/hocuspocus/server.mjs index 51adeaf..f2ac19f 100644 --- a/services/hocuspocus/server.mjs +++ b/services/hocuspocus/server.mjs @@ -5,6 +5,7 @@ import { createRequire } from "node:module" const require = createRequire(import.meta.url) +const { Node } = require("@tiptap/core") const StarterKit = require("@tiptap/starter-kit").default const Underline = require("@tiptap/extension-underline").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 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. */ const transformerExtensions = [ - StarterKit.configure({ undoRedo: false }), + StarterKit.configure({ undoRedo: false, link: false, underline: false }), Underline, Link.configure({ openOnClick: false }), TextStyle, @@ -27,6 +78,8 @@ const transformerExtensions = [ TableCell, TableHeader, Image.configure({ inline: true, allowBase64: true }), + DocsGraphic, + DocsInlineGraphic, ] const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234) @@ -61,6 +114,23 @@ function tipTapContentHasText(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) { if (!context?.path || !context?.user) return null const params = new URLSearchParams({ user: context.user, path: context.path }) @@ -73,12 +143,12 @@ async function loadFromUltid(context) { if (!raw.trim()) return null try { const doc = JSON.parse(raw) - const hasStoredText = tipTapContentHasText(doc.content) - if (doc.content && hasStoredText) { + const hasStoredBody = tipTapContentHasBody(doc.content) + if (doc.content && hasStoredBody) { const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions) return Buffer.from(Y.encodeStateAsUpdate(ydoc)) } - if (doc.yjsState) { + if (doc.yjsState && !hasStoredBody) { return Buffer.from(doc.yjsState, "base64") } } catch (err) {