package main import ( "context" "fmt" "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/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 activity *activity.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"` } type EventDTO struct { ID string `json:"id"` NodeID string `json:"nodeId"` ParentID string `json:"parentId"` EventType string `json:"eventType"` Title string `json:"title"` Metadata string `json:"metadata"` 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: "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. func (a *App) ListTodayView() (*TodayDashboardDTO, error) { start, end := activity.TodayBoundaries() // 1. Collect events from activity_events table. aeByParent, err := a.activity.ListTodayEventsByParent() if err != nil { aeByParent = nil } // 2. Query notes created/updated today directly from nodes. type noteRow struct { ID string ParentID string Title string CreatedAt string } var todayNotes []noteRow if r, err := a.db.Query(`SELECT n.id, COALESCE(n.parent_id,''), n.title, n.created_at FROM nodes n WHERE n.deleted_at IS NULL AND n.type='note' AND ((n.created_at >= ?1 AND n.created_at < ?2) OR (n.updated_at >= ?1 AND n.updated_at < ?2))`, start, end); err == nil { for r.Next() { var nr noteRow if err := r.Scan(&nr.ID, &nr.ParentID, &nr.Title, &nr.CreatedAt); err == nil { todayNotes = append(todayNotes, nr) } } r.Close() } // 3. Query files created today from files table. type fileRow struct { ID string NodeID string Filename string ParentID string CreatedAt string } var todayFiles []fileRow if r, err := a.db.Query(`SELECT f.id, f.node_id, f.filename, COALESCE(n.parent_id,''), f.created_at FROM files f JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL AND (f.created_at >= ?1 AND f.created_at < ?2)`, start, end); err == nil { for r.Next() { var fr fileRow if err := r.Scan(&fr.ID, &fr.NodeID, &fr.Filename, &fr.ParentID, &fr.CreatedAt); err == nil { todayFiles = append(todayFiles, fr) } } r.Close() } // Also include files updated today (but not created today). if r, err := a.db.Query(`SELECT f.id, f.node_id, f.filename, COALESCE(n.parent_id,''), f.updated_at FROM files f JOIN nodes n ON f.node_id = n.id WHERE n.deleted_at IS NULL AND f.updated_at >= ?1 AND f.updated_at < ?2 AND f.created_at < ?1`, start, end); err == nil { for r.Next() { var fr fileRow if err := r.Scan(&fr.ID, &fr.NodeID, &fr.Filename, &fr.ParentID, &fr.CreatedAt); err == nil { todayFiles = append(todayFiles, fr) } } r.Close() } // 4. Get root nodes that were created/updated today. todayNodes, _ := a.nodes.ListTodayNodes() // Build caseID → events map from all sources. type rawEvent struct { NodeID string ParentID string EventType string Title string CreatedAt string } type caseInfo struct { Node nodes.Node Events []rawEvent } caseMap := make(map[string]*caseInfo) // Helper: ensure case entry exists. 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, ParentID: pid, EventType: e.EventType, Title: e.Title, CreatedAt: e.CreatedAt, }) } } // Merge notes from direct query (avoid duplicates with ae). noteSeen := make(map[string]bool) for _, nr := range todayNotes { if noteSeen[nr.ID] { continue } noteSeen[nr.ID] = true caseID := nr.ParentID ci := ensureCase(caseID) ci.Events = append(ci.Events, rawEvent{ NodeID: nr.ID, ParentID: caseID, EventType: activity.TypeNoteCreated, Title: nr.Title, CreatedAt: nr.CreatedAt, }) } // Merge files. fileSeen := make(map[string]bool) for _, fr := range todayFiles { if fileSeen[fr.ID] { continue } fileSeen[fr.ID] = true caseID := fr.ParentID ci := ensureCase(caseID) ci.Events = append(ci.Events, rawEvent{ NodeID: fr.NodeID, ParentID: caseID, EventType: activity.TypeFileAdded, Title: fr.Filename, CreatedAt: fr.CreatedAt, }) } // Merge today's root nodes (even without events). for _, n := range todayNodes { _ = ensureCase(n.ID) if ci := caseMap[n.ID]; ci.Node.ID == "" { ci.Node = n } } // Build final groups and flat timeline. 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 { et := re.EventType dtoEvents = append(dtoEvents, EventDTO{ ID: ci.Node.ID + "/" + re.NodeID, NodeID: re.NodeID, ParentID: re.ParentID, EventType: et, Title: re.Title, Metadata: "{}", CreatedAt: re.CreatedAt, }) switch et { 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) if len(dtoEvents) > 0 { 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 groups by lastActivityAt desc. sort.Slice(groups, func(i, j int) bool { return groups[i].LastActivityAt > groups[j].LastActivityAt }) // Sort flat events by createdAt desc. sort.Slice(flatEvents, func(i, j int) bool { return flatEvents[i].CreatedAt > flatEvents[j].CreatedAt }) dateStr := time.Now().Format("2006-01-02") return &TodayDashboardDTO{ Date: dateStr, Summary: summary, Groups: groups, Events: flatEvents, }, 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 } _ = a.activity.Record(n.ID, parentID, activity.TypeNodeCreated, title, "") 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 } _ = a.activity.Record(node.ID, parentID, activity.TypeNoteCreated, title, "") 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 { 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(noteID, pid, activity.TypeNoteUpdated, n.Title, "") } 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(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) } 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(n.ID, nodeID, activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`) } 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 if n.Type == nodes.TypeFolder { evType = activity.TypeFolderDeleted } _ = a.activity.Record(nodeID, pid, evType, n.Title, "") } 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(node.ID, parentID, activity.TypeFileAdded, filename, "") 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(node.ID, pid, activity.TypeFileCopied, node.Title, "") 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 if n.Type == nodes.TypeFolder { evType = activity.TypeFolderRenamed } _ = a.activity.Record(nodeID, pid, evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`) 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(nodeID, pid, activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`) 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) 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 }