docxi import lol
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 00:27:21 +02:00
parent d02ec4afd9
commit 20c4fef3c6
18 changed files with 566 additions and 45 deletions

View File

@ -36,7 +36,7 @@ DOMAIN=localhost
ULTID_PORT=8080 ULTID_PORT=8080
# Origines navigateur autorisees (web app sur autre port/origine que l'API). # Origines navigateur autorisees (web app sur autre port/origine que l'API).
# Vide = auto : localhost/127.0.0.1/LAN prive en dev ; http(s)://${DOMAIN} en prod. # Vide = auto : localhost/127.0.0.1/LAN prive en dev ; http(s)://${DOMAIN} en prod.
# Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 # Exemple dev explicite : ULTID_CORS_ALLOWED_ORIGINS=http://localhost:3004,http://127.0.0.1:3004
# ULTID_CORS_ALLOWED_ORIGINS= # ULTID_CORS_ALLOWED_ORIGINS=
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
@ -245,8 +245,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET=
MAIL_MICROSOFT_OAUTH_TENANT=common MAIL_MICROSOFT_OAUTH_TENANT=common
MAIL_OAUTH_REDIRECT_URL= MAIL_OAUTH_REDIRECT_URL=
MAIL_APP_URL=http://localhost/mail MAIL_APP_URL=http://localhost/mail
# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte ; prod: suite-frontend:3000) # Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte :3004 ; prod: suite-frontend:3000)
MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000 MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@ -104,7 +104,7 @@ Un seul **nginx** expose lentrée HTTP (`:80`) et route :
| `/auth/*` | Authentik | | `/auth/*` | Authentik |
| `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) | | `/meet/*` | Jitsi (si `JITSI_ENABLED=true`) |
| `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) | | `/cloud/*` | Nextcloud nginx+FPM (si `NEXTCLOUD_ENABLED=true`) |
| `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3000` ; Docker : `suite-frontend:3000`) | | `/mail/*`, `/drive/*`, `/contacts`, `/admin/*` | Suite frontend (`MAIL_FRONTEND_UPSTREAM`, défaut `host.docker.internal:3004` ; Docker : `suite-frontend:3000`) |
Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`). Nextcloud : FPM + nginx dédié ; ultid appelle `NEXTCLOUD_URL` en interne (`http://nextcloud:80`).
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx). Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).

View File

@ -30,9 +30,9 @@ entries:
- matching_mode: strict - matching_mode: strict
url: http://127.0.0.1/api/auth/callback url: http://127.0.0.1/api/auth/callback
- matching_mode: strict - matching_mode: strict
url: http://localhost:3000/api/auth/callback url: http://localhost:3004/api/auth/callback
- matching_mode: strict - matching_mode: strict
url: http://127.0.0.1:3000/api/auth/callback url: http://127.0.0.1:3004/api/auth/callback
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]] signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application - model: authentik_core.application

View File

@ -8,7 +8,7 @@ services:
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro - ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
environment: environment:
DOMAIN: ${DOMAIN:-localhost} DOMAIN: ${DOMAIN:-localhost}
MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3000} MAIL_FRONTEND_UPSTREAM: ${MAIL_FRONTEND_UPSTREAM:-host.docker.internal:3004}
env_file: ../.env.resolved env_file: ../.env.resolved
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"

View File

@ -6,7 +6,7 @@ map $http_upgrade $connection_upgrade {
'' close; '' close;
} }
# Reflect browser Origin for cross-origin API calls (web app on :3000, API on :80). # Reflect browser Origin for cross-origin API calls (web app on :3004, API on :80).
map $http_origin $cors_allow_origin { map $http_origin $cors_allow_origin {
default $http_origin; default $http_origin;
'' '*'; '' '*';
@ -202,7 +202,7 @@ server {
proxy_set_header Connection $connection_upgrade; proxy_set_header Connection $connection_upgrade;
} }
# Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3000) # Ulti Suite frontend (mail + drive + contacts) — dev: pnpm dev on host (MAIL_FRONTEND_UPSTREAM=host.docker.internal:3004)
# Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000 # Prod: set MAIL_FRONTEND_UPSTREAM=suite-frontend:3000
location ^~ /api/auth/ { location ^~ /api/auth/ {
resolver 127.0.0.11 valid=10s ipv6=off; resolver 127.0.0.11 valid=10s ipv6=off;

View File

@ -186,7 +186,7 @@ func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.Pend
func (h *Handler) oauthReturnURL(status, code string) string { func (h *Handler) oauthReturnURL(status, code string) string {
base := strings.TrimRight(h.appURL, "/") base := strings.TrimRight(h.appURL, "/")
if base == "" { if base == "" {
base = "http://localhost:3000" base = "http://localhost:3004"
} }
u := base + "/mail/settings/accounts?oauth=" + status u := base + "/mail/settings/accounts?oauth=" + status
if code != "" { if code != "" {

View File

@ -15,6 +15,7 @@ type UltiDoc struct {
Editor string `json:"editor"` Editor string `json:"editor"`
Source *UltiDocSource `json:"source,omitempty"` Source *UltiDocSource `json:"source,omitempty"`
Content json.RawMessage `json:"content"` Content json.RawMessage `json:"content"`
PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"`
YjsState string `json:"yjsState,omitempty"` YjsState string `json:"yjsState,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"`
} }
@ -25,10 +26,96 @@ type UltiDocSource struct {
ImportedAt string `json:"importedAt,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 { func emptyDocContent() json.RawMessage {
return json.RawMessage(`{"type":"doc","content":[{"type":"paragraph"}]}`) 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 { func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc {
if len(content) == 0 { if len(content) == 0 {
content = emptyDocContent() content = emptyDocContent()
@ -42,6 +129,75 @@ func NewUltiDoc(content json.RawMessage, source *UltiDocSource) UltiDoc {
} }
} }
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) { func ParseUltiDoc(raw []byte) (UltiDoc, error) {
var doc UltiDoc var doc UltiDoc
if err := json.Unmarshal(raw, &doc); err != nil { if err := json.Unmarshal(raw, &doc); err != nil {

View File

@ -0,0 +1,25 @@
package richtext
import "testing"
func TestIsEmptyDocContent(t *testing.T) {
tests := []struct {
name string
content string
empty bool
}{
{"blank", "", true},
{"empty paragraph", `{"type":"doc","content":[{"type":"paragraph"}]}`, true},
{"whitespace text", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":" "}]}]}`, true},
{"has text", `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}`, false},
{"heading text", `{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"type":"text","text":"Title"}]}]}`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isEmptyDocContent([]byte(tt.content))
if got != tt.empty {
t.Fatalf("isEmptyDocContent() = %v, want %v", got, tt.empty)
}
})
}
}

View File

@ -134,6 +134,7 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
type saveRequest struct { type saveRequest struct {
Path string `json:"path"` Path string `json:"path"`
Document json.RawMessage `json:"document"` Document json.RawMessage `json:"document"`
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
YjsState string `json:"yjsState,omitempty"` YjsState string `json:"yjsState,omitempty"`
} }
@ -148,8 +149,42 @@ func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil { if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil {
return return
} }
doc := NewUltiDoc(req.Document, nil) path := normalizePath(req.Path)
existingRaw, _ := h.svc.LoadDocument(r.Context(), ncUser, path)
var existing UltiDoc
if len(existingRaw) > 0 {
if parsed, err := ParseUltiDoc(existingRaw); err == nil {
existing = parsed
}
}
var doc UltiDoc
switch {
case len(req.Document) > 0:
doc = NewUltiDoc(req.Document, nil)
case len(req.PageSetup) > 0:
if existing.SchemaVersion > 0 {
doc = existing
} else {
doc = NewUltiDoc(nil, nil)
}
default:
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "document", Message: "document or pageSetup required"},
))
return
}
if len(req.PageSetup) > 0 {
var pageSetup UltiDocPageSetup
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
doc.PageSetup = &pageSetup
}
}
if req.YjsState != "" {
doc.YjsState = req.YjsState doc.YjsState = req.YjsState
}
preserveUltiDocMetadata(&doc, existing)
payload, err := doc.Marshal() payload, err := doc.Marshal()
if err != nil { if err != nil {
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
@ -197,7 +232,26 @@ func (h *Handler) PutDocument(w http.ResponseWriter, r *http.Request) {
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return return
} }
if err := h.svc.SaveDocument(r.Context(), user, path, raw, platformUser); err != nil { existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), user, path)
var existing UltiDoc
if len(existingRaw) > 0 {
if parsed, parseErr := ParseUltiDoc(existingRaw); parseErr == nil {
existing = parsed
}
}
doc, err := ApplyUltiDocPatch(existing, raw)
if err != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"},
))
return
}
payload, err := doc.Marshal()
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
if err := h.svc.SaveDocument(r.Context(), user, path, payload, platformUser); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError) http.Error(w, "save failed", http.StatusInternalServerError)
return return
} }
@ -241,10 +295,18 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
return return
} }
path := normalizePath(payload.Path) path := normalizePath(payload.Path)
existingRaw, _ := h.svc.LoadDocumentForUser(r.Context(), payload.User, path)
var existing UltiDoc
if len(existingRaw) > 0 {
if parsed, err := ParseUltiDoc(existingRaw); err == nil {
existing = parsed
}
}
var raw []byte var raw []byte
if len(payload.Document) > 0 { if len(payload.Document) > 0 {
doc := NewUltiDoc(payload.Document, nil) doc := NewUltiDoc(payload.Document, nil)
doc.YjsState = payload.YjsState doc.YjsState = payload.YjsState
preserveUltiDocMetadata(&doc, existing)
var err error var err error
raw, err = doc.Marshal() raw, err = doc.Marshal()
if err != nil { if err != nil {
@ -253,6 +315,7 @@ 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)
var err error var err error
raw, err = doc.Marshal() raw, err = doc.Marshal()
if err != nil { if err != nil {

View File

@ -160,7 +160,26 @@ func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request)
apivalidate.WriteInternal(w, r) apivalidate.WriteInternal(w, r)
return return
} }
if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, raw); err != nil { existingRaw, loadErr := h.svc.LoadPublicDocumentLegacy(r.Context(), token, path, password)
var existing UltiDoc
if loadErr == nil && len(existingRaw) > 0 {
if parsed, parseErr := ParseUltiDoc(existingRaw); parseErr == nil {
existing = parsed
}
}
doc, err := ApplyUltiDocPatch(existing, raw)
if err != nil {
apivalidate.WriteValidationError(w, r, apivalidate.NewValidationError(
apivalidate.FieldDetail{Field: "document", Message: "invalid JSON"},
))
return
}
payload, err := doc.Marshal()
if err != nil {
apivalidate.WriteInternal(w, r)
return
}
if err := h.svc.SavePublicDocumentLegacy(r.Context(), token, path, password, payload); err != nil {
http.Error(w, "save failed", http.StatusInternalServerError) http.Error(w, "save failed", http.StatusInternalServerError)
return return
} }

View File

@ -56,6 +56,8 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
pageSetup, _ := s.loadPublicPageSetupFromSidecar(ctx, token, password, canonical, displayName)
return &PublicSessionResult{ return &PublicSessionResult{
SessionResult: SessionResult{ SessionResult: SessionResult{
RoomID: roomID, RoomID: roomID,
@ -66,6 +68,7 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode
Mode: mode, Mode: mode,
ImportRequired: importRequired, ImportRequired: importRequired,
Collaboration: collab, Collaboration: collab,
PageSetup: pageSetup,
}, },
DocumentURL: docURL, DocumentURL: docURL,
SaveURL: saveURL, SaveURL: saveURL,
@ -89,7 +92,8 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat
sidecar := sidecarPathForSource(source) sidecar := sidecarPathForSource(source)
if s.publicSidecarExists(ctx, token, password, sidecar, displayName) { if s.publicSidecarExists(ctx, token, password, sidecar, displayName) {
return sidecar, source, false, nil needsImport := s.publicSidecarImportRequired(ctx, token, password, sidecar, source, displayName)
return sidecar, source, needsImport, nil
} }
if _, err := s.publicFileExists(ctx, token, source, password); err != nil { if _, err := s.publicFileExists(ctx, token, source, password); err != nil {
@ -99,6 +103,34 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat
return sidecar, source, true, nil return sidecar, source, true, nil
} }
func (s *Service) publicSidecarImportRequired(ctx context.Context, token, password, sidecarPath, sourcePath, displayName string) bool {
body, err := s.LoadPublicDocument(ctx, token, sidecarPath, password, displayName)
if err != nil {
return true
}
doc, err := ParseUltiDoc(body)
if err != nil {
return true
}
if !isEmptyDocContent(doc.Content) {
return false
}
candidates := []string{sourcePath}
if doc.Source != nil && doc.Source.Path != "" && doc.Source.Path != sourcePath {
candidates = append(candidates, doc.Source.Path)
}
for _, src := range candidates {
if src == "" {
continue
}
exists, err := s.publicFileExists(ctx, token, src, password)
if err == nil && exists {
return true
}
}
return false
}
func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string { func (s *Service) publicClientSourcePath(ctx context.Context, token, password, clientPath, displayName string) string {
binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password) binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password)
if err == nil { if err == nil {
@ -153,6 +185,12 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis
Path: source, Path: source,
ImportedAt: time.Now().UTC().Format(time.RFC3339), ImportedAt: time.Now().UTC().Format(time.RFC3339),
}) })
if len(req.PageSetup) > 0 {
var pageSetup UltiDocPageSetup
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
doc.PageSetup = &pageSetup
}
}
payload, err := doc.Marshal() payload, err := doc.Marshal()
if err != nil { if err != nil {
return "", err return "", err
@ -163,6 +201,18 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis
return canonical, nil return canonical, nil
} }
func (s *Service) loadPublicPageSetupFromSidecar(ctx context.Context, token, password, canonicalPath, displayName string) (json.RawMessage, error) {
body, err := s.LoadPublicDocument(ctx, token, canonicalPath, password, displayName)
if err != nil {
return nil, err
}
doc, err := ParseUltiDoc(body)
if err != nil || doc.PageSetup == nil {
return nil, err
}
return json.Marshal(doc.PageSetup)
}
func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) { func (s *Service) publicFileExists(ctx context.Context, token, path, password string) (bool, error) {
_, err := s.nc.PublicShareFileRevision(ctx, token, path, password) _, err := s.nc.PublicShareFileRevision(ctx, token, path, password)
if err != nil { if err != nil {

View File

@ -42,6 +42,7 @@ type SessionResult struct {
Mode string `json:"mode"` Mode string `json:"mode"`
ImportRequired bool `json:"importRequired"` ImportRequired bool `json:"importRequired"`
Collaboration bool `json:"collaboration"` Collaboration bool `json:"collaboration"`
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
} }
func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) { func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, editorUserID, editorName string) (*SessionResult, error) {
@ -79,6 +80,8 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL) wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
collab := wsURL != "" && s.Cfg.HocuspocusSecret != "" collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical)
return &SessionResult{ return &SessionResult{
RoomID: roomID, RoomID: roomID,
CanonicalPath: canonical, CanonicalPath: canonical,
@ -88,6 +91,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
Mode: mode, Mode: mode,
ImportRequired: importRequired, ImportRequired: importRequired,
Collaboration: collab, Collaboration: collab,
PageSetup: pageSetup,
}, nil }, nil
} }
@ -100,7 +104,11 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str
if s.Cfg.StorageMode == "overwrite" { if s.Cfg.StorageMode == "overwrite" {
target := sidecarPathForSource(filePath) target := sidecarPathForSource(filePath)
if _, err := s.nc.FileRevision(ctx, ncUser, target); err == nil { if _, err := s.nc.FileRevision(ctx, ncUser, target); err == nil {
return target, filePath, false, nil needsImport, err := s.sidecarImportRequired(ctx, ncUser, target, filePath)
if err != nil {
return "", "", false, err
}
return target, filePath, needsImport, nil
} }
// Will import and optionally move — treat as import required if source exists. // Will import and optionally move — treat as import required if source exists.
if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err == nil { if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err == nil {
@ -110,7 +118,11 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str
} }
if _, err := s.nc.FileRevision(ctx, ncUser, sidecar); err == nil { if _, err := s.nc.FileRevision(ctx, ncUser, sidecar); err == nil {
return sidecar, filePath, false, nil needsImport, err := s.sidecarImportRequired(ctx, ncUser, sidecar, filePath)
if err != nil {
return "", "", false, err
}
return sidecar, filePath, needsImport, nil
} }
if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil { if _, err := s.nc.FileRevision(ctx, ncUser, filePath); err != nil {
return "", "", false, fmt.Errorf("file not found: %s", filePath) return "", "", false, fmt.Errorf("file not found: %s", filePath)
@ -118,6 +130,45 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str
return sidecar, filePath, true, nil return sidecar, filePath, true, nil
} }
func (s *Service) sidecarImportRequired(ctx context.Context, ncUser, sidecarPath, sourcePath string) (bool, error) {
body, err := s.LoadDocument(ctx, ncUser, sidecarPath)
if err != nil {
return true, nil
}
doc, err := ParseUltiDoc(body)
if err != nil {
return true, nil
}
if !isEmptyDocContent(doc.Content) {
return false, nil
}
candidates := []string{sourcePath}
if doc.Source != nil && doc.Source.Path != "" && doc.Source.Path != sourcePath {
candidates = append(candidates, doc.Source.Path)
}
for _, src := range candidates {
if src == "" {
continue
}
if _, err := s.nc.FileRevision(ctx, ncUser, src); err == nil {
return true, nil
}
}
return false, nil
}
func (s *Service) loadPageSetupFromSidecar(ctx context.Context, ncUser, canonicalPath string) (json.RawMessage, error) {
body, err := s.LoadDocument(ctx, ncUser, canonicalPath)
if err != nil {
return nil, err
}
doc, err := ParseUltiDoc(body)
if err != nil || doc.PageSetup == nil {
return nil, err
}
return json.Marshal(doc.PageSetup)
}
func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) { func (s *Service) LoadDocument(ctx context.Context, ncUser, path string) ([]byte, error) {
path = normalizePath(path) path = normalizePath(path)
body, _, err := s.nc.Download(ctx, ncUser, path) body, _, err := s.nc.Download(ctx, ncUser, path)
@ -156,6 +207,7 @@ func (s *Service) LoadDocumentForUser(ctx context.Context, ncUser, path string)
type ImportRequest struct { type ImportRequest struct {
SourcePath string `json:"source_path"` SourcePath string `json:"source_path"`
Content json.RawMessage `json:"content,omitempty"` Content json.RawMessage `json:"content,omitempty"`
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
YjsState string `json:"yjsState,omitempty"` YjsState string `json:"yjsState,omitempty"`
} }
@ -194,6 +246,12 @@ 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),
}) })
if len(req.PageSetup) > 0 {
var pageSetup UltiDocPageSetup
if err := json.Unmarshal(req.PageSetup, &pageSetup); err == nil {
doc.PageSetup = &pageSetup
}
}
if req.YjsState != "" { if req.YjsState != "" {
doc.YjsState = req.YjsState doc.YjsState = req.YjsState
} }

View File

@ -103,8 +103,8 @@ func ultimailRedirectURIs(cfg *config.Config) []string {
mail := strings.TrimRight(cfg.MailAppURL, "/") mail := strings.TrimRight(cfg.MailAppURL, "/")
return uniqueURIs( return uniqueURIs(
mail+"/api/auth/callback", mail+"/api/auth/callback",
"http://localhost:3000/api/auth/callback", "http://localhost:3004/api/auth/callback",
"http://127.0.0.1:3000/api/auth/callback", "http://127.0.0.1:3004/api/auth/callback",
base+"/api/auth/callback", base+"/api/auth/callback",
) )
} }
@ -143,8 +143,8 @@ func driveRedirectURIs(cfg *config.Config) []string {
base := baseURL(cfg) base := baseURL(cfg)
return uniqueURIs( return uniqueURIs(
base+"/api/auth/callback", base+"/api/auth/callback",
"http://localhost:3000/api/auth/callback", "http://localhost:3004/api/auth/callback",
"http://127.0.0.1:3000/api/auth/callback", "http://127.0.0.1:3004/api/auth/callback",
) )
} }

View File

@ -221,7 +221,7 @@ func Load() (*Config, error) {
MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"), MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"),
MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"), MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"),
MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"), MailOAuthRedirectURL: os.Getenv("MAIL_OAUTH_REDIRECT_URL"),
MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3000")), MailAppURL: envOrDefault("MAIL_APP_URL", envOrDefault("NEXT_PUBLIC_APP_URL", "http://localhost:3004")),
SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour), SecretRotationMaxAge: envDuration("SECRET_ROTATION_MAX_AGE", 90*24*time.Hour),
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"), OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),

View File

@ -192,7 +192,7 @@ func buildTestConfig(env Env, infra *infra, oidc *OIDCServer) *config.Config {
MailCredentialKeys: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=", MailCredentialKeys: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
MailActiveCredentialKeyID: "v1", MailActiveCredentialKeyID: "v1",
MailWebhookSharedSecret: "test-webhook-secret", MailWebhookSharedSecret: "test-webhook-secret",
MailAppURL: "http://localhost:3000", MailAppURL: "http://localhost:3004",
SearchEngine: "postgres", SearchEngine: "postgres",
MeilisearchURL: env.MeilisearchURL, MeilisearchURL: env.MeilisearchURL,
MeilisearchKey: env.MeilisearchKey, MeilisearchKey: env.MeilisearchKey,

View File

@ -10,6 +10,16 @@
"dependencies": { "dependencies": {
"@hocuspocus/server": "^4.1.0", "@hocuspocus/server": "^4.1.0",
"@hocuspocus/transformer": "^4.1.0", "@hocuspocus/transformer": "^4.1.0",
"@tiptap/extension-highlight": "^3.23.2",
"@tiptap/extension-image": "^3.23.2",
"@tiptap/extension-link": "^3.23.2",
"@tiptap/extension-table": "^3.23.2",
"@tiptap/extension-table-cell": "^3.23.2",
"@tiptap/extension-table-header": "^3.23.2",
"@tiptap/extension-table-row": "^3.23.2",
"@tiptap/extension-text-align": "^3.23.2",
"@tiptap/extension-text-style": "^3.23.2",
"@tiptap/extension-underline": "^3.23.2",
"@tiptap/starter-kit": "^3.23.2", "@tiptap/starter-kit": "^3.23.2",
"yjs": "^13.6.27" "yjs": "^13.6.27"
} }

View File

@ -14,6 +14,36 @@ 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/extension-highlight':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/extension-image':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/extension-link':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
'@tiptap/extension-table':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
'@tiptap/extension-table-cell':
specifier: ^3.23.2
version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))
'@tiptap/extension-table-header':
specifier: ^3.23.2
version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))
'@tiptap/extension-table-row':
specifier: ^3.23.2
version: 3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))
'@tiptap/extension-text-align':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/extension-text-style':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/extension-underline':
specifier: ^3.23.2
version: 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))
'@tiptap/starter-kit': '@tiptap/starter-kit':
specifier: ^3.23.2 specifier: ^3.23.2
version: 3.26.0 version: 3.26.0
@ -97,12 +127,22 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': 3.26.0 '@tiptap/core': 3.26.0
'@tiptap/extension-highlight@3.26.0':
resolution: {integrity: sha512-/b6pImBTdgOc8UVMJiJNQ/QAtZ0Z4+2XyxmzoNIiOPGjQDy/IxLtci8+M2BYWRIRbgjCS9XllsfnKWVyO8pe+w==}
peerDependencies:
'@tiptap/core': 3.26.0
'@tiptap/extension-horizontal-rule@3.26.0': '@tiptap/extension-horizontal-rule@3.26.0':
resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==} resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==}
peerDependencies: peerDependencies:
'@tiptap/core': 3.26.0 '@tiptap/core': 3.26.0
'@tiptap/pm': 3.26.0 '@tiptap/pm': 3.26.0
'@tiptap/extension-image@3.26.0':
resolution: {integrity: sha512-vinrbKa9Awmlb/UPpnes1pezL+ZeUC2v7XczZyNbggvcHKhlVkuXZIKytFQDXEYOTaKYzYE8B/Gz098PiJ9NYQ==}
peerDependencies:
'@tiptap/core': 3.26.0
'@tiptap/extension-italic@3.26.0': '@tiptap/extension-italic@3.26.0':
resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==} resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==}
peerDependencies: peerDependencies:
@ -145,6 +185,37 @@ packages:
peerDependencies: peerDependencies:
'@tiptap/core': 3.26.0 '@tiptap/core': 3.26.0
'@tiptap/extension-table-cell@3.26.0':
resolution: {integrity: sha512-RhsCSJXgC7AxltrAZ7ePya+OG6H8fDEnQsr/jhWuucdnkH2jXPnYnXhzgOoJifyHwxRS+Sx0FRQWGHDA3peGMA==}
peerDependencies:
'@tiptap/extension-table': 3.26.0
'@tiptap/extension-table-header@3.26.0':
resolution: {integrity: sha512-kQx5RSjPygmhgNnkZtoD2j5AQ1Vj2Pzzif/sm8I75MlWNqp7yIWhCOcK7cwL6JQqf8GovzjAeMVY+5SDAOuVxw==}
peerDependencies:
'@tiptap/extension-table': 3.26.0
'@tiptap/extension-table-row@3.26.0':
resolution: {integrity: sha512-uBlCG1QIhgBwwXQROqolDlMwv9f2Wy3dH/2o5mlNwSQPI0B1+hdLV1eVep3MKRDweJqlP7kIhNNJxCUZQO+WYA==}
peerDependencies:
'@tiptap/extension-table': 3.26.0
'@tiptap/extension-table@3.26.0':
resolution: {integrity: sha512-pLL1+tKeUWSF/w4se84tjWUnuraKVELQtIHwi1XKoq6vkevotwwMb99xY6cJ752FaUFVDbViFe/JUYWBoU+bIw==}
peerDependencies:
'@tiptap/core': 3.26.0
'@tiptap/pm': 3.26.0
'@tiptap/extension-text-align@3.26.0':
resolution: {integrity: sha512-yEtgrEJyE7sfcIAzk9cmJUQUMQZ/J0RU3k7mE+hy5o7t8j78Zs+KcsH+hrczn0MNzblg4gfX2erN+1/SGfSlpA==}
peerDependencies:
'@tiptap/core': 3.26.0
'@tiptap/extension-text-style@3.26.0':
resolution: {integrity: sha512-4IeIdiubF5/Em9AodaLkCKUNwkQaK3KuwnneXS61x4Yoyr0zK21i3kZAFK7ils7jUwSrRdGdM1FglBnB4tK8nA==}
peerDependencies:
'@tiptap/core': 3.26.0
'@tiptap/extension-text@3.26.0': '@tiptap/extension-text@3.26.0':
resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==} resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==}
peerDependencies: peerDependencies:
@ -335,11 +406,19 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-highlight@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)': '@tiptap/extension-horizontal-rule@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)':
dependencies: dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/pm': 3.26.0 '@tiptap/pm': 3.26.0
'@tiptap/extension-image@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': '@tiptap/extension-italic@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies: dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
@ -375,6 +454,31 @@ snapshots:
dependencies: dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-table-cell@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
'@tiptap/extension-table-header@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
'@tiptap/extension-table-row@3.26.0(@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/extension-table': 3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)
'@tiptap/extension-table@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))(@tiptap/pm@3.26.0)':
dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/pm': 3.26.0
'@tiptap/extension-text-align@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-text-style@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
'@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))': '@tiptap/extension-text@3.26.0(@tiptap/core@3.26.0(@tiptap/pm@3.26.0))':
dependencies: dependencies:
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0) '@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)

View File

@ -6,6 +6,28 @@ import { createRequire } from "node:module"
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const StarterKit = require("@tiptap/starter-kit").default const StarterKit = require("@tiptap/starter-kit").default
const Underline = require("@tiptap/extension-underline").default
const Link = require("@tiptap/extension-link").default
const { TextStyle } = require("@tiptap/extension-text-style")
const Highlight = require("@tiptap/extension-highlight").default
const TextAlign = require("@tiptap/extension-text-align").default
const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table")
const Image = require("@tiptap/extension-image").default
/** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */
const transformerExtensions = [
StarterKit.configure({ undoRedo: false }),
Underline,
Link.configure({ openOnClick: false }),
TextStyle,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ["heading", "paragraph"] }),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
Image.configure({ inline: true, allowBase64: true }),
]
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234) const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
const SECRET = process.env.HOCUSPOCUS_SECRET || "" const SECRET = process.env.HOCUSPOCUS_SECRET || ""
@ -26,6 +48,19 @@ function hookContext(payload) {
return payload?.lastContext ?? payload?.context ?? {} return payload?.lastContext ?? payload?.context ?? {}
} }
function tipTapContentHasText(content) {
if (!content || typeof content !== "object") return false
const walk = (node) => {
if (!node || typeof node !== "object") return false
if (typeof node.text === "string" && node.text.trim()) return true
if (Array.isArray(node.content)) {
return node.content.some(walk)
}
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 })
@ -38,13 +73,14 @@ 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)
if (doc.content && hasStoredText) {
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
}
if (doc.yjsState) { if (doc.yjsState) {
return Buffer.from(doc.yjsState, "base64") return Buffer.from(doc.yjsState, "base64")
} }
if (doc.content) {
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", [StarterKit])
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
}
} catch (err) { } catch (err) {
console.error("[onLoadDocument] parse", err) console.error("[onLoadDocument] parse", err)
} }
@ -58,7 +94,7 @@ async function storeToUltid(context, document) {
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64") const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
let tiptapJson = null let tiptapJson = null
try { try {
tiptapJson = TiptapTransformer.fromYdoc(document, "default") tiptapJson = TiptapTransformer.fromYdoc(document, "default", transformerExtensions)
} catch { } catch {
/* optional */ /* optional */
} }