Author SHA1 Message Date
mirivlad 99e47fcb17 feat: add user registration web form at /register 2026-06-01 23:46:25 +08:00
mirivlad 0ef54c31f8 feat: user web GUI — login, dashboard with devices/keys, logout 2026-06-01 23:40:48 +08:00
mirivlad b3662d4876 test: update smoke test for user auth flow 2026-06-01 23:36:38 +08:00
mirivlad f8dc436709 feat: client auth — login/password flow, auto device reg, sync interval + improved sync UI 2026-06-01 23:36:19 +08:00
mirivlad 241a9d8c06 feat: user registration, email confirmation, login, device management + SMTP config UI in admin panel 2026-06-01 23:33:58 +08:00
mirivlad 5db3da3618 fix: protect device register with admin auth; improve admin UI (full API key, copy button, styling) 2026-06-01 23:22:19 +08:00
mirivlad e828ebd44e docs: add sync server installation and usage guide 2026-06-01 23:13:59 +08:00
mirivlad 84c0bcbcab test: add E2E smoke test for sync 2026-06-01 23:07:24 +08:00
mirivlad a1a50863c5 gui: add sync settings panel in Svelte 2026-06-01 22:58:12 +08:00
mirivlad 1abe8c4fa0 cli: add sync push/pull/status commands 2026-06-01 22:56:05 +08:00
mirivlad 5b2cec5bcc sync: fix SyncStatus binding — remove invalid type assertion, use config for device ID 2026-06-01 22:55:50 +08:00
mirivlad 1a20edac44 feat: sync — client ops recording in core services
- internal/core/sync/: Service, Client, Blob packages

- RecordOp creates sync_ops entries for all mutations

- Client for push/pull/blob HTTP to server

- Blob SHA-256 hashing and local storage

- Wired into app.go alongside activity recording

- Device ID from config or fallback
2026-06-01 22:54:23 +08:00
mirivlad ad684eb118 feat: sync — push/pull API endpoints
- POST /api/v1/sync/push — accepts ops, assigns revisions, returns accepted list

- POST /api/v1/sync/pull — returns ops since given revision with server_revision
2026-06-01 22:51:30 +08:00
mirivlad 10c6d06e38 feat: sync — blob upload/download with SHA-256 storage
- POST /api/v1/blobs/ — multipart upload, stored as blobs/ab/cd/sha256

- GET /api/v1/blobs/{sha256} — download by hash

- server_blobs table for tracking stored blobs
2026-06-01 22:50:38 +08:00
mirivlad ec928e3be6 feat: sync — systemd unit and install.sh for server deployment
- verstak-server.service — systemd unit with sandboxing, configurable port via env

- install.sh — creates user, installs binary, sets up admin, enables service

  Usage: sudo ./install.sh --admin-user admin --admin-pass secret [--port 47732]
2026-06-01 22:49:40 +08:00
mirivlad c5e0060fee chore: add verstak-server to gitignore 2026-06-01 22:49:10 +08:00
mirivlad 834b5ef0d4 feat: sync — server skeleton with health, admin login/dashboard, device registration
- cmd/verstak-server/main.go — flags: --port, --data, --admin-user, --admin-pass

- Server DB schema: server_devices, server_revisions, server_ops

- Health endpoint GET /api/v1/health

- Admin login page + session cookie auth

- Admin dashboard with device stats and API key management

- Device registration POST /api/v1/device/register

- Stub push/pull/blob endpoints
2026-06-01 22:49:02 +08:00
mirivlad 4145b4d74a feat: sync — migration 010 for sync_ops and sync_state tables 2026-06-01 22:45:12 +08:00
mirivlad edc708a106 chore: add spaces/ to gitignore (vault data) 2026-06-01 22:17:39 +08:00
mirivlad ee708d30bb docs: sync documentation with current codebase state
- Architecture: Wails v3→v2, removed TUI/sync/security from diagram

- UI/UX: layout updated to sidebar+header, sync marked future

- Roadmap: Wails v3→v2 migration note, milestones 10+ PAUSED

- MVP Checklist: mark implemented features as done

- PLAN.md: bindings list synced, repo structure fixed, progress updated

- Medium-term steps documented (sync, scanner, TUI, Lua, etc.)
2026-06-01 22:17:29 +08:00
mirivlad 305158ecc6 test: MVP smoke test for core workflow
Covers: vault init, node tree, notes CRUD, file import, actions CRUD, worklog, search (FTS5 optional), reopen persistence, soft delete, worklog report
2026-06-01 22:17:25 +08:00
mirivlad 996322f3a9 gui: actions CRUD + FromTemplate bindings + UI
- CreateAction / DeleteAction Wails bindings

- FromTemplate / ListTemplates bindings with recursive tree creation

- Plugin manager stored in App struct for template access

- Action creation modal (title, kind, data) in Overview and Actions tabs

- Delete action button on action cards

- Template selector in new-node dialog
2026-06-01 22:17:18 +08:00
mirivlad a098cf721c add missing Wails bindings for ListActivityFeed, ListActivityByNode, CountActivityByNode 2026-06-01 22:00:58 +08:00
mirivlad 3672e3133b activity: global feed, per-case log, sidebar section, today UX
- migration 009: target_type, target_id, target_path columns
- new Event fields: TargetType, TargetID, TargetPath
- ListActivityFeed (paginated global), ListActivityByNode (per-case)
- all Record() callsites pass target info
- frontend: Активность sidebar section with chronological feed
- per-case Активность tab with real data (was placeholder)
- today events: clickable, target-type badges, event counts
2026-06-01 02:53:56 +08:00
mirivlad 5a1c4c6d7f simplify ListTodayView: remove fallback table queries, pure activity_events + ListTodayNodes
TodayView now uses only activity_events (source of truth) + ListTodayNodes (ensure changed cases appear). Removed direct queries to nodes (notes) and files tables — those will come from activity_events going forward.
2026-06-01 02:45:55 +08:00
mirivlad 08c9d5dbea gitignore: fix verstak-gui pattern to root-only, add .verstak/ 2026-06-01 02:16:25 +08:00
mirivlad c74fa3ad43 today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar 2026-06-01 02:16:13 +08:00
mirivlad 69891e395c Today view and Inbox section: dynamic query, not stored sections
- ListTodayView() on backend: queries nodes created or updated today
  using local timezone day boundaries
- todayBoundaries() helper returns start/end of current day in RFC3339
- Section validation: Create() rejects today and inbox as node sections
- validSections moved from repository.go to types.go with IsValidSection
  and IsServiceSection helpers
- Frontend selectSection('today') calls ListTodayView instead of
  ListNodesBySection('today')
- FAB (create node) hidden in today and inbox sections
- CreateNode in app.go guarded against today/inbox sections
- types.go: today/inbox defined as service sections (sidebar only)
2026-06-01 01:39:02 +08:00
mirivlad a4ae22c445 File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
2026-06-01 01:35:45 +08:00
53 changed files with 4913 additions and 304 deletions
+7 -2
View File
@@ -22,8 +22,13 @@ go.work
frontend/dist/
frontend/node_modules/
frontend/bindings/
verstak-gui
verstak-cli
/verstak-gui
/verstak-cli
/verstak-server
# Vault data
.verstak/
spaces/
# VS Code
.vscode/
BIN
View File
Binary file not shown.
+532 -3
View File
@@ -6,16 +6,22 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
)
@@ -28,9 +34,12 @@ type App struct {
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
}
@@ -115,6 +124,47 @@ type SearchResultDTO struct {
Type string `json:"type"`
}
type EventDTO struct {
ID string `json:"id"`
NodeID string `json:"nodeId"`
EventType string `json:"eventType"`
TargetType string `json:"targetType"`
TargetID string `json:"targetId"`
TargetPath string `json:"targetPath"`
Title string `json:"title"`
DetailsJSON string `json:"detailsJson"`
CreatedAt string `json:"createdAt"`
}
type CaseActivityDTO struct {
Node NodeDTO `json:"node"`
Events []EventDTO `json:"events"`
}
type SummaryDTO struct {
ChangedCases int `json:"changedCases"`
Notes int `json:"notes"`
Files int `json:"files"`
Actions int `json:"actions"`
TimeEntries int `json:"timeEntries"`
}
type TodayGroupDTO struct {
NodeID string `json:"nodeId"`
NodeTitle string `json:"nodeTitle"`
NodeKind string `json:"nodeKind"`
Section string `json:"section"`
LastActivityAt string `json:"lastActivityAt"`
Events []EventDTO `json:"events"`
}
type TodayDashboardDTO struct {
Date string `json:"date"`
Summary SummaryDTO `json:"summary"`
Groups []TodayGroupDTO `json:"groups"`
Events []EventDTO `json:"events"`
}
// ============================================================
// Sections
// ============================================================
@@ -123,6 +173,7 @@ 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: "Рецепты"},
@@ -143,6 +194,175 @@ func (a *App) ListNodesBySection(section string) ([]NodeDTO, error) {
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 {
@@ -161,10 +381,15 @@ func (a *App) GetNodeDetail(nodeID string) (*NodeDTO, error) {
}
func (a *App) CreateNode(parentID, nodeType, title, section string) (*NodeDTO, error) {
if section == "today" || section == "inbox" {
return nil, fmt.Errorf("cannot create node with section %q", section)
}
n, err := a.nodes.Create(parentID, nodeType, title, section)
if err != nil {
return nil, err
}
_ = a.activity.Record(n.ID, activity.TargetNode, n.ID, "", activity.TypeNodeCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNode, n.ID, syncsvc.OpCreate, map[string]string{"title": title})
dto := toNodeDTO(n)
return &dto, nil
}
@@ -173,6 +398,66 @@ 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
// ============================================================
@@ -198,6 +483,8 @@ func (a *App) CreateNote(parentID, title string) (*NodeDTO, error) {
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetNote, node.ID, "", activity.TypeNoteCreated, title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, node.ID, syncsvc.OpCreate, map[string]string{"title": title})
dto := toNodeDTO(node)
return &dto, nil
}
@@ -209,7 +496,19 @@ func (a *App) ReadNote(noteID string) (string, error) {
// SaveNote saves note content.
func (a *App) SaveNote(noteID, content string) error {
return a.notes.Save(noteID, content)
if err := a.notes.Save(noteID, content); err != nil {
return err
}
// Record note_updated event.
if n, err := a.nodes.GetActive(noteID); err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetNote, noteID, "", activity.TypeNoteUpdated, n.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityNote, noteID, syncsvc.OpUpdate, map[string]string{"title": n.Title})
}
return nil
}
// ============================================================
@@ -278,6 +577,10 @@ func (a *App) AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
}
return toNodeDTOs(nodes), nil
}
@@ -286,10 +589,33 @@ func (a *App) AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error) {
if err != nil {
return nil, err
}
for _, n := range nodes {
_ = a.activity.Record(nodeID, activity.TargetFile, n.ID, "", activity.TypeFileAdded, n.Title, `{"source":"`+sourcePath+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, n.ID, syncsvc.OpCreate, map[string]string{"title": n.Title})
}
return toNodeDTOs(nodes), nil
}
func (a *App) DeleteFileOrFolder(nodeID string) error {
n, err := a.nodes.GetActive(nodeID)
if err == nil {
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileDeleted
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderDeleted
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, n.Title, "")
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpDelete, nil)
}
return a.files.DeleteNodeAndChildren(nodeID)
}
@@ -298,6 +624,8 @@ func (a *App) CreateEmptyFile(parentID, filename string) (*NodeDTO, error) {
if err != nil {
return nil, err
}
_ = a.activity.Record(parentID, activity.TargetFile, node.ID, "", activity.TypeFileAdded, filename, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": filename})
dto := toNodeDTO(node)
return &dto, nil
}
@@ -307,16 +635,80 @@ func (a *App) DuplicateNode(nodeID string) (*NodeDTO, error) {
if err != nil {
return nil, err
}
// Find parent for recording
n, err2 := a.nodes.GetActive(nodeID)
pid := ""
if err2 == nil && n.ParentID != nil {
pid = *n.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, node.ID, "", activity.TypeFileCopied, node.Title, "")
_ = a.sync.RecordOp(syncsvc.EntityFile, node.ID, syncsvc.OpCreate, map[string]string{"title": node.Title})
dto := toNodeDTO(node)
return &dto, nil
}
func (a *App) RenameNode(nodeID, newTitle string) error {
return a.nodes.UpdateTitle(nodeID, newTitle)
n, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
oldTitle := n.Title
if err := a.nodes.UpdateTitle(nodeID, newTitle); err != nil {
return err
}
pid := ""
if n.ParentID != nil {
pid = *n.ParentID
}
evType := activity.TypeFileRenamed
targetType := activity.TargetFile
if n.Type == nodes.TypeFolder {
evType = activity.TypeFolderRenamed
targetType = activity.TargetFolder
}
_ = a.activity.Record(pid, targetType, nodeID, "", evType, newTitle, `{"from":"`+oldTitle+`","to":"`+newTitle+`"}`)
syncEntity := syncsvc.EntityFile
if n.Type == nodes.TypeFolder {
syncEntity = syncsvc.EntityFolder
}
_ = a.sync.RecordOp(syncEntity, nodeID, syncsvc.OpUpdate, map[string]string{"title": newTitle})
return nil
}
func (a *App) ValidateName(name string) error {
return files.ValidateName(name)
}
func (a *App) MoveNode(nodeID, newParentID string) error {
return a.nodes.Move(nodeID, newParentID, 0)
// Check for name conflict at destination
destChildren, err := a.nodes.ListChildren(newParentID, false)
if err != nil {
return err
}
node, err := a.nodes.GetActive(nodeID)
if err != nil {
return err
}
for i := range destChildren {
if destChildren[i].Title == node.Title {
// Conflict: auto-rename
newName := a.files.UniqueTitleCopy(newParentID, node.Title)
if err := a.nodes.UpdateTitle(nodeID, newName); err != nil {
return err
}
break
}
}
if err := a.nodes.Move(nodeID, newParentID, 0); err != nil {
return err
}
pid := ""
if node.ParentID != nil {
pid = *node.ParentID
}
_ = a.activity.Record(pid, activity.TargetFile, nodeID, "", activity.TypeFileMoved, node.Title, `{"to":"`+newParentID+`"}`)
_ = a.sync.RecordOp(syncsvc.EntityFile, nodeID, syncsvc.OpMove, map[string]string{"title": node.Title})
return nil
}
func (a *App) PreviewImport(sourcePath string) (*files.ImportSummary, error) {
@@ -349,6 +741,26 @@ func (a *App) ListActions(nodeID string) ([]ActionDTO, error) {
return result, nil
}
func (a *App) CreateAction(nodeID, kind, title, data string) (*ActionDTO, error) {
rec, err := a.actions.Create(nodeID, kind, title, data, "", data, nil, kind == "run_command" || kind == "run_script", false)
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityAction, rec.ID, syncsvc.OpCreate, map[string]string{"title": rec.Title, "kind": rec.Kind})
return &ActionDTO{
ID: rec.ID,
NodeID: rec.NodeID,
Title: rec.Title,
Type: rec.Kind,
Data: data,
}, nil
}
func (a *App) DeleteAction(id string) error {
_ = a.sync.RecordOp(syncsvc.EntityAction, id, syncsvc.OpDelete, nil)
return a.actions.Delete(id)
}
func (a *App) RunAction(id string) error {
_, err := a.actions.Run(id)
return err
@@ -385,6 +797,7 @@ func (a *App) CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, e
if err != nil {
return nil, err
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, map[string]string{"summary": summary})
mins := 0
if entry.Minutes != nil {
mins = *entry.Minutes
@@ -423,6 +836,122 @@ func (a *App) Search(query string) ([]SearchResultDTO, error) {
return out, nil
}
// ============================================================
// Sync
// ============================================================
type SyncStatusDTO struct {
Configured bool `json:"configured"`
ServerURL string `json:"serverUrl"`
DeviceID string `json:"deviceId"`
UnpushedOps int `json:"unpushedOps"`
LastSyncAt string `json:"lastSyncAt"`
SyncInterval int `json:"syncInterval"`
}
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
if err != nil {
return &SyncStatusDTO{}, nil
}
unpushed, _ := a.sync.GetUnpushedOps()
cfg, _ := config.Load(a.vault)
dto := &SyncStatusDTO{
Configured: serverURL != "" && apiKey != "",
ServerURL: serverURL,
UnpushedOps: len(unpushed),
LastSyncAt: lastSyncAt,
}
if cfg != nil {
dto.DeviceID = cfg.Sync.DeviceID
dto.SyncInterval = cfg.Sync.SyncInterval
}
return dto, nil
}
func (a *App) SyncConfigure(serverURL, username, password string) error {
// Register device on server with user credentials.
hostname, _ := os.Hostname()
if hostname == "" {
hostname = "unknown"
}
client := syncsvc.NewClient(serverURL, "", "", a.vault)
deviceID, apiKey, err := client.RegisterDeviceWithAuth(hostname, username, password)
if err != nil {
return fmt.Errorf("register: %w", err)
}
if err := a.sync.SetState(serverURL, apiKey); err != nil {
return err
}
// Persist to vault config.
cfg, err := config.Load(a.vault)
if err != nil {
return err
}
cfg.Sync.ServerURL = serverURL
cfg.Sync.APIKey = apiKey
cfg.Sync.DeviceID = deviceID
return config.Save(a.vault, cfg)
}
func (a *App) SyncTestConnection(serverURL, username, password string) error {
client := syncsvc.NewClient(serverURL, "", "", a.vault)
_, _, err := client.RegisterDeviceWithAuth("test-connection", username, password)
return err
}
func (a *App) SyncSetInterval(minutes int) error {
cfg, err := config.Load(a.vault)
if err != nil {
return err
}
cfg.Sync.SyncInterval = minutes
return config.Save(a.vault, cfg)
}
func (a *App) SyncNow() (map[string]interface{}, error) {
serverURL, apiKey, lastRev, _, err := a.sync.GetState()
if err != nil || serverURL == "" || apiKey == "" {
return nil, fmt.Errorf("sync not configured")
}
deviceID := ""
if cfg, err := config.Load(a.vault); err == nil {
deviceID = cfg.Sync.DeviceID
}
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
// Push unpushed ops.
unpushed, err := a.sync.GetUnpushedOps()
if err != nil {
return nil, fmt.Errorf("get ops: %w", err)
}
pushResult := &syncsvc.PushResponse{}
if len(unpushed) > 0 {
pushResult, err = client.Push(unpushed)
if err != nil {
return nil, fmt.Errorf("push: %w", err)
}
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
return nil, fmt.Errorf("mark pushed: %w", err)
}
}
// Pull remote ops.
pullResult, err := client.Pull(lastRev)
if err != nil {
return nil, fmt.Errorf("pull: %w", err)
}
return map[string]interface{}{
"pushed": len(pushResult.Accepted),
"pulled": len(pullResult.Ops),
"serverRevision": pullResult.ServerRevision,
}, nil
}
// ============================================================
// File Dialogs (Wails v2 Runtime)
// ============================================================
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
<script type="module" crossorigin src="/assets/main-DZkGJWBF.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BnVt-oqm.css">
</head>
<body>
<div id="app"></div>
+19 -1
View File
@@ -7,12 +7,15 @@ import (
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2"
@@ -46,18 +49,33 @@ func main() {
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
pm := plugins.NewManager(abs)
pm.Discover()
// Sync service — use configured device ID or vault ID as fallback.
deviceID := ""
if cfg, err := config.Load(abs); err == nil {
deviceID = cfg.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
activity: activitySvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
sync: syncSvc,
vault: abs,
}
+110
View File
@@ -0,0 +1,110 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
#
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
BIN="./verstak-server"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
# 2. Install binary.
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
exit 1
fi
echo "Installing binary to /usr/local/bin/verstak-server"
cp "$BIN" /usr/local/bin/verstak-server
chmod 755 /usr/local/bin/verstak-server
# 3. Create data directory.
echo "Creating /var/lib/verstak-server"
mkdir -p /var/lib/verstak-server
chown "$USER:$USER" /var/lib/verstak-server
chmod 750 /var/lib/verstak-server
# 4. Set up admin user (first run).
echo "Setting up admin user"
/usr/local/bin/verstak-server \
--port "$PORT" \
--data /var/lib/verstak-server \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
SERVER_PID=$!
sleep 2
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
flag.Parse()
absData, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("data dir: %v", err)
}
if err := os.MkdirAll(absData, 0750); err != nil {
log.Fatalf("create data dir: %v", err)
}
cfg, err := LoadConfig(absData)
if err != nil {
log.Fatalf("config: %v", err)
}
// First-run admin setup.
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
}
// Open server DB.
dbPath := filepath.Join(absData, "server.db")
srv, err := NewServer(dbPath, absData, cfg)
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Verstak Sync Server
Documentation=https://github.com/anomalyco/verstak
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
Restart=on-failure
RestartSec=5
User=verstak
Group=verstak
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
StateDirectory=verstak-server
RuntimeDirectory=verstak-server
[Install]
WantedBy=multi-user.target
+161
View File
@@ -8,9 +8,11 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
@@ -38,6 +40,8 @@ func main() {
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
@@ -56,6 +60,7 @@ func usage() {
fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions")
fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help")
}
@@ -597,6 +602,162 @@ func runIndexRebuild(args []string) {
fmt.Printf("indexed %d nodes\n", count)
}
// --- sync ---
func runSync(args []string) {
if len(args) == 0 {
fmt.Println("verstak sync — synchronize with server")
fmt.Println()
fmt.Println("Usage: verstak sync <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" push Push local changes to server")
fmt.Println(" pull Pull remote changes from server")
fmt.Println(" status Show sync status")
os.Exit(0)
}
switch args[0] {
case "push":
runSyncPush(args[1:])
case "pull":
runSyncPull(args[1:])
case "status":
runSyncStatus(args[1:])
case "--help", "-h":
runSync(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
os.Exit(1)
}
}
func openSyncDB(args []string) (*storage.DB, string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
return db, abs
}
func runSyncPush(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
unpushed, err := syncSvc.GetUnpushedOps()
if err != nil {
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
os.Exit(1)
}
if len(unpushed) == 0 {
fmt.Println("Nothing to push.")
return
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
os.Exit(1)
}
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
}
func runSyncPull(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastRev, _, err := syncSvc.GetState()
if err != nil {
lastRev = 0
}
result, err := client.Pull(lastRev)
if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1)
}
var opIDs []string
for _, op := range result.Ops {
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
opIDs = append(opIDs, op.OpID)
}
if len(opIDs) > 0 {
syncSvc.MarkApplied(opIDs)
}
fmt.Printf("Pulled %d ops (server rev: %d)\n", len(result.Ops), result.ServerRevision)
}
func runSyncStatus(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
serverURL := ""
deviceID := ""
if cfg != nil {
serverURL = cfg.Sync.ServerURL
deviceID = cfg.Sync.DeviceID
}
unpushed := 0
if configured {
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
ops, _ := syncSvc.GetUnpushedOps()
unpushed = len(ops)
}
fmt.Println("Sync Status")
fmt.Println(" Configured:", configured)
fmt.Println(" Server:", serverURL)
fmt.Println(" Device:", deviceID)
fmt.Println(" Unpushed ops:", unpushed)
}
// --- plugin ---
func runPlugin(args []string) {
+1
View File
@@ -37,6 +37,7 @@
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
## Главные принципы
+4 -6
View File
@@ -4,12 +4,12 @@
```text
┌────────────────────┐
│ GUI/Wails │
│ GUI/Wails v2 │
└─────────┬──────────┘
│
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
└─────────────┘ └───────┬────────┘ └─────────────┘
┌───────▼────────┐ ┌─────────────┐
│ Core Library │◀───│ CLI Commands │
└───────┬────────┘ └─────────────┘
│
┌─────────▼──────────┐
│ Local Vault+SQLite │
@@ -161,8 +161,6 @@ verstak/
activity/
search/
importers/
sync/
security/
config/
gui/
+8 -8
View File
@@ -18,23 +18,23 @@
- import/dokuwiki/mysql-cleanup.txt
Быстрые действия:
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
```
## 2. Общий layout
```text
┌────────────────────────────────────────────────────┐
│ Search | Quick Add | Sync Status | Settings │
├───────────────┬────────────────────────────────────┤
│ Tree sidebar │ Main content │
│ │ │
│ Сегодня │ Case / Note / File / Search │
│ Клиенты │ │
┌───────────────┬────────────────────────────────────┐
│ Tree sidebar │ Header: Section/Node Title │
│ ├────────────────────────────────────┤
│ Сегодня │ Main content │
│ Клиенты │ Case / Note / File / Search │
│ Проекты │ │
│ Рецепты │ │
│ Документы │ │
└───────────────┴────────────────────────────────────┘
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
```
## 3. Sidebar tree
+9 -5
View File
@@ -1,5 +1,7 @@
# Верстак — roadmap
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
## Milestone 0 — Skeleton
- Go module;
@@ -65,6 +67,8 @@ Acceptance:
## Milestone 4 — GUI MVP
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
- Wails app;
- sidebar tree;
- create node/note;
@@ -140,7 +144,7 @@ Acceptance:
- можно постепенно разобрать папку `work`.
## Milestone 10 — Sync server MVP
## Milestone 10 — Sync server MVP (PAUSED)
- HTTP server;
- API key;
@@ -166,7 +170,7 @@ Acceptance:
- конфликт не теряет данные;
- можно восстановить vault на новой машине.
## Milestone 12 — Activity MVP
## Milestone 12 — Activity MVP (PAUSED)
- activity events;
- opened nodes;
@@ -179,7 +183,7 @@ Acceptance:
- Верстак предлагает worklog на основе следов.
## Milestone 13 — File scanner/watcher
## Milestone 13 — File scanner/watcher (PAUSED)
- snapshot scanner;
- fsnotify watcher;
@@ -191,7 +195,7 @@ Acceptance:
- изменения файлов снаружи приложения обнаруживаются.
## Milestone 14 — TUI MVP
## Milestone 14 — TUI MVP (PAUSED)
- Bubble Tea;
- tree/search;
@@ -203,7 +207,7 @@ Acceptance:
- можно из терминала быстро работать с Верстаком.
## Milestone 15 — 1.0 polish
## Milestone 15 — 1.0 polish (PAUSED)
- installers;
- backup export;
+13 -13
View File
@@ -6,19 +6,19 @@
## Обязательные функции
- [ ] Создать vault.
- [ ] Создать дерево дел.
- [ ] Создать Markdown-заметку.
- [ ] Редактировать Markdown-заметку.
- [ ] Добавить файл в дело.
- [ ] Открыть файл системным приложением.
- [ ] Создать action “Открыть URL”.
- [ ] Создать action “Открыть папку”.
- [ ] Создать action “Запустить команду”.
- [ ] Добавить запись работы.
- [ ] Скопировать отчёт по работам.
- [ ] Поиск по заметкам.
- [ ] Поиск по именам файлов.
- [x] Создать vault.
- [x] Создать дерево дел.
- [x] Создать Markdown-заметку.
- [x] Редактировать Markdown-заметку.
- [x] Добавить файл в дело.
- [x] Открыть файл системным приложением.
- [x] Создать action “Открыть URL”.
- [x] Создать action “Открыть папку”.
- [x] Создать action “Запустить команду”.
- [x] Добавить запись работы.
- [x] Скопировать отчёт по работам.
- [x] Поиск по заметкам.
- [x] Поиск по именам файлов.
- [x] Поиск по журналу работ.
- [x] Базовый импорт DokuWiki (плагин).
+185
View File
@@ -0,0 +1,185 @@
# Сервер синхронизации Верстак — руководство
## 1. Зачем нужен сервер
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
Он не обязателен — Верстак работает полностью локально и без сервера.
## 2. Установка
### Быстрая установка (systemd)
```bash
# Сборка бинарника
go build -o verstak-server ./cmd/verstak-server/
# Установка (от root)
sudo ./verstak-server/install.sh \
--port 47732 \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Скрипт:
- создаёт системного пользователя `verstak`;
- копирует бинарник в `/usr/local/bin/verstak-server`;
- создаёт `/var/lib/verstak-server` для данных;
- записывает админа в конфиг;
- устанавливает systemd-сервис `verstak-server.service`;
- запускает и включает автозапуск.
### Ручной запуск (для тестирования)
```bash
# Одной командой
./verstak-server \
--port 47732 \
--data ./server-data \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Флаги:
| Флаг | По умолчанию | Описание |
|---|---|---|
| `--port` | `47732` | Порт сервера |
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
| `--admin-pass` | — | Пароль администратора |
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
## 3. Админ-панель
### Как открыть
Откройте в браузере: `http://<сервер>:47732/admin/login`
Войдите с логином и паролем, указанными при установке.
### Что там есть
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей.
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
### Несколько админов
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
## 4. API-ключи (устройства)
### Что такое API-ключ
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
### Как создать
Через админ-панель:
1. Зайти в `/admin/dashboard`.
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
3. Скопировать сгенерированный ключ.
Через API (требует логин и пароль администратора):
```bash
curl -X POST http://localhost:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-ноутбук","username":"admin","password":"пароль-админа"}'
```
Ответ: `{"device_id":"...","api_key":"..."}`
**Важно:** не выставляйте сервер в интернет без HTTPS (через reverse proxy). До создания полноценной системы пользователей регистрация устройств требует учётных данных администратора.
### Как использовать
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
```bash
curl -X POST http://localhost:47732/api/v1/sync/push \
-H "Authorization: Bearer b10c5d8e3f2a..." \
-H "Content-Type: application/json" \
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
```
В клиенте Верстак достаточно вбить URL сервера и API-ключ в настройках (GUI Settings или `config.yml`).
### Один ключ на все устройства или отдельный на каждое?
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в админ-панели), не затронув остальные.
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
- его нельзя будет удалить, не отключив все устройства разом;
- в логах сервера все операции будут выглядеть как от одного устройства;
- это не поддерживаемый сценарий.
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
## 5. Настройка клиента
### CLI
```bash
verstak sync push --vault /путь/к/vault
verstak sync pull --vault /путь/к/vault
verstak sync status --vault /путь/к/vault
```
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
```yaml
sync:
server_url: http://мой-сервер:47732
api_key: мой-ключ
device_id: id-устройства # опционально
auto_sync: false
```
### GUI
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера и API-ключ, нажмите "Сохранить". Кнопка "Синхронизировать" запускает push + pull.
## 6. Безопасность
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
Что стоит учесть:
- Регистрация устройств открыта — любой, кто достучался до сервера, может создать ключ.
- Нет logout'а — сессия живёт 24 часа или до перезапуска сервера.
- Нет rate limiting'а — возможен перебор пароля.
- Пароль хранится в bcrypt — база данных не должна быть общедоступной.
Рекомендации для production:
- Закрыть порт сервера фаерволом (только доверенные IP).
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
- Или поставить nginx/Caddy перед сервером с HTTPS и базовой аутентификацией на `/api/v1/device/register`.
## 7. Полный API
### Открытые endpoint'ы
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/health` | Проверка здоровья сервера |
### Требуют API-ключ (Authorization: Bearer)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
### Требуют логин+пароль администратора
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/device/register` | Регистрация устройства (body: name + username + password) |
### Требуют сессию админа (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/admin/login` | Страница входа / отправка формы |
| GET | `/admin/dashboard` | Дашборд |
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
| POST | `/admin/api/keys` | Создать ключ |
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
+94 -61
View File
@@ -21,21 +21,21 @@
| 8 | FTS5 Search | ✅ выполнен |
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
| 13 | **Drag-and-drop** | ⬜ не начат |
| 14 | **MVP stabilization** | ⬜ не начат |
| 15 | Sync Server Skeleton | 🔒 PAUSED |
| 16 | Sync Client MVP | 🔒 PAUSED |
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
| 19 | Integrity Check + Repair | 🔒 PAUSED |
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
| 22 | Calendar/Kanban | 🔒 PAUSED |
| 23 | New templates/integrations | 🔒 PAUSED |
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
@@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
**GUI Build (Wails v2):**
```bash
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
./verstak-gui
```
Или для dev режима: `wails dev` (требует Wails v2 CLI)
---
## Текущий этап: Wails v2 Vertical MVP
## Текущий этап: MVP Stabilization
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
**Прогресс:**
**Прогресс Wails v2 Desktop GUI:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- 🔄 Notes bindings + UI
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- 🔄 Node creation
- 🔄 Section filtering
- ✅ Notes bindings + UI (create/read/save/dirty state)
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- ✅ Node creation + template selection (FromTemplate)
- ✅ Section filtering
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
- ✅ Drag-and-drop (internal + external OS file drops)
- ✅ Actions CRUD (create/list/run/delete)
- ✅ Worklog entry form
- ✅ Today dashboard + Activity feed (global + per-case)
- ✅ Search
- ✅ Import dialog with safety checks (PreviewImport)
- ✅ Keyboard shortcuts
**Пауза (не начинать до завершения vertical MVP):**
- Файлы/папки workflow
- Drag-and-drop
- Sync, plugins, Lua, browser extension, TUI
- Новые шаблоны, DokuWiki importer
**Среднесрочные шаги (заморожены до стабилизации MVP):**
- Sync server/client
- File Scanner/Watcher
- TUI (Bubble Tea)
- Activity suggestions
- Lua runtime
- Browser extension
---
@@ -175,38 +177,65 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
4. Перенести текущий UI shell из inline HTML в frontend
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
### Backend bindings (минимум)
### Backend bindings (Wails v2 — реализовано)
```go
// Nodes
ListSections() ([]SectionDTO, error)
ListNodes(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
ListSections() []SectionDTO
ListNodesBySection(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (*NodeDTO, error)
ListChildren(parentID string) ([]NodeDTO, error)
CreateNode(parentID, typ, title, section string) (*NodeDTO, error)
DeleteNode(id string) error
MoveNode(id, parentID string) error
MoveNode(nodeID, newParentID string) error
RenameNode(nodeID, newTitle string) error
// Templates
ListTemplates() []TemplateDTO
FromTemplate(parentID, typ, title, section, template string) (*NodeDTO, error)
// Notes
CreateNote(parentID, title string) (NodeDTO, error)
ListNotes(nodeID string) ([]NodeDTO, error)
CreateNote(parentID, title string) (*NodeDTO, error)
ReadNote(noteID string) (string, error)
SaveNote(noteID, content string) error
// Files
ListFiles(nodeID string) ([]FileDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
ListItems(nodeID string) ([]FileTreeItemDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error)
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
DeleteFileOrFolder(id string) error
OpenFile(id string) error
OpenFolder(id string) error
DeleteFileOrFolder(nodeID string) error
CreateEmptyFile(parentID, filename string) (*NodeDTO, error)
DuplicateNode(nodeID string) (*NodeDTO, error)
OpenFile(fileID string) error
OpenFolder(nodeID string) error
ReadFileText(fileID string) (string, error)
GetFileBase64(fileID string) (string, error)
PreviewImport(sourcePath string) (*ImportSummary, error)
ValidateName(name string) error
PickFile() (string, error)
PickFiles() ([]string, error)
PickDirectory() (string, error)
// Actions/Worklog/Search
// Actions
ListActions(nodeID string) ([]ActionDTO, error)
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
DeleteAction(id string) error
RunAction(id string) error
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
// Worklog
ListWorklog(nodeID string) ([]WorklogDTO, error)
CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error)
// Search
Search(query string) ([]SearchResultDTO, error)
// Activity
ListTodayView() (*TodayDashboardDTO, error)
ListActivityFeed(limit, offset int) ([]EventDTO, error)
ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error)
CountActivityByNode(nodeID string) (int, error)
```
---
@@ -311,18 +340,22 @@ verstak/
cmd/
verstak/ # CLI
verstak-gui/ # Wails GUI main
verstak-tui/ # Bubble Tea TUI
verstak-server/ # Sync server
verstak-gui/ # Wails v2 GUI main
frontend/ # Wails frontend
frontend/ # Wails v2 Svelte frontend
package.json
wails.json
vite.config.js
src/
App.svelte
components/
stores/
styles/
FileTreeRow.svelte
lib/
FileBreadcrumbs.svelte
FilePreviewModal.svelte
ConfirmModal.svelte
FileIcon.svelte
FileActions.svelte
fileUtils.js
api/verstak.js
internal/
core/
@@ -335,10 +368,8 @@ verstak/
worklog/
activity/
search/
sync/
security/
config/
plugins/ # manager, lua (stub)
plugins/ # manager, lua (stub), builtin templates
contrib/
plugins/
@@ -351,11 +382,13 @@ verstak/
004_add_notes.sql
005_add_actions.sql
006_add_worklog.sql
007_search.sql
008_sync.sql
009_section.sql
```
## RAID
- **Критично:** Wails требует Node.js для frontend-сборки
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
- **Зависимость:** Steps 15+ ждут завершения step 14
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
+663 -32
View File
@@ -26,6 +26,12 @@
// ===== State =====
let sections = []
let nodes = []
let todayDashboard = null
let activityFeed = []
let activityOffset = 0
let activityHasMore = true
let activityLoading = false
let caseActivity = []
let version = ''
let error = ''
let selectedSection = ''
@@ -41,8 +47,23 @@
let showCreateNode = false
let newNodeTitle = ''
let newNodeSection = 'clients'
let newNodeTemplate = ''
let templates = []
let showCreateNote = false
let newNoteTitle = ''
let showCreateAction = false
let newActionTitle = ''
let newActionKind = 'open_url'
let newActionData = ''
let actionKinds = [
{ id: 'open_url', label: 'Открыть URL' },
{ id: 'open_file', label: 'Открыть файл' },
{ id: 'open_folder', label: 'Открыть папку' },
{ id: 'run_command', label: 'Запустить команду' },
{ id: 'run_script', label: 'Запустить скрипт' },
{ id: 'open_terminal', label: 'Открыть терминал' },
{ id: 'launch_app', label: 'Запустить приложение' },
]
let loading = true
let importing = false
let importSummary = null
@@ -72,6 +93,21 @@
let confirmAction = null
let cancelAction = null
let showRename = false
let renameId = ''
let renameValue = ''
let renameError = ''
// ===== Sync state =====
let showSettings = false
let syncStatus = null
let syncLoading = false
let syncServerUrl = ''
let syncUsername = ''
let syncPassword = ''
let syncInterval = 0
let syncResult = ''
const tabs = [
{ id: 'overview', label: 'Обзор' },
{ id: 'notes', label: 'Заметки' },
@@ -94,6 +130,7 @@
sections = [
{ id: 'today', label: 'Сегодня' },
{ id: 'inbox', label: 'Неразобранное' },
{ id: 'activity', label: 'Активность' },
{ id: 'clients', label: 'Клиенты' },
{ id: 'projects', label: 'Проекты' },
{ id: 'recipes', label: 'Рецепты' },
@@ -111,6 +148,7 @@
window.addEventListener('keydown', handleKeydown)
loading = false
loadSyncStatus()
})
onDestroy(() => {
@@ -129,11 +167,26 @@
worklog = []
showCreateNode = false
error = ''
todayDashboard = null
activityFeed = []
activityOffset = 0
activityHasMore = true
nodes = []
try {
if (id === 'today') {
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
} else if (id === 'activity') {
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
activityOffset = activityFeed.length
activityHasMore = activityFeed.length === 50
} else {
nodes = await wailsCall('ListNodesBySection', id) || []
}
} catch (e) {
error = String(e)
nodes = []
todayDashboard = { cases: [] }
activityFeed = []
}
}
@@ -158,6 +211,7 @@
showCreateNode = false
showCreateNote = false
error = ''
caseActivity = []
await loadTabData(node.id)
}
@@ -166,6 +220,7 @@
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
}
async function loadTree(nodeID) {
@@ -282,19 +337,8 @@
}
function renameItem(id) {
const name = prompt('Новое имя:')
if (!name || !name.trim()) return
renameSubmit(id, name.trim())
}
async function renameSubmit(id, newName) {
try {
await wailsCall('RenameNode', id, newName)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
const item = fileItems.find(x => x.id === id)
if (item) openRename(item.id, item.name)
}
function cutItem(id) {
@@ -343,6 +387,20 @@
selectedIds = fileItems.map(x => x.id)
}
function rangeSelect(id) {
if (fileItems.length === 0) return
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
const lastIdx = fileItems.findIndex(x => x.id === lastId)
const curIdx = fileItems.findIndex(x => x.id === id)
if (lastIdx === -1 || curIdx === -1) return
const start = Math.min(lastIdx, curIdx)
const end = Math.max(lastIdx, curIdx)
const range = fileItems.slice(start, end + 1).map(x => x.id)
const set = new Set(selectedIds)
range.forEach(x => set.add(x))
selectedIds = [...set]
}
function clearSelection() {
selectedIds = []
}
@@ -421,13 +479,104 @@
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
if (e.ctrlKey || e.metaKey) {
if (e.key === 'c') { e.preventDefault(); copySelected() }
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
else if (e.key === 'a') { e.preventDefault(); selectAll() }
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
} else if (e.key === 'Enter') {
e.preventDefault()
openSelected()
} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
if (previewItem) { e.preventDefault(); closePreview(); return }
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
// Backspace without selection → navigate up
if (e.key === 'Backspace' && folderStack.length > 0) {
e.preventDefault()
navigateBack()
return
}
} else if (e.key === 'Escape') {
if (previewItem) { closePreview(); return }
if (selectedIds.length > 0) { clearSelection(); return }
} else if (e.key === 'F2') {
e.preventDefault()
openRenameForSelection()
}
}
function openSelected() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
if (item.type === 'folder') {
navigateToFolder(item.id)
} else {
openPreview(item)
}
}
}
}
function openSelectedExternal() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item && item.fileId) {
wailsCall('OpenFile', item.fileId)
}
}
}
// ===== Rename modal =====
function openRename(id, currentName) {
renameId = id
renameValue = currentName
renameError = ''
showRename = true
}
function openRenameForSelection() {
if (selectedIds.length === 1) {
const item = fileItems.find(x => x.id === selectedIds[0])
if (item) {
openRename(item.id, item.name)
}
}
}
async function submitRename() {
const name = renameValue.trim()
if (!name) { renameError = 'Имя не может быть пустым'; return }
// Validate name via backend
try {
await wailsCall('ValidateName', name)
} catch (e) {
renameError = 'Недопустимое имя'
return
}
showRename = false
renameId = ''
try {
await wailsCall('RenameNode', renameId, name)
const parentId = currentFolderId || selectedNode.id
await loadFolder(parentId)
} catch (e) {
error = String(e)
}
}
function cancelRename() {
showRename = false
renameId = ''
renameValue = ''
renameError = ''
}
function onRenameKeydown(e) {
if (e.key === 'Enter') submitRename()
else renameError = ''
}
// ===== Confirm modal =====
@@ -463,14 +612,22 @@
showCreateNode = true
newNodeTitle = ''
newNodeSection = selectedSection || 'clients'
newNodeTemplate = ''
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
}
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
async function submitCreateNode() {
if (!newNodeTitle.trim()) return
try {
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
let node
if (newNodeTemplate) {
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
} else {
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
}
showCreateNode = false
newNodeTitle = ''
newNodeTemplate = ''
await selectSection(newNodeSection)
} catch (e) { error = String(e) }
}
@@ -652,10 +809,154 @@
// ===== Helpers =====
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
function eventLabel(type) {
const labels = {
'note_created': 'Заметка создана',
'note_updated': 'Заметка изменена',
'file_added': 'Файл добавлен',
'file_deleted': 'Файл удалён',
'file_renamed': 'Файл переименован',
'file_copied': 'Файл скопирован',
'file_moved': 'Файл перемещён',
'folder_added': 'Папка добавлена',
'folder_deleted': 'Папка удалена',
'folder_renamed': 'Папка переименована',
'node_created': 'Дело создано',
'node_updated': 'Дело изменено',
}
return labels[type] || type
}
function eventIcon(type) {
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
if (type === 'file_copied') return '⧉'
if (type === 'file_moved') return '→'
return '•'
}
function formatTime(str) {
if (!str) return ''
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
}
function formatDate(str) {
if (!str) return ''
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
}
function nodeKindLabel(kind) {
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
return labels[kind] || kind || 'Дело'
}
function pluralize(n, one, few, many) {
n = Math.abs(n) % 100
if (n >= 5 && n <= 20) return many
n %= 10
if (n === 1) return one
if (n >= 2 && n <= 4) return few
return many
}
// ===== Actions =====
function openCreateAction() {
showCreateAction = true
newActionTitle = ''
newActionKind = 'open_url'
newActionData = ''
}
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
async function submitCreateAction() {
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
try {
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
if (action && action.id) {
actions = [...actions, action]
}
showCreateAction = false
newActionTitle = ''
newActionData = ''
} catch (e) { error = String(e) }
}
async function deleteAction(id) {
try {
await wailsCall('DeleteAction', id)
actions = actions.filter(a => a.id !== id)
} catch (e) { error = String(e) }
}
function actionKindLabel(kind) {
const k = actionKinds.find(k => k.id === kind)
return k ? k.label : kind
}
async function openNodeById(id) {
try {
const node = await wailsCall('GetNodeDetail', id)
if (node) selectNode(node)
} catch (e) {
error = String(e)
}
}
// ===== Sync =====
async function loadSyncStatus() {
try {
syncStatus = await wailsCall('SyncStatus')
} catch (e) {
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
}
}
function openSettings() {
showSettings = true
syncServerUrl = syncStatus?.serverUrl || ''
syncUsername = ''
syncPassword = ''
syncInterval = syncStatus?.syncInterval || 0
syncResult = ''
}
function closeSettings() {
showSettings = false
syncResult = ''
}
async function saveSyncConfig() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
if (syncInterval > 0) {
await wailsCall('SyncSetInterval', syncInterval)
}
syncResult = 'ok'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
async function testConnection() {
syncLoading = true
syncResult = ''
try {
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
syncResult = 'connection ok'
} catch (e) {
syncResult = 'connection failed: ' + String(e)
}
syncLoading = false
}
async function runSyncNow() {
syncLoading = true
syncResult = ''
try {
const r = await wailsCall('SyncNow')
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (rev ' + r.serverRevision + ')'
await loadSyncStatus()
} catch (e) {
syncResult = 'err: ' + String(e)
}
syncLoading = false
}
</script>
<div class="app">
@@ -675,7 +976,7 @@
</button>
{/each}
</div>
{#if selectedSection}
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="nav-group">
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
{#each nodes as node}
@@ -688,7 +989,14 @@
</div>
{/if}
</nav>
<div class="sidebar-footer"><span class="version">{version}</span></div>
<div class="sidebar-footer">
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
<span class="sync-dot" class:active={syncStatus?.configured}></span>
<span class="sidebar-sync-label">Синхронизация</span>
</button>
<span class="version">{version}</span>
</div>
</aside>
<!-- Main -->
@@ -704,6 +1012,16 @@
<span class="crumb placeholder">Выберите раздел или дело</span>
{/if}
</div>
<div class="header-right">
{#if syncStatus?.configured}
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{#if syncStatus.unpushedOps > 0}
<span class="sync-badge">{syncStatus.unpushedOps}</span>
{/if}
</button>
{/if}
</div>
</header>
{#if error}
@@ -757,7 +1075,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
Добавить файл
</button>
<button class="qa-btn" disabled title="Следующий этап">
<button class="qa-btn" on:click={openCreateAction}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
Добавить действие
</button>
@@ -832,7 +1150,7 @@
</div>
{:else}
{#if folderStack.length > 0}
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
const i = e.detail
if (i === 0) {
folderStack = []
@@ -847,7 +1165,7 @@
Back
</button>
{:else}
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
{/if}
{#if fileItems.length === 0}
@@ -877,6 +1195,7 @@
on:navigate={(e) => navigateToFolder(e.detail)}
on:preview={(e) => openPreview(e.detail)}
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
on:delete={(e) => deleteFile(e.detail)}
on:rename={(e) => renameItem(e.detail.id)}
on:duplicate={(e) => duplicateItem(e.detail)}
@@ -884,6 +1203,7 @@
on:copy={(e) => copyItem(e.detail)}
on:selectOne={(e) => selectOne(e.detail)}
on:toggleSelect={(e) => toggleSelection(e.detail)}
on:rangeSelect={(e) => rangeSelect(e.detail)}
/>
{/each}
</div>
@@ -907,16 +1227,30 @@
{/if}
{:else if activeTab === 'actions'}
<div class="actions-tab">
<div class="tab-toolbar">
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
</div>
{#if actions.length === 0}
<div class="empty-state"><p>Действий пока нет</p></div>
{:else}
{#each actions as action}
<div class="action-card">
<span>{action.title}</span><span class="action-type">{action.type}</span>
<div class="action-info">
<span class="action-title">{action.title}</span>
<span class="action-type">{actionKindLabel(action.type)}</span>
<span class="action-data">{action.data}</span>
</div>
<div class="action-btns">
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</div>
</div>
{/each}
{/if}
</div>
{:else if activeTab === 'worklog'}
<div class="worklog-tab">
@@ -939,7 +1273,111 @@
</div>
{:else if activeTab === 'activity'}
<div class="empty-state"><p>Активность появится позже</p></div>
<div class="activity-tab">
{#if caseActivity.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
{:else}
<div class="activity-events">
{#each caseActivity as ev}
<div class="activity-event">
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
<span class="activity-event-title">{ev.title}</span>
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
{:else if selectedSection === 'today' && todayDashboard}
<div class="today-dashboard">
<div class="today-header">
<h2>Сегодня</h2>
<span class="today-date">{todayDashboard.date}</span>
</div>
{#if todayDashboard.summary}
<div class="today-summary">
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
</div>
{/if}
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
{#each todayDashboard.groups as group}
<div class="today-case">
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-case-title">{group.nodeTitle}</span>
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
</div>
{#if group.events && group.events.length > 0}
<div class="today-events">
{#each group.events as ev}
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
<span class="today-event-title">{ev.title}</span>
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{:else}
<div class="today-events-empty">Изменён сегодня</div>
{/if}
</div>
{/each}
{#if todayDashboard.events && todayDashboard.events.length > 0}
<div class="today-timeline">
<h3>Лента за сегодня</h3>
{#each todayDashboard.events as ev}
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<span class="timeline-dot"></span>
<span class="timeline-title">{ev.title}</span>
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
</div>
{/each}
</div>
{/if}
{:else}
<div class="today-empty">
<p>Сегодня пока тихо</p>
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
</div>
{/if}
</div>
{:else if selectedSection === 'activity'}
<div class="activity-feed">
<div class="activity-feed-header">
<h2>Активность</h2>
</div>
{#if activityFeed.length === 0}
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
{:else}
<div class="activity-feed-events">
{#each activityFeed as ev}
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
<div class="activity-feed-body">
<span class="activity-feed-title">{ev.title}</span>
<div class="activity-feed-meta">
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -954,7 +1392,7 @@
</div>
{/if}
{#if !noteEditor && !selectedNode}
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
{/if}
@@ -970,11 +1408,22 @@
<div class="form-group">
<label>Раздел</label>
<select bind:value={newNodeSection}>
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
<option value={s.id}>{s.label}</option>
{/each}
</select>
</div>
{#if templates.length > 0}
<div class="form-group">
<label>Шаблон (опционально)</label>
<select bind:value={newNodeTemplate}>
<option value="">Без шаблона</option>
{#each templates as t}
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
{/each}
</select>
</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
@@ -983,6 +1432,37 @@
</div>
{/if}
{#if showCreateAction}
<div class="modal-overlay" on:click|self={cancelCreateAction}>
<div class="modal">
<h3>Новое действие</h3>
<div class="form-group">
<label>Название</label>
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
</div>
<div class="form-group">
<label>Тип</label>
<select bind:value={newActionKind}>
{#each actionKinds as k}
<option value={k.id}>{k.label}</option>
{/each}
</select>
</div>
<div class="form-group">
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
bind:value={newActionData}
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
</div>
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showImportDialog && importSummary}
<div class="modal-overlay" on:click|self={cancelImport}>
<div class="modal">
@@ -1007,6 +1487,26 @@
</div>
{/if}
{#if showRename}
<div class="modal-overlay" on:click|self={cancelRename}>
<div class="modal">
<h3>Переименовать</h3>
<div class="form-group">
<label>Новое имя</label>
<input type="text" bind:value={renameValue}
on:keydown={onRenameKeydown} />
</div>
{#if renameError}
<div class="rename-error">{renameError}</div>
{/if}
<div class="modal-actions">
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
<button class="btn" on:click={cancelRename}>Отмена</button>
</div>
</div>
</div>
{/if}
{#if showConfirm}
<ConfirmModal
title={confirmTitle}
@@ -1017,6 +1517,48 @@
on:cancel={handleCancel}
/>
{/if}
{#if showSettings}
<div class="modal-overlay" on:click|self={closeSettings}>
<div class="modal modal-sync">
<h3>Настройки синхронизации</h3>
{#if syncStatus}
<div class="sync-status">
<div class="sync-row"><span class="sync-label">Статус</span><span class="sync-value">{syncStatus.configured ? 'Включена' : 'Отключена'}</span></div>
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl || '—'}</span></div>
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value mono">{syncStatus.deviceId || '—'}</span></div>
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt || '—'}</span></div>
</div>
{/if}
<div class="form-group">
<label>URL сервера</label>
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
</div>
<div class="form-group">
<label>Логин</label>
<input type="text" placeholder="username" bind:value={syncUsername} />
</div>
<div class="form-group">
<label>Пароль</label>
<input type="password" placeholder="password" bind:value={syncPassword} />
</div>
<div class="form-group">
<label>Автосинхронизация (мин)</label>
<input type="number" placeholder="0 = отключено" bind:value={syncInterval} min="0" />
</div>
{#if syncResult}
<div class="sync-result">{syncResult}</div>
{/if}
<div class="modal-actions">
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
<button class="btn" on:click={runSyncNow} disabled={syncLoading || !syncStatus?.configured}>Синхронизировать</button>
<button class="btn" on:click={closeSettings}>Закрыть</button>
</div>
</div>
</div>
{/if}
</main>
</div>
@@ -1036,12 +1578,18 @@
.nav-item:hover { background: #222233; }
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
.version { font-size: 11px; color: #555; }
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
.version { font-size: 11px; color: #555; text-align: center; }
/* Main */
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
.header-right { display: flex; align-items: center; gap: 8px; }
.header-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #b0b0c0; font-family: inherit; font-size: 13px; position: relative; }
.header-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
.crumb { font-size: 14px; font-weight: 500; }
.crumb.placeholder { color: #666; }
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
@@ -1107,8 +1655,15 @@
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
/* Actions */
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
.actions-tab { padding: 24px; }
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.action-title { font-weight: 500; }
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
.action-btns .btn-danger:hover { background: #3a2222; }
/* Empty states */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
@@ -1159,4 +1714,80 @@
.import-summary { margin-bottom: 16px; }
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
/* Today Dashboard */
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
.today-header h2 { font-size: 24px; }
.today-date { font-size: 13px; color: #666; }
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
.today-case-header:hover { background: #1e1e30; }
.today-case-title { font-weight: 500; }
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
.today-events { padding: 8px 16px; }
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; }
.today-event:hover { color: #e4e4ef; }
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
.today-event-title { flex: 1; }
.today-event-type { font-size: 11px; color: #666; }
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
.today-empty { padding: 48px 24px; text-align: center; }
.today-empty p { color: #666; font-size: 14px; margin: 0; }
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
.today-timeline { margin-top: 24px; }
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; }
.timeline-event:hover { color: #e4e4ef; }
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
.timeline-title { flex: 1; }
.timeline-type { font-size: 11px; color: #666; }
.timeline-time { font-size: 11px; color: #555; }
/* Activity tab (per-case) */
.activity-tab { padding: 24px; }
.activity-events { display: flex; flex-direction: column; gap: 2px; }
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
/* Activity feed (global section) */
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
.activity-feed-header { margin-bottom: 20px; }
.activity-feed-header h2 { font-size: 24px; }
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
.activity-feed-body { flex: 1; min-width: 0; }
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.activity-feed-type { font-size: 11px; color: #666; }
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
.activity-feed-time { font-size: 11px; color: #555; }
/* Sync */
.sidebar-sync-btn { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #888; font-family: inherit; font-size: 13px; }
.sidebar-sync-btn:hover { background: #222233; color: #e4e4ef; border-color: #6366f1; }
.sidebar-sync-label { flex: 1; text-align: left; }
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
.modal-sync { width: 460px; }
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
.sync-label { color: #666; }
.sync-value { color: #e4e4ef; }
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
</style>
+92 -46
View File
@@ -1,7 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
export let item
export let selected = false
@@ -13,6 +13,7 @@
const kind = getFileKind(item)
const isFolder = item.type === 'folder'
const fileType = formatFileType(item)
let menuOpen = false
let clickTimer = null
@@ -25,7 +26,7 @@
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
// Double click
// Double click → открыть
if (isFolder) {
dispatch('navigate', item.id)
} else {
@@ -34,37 +35,23 @@
} else {
clickTimer = setTimeout(() => {
clickTimer = null
// Single click: select
if (selected) {
// Already selected: navigate/preview
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
// Single click → выделить
dispatch('selectOne', item.id)
}
}, 250)
}
}
}
function handleKeydown(e) {
if (e.key === 'Enter') {
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
function handleShowInFolder() {
dispatch('showInFolder', item.id)
}
function handleDelete() {
menuOpen = false
dispatch('delete', { id: item.id, type: item.type })
}
@@ -88,6 +75,15 @@
dispatch('copy', item.id)
}
function handleOpen() {
menuOpen = false
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
function toggleMenu() {
menuOpen = !menuOpen
}
@@ -107,48 +103,48 @@
function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id)
}
function oncontextmenu(e) {
e.preventDefault()
menuOpen = true
}
</script>
<svelte:window on:click={closeMenu}/>
<div class="file-row"
class:file-row--selected={selected}
class:file-row--dragover={false}
role="button"
tabindex="0"
draggable="true"
on:click={handleClick}
on:keydown={handleKeydown}
on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
<div class="file-row-icon">
<FileIcon {kind} size={22}/>
</div>
<div class="file-row-body">
<div class="file-row-name" title={item.name}>{item.name}</div>
<div class="file-row-meta">
{#if isFolder}
<span>Folder</span>
{:else}
<span>{formatFileSize(item.size)}</span>
{#if item.mime}
<span>{fileType}</span>
{#if !isFolder}
<span class="meta-sep">·</span>
<span>{formatMimeType(item.mime)}</span>
{/if}
<span>{formatFileSize(item.size)}</span>
{/if}
</div>
</div>
<div class="file-row-actions">
{#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
@@ -156,21 +152,21 @@
</svg>
</button>
{:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
{/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
@@ -183,10 +179,42 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
<button class="menu-item" on:click={handleOpen} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Открыть
</button>
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть во внешней программе
</button>
{#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Показать в проводнике
</button>
{/if}
<div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать
</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать
</button>
<button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать
</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать
</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Удалить
</button>
</div>
{/if}
@@ -201,6 +229,7 @@
transition: background 0.12s;
min-height: 52px;
user-select: none;
position: relative;
}
.file-row:hover {
@@ -314,17 +343,19 @@
margin-top: 4px;
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 6px;
border-radius: 8px;
padding: 4px;
z-index: 100;
min-width: 140px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
min-width: 220px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.menu-item {
display: block;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 6px 12px;
padding: 7px 10px;
border: none;
background: transparent;
color: #ccc;
@@ -337,10 +368,25 @@
.menu-item:hover {
background: #2a2a3c;
color: #fff;
}
.menu-item-danger {
color: #ff6b6b;
}
.menu-item-danger:hover {
background: #3a2222;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-sep {
height: 1px;
background: #2a2a3c;
margin: 4px 8px;
}
</style>
+2
View File
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
// Re-export all methods
export const listSections = () => App.ListSections()
export const listTodayView = () => App.ListTodayView()
export const listNodesBySection = (section) => App.ListNodesBySection(section)
export const listChildren = (parentID) => App.ListChildren(parentID)
export const getNodeDetail = (id) => App.GetNodeDetail(id)
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
export const validateName = (name) => App.ValidateName(name)
export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id)
+7 -7
View File
@@ -43,7 +43,7 @@
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
@@ -60,11 +60,11 @@
</header>
<div class="preview-body">
{#if loading}
<div class="preview-status"><p>Loading preview...</p></div>
<div class="preview-status"><p>Загрузка...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{:else if showImage && content}
<div class="preview-image-container">
@@ -79,14 +79,14 @@
</div>
{:else}
<div class="preview-status">
<p>PDF preview is not available in this environment.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<p>Предпросмотр PDF недоступен.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Preview is not available for this file type.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
<p>Предпросмотр недоступен для этого типа файлов.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
</div>
+41 -31
View File
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
}
const mimeLabels = {
'image/jpeg': 'JPEG image',
'image/png': 'PNG image',
'image/gif': 'GIF image',
'image/webp': 'WebP image',
'image/svg+xml': 'SVG image',
'image/bmp': 'BMP image',
'image/tiff': 'TIFF image',
'image/avif': 'AVIF image',
'application/pdf': 'PDF document',
'application/msword': 'Word document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
'application/vnd.ms-excel': 'Excel spreadsheet',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
'application/zip': 'ZIP archive',
'application/gzip': 'GZIP archive',
'application/x-tar': 'TAR archive',
'application/x-7z-compressed': '7z archive',
'application/x-rar-compressed': 'RAR archive',
'text/plain': 'Text file',
'text/html': 'HTML file',
'text/css': 'CSS file',
'text/javascript': 'JavaScript file',
'application/json': 'JSON file',
'application/xml': 'XML file',
'application/x-yaml': 'YAML file',
'application/octet-stream': 'Binary file',
'application/x-msdos-program': 'Executable',
'inode/directory': 'Folder',
'image/jpeg': 'Изображение JPEG',
'image/png': 'Изображение PNG',
'image/gif': 'Изображение GIF',
'image/webp': 'Изображение WebP',
'image/svg+xml': 'Изображение SVG',
'image/bmp': 'Изображение BMP',
'image/tiff': 'Изображение TIFF',
'image/avif': 'Изображение AVIF',
'application/pdf': 'PDF документ',
'application/msword': 'Документ Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
'application/vnd.ms-excel': 'Таблица Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
'application/zip': 'ZIP архив',
'application/gzip': 'GZIP архив',
'application/x-tar': 'TAR архив',
'application/x-7z-compressed': '7z архив',
'application/x-rar-compressed': 'RAR архив',
'text/plain': 'Текстовый файл',
'text/html': 'HTML файл',
'text/css': 'CSS файл',
'text/javascript': 'JavaScript файл',
'application/json': 'JSON файл',
'application/xml': 'XML файл',
'application/x-yaml': 'YAML файл',
'application/octet-stream': 'Бинарный файл',
'application/x-msdos-program': 'Исполняемый файл',
'inode/directory': 'Папка',
}
export function formatMimeType(mime) {
if (!mime) return 'Unknown'
if (!mime) return 'Неизвестно'
return mimeLabels[mime] || mime
}
export function formatFileType(item) {
if (item.type === 'folder') return 'Папка'
const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (ext) return ext.toUpperCase()
return 'Файл'
}
export function getFileKind(item) {
if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase()
+20
View File
@@ -6,6 +6,10 @@ export function ListSections() {
return window['go']['main']['App']['ListSections']();
}
export function ListTodayView() {
return window['go']['main']['App']['ListTodayView']();
}
export function ListNodesBySection(arg1) {
return window['go']['main']['App']['ListNodesBySection'](arg1);
}
@@ -105,3 +109,19 @@ export function OpenFolder(arg1) {
export function VerstakVersion() {
return window['go']['main']['App']['VerstakVersion']();
}
export function ListActivityFeed(arg1, arg2) {
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
}
export function ListActivityByNode(arg1, arg2, arg3) {
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
}
export function CountActivityByNode(arg1) {
return window['go']['main']['App']['CountActivityByNode'](arg1);
}
export function CreateEmptyFile(arg1, arg2) {
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
}
+182
View File
@@ -0,0 +1,182 @@
package activity
import (
"database/sql"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Event types.
const (
TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated"
TypeActionCreated = "action_created"
TypeActionDone = "action_done"
TypeWorklogAdded = "worklog_added"
)
// Target types.
const (
TargetNote = "note"
TargetFile = "file"
TargetFolder = "folder"
TargetAction = "action"
TargetNode = "node"
TargetWorklog = "worklog"
)
// Event represents an activity event.
type Event struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
EventType string `json:"event_type"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
TargetPath string `json:"target_path,omitempty"`
Title string `json:"title"`
DetailsJSON string `json:"details_json,omitempty"`
CreatedAt string `json:"created_at"`
}
// Service records and queries activity events.
type Service struct {
db *storage.DB
}
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Record inserts a new activity event.
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
return err
}
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
func TodayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// scanEvent scans a single event row.
func scanEvent(rows *sql.Rows) (Event, error) {
var e Event
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
&e.TargetType, &e.TargetID, &e.TargetPath,
&e.Title, &e.DetailsJSON, &e.CreatedAt)
return e, err
}
// ListTodayEvents returns all activity events from today.
func (s *Service) ListTodayEvents() ([]Event, error) {
start, end := TodayBoundaries()
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE created_at >= ? AND created_at < ?
ORDER BY created_at DESC`, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListTodayEventsByParent returns today's events grouped by their NodeID.
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
events, err := s.ListTodayEvents()
if err != nil {
return nil, err
}
grouped := make(map[string][]Event, 8)
for _, e := range events {
pid := e.NodeID
grouped[pid] = append(grouped[pid], e)
}
return grouped, nil
}
// ListRecent returns a paginated global activity feed.
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListByNode returns paginated events for a specific node.
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE node_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// CountByNode returns the total number of events for a node.
func (s *Service) CountByNode(nodeID string) (int, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
return count, err
}
+1
View File
@@ -27,6 +27,7 @@ type SyncConfig struct {
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
SyncInterval int `yaml:"sync_interval"`
}
type BrowserConfig struct {
+120 -18
View File
@@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
return s.db
}
// --- security helpers ---
// ValidateName is an exported wrapper for validateName.
func ValidateName(name string) error {
return validateName(name)
}
// validateName rejects filenames with path separators, relative components,
// and other dangerous patterns.
func validateName(name string) error {
if name == "" {
return fmt.Errorf("name is required")
}
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("name must not contain path separators")
}
if strings.Contains(name, "..") {
return fmt.Errorf("name must not contain '..'")
}
if strings.Contains(name, "\x00") {
return fmt.Errorf("name must not contain null bytes")
}
if len(name) > 255 {
return fmt.Errorf("name too long (max 255)")
}
return nil
}
// vaultPath resolves a relative vault path and checks it stays within jail.
func (s *Service) vaultPath(rel string) (string, error) {
abs := filepath.Join(s.vaultRoot, rel)
cleaned := filepath.Clean(abs)
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
return "", fmt.Errorf("path escapes vault root")
}
return cleaned, nil
}
// absPathSafe resolves an absolute path and checks jail if it's under vault.
// For "external" mode records the path stored may be an absolute external path.
// This function only checks path safety — it does not enforce that external
// files must be inside the vault.
func (s *Service) absPathSafe(rec *Record) (string, error) {
if rec.StorageMode == "vault" {
return s.vaultPath(rec.Path)
}
abs, err := filepath.Abs(rec.Path)
if err != nil {
return "", fmt.Errorf("abs: %w", err)
}
return filepath.Clean(abs), nil
}
// --- public operations ---
// AddExternal registers an external file (absolute path) without copying.
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
relPath, _ := filepath.Rel(s.vaultRoot, dest)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
}
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
return err
}
if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path)
src, err := s.vaultPath(rec.Path)
if err != nil {
return err
}
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
if err := os.MkdirAll(trashDir, 0o750); err != nil {
return err
}
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
// verify trash is inside vault
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
if err != nil {
return err
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
return openWithSystem(abs)
}
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
// CreateEmptyFile creates a file node and an empty vault file.
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
if err := validateName(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
filename = s.uniqueTitle(parentID, filename)
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
if err != nil {
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
}
f.Close()
relPath, _ := filepath.Rel(s.vaultRoot, dest)
// Verify dest is inside vault
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
return node, err
}
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if original.ParentID != nil {
parentID = *original.ParentID
}
newName := s.uniqueTitle(parentID, original.Title)
newName := s.copyTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
if err != nil {
return nil, err
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
if len(records) > 0 {
src := &records[0]
if src.StorageMode == "vault" {
srcPath := filepath.Join(s.vaultRoot, src.Path)
srcPath, err := s.vaultPath(src.Path)
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
os.MkdirAll(dir, 0o750)
dst := filepath.Join(dir, newName)
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
return nil, fmt.Errorf("copy file: %w", err)
}
relPath, _ := filepath.Rel(s.vaultRoot, dst)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
if err != nil {
return nil, err
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
}
}
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
func (s *Service) copyTitle(parentID, desired string) string {
children, _ := s.nodes.ListChildren(parentID, false)
used := make(map[string]bool, len(children))
for i := range children {
used[children[i].Title] = true
}
ext := filepath.Ext(desired)
base := strings.TrimSuffix(desired, ext)
copyName := base + " (copy)" + ext
if !used[copyName] {
return copyName
}
for n := 2; ; n++ {
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
if !used[candidate] {
return candidate
}
}
}
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
return s.copyTitle(parentID, desired)
}
// --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
+53 -8
View File
@@ -50,16 +50,10 @@ func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
// --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// Create inserts a root or child node.
// parentID may be empty for root-level nodes.
// For root nodes, section determines sidebar placement (may be empty → inbox).
// For root nodes, section determines sidebar placement (may be empty = inbox).
// section must be a valid section (clients, projects, etc.) or empty for inbox.
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ)
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if title == "" {
return nil, errors.New("title is required")
}
if section != "" && !IsValidSection(section) {
return nil, fmt.Errorf("invalid section: %s", section)
}
n := &Node{
ID: util.UUID7(),
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if err != nil {
return nil, err
}
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
}
return n, nil
}
// touch updates a node's updated_at without changing other fields.
func (r *Repository) touch(id string) error {
t := now()
_, err := r.db.Exec(
`UPDATE nodes SET updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`, t, id)
return err
}
func (r *Repository) insertNode(n *Node) error {
var parent interface{}
if n.ParentID != nil {
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
return scanNodes(rows)
}
// todayBoundaries returns RFC3339 start and end strings for the current day
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
func todayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// ListTodayNodes returns active root-level nodes created or updated today.
// This is a dynamic view, not a section — it shows the day's activity.
// Child nodes (notes, files, folders) are not listed directly; instead,
// their parent is bumped via touch() on creation.
func (r *Repository) ListTodayNodes() ([]Node, error) {
start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes
WHERE deleted_at IS NULL
AND parent_id IS NULL
AND (
(created_at >= ? AND created_at < ?)
OR
(updated_at >= ? AND updated_at < ?)
)
ORDER BY updated_at DESC, created_at DESC`
rows, err := r.db.Query(q, start, end, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// UpdateTitle changes title (slug is recomputed).
func (r *Repository) UpdateTitle(id, title string) error {
if title == "" {
+29
View File
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
TypeLink: {},
}
// Valid sections for root-level nodes.
// today and inbox are service sections, not stored in nodes.section.
var validSections = map[string]struct{}{
"clients": {},
"projects": {},
"recipes": {},
"documents": {},
"archive": {},
}
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
"activity": {},
}
// IsValidType checks whether a type string is recognized.
func IsValidType(t string) bool {
_, ok := TypeSet[t]
return ok
}
// IsValidSection returns true for sections that can be stored on a node.
func IsValidSection(s string) bool {
_, ok := validSections[s]
return ok
}
// IsServiceSection returns true for sidebar-only sections (today, inbox).
func IsServiceSection(s string) bool {
_, ok := serviceSections[s]
return ok
}
// Slugify converts a title into a filesystem-safe slug.
// Examples:
//
+278
View File
@@ -0,0 +1,278 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"verstak/internal/core/actions"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestMVPSmoke(t *testing.T) {
// Create a test vault with a real filesystem area.
vaultDir := t.TempDir()
// 1. Init vault.
if err := vault.Init(vaultDir); err != nil {
t.Fatalf("vault init: %v", err)
}
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultDir, nodeRepo)
noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
// 2. Create client case structure.
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
if err != nil {
t.Fatalf("create client: %v", err)
}
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
if err != nil {
t.Fatalf("create project: %v", err)
}
// 3. Create a Markdown note.
noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
if noteNode.Title != "overview.md" {
t.Errorf("note title = %q, want overview.md", noteNode.Title)
}
// 4. Write note content.
content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление."
if err := noteSvc.Save(noteNode.ID, content); err != nil {
t.Fatalf("save note: %v", err)
}
// 5. Read note back.
got, err := noteSvc.Read(noteNode.ID)
if err != nil {
t.Fatalf("read note: %v", err)
}
if got != content {
t.Errorf("note content mismatch: got %q, want %q", got, content)
}
// 6. Verify note file exists on disk.
noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID)
notePath := ""
if len(noteFileRecs) > 0 {
notePath = noteFileRecs[0].Path
if _, err := os.Stat(notePath); os.IsNotExist(err) {
t.Errorf("note file not on disk: %s", notePath)
}
}
_ = noteRec
// 7. Add a dummy external file.
dummyPath := filepath.Join(vaultDir, "dogovor.docx")
if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil {
t.Fatal(err)
}
created, err := fileSvc.AddPathCopy(project.ID, dummyPath)
if err != nil {
t.Fatalf("add file: %v", err)
}
if len(created) == 0 {
t.Fatal("no nodes created from file import")
}
// 8. Open the imported file (just verify it opens without error).
fileNode := created[0]
if fileNode.Type != nodes.TypeFile {
t.Errorf("expected file type, got %s", fileNode.Type)
}
fileRecs, err := fileSvc.ListByNode(fileNode.ID)
if err != nil || len(fileRecs) == 0 {
t.Fatalf("file record not found for imported file")
}
_ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display)
// 9. Verify the imported file node exists as a child of the project.
children, err := nodeRepo.ListChildren(project.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
hasImported := false
for _, c := range children {
if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" {
hasImported = true
break
}
}
if !hasImported {
t.Error("imported file node not found under project")
}
// 10. Create actions.
urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
if err != nil {
t.Fatalf("create url action: %v", err)
}
if urlAction.Kind != actions.KindOpenURL {
t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL)
}
folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false)
if err != nil {
t.Fatalf("create folder action: %v", err)
}
if folderAction.Kind != actions.KindOpenFolder {
t.Errorf("action kind = %s", folderAction.Kind)
}
// 11. List actions.
actionList, err := actionSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actionList) != 2 {
t.Errorf("expected 2 actions, got %d", len(actionList))
}
// 12. Run an action (open URL — should not error).
_, err = actionSvc.Run(urlAction.ID)
if err != nil {
t.Fatalf("run url action: %v", err)
}
// 13. Add worklog.
entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false)
if err != nil {
t.Fatalf("add worklog: %v", err)
}
if entry.Minutes == nil || *entry.Minutes != 180 {
t.Errorf("worklog minutes = %v, want 180", entry.Minutes)
}
// 14. List worklog.
worklogList, err := worklogSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list worklog: %v", err)
}
if len(worklogList) != 1 {
t.Errorf("expected 1 worklog entry, got %d", len(worklogList))
}
// 15. Search for something (FTS5 may not be available — gracefully skip).
_ = searchSvc.Rebuild()
_ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note")
results, err := searchSvc.Search("WordPress")
if err != nil {
_ = err
}
if len(results) > 0 {
t.Logf("search found %d results for 'WordPress'", len(results))
} else {
t.Log("search returned no results (FTS5 may not be available)")
}
// 16. Verify section filtering.
roots, err := nodeRepo.ListRoots(false, "clients")
if err != nil {
t.Fatalf("list roots by section: %v", err)
}
found := false
for _, r := range roots {
if r.ID == client.ID {
found = true
break
}
}
if !found {
t.Error("client not found in section 'clients'")
}
// 17. Soft delete node and verify.
if err := nodeRepo.SoftDelete(project.ID); err != nil {
t.Fatalf("soft delete project: %v", err)
}
_, err = nodeRepo.GetActive(project.ID)
if err == nil {
t.Errorf("expected error getting deleted node")
}
// 18. Reopen app (simulate by reconnecting to same DB).
db2, err := storage.Open(db.Path())
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer db2.Close()
repo2 := nodes.NewRepository(db2)
// 19. Verify data persists.
client2, err := repo2.GetActive(client.ID)
if err != nil {
t.Fatalf("get client after reopen: %v", err)
}
if client2.Title != "ООО Ромашка" {
t.Errorf("client title after reopen = %q", client2.Title)
}
// 20. Verify soft-deleted node stays soft-deleted.
allNodes, err := repo2.ListChildren(client.ID, true)
if err != nil {
t.Fatalf("list children after reopen: %v", err)
}
if len(allNodes) == 0 {
t.Error("no children found after reopen")
}
t.Log("MVP smoke test passed!")
}
func TestMVPSmoke_WorklogReport(t *testing.T) {
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
worklogSvc := worklog.NewService(db)
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false)
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false)
if err != nil {
t.Fatal(err)
}
report, err := worklogSvc.Report(n.ID)
if err != nil {
t.Fatalf("report: %v", err)
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing entries")
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing tasks")
}
t.Logf("report:\n%s", report)
}
@@ -0,0 +1,17 @@
package storage
// migration008 — activity_events table for the Сегодня (Today) dashboard.
const migration008 = `
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
parent_id TEXT,
event_type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
`
@@ -0,0 +1,8 @@
package storage
// migration009 — add target_type, target_id, target_path to activity_events.
const migration009 = `
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
`
@@ -0,0 +1,29 @@
package storage
// migration010 — sync_ops table for recording operations to push to sync server.
const migration010 = `
CREATE TABLE IF NOT EXISTS sync_ops (
id TEXT PRIMARY KEY,
op_id TEXT NOT NULL UNIQUE,
device_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
pushed_at TEXT,
applied_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_ops_pushed ON sync_ops(pushed_at);
CREATE INDEX IF NOT EXISTS idx_sync_ops_entity ON sync_ops(entity_type, entity_id);
CREATE TABLE IF NOT EXISTS sync_state (
device_id TEXT PRIMARY KEY,
server_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
last_push_rev INTEGER NOT NULL DEFAULT 0,
last_pull_rev INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT
);
`
+3 -1
View File
@@ -64,7 +64,9 @@ var migrationFiles = map[int]string{
5: migration005,
6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
// 8: migration008, etc.
8: migration008,
9: migration009,
10: migration010,
}
func (db *DB) runInitialSchema() error {
+80
View File
@@ -0,0 +1,80 @@
package sync
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// BlobDir returns the path to .verstak/blobs/ inside the vault.
func BlobDir(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "blobs")
}
// BlobPath returns the on-disk path for a SHA-256 hash.
func BlobPath(blobsDir, shaHex string) string {
return filepath.Join(blobsDir, shaHex[:2], shaHex[2:4], shaHex)
}
// HashFile computes SHA-256 of a file.
func HashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// HashBytes computes SHA-256 of byte data.
func HashBytes(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// StoreBlob copies a file into the blob store, returns its SHA-256.
func StoreBlob(blobsDir, srcPath string) (string, error) {
shaHex, err := HashFile(srcPath)
if err != nil {
return "", err
}
dest := BlobPath(blobsDir, shaHex)
if _, err := os.Stat(dest); err == nil {
return shaHex, nil // already exists
}
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
return "", err
}
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dest)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
_ = dst.Sync()
return shaHex, nil
}
// ReadBlob reads a blob by SHA-256 hash.
func ReadBlob(blobsDir, shaHex string) ([]byte, error) {
return os.ReadFile(BlobPath(blobsDir, shaHex))
}
// Ensure the package compiles without unused errors.
var _ = fmt.Sprintf
+243
View File
@@ -0,0 +1,243 @@
package sync
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// Client communicates with the Verstak Sync Server.
type Client struct {
ServerURL string
APIKey string
DeviceID string
VaultRoot string
HTTP *http.Client
}
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
ServerURL: serverURL,
APIKey: apiKey,
DeviceID: deviceID,
VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
body := map[string]string{"name": name}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
return "", err
}
return resp.APIKey, nil
}
// RegisterDeviceWithAuth registers a device with user credentials.
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
body := map[string]string{"name": name, "username": username, "password": password}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
// Temporarily clear API key for this request (server expects login/password, not API key).
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/device/register", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.APIKey, nil
}
// Login authenticates with user credentials and returns a session token.
func (c *Client) Login(username, password string) (token string, err error) {
body := map[string]string{"username": username, "password": password}
var resp struct {
Token string `json:"token"`
}
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/auth/login", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", err
}
return resp.Token, nil
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
Ops []PushOp `json:"ops"`
}
// PushOp is a single operation in a push request.
type PushOp struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
}
// PushResponse is the response from POST /sync/push.
type PushResponse struct {
Accepted []string `json:"accepted"`
Count int `json:"count"`
}
// Push sends local operations to the server.
func (c *Client) Push(ops []Op) (*PushResponse, error) {
pushOps := make([]PushOp, len(ops))
for i, op := range ops {
pushOps[i] = PushOp{
OpID: op.OpID,
EntityType: op.EntityType,
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
CreatedAt: op.CreatedAt,
}
}
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
var resp PushResponse
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// PullRequest is the payload for POST /sync/pull.
type PullRequest struct {
SinceRevision int `json:"since_revision"`
}
// PullResponse is the response from POST /sync/pull.
type PullResponse struct {
ServerRevision int `json:"server_revision"`
Ops []Op `json:"ops"`
}
// Pull fetches remote operations since a given revision.
func (c *Client) Pull(sinceRevision int) (*PullResponse, error) {
req := PullRequest{SinceRevision: sinceRevision}
var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadBlob uploads a file to the server and returns its SHA-256.
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
if err != nil {
return "", err
}
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(fw, f); err != nil {
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
SHA256 string `json:"sha256"`
Size int `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.SHA256, nil
}
// DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- internal ---
func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.APIKey)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
+155
View File
@@ -0,0 +1,155 @@
package sync
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Entity types (matches activity targets).
const (
EntityNode = "node"
EntityNote = "note"
EntityFile = "file"
EntityFolder = "folder"
EntityAction = "action"
EntityWorklog = "worklog"
)
// Op types.
const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
OpMove = "move"
)
// Op represents a sync operation.
type Op struct {
ID string `json:"id"`
OpID string `json:"op_id"`
DeviceID string `json:"device_id,omitempty"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
}
// Service records and manages sync operations.
type Service struct {
db *storage.DB
deviceID string
}
// NewService creates a sync service.
func NewService(db *storage.DB, deviceID string) *Service {
return &Service{db: db, deviceID: deviceID}
}
// RecordOp writes a sync operation to the local sync_ops table.
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
var payloadStr string
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return err
}
payloadStr = string(b)
}
_, err := s.db.Exec(
`INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, id, s.deviceID, entityType, entityID, opType, payloadStr, now,
)
return err
}
// GetUnpushedOps returns ops that have not been pushed yet.
func (s *Service) GetUnpushedOps() ([]Op, error) {
rows, err := s.db.Query(
`SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at
FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOps(rows)
}
// MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// MarkApplied marks remote ops as applied locally.
func (s *Service) MarkApplied(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET applied_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// GetState returns the current sync state.
func (s *Service) GetState() (serverURL, apiKey string, lastPushRev int, lastSyncAt string, err error) {
err = s.db.QueryRow(
`SELECT server_url, api_key, last_push_rev, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
s.deviceID).Scan(&serverURL, &apiKey, &lastPushRev, &lastSyncAt)
if err == sql.ErrNoRows {
return "", "", 0, "", nil
}
return
}
// SetState saves sync connection state.
func (s *Service) SetState(serverURL, apiKey string) error {
_, err := s.db.Exec(
`INSERT INTO sync_state (device_id, server_url, api_key, last_push_rev, last_sync_at)
VALUES (?, ?, ?, 0, '')
ON CONFLICT(device_id) DO UPDATE SET server_url=excluded.server_url, api_key=excluded.api_key`,
s.deviceID, serverURL, apiKey,
)
return err
}
// --- helpers ---
func scanOps(rows *sql.Rows) ([]Op, error) {
var out []Op
for rows.Next() {
var o Op
var pushedAt sql.NullString
if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID,
&o.OpType, &o.PayloadJSON, &o.CreatedAt, &pushedAt); err != nil {
return nil, err
}
if pushedAt.Valid {
o.PushedAt = &pushedAt.String
}
out = append(out, o)
}
return out, rows.Err()
}
// MustVar ensures the package is not considered unused.
var _ = fmt.Sprintf
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== Verstak Sync E2E Smoke Test ==="
SERVER_PORT=18999
SDIR=$(mktemp -d)
VD=$(mktemp -d)
cleanup() { kill "$SPID" 2>/dev/null || true; rm -rf "$SDIR" "$VD"; }
trap cleanup EXIT
echo ":: Build"
go build -o /tmp/vs-server ./cmd/verstak-server/
go build -o /tmp/vs-cli ./cmd/verstak/
echo ":: Start server"
/tmp/vs-server --port "$SERVER_PORT" --data "$SDIR" --admin-user admin --admin-pass pass > /dev/null 2>&1 &
SPID=$!
sleep 2
echo ":: Health"
curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo " OK"
echo ":: Register device"
# Register user first
curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"username":"smokeuser","email":"smoke@test.local","password":"mypass123"}' > /dev/null
# Confirm email
TOKEN=$(sqlite3 "$SDIR/server.db" "SELECT token FROM server_email_tokens WHERE purpose='confirm' LIMIT 1")
curl -sf "http://localhost:$SERVER_PORT/api/v1/auth/confirm?token=$TOKEN" > /dev/null
# Register device with user creds
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
-H "Content-Type: application/json" -d '{"name":"smoke","username":"smokeuser","password":"mypass123"}')
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
echo " device=$DID"
echo ":: Init vault"
/tmp/vs-cli init --vault "$VD"
echo ":: Configure sync"
cat > "$VD/.verstak/config.yml" <<YML
sync:
server_url: http://localhost:$SERVER_PORT
api_key: $AKEY
device_id: $DID
auto_sync: false
YML
echo ":: Insert sync op (simulate recorded op)"
sqlite3 "$VD/.verstak/index.db" \
"INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) VALUES ('e1','e1','$DID','node','n1','create','{\"title\":\"t\"}',datetime('now'));"
echo ":: Push"
/tmp/vs-cli sync push --vault "$VD" | grep -q "Pushed 1" && echo " OK"
echo ":: Pull"
/tmp/vs-cli sync pull --vault "$VD" | grep -q "Pulled" && echo " OK"
echo ":: Status"
/tmp/vs-cli sync status --vault "$VD"
echo ""
echo "=== ALL TESTS PASSED ==="