verstak/cmd/verstak-gui/sync_apply.go

937 lines
28 KiB
Go

package main
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"time"
"verstak/internal/core/activity"
"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)
}
}
}
// If the node was created from a template, also create child nodes
// for any default_files and default_folders that were not already synced
// as individual ops (backward compatibility with devices that do not
// sync template children).
_ = a.ensureTemplateChildren(payload.ID, payload.TemplateID, fsPath, payload.Title)
return nil
}
// ensureTemplateChildren creates child nodes for a template's default files
// and folders if they don't already exist. This handles backward compatibility
// with devices that do not sync template children as individual ops.
func (a *App) ensureTemplateChildren(nodeID, templateID, parentFsPath, title string) error {
if templateID == "" {
return nil
}
tmpl, ok := a.templates.Get(templateID)
if !ok {
return nil
}
nowRFC := time.Now().UTC().Format(time.RFC3339)
if len(tmpl.DefaultFolders) == 0 && len(tmpl.DefaultFiles) == 0 {
return nil
}
// Check existing children to avoid duplicates.
existing, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return err
}
exists := make(map[string]bool, len(existing))
for i := range existing {
exists[existing[i].Title] = true
}
for _, folderName := range tmpl.DefaultFolders {
if exists[folderName] {
continue
}
folderSeg := templates.SafeDisplayNameToPathSegment(folderName)
if folderSeg == "" {
folderSeg = "folder"
}
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeFolder, folderName, 0, "", "")
if childErr != nil {
continue
}
childFsPath := folderSeg
if parentFsPath != "" {
childFsPath = filepath.Join(parentFsPath, folderSeg)
}
fullPath := filepath.Join(a.vault, childFsPath)
fullPath = templates.UniquePath(fullPath)
rel, _ := filepath.Rel(a.vault, fullPath)
childFsPath = rel
_ = a.nodes.UpdateFsPath(childNode.ID, childFsPath)
_ = os.MkdirAll(fullPath, 0o755)
_ = a.activity.Record(nodeID, activity.TargetFolder, childNode.ID, "", activity.TypeNodeCreated, folderName, "")
_ = a.sync.RecordOp(syncsvc.EntityFolder, childNode.ID, syncsvc.OpCreate, nodePayload(childNode))
}
for _, df := range tmpl.DefaultFiles {
fileTitle := strings.TrimSuffix(filepath.Base(df.Path), filepath.Ext(df.Path))
if fileTitle == "" {
fileTitle = "Overview"
}
if exists[fileTitle] {
continue
}
childNode, childErr := a.nodes.Create(&nodeID, nodes.TypeNote, fileTitle, 0, "", "")
if childErr != nil {
continue
}
content := fmt.Sprintf("# %s\n\n", title)
fpath := filepath.Join(a.vault, parentFsPath, df.Path)
_ = os.MkdirAll(filepath.Dir(fpath), 0o750)
if err := os.WriteFile(fpath, []byte(content), 0o640); err != nil {
_ = a.nodes.SoftDelete(childNode.ID)
continue
}
relPath, _ := filepath.Rel(a.vault, fpath)
fi, _ := os.Stat(fpath)
size := int64(0)
if fi != nil {
size = fi.Size()
}
fileID := util.UUID7()
_, _ = a.db.Exec(
`INSERT INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
VALUES (?,?,?,?,'vault',?,'','text/plain',?,?,0)`,
fileID, childNode.ID, filepath.Base(fpath), relPath, size, nowRFC, nowRFC)
_, _ = a.db.Exec(
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
childNode.ID, fileID, "markdown")
_ = a.activity.Record(nodeID, activity.TargetNote, childNode.ID, "", activity.TypeNoteCreated, fileTitle, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, childNode.ID, syncsvc.OpCreate, map[string]interface{}{
"node_id": childNode.ID,
"parent_id": nodeID,
"title": fileTitle,
"file_id": fileID,
"format": "markdown",
"content": content,
"filename": filepath.Base(fpath),
"path": relPath,
"created_at": nowRFC,
"updated_at": nowRFC,
})
}
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
}
n, err := a.nodes.Get(op.EntityID)
if err != nil {
return nil
}
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
// FS-first: rename folder on disk before touching DB
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
if err != nil {
return fmt.Errorf("unsafe fs_path in node update: %w", err)
}
payload.FsPath = cleanPath
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 {
return fmt.Errorf("rename folder for update %s -> %s: %w", oldPhys, newPhys, err)
}
}
}
// Any title/fs_path/template_id changes? Then do atomic DB transaction.
if payload.Title != "" || payload.FsPath != "" || payload.TemplateID != "" {
tx, err := a.db.Begin()
if err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if payload.Title != "" {
slug := nodes.Slugify(payload.Title)
if _, err := tx.Exec(
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
payload.Title, slug, now, op.EntityID); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if payload.FsPath != "" && isFolderLike {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
if n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if payload.TemplateID != "" {
if _, err := tx.Exec(
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
payload.TemplateID, now, op.EntityID); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
}
if err := tx.Commit(); err != nil {
if payload.FsPath != "" && isFolderLike && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("commit tx: %w", 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
if isFolderLike {
// Folder-like: FS-first rename, then DB transaction
if payload.FsPath != "" && n.FsPath != "" {
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.FsPath)
if err != nil {
return fmt.Errorf("unsafe fs_path in node move: %w", err)
}
payload.FsPath = cleanPath
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 {
return fmt.Errorf("move folder %s -> %s: %w", oldPhys, newPhys, err)
}
}
}
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
tx, err := a.db.Begin()
if err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, op.EntityID); err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return err
}
if payload.FsPath != "" && n.FsPath != "" {
if _, err := tx.Exec(
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
payload.FsPath, now, op.EntityID); err != nil {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
return err
}
}
if err := tx.Commit(); err != nil {
if payload.FsPath != "" && n.FsPath != "" {
_ = os.Rename(filepath.Join(a.vault, payload.FsPath), filepath.Join(a.vault, n.FsPath))
}
return fmt.Errorf("commit tx: %w", err)
}
return nil
}
// Note/file: FS-first move, then DB transaction
return a.moveNodeFiles(n, payload.ParentID, now)
}
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"`
ParentID string `json:"parent_id"`
Title string `json:"title"`
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)
title := payload.Title
if title == "" {
title = "remote-note"
}
slug := nodes.Slugify(title)
if _, err := a.nodes.Get(payload.NodeID); err != nil {
var parent interface{}
if payload.ParentID != "" {
parent = payload.ParentID
}
_, e := a.db.Exec(
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
VALUES (?,?,'note',?,?,'','',?,?,1)`,
payload.NodeID, parent, title, slug, now, now)
if e != nil {
return e
}
} else if payload.ParentID != "" {
// Update parent_id on existing node (e.g., created by old version without parent_id).
_, _ = a.db.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=? AND (parent_id IS NULL OR parent_id='')`,
payload.ParentID, now, payload.NodeID)
}
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,sha256,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) moveNodeFiles(n *nodes.Node, newParentID, now 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 string
oldPath string
oldAbs string
newRelPath string
newAbs 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.oldPath); err != nil {
continue
}
if fm.oldPath == "" {
continue
}
fm.oldAbs = filepath.Join(a.vault, fm.oldPath)
filename := filepath.Base(fm.oldPath)
fm.newRelPath = filename
if parentFsPath != "" {
fm.newRelPath = filepath.Join(parentFsPath, filename)
}
fm.newAbs = filepath.Join(a.vault, fm.newRelPath)
fileMoves = append(fileMoves, fm)
}
frows.Close()
}
if len(fileMoves) == 0 {
return nil
}
// FS-first: move all files (with rollback on partial failure)
for i, fm := range fileMoves {
if _, err := os.Stat(fm.oldAbs); err != nil {
for j := 0; j < i; j++ {
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
}
return fmt.Errorf("source file not found for move: %w", err)
}
_ = os.MkdirAll(filepath.Dir(fm.newAbs), 0o750)
if err := os.Rename(fm.oldAbs, fm.newAbs); err != nil {
for j := 0; j < i; j++ {
_ = os.Rename(fileMoves[j].newAbs, fileMoves[j].oldAbs)
}
return fmt.Errorf("move file %s -> %s: %w", fm.oldAbs, fm.newAbs, err)
}
}
// Atomic DB transaction: parent_id + file paths
tx, err := a.db.Begin()
if err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
var parent interface{}
if newParentID != "" {
parent = newParentID
}
if _, err := tx.Exec(
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
parent, now, n.ID); err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return err
}
for _, fm := range fileMoves {
if _, err := tx.Exec(`UPDATE files SET path=? WHERE id=?`,
fm.newRelPath, fm.id); err != nil {
for _, fm2 := range fileMoves {
_ = os.Rename(fm2.newAbs, fm2.oldAbs)
}
return err
}
}
if err := tx.Commit(); err != nil {
for _, fm := range fileMoves {
_ = os.Rename(fm.newAbs, fm.oldAbs)
}
return fmt.Errorf("commit tx: %w", err)
}
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
}
// FS-first move, then DB transaction (handled inside moveNodeFiles)
return a.moveNodeFiles(n, payload.ParentID, now)
}
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
}