diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index b34bcf2..16e57f4 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "log" "os" @@ -22,6 +23,7 @@ import ( "verstak/internal/core/plugins" "verstak/internal/core/search" "verstak/internal/core/storage" + "verstak/internal/core/util" syncsvc "verstak/internal/core/sync" "verstak/internal/core/worklog" ) @@ -60,6 +62,7 @@ func (a *App) autoSyncLoop() { ticker := time.NewTicker(checkInterval) defer ticker.Stop() log.Printf("[autosync] started, vault=%s", a.vault) + var lastSync time.Time for { select { case <-ticker.C: @@ -74,21 +77,27 @@ func (a *App) autoSyncLoop() { serverURL = sURL } if serverURL == "" { - log.Printf("[autosync] no server URL") continue } - if cfg != nil && cfg.Sync.SyncInterval <= 0 { - log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval) + interval := 0 + if cfg != nil { + interval = cfg.Sync.SyncInterval + } + if interval <= 0 { + continue + } + if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute { continue } deviceToken := config.LoadDeviceToken(a.vault) if deviceToken == "" { - log.Printf("[autosync] no device token") continue } log.Printf("[autosync] running SyncNow...") if _, err := a.SyncNow(); err != nil { log.Printf("[autosync] SyncNow error: %v", err) + } else { + lastSync = time.Now() } case <-a.ctx.Done(): log.Printf("[autosync] stopped") @@ -433,11 +442,68 @@ func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, e return nil, err } _ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "") - _ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title}) + _ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, nodePayload(n)) dto := toNodeDTO(n) return &dto, nil } +func nodePayload(n *nodes.Node) map[string]interface{} { + pid := "" + if n.ParentID != nil { + pid = *n.ParentID + } + return map[string]interface{}{ + "id": n.ID, + "parent_id": pid, + "type": n.Type, + "title": n.Title, + "slug": n.Slug, + "section": n.Section, + "sort_order": n.SortOrder, + "created_at": n.CreatedAt.Format(time.RFC3339), + "updated_at": n.UpdatedAt.Format(time.RFC3339), + } +} + +func (a *App) filePayload(n *nodes.Node) map[string]interface{} { + p := map[string]interface{}{ + "node_id": n.ID, + "type": n.Type, + "title": n.Title, + "slug": n.Slug, + "created_at": n.CreatedAt.Format(time.RFC3339), + "updated_at": n.UpdatedAt.Format(time.RFC3339), + } + if n.ParentID != nil { + p["parent_id"] = *n.ParentID + } + // Look up the linked file record, if any. + if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 { + rec := recs[0] + p["filename"] = rec.Filename + p["path"] = rec.Path + p["storage_mode"] = rec.StorageMode + p["size"] = rec.Size + p["sha256"] = rec.SHA256 + p["mime"] = rec.MIME + p["file_id"] = rec.ID + // Compute blob SHA-256 for vault files. + if rec.StorageMode == "vault" { + if rec.SHA256 != "" { + p["blob_sha256"] = rec.SHA256 + } else { + absPath := filepath.Join(a.vault, rec.Path) + if hash, err := syncsvc.HashFile(absPath); err == nil { + p["blob_sha256"] = hash + } + } + } + } else { + p["filename"] = n.Title + } + return p +} + func (a *App) DeleteNode(id string) error { return a.nodes.SoftDelete(id) } @@ -523,16 +589,30 @@ func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) { // CreateNote creates a note under a parent node. func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) { - node, _, err := a.notes.Create(parentID, title, "") + node, fileRec, err := a.notes.Create(parentID, title, "") if err != nil { return nil, err } + content, _ := a.notes.Read(node.ID) _ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "") - _ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title}) + _ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content)) dto := toNodeDTO(node) return &dto, nil } +func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} { + return map[string]interface{}{ + "node_id": node.ID, + "file_id": fileRec.ID, + "format": "markdown", + "content": content, + "filename": fileRec.Filename, + "path": fileRec.Path, + "created_at": node.CreatedAt.Format(time.RFC3339), + "updated_at": node.UpdatedAt.Format(time.RFC3339), + } +} + // ReadNote reads note content. func (a *App) ReadNote(noteID string) (string, error) { return a.notes.Read(noteID) @@ -550,7 +630,11 @@ func (a *App) SaveNote(noteID, content string) error { pid = *n.ParentID } _ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "") - _ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title}) + _ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]interface{}{ + "node_id": noteID, + "content": content, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) } return nil } @@ -623,7 +707,7 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) { } for _, n := range nodes { _ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) - _ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title}) + _ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n)) } return toNodeDTOs(nodes), nil } @@ -635,7 +719,7 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) { } for _, n := range nodes { _ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) - _ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title}) + _ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, a.filePayload(&n)) } return toNodeDTOs(nodes), nil } @@ -669,7 +753,7 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) { return nil, err } _ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "") - _ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename}) + _ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node)) dto := toNodeDTO(node) return &dto, nil } @@ -686,7 +770,7 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) { pid = *n.ParentID } _ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "") - _ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title}) + _ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node)) dto := toNodeDTO(node) return &dto, nil } @@ -715,7 +799,10 @@ func (a *App) RenameNode(nodeID, newTitle string) error { if n.Type == nodes.TypeFolder { syncEntity = syncsvc.EntityFolder } - _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle}) + _ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{ + "title": newTitle, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) return nil } @@ -751,7 +838,10 @@ func (a *App) MoveNode(nodeID, newParentID string) error { pid = *node.ParentID } _ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`) - _ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title}) + _ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]interface{}{ + "parent_id": newParentID, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) return nil } @@ -790,7 +880,7 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) if err != nil { return nil, err } - _ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind}) + _ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec)) return &ActionDTO{ ID: rec.ID, NodeID: rec.NodeID, @@ -800,6 +890,23 @@ func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) }, nil } +func actionPayload(rec *actions.Record) map[string]interface{} { + return map[string]interface{}{ + "id": rec.ID, + "node_id": rec.NodeID, + "title": rec.Title, + "kind": rec.Kind, + "command": rec.Command, + "args": rec.Args, + "working_dir": rec.WorkingDir, + "url": rec.URL, + "confirm_required": rec.ConfirmRequired, + "capture_output": rec.CaptureOutput, + "created_at": rec.CreatedAt.Format(time.RFC3339), + "updated_at": rec.UpdatedAt.Format(time.RFC3339), + } +} + func (a *App) DeleteAction(id string) error { _ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil) return a.actions.Delete(id) @@ -841,7 +948,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e if err != nil { return nil, err } - _ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary}) + _ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry)) mins := 0 if entry.Minutes != nil { mins = *entry.Minutes @@ -856,6 +963,32 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e return dto, nil } +func worklogPayload(entry *worklog.Entry) map[string]interface{} { + mins := 0 + if entry.Minutes != nil { + mins = *entry.Minutes + } + p := map[string]interface{}{ + "id": entry.ID, + "node_id": entry.NodeID, + "summary": entry.Summary, + "details": entry.Details, + "minutes": mins, + "date": entry.Date, + "approximate": entry.Approximate, + "billable": entry.Billable, + "created_at": entry.CreatedAt.Format(time.RFC3339), + "updated_at": entry.UpdatedAt.Format(time.RFC3339), + } + if entry.StartedAt != nil { + p["started_at"] = entry.StartedAt.Format(time.RFC3339) + } + if entry.EndedAt != nil { + p["ended_at"] = entry.EndedAt.Format(time.RFC3339) + } + return p +} + // ============================================================ // Search // ============================================================ @@ -988,9 +1121,9 @@ func (a *App) SyncDisconnect() error { } func (a *App) SyncTestConnection(serverURL, username, password string) error { + // Use a dedicated auth test that does NOT create a device. client := syncsvc.NewClient(serverURL, "", "", a.vault) - _, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2") - return err + return client.TestAuth(serverURL, username, password) } func (a *App) SyncSetInterval(minutes int) error { @@ -1027,11 +1160,14 @@ func (a *App) SyncNow() (map[string]interface{}, error) { client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault) client.DeviceToken = deviceToken - // Push unpushed ops. + // Push unpushed ops — set last_seen_server_seq on each. unpushed, err := a.sync.GetUnpushedOps() if err != nil { return nil, fmt.Errorf("get ops: %w", err) } + for i := range unpushed { + unpushed[i].LastSeenServerSeq = lastPullSeq + } pushResult := &syncsvc.PushResponse{} if len(unpushed) > 0 { pushResult, err = client.Push(unpushed) @@ -1049,11 +1185,15 @@ func (a *App) SyncNow() (map[string]interface{}, error) { return nil, fmt.Errorf("pull: %w", err) } - if len(pullResult.Ops) > 0 { - // Apply pulled ops locally (record as remote ops, mark applied). - for _, op := range pullResult.Ops { - _ = a.sync.RecordRemoteOp(op) + // Apply each pulled op to the local vault. + var applyErrors []string + for _, op := range pullResult.Ops { + if err := a.applyRemoteOp(op); err != nil { + applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err)) } + _ = a.sync.RecordRemoteOp(op) + } + if len(pullResult.Ops) > 0 { opIDs := make([]string, len(pullResult.Ops)) for i, op := range pullResult.Ops { opIDs[i] = op.OpID @@ -1061,17 +1201,523 @@ func (a *App) SyncNow() (map[string]interface{}, error) { _ = a.sync.MarkApplied(opIDs) } + // Report conflicts. + if len(pushResult.Conflicts) > 0 { + log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts)) + for _, c := range pushResult.Conflicts { + log.Printf("[sync] conflict: op=%v entity=%v/%v", + c["op_id"], c["entity_type"], c["entity_id"]) + } + } + // Update sync state. if pullResult.ServerSequence > lastPullSeq { _ = a.sync.SetLastPullSeq(pullResult.ServerSequence) } _ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339)) - return map[string]interface{}{ + result := map[string]interface{}{ "pushed": len(pushResult.Accepted), "pulled": len(pullResult.Ops), "serverSequence": pullResult.ServerSequence, - }, nil + } + if len(applyErrors) > 0 { + result["applyErrors"] = applyErrors + } + if len(pushResult.Conflicts) > 0 { + result["conflicts"] = pushResult.Conflicts + } + return result, nil +} + +// 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 // unknown entity type, skip silently +} + +// --- 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") + } + + // Check if node already exists (e.g., created by a prior file/note op). + if _, err := a.nodes.Get(payload.ID); err == nil { + return nil // already exists + } + + // Insert directly (bypass slug uniqueness / validation for remote ops). + 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,section,sort_order,created_at,updated_at,revision,device_id) + VALUES (?,?,?,?,?,?,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 + } + // No title = just touch. + _, 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) + + // Ensure the parent node exists (create a placeholder if not). + 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,created_at,updated_at,revision) + VALUES (?,'note','remote-note',?,?,?,1)`, + payload.NodeID, slug, now, now) + if e != nil { + return e + } + } + + // Write the .md file. + dest := filepath.Join(a.vault, payload.Path) + if payload.Path == "" { + filename := payload.Filename + if filename == "" { + filename = payload.NodeID[:8] + ".md" + } + dest = filepath.Join(a.vault, "spaces", filename) + payload.Path, _ = filepath.Rel(a.vault, dest) + } + 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() + } + + // Create file record. + 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 + } + + // Create notes link. + 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 + } + + // Find the note's file path. + 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) + } + + var abs string + if storageMode == "vault" { + abs = filepath.Join(a.vault, filePath) + } else { + abs = filePath + } + 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 +} + +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) + + // Create the node if not exists. + 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 + } + } + + // Download blob if needed and not already present on disk. + 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) { + // Download from server. + 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) + } + } + + // Place file in vault. + dest := filepath.Join(a.vault, payload.Path) + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil { + // Copy blob to actual vault location. + input, rErr := os.ReadFile(blobPath) + if rErr == nil { + _ = os.WriteFile(dest, input, 0o640) + } + } + } + + // Create file record. + 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 +} + +// --- small helpers --- + +func jsonArgs(args []string) string { + if len(args) == 0 { + return "" + } + b, _ := json.Marshal(args) + return string(b) +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func strPtr(s string) interface{} { + if s == "" { + return nil + } + return s } // ============================================================