docxi import lol
This commit is contained in:
parent
d02ec4afd9
commit
20c4fef3c6
@ -36,7 +36,7 @@ DOMAIN=localhost
|
||||
ULTID_PORT=8080
|
||||
# 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.
|
||||
# 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=
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@ -245,8 +245,8 @@ MAIL_MICROSOFT_OAUTH_CLIENT_SECRET=
|
||||
MAIL_MICROSOFT_OAUTH_TENANT=common
|
||||
MAIL_OAUTH_REDIRECT_URL=
|
||||
MAIL_APP_URL=http://localhost/mail
|
||||
# Cible nginx → suite frontend unifié mail+drive (dev: Next sur l'hôte ; prod: suite-frontend:3000)
|
||||
MAIL_FRONTEND_UPSTREAM=host.docker.internal: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:3004
|
||||
MAIL_WEBHOOK_SHARED_SECRET_ROTATED_AT=2026-01-01T00:00:00Z
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@ -104,7 +104,7 @@ Un seul **nginx** expose l’entrée HTTP (`:80`) et route :
|
||||
| `/auth/*` | Authentik |
|
||||
| `/meet/*` | Jitsi (si `JITSI_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`).
|
||||
Caddy retiré : un seul proxy évite la double couche ; TLS plus tard (certbot, Traefik, ou `listen 443` nginx).
|
||||
|
||||
@ -30,9 +30,9 @@ entries:
|
||||
- matching_mode: strict
|
||||
url: http://127.0.0.1/api/auth/callback
|
||||
- matching_mode: strict
|
||||
url: http://localhost:3000/api/auth/callback
|
||||
url: http://localhost:3004/api/auth/callback
|
||||
- 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]]
|
||||
|
||||
- model: authentik_core.application
|
||||
|
||||
@ -8,7 +8,7 @@ services:
|
||||
- ./nginx/default.conf.template:/etc/nginx/templates/default.conf.template:ro
|
||||
environment:
|
||||
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
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
@ -6,7 +6,7 @@ map $http_upgrade $connection_upgrade {
|
||||
'' 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 {
|
||||
default $http_origin;
|
||||
'' '*';
|
||||
@ -202,7 +202,7 @@ server {
|
||||
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
|
||||
location ^~ /api/auth/ {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
@ -186,7 +186,7 @@ func (h *Handler) oauthPendingFromRequest(req *oauthStartRequest) mailoauth.Pend
|
||||
func (h *Handler) oauthReturnURL(status, code string) string {
|
||||
base := strings.TrimRight(h.appURL, "/")
|
||||
if base == "" {
|
||||
base = "http://localhost:3000"
|
||||
base = "http://localhost:3004"
|
||||
}
|
||||
u := base + "/mail/settings/accounts?oauth=" + status
|
||||
if code != "" {
|
||||
|
||||
@ -11,12 +11,13 @@ 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"`
|
||||
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 {
|
||||
@ -25,10 +26,96 @@ type UltiDocSource struct {
|
||||
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 {
|
||||
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()
|
||||
@ -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) {
|
||||
var doc UltiDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
|
||||
25
internal/api/richtext/document_test.go
Normal file
25
internal/api/richtext/document_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -132,9 +132,10 @@ func (h *Handler) Export(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
type saveRequest struct {
|
||||
Path string `json:"path"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Document json.RawMessage `json:"document"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
||||
@ -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 {
|
||||
return
|
||||
}
|
||||
doc := NewUltiDoc(req.Document, nil)
|
||||
doc.YjsState = req.YjsState
|
||||
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
|
||||
}
|
||||
preserveUltiDocMetadata(&doc, existing)
|
||||
payload, err := doc.Marshal()
|
||||
if err != nil {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
@ -197,7 +232,26 @@ func (h *Handler) PutDocument(w http.ResponseWriter, r *http.Request) {
|
||||
apivalidate.WriteInternal(w, r)
|
||||
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)
|
||||
return
|
||||
}
|
||||
@ -241,10 +295,18 @@ func (h *Handler) HookStore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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
|
||||
if len(payload.Document) > 0 {
|
||||
doc := NewUltiDoc(payload.Document, nil)
|
||||
doc.YjsState = payload.YjsState
|
||||
preserveUltiDocMetadata(&doc, existing)
|
||||
var err error
|
||||
raw, err = doc.Marshal()
|
||||
if err != nil {
|
||||
@ -253,6 +315,7 @@ 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)
|
||||
var err error
|
||||
raw, err = doc.Marshal()
|
||||
if err != nil {
|
||||
|
||||
@ -160,7 +160,26 @@ func (h *Handler) PublicSharePutDocument(w http.ResponseWriter, r *http.Request)
|
||||
apivalidate.WriteInternal(w, r)
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
@ -56,6 +56,8 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode
|
||||
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
|
||||
collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
|
||||
|
||||
pageSetup, _ := s.loadPublicPageSetupFromSidecar(ctx, token, password, canonical, displayName)
|
||||
|
||||
return &PublicSessionResult{
|
||||
SessionResult: SessionResult{
|
||||
RoomID: roomID,
|
||||
@ -66,6 +68,7 @@ func (s *Service) CreatePublicSession(ctx context.Context, token, filePath, mode
|
||||
Mode: mode,
|
||||
ImportRequired: importRequired,
|
||||
Collaboration: collab,
|
||||
PageSetup: pageSetup,
|
||||
},
|
||||
DocumentURL: docURL,
|
||||
SaveURL: saveURL,
|
||||
@ -89,7 +92,8 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat
|
||||
sidecar := sidecarPathForSource(source)
|
||||
|
||||
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 {
|
||||
@ -99,6 +103,34 @@ func (s *Service) resolvePublicCanonicalPath(ctx context.Context, token, filePat
|
||||
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 {
|
||||
binding, err := s.nc.ResolvePublicShareBinding(ctx, token, password)
|
||||
if err == nil {
|
||||
@ -153,6 +185,12 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis
|
||||
Path: source,
|
||||
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()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@ -163,6 +201,18 @@ func (s *Service) ImportPublicDocument(ctx context.Context, token, password, dis
|
||||
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) {
|
||||
_, err := s.nc.PublicShareFileRevision(ctx, token, path, password)
|
||||
if err != nil {
|
||||
|
||||
@ -34,14 +34,15 @@ func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims)
|
||||
}
|
||||
|
||||
type SessionResult struct {
|
||||
RoomID string `json:"roomId"`
|
||||
CanonicalPath string `json:"canonicalPath"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
WsURL string `json:"wsUrl"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
ImportRequired bool `json:"importRequired"`
|
||||
Collaboration bool `json:"collaboration"`
|
||||
RoomID string `json:"roomId"`
|
||||
CanonicalPath string `json:"canonicalPath"`
|
||||
SourcePath string `json:"sourcePath,omitempty"`
|
||||
WsURL string `json:"wsUrl"`
|
||||
Token string `json:"token"`
|
||||
Mode string `json:"mode"`
|
||||
ImportRequired bool `json:"importRequired"`
|
||||
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) {
|
||||
@ -79,6 +80,8 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
|
||||
wsURL := strings.TrimSpace(s.Cfg.HocuspocusPublicURL)
|
||||
collab := wsURL != "" && s.Cfg.HocuspocusSecret != ""
|
||||
|
||||
pageSetup, _ := s.loadPageSetupFromSidecar(ctx, ncUser, canonical)
|
||||
|
||||
return &SessionResult{
|
||||
RoomID: roomID,
|
||||
CanonicalPath: canonical,
|
||||
@ -88,6 +91,7 @@ func (s *Service) CreateSession(ctx context.Context, ncUser, filePath, mode, edi
|
||||
Mode: mode,
|
||||
ImportRequired: importRequired,
|
||||
Collaboration: collab,
|
||||
PageSetup: pageSetup,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -100,7 +104,11 @@ func (s *Service) resolveCanonicalPath(ctx context.Context, ncUser, filePath str
|
||||
if s.Cfg.StorageMode == "overwrite" {
|
||||
target := sidecarPathForSource(filePath)
|
||||
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.
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
path = normalizePath(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 {
|
||||
SourcePath string `json:"source_path"`
|
||||
Content json.RawMessage `json:"content,omitempty"`
|
||||
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||
YjsState string `json:"yjsState,omitempty"`
|
||||
}
|
||||
|
||||
@ -194,6 +246,12 @@ func (s *Service) ImportDocument(ctx context.Context, ncUser, platformUserID str
|
||||
Path: source,
|
||||
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 != "" {
|
||||
doc.YjsState = req.YjsState
|
||||
}
|
||||
|
||||
@ -103,8 +103,8 @@ func ultimailRedirectURIs(cfg *config.Config) []string {
|
||||
mail := strings.TrimRight(cfg.MailAppURL, "/")
|
||||
return uniqueURIs(
|
||||
mail+"/api/auth/callback",
|
||||
"http://localhost:3000/api/auth/callback",
|
||||
"http://127.0.0.1:3000/api/auth/callback",
|
||||
"http://localhost:3004/api/auth/callback",
|
||||
"http://127.0.0.1:3004/api/auth/callback",
|
||||
base+"/api/auth/callback",
|
||||
)
|
||||
}
|
||||
@ -143,8 +143,8 @@ func driveRedirectURIs(cfg *config.Config) []string {
|
||||
base := baseURL(cfg)
|
||||
return uniqueURIs(
|
||||
base+"/api/auth/callback",
|
||||
"http://localhost:3000/api/auth/callback",
|
||||
"http://127.0.0.1:3000/api/auth/callback",
|
||||
"http://localhost:3004/api/auth/callback",
|
||||
"http://127.0.0.1:3004/api/auth/callback",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -221,7 +221,7 @@ func Load() (*Config, error) {
|
||||
MailMicrosoftOAuthSecret: secrets.Env("MAIL_MICROSOFT_OAUTH_CLIENT_SECRET"),
|
||||
MailMicrosoftOAuthTenant: envOrDefault("MAIL_MICROSOFT_OAUTH_TENANT", "common"),
|
||||
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),
|
||||
OIDCSecretRotatedAt: envTime("ULTID_OIDC_CLIENT_SECRET_ROTATED_AT"),
|
||||
|
||||
@ -192,7 +192,7 @@ func buildTestConfig(env Env, infra *infra, oidc *OIDCServer) *config.Config {
|
||||
MailCredentialKeys: "v1:MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=",
|
||||
MailActiveCredentialKeyID: "v1",
|
||||
MailWebhookSharedSecret: "test-webhook-secret",
|
||||
MailAppURL: "http://localhost:3000",
|
||||
MailAppURL: "http://localhost:3004",
|
||||
SearchEngine: "postgres",
|
||||
MeilisearchURL: env.MeilisearchURL,
|
||||
MeilisearchKey: env.MeilisearchKey,
|
||||
|
||||
@ -10,6 +10,16 @@
|
||||
"dependencies": {
|
||||
"@hocuspocus/server": "^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",
|
||||
"yjs": "^13.6.27"
|
||||
}
|
||||
|
||||
@ -14,6 +14,36 @@ 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/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':
|
||||
specifier: ^3.23.2
|
||||
version: 3.26.0
|
||||
@ -97,12 +127,22 @@ packages:
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-a+N/C4wkQV+/8x4ShdoiC2JdTW3Tw84C5cAloYLFMeaWmRa2me9ACSI+zo0SO9bbH9RJwsoRp7eaxBbk27eF1Q==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': 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':
|
||||
resolution: {integrity: sha512-s8oFpH+0xmhvY19f452/2dExO3p1tjxh761g6cg4irwEUNUEAJKF2VLcjiaeOhNJ+pmnQYxb+VSkwkXvO+7vHQ==}
|
||||
peerDependencies:
|
||||
@ -145,6 +185,37 @@ packages:
|
||||
peerDependencies:
|
||||
'@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':
|
||||
resolution: {integrity: sha512-yZXdevp3/8omGbb40Z52VfvID+tsRNhPQ1GNUToD56XSr2BjdJyAzAb9rWGgDKgVMUPLgJ26yT0O278RFqOKhA==}
|
||||
peerDependencies:
|
||||
@ -335,11 +406,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@tiptap/core': 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))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
|
||||
@ -375,6 +454,31 @@ snapshots:
|
||||
dependencies:
|
||||
'@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))':
|
||||
dependencies:
|
||||
'@tiptap/core': 3.26.0(@tiptap/pm@3.26.0)
|
||||
|
||||
@ -6,6 +6,28 @@ import { createRequire } from "node:module"
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
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 SECRET = process.env.HOCUSPOCUS_SECRET || ""
|
||||
@ -26,6 +48,19 @@ function hookContext(payload) {
|
||||
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) {
|
||||
if (!context?.path || !context?.user) return null
|
||||
const params = new URLSearchParams({ user: context.user, path: context.path })
|
||||
@ -38,13 +73,14 @@ 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 ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions)
|
||||
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
||||
}
|
||||
if (doc.yjsState) {
|
||||
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) {
|
||||
console.error("[onLoadDocument] parse", err)
|
||||
}
|
||||
@ -58,7 +94,7 @@ async function storeToUltid(context, document) {
|
||||
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
|
||||
let tiptapJson = null
|
||||
try {
|
||||
tiptapJson = TiptapTransformer.fromYdoc(document, "default")
|
||||
tiptapJson = TiptapTransformer.fromYdoc(document, "default", transformerExtensions)
|
||||
} catch {
|
||||
/* optional */
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user