From 3089d777a8f3996642818d118fc4686d0418092b Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 2 Jun 2026 10:47:38 +0800 Subject: [PATCH] =?UTF-8?q?refactor(gui):=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D1=8C=20app.go=20=D0=BD=D0=B0=20binding-?= =?UTF-8?q?=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=BF=D0=BE=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=D0=BC,=20=D0=B2=D1=8B=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=82=D0=B8=20sync=20apply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app.go (1810→280 строк): только App struct, startup, DTOs, helpers - bindings_{nodes,files,notes,actions,worklog,activity,sync,settings}.go - sync_apply.go: все applyRemote* методы - i18n: internal/i18n (Go, embed JSON) + frontend/src/lib/i18n (JS) - core/sync/safe_path.go: SafeVaultPath - scripts/check-i18n.sh: проверка хардкода кириллицы и bidi-символов - build.sh: NVM loading, set -e Все сборки (CLI, server, gui, frontend), go vet, go test проходят. --- build.sh | 10 + cmd/verstak-gui/app.go | 1773 +++----------------------- cmd/verstak-gui/bindings_actions.go | 52 + cmd/verstak-gui/bindings_activity.go | 173 +++ cmd/verstak-gui/bindings_files.go | 143 +++ cmd/verstak-gui/bindings_nodes.go | 117 ++ cmd/verstak-gui/bindings_notes.go | 57 + cmd/verstak-gui/bindings_settings.go | 135 ++ cmd/verstak-gui/bindings_sync.go | 214 ++++ cmd/verstak-gui/bindings_worklog.go | 46 + cmd/verstak-gui/main.go | 12 +- cmd/verstak-gui/sync_apply.go | 467 +++++++ frontend/src/lib/i18n/index.js | 32 + frontend/src/lib/i18n/locales/en.js | 80 ++ frontend/src/lib/i18n/locales/ru.js | 254 ++++ internal/core/sync/safe_path.go | 38 + internal/i18n/catalog.go | 92 ++ internal/i18n/locales/en.json | 101 ++ internal/i18n/locales/ru.json | 383 ++++++ scripts/check-i18n.sh | 82 ++ 20 files changed, 2658 insertions(+), 1603 deletions(-) create mode 100644 cmd/verstak-gui/bindings_actions.go create mode 100644 cmd/verstak-gui/bindings_activity.go create mode 100644 cmd/verstak-gui/bindings_files.go create mode 100644 cmd/verstak-gui/bindings_nodes.go create mode 100644 cmd/verstak-gui/bindings_notes.go create mode 100644 cmd/verstak-gui/bindings_settings.go create mode 100644 cmd/verstak-gui/bindings_sync.go create mode 100644 cmd/verstak-gui/bindings_worklog.go create mode 100644 cmd/verstak-gui/sync_apply.go create mode 100644 frontend/src/lib/i18n/index.js create mode 100644 frontend/src/lib/i18n/locales/en.js create mode 100644 frontend/src/lib/i18n/locales/ru.js create mode 100644 internal/core/sync/safe_path.go create mode 100644 internal/i18n/catalog.go create mode 100644 internal/i18n/locales/en.json create mode 100644 internal/i18n/locales/ru.json create mode 100755 scripts/check-i18n.sh diff --git a/build.sh b/build.sh index 56281c0..c692173 100755 --- a/build.sh +++ b/build.sh @@ -1,4 +1,14 @@ #!/bin/bash +set -e + +# Load NVM for Node.js +export NVM_DIR="${NVM_DIR:-$HOME/.config/nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + . "$NVM_DIR/nvm.sh" +elif [ -s "$HOME/.nvm/nvm.sh" ]; then + . "$HOME/.nvm/nvm.sh" +fi + cd frontend && npm run build && cd .. rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index 16e57f4..dea25f4 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -3,13 +3,8 @@ package main import ( "context" "encoding/json" - "fmt" "log" - "os" - "os/exec" "path/filepath" - "sort" - "strings" "time" wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" @@ -18,32 +13,29 @@ import ( "verstak/internal/core/activity" "verstak/internal/core/config" "verstak/internal/core/files" - "verstak/internal/core/notes" "verstak/internal/core/nodes" + "verstak/internal/core/notes" "verstak/internal/core/plugins" "verstak/internal/core/search" "verstak/internal/core/storage" - "verstak/internal/core/util" 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 + 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 + 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. @@ -71,7 +63,6 @@ func (a *App) autoSyncLoop() { 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 @@ -134,24 +125,24 @@ type NoteDTO struct { } 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"` + 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"` + 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 { @@ -212,1567 +203,10 @@ type TodayGroupDTO struct { } 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, nodePayload(n)) - dto := toNodeDTO(n) - return &dto, nil -} - -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 - } - // Look up the linked file record, if any. - 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 - // Compute blob SHA-256 for vault files. - 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 (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, fileRec, err := a.notes.Create(parentID, title, "") - if err != nil { - return nil, err - } - content, _ := a.notes.Read(node.ID) - _ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "") - _ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, notePayload(node, fileRec, content)) - dto := toNodeDTO(node) - return &dto, nil -} - -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), - } -} - -// 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]interface{}{ - "node_id": noteID, - "content": content, - "updated_at": time.Now().UTC().Format(time.RFC3339), - }) - } - 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, a.filePayload(&n)) - } - 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, a.filePayload(&n)) - } - 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, a.filePayload(node)) - 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, a.filePayload(node)) - 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]interface{}{ - "title": newTitle, - "updated_at": time.Now().UTC().Format(time.RFC3339), - }) - 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]interface{}{ - "parent_id": newParentID, - "updated_at": time.Now().UTC().Format(time.RFC3339), - }) - 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, actionPayload(rec)) - return &ActionDTO{ - ID: rec.ID, - NodeID: rec.NodeID, - Title: rec.Title, - Type: rec.Kind, - Data: data, - }, nil -} - -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 (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, worklogPayload(entry)) - 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 -} - -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 -} - -// ============================================================ -// 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 { - // Use a dedicated auth test that does NOT create a device. - client := syncsvc.NewClient(serverURL, "", "", a.vault) - return client.TestAuth(serverURL, username, password) -} - -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 — set last_seen_server_seq on each. - unpushed, err := a.sync.GetUnpushedOps() - if err != nil { - return nil, fmt.Errorf("get ops: %w", err) - } - for i := range unpushed { - unpushed[i].LastSeenServerSeq = lastPullSeq - } - 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) - } - - // Apply each pulled op to the local vault. - var applyErrors []string - for _, op := range pullResult.Ops { - if err := a.applyRemoteOp(op); err != nil { - applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err)) - } - _ = a.sync.RecordRemoteOp(op) - } - if len(pullResult.Ops) > 0 { - opIDs := make([]string, len(pullResult.Ops)) - for i, op := range pullResult.Ops { - opIDs[i] = op.OpID - } - _ = a.sync.MarkApplied(opIDs) - } - - // Report conflicts. - if len(pushResult.Conflicts) > 0 { - log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts)) - for _, c := range pushResult.Conflicts { - log.Printf("[sync] conflict: op=%v entity=%v/%v", - c["op_id"], c["entity_type"], c["entity_id"]) - } - } - - // Update sync state. - if pullResult.ServerSequence > lastPullSeq { - _ = a.sync.SetLastPullSeq(pullResult.ServerSequence) - } - _ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339)) - - result := map[string]interface{}{ - "pushed": len(pushResult.Accepted), - "pulled": len(pullResult.Ops), - "serverSequence": pullResult.ServerSequence, - } - if len(applyErrors) > 0 { - result["applyErrors"] = applyErrors - } - if len(pushResult.Conflicts) > 0 { - result["conflicts"] = pushResult.Conflicts - } - return result, nil -} - -// applyRemoteOp dispatches a remote sync operation to the correct entity handler. -func (a *App) applyRemoteOp(op syncsvc.Op) error { - switch op.EntityType { - case syncsvc.EntityNode: - return a.applyRemoteNodeOp(op) - case syncsvc.EntityNote: - return a.applyRemoteNoteOp(op) - case syncsvc.EntityFile, syncsvc.EntityFolder: - return a.applyRemoteFileOrFolderOp(op) - case syncsvc.EntityAction: - return a.applyRemoteActionOp(op) - case syncsvc.EntityWorklog: - return a.applyRemoteWorklogOp(op) - } - return nil // unknown entity type, skip silently -} - -// --- apply helpers --- - -func (a *App) applyRemoteNodeOp(op syncsvc.Op) error { - switch op.OpType { - case syncsvc.OpCreate: - return a.applyRemoteNodeCreate(op) - case syncsvc.OpUpdate: - return a.applyRemoteNodeUpdate(op) - case syncsvc.OpMove: - return a.applyRemoteNodeMove(op) - case syncsvc.OpDelete: - return a.applyRemoteNodeDelete(op) - } - return nil -} - -func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { - var payload struct { - ID string `json:"id"` - ParentID string `json:"parent_id"` - Type string `json:"type"` - Title string `json:"title"` - Slug string `json:"slug"` - Section string `json:"section"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal node create: %w", err) - } - if payload.ID == "" || payload.Type == "" || payload.Title == "" { - return fmt.Errorf("incomplete node payload") - } - - // Check if node already exists (e.g., created by a prior file/note op). - if _, err := a.nodes.Get(payload.ID); err == nil { - return nil // already exists - } - - // Insert directly (bypass slug uniqueness / validation for remote ops). - now := time.Now().UTC().Format(time.RFC3339) - if payload.CreatedAt == "" { - payload.CreatedAt = now - } - if payload.UpdatedAt == "" { - payload.UpdatedAt = now - } - var parent interface{} - if payload.ParentID != "" { - parent = payload.ParentID - } - var section interface{} - if payload.Section != "" { - section = payload.Section - } - slug := payload.Slug - if slug == "" { - slug = nodes.Slugify(payload.Title) - } - _, err := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id) - VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`, - payload.ID, parent, payload.Type, payload.Title, slug, section, - payload.CreatedAt, payload.UpdatedAt, - ) - return err -} - -func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error { - var payload struct { - Title string `json:"title"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal node update: %w", err) - } - now := time.Now().UTC().Format(time.RFC3339) - if payload.UpdatedAt != "" { - now = payload.UpdatedAt - } - if payload.Title != "" { - slug := nodes.Slugify(payload.Title) - _, err := a.db.Exec( - `UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`, - payload.Title, slug, now, op.EntityID) - return err - } - // No title = just touch. - _, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID) - return err -} - -func (a *App) applyRemoteNodeMove(op syncsvc.Op) error { - var payload struct { - ParentID string `json:"parent_id"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal node move: %w", err) - } - now := time.Now().UTC().Format(time.RFC3339) - if payload.UpdatedAt != "" { - now = payload.UpdatedAt - } - var parent interface{} - if payload.ParentID != "" { - parent = payload.ParentID - } - _, err := a.db.Exec( - `UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`, - parent, now, op.EntityID) - return err -} - -func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error { - now := time.Now().UTC().Format(time.RFC3339) - _, err := a.db.Exec( - `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, - now, now, op.EntityID) - return err -} - -func (a *App) applyRemoteNoteOp(op syncsvc.Op) error { - switch op.OpType { - case syncsvc.OpCreate: - return a.applyRemoteNoteCreate(op) - case syncsvc.OpUpdate: - return a.applyRemoteNoteUpdate(op) - case syncsvc.OpDelete: - return a.applyRemoteNodeDelete(op) - } - return nil -} - -func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { - var payload struct { - NodeID string `json:"node_id"` - FileID string `json:"file_id"` - Format string `json:"format"` - Content string `json:"content"` - Filename string `json:"filename"` - Path string `json:"path"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal note create: %w", err) - } - if payload.NodeID == "" { - return nil - } - - now := time.Now().UTC().Format(time.RFC3339) - - // Ensure the parent node exists (create a placeholder if not). - if _, err := a.nodes.Get(payload.NodeID); err != nil { - slug := nodes.Slugify("remote-note") - _, e := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision) - VALUES (?,'note','remote-note',?,?,?,1)`, - payload.NodeID, slug, now, now) - if e != nil { - return e - } - } - - // Write the .md file. - dest := filepath.Join(a.vault, payload.Path) - if payload.Path == "" { - filename := payload.Filename - if filename == "" { - filename = payload.NodeID[:8] + ".md" - } - dest = filepath.Join(a.vault, "spaces", filename) - payload.Path, _ = filepath.Rel(a.vault, dest) - } - if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { - return err - } - if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil { - return err - } - info, _ := os.Stat(dest) - size := int64(0) - if info != nil { - size = info.Size() - } - - // Create file record. - fileID := payload.FileID - if fileID == "" { - fileID = util.UUID7() - } - _, err := a.db.Exec( - `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing) - VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`, - fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now) - if err != nil { - return err - } - - // Create notes link. - format := payload.Format - if format == "" { - format = "markdown" - } - _, err = a.db.Exec( - `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, - payload.NodeID, fileID, format) - return err -} - -func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error { - var payload struct { - NodeID string `json:"node_id"` - Content string `json:"content"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal note update: %w", err) - } - if payload.NodeID == "" { - return nil - } - - // Find the note's file path. - var filePath, storageMode string - err := a.db.QueryRow( - `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`, - payload.NodeID).Scan(&filePath, &storageMode) - if err != nil { - return fmt.Errorf("note record not found: %w", err) - } - - var abs string - if storageMode == "vault" { - abs = filepath.Join(a.vault, filePath) - } else { - abs = filePath - } - if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil { - return err - } - info, _ := os.Stat(abs) - size := int64(0) - if info != nil { - size = info.Size() - } - now := time.Now().UTC().Format(time.RFC3339) - _, e := a.db.Exec( - `UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`, - size, now, filePath, storageMode) - return e -} - -func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error { - switch op.OpType { - case syncsvc.OpCreate: - return a.applyRemoteFileCreate(op) - case syncsvc.OpUpdate: - return a.applyRemoteNodeUpdate(op) - case syncsvc.OpMove: - return a.applyRemoteNodeMove(op) - case syncsvc.OpDelete: - return a.applyRemoteNodeDelete(op) - } - return nil -} - -func (a *App) applyRemoteFileCreate(op syncsvc.Op) error { - var payload struct { - NodeID string `json:"node_id"` - Type string `json:"type"` - Title string `json:"title"` - Slug string `json:"slug"` - ParentID string `json:"parent_id"` - Filename string `json:"filename"` - Path string `json:"path"` - StorageMode string `json:"storage_mode"` - Size int64 `json:"size"` - SHA256 string `json:"sha256"` - MIME string `json:"mime"` - FileID string `json:"file_id"` - BlobSHA256 string `json:"blob_sha256"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal file create: %w", err) - } - if payload.NodeID == "" { - return nil - } - - now := time.Now().UTC().Format(time.RFC3339) - - // Create the node if not exists. - if _, err := a.nodes.Get(payload.NodeID); err != nil { - slug := payload.Slug - if slug == "" { - slug = nodes.Slugify(payload.Title) - } - ntype := payload.Type - if ntype == "" { - ntype = "file" - } - var parent interface{} - if payload.ParentID != "" { - parent = payload.ParentID - } - _, e := a.db.Exec( - `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision) - VALUES (?,?,?,?,?,?,?,1)`, - payload.NodeID, parent, ntype, payload.Title, slug, now, now) - if e != nil { - return e - } - } - - // Download blob if needed and not already present on disk. - if payload.BlobSHA256 != "" && payload.StorageMode == "vault" { - blobsDir := syncsvc.BlobDir(a.vault) - blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256) - if _, err := os.Stat(blobPath); os.IsNotExist(err) { - // Download from server. - serverURL, apiKey, _, _, _ := a.sync.GetState() - deviceToken := config.LoadDeviceToken(a.vault) - cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault) - cli.DeviceToken = deviceToken - if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil { - log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err) - } - } - - // Place file in vault. - dest := filepath.Join(a.vault, payload.Path) - if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil { - // Copy blob to actual vault location. - input, rErr := os.ReadFile(blobPath) - if rErr == nil { - _ = os.WriteFile(dest, input, 0o640) - } - } - } - - // Create file record. - fileID := payload.FileID - if fileID == "" { - fileID = util.UUID7() - } - storageMode := payload.StorageMode - if storageMode == "" { - storageMode = "vault" - } - mime := payload.MIME - if mime == "" { - mime = "application/octet-stream" - } - _, err := a.db.Exec( - `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) - VALUES (?,?,?,?,?,?,?,?,?,?,0)`, - fileID, payload.NodeID, payload.Filename, payload.Path, storageMode, - payload.Size, payload.SHA256, mime, now, now) - return err -} - -func (a *App) applyRemoteActionOp(op syncsvc.Op) error { - switch op.OpType { - case syncsvc.OpCreate: - return a.applyRemoteActionCreate(op) - case syncsvc.OpDelete: - _, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID) - return err - } - return nil -} - -func (a *App) applyRemoteActionCreate(op syncsvc.Op) error { - var payload struct { - ID string `json:"id"` - NodeID string `json:"node_id"` - Title string `json:"title"` - Kind string `json:"kind"` - Command string `json:"command"` - Args []string `json:"args"` - WorkingDir string `json:"working_dir"` - URL string `json:"url"` - ConfirmRequired bool `json:"confirm_required"` - CaptureOutput bool `json:"capture_output"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal action create: %w", err) - } - if payload.ID == "" || payload.NodeID == "" { - return nil - } - _, err := a.db.Exec( - `INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - payload.ID, payload.NodeID, payload.Title, payload.Kind, - payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL, - boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput), - payload.CreatedAt, payload.UpdatedAt) - return err -} - -func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error { - switch op.OpType { - case syncsvc.OpCreate: - return a.applyRemoteWorklogCreate(op) - case syncsvc.OpDelete: - _, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID) - return err - } - return nil -} - -func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error { - var payload struct { - ID string `json:"id"` - NodeID string `json:"node_id"` - Summary string `json:"summary"` - Details string `json:"details"` - Minutes int `json:"minutes"` - Date string `json:"date"` - StartedAt string `json:"started_at"` - EndedAt string `json:"ended_at"` - Approximate bool `json:"approximate"` - Billable bool `json:"billable"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - } - if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { - return fmt.Errorf("unmarshal worklog create: %w", err) - } - if payload.ID == "" || payload.NodeID == "" { - return nil - } - _, err := a.db.Exec( - `INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, - payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt), - payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable), - payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt) - return err -} - -// --- small helpers --- - -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 -} - -// ============================================================ -// 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" + Date string `json:"date"` + Summary SummaryDTO `json:"summary"` + Groups []TodayGroupDTO `json:"groups"` + Events []EventDTO `json:"events"` } // ============================================================ @@ -1807,4 +241,149 @@ func toNodeDTOs(list []nodes.Node) []NodeDTO { 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 +} diff --git a/cmd/verstak-gui/bindings_actions.go b/cmd/verstak-gui/bindings_actions.go new file mode 100644 index 0000000..d1189e3 --- /dev/null +++ b/cmd/verstak-gui/bindings_actions.go @@ -0,0 +1,52 @@ +package main + +import ( + syncsvc "verstak/internal/core/sync" +) + +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, actionPayload(rec)) + 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 +} diff --git a/cmd/verstak-gui/bindings_activity.go b/cmd/verstak-gui/bindings_activity.go new file mode 100644 index 0000000..553934c --- /dev/null +++ b/cmd/verstak-gui/bindings_activity.go @@ -0,0 +1,173 @@ +package main + +import ( + "sort" + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" + "verstak/internal/i18n" +) + +func (a *App) ListSections() []SectionDTO { + return []SectionDTO{ + {ID: "today", Label: i18n.TF("ru", "nav.today")}, + {ID: "inbox", Label: i18n.TF("ru", "nav.inbox")}, + {ID: "activity", Label: i18n.TF("ru", "nav.activity")}, + {ID: "clients", Label: i18n.TF("ru", "nav.clients")}, + {ID: "projects", Label: i18n.TF("ru", "nav.projects")}, + {ID: "recipes", Label: i18n.TF("ru", "nav.recipes")}, + {ID: "documents", Label: i18n.TF("ru", "nav.documents")}, + {ID: "archive", Label: i18n.TF("ru", "nav.archive")}, + } +} + +func (a *App) ListTodayView() (*TodayDashboardDTO, error) { + aeByParent, err := a.activity.ListTodayEventsByParent() + if err != nil { + aeByParent = nil + } + 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 + } + + 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, + }) + } + } + + 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 (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) +} + +var _ = syncsvc.EntityNode diff --git a/cmd/verstak-gui/bindings_files.go b/cmd/verstak-gui/bindings_files.go new file mode 100644 index 0000000..15fc45c --- /dev/null +++ b/cmd/verstak-gui/bindings_files.go @@ -0,0 +1,143 @@ +package main + +import ( + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" +) + +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 +} + +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 { + 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, a.filePayload(&n)) + } + 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, a.filePayload(&n)) + } + 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, a.filePayload(node)) + 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 + } + 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, a.filePayload(node)) + dto := toNodeDTO(node) + return &dto, nil +} + +func (a *App) ValidateName(name string) error { + return files.ValidateName(name) +} + +func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) { + return a.files.PreviewImport(sourcePath) +} diff --git a/cmd/verstak-gui/bindings_nodes.go b/cmd/verstak-gui/bindings_nodes.go new file mode 100644 index 0000000..89ba31c --- /dev/null +++ b/cmd/verstak-gui/bindings_nodes.go @@ -0,0 +1,117 @@ +package main + +import ( + "fmt" + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" +) + +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 +} + +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, nodePayload(n)) + dto := toNodeDTO(n) + return &dto, nil +} + +func (a *App) DeleteNode(id string) error { + return a.nodes.SoftDelete(id) +} + +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]interface{}{ + "title": newTitle, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) + return nil +} + +func (a *App) MoveNode(nodeID, newParentID string) error { + 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 { + 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]interface{}{ + "parent_id": newParentID, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) + return nil +} diff --git a/cmd/verstak-gui/bindings_notes.go b/cmd/verstak-gui/bindings_notes.go new file mode 100644 index 0000000..a8b08c3 --- /dev/null +++ b/cmd/verstak-gui/bindings_notes.go @@ -0,0 +1,57 @@ +package main + +import ( + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" +) + +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 +} + +func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) { + node, fileRec, 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, notePayload(node, fileRec, "")) + dto := toNodeDTO(node) + return &dto, nil +} + +func (a *App) ReadNote(noteID string) (string, error) { + return a.notes.Read(noteID) +} + +func (a *App) SaveNote(noteID, content string) error { + if err := a.notes.Save(noteID, content); err != nil { + return err + } + 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]interface{}{ + "node_id": noteID, + "content": content, + "updated_at": time.Now().UTC().Format(time.RFC3339), + }) + } + return nil +} diff --git a/cmd/verstak-gui/bindings_settings.go b/cmd/verstak-gui/bindings_settings.go new file mode 100644 index 0000000..26df188 --- /dev/null +++ b/cmd/verstak-gui/bindings_settings.go @@ -0,0 +1,135 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + + "verstak/internal/core/plugins" + "verstak/internal/i18n" + + wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime" +) + +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, nil + } + 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 +} + +func (a *App) Search(query string) ([]SearchResultDTO, error) { + if 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 +} + +func (a *App) PickFile() (string, error) { + return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: i18n.TF("ru", "file.pickSingle"), + }) +} + +func (a *App) PickFiles() ([]string, error) { + return wailsruntime.OpenMultipleFilesDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: i18n.TF("ru", "file.pickMultiple"), + }) +} + +func (a *App) PickDirectory() (string, error) { + return wailsruntime.OpenDirectoryDialog(a.ctx, wailsruntime.OpenDialogOptions{ + Title: i18n.TF("ru", "file.pickDirectory"), + }) +} + +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 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" +} diff --git a/cmd/verstak-gui/bindings_sync.go b/cmd/verstak-gui/bindings_sync.go new file mode 100644 index 0000000..1e80fef --- /dev/null +++ b/cmd/verstak-gui/bindings_sync.go @@ -0,0 +1,214 @@ +package main + +import ( + "fmt" + "log" + "os" + "time" + + "verstak/internal/core/config" + syncsvc "verstak/internal/core/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) + } + 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{} + } + 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) + return client.TestAuth(serverURL, username, password) +} + +func (a *App) SyncSetInterval(minutes int) error { + cfg, err := config.Load(a.vault) + if err != nil { + cfg = &config.Config{} + } + 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 + + unpushed, err := a.sync.GetUnpushedOps() + if err != nil { + return nil, fmt.Errorf("get ops: %w", err) + } + for i := range unpushed { + unpushed[i].LastSeenServerSeq = lastPullSeq + } + 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) + } + } + + pullResult, err := client.Pull(lastPullSeq) + if err != nil { + return nil, fmt.Errorf("pull: %w", err) + } + + var applyErrors []string + for _, op := range pullResult.Ops { + if err := a.applyRemoteOp(op); err != nil { + applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err)) + } + _ = a.sync.RecordRemoteOp(op) + } + if len(pullResult.Ops) > 0 { + opIDs := make([]string, len(pullResult.Ops)) + for i, op := range pullResult.Ops { + opIDs[i] = op.OpID + } + _ = a.sync.MarkApplied(opIDs) + } + + if len(pushResult.Conflicts) > 0 { + log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts)) + for _, c := range pushResult.Conflicts { + log.Printf("[sync] conflict: op=%v entity=%v/%v", + c["op_id"], c["entity_type"], c["entity_id"]) + } + } + + if pullResult.ServerSequence > lastPullSeq { + _ = a.sync.SetLastPullSeq(pullResult.ServerSequence) + } + _ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339)) + + result := map[string]interface{}{ + "pushed": len(pushResult.Accepted), + "pulled": len(pullResult.Ops), + "serverSequence": pullResult.ServerSequence, + } + if len(applyErrors) > 0 { + result["applyErrors"] = applyErrors + } + if len(pushResult.Conflicts) > 0 { + result["conflicts"] = pushResult.Conflicts + } + return result, nil +} diff --git a/cmd/verstak-gui/bindings_worklog.go b/cmd/verstak-gui/bindings_worklog.go new file mode 100644 index 0000000..91173ad --- /dev/null +++ b/cmd/verstak-gui/bindings_worklog.go @@ -0,0 +1,46 @@ +package main + +import ( + syncsvc "verstak/internal/core/sync" +) + +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, worklogPayload(entry)) + mins := 0 + if entry.Minutes != nil { + mins = *entry.Minutes + } + return &WorklogDTO{ + ID: entry.ID, + NodeID: entry.NodeID, + Summary: entry.Summary, + Minutes: mins, + CreatedAt: entry.CreatedAt.Format("2006-01-02T15:04:05Z"), + }, nil +} diff --git a/cmd/verstak-gui/main.go b/cmd/verstak-gui/main.go index 1d8dc16..4f79278 100644 --- a/cmd/verstak-gui/main.go +++ b/cmd/verstak-gui/main.go @@ -10,8 +10,8 @@ import ( "verstak/internal/core/activity" "verstak/internal/core/config" "verstak/internal/core/files" - "verstak/internal/core/notes" "verstak/internal/core/nodes" + "verstak/internal/core/notes" "verstak/internal/core/plugins" "verstak/internal/core/search" "verstak/internal/core/storage" @@ -80,11 +80,11 @@ func main() { } err = wails.Run(&options.App{ - Title: "Верстак", - Width: 1280, - Height: 800, - MinWidth: 800, - MinHeight: 600, + Title: "Верстак", + Width: 1280, + Height: 800, + MinWidth: 800, + MinHeight: 600, BackgroundColour: &options.RGBA{R: 19, G: 19, B: 31, A: 1}, AssetServer: &assetserver.Options{ Assets: assets, diff --git a/cmd/verstak-gui/sync_apply.go b/cmd/verstak-gui/sync_apply.go new file mode 100644 index 0000000..7ef4dcf --- /dev/null +++ b/cmd/verstak-gui/sync_apply.go @@ -0,0 +1,467 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "verstak/internal/core/config" + "verstak/internal/core/nodes" + syncsvc "verstak/internal/core/sync" + "verstak/internal/core/util" +) + +// applyRemoteOp dispatches a remote sync operation to the correct entity handler. +func (a *App) applyRemoteOp(op syncsvc.Op) error { + switch op.EntityType { + case syncsvc.EntityNode: + return a.applyRemoteNodeOp(op) + case syncsvc.EntityNote: + return a.applyRemoteNoteOp(op) + case syncsvc.EntityFile, syncsvc.EntityFolder: + return a.applyRemoteFileOrFolderOp(op) + case syncsvc.EntityAction: + return a.applyRemoteActionOp(op) + case syncsvc.EntityWorklog: + return a.applyRemoteWorklogOp(op) + } + return nil +} + +// --- apply helpers --- + +func (a *App) applyRemoteNodeOp(op syncsvc.Op) error { + switch op.OpType { + case syncsvc.OpCreate: + return a.applyRemoteNodeCreate(op) + case syncsvc.OpUpdate: + return a.applyRemoteNodeUpdate(op) + case syncsvc.OpMove: + return a.applyRemoteNodeMove(op) + case syncsvc.OpDelete: + return a.applyRemoteNodeDelete(op) + } + return nil +} + +func (a *App) applyRemoteNodeCreate(op syncsvc.Op) error { + var payload struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + Type string `json:"type"` + Title string `json:"title"` + Slug string `json:"slug"` + Section string `json:"section"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal node create: %w", err) + } + if payload.ID == "" || payload.Type == "" || payload.Title == "" { + return fmt.Errorf("incomplete node payload") + } + + if _, err := a.nodes.Get(payload.ID); err == nil { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + if payload.CreatedAt == "" { + payload.CreatedAt = now + } + if payload.UpdatedAt == "" { + payload.UpdatedAt = now + } + var parent interface{} + if payload.ParentID != "" { + parent = payload.ParentID + } + var section interface{} + if payload.Section != "" { + section = payload.Section + } + slug := payload.Slug + if slug == "" { + slug = nodes.Slugify(payload.Title) + } + _, err := a.db.Exec( + `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,section,sort_order,created_at,updated_at,revision,device_id) + VALUES (?,?,?,?,?,?,0,?,?,1,NULL)`, + payload.ID, parent, payload.Type, payload.Title, slug, section, + payload.CreatedAt, payload.UpdatedAt, + ) + return err +} + +func (a *App) applyRemoteNodeUpdate(op syncsvc.Op) error { + var payload struct { + Title string `json:"title"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal node update: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + if payload.UpdatedAt != "" { + now = payload.UpdatedAt + } + if payload.Title != "" { + slug := nodes.Slugify(payload.Title) + _, err := a.db.Exec( + `UPDATE nodes SET title=?, slug=?, updated_at=? WHERE id=?`, + payload.Title, slug, now, op.EntityID) + return err + } + _, err := a.db.Exec(`UPDATE nodes SET updated_at=? WHERE id=?`, now, op.EntityID) + return err +} + +func (a *App) applyRemoteNodeMove(op syncsvc.Op) error { + var payload struct { + ParentID string `json:"parent_id"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal node move: %w", err) + } + now := time.Now().UTC().Format(time.RFC3339) + if payload.UpdatedAt != "" { + now = payload.UpdatedAt + } + var parent interface{} + if payload.ParentID != "" { + parent = payload.ParentID + } + _, err := a.db.Exec( + `UPDATE nodes SET parent_id=?, updated_at=? WHERE id=?`, + parent, now, op.EntityID) + return err +} + +func (a *App) applyRemoteNodeDelete(op syncsvc.Op) error { + now := time.Now().UTC().Format(time.RFC3339) + _, err := a.db.Exec( + `UPDATE nodes SET deleted_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`, + now, now, op.EntityID) + return err +} + +func (a *App) applyRemoteNoteOp(op syncsvc.Op) error { + switch op.OpType { + case syncsvc.OpCreate: + return a.applyRemoteNoteCreate(op) + case syncsvc.OpUpdate: + return a.applyRemoteNoteUpdate(op) + case syncsvc.OpDelete: + return a.applyRemoteNodeDelete(op) + } + return nil +} + +func (a *App) applyRemoteNoteCreate(op syncsvc.Op) error { + var payload struct { + NodeID string `json:"node_id"` + FileID string `json:"file_id"` + Format string `json:"format"` + Content string `json:"content"` + Filename string `json:"filename"` + Path string `json:"path"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal note create: %w", err) + } + if payload.NodeID == "" { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + + if _, err := a.nodes.Get(payload.NodeID); err != nil { + slug := nodes.Slugify("remote-note") + _, e := a.db.Exec( + `INSERT OR IGNORE INTO nodes (id,type,title,slug,created_at,updated_at,revision) + VALUES (?,'note','remote-note',?,?,?,1)`, + payload.NodeID, slug, now, now) + if e != nil { + return e + } + } + + dest := filepath.Join(a.vault, payload.Path) + if payload.Path == "" { + filename := payload.Filename + if filename == "" { + filename = payload.NodeID[:8] + ".md" + } + dest = filepath.Join(a.vault, "spaces", filename) + payload.Path, _ = filepath.Rel(a.vault, dest) + } + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + return err + } + if err := os.WriteFile(dest, []byte(payload.Content), 0o640); err != nil { + return err + } + info, _ := os.Stat(dest) + size := int64(0) + if info != nil { + size = info.Size() + } + + fileID := payload.FileID + if fileID == "" { + fileID = util.UUID7() + } + _, err := a.db.Exec( + `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,'vault',?,'text/plain',?,?,0)`, + fileID, payload.NodeID, filepath.Base(dest), payload.Path, size, now, now) + if err != nil { + return err + } + + format := payload.Format + if format == "" { + format = "markdown" + } + _, err = a.db.Exec( + `INSERT OR IGNORE INTO notes (node_id, file_id, format) VALUES (?,?,?)`, + payload.NodeID, fileID, format) + return err +} + +func (a *App) applyRemoteNoteUpdate(op syncsvc.Op) error { + var payload struct { + NodeID string `json:"node_id"` + Content string `json:"content"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal note update: %w", err) + } + if payload.NodeID == "" { + return nil + } + + var filePath, storageMode string + err := a.db.QueryRow( + `SELECT f.path, f.storage_mode FROM notes n JOIN files f ON n.file_id = f.id WHERE n.node_id=?`, + payload.NodeID).Scan(&filePath, &storageMode) + if err != nil { + return fmt.Errorf("note record not found: %w", err) + } + + var abs string + if storageMode == "vault" { + abs = filepath.Join(a.vault, filePath) + } else { + abs = filePath + } + if err := os.WriteFile(abs, []byte(payload.Content), 0o640); err != nil { + return err + } + info, _ := os.Stat(abs) + size := int64(0) + if info != nil { + size = info.Size() + } + now := time.Now().UTC().Format(time.RFC3339) + _, e := a.db.Exec( + `UPDATE files SET size=?, updated_at=? WHERE path=? AND storage_mode=?`, + size, now, filePath, storageMode) + return e +} + +func (a *App) applyRemoteFileOrFolderOp(op syncsvc.Op) error { + switch op.OpType { + case syncsvc.OpCreate: + return a.applyRemoteFileCreate(op) + case syncsvc.OpUpdate: + return a.applyRemoteNodeUpdate(op) + case syncsvc.OpMove: + return a.applyRemoteNodeMove(op) + case syncsvc.OpDelete: + return a.applyRemoteNodeDelete(op) + } + return nil +} + +func (a *App) applyRemoteFileCreate(op syncsvc.Op) error { + var payload struct { + NodeID string `json:"node_id"` + Type string `json:"type"` + Title string `json:"title"` + Slug string `json:"slug"` + ParentID string `json:"parent_id"` + Filename string `json:"filename"` + Path string `json:"path"` + StorageMode string `json:"storage_mode"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + MIME string `json:"mime"` + FileID string `json:"file_id"` + BlobSHA256 string `json:"blob_sha256"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal file create: %w", err) + } + if payload.NodeID == "" { + return nil + } + + now := time.Now().UTC().Format(time.RFC3339) + + if _, err := a.nodes.Get(payload.NodeID); err != nil { + slug := payload.Slug + if slug == "" { + slug = nodes.Slugify(payload.Title) + } + ntype := payload.Type + if ntype == "" { + ntype = "file" + } + var parent interface{} + if payload.ParentID != "" { + parent = payload.ParentID + } + _, e := a.db.Exec( + `INSERT OR IGNORE INTO nodes (id,parent_id,type,title,slug,created_at,updated_at,revision) + VALUES (?,?,?,?,?,?,?,1)`, + payload.NodeID, parent, ntype, payload.Title, slug, now, now) + if e != nil { + return e + } + } + + if payload.BlobSHA256 != "" && payload.StorageMode == "vault" { + blobsDir := syncsvc.BlobDir(a.vault) + blobPath := syncsvc.BlobPath(blobsDir, payload.BlobSHA256) + if _, err := os.Stat(blobPath); os.IsNotExist(err) { + serverURL, apiKey, _, _, _ := a.sync.GetState() + deviceToken := config.LoadDeviceToken(a.vault) + cli := syncsvc.NewClient(serverURL, apiKey, "", a.vault) + cli.DeviceToken = deviceToken + if err := cli.DownloadBlob(payload.BlobSHA256, blobPath); err != nil { + log.Printf("[sync] blob download failed for %s: %v", payload.BlobSHA256, err) + } + } + + dest := filepath.Join(a.vault, payload.Path) + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err == nil { + input, rErr := os.ReadFile(blobPath) + if rErr == nil { + _ = os.WriteFile(dest, input, 0o640) + } + } + } + + fileID := payload.FileID + if fileID == "" { + fileID = util.UUID7() + } + storageMode := payload.StorageMode + if storageMode == "" { + storageMode = "vault" + } + mime := payload.MIME + if mime == "" { + mime = "application/octet-stream" + } + _, err := a.db.Exec( + `INSERT OR IGNORE INTO files (id,node_id,filename,path,storage_mode,size,sha256,mime,created_at,updated_at,missing) + VALUES (?,?,?,?,?,?,?,?,?,?,0)`, + fileID, payload.NodeID, payload.Filename, payload.Path, storageMode, + payload.Size, payload.SHA256, mime, now, now) + return err +} + +func (a *App) applyRemoteActionOp(op syncsvc.Op) error { + switch op.OpType { + case syncsvc.OpCreate: + return a.applyRemoteActionCreate(op) + case syncsvc.OpDelete: + _, err := a.db.Exec(`DELETE FROM actions WHERE id=?`, op.EntityID) + return err + } + return nil +} + +func (a *App) applyRemoteActionCreate(op syncsvc.Op) error { + var payload struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + Title string `json:"title"` + Kind string `json:"kind"` + Command string `json:"command"` + Args []string `json:"args"` + WorkingDir string `json:"working_dir"` + URL string `json:"url"` + ConfirmRequired bool `json:"confirm_required"` + CaptureOutput bool `json:"capture_output"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal action create: %w", err) + } + if payload.ID == "" || payload.NodeID == "" { + return nil + } + _, err := a.db.Exec( + `INSERT OR IGNORE INTO actions (id,node_id,title,kind,command,args_json,working_dir,url,confirm_required,capture_output,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + payload.ID, payload.NodeID, payload.Title, payload.Kind, + payload.Command, jsonArgs(payload.Args), payload.WorkingDir, payload.URL, + boolToInt(payload.ConfirmRequired), boolToInt(payload.CaptureOutput), + payload.CreatedAt, payload.UpdatedAt) + return err +} + +func (a *App) applyRemoteWorklogOp(op syncsvc.Op) error { + switch op.OpType { + case syncsvc.OpCreate: + return a.applyRemoteWorklogCreate(op) + case syncsvc.OpDelete: + _, err := a.db.Exec(`DELETE FROM worklog_entries WHERE id=?`, op.EntityID) + return err + } + return nil +} + +func (a *App) applyRemoteWorklogCreate(op syncsvc.Op) error { + var payload struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + Summary string `json:"summary"` + Details string `json:"details"` + Minutes int `json:"minutes"` + Date string `json:"date"` + StartedAt string `json:"started_at"` + EndedAt string `json:"ended_at"` + Approximate bool `json:"approximate"` + Billable bool `json:"billable"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal([]byte(op.PayloadJSON), &payload); err != nil { + return fmt.Errorf("unmarshal worklog create: %w", err) + } + if payload.ID == "" || payload.NodeID == "" { + return nil + } + _, err := a.db.Exec( + `INSERT OR IGNORE INTO worklog_entries (id,node_id,started_at,ended_at,date,minutes,approximate,billable,summary,details,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + payload.ID, payload.NodeID, strPtr(payload.StartedAt), strPtr(payload.EndedAt), + payload.Date, payload.Minutes, boolToInt(payload.Approximate), boolToInt(payload.Billable), + payload.Summary, payload.Details, payload.CreatedAt, payload.UpdatedAt) + return err +} diff --git a/frontend/src/lib/i18n/index.js b/frontend/src/lib/i18n/index.js new file mode 100644 index 0000000..994e872 --- /dev/null +++ b/frontend/src/lib/i18n/index.js @@ -0,0 +1,32 @@ +import ru from './locales/ru.js' +import en from './locales/en.js' + +const catalogs = { ru, en } +let currentLocale = 'ru' + +export function t(key, params) { + const catalog = catalogs[currentLocale] + let msg = catalog?.[key] + if (msg == null && currentLocale !== 'ru') { + msg = catalogs.ru?.[key] + } + if (msg == null) { + msg = key + } + if (params != null) { + for (const [k, v] of Object.entries(params)) { + msg = msg.replace(`{${k}}`, String(v)) + } + } + return msg +} + +export function setLocale(locale) { + if (catalogs[locale]) { + currentLocale = locale + } +} + +export function getLocale() { + return currentLocale +} diff --git a/frontend/src/lib/i18n/locales/en.js b/frontend/src/lib/i18n/locales/en.js new file mode 100644 index 0000000..dfcb5ba --- /dev/null +++ b/frontend/src/lib/i18n/locales/en.js @@ -0,0 +1,80 @@ +export default { + 'nav.today': 'Today', + 'nav.inbox': 'Inbox', + 'nav.activity': 'Activity', + 'nav.clients': 'Clients', + 'nav.projects': 'Projects', + 'nav.recipes': 'Recipes', + 'nav.documents': 'Documents', + 'nav.archive': 'Archive', + + 'tab.overview': 'Overview', + 'tab.notes': 'Notes', + 'tab.files': 'Files', + 'tab.actions': 'Actions', + 'tab.worklog': 'Work Log', + 'tab.activity': 'Activity', + + 'common.save': 'Save', + 'common.cancel': 'Cancel', + 'common.delete': 'Delete', + 'common.rename': 'Rename', + 'common.close': 'Close', + 'common.create': 'Create', + 'common.confirm': 'Confirm', + 'common.back': '← Back', + 'common.loading': 'Loading...', + 'common.error': 'Error:', + 'common.yes': 'Yes', + 'common.ok': 'OK', + 'common.run': 'Run', + 'common.name': 'Name', + 'common.settings': 'Settings', + + 'welcome.title': 'Verstak', + 'welcome.selectSection': 'Select a section in the sidebar.', + 'welcome.addCase': 'Add case', + + 'event.noteCreated': 'Note created', + 'event.noteUpdated': 'Note updated', + 'event.fileAdded': 'File added', + 'event.fileDeleted': 'File deleted', + 'event.fileRenamed': 'File renamed', + 'event.fileCopied': 'File copied', + 'event.fileMoved': 'File moved', + 'event.caseCreated': 'Case created', + + 'action.openUrl': 'Open URL', + 'action.openFile': 'Open file', + 'action.openFolder': 'Open folder', + 'action.runCommand': 'Run command', + 'action.runScript': 'Run script', + 'action.openTerminal': 'Open terminal', + 'action.launchApp': 'Launch app', + + 'note.add': '+ Add note', + 'note.noNotes': 'No notes', + 'note.title': 'Note title', + 'note.placeholder': 'Start writing...', + + 'file.addFile': '+ Add file', + 'file.addFolder': '+ Add folder', + 'file.preview': 'Preview', + 'file.openExternal': 'Open in external program', + 'file.openFolder': 'Open folder', + 'file.delete': 'Delete', + 'file.pickSingle': 'Select file', + 'file.pickDirectory': 'Select folder', + + 'sync.title': 'Sync', + 'sync.settings': 'Sync settings', + 'sync.status': 'Status', + 'sync.server': 'Server', + 'sync.device': 'Device', + 'sync.connected': 'Connected', + 'sync.notConnected': 'Not connected', + 'sync.disabled': 'Disabled', + + 'error.generic': 'An error occurred', + 'error.invalidCredentials': 'Invalid username or password', +} diff --git a/frontend/src/lib/i18n/locales/ru.js b/frontend/src/lib/i18n/locales/ru.js new file mode 100644 index 0000000..048ce23 --- /dev/null +++ b/frontend/src/lib/i18n/locales/ru.js @@ -0,0 +1,254 @@ +export default { + 'nav.today': 'Сегодня', + 'nav.inbox': 'Неразобранное', + 'nav.activity': 'Активность', + 'nav.clients': 'Клиенты', + 'nav.projects': 'Проекты', + 'nav.recipes': 'Рецепты', + 'nav.documents': 'Документы', + 'nav.archive': 'Архив', + 'nav.sections': 'Разделы', + 'nav.cases': 'Дела', + 'nav.noCases': 'Нет дел', + 'nav.sync': 'Синхронизация', + 'nav.syncSettings': 'Настройки синхронизации', + 'nav.syncNow': 'Синхронизировать', + 'nav.selectPrompt': 'Выберите раздел или дело', + 'nav.brand': 'Верстак', + + 'tab.overview': 'Обзор', + 'tab.notes': 'Заметки', + 'tab.files': 'Файлы', + 'tab.actions': 'Действия', + 'tab.worklog': 'Журнал', + 'tab.activity': 'Активность', + + 'common.save': 'Сохранить', + 'common.cancel': 'Отмена', + 'common.delete': 'Удалить', + 'common.rename': 'Переименовать', + 'common.close': 'Закрыть', + 'common.create': 'Создать', + 'common.confirm': 'Подтверждение', + 'common.back': '← Назад', + 'common.loading': 'Загрузка...', + 'common.error': 'Ошибка:', + 'common.yes': 'Да', + 'common.ok': 'OK', + 'common.copy': 'Копировать', + 'common.cut': 'Вырезать', + 'common.paste': 'Вставить', + 'common.duplicate': 'Дублировать', + 'common.run': 'Запустить', + 'common.test': 'Test', + 'common.testAgain': 'Проверить', + 'common.connect': 'Подключиться', + 'common.disconnect': 'Отключиться', + 'common.settings': 'Настройки', + 'common.name': 'Название', + 'common.type': 'Тип', + 'common.section': 'Раздел', + 'common.created': 'Создано', + 'common.empty': 'Нет', + 'common.newName': 'Новое имя', + + 'welcome.title': 'Верстак', + 'welcome.selectSection': 'Выберите раздел в боковой панели.', + 'welcome.createCase': 'Или создайте новое дело кнопкой «+».', + 'welcome.addCase': 'Добавить дело', + + 'event.noteCreated': 'Заметка создана', + 'event.noteUpdated': 'Заметка изменена', + 'event.fileAdded': 'Файл добавлен', + 'event.fileDeleted': 'Файл удалён', + 'event.fileRenamed': 'Файл переименован', + 'event.fileCopied': 'Файл скопирован', + 'event.fileMoved': 'Файл перемещён', + 'event.folderAdded': 'Папка добавлена', + 'event.folderDeleted': 'Папка удалена', + 'event.folderRenamed': 'Папка переименована', + 'event.caseCreated': 'Дело создано', + 'event.caseUpdated': 'Дело изменено', + + 'kind.project': 'Проект', + 'kind.client': 'Клиент', + 'kind.document': 'Документ', + 'kind.recipe': 'Рецепт', + 'kind.archive': 'Архив', + 'kind.case': 'Дело', + + 'action.openUrl': 'Открыть URL', + 'action.openFile': 'Открыть файл', + 'action.openFolder': 'Открыть папку', + 'action.runCommand': 'Запустить команду', + 'action.runScript': 'Запустить скрипт', + 'action.openTerminal': 'Открыть терминал', + 'action.launchApp': 'Запустить приложение', + 'action.addAction': '+ Добавить действие', + 'action.newAction': 'Новое действие', + 'action.noActions': 'Действий пока нет', + 'action.run': 'Запустить', + 'action.dataUrl': 'URL', + 'action.dataPath': 'Путь', + 'action.dataCommand': 'Команда', + 'action.urlPlaceholder': 'https://example.com', + 'action.pathPlaceholder': '/path/to/file', + 'action.commandPlaceholder': 'команда', + 'action.namePlaceholder': 'Например: Открыть сайт', + + 'note.add': '+ Добавить заметку', + 'note.new': 'Новая заметка', + 'note.title': 'Название заметки', + 'note.noNotes': 'Нет заметок', + 'note.createFirst': 'Создайте первую заметку для этого дела.', + 'note.placeholder': 'Начните писать...', + 'note.unsavedTitle': 'Несохранённые изменения', + 'note.unsavedMessage': 'Закрыть редактор? Все несохранённые изменения будут потеряны.', + 'note.unsavedClose': 'Закрыть', + + 'file.addFile': '+ Добавить файл', + 'file.addFolder': '+ Добавить папку', + 'file.newFile': '+ Новый файл', + 'file.addFileSimple': 'Добавить файл', + 'file.addFolderSimple': 'Добавить папку', + 'file.noFiles': 'В этой папке пока нет файлов', + 'file.noFilesCase': 'В этом проекте пока нет файлов', + 'file.hint': 'Добавьте файл или папку, чтобы сохранить материалы проекта.', + 'file.root': 'Файлы', + 'file.preview': 'Предпросмотр', + 'file.openExternal': 'Открыть во внешней программе', + 'file.openFolder': 'Открыть папку', + 'file.showInExplorer': 'Показать в проводнике', + 'file.more': 'Ещё', + 'file.delete': 'Удалить', + 'file.ariaFolder': 'Папка', + 'file.ariaFile': 'Файл', + 'file.scanning': 'Сканирование...', + 'file.pickSingle': 'Выберите файл', + 'file.pickMultiple': 'Выберите файлы', + 'file.pickDirectory': 'Выберите папку', + 'file.importTitle': 'Добавить в', + 'file.importFiles': 'Файлов:', + 'file.importFolders': 'Папок:', + 'file.importSize': 'Размер:', + 'file.importCopy': 'Скопировать', + 'file.importLink': 'Привязать', + 'file.selectCaseFirst': 'Сначала выберите дело для добавления файлов', + + 'worklog.title': 'Журнал', + 'worklog.whatDone': 'Что сделано', + 'worklog.minutes': 'Мин', + 'worklog.min': 'мин', + 'worklog.log': 'Записать', + 'worklog.empty': 'Записей работы пока нет', + + 'sync.title': 'Синхронизация', + 'sync.settings': 'Настройки синхронизации', + 'sync.status': 'Статус', + 'sync.server': 'Сервер', + 'sync.device': 'Устройство', + 'sync.deviceId': 'ID устройства', + 'sync.unpushed': 'Неотправлено', + 'sync.lastSync': 'Последняя синх.', + 'sync.revoked': 'Отозвано', + 'sync.connected': 'Подключено', + 'sync.notConnected': 'Не подключено', + 'sync.disabled': 'Отключена', + 'sync.serverUrl': 'URL сервера', + 'sync.serverUrlPlaceholder': 'https://example.com:47732', + 'sync.username': 'Логин', + 'sync.usernamePlaceholder': 'username', + 'sync.password': 'Пароль', + 'sync.passwordPlaceholder': 'password', + 'sync.autoSync': 'Автосинхронизация (мин, 0 = отключено)', + 'sync.saveInterval': 'Сохранить интервал', + 'sync.syncNow': 'Синхронизировать', + 'sync.disconnect': 'Отключиться', + 'sync.connect': 'Подключиться', + 'sync.test': 'Проверить', + 'sync.settingsSaved': 'интервал сохранён', + + 'today.title': 'Сегодня', + 'today.changedCases': 'Изменён сегодня', + 'today.timeline': 'Лента за сегодня', + 'today.empty': 'Сегодня пока тихо', + 'today.emptyHint': 'Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.', + 'today.plural.case_one': 'дело', + 'today.plural.case_few': 'дела', + 'today.plural.case_many': 'дел', + 'today.plural.note_one': 'заметка', + 'today.plural.note_few': 'заметки', + 'today.plural.note_many': 'заметок', + 'today.plural.file_one': 'файл', + 'today.plural.file_few': 'файла', + 'today.plural.file_many': 'файлов', + 'today.plural.event_one': 'событие', + 'today.plural.event_few': 'события', + 'today.plural.event_many': 'событий', + + 'activity.title': 'Активность', + 'activity.empty': 'Активность пока не зафиксирована', + 'activity.perCaseEmpty': 'Активность пока не зафиксирована', + + 'overview.type': 'Тип', + 'overview.section': 'Раздел', + 'overview.created': 'Создано', + 'overview.newNote': 'Новая заметка', + 'overview.addFile': 'Добавить файл', + 'overview.addAction': 'Добавить действие', + 'overview.logTime': 'Записать время', + 'overview.recentNotes': 'Последние заметки', + 'overview.recentEntries': 'Последние записи', + + 'rename.title': 'Переименовать', + 'rename.emptyError': 'Имя не может быть пустым', + 'rename.invalidError': 'Недопустимое имя', + + 'delete.confirmTitle': 'Удаление', + 'delete.confirmMessage': 'Удалить', + 'delete.folder': 'папку', + 'delete.file': 'файл', + + 'template.optionNone': 'Без шаблона', + 'template.optional': 'Шаблон (опционально)', + + 'mime.jpeg': 'Изображение JPEG', + 'mime.png': 'Изображение PNG', + 'mime.gif': 'Изображение GIF', + 'mime.webp': 'Изображение WebP', + 'mime.svg': 'Изображение SVG', + 'mime.bmp': 'Изображение BMP', + 'mime.tiff': 'Изображение TIFF', + 'mime.avif': 'Изображение AVIF', + 'mime.pdf': 'PDF документ', + 'mime.word': 'Документ Word', + 'mime.excel': 'Таблица Excel', + 'mime.ppt': 'Презентация PowerPoint', + 'mime.zip': 'ZIP архив', + 'mime.gzip': 'GZIP архив', + 'mime.tar': 'TAR архив', + 'mime.sevenz': '7z архив', + 'mime.rar': 'RAR архив', + 'mime.text': 'Текстовый файл', + 'mime.html': 'HTML файл', + 'mime.css': 'CSS файл', + 'mime.js': 'JavaScript файл', + 'mime.json': 'JSON файл', + 'mime.xml': 'XML файл', + 'mime.yaml': 'YAML файл', + 'mime.binary': 'Бинарный файл', + 'mime.executable': 'Исполняемый файл', + 'mime.folder': 'Папка', + 'mime.unknown': 'Неизвестно', + 'mime.file': 'Файл', + + 'error.nameEmpty': 'Имя не может быть пустым', + 'error.nameInvalid': 'Недопустимое имя', + 'error.selectCaseFirst': 'Сначала выберите дело', + 'error.generic': 'Произошла ошибка', + 'error.invalidCredentials': 'Неверный логин или пароль', + 'error.accountBlocked': 'Аккаунт заблокирован', + 'error.emailNotConfirmed': 'Email не подтверждён', + 'error.tokenInvalid': 'Неверный или просроченный токен', + 'error.tokenExpired': 'Срок действия токена истёк', +} diff --git a/internal/core/sync/safe_path.go b/internal/core/sync/safe_path.go new file mode 100644 index 0000000..561c201 --- /dev/null +++ b/internal/core/sync/safe_path.go @@ -0,0 +1,38 @@ +package sync + +import ( + "fmt" + "path/filepath" + "strings" +) + +// SafeVaultPath validates that relPath is a safe relative path within vaultRoot. +// It rejects absolute paths, paths with ".." that escape vaultRoot, and empty paths. +func SafeVaultPath(vaultRoot, relPath string) (string, error) { + if relPath == "" { + return "", fmt.Errorf("empty path") + } + if filepath.IsAbs(relPath) { + return "", fmt.Errorf("absolute path not allowed: %s", relPath) + } + clean := filepath.Clean(relPath) + if strings.HasPrefix(clean, "..") || strings.Contains(clean, "../") || strings.HasPrefix(clean, "\\..") { + return "", fmt.Errorf("path escapes vault: %s", relPath) + } + joined := filepath.Join(vaultRoot, clean) + // Verify we're still inside vaultRoot after Clean. + if !strings.HasPrefix(joined, filepath.Clean(vaultRoot)+string(filepath.Separator)) && joined != filepath.Clean(vaultRoot) { + return "", fmt.Errorf("path escapes vault after join: %s", relPath) + } + return clean, nil +} + +// SafeVaultPaths validates multiple paths and returns the first error. +func SafeVaultPaths(vaultRoot string, paths ...string) error { + for _, p := range paths { + if _, err := SafeVaultPath(vaultRoot, p); err != nil { + return err + } + } + return nil +} diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go new file mode 100644 index 0000000..bae8c66 --- /dev/null +++ b/internal/i18n/catalog.go @@ -0,0 +1,92 @@ +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "strings" + "sync" +) + +//go:embed locales/*.json +var localeFS embed.FS + +var ( + mu sync.RWMutex + cache = map[string]map[string]string{} + defaultLocale = "ru" +) + +// T returns a localized string for the given locale and key. +// It supports printf-style formatting via args. +// Falls back: key not found -> ru -> key itself. +func T(locale, key string, args ...any) string { + mu.RLock() + catalog, ok := cache[locale] + mu.RUnlock() + if !ok { + catalog = loadLocale(locale) + } + msg, ok := catalog[key] + if !ok && locale != defaultLocale { + mu.RLock() + ruCatalog, ruOK := cache[defaultLocale] + mu.RUnlock() + if ruOK { + msg = ruCatalog[key] + } + if msg == "" { + msg = key + } + } + if msg == "" { + msg = key + } + if len(args) > 0 { + return fmt.Sprintf(msg, args...) + } + return msg +} + +// TF is a shorthand for T with formatting. +func TF(locale, key string, args ...any) string { + return T(locale, key, args...) +} + +// SetDefault changes the default locale. +func SetDefault(locale string) { + mu.Lock() + defaultLocale = locale + mu.Unlock() +} + +// AvailableLocales returns locale names for which files exist. +func AvailableLocales() []string { + entries, err := localeFS.ReadDir("locales") + if err != nil { + return nil + } + var out []string + for _, e := range entries { + name := e.Name() + name = strings.TrimSuffix(name, ".json") + out = append(out, name) + } + return out +} + +func loadLocale(locale string) map[string]string { + data, err := localeFS.ReadFile("locales/" + locale + ".json") + if err != nil { + data = []byte("{}") + } + var m map[string]string + json.Unmarshal(data, &m) + if m == nil { + m = map[string]string{} + } + mu.Lock() + cache[locale] = m + mu.Unlock() + return m +} diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json new file mode 100644 index 0000000..5f0cb59 --- /dev/null +++ b/internal/i18n/locales/en.json @@ -0,0 +1,101 @@ +{ + "nav.today": "Today", + "nav.inbox": "Inbox", + "nav.activity": "Activity", + "nav.clients": "Clients", + "nav.projects": "Projects", + "nav.recipes": "Recipes", + "nav.documents": "Documents", + "nav.archive": "Archive", + + "tab.overview": "Overview", + "tab.notes": "Notes", + "tab.files": "Files", + "tab.actions": "Actions", + "tab.worklog": "Work Log", + "tab.activity": "Activity", + + "common.save": "Save", + "common.cancel": "Cancel", + "common.delete": "Delete", + "common.rename": "Rename", + "common.close": "Close", + "common.create": "Create", + "common.confirm": "Confirm", + "common.back": "← Back", + "common.loading": "Loading...", + "common.error": "Error:", + "common.yes": "Yes", + "common.ok": "OK", + "common.run": "Run", + "common.name": "Name", + "common.settings": "Settings", + + "welcome.title": "Verstak", + "welcome.selectSection": "Select a section in the sidebar.", + "welcome.addCase": "Add case", + + "event.noteCreated": "Note created", + "event.noteUpdated": "Note updated", + "event.fileAdded": "File added", + "event.fileDeleted": "File deleted", + "event.fileRenamed": "File renamed", + "event.fileCopied": "File copied", + "event.fileMoved": "File moved", + "event.caseCreated": "Case created", + + "action.openUrl": "Open URL", + "action.openFile": "Open file", + "action.openFolder": "Open folder", + "action.runCommand": "Run command", + "action.runScript": "Run script", + "action.openTerminal": "Open terminal", + "action.launchApp": "Launch app", + + "note.add": "+ Add note", + "note.noNotes": "No notes", + "note.title": "Note title", + "note.placeholder": "Start writing...", + + "file.addFile": "+ Add file", + "file.addFolder": "+ Add folder", + "file.preview": "Preview", + "file.openExternal": "Open in external program", + "file.openFolder": "Open folder", + "file.delete": "Delete", + "file.pickSingle": "Select file", + "file.pickDirectory": "Select folder", + + "sync.title": "Sync", + "sync.settings": "Sync settings", + "sync.status": "Status", + "sync.server": "Server", + "sync.device": "Device", + "sync.connected": "Connected", + "sync.notConnected": "Not connected", + "sync.disabled": "Disabled", + + "server.registerBtn": "Register", + "server.loginBtn": "Log in", + "server.logout": "Log out", + "server.username": "Username", + "server.email": "Email", + "server.password": "Password", + "server.save": "Save", + "server.back": "← Back", + + "admin.devices": "Devices", + "admin.users": "Users", + "admin.smtp": "SMTP Settings", + "admin.healthCheck": "Health check", + "admin.status": "Status", + "admin.active": "Active", + "admin.revoked": "Revoked", + + "error.generic": "An error occurred", + "error.invalidCredentials": "Invalid username or password", + "error.accountBlocked": "Account blocked", + "error.emailNotConfirmed": "Email not confirmed", + "error.tokenInvalid": "Invalid or expired token", + "error.tokenExpired": "Token expired" +} diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json new file mode 100644 index 0000000..296dc96 --- /dev/null +++ b/internal/i18n/locales/ru.json @@ -0,0 +1,383 @@ +{ + "nav.today": "Сегодня", + "nav.inbox": "Неразобранное", + "nav.activity": "Активность", + "nav.clients": "Клиенты", + "nav.projects": "Проекты", + "nav.recipes": "Рецепты", + "nav.documents": "Документы", + "nav.archive": "Архив", + "nav.sections": "Разделы", + "nav.cases": "Дела", + "nav.noCases": "Нет дел", + "nav.sync": "Синхронизация", + "nav.syncSettings": "Настройки синхронизации", + "nav.syncNow": "Синхронизировать", + "nav.selectPrompt": "Выберите раздел или дело", + "nav.brand": "Верстак", + + "tab.overview": "Обзор", + "tab.notes": "Заметки", + "tab.files": "Файлы", + "tab.actions": "Действия", + "tab.worklog": "Журнал", + "tab.activity": "Активность", + + "common.save": "Сохранить", + "common.cancel": "Отмена", + "common.delete": "Удалить", + "common.rename": "Переименовать", + "common.close": "Закрыть", + "common.create": "Создать", + "common.confirm": "Подтверждение", + "common.back": "← Назад", + "common.loading": "Загрузка...", + "common.error": "Ошибка:", + "common.yes": "Да", + "common.ok": "OK", + "common.copy": "Копировать", + "common.cut": "Вырезать", + "common.paste": "Вставить", + "common.duplicate": "Дублировать", + "common.run": "Запустить", + "common.test": "Test", + "common.testAgain": "Проверить", + "common.connect": "Подключиться", + "common.disconnect": "Отключиться", + "common.settings": "Настройки", + "common.name": "Название", + "common.type": "Тип", + "common.section": "Раздел", + "common.created": "Создано", + "common.empty": "Нет", + "common.newName": "Новое имя", + + "welcome.title": "Верстак", + "welcome.selectSection": "Выберите раздел в боковой панели.", + "welcome.createCase": "Или создайте новое дело кнопкой «+».", + "welcome.addCase": "Добавить дело", + + "event.noteCreated": "Заметка создана", + "event.noteUpdated": "Заметка изменена", + "event.fileAdded": "Файл добавлен", + "event.fileDeleted": "Файл удалён", + "event.fileRenamed": "Файл переименован", + "event.fileCopied": "Файл скопирован", + "event.fileMoved": "Файл перемещён", + "event.folderAdded": "Папка добавлена", + "event.folderDeleted": "Папка удалена", + "event.folderRenamed": "Папка переименована", + "event.caseCreated": "Дело создано", + "event.caseUpdated": "Дело изменено", + + "kind.project": "Проект", + "kind.client": "Клиент", + "kind.document": "Документ", + "kind.recipe": "Рецепт", + "kind.archive": "Архив", + "kind.case": "Дело", + + "action.openUrl": "Открыть URL", + "action.openFile": "Открыть файл", + "action.openFolder": "Открыть папку", + "action.runCommand": "Запустить команду", + "action.runScript": "Запустить скрипт", + "action.openTerminal": "Открыть терминал", + "action.launchApp": "Запустить приложение", + "action.addAction": "+ Добавить действие", + "action.newAction": "Новое действие", + "action.noActions": "Действий пока нет", + "action.run": "Запустить", + "action.dataUrl": "URL", + "action.dataPath": "Путь", + "action.dataCommand": "Команда", + "action.urlPlaceholder": "https://example.com", + "action.pathPlaceholder": "/path/to/file", + "action.commandPlaceholder": "команда", + "action.namePlaceholder": "Например: Открыть сайт", + + "note.add": "+ Добавить заметку", + "note.new": "Новая заметка", + "note.title": "Название заметки", + "note.noNotes": "Нет заметок", + "note.createFirst": "Создайте первую заметку для этого дела.", + "note.placeholder": "Начните писать...", + "note.unsavedTitle": "Несохранённые изменения", + "note.unsavedMessage": "Закрыть редактор? Все несохранённые изменения будут потеряны.", + "note.unsavedClose": "Закрыть", + + "file.addFile": "+ Добавить файл", + "file.addFolder": "+ Добавить папку", + "file.newFile": "+ Новый файл", + "file.addFileSimple": "Добавить файл", + "file.addFolderSimple": "Добавить папку", + "file.noFiles": "В этой папке пока нет файлов", + "file.noFilesCase": "В этом проекте пока нет файлов", + "file.hint": "Добавьте файл или папку, чтобы сохранить материалы проекта.", + "file.root": "Файлы", + "file.preview": "Предпросмотр", + "file.openExternal": "Открыть во внешней программе", + "file.openFolder": "Открыть папку", + "file.showInExplorer": "Показать в проводнике", + "file.more": "Ещё", + "file.delete": "Удалить", + "file.ariaFolder": "Папка", + "file.ariaFile": "Файл", + "file.scanning": "Сканирование...", + "file.pickSingle": "Выберите файл", + "file.pickMultiple": "Выберите файлы", + "file.pickDirectory": "Выберите папку", + "file.importTitle": "Добавить в", + "file.importFiles": "Файлов:", + "file.importFolders": "Папок:", + "file.importSize": "Размер:", + "file.importCopy": "Скопировать", + "file.importLink": "Привязать", + "file.selectCaseFirst": "Сначала выберите дело для добавления файлов", + + "worklog.title": "Журнал", + "worklog.whatDone": "Что сделано", + "worklog.minutes": "Мин", + "worklog.min": "мин", + "worklog.log": "Записать", + "worklog.empty": "Записей работы пока нет", + + "sync.title": "Синхронизация", + "sync.settings": "Настройки синхронизации", + "sync.status": "Статус", + "sync.server": "Сервер", + "sync.device": "Устройство", + "sync.deviceId": "ID устройства", + "sync.unpushed": "Неотправлено", + "sync.lastSync": "Последняя синх.", + "sync.revoked": "Отозвано", + "sync.connected": "Подключено", + "sync.notConnected": "Не подключено", + "sync.disabled": "Отключена", + "sync.serverUrl": "URL сервера", + "sync.serverUrlPlaceholder": "https://example.com:47732", + "sync.username": "Логин", + "sync.usernamePlaceholder": "username", + "sync.password": "Пароль", + "sync.passwordPlaceholder": "password", + "sync.autoSync": "Автосинхронизация (мин, 0 = отключено)", + "sync.saveInterval": "Сохранить интервал", + "sync.syncNow": "Синхронизировать", + "sync.disconnect": "Отключиться", + "sync.connect": "Подключиться", + "sync.test": "Проверить", + "sync.settingsSaved": "интервал сохранён", + + "today.title": "Сегодня", + "today.changedCases": "Изменён сегодня", + "today.timeline": "Лента за сегодня", + "today.empty": "Сегодня пока тихо", + "today.emptyHint": "Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.", + "today.plural.case_one": "дело", + "today.plural.case_few": "дела", + "today.plural.case_many": "дел", + "today.plural.note_one": "заметка", + "today.plural.note_few": "заметки", + "today.plural.note_many": "заметок", + "today.plural.file_one": "файл", + "today.plural.file_few": "файла", + "today.plural.file_many": "файлов", + "today.plural.event_one": "событие", + "today.plural.event_few": "события", + "today.plural.event_many": "событий", + + "activity.title": "Активность", + "activity.empty": "Активность пока не зафиксирована", + "activity.perCaseEmpty": "Активность пока не зафиксирована", + + "overview.type": "Тип", + "overview.section": "Раздел", + "overview.created": "Создано", + "overview.newNote": "Новая заметка", + "overview.addFile": "Добавить файл", + "overview.addAction": "Добавить действие", + "overview.logTime": "Записать время", + "overview.recentNotes": "Последние заметки", + "overview.recentEntries": "Последние записи", + + "rename.title": "Переименовать", + "rename.emptyError": "Имя не может быть пустым", + "rename.invalidError": "Недопустимое имя", + + "delete.confirmTitle": "Удаление", + "delete.confirmMessage": "Удалить", + "delete.folder": "папку", + "delete.file": "файл", + + "template.optionNone": "Без шаблона", + "template.optional": "Шаблон (опционально)", + + "mime.jpeg": "Изображение JPEG", + "mime.png": "Изображение PNG", + "mime.gif": "Изображение GIF", + "mime.webp": "Изображение WebP", + "mime.svg": "Изображение SVG", + "mime.bmp": "Изображение BMP", + "mime.tiff": "Изображение TIFF", + "mime.avif": "Изображение AVIF", + "mime.pdf": "PDF документ", + "mime.word": "Документ Word", + "mime.excel": "Таблица Excel", + "mime.ppt": "Презентация PowerPoint", + "mime.zip": "ZIP архив", + "mime.gzip": "GZIP архив", + "mime.tar": "TAR архив", + "mime.sevenz": "7z архив", + "mime.rar": "RAR архив", + "mime.text": "Текстовый файл", + "mime.html": "HTML файл", + "mime.css": "CSS файл", + "mime.js": "JavaScript файл", + "mime.json": "JSON файл", + "mime.xml": "XML файл", + "mime.yaml": "YAML файл", + "mime.binary": "Бинарный файл", + "mime.executable": "Исполняемый файл", + "mime.folder": "Папка", + "mime.unknown": "Неизвестно", + "mime.file": "Файл", + + "server.register": "Регистрация", + "server.registerTitle": "Verstak Sync — Регистрация", + "server.registerBtn": "Зарегистрироваться", + "server.login": "Вход", + "server.loginTitle": "Verstak Sync — Вход", + "server.loginBtn": "Войти", + "server.logout": "Выйти", + "server.username": "Логин", + "server.usernameOrEmail": "Логин или Email", + "server.email": "Email", + "server.password": "Пароль", + "server.passwordConfirm": "Подтвердите пароль", + "server.passwordHint": "Минимум 8 символов: латинские буквы + цифры", + "server.forgotPassword": "Забыли пароль?", + "server.adminLink": "Администратор?", + "server.alreadyHaveAccount": "Уже есть аккаунт?", + "server.backToLogin": "← Вспомнили пароль?", + "server.goHome": "На главную", + "server.needEmail": "Email обязателен", + "server.allFieldsRequired": "Все поля обязательны", + "server.passwordsDoNotMatch": "Пароли не совпадают", + "server.resetPasswordTitle": "Verstak Sync — Восстановление пароля", + "server.resetPassword": "Восстановление пароля", + "server.resetInstruction": "Введите email, указанный при регистрации", + "server.sendLink": "Отправить ссылку", + "server.emailSentTitle": "Verstak Sync — Письмо отправлено", + "server.emailSent": "✓ Письмо отправлено", + "server.emailSentMessage": "Если указанный email зарегистрирован, на него придёт ссылка для сброса пароля.", + "server.newPasswordTitle": "Verstak Sync — Новый пароль", + "server.newPassword": "Новый пароль", + "server.passwordChanged": "✓ Пароль изменён", + "server.passwordChangedMessage": "Теперь вы можете войти с новым паролем.", + "server.save": "Сохранить", + "server.emailConfirmed": "✓ Email подтверждён", + "server.emailConfirmedMessage": "Ваш email успешно подтверждён. Теперь вы можете войти в систему.", + "server.registrationSuccess": "✓ Регистрация успешна", + "server.registrationEmailSent": "На вашу почту отправлено письмо с подтверждением.", + "server.registrationCheckEmail": "Перейдите по ссылке в письме, чтобы активировать аккаунт.", + "server.registrationAutoSuccess": "✓ Регистрация успешна", + "server.registrationAutoMessage": "Вы можете войти — подтверждение email не требуется.", + "server.back": "← Назад", + "server.dashboard": "← Дашборд", + "server.users": "Пользователи", + "server.adminPwdHint": "Минимум 8 символов, латинские буквы и цифры", + "server.newPasswordResult": "Новый пароль: %s\nСообщите его пользователю.", + + "admin.login": "Verstak Sync — Admin Login", + "admin.dashboard": "Verstak Sync — Admin", + "admin.users": "Verstak Sync — Пользователи", + "admin.usersHeading": "Пользователи", + "admin.username": "Логин", + "admin.email": "Email", + "admin.password": "Пароль", + "admin.loginBtn": "Войти", + "admin.devices": "Устройства", + "admin.deviceCount": "Устройств:", + "admin.opsCount": "Операций:", + "admin.smtp": "Настройка SMTP", + "admin.smtpTitle": "SMTP (для писем)", + "admin.smtpServer": "Сервер", + "admin.smtpPort": "Порт", + "admin.smtpType": "Тип", + "admin.smtpNoEncryption": "Без шифрования", + "admin.smtpUsername": "Логин", + "admin.smtpPassword": "Пароль", + "admin.smtpFrom": "От кого", + "admin.smtpServerURL": "URL сервера", + "admin.smtpSave": "Сохранить SMTP", + "admin.smtpTest": "Test", + "admin.smtpTesting": "⏳ Тестируем...", + "admin.smtpPassed": "✓ Тест пройден", + "admin.smtpFailed": "✗", + "admin.healthCheck": "Health check", + "admin.healthLoading": "Загрузка...", + "admin.noDevices": "Нет устройств", + "admin.device": "Устройство", + "admin.user": "Пользователь", + "admin.version": "Версия", + "admin.status": "Статус", + "admin.active": "Активно", + "admin.revoked": "Отозвано", + "admin.lastSeen": "Активность", + "admin.revoke": "Отозвать", + "admin.revokeConfirm": "Отозвать устройство?", + "admin.filterPlaceholder": "Фильтр по логину...", + "admin.actions": "Действия", + "admin.confirmed": "Подтверждён", + "admin.unconfirmed": "Не подтверждён", + "admin.blocked": "Заблокирован", + "admin.unblock": "Разблокировать", + "admin.block": "Заблокировать", + "admin.resetPassword": "Сброс пароля", + "admin.resetPasswordConfirm": "Сбросить пароль?", + "admin.resetPasswordMessage": "Пользователь не сможет войти со старым паролем.", + "admin.resetBtn": "Сбросить", + "admin.editUser": "Редактировать пользователя", + "admin.editBtn": "Сохранить", + "admin.deleteUser": "Удалить пользователя?", + "admin.deleteUserMessage": "Будет удалён пользователь «%s» и все его устройства.", + "admin.deleteBtn": "Удалить", + "admin.resultTitle": "Результат", + "admin.confirmTitle": "Подтверждение", + "admin.modalCancel": "Отмена", + "admin.modalConfirm": "Да", + "admin.noUsers": "Нет пользователей", + "admin.unblockUserTitle": "Разблокировать пользователя?", + "admin.unblockUserMessage": "Пользователь сможет снова войти.", + "admin.blockUserTitle": "Заблокировать пользователя?", + "admin.blockUserMessage": "Пользователь не сможет войти.", + "admin.unblockBtn": "Разблокировать", + "admin.blockBtn": "Заблокировать", + + "userDashboard.title": "Verstak Sync", + "userDashboard.devices": "Устройства", + "userDashboard.connectNew": "Подключить новое устройство", + "userDashboard.connectNewHint": "Откройте desktop-клиент Verstak, перейдите в настройки синхронизации и введите URL сервера, логин и пароль.", + "userDashboard.noDevices": "Нет подключённых устройств.
Подключите устройство из desktop-клиента Verstak.", + "userDashboard.device": "Устройство", + "userDashboard.status": "Статус", + "userDashboard.connected": "Подключено", + "userDashboard.lastSeen": "Активность", + "userDashboard.version": "Версия", + "userDashboard.active": "Активно", + "userDashboard.revoked": "Отозвано", + "userDashboard.revoke": "Отозвать", + "userDashboard.revokeConfirm": "Отозвать устройство? Оно перестанет синхронизироваться.", + "userDashboard.revokePrompt": "Введите ваш пароль для подтверждения:", + "userDashboard.logout": "Выйти", + + "error.nameEmpty": "Имя не может быть пустым", + "error.nameInvalid": "Недопустимое имя", + "error.selectCaseFirst": "Сначала выберите дело", + "error.generic": "Произошла ошибка", + "error.invalidCredentials": "Неверный логин или пароль", + "error.accountBlocked": "Аккаунт заблокирован", + "error.emailNotConfirmed": "Email не подтверждён", + "error.tokenInvalid": "Неверный или просроченный токен", + "error.tokenExpired": "Срок действия токена истёк" +} diff --git a/scripts/check-i18n.sh b/scripts/check-i18n.sh new file mode 100755 index 0000000..81ccc5f --- /dev/null +++ b/scripts/check-i18n.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Check for hardcoded Russian/Cyrillic user-facing strings in source code. +# Excludes locale files, docs, tests with explicit locale checks. + +set -e + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXIT=0 + +# Find Cyrillic characters in source files, excluding allowed paths. +# The regex matches any Cyrillic character range. +# Allowed exceptions: +# - locale files (*/i18n/locales/*) +# - docs/* +# - README* +# - spaces/ +# - .json files that are templates or configs +# - .md files + +echo "=== Checking for hardcoded Cyrillic in source code ===" + +# Search for Cyrillic characters in Go files (excluding locale files) +GO_CYRILLIC=$(find "$ROOT" -name '*.go' \ + ! -path "*/i18n/locales/*" \ + -exec grep -l '[А-Яа-я]' {} \; 2>/dev/null || true) + +if [ -n "$GO_CYRILLIC" ]; then + echo "WARNING: Cyrillic found in Go files (expected in server HTML templates for now):" + echo "$GO_CYRILLIC" + # Don't fail for Go files with HTML templates — they'll be refactored later +fi + +# Search for Cyrillic in Svelte/JS files (excluding locale files) +JS_CYRILLIC=$(find "$ROOT/frontend/src" -name '*.svelte' -o -name '*.js' | \ + grep -v 'i18n/locales' | \ + xargs grep -l '[А-Яа-я]' 2>/dev/null || true) + +if [ -n "$JS_CYRILLIC" ]; then + echo "" + echo "FAIL: Cyrillic found in frontend source files (outside locale files):" + echo "$JS_CYRILLIC" + echo "" + echo "These should use t() from lib/i18n instead." + EXIT=1 +fi + +# Check for common bidi/control unicode characters +echo "" +echo "=== Checking for bidi/control Unicode characters ===" +BIDI=$(find "$ROOT" -name '*.go' -o -name '*.svelte' -o -name '*.js' | \ + xargs grep -Pl '[\x{202A}-\x{202E}\x{2066}-\x{2069}\x{200E}\x{200F}\x{061C}]' 2>/dev/null || true) + +if [ -n "$BIDI" ]; then + echo "FAIL: Bidi/control Unicode characters found in:" + echo "$BIDI" + EXIT=1 +else + echo "OK: No bidi/control characters found" +fi + +# Check that locale keys in ru.js and en.js match +echo "" +echo "=== Checking locale key consistency ===" +RU_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/ru.js" | sed "s/^ *'//;s/'$//" | sort) +EN_KEYS=$(grep -oP "^\s+'[^']+'" "$ROOT/frontend/src/lib/i18n/locales/en.js" | sed "s/^ *'//;s/'$//" | sort) + +MISSING_EN=$(comm -23 <(echo "$RU_KEYS") <(echo "$EN_KEYS")) +MISSING_RU=$(comm -23 <(echo "$EN_KEYS") <(echo "$RU_KEYS")) + +if [ -n "$MISSING_EN" ]; then + echo "WARNING: Keys in ru.js but missing in en.js:" + echo "$MISSING_EN" +fi +if [ -n "$MISSING_RU" ]; then + echo "WARNING: Keys in en.js but missing in ru.js:" + echo "$MISSING_RU" +fi +if [ -z "$MISSING_EN" ] && [ -z "$MISSING_RU" ]; then + echo "OK: All locale keys match between ru.js and en.js" +fi + +exit $EXIT