package main import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" "verstak/internal/core/actions" "verstak/internal/core/files" "verstak/internal/core/notes" "verstak/internal/core/nodes" "verstak/internal/core/search" "verstak/internal/core/storage" "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 actions *actions.Service worklog *worklog.Service search *search.Service vault string } // startup is called when the app starts. Store context. func (a *App) startup(ctx context.Context) { a.ctx = ctx } // ============================================================ // 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 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"` } // ============================================================ // Sections // ============================================================ func (a *App) ListSections() []SectionDTO { return []SectionDTO{ {ID: "today", Label: "Сегодня"}, {ID: "inbox", 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 } 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) { n, err := a.nodes.Create(parentID, nodeType, title, section) if err != nil { return nil, err } dto := toNodeDTO(n) return &dto, nil } func (a *App) DeleteNode(id string) error { return a.nodes.SoftDelete(id) } // ============================================================ // 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, _, err := a.notes.Create(parentID, title, "") if err != nil { return nil, err } dto := toNodeDTO(node) return &dto, nil } // 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 { return a.notes.Save(noteID, content) } // ============================================================ // Files // ============================================================ 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 { isDir := records[i].MIME == "inode/directory" missing := false result[i] = FileDTO{ ID: records[i].ID, NodeID: records[i].NodeID, Name: records[i].Filename, Path: records[i].Path, Size: records[i].Size, Mime: records[i].MIME, IsDir: isDir, Missing: missing, } } return result, nil } // ============================================================ // 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) 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 } 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 } // ============================================================ // 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 } // ============================================================ // 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) 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 }