package main import ( "encoding/json" "fmt" "log" "os" "path/filepath" "time" "verstak/internal/core/config" "verstak/internal/core/nodes" syncsvc "verstak/internal/core/sync" "verstak/internal/core/util" ) // applyRemoteOp dispatches a remote sync operation to the correct entity handler. func (a *App) applyRemoteOp(op syncsvc.Op) error { switch op.EntityType { case syncsvc.EntityNode: return a.applyRemoteNodeOp(op) case syncsvc.EntityNote: return a.applyRemoteNoteOp(op) case syncsvc.EntityFile, syncsvc.EntityFolder: return a.applyRemoteFileOrFolderOp(op) case syncsvc.EntityAction: return a.applyRemoteActionOp(op) case syncsvc.EntityWorklog: return a.applyRemoteWorklogOp(op) } return nil } // --- apply helpers --- func (a *App) applyRemoteNodeOp(op syncsvc.Op) error { switch op.OpType { case syncsvc.OpCreate: return a.applyRemoteNodeCreate(op) case syncsvc.OpUpdate: return a.applyRemoteNodeUpdate(op) case syncsvc.OpMove: return a.applyRemoteNodeMove(op) case syncsvc.OpDelete: return a.applyRemoteNodeDelete(op) } return nil } func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { var payload struct { ID string `json:"id"` ParentID string `json:"parent_id"` Type string `json:"type"` Title string `json:"title"` Slug string `json:"slug"` Section string `json:"section"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal node create: %w", err) } if payload.ID == "" || payload.Type == "" || payload.Title == "" { return fmt.Errorf("incomplete node payload") } if _, err := a.nodes.Get(payload.ID); err == nil { return nil } now := time.Now().UTC().Format(time.RFC3339) if payload.CreatedAt == "" { payload.CreatedAt = now } if payload.UpdatedAt == "" { payload.UpdatedAt = now } var parent interface{} if payload.ParentID != "" { parent = payload.ParentID } var section interface{} if payload.Section != "" { section = payload.Section } slug := payload.Slug if slug == "" { slug = nodes.Slugify(payload.Title) } _, err := a.db.Exec( `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id) VALUES (?,?,?,?,?,?,?,?,0,0,?,?,1,NULL)`, payload.ID, parent, payload.Type, payload.Title, slug, "", "", section, payload.CreatedAt, payload.UpdatedAt, ) return err } func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error { var payload struct { Title string `json:"title"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal node update: %w", err) } now := time.Now().UTC().Format(time.RFC3339) if payload.UpdatedAt != "" { now = payload.UpdatedAt } if payload.Title != "" { slug := nodes.Slugify(payload.Title) _, err := a.db.Exec( `UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`, payload.Title, slug, now, op.EntityID) return err } _, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID) return err } func (a *App) applyRemoteNodeMove(op syncsvc.Op) error { var payload struct { ParentID string `json:"parent_id"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal node move: %w", err) } now := time.Now().UTC().Format(time.RFC3339) if payload.UpdatedAt != "" { now = payload.UpdatedAt } var parent interface{} if payload.ParentID != "" { parent = payload.ParentID } _, err := a.db.Exec( `UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`, parent, now, op.EntityID) return err } func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error { now := time.Now().UTC().Format(time.RFC3339) _, err := a.db.Exec( `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, now, now, op.EntityID) return err } func (a *App) applyRemoteNoteOp(op syncsvc.Op) error { switch op.OpType { case syncsvc.OpCreate: return a.applyRemoteNoteCreate(op) case syncsvc.OpUpdate: return a.applyRemoteNoteUpdate(op) case syncsvc.OpDelete: return a.applyRemoteNodeDelete(op) } return nil } func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { var payload struct { NodeID string `json:"node_id"` FileID string `json:"file_id"` Format string `json:"format"` Content string `json:"content"` Filename string `json:"filename"` Path string `json:"path"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal note create: %w", err) } if payload.NodeID == "" { return nil } now := time.Now().UTC().Format(time.RFC3339) if _, err := a.nodes.Get(payload.NodeID); err != nil { slug := nodes.Slugify("remote-note") _, e := a.db.Exec( `INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision) VALUES (?,'note','remote-note',?,'','',?,?,1)`, payload.NodeID, slug, now, now) if e != nil { return e } } var dest string if payload.Path == "" { filename := payload.Filename if filename == "" { filename = payload.NodeID[:8] + ".md" } else { cleanFilename, err := syncsvc.SafeVaultPath(a.vault, filename) if err != nil { return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err) } filename = cleanFilename } parentFsPath := "" if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil { if parent, err := a.nodes.GetActive(*noteNode.ParentID); err == nil { parentFsPath = parent.FsPath } } if parentFsPath == "" { parentFsPath = "spaces" } dest = filepath.Join(a.vault, parentFsPath, filename) payload.Path, _ = filepath.Rel(a.vault, dest) } else { cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path) if err != nil { return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err) } dest = filepath.Join(a.vault, cleanPath) } if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { return err } if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil { return err } info, _ := os.Stat(dest) size := int64(0) if info != nil { size = info.Size() } fileID := payload.FileID if fileID == "" { fileID = util.UUID7() } _, err := a.db.Exec( `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing) VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`, fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now) if err != nil { return err } format := payload.Format if format == "" { format = "markdown" } _, err = a.db.Exec( `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, payload.NodeID, fileID, format) return err } func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error { var payload struct { NodeID string `json:"node_id"` Content string `json:"content"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal note update: %w", err) } if payload.NodeID == "" { return nil } var filePath, storageMode string err := a.db.QueryRow( `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`, payload.NodeID).Scan(&filePath, &storageMode) if err != nil { return fmt.Errorf("note record not found: %w", err) } if storageMode == "vault" { abs, err := syncsvc.SafeVaultPath(a.vault, filePath) if err != nil { return fmt.Errorf("unsafe vault path in note update: %w", err) } if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil { return err } info, _ := os.Stat(abs) size := int64(0) if info != nil { size = info.Size() } now := time.Now().UTC().Format(time.RFC3339) _, e := a.db.Exec( `UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`, size, now, filePath, storageMode) return e } log.Printf("applyRemoteNoteUpdate: skipping non-vault note update for node %s (mode=%s, path=%s)", payload.NodeID, storageMode, filePath) return nil } func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error { switch op.OpType { case syncsvc.OpCreate: return a.applyRemoteFileCreate(op) case syncsvc.OpUpdate: return a.applyRemoteNodeUpdate(op) case syncsvc.OpMove: return a.applyRemoteNodeMove(op) case syncsvc.OpDelete: return a.applyRemoteNodeDelete(op) } return nil } func (a *App) applyRemoteFileCreate(op syncsvc.Op) error { var payload struct { NodeID string `json:"node_id"` Type string `json:"type"` Title string `json:"title"` Slug string `json:"slug"` ParentID string `json:"parent_id"` Filename string `json:"filename"` Path string `json:"path"` StorageMode string `json:"storage_mode"` Size int64 `json:"size"` SHA256 string `json:"sha256"` MIME string `json:"mime"` FileID string `json:"file_id"` BlobSHA256 string `json:"blob_sha256"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal file create: %w", err) } if payload.NodeID == "" { return nil } now := time.Now().UTC().Format(time.RFC3339) if _, err := a.nodes.Get(payload.NodeID); err != nil { slug := payload.Slug if slug == "" { slug = nodes.Slugify(payload.Title) } ntype := payload.Type if ntype == "" { ntype = "file" } var parent interface{} if payload.ParentID != "" { parent = payload.ParentID } _, e := a.db.Exec( `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision) VALUES (?,?,?,?,?,?,?,1)`, payload.NodeID, parent, ntype, payload.Title, slug, now, now) if e != nil { return e } } if payload.BlobSHA256 != "" && payload.StorageMode == "vault" { blobsDir := syncsvc.BlobDir(a.vault) blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256) if _, err := os.Stat(blobPath); os.IsNotExist(err) { serverURL, apiKey, _, _, _ := a.sync.GetState() deviceToken := config.LoadDeviceToken(a.vault) cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault) cli.DeviceToken = deviceToken if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil { log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err) } } cleanPath, pathErr := syncsvc.SafeVaultPath(a.vault, payload.Path) if pathErr != nil { return fmt.Errorf("unsafe path in file: %w", pathErr) } dest := filepath.Join(a.vault, cleanPath) if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil { input, rErr := os.ReadFile(blobPath) if rErr == nil { _ = os.WriteFile(dest, input, 0o640) } } } fileID := payload.FileID if fileID == "" { fileID = util.UUID7() } storageMode := payload.StorageMode if storageMode == "" { storageMode = "vault" } mime := payload.MIME if mime == "" { mime = "application/octet-stream" } _, err := a.db.Exec( `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) VALUES (?,?,?,?,?,?,?,?,?,?,0)`, fileID, payload.NodeID, payload.Filename, payload.Path, storageMode, payload.Size, payload.SHA256, mime, now, now) return err } func (a *App) applyRemoteActionOp(op syncsvc.Op) error { switch op.OpType { case syncsvc.OpCreate: return a.applyRemoteActionCreate(op) case syncsvc.OpDelete: _, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID) return err } return nil } func (a *App) applyRemoteActionCreate(op syncsvc.Op) error { var payload struct { ID string `json:"id"` NodeID string `json:"node_id"` Title string `json:"title"` Kind string `json:"kind"` Command string `json:"command"` Args []string `json:"args"` WorkingDir string `json:"working_dir"` URL string `json:"url"` ConfirmRequired bool `json:"confirm_required"` CaptureOutput bool `json:"capture_output"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal action create: %w", err) } if payload.ID == "" || payload.NodeID == "" { return nil } _, err := a.db.Exec( `INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, payload.ID, payload.NodeID, payload.Title, payload.Kind, payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL, boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput), payload.CreatedAt, payload.UpdatedAt) return err } func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error { switch op.OpType { case syncsvc.OpCreate: return a.applyRemoteWorklogCreate(op) case syncsvc.OpDelete: _, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID) return err } return nil } func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error { var payload struct { ID string `json:"id"` NodeID string `json:"node_id"` Summary string `json:"summary"` Details string `json:"details"` Minutes int `json:"minutes"` Date string `json:"date"` StartedAt string `json:"started_at"` EndedAt string `json:"ended_at"` Approximate bool `json:"approximate"` Billable bool `json:"billable"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { return fmt.Errorf("unmarshal worklog create: %w", err) } if payload.ID == "" || payload.NodeID == "" { return nil } _, err := a.db.Exec( `INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt), payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable), payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt) return err }