docxi import lol
This commit is contained in:
parent
d02ec4afd9
commit
20c4fef3c6
@ -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
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -104,7 +104,7 @@ Un seul **nginx** expose l’entré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).
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 != "" {
|
||||||
|
|||||||
@ -11,12 +11,13 @@ const schemaVersion = 1
|
|||||||
|
|
||||||
// UltiDoc is the canonical on-disk format for TipTap documents.
|
// UltiDoc is the canonical on-disk format for TipTap documents.
|
||||||
type UltiDoc struct {
|
type UltiDoc struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
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"`
|
||||||
YjsState string `json:"yjsState,omitempty"`
|
PageSetup *UltiDocPageSetup `json:"pageSetup,omitempty"`
|
||||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
YjsState string `json:"yjsState,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UltiDocSource struct {
|
type UltiDocSource struct {
|
||||||
@ -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 {
|
||||||
|
|||||||
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 {
|
type saveRequest struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Document json.RawMessage `json:"document"`
|
Document json.RawMessage `json:"document"`
|
||||||
YjsState string `json:"yjsState,omitempty"`
|
PageSetup json.RawMessage `json:"pageSetup,omitempty"`
|
||||||
|
YjsState string `json:"yjsState,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Save(w http.ResponseWriter, r *http.Request) {
|
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 {
|
if err := apivalidate.DecodeJSON(w, r, 32<<20, &req); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doc := NewUltiDoc(req.Document, nil)
|
path := normalizePath(req.Path)
|
||||||
doc.YjsState = req.YjsState
|
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()
|
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 {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -34,14 +34,15 @@ func (s *Service) EnsureNextcloudUser(ctx context.Context, claims *auth.Claims)
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionResult struct {
|
type SessionResult struct {
|
||||||
RoomID string `json:"roomId"`
|
RoomID string `json:"roomId"`
|
||||||
CanonicalPath string `json:"canonicalPath"`
|
CanonicalPath string `json:"canonicalPath"`
|
||||||
SourcePath string `json:"sourcePath,omitempty"`
|
SourcePath string `json:"sourcePath,omitempty"`
|
||||||
WsURL string `json:"wsUrl"`
|
WsURL string `json:"wsUrl"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 */
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user