fix(sync): expand payloads, implement ApplyRemoteOp, fix SyncTestConnection and auto sync
- Expand all RecordOp payloads with full entity data needed for reconstruction - Add applyRemoteOp with handlers for node/note/file/action/worklog CRUD - SyncNow() now applies pulled ops to real DB tables + sets LastSeenServerSeq - SyncTestConnection uses /api/auth/test instead of PairDevice - autoSyncLoop respects configured interval with lastSync timing - Add helper functions: nodePayload, notePayload, filePayload, actionPayload, worklogPayload
This commit is contained in:
parent
f8f9510e2a
commit
4a96aa3468
|
|
@ -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
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue