verstak/cmd/verstak-gui/app.go

1811 lines
49 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"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"
)
// App is the Wails v2 application adapter. It wraps core services.
type App struct {
ctx context.Context
db *storage.DB
nodes *nodes.Repository
files *files.Service
notes *notes.Service
activity *activity.Service
actions *actions.Service
worklog *worklog.Service
search *search.Service
plugins *plugins.Manager
sync *syncsvc.Service
vault string
}
// startup is called when the app starts. Store context and wire drag-and-drop.
func (a *App) startup(ctx context.Context) {
a.ctx = ctx
wailsruntime.OnFileDrop(ctx, func(x, y int, paths []string) {
if len(paths) > 0 {
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
}
})
go a.autoSyncLoop()
}
func (a *App) autoSyncLoop() {
const checkInterval = 60 * time.Second
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
log.Printf("[autosync] started, vault=%s", a.vault)
var lastSync time.Time
for {
select {
case <-ticker.C:
serverURL := ""
cfg, err := config.Load(a.vault)
if err == nil {
serverURL = cfg.Sync.ServerURL
}
// Fall back to SQLite sync_state if config doesn't have it.
if serverURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
serverURL = sURL
}
if serverURL == "" {
continue
}
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 == "" {
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")
return
}
}
}
// ============================================================
// DTOs
// ============================================================
type NodeDTO struct {
ID string `json:"id"`
ParentID string `json:"parentId"`
Title string `json:"title"`
Type string `json:"type"`
Section string `json:"section"`
Path string `json:"path"`
CreatedAt string `json:"createdAt"`
}
type SectionDTO struct {
ID string `json:"id"`
Label string `json:"label"`
}
type NoteDTO struct {
ID string `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Format string `json:"format"`
CreatedAt string `json:"createdAt"`
}
type FileDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Name string `json:"name"`
Path string `json:"path"`
Size int64 `json:"size"`
Mime string `json:"mime"`
IsDir bool `json:"isDir"`
Missing bool `json:"missing"`
}
type FileTreeItemDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "folder" | "file"
FileID string `json:"fileId,omitempty"`
Size int64 `json:"size,omitempty"`
Mime string `json:"mime,omitempty"`
HasKids bool `json:"hasKids"`
}
type ActionDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Title string `json:"title"`
Type string `json:"type"`
Data string `json:"data"`
}
type WorklogDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
Summary string `json:"summary"`
Minutes int `json:"minutes"`
CreatedAt string `json:"createdAt"`
}
type SearchResultDTO struct {
NodeID string `json:"nodeId"`
Title string `json:"title"`
Snippet string `json:"snippet"`
Type string `json:"type"`
}
type EventDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
EventType string `json:"eventType"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetPath string `json:"targetPath"`
Title string `json:"title"`
DetailsJSON string `json:"detailsJson"`
CreatedAt string `json:"createdAt"`
}
type CaseActivityDTO struct {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
}
type SummaryDTO struct {
ChangedCases int `json:"changedCases"`
Notes int `json:"notes"`
Files int `json:"files"`
Actions int `json:"actions"`
TimeEntries int `json:"timeEntries"`
}
type TodayGroupDTO struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodeKind string `json:"nodeKind"`
Section string `json:"section"`
LastActivityAt string `json:"lastActivityAt"`
Events []EventDTO `json:"events"`
}
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
Events []EventDTO `json:"events"`
}
// ============================================================
// Sections
// ============================================================
func (a *App) ListSections() []SectionDTO {
return []SectionDTO{
{ID: "today", Label: "Сегодня"},
{ID: "inbox", Label: "Неразобранное"},
{ID: "activity", Label: "Активность"},
{ID: "clients", Label: "Клиенты"},
{ID: "projects", Label: "Проекты"},
{ID: "recipes", Label: "Рецепты"},
{ID: "documents", Label: "Документы"},
{ID: "archive", Label: "Архив"},
}
}
// ============================================================
// Nodes
// ============================================================
func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
list, err := a.nodes.ListRoots(false, section)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
// ListTodayView returns a dashboard of today's activity.
// For MVP this uses activity_events + root nodes changed today.
// Future: full Activity/Event Log system will be the single source of truth.
func (a *App) ListTodayView() (*TodayDashboardDTO, error) {
// Collect events from activity_events, grouped by parent node.
aeByParent, err := a.activity.ListTodayEventsByParent()
if err != nil {
aeByParent = nil
}
// Root nodes that were created/updated today.
todayNodes, _ := a.nodes.ListTodayNodes()
type rawEvent struct {
NodeID string
EventType string
TargetType string
TargetID string
TargetPath string
Title string
CreatedAt string
}
type caseInfo struct {
Node nodes.Node
Events []rawEvent
}
caseMap := make(map[string]*caseInfo)
ensureCase := func(caseID string) *caseInfo {
if ci, ok := caseMap[caseID]; ok {
return ci
}
ci := &caseInfo{Events: nil}
if n, err := a.nodes.GetActive(caseID); err == nil {
ci.Node = *n
}
caseMap[caseID] = ci
return ci
}
// Merge activity_events.
for pid, events := range aeByParent {
ci := ensureCase(pid)
for _, e := range events {
ci.Events = append(ci.Events, rawEvent{
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
CreatedAt: e.CreatedAt,
})
}
}
// Ensure all today's root nodes are present (even without events).
for _, n := range todayNodes {
_ = ensureCase(n.ID)
if ci := caseMap[n.ID]; ci.Node.ID == "" {
ci.Node = n
}
}
var groups []TodayGroupDTO
var flatEvents []EventDTO
summary := SummaryDTO{}
for _, ci := range caseMap {
if ci.Node.ID == "" {
continue
}
summary.ChangedCases++
dtoEvents := make([]EventDTO, 0, len(ci.Events))
for _, re := range ci.Events {
dtoEvents = append(dtoEvents, EventDTO{
ID: ci.Node.ID + "/" + re.NodeID + "/" + re.CreatedAt,
NodeID: re.NodeID,
EventType: re.EventType,
TargetType: re.TargetType,
TargetID: re.TargetID,
TargetPath: re.TargetPath,
Title: re.Title,
CreatedAt: re.CreatedAt,
})
switch re.EventType {
case activity.TypeNoteCreated, activity.TypeNoteUpdated:
summary.Notes++
case activity.TypeFileAdded, activity.TypeFileDeleted, activity.TypeFileRenamed, activity.TypeFileCopied, activity.TypeFileMoved:
summary.Files++
}
}
last := ci.Node.UpdatedAt.Format(time.RFC3339)
for _, e := range dtoEvents {
if e.CreatedAt > last {
last = e.CreatedAt
}
}
groups = append(groups, TodayGroupDTO{
NodeID: ci.Node.ID,
NodeTitle: ci.Node.Title,
NodeKind: ci.Node.Type,
Section: ci.Node.Section,
LastActivityAt: last,
Events: dtoEvents,
})
flatEvents = append(flatEvents, dtoEvents...)
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].LastActivityAt > groups[j].LastActivityAt
})
sort.Slice(flatEvents, func(i, j int) bool {
return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt
})
return &TodayDashboardDTO{
Date: time.Now().Format("2006-01-02"),
Summary: summary,
Groups: groups,
Events: flatEvents,
}, nil
}
func toEventDTO(e activity.Event) EventDTO {
return EventDTO{
ID: e.ID,
NodeID: e.NodeID,
EventType: e.EventType,
TargetType: e.TargetType,
TargetID: e.TargetID,
TargetPath: e.TargetPath,
Title: e.Title,
DetailsJSON: e.DetailsJSON,
CreatedAt: e.CreatedAt,
}
}
func (a *App) ListActivityFeed(limit, offset int) ([]EventDTO, error) {
events, err := a.activity.ListRecent(limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = toEventDTO(e)
}
return result, nil
}
func (a *App) ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error) {
events, err := a.activity.ListByNode(nodeID, limit, offset)
if err != nil {
return nil, err
}
result := make([]EventDTO, len(events))
for i, e := range events {
result[i] = toEventDTO(e)
}
return result, nil
}
func (a *App) CountActivityByNode(nodeID string) (int, error) {
return a.activity.CountByNode(nodeID)
}
func (a *App) ListChildren(parentID string) ([]NodeDTO, error) {
list, err := a.nodes.ListChildren(parentID, false)
if err != nil {
return nil, err
}
return toNodeDTOs(list), nil
}
func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
dto := toNodeDTO(n)
return &dto, nil
}
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
if section == "today" || section == "inbox" {
return nil, fmt.Errorf("cannot create node with section %q", section)
}
n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil {
return nil, err
}
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, 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)
}
// ============================================================
// Templates
// ============================================================
type TemplateDTO struct {
Name string `json:"name"`
Description string `json:"description"`
Icon string `json:"icon"`
}
func (a *App) ListTemplates() []TemplateDTO {
templates := a.plugins.Templates()
out := make([]TemplateDTO, 0, len(templates))
for _, t := range templates {
out = append(out, TemplateDTO{
Name: t.Name,
Description: t.Description,
Icon: t.Icon,
})
}
return out
}
func (a *App) FromTemplate(parentID, nodeType, title, section, template string) (*NodeDTO, error) {
var tmpl *plugins.TemplateDefinition
for _, t := range a.plugins.Templates() {
if t.Name == template {
tmpl = &t
break
}
}
if tmpl == nil {
return nil, fmt.Errorf("template %q not found", template)
}
root, err := a.nodes.Create(parentID, tmpl.RootType, title, section)
if err != nil {
return nil, err
}
var createTree func(parentID string, nodes []plugins.TreeNode) error
createTree = func(parentID string, nodes []plugins.TreeNode) error {
for _, tn := range nodes {
child, err := a.nodes.Create(parentID, tn.Type, tn.Title, "")
if err != nil {
return err
}
if len(tn.Children) > 0 {
if err := createTree(child.ID, tn.Children); err != nil {
return err
}
}
}
return nil
}
if err := createTree(root.ID, tmpl.Tree); err != nil {
return nil, err
}
dto := toNodeDTO(root)
return &dto, nil
}
// ============================================================
// Notes
// ============================================================
// ListNotes returns note-type children of a node.
func (a *App) ListNotes(nodeID string) ([]NodeDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
var result []NodeDTO
for i := range children {
if children[i].Type == nodes.TypeNote {
result = append(result, toNodeDTO(&children[i]))
}
}
return result, nil
}
// CreateNote creates a note under a parent node.
func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
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, 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)
}
// SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error {
if err := a.notes.Save(noteID, content); err != nil {
return err
}
// Record note_updated event.
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, 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
}
// ============================================================
// Files
// ============================================================
// ListFiles returns file records directly linked to a node (non-recursive).
func (a *App) ListFiles(nodeID string) ([]FileDTO, error) {
records, err := a.files.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]FileDTO, len(records))
for i := range records {
rec := &records[i]
result[i] = FileDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Name: rec.Filename,
Path: rec.Path,
Size: rec.Size,
Mime: rec.MIME,
IsDir: rec.MIME == "inode/directory",
Missing: rec.Missing,
}
}
return result, nil
}
// ListItems returns children of a node for the file tree view.
// Folders can be expanded; files include their file record info.
func (a *App) ListItems(nodeID string) ([]FileTreeItemDTO, error) {
children, err := a.nodes.ListChildren(nodeID, false)
if err != nil {
return nil, err
}
result := make([]FileTreeItemDTO, 0, len(children))
for i := range children {
if children[i].Type != nodes.TypeFolder && children[i].Type != nodes.TypeFile {
continue
}
item := FileTreeItemDTO{
ID: children[i].ID,
Name: children[i].Title,
Type: children[i].Type,
}
if children[i].Type == nodes.TypeFolder {
// Check if this folder has children
kids, _ := a.nodes.ListChildren(children[i].ID, false)
item.HasKids = len(kids) > 0
} else if children[i].Type == nodes.TypeFile {
records, _ := a.files.ListByNode(children[i].ID)
if len(records) > 0 {
item.FileID = records[0].ID
item.Size = records[0].Size
item.Mime = records[0].MIME
}
}
result = append(result, item)
}
return result, nil
}
func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathCopy(nodeID, sourcePath)
if err != nil {
return nil, err
}
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, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
nodes, err := a.files.AddPathLink(nodeID, sourcePath)
if err != nil {
return nil, err
}
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, a.filePayload(&n))
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
}
return a.files.DeleteNodeAndChildren(nodeID)
}
func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
node, err := a.files.CreateEmptyFile(parentID, filename)
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
node, err := a.files.Duplicate(nodeID)
if err != nil {
return nil, err
}
// Find parent for recording
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, a.filePayload(node))
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) RenameNode(nodeID, newTitle string) error {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
oldTitle := n.Title
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileRenamed
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderRenamed
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]interface{}{
"title": newTitle,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
// Check for name conflict at destination
destChildren, err := a.nodes.ListChildren(newParentID, false)
if err != nil {
return err
}
node, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
for i := range destChildren {
if destChildren[i].Title == node.Title {
// Conflict: auto-rename
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
return err
}
break
}
}
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
return err
}
pid := ""
if node.ParentID != nil {
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]interface{}{
"parent_id": newParentID,
"updated_at": time.Now().UTC().Format(time.RFC3339),
})
return nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
return a.files.PreviewImport(sourcePath)
}
// ============================================================
// Actions
// ============================================================
func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
list, err := a.actions.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]ActionDTO, len(list))
for i := range list {
data := list[i].Command
if list[i].URL != "" {
data = list[i].URL
}
result[i] = ActionDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Title: list[i].Title,
Type: list[i].Kind,
Data: data,
}
}
return result, nil
}
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, actionPayload(rec))
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Title: rec.Title,
Type: rec.Kind,
Data: data,
}, 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)
}
func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id)
return err
}
// ============================================================
// Worklog
// ============================================================
func (a *App) ListWorklog(nodeID string) ([]WorklogDTO, error) {
list, err := a.worklog.ListByNode(nodeID)
if err != nil {
return nil, err
}
result := make([]WorklogDTO, len(list))
for i := range list {
mins := 0
if list[i].Minutes != nil {
mins = *list[i].Minutes
}
result[i] = WorklogDTO{
ID: list[i].ID,
NodeID: list[i].NodeID,
Summary: list[i].Summary,
Minutes: mins,
CreatedAt: list[i].CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
return result, nil
}
func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error) {
entry, err := a.worklog.Add(nodeID, summary, "", minutes, false, false)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
}
dto := &WorklogDTO{
ID: entry.ID,
NodeID: entry.NodeID,
Summary: entry.Summary,
Minutes: mins,
CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
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
// ============================================================
func (a *App) Search(query string) ([]SearchResultDTO, error) {
if strings.TrimSpace(query) == "" {
return []SearchResultDTO{}, nil
}
results, err := a.search.Search(query)
if err != nil {
return nil, err
}
out := make([]SearchResultDTO, len(results))
for i, r := range results {
out[i] = SearchResultDTO{
NodeID: r.NodeID,
Title: r.Title,
Snippet: r.Snippet,
Type: r.Type,
}
}
return out, nil
}
// ============================================================
// Sync
// ============================================================
type SyncStatusDTO struct {
Configured bool `json:"configured"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
Connected bool `json:"connected"`
Revoked bool `json:"revoked"`
TokenStored bool `json:"tokenStored"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
cfg, _ := config.Load(a.vault)
deviceToken := config.LoadDeviceToken(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
ServerURL: serverURL,
LastSyncAt: lastSyncAt,
UnpushedOps: 0,
TokenStored: deviceToken != "",
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval
}
unpushed, _ := a.sync.GetUnpushedOps()
dto.UnpushedOps = len(unpushed)
if deviceToken != "" {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
client.DeviceToken = deviceToken
if cfg != nil {
client.DeviceID = cfg.Sync.DeviceID
}
if info, err := client.GetMe(); err == nil {
dto.DeviceName = info.DeviceName
dto.DeviceID = info.DeviceID
dto.Connected = true
if info.RevokedAt != "" {
dto.Revoked = true
dto.Connected = false
}
}
}
return dto, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-gui/v2")
if err != nil {
return fmt.Errorf("pair: %w", err)
}
// Save token to separate file with 0600 perms.
if err := config.SaveDeviceToken(a.vault, deviceToken); err != nil {
return fmt.Errorf("save token: %w", err)
}
if err := a.sync.SetState(serverURL, ""); err != nil {
return err
}
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.DeviceID = deviceID
cfg.Sync.APIKey = ""
return config.Save(a.vault, cfg)
}
func (a *App) SyncDisconnect() error {
deviceToken := config.LoadDeviceToken(a.vault)
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
}
// Revoke token on server if we have one.
if deviceToken != "" {
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
client.DeviceToken = deviceToken
_ = client.RevokeCurrent()
}
config.RemoveDeviceToken(a.vault)
cfg.Sync.ServerURL = ""
cfg.Sync.DeviceID = ""
cfg.Sync.APIKey = ""
if err := config.Save(a.vault, cfg); err != nil {
return err
}
return a.sync.SetState("", "")
}
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)
return client.TestAuth(serverURL, username, password)
}
func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault)
if err != nil {
cfg = &config.Config{}
}
// If config lost the server URL, restore from sync_state.
if cfg.Sync.ServerURL == "" {
sURL, _, _, _, _ := a.sync.GetState()
if sURL != "" {
cfg.Sync.ServerURL = sURL
}
}
if cfg.Sync.DeviceID == "" {
cfg.Sync.DeviceID = a.sync.GetDeviceID()
}
cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
serverURL, apiKey, lastPullSeq, _, err := a.sync.GetState()
deviceToken := config.LoadDeviceToken(a.vault)
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
return nil, fmt.Errorf("sync not configured")
}
deviceID := ""
if cfg, err := config.Load(a.vault); err == nil {
deviceID = cfg.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
client.DeviceToken = deviceToken
// 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)
if err != nil {
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
return nil, fmt.Errorf("mark pushed: %w", err)
}
}
// Pull remote ops.
pullResult, err := client.Pull(lastPullSeq)
if err != nil {
return nil, fmt.Errorf("pull: %w", err)
}
// 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
}
_ = 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))
result := map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverSequence": pullResult.ServerSequence,
}
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
}
// ============================================================
// File Dialogs (Wails v2 Runtime)
// ============================================================
func (a *App) PickFile() (string, error) {
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файл",
})
}
func (a *App) PickFiles() ([]string, error) {
return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите файлы",
})
}
func (a *App) PickDirectory() (string, error) {
return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{
Title: "Выберите папку",
})
}
// ============================================================
// System helpers
// ============================================================
func (a *App) OpenFile(fileID string) error {
return a.files.Open(fileID)
}
func (a *App) ReadFileText(fileID string) (string, error) {
return a.files.ReadText(fileID)
}
func (a *App) GetFileBase64(fileID string) (string, error) {
return a.files.ReadBase64(fileID)
}
func (a *App) OpenFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return fmt.Errorf("get node: %w", err)
}
dir := filepath.Join(a.vault, "spaces", n.Slug)
if _, err := os.Stat(dir); os.IsNotExist(err) {
dir = a.vault
}
cmd := exec.Command("xdg-open", dir)
return cmd.Run()
}
func (a *App) VerstakVersion() string {
return "verstak-gui/v2"
}
// ============================================================
// Helpers
// ============================================================
func toNodeDTO(n *nodes.Node) NodeDTO {
parentID := ""
if n.ParentID != nil {
parentID = *n.ParentID
}
path := ""
if n.Path != nil {
path = *n.Path
}
return NodeDTO{
ID: n.ID,
ParentID: parentID,
Title: n.Title,
Type: n.Type,
Section: n.Section,
Path: path,
CreatedAt: n.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}
func toNodeDTOs(list []nodes.Node) []NodeDTO {
result := make([]NodeDTO, len(list))
for i := range list {
result[i] = toNodeDTO(&list[i])
}
return result
}