package main import ( "context" "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" 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) 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 == "" { log.Printf("[autosync] no server URL") continue } if cfg != nil && cfg.Sync.SyncInterval <= 0 { log.Printf("[autosync] interval=%d, skipping", cfg.Sync.SyncInterval) continue } deviceToken := config.LoadDeviceToken(a.vault) if deviceToken == "" { log.Printf("[autosync] no device token") continue } log.Printf("[autosync] running SyncNow...") if _, err := a.SyncNow(); err != nil { log.Printf("[autosync] SyncNow error: %v", err) } 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, 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"` 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 { client := syncsvc.NewClient(serverURL, "", "", a.vault) _, _, err := client.PairDevice(serverURL, username, password, "test-connection", "verstak-gui/v2") return err } 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. 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(lastPullSeq) if err != nil { return nil, fmt.Errorf("pull: %w", err) } if len(pullResult.Ops) > 0 { // Apply pulled ops locally (record as remote ops, mark applied). for _, op := range pullResult.Ops { _ = a.sync.RecordRemoteOp(op) } opIDs := make([]string, len(pullResult.Ops)) for i, op := range pullResult.Ops { opIDs[i] = op.OpID } _ = a.sync.MarkApplied(opIDs) } // Update sync state. if pullResult.ServerSequence > lastPullSeq { _ = a.sync.SetLastPullSeq(pullResult.ServerSequence) } _ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339)) return map[string]interface{}{ "pushed": len(pushResult.Accepted), "pulled": len(pullResult.Ops), "serverSequence": pullResult.ServerSequence, }, 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 }