717 lines
20 KiB
Go
717 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"verstak/internal/core/config"
|
|
"verstak/internal/core/nodes"
|
|
syncsvc "verstak/internal/core/sync"
|
|
"verstak/internal/core/templates"
|
|
"verstak/internal/core/util"
|
|
)
|
|
|
|
// applyRemoteOp dispatches a remote sync operation to the correct entity handler.
|
|
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
|
switch op.EntityType {
|
|
case syncsvc.EntityNode:
|
|
return a.applyRemoteNodeOp(op)
|
|
case syncsvc.EntityNote:
|
|
return a.applyRemoteNoteOp(op)
|
|
case syncsvc.EntityFile, syncsvc.EntityFolder:
|
|
return a.applyRemoteFileOrFolderOp(op)
|
|
case syncsvc.EntityAction:
|
|
return a.applyRemoteActionOp(op)
|
|
case syncsvc.EntityWorklog:
|
|
return a.applyRemoteWorklogOp(op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- apply helpers ---
|
|
|
|
func (a *App) applyRemoteNodeOp(op syncsvc.Op) error {
|
|
switch op.OpType {
|
|
case syncsvc.OpCreate:
|
|
return a.applyRemoteNodeCreate(op)
|
|
case syncsvc.OpUpdate:
|
|
return a.applyRemoteNodeUpdate(op)
|
|
case syncsvc.OpMove:
|
|
return a.applyRemoteNodeMove(op)
|
|
case syncsvc.OpDelete:
|
|
return a.applyRemoteNodeDelete(op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
ID string `json:"id"`
|
|
ParentID string `json:"parent_id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
TemplateID string `json:"template_id"`
|
|
FsPath string `json:"fs_path"`
|
|
Section string `json:"section"`
|
|
SortOrder int `json:"sort_order"`
|
|
Archived bool `json:"archived"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal node create: %w", err)
|
|
}
|
|
if payload.ID == "" || payload.Type == "" || payload.Title == "" {
|
|
return fmt.Errorf("incomplete node payload")
|
|
}
|
|
|
|
if _, err := a.nodes.Get(payload.ID); err == nil {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if payload.CreatedAt == "" {
|
|
payload.CreatedAt = now
|
|
}
|
|
if payload.UpdatedAt == "" {
|
|
payload.UpdatedAt = now
|
|
}
|
|
var parent interface{}
|
|
if payload.ParentID != "" {
|
|
parent = payload.ParentID
|
|
}
|
|
var section interface{}
|
|
if payload.Section != "" {
|
|
section = payload.Section
|
|
}
|
|
slug := payload.Slug
|
|
if slug == "" {
|
|
slug = nodes.Slugify(payload.Title)
|
|
}
|
|
|
|
// Determine fs_path for folder-like nodes
|
|
fsPath := payload.FsPath
|
|
if fsPath == "" {
|
|
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
|
if isFolderLike {
|
|
seg := templates.SafeDisplayNameToPathSegment(payload.Title)
|
|
if seg == "" {
|
|
seg = "node"
|
|
}
|
|
if payload.ParentID != "" {
|
|
if parent, err := a.nodes.Get(payload.ParentID); err == nil && parent.FsPath != "" {
|
|
fsPath = filepath.Join(parent.FsPath, seg)
|
|
}
|
|
}
|
|
if fsPath == "" {
|
|
fsPath = filepath.Join(".verstak", "remote-inbox")
|
|
}
|
|
// Ensure unique path
|
|
fullPath := filepath.Join(a.vault, fsPath)
|
|
fullPath = templates.UniquePath(fullPath)
|
|
rel, _ := filepath.Rel(a.vault, fullPath)
|
|
fsPath = rel
|
|
}
|
|
}
|
|
|
|
archived := 0
|
|
if payload.Archived {
|
|
archived = 1
|
|
}
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,template_id,fs_path,section,sort_order,archived,created_at,updated_at,revision,device_id)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1,NULL)`,
|
|
payload.ID, parent, payload.Type, payload.Title, slug,
|
|
payload.TemplateID, fsPath, section, payload.SortOrder, archived,
|
|
payload.CreatedAt, payload.UpdatedAt,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create physical folder for folder-like nodes
|
|
if fsPath != "" {
|
|
isFolderLike := payload.Type != "note" && payload.Type != "file"
|
|
if isFolderLike {
|
|
physPath := filepath.Join(a.vault, fsPath)
|
|
if err := os.MkdirAll(physPath, 0o755); err != nil {
|
|
log.Printf("[sync] create folder for remote node %s: %v", payload.ID, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
Title string `json:"title"`
|
|
FsPath string `json:"fs_path"`
|
|
TemplateID string `json:"template_id"`
|
|
Archived *bool `json:"archived,omitempty"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal node update: %w", err)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if payload.UpdatedAt != "" {
|
|
now = payload.UpdatedAt
|
|
}
|
|
|
|
// Get current node to check if it's folder-like
|
|
n, err := a.nodes.Get(op.EntityID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
|
|
|
if payload.Title != "" || payload.FsPath != "" {
|
|
if payload.Title != "" {
|
|
slug := nodes.Slugify(payload.Title)
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`,
|
|
payload.Title, slug, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if payload.FsPath != "" && isFolderLike {
|
|
oldFsPath := n.FsPath
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
|
payload.FsPath, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Physically rename folder if old path existed
|
|
if oldFsPath != "" {
|
|
oldPhys := filepath.Join(a.vault, oldFsPath)
|
|
newPhys := filepath.Join(a.vault, payload.FsPath)
|
|
if _, err := os.Stat(oldPhys); err == nil {
|
|
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
|
if err := os.Rename(oldPhys, newPhys); err != nil {
|
|
log.Printf("[sync] rename folder %s -> %s: %v", oldPhys, newPhys, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if payload.TemplateID != "" {
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET template_id=?, updated_at=? WHERE id=?`,
|
|
payload.TemplateID, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if payload.Archived != nil {
|
|
v := 0
|
|
if *payload.Archived {
|
|
v = 1
|
|
}
|
|
_, err := a.db.Exec(
|
|
`UPDATE nodes SET archived=?, updated_at=? WHERE id=?`,
|
|
v, now, op.EntityID)
|
|
return err
|
|
}
|
|
|
|
_, err = a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyRemoteNodeMove(op syncsvc.Op) error {
|
|
var payload struct {
|
|
ParentID string `json:"parent_id"`
|
|
FsPath string `json:"fs_path"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal node move: %w", err)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if payload.UpdatedAt != "" {
|
|
now = payload.UpdatedAt
|
|
}
|
|
|
|
n, err := a.nodes.Get(op.EntityID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
isFolderLike := n.Type != nodes.TypeNote && n.Type != nodes.TypeFile
|
|
|
|
// Update parent_id
|
|
var parent interface{}
|
|
if payload.ParentID != "" {
|
|
parent = payload.ParentID
|
|
}
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
|
parent, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
|
|
if isFolderLike {
|
|
// Folder-like node: update fs_path and physically move directory
|
|
if payload.FsPath != "" && n.FsPath != "" {
|
|
oldPhys := filepath.Join(a.vault, n.FsPath)
|
|
newPhys := filepath.Join(a.vault, payload.FsPath)
|
|
if _, err := os.Stat(oldPhys); err == nil {
|
|
_ = os.MkdirAll(filepath.Dir(newPhys), 0o750)
|
|
if err := os.Rename(oldPhys, newPhys); err != nil {
|
|
log.Printf("[sync] move folder %s -> %s: %v", oldPhys, newPhys, err)
|
|
}
|
|
}
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET fs_path=?, updated_at=? WHERE id=?`,
|
|
payload.FsPath, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else {
|
|
// Note/file: move physical file to new parent's directory
|
|
return a.moveNodeFiles(n, payload.ParentID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error {
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, err := a.db.Exec(
|
|
`UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
|
|
now, now, op.EntityID)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyRemoteNoteOp(op syncsvc.Op) error {
|
|
switch op.OpType {
|
|
case syncsvc.OpCreate:
|
|
return a.applyRemoteNoteCreate(op)
|
|
case syncsvc.OpUpdate:
|
|
return a.applyRemoteNoteUpdate(op)
|
|
case syncsvc.OpMove:
|
|
return a.applyRemoteNoteMove(op)
|
|
case syncsvc.OpDelete:
|
|
return a.applyRemoteNodeDelete(op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
NodeID string `json:"node_id"`
|
|
FileID string `json:"file_id"`
|
|
Format string `json:"format"`
|
|
Content string `json:"content"`
|
|
Filename string `json:"filename"`
|
|
Path string `json:"path"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal note create: %w", err)
|
|
}
|
|
if payload.NodeID == "" {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
|
slug := nodes.Slugify("remote-note")
|
|
_, e := a.db.Exec(
|
|
`INSERT OR IGNORE INTO nodes (id,type,title,slug,template_id,fs_path,created_at,updated_at,revision)
|
|
VALUES (?,'note','remote-note',?,'','',?,?,1)`,
|
|
payload.NodeID, slug, now, now)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
|
|
var dest string
|
|
if payload.Path == "" {
|
|
filename := payload.Filename
|
|
if filename == "" {
|
|
filename = payload.NodeID[:8] + ".md"
|
|
}
|
|
parentFsPath := ""
|
|
if noteNode, err := a.nodes.Get(payload.NodeID); err == nil && noteNode.ParentID != nil {
|
|
if parent, err := a.nodes.GetActive(*noteNode.ParentID); err == nil {
|
|
parentFsPath = parent.FsPath
|
|
}
|
|
}
|
|
if parentFsPath == "" {
|
|
parentFsPath = filepath.Join(".verstak", "remote-inbox")
|
|
}
|
|
dest = filepath.Join(a.vault, parentFsPath, filename)
|
|
payload.Path, _ = filepath.Rel(a.vault, dest)
|
|
} else {
|
|
cleanPath, err := syncsvc.SafeVaultPath(a.vault, payload.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("unsafe path in %s: %w", op.EntityType, err)
|
|
}
|
|
dest = filepath.Join(a.vault, cleanPath)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil {
|
|
return err
|
|
}
|
|
info, _ := os.Stat(dest)
|
|
size := int64(0)
|
|
if info != nil {
|
|
size = info.Size()
|
|
}
|
|
|
|
fileID := payload.FileID
|
|
if fileID == "" {
|
|
fileID = util.UUID7()
|
|
}
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing)
|
|
VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`,
|
|
fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
format := payload.Format
|
|
if format == "" {
|
|
format = "markdown"
|
|
}
|
|
_, err = a.db.Exec(
|
|
`INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`,
|
|
payload.NodeID, fileID, format)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
NodeID string `json:"node_id"`
|
|
Content string `json:"content"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal note update: %w", err)
|
|
}
|
|
if payload.NodeID == "" {
|
|
return nil
|
|
}
|
|
|
|
var filePath, storageMode string
|
|
err := a.db.QueryRow(
|
|
`SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`,
|
|
payload.NodeID).Scan(&filePath, &storageMode)
|
|
if err != nil {
|
|
return fmt.Errorf("note record not found: %w", err)
|
|
}
|
|
|
|
if storageMode == "vault" {
|
|
clean, err := syncsvc.SafeVaultPath(a.vault, filePath)
|
|
if err != nil {
|
|
return fmt.Errorf("unsafe vault path in note update: %w", err)
|
|
}
|
|
abs := filepath.Join(a.vault, clean)
|
|
if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil {
|
|
return err
|
|
}
|
|
info, _ := os.Stat(abs)
|
|
size := int64(0)
|
|
if info != nil {
|
|
size = info.Size()
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
_, e := a.db.Exec(
|
|
`UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`,
|
|
size, now, filePath, storageMode)
|
|
return e
|
|
}
|
|
log.Printf("applyRemoteNoteUpdate: skipping non-vault note update for node %s (mode=%s, path=%s)",
|
|
payload.NodeID, storageMode, filePath)
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteNoteMove(op syncsvc.Op) error {
|
|
var payload struct {
|
|
ParentID string `json:"parent_id"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal note move: %w", err)
|
|
}
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
if payload.UpdatedAt != "" {
|
|
now = payload.UpdatedAt
|
|
}
|
|
|
|
n, err := a.nodes.Get(op.EntityID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
// Update parent_id
|
|
var parent interface{}
|
|
if payload.ParentID != "" {
|
|
parent = payload.ParentID
|
|
}
|
|
if _, err := a.db.Exec(
|
|
`UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`,
|
|
parent, now, op.EntityID); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Move physical file to new parent's directory
|
|
return a.moveNodeFiles(n, payload.ParentID)
|
|
}
|
|
|
|
func (a *App) moveNodeFiles(n *nodes.Node, newParentID string) error {
|
|
var parentFsPath string
|
|
if newParentID != "" {
|
|
parent, err := a.nodes.GetActive(newParentID)
|
|
if err == nil && parent.FsPath != "" {
|
|
parentFsPath = parent.FsPath
|
|
}
|
|
}
|
|
|
|
type fileMove struct {
|
|
id, path string
|
|
}
|
|
var fileMoves []fileMove
|
|
frows, ferr := a.db.Query(`SELECT id, path FROM files WHERE node_id=?`, n.ID)
|
|
if ferr == nil {
|
|
for frows.Next() {
|
|
var fm fileMove
|
|
if err := frows.Scan(&fm.id, &fm.path); err != nil {
|
|
continue
|
|
}
|
|
fileMoves = append(fileMoves, fm)
|
|
}
|
|
frows.Close()
|
|
}
|
|
|
|
for _, fm := range fileMoves {
|
|
if fm.path == "" {
|
|
continue
|
|
}
|
|
filename := filepath.Base(fm.path)
|
|
newRelPath := filename
|
|
if parentFsPath != "" {
|
|
newRelPath = filepath.Join(parentFsPath, filename)
|
|
}
|
|
oldAbs := filepath.Join(a.vault, fm.path)
|
|
newAbs := filepath.Join(a.vault, newRelPath)
|
|
|
|
if _, err := os.Stat(oldAbs); err == nil {
|
|
_ = os.MkdirAll(filepath.Dir(newAbs), 0o750)
|
|
if err := os.Rename(oldAbs, newAbs); err == nil {
|
|
_, _ = a.db.Exec(`UPDATE files SET path=? WHERE id=?`,
|
|
newRelPath, fm.id)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error {
|
|
switch op.OpType {
|
|
case syncsvc.OpCreate:
|
|
return a.applyRemoteFileCreate(op)
|
|
case syncsvc.OpUpdate:
|
|
return a.applyRemoteNodeUpdate(op)
|
|
case syncsvc.OpMove:
|
|
return a.applyRemoteNodeMove(op)
|
|
case syncsvc.OpDelete:
|
|
return a.applyRemoteNodeDelete(op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteFileCreate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
NodeID string `json:"node_id"`
|
|
Type string `json:"type"`
|
|
Title string `json:"title"`
|
|
Slug string `json:"slug"`
|
|
ParentID string `json:"parent_id"`
|
|
Filename string `json:"filename"`
|
|
Path string `json:"path"`
|
|
StorageMode string `json:"storage_mode"`
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
MIME string `json:"mime"`
|
|
FileID string `json:"file_id"`
|
|
BlobSHA256 string `json:"blob_sha256"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal file create: %w", err)
|
|
}
|
|
if payload.NodeID == "" {
|
|
return nil
|
|
}
|
|
|
|
now := time.Now().UTC().Format(time.RFC3339)
|
|
|
|
if _, err := a.nodes.Get(payload.NodeID); err != nil {
|
|
slug := payload.Slug
|
|
if slug == "" {
|
|
slug = nodes.Slugify(payload.Title)
|
|
}
|
|
ntype := payload.Type
|
|
if ntype == "" {
|
|
ntype = "file"
|
|
}
|
|
var parent interface{}
|
|
if payload.ParentID != "" {
|
|
parent = payload.ParentID
|
|
}
|
|
_, e := a.db.Exec(
|
|
`INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision)
|
|
VALUES (?,?,?,?,?,?,?,1)`,
|
|
payload.NodeID, parent, ntype, payload.Title, slug, now, now)
|
|
if e != nil {
|
|
return e
|
|
}
|
|
}
|
|
|
|
if payload.BlobSHA256 != "" && payload.StorageMode == "vault" {
|
|
blobsDir := syncsvc.BlobDir(a.vault)
|
|
blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256)
|
|
if _, err := os.Stat(blobPath); os.IsNotExist(err) {
|
|
serverURL, apiKey, _, _, _ := a.sync.GetState()
|
|
deviceToken := config.LoadDeviceToken(a.vault)
|
|
cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault)
|
|
cli.DeviceToken = deviceToken
|
|
if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil {
|
|
log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err)
|
|
}
|
|
}
|
|
|
|
cleanPath, pathErr := syncsvc.SafeVaultPath(a.vault, payload.Path)
|
|
if pathErr != nil {
|
|
return fmt.Errorf("unsafe path in file: %w", pathErr)
|
|
}
|
|
dest := filepath.Join(a.vault, cleanPath)
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil {
|
|
input, rErr := os.ReadFile(blobPath)
|
|
if rErr == nil {
|
|
_ = os.WriteFile(dest, input, 0o640)
|
|
}
|
|
}
|
|
}
|
|
|
|
fileID := payload.FileID
|
|
if fileID == "" {
|
|
fileID = util.UUID7()
|
|
}
|
|
storageMode := payload.StorageMode
|
|
if storageMode == "" {
|
|
storageMode = "vault"
|
|
}
|
|
mime := payload.MIME
|
|
if mime == "" {
|
|
mime = "application/octet-stream"
|
|
}
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,0)`,
|
|
fileID, payload.NodeID, payload.Filename, payload.Path, storageMode,
|
|
payload.Size, payload.SHA256, mime, now, now)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyRemoteActionOp(op syncsvc.Op) error {
|
|
switch op.OpType {
|
|
case syncsvc.OpCreate:
|
|
return a.applyRemoteActionCreate(op)
|
|
case syncsvc.OpDelete:
|
|
_, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteActionCreate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
ID string `json:"id"`
|
|
NodeID string `json:"node_id"`
|
|
Title string `json:"title"`
|
|
Kind string `json:"kind"`
|
|
Command string `json:"command"`
|
|
Args []string `json:"args"`
|
|
WorkingDir string `json:"working_dir"`
|
|
URL string `json:"url"`
|
|
ConfirmRequired bool `json:"confirm_required"`
|
|
CaptureOutput bool `json:"capture_output"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal action create: %w", err)
|
|
}
|
|
if payload.ID == "" || payload.NodeID == "" {
|
|
return nil
|
|
}
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
payload.ID, payload.NodeID, payload.Title, payload.Kind,
|
|
payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL,
|
|
boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput),
|
|
payload.CreatedAt, payload.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error {
|
|
switch op.OpType {
|
|
case syncsvc.OpCreate:
|
|
return a.applyRemoteWorklogCreate(op)
|
|
case syncsvc.OpDelete:
|
|
_, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error {
|
|
var payload struct {
|
|
ID string `json:"id"`
|
|
NodeID string `json:"node_id"`
|
|
Summary string `json:"summary"`
|
|
Details string `json:"details"`
|
|
Minutes int `json:"minutes"`
|
|
Date string `json:"date"`
|
|
StartedAt string `json:"started_at"`
|
|
EndedAt string `json:"ended_at"`
|
|
Approximate bool `json:"approximate"`
|
|
Billable bool `json:"billable"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil {
|
|
return fmt.Errorf("unmarshal worklog create: %w", err)
|
|
}
|
|
if payload.ID == "" || payload.NodeID == "" {
|
|
return nil
|
|
}
|
|
_, err := a.db.Exec(
|
|
`INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt),
|
|
payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable),
|
|
payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt)
|
|
return err
|
|
}
|