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 }