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 }