verstak/cmd/verstak-gui/sync_apply.go

717 lines
20 KiB
Go

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/templates"
"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"`
TemplateID string `json:"template_id"`
FsPath string `json:"fs_path"`
Section string `json:"section"`
SortOrder int `json:"sort_order"`
Archived bool `json:"archived"`
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)
}
// Determine fs_path for folder-like nodes
fsPath := payload.FsPath
if fsPath == "" {
isFolderLike := payload.Type != "note" && payload.Type != "file"
if isFolderLike {
seg := templates.SafeDisplayNameToPathSegment(payload.Title)
if seg == "" {
seg = "node"
}
if payload.ParentID != "" {
if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" {
fsPath = filepath.Join(parent.FsPath, seg)
}
}
if fsPath == "" {
fsPath = filepath.Join(".verstak", "remote-inbox")
}
// Ensure unique path
fullPath := filepath.Join(a.vault, fsPath)
fullPath = templates.UniquePath(fullPath)
rel, _ := filepath.Rel(a.vault, fullPath)
fsPath = rel
}
}
archived := 0
if payload.Archived {
archived = 1
}
_, 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 (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`,
payload.ID, parent, payload.Type, payload.Title, slug,
payload.TemplateID, fsPath, section, payload.SortOrder, archived,
payload.CreatedAt, payload.UpdatedAt,
)
if err != nil {
return err
}
// Create physical folder for folder-like nodes
if fsPath != "" {
isFolderLike := payload.Type != "note" && payload.Type != "file"
if isFolderLike {
physPath := filepath.Join(a.vault, fsPath)
if err := os.MkdirAll(physPath, 0o755); err != nil {
log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err)
}
}
}
return nil
}
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
var payload struct {
Title string `json:"title"`
FsPath string `json:"fs_path"`
TemplateID string `json:"template_id"`
Archived *bool `json:"archived,omitempty"`
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
}
// Get current node to check if it's folder-like
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
if payload.Title != "" || payload.FsPath != "" {
if payload.Title != "" {
slug := nodes.Slugify(payload.Title)
if _, err := a.db.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
payload.Title, slug, now, op.EntityID); err != nil {
return err
}
}
if payload.FsPath != "" && isFolderLike {
oldFsPath := n.FsPath
if _, err := a.db.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
return err
}
// Physically rename folder if old path existed
if oldFsPath != "" {
oldPhys := filepath.Join(a.vault, oldFsPath)
newPhys := filepath.Join(a.vault, payload.FsPath)
if _, err := os.Stat(oldPhys); err == nil {
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
if err := os.Rename(oldPhys, newPhys); err != nil {
log.Printf("[sync] rename folder %s -> %s: %v", oldPhys, newPhys, err)
}
}
}
}
if payload.TemplateID != "" {
if _, err := a.db.Exec(
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
payload.TemplateID, now, op.EntityID); err != nil {
return err
}
}
return nil
}
if payload.Archived != nil {
v := 0
if *payload.Archived {
v = 1
}
_, err := a.db.Exec(
`UPDATE nodes SET archived=?, updated_at=? WHERE id=?`,
v, 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"`
FsPath string `json:"fs_path"`
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
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
// Update parent_id
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
if _, err := a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID); err != nil {
return err
}
if isFolderLike {
// Folder-like node: update fs_path and physically move directory
if payload.FsPath != "" && n.FsPath != "" {
oldPhys := filepath.Join(a.vault, n.FsPath)
newPhys := filepath.Join(a.vault, payload.FsPath)
if _, err := os.Stat(oldPhys); err == nil {
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
if err := os.Rename(oldPhys, newPhys); err != nil {
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
}
}
if _, err := a.db.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
return err
}
}
} else {
// Note/file: move physical file to new parent's directory
return a.moveNodeFiles(n, payload.ParentID)
}
return nil
}
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.OpMove:
return a.applyRemoteNoteMove(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"
}
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 = filepath.Join(".verstak", "remote-inbox")
}
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" {
clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
if err != nil {
return fmt.Errorf("unsafe vault path in note update: %w", err)
}
abs := filepath.Join(a.vault, clean)
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) applyRemoteNoteMove(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 note move: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if payload.UpdatedAt != "" {
now = payload.UpdatedAt
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
// Update parent_id
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
if _, err := a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID); err != nil {
return err
}
// Move physical file to new parent's directory
return a.moveNodeFiles(n, payload.ParentID)
}
func (a *App) moveNodeFiles(n *nodes.Node, newParentID string) error {
var parentFsPath string
if newParentID != "" {
parent, err := a.nodes.GetActive(newParentID)
if err == nil && parent.FsPath != "" {
parentFsPath = parent.FsPath
}
}
type fileMove struct {
id, path string
}
var fileMoves []fileMove
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
if ferr == nil {
for frows.Next() {
var fm fileMove
if err := frows.Scan(&fm.id, &fm.path); err != nil {
continue
}
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
for _, fm := range fileMoves {
if fm.path == "" {
continue
}
filename := filepath.Base(fm.path)
newRelPath := filename
if parentFsPath != "" {
newRelPath = filepath.Join(parentFsPath, filename)
}
oldAbs := filepath.Join(a.vault, fm.path)
newAbs := filepath.Join(a.vault, newRelPath)
if _, err := os.Stat(oldAbs); err == nil {
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
if err := os.Rename(oldAbs, newAbs); err == nil {
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
newRelPath, fm.id)
}
}
}
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
}