package main import ( "context" "encoding/json" "log" "path/filepath" "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/nodes" "verstak/internal/core/notes" "verstak/internal/core/plugins" "verstak/internal/core/search" "verstak/internal/core/storage" syncsvc "verstak/internal/core/sync" "verstak/internal/core/templates" "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 templates *templates.Registry 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) var lastSync time.Time for { select { case <-ticker.C: serverURL := "" cfg, err := config.Load(a.vault) if err == nil { serverURL = cfg.Sync.ServerURL } if serverURL == "" { sURL, _, _, _, _ := a.sync.GetState() serverURL = sURL } if serverURL == "" { continue } interval := 0 if cfg != nil { interval = cfg.Sync.SyncInterval } if interval <= 0 { continue } if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute { continue } deviceToken := config.LoadDeviceToken(a.vault) if deviceToken == "" { continue } log.Printf("[autosync] running SyncNow...") if _, err := a.SyncNow(); err != nil { log.Printf("[autosync] SyncNow error: %v", err) } else { lastSync = time.Now() } case <-a.ctx.Done(): log.Printf("[autosync] stopped") return } } } // ============================================================ // DTOs // ============================================================ type NodeDTO struct { ID string `json:"id"` ParentID *string `json:"parent_id,omitempty"` Type string `json:"type"` Title string `json:"title"` TemplateID string `json:"template_id"` FsPath string `json:"fs_path"` SortOrder int `json:"sort_order"` Archived bool `json:"archived"` HasChildren bool `json:"has_children"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type TemplateDTO struct { ID string `json:"id"` Title string `json:"title"` Type string `json:"type"` Icon string `json:"icon,omitempty"` } 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"` 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"` NodeTitle string `json:"nodeTitle,omitempty"` Summary string `json:"summary"` Minutes int `json:"minutes"` Date string `json:"date,omitempty"` Details string `json:"details,omitempty"` Approximate bool `json:"approximate"` Billable bool `json:"billable"` Source string `json:"source"` 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"` } // ============================================================ // Helpers // ============================================================ func toNodeDTO(n *nodes.Node) NodeDTO { return NodeDTO{ ID: n.ID, ParentID: n.ParentID, Type: n.Type, Title: n.Title, TemplateID: n.TemplateID, FsPath: n.FsPath, SortOrder: n.SortOrder, Archived: n.Archived, CreatedAt: n.CreatedAt.Format(time.RFC3339), UpdatedAt: n.UpdatedAt.Format(time.RFC3339), } } func toNodeDTOs(list []nodes.Node) []NodeDTO { result := make([]NodeDTO, len(list)) for i := range list { result[i] = toNodeDTO(&list[i]) } return result } 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 nodePayload(n *nodes.Node) map[string]interface{} { pid := "" if n.ParentID != nil { pid = *n.ParentID } return map[string]interface{}{ "id": n.ID, "parent_id": pid, "type": n.Type, "title": n.Title, "slug": n.Slug, "template_id": n.TemplateID, "fs_path": n.FsPath, "section": n.Section, "sort_order": n.SortOrder, "archived": n.Archived, "created_at": n.CreatedAt.Format(time.RFC3339), "updated_at": n.UpdatedAt.Format(time.RFC3339), } } func (a *App) filePayload(n *nodes.Node) map[string]interface{} { p := map[string]interface{}{ "node_id": n.ID, "type": n.Type, "title": n.Title, "slug": n.Slug, "created_at": n.CreatedAt.Format(time.RFC3339), "updated_at": n.UpdatedAt.Format(time.RFC3339), } if n.ParentID != nil { p["parent_id"] = *n.ParentID } if recs, err := a.files.ListByNode(n.ID); err == nil && len(recs) > 0 { rec := recs[0] p["filename"] = rec.Filename p["path"] = rec.Path p["storage_mode"] = rec.StorageMode p["size"] = rec.Size p["sha256"] = rec.SHA256 p["mime"] = rec.MIME p["file_id"] = rec.ID if rec.StorageMode == "vault" { if rec.SHA256 != "" { p["blob_sha256"] = rec.SHA256 } else { absPath := filepath.Join(a.vault, rec.Path) if hash, err := syncsvc.HashFile(absPath); err == nil { p["blob_sha256"] = hash } } } } else { p["filename"] = n.Title } return p } func notePayload(node *nodes.Node, fileRec *files.Record, content string) map[string]interface{} { pid := "" if node.ParentID != nil { pid = *node.ParentID } return map[string]interface{}{ "node_id": node.ID, "parent_id": pid, "title": node.Title, "file_id": fileRec.ID, "format": "markdown", "content": content, "filename": fileRec.Filename, "path": fileRec.Path, "created_at": node.CreatedAt.Format(time.RFC3339), "updated_at": node.UpdatedAt.Format(time.RFC3339), } } func actionPayload(rec *actions.Record) map[string]interface{} { return map[string]interface{}{ "id": rec.ID, "node_id": rec.NodeID, "title": rec.Title, "kind": rec.Kind, "command": rec.Command, "args": rec.Args, "working_dir": rec.WorkingDir, "url": rec.URL, "confirm_required": rec.ConfirmRequired, "capture_output": rec.CaptureOutput, "created_at": rec.CreatedAt.Format(time.RFC3339), "updated_at": rec.UpdatedAt.Format(time.RFC3339), } } func worklogPayload(entry *worklog.Entry) map[string]interface{} { mins := 0 if entry.Minutes != nil { mins = *entry.Minutes } p := map[string]interface{}{ "id": entry.ID, "node_id": entry.NodeID, "summary": entry.Summary, "details": entry.Details, "minutes": mins, "date": entry.Date, "approximate": entry.Approximate, "billable": entry.Billable, "created_at": entry.CreatedAt.Format(time.RFC3339), "updated_at": entry.UpdatedAt.Format(time.RFC3339), } if entry.StartedAt != nil { p["started_at"] = entry.StartedAt.Format(time.RFC3339) } if entry.EndedAt != nil { p["ended_at"] = entry.EndedAt.Format(time.RFC3339) } return p } func jsonArgs(args []string) string { if len(args) == 0 { return "" } b, _ := json.Marshal(args) return string(b) } func boolToInt(b bool) int { if b { return 1 } return 0 } func strPtr(s string) *string { if s == "" { return nil } return &s }