390 lines
9.3 KiB
Go
390 lines
9.3 KiB
Go
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/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)
|
|
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:"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"`
|
|
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"`
|
|
}
|
|
|
|
// ============================================================
|
|
// 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
|
|
}
|
|
|
|
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,
|
|
"section": n.Section,
|
|
"sort_order": n.SortOrder,
|
|
"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{} {
|
|
return map[string]interface{}{
|
|
"node_id": node.ID,
|
|
"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) interface{} {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|