1043 lines
28 KiB
Go
1043 lines
28 KiB
Go
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/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"
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ============================================================
|
|
// 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, map[string]string{"title": title})
|
|
dto := toNodeDTO(n)
|
|
return &dto, nil
|
|
}
|
|
|
|
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, _, err := a.notes.Create(parentID, title, "")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
|
|
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": 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(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
|
|
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": 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(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
|
|
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
|
|
}
|
|
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, map[string]string{"title": n.Title})
|
|
}
|
|
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, map[string]string{"title": 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(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
|
|
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": 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
|
|
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]string{"title": 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(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
|
|
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
|
|
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, map[string]string{"title": rec.Title, "kind": rec.Kind})
|
|
return &ActionDTO{
|
|
ID: rec.ID,
|
|
NodeID: rec.NodeID,
|
|
Title: rec.Title,
|
|
Type: rec.Kind,
|
|
Data: data,
|
|
}, nil
|
|
}
|
|
|
|
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, map[string]string{"summary": summary})
|
|
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
|
|
}
|
|
|
|
// ============================================================
|
|
// Sync
|
|
// ============================================================
|
|
|
|
type SyncStatusDTO struct {
|
|
Configured bool `json:"configured"`
|
|
ServerURL string `json:"serverUrl"`
|
|
DeviceID string `json:"deviceId"`
|
|
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
|
|
}
|
|
unpushed, _ := a.sync.GetUnpushedOps()
|
|
cfg, _ := config.Load(a.vault)
|
|
dto := &SyncStatusDTO{
|
|
Configured: serverURL != "" && apiKey != "",
|
|
ServerURL: serverURL,
|
|
UnpushedOps: len(unpushed),
|
|
LastSyncAt: lastSyncAt,
|
|
}
|
|
if cfg != nil {
|
|
dto.DeviceID = cfg.Sync.DeviceID
|
|
dto.SyncInterval = cfg.Sync.SyncInterval
|
|
}
|
|
return dto, nil
|
|
}
|
|
|
|
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
|
// Register device on server with user credentials.
|
|
hostname, _ := os.Hostname()
|
|
if hostname == "" {
|
|
hostname = "unknown"
|
|
}
|
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
|
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
|
|
if err != nil {
|
|
return fmt.Errorf("register: %w", err)
|
|
}
|
|
|
|
if err := a.sync.SetState(serverURL, apiKey); err != nil {
|
|
return err
|
|
}
|
|
// Persist to vault config.
|
|
cfg, err := config.Load(a.vault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Sync.ServerURL = serverURL
|
|
cfg.Sync.APIKey = apiKey
|
|
cfg.Sync.DeviceID = deviceID
|
|
return config.Save(a.vault, cfg)
|
|
}
|
|
|
|
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
|
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
|
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
|
|
return err
|
|
}
|
|
|
|
func (a *App) SyncSetInterval(minutes int) error {
|
|
cfg, err := config.Load(a.vault)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg.Sync.SyncInterval = minutes
|
|
return config.Save(a.vault, cfg)
|
|
}
|
|
|
|
func (a *App) SyncNow() (map[string]interface{}, error) {
|
|
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
|
|
if err != nil || serverURL == "" || apiKey == "" {
|
|
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)
|
|
|
|
// Push unpushed ops.
|
|
unpushed, err := a.sync.GetUnpushedOps()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get ops: %w", err)
|
|
}
|
|
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(lastRev)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pull: %w", err)
|
|
}
|
|
|
|
return map[string]interface{}{
|
|
"pushed": len(pushResult.Accepted),
|
|
"pulled": len(pullResult.Ops),
|
|
"serverRevision": pullResult.ServerRevision,
|
|
}, 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
|
|
}
|
|
|
|
|