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 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) } }) } // ============================================================ // 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"` } // ============================================================ // 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 } // ListTodayView returns nodes created or updated today — a dynamic day view. func (a *App) ListTodayView() ([]NodeDTO, error) { list, err := a.nodes.ListTodayNodes() 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) { 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 } 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 // ============================================================ // 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 } 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 } return toNodeDTOs(nodes), nil } func (a *App) DeleteFileOrFolder(nodeID string) error { 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 } 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 } dto := toNodeDTO(node) return &dto, nil } func (a *App) RenameNode(nodeID, newTitle string) error { return a.nodes.UpdateTitle(nodeID, newTitle) } 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 } } return a.nodes.Move(nodeID, newParentID, 0) } 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) 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) 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 }