From 358c649b42277768d7aa1c675850b04dd0c40a55 Mon Sep 17 00:00:00 2001 From: mirivlad Date: Sat, 6 Jun 2026 18:23:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=A8=D0=90=D0=93=201=20=E2=80=94=20Br?= =?UTF-8?q?idge=20HTTP-=D1=81=D0=B5=D1=80=D0=B2=D0=B5=D1=80=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B1=D1=80=D0=B0=D1=83=D0=B7=D0=B5=D1=80=D0=BD=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D1=80=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/core/bridge/ — лёгкий HTTP-сервер на 127.0.0.1 - POST /api/events — приём батча событий от расширения - GET /api/ping — healthcheck для расширения - X-Verstak-Secret — аутентификация по shared-secret - AutoGenPort — случайный порт если 9786 занят - config.BridgeConfig — порт, секрет, auto_gen_port - App: интеграция startBridge/stopBridge при open/close vault - bindings_bridge.go — BridgeInfo(), startBridge(), saveBridgeConfig() - Тесты: ping, auth, success, empty batch, secret gen, auto-port --- .codex/config.toml | 21 ++ cmd/verstak-gui/app.go | 28 +- cmd/verstak-gui/bindings_bridge.go | 96 ++++++ cmd/verstak-gui/bindings_config.go | 47 +++ cmd/verstak-gui/bindings_watcher.go | 66 ++++ docs/PLAN.md | 60 +++- go.mod | 3 +- go.sum | 2 + internal/core/activity/activity.go | 2 + internal/core/bridge/bridge.go | 245 ++++++++++++++ internal/core/bridge/bridge_test.go | 156 +++++++++ internal/core/config/appconfig.go | 29 +- internal/core/files/file.go | 26 ++ internal/core/nodes/repository.go | 12 + internal/core/storage/migrations_017.sql.go | 16 + internal/core/storage/storage.go | 1 + internal/core/watcher/scanner.go | 180 ++++++++++ internal/core/watcher/service.go | 93 ++++++ internal/core/watcher/watcher.go | 344 ++++++++++++++++++++ internal/core/watcher/watcher_test.go | 270 +++++++++++++++ 20 files changed, 1676 insertions(+), 21 deletions(-) create mode 100644 .codex/config.toml create mode 100644 cmd/verstak-gui/bindings_bridge.go create mode 100644 cmd/verstak-gui/bindings_watcher.go create mode 100644 internal/core/bridge/bridge.go create mode 100644 internal/core/bridge/bridge_test.go create mode 100644 internal/core/storage/migrations_017.sql.go create mode 100644 internal/core/watcher/scanner.go create mode 100644 internal/core/watcher/service.go create mode 100644 internal/core/watcher/watcher.go create mode 100644 internal/core/watcher/watcher_test.go diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..b59fbbd --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,21 @@ +[mcp_servers.go_lsp] +command = "mcp-language-server" +args = [ + "--workspace", "/home/mirivlad/git/verstak", + "--lsp", "gopls" +] +enabled = true +default_tools_approval_mode = "approve" +tool_timeout_sec = 30 + +[mcp_servers.ts_lsp] +command = "mcp-language-server" +args = [ + "--workspace", "/home/mirivlad/git/verstak", + "--lsp", "typescript-language-server", + "--", + "--stdio" +] +enabled = true +default_tools_approval_mode = "approve" +tool_timeout_sec = 30 diff --git a/cmd/verstak-gui/app.go b/cmd/verstak-gui/app.go index ba6aa41..ac29415 100644 --- a/cmd/verstak-gui/app.go +++ b/cmd/verstak-gui/app.go @@ -13,6 +13,7 @@ import ( "verstak/internal/core/actions" "verstak/internal/core/activity" + "verstak/internal/core/bridge" "verstak/internal/core/config" "verstak/internal/core/files" "verstak/internal/core/nodes" @@ -22,6 +23,7 @@ import ( "verstak/internal/core/storage" syncsvc "verstak/internal/core/sync" "verstak/internal/core/templates" + "verstak/internal/core/watcher" "verstak/internal/core/worklog" ) @@ -31,18 +33,20 @@ type App struct { mu sync.RWMutex vaultOpen bool - db *storage.DB - nodes *nodes.Repository - templates *templates.Registry - files *files.Service - notes *notes.Service - activity *activity.Service - actions *actions.Service - worklog *worklog.Service - search *search.Service - plugins *plugins.Manager - sync *syncsvc.Service - vault string + db *storage.DB + nodes *nodes.Repository + templates *templates.Registry + files *files.Service + notes *notes.Service + activity *activity.Service + actions *actions.Service + worklog *worklog.Service + search *search.Service + plugins *plugins.Manager + sync *syncsvc.Service + fileWatcher *watcher.Service + bridge *bridge.Server + vault string } // requireVault returns an error if no vault is open and services are not initialized. diff --git a/cmd/verstak-gui/bindings_bridge.go b/cmd/verstak-gui/bindings_bridge.go new file mode 100644 index 0000000..1c660a7 --- /dev/null +++ b/cmd/verstak-gui/bindings_bridge.go @@ -0,0 +1,96 @@ +package main + +import ( + "log" + + "verstak/internal/core/bridge" + "verstak/internal/core/config" +) + +// startBridge creates and starts the local HTTP bridge for browser extension. +func (a *App) startBridge(appCfg *config.AppConfig) { + // Determine bridge config + bc := a.bridgeConfig(appCfg) + + handler := func(events []bridge.Event) { + // For now, log received events. Storage comes in Step 2. + for _, ev := range events { + log.Printf("[bridge] event: type=%s url=%s domain=%s", ev.Type, ev.URL, ev.Domain) + } + } + + srv := bridge.NewServer(bridge.Config{ + Port: bc.Port, + Secret: bc.Secret, + }, handler) + + port, err := srv.Start(bridge.Config{ + Port: bc.Port, + AutoGenPort: bc.AutoGenPort, + Secret: bc.Secret, + }) + if err != nil { + log.Printf("[bridge] failed to start: %v", err) + return + } + + // Save the actual port and secret back to config if auto-generated. + if bc.AutoGenPort { + bc.Port = port + } + if bc.Secret == "" { + bc.Secret = srv.Secret() + } + a.saveBridgeConfig(appCfg, bc) + + a.mu.Lock() + a.bridge = srv + a.mu.Unlock() +} + +// bridgeConfig extracts bridge config from app config, generating defaults if needed. +func (a *App) bridgeConfig(appCfg *config.AppConfig) *config.BridgeConfig { + if appCfg != nil && appCfg.Vault.Bridge.Port != 0 { + bc := &appCfg.Vault.Bridge + // If secret is empty, generate one on first run + if bc.Secret == "" { + bc.Secret = bridge.GenerateSecret() + } + return bc + } + return &config.BridgeConfig{ + Port: 9786, + AutoGenPort: true, + Secret: bridge.GenerateSecret(), + } +} + +// saveBridgeConfig persists the bridge config to disk. +func (a *App) saveBridgeConfig(appCfg *config.AppConfig, bc *config.BridgeConfig) { + if appCfg == nil { + // Load or create fresh + loaded, err := config.LoadAppConfig() + if err != nil || loaded == nil { + loaded = config.DefaultAppConfig() + } + appCfg = loaded + } + appCfg.Vault.Bridge = *bc + if err := config.SaveAppConfig(appCfg); err != nil { + log.Printf("[bridge] save config: %v", err) + } +} + +// BridgeInfo returns the current bridge server status. +func (a *App) BridgeInfo() map[string]interface{} { + info := map[string]interface{}{ + "running": false, + "port": 0, + } + if a.bridge != nil { + info["running"] = a.bridge.Running() + info["port"] = a.bridge.Port() + info["secret"] = a.bridge.Secret() + } + return info +} diff --git a/cmd/verstak-gui/bindings_config.go b/cmd/verstak-gui/bindings_config.go index af60887..b2c9d16 100644 --- a/cmd/verstak-gui/bindings_config.go +++ b/cmd/verstak-gui/bindings_config.go @@ -18,6 +18,7 @@ import ( syncsvc "verstak/internal/core/sync" "verstak/internal/core/templates" "verstak/internal/core/vault" + "verstak/internal/core/watcher" "verstak/internal/core/worklog" ) @@ -229,6 +230,29 @@ func (a *App) initVault(vaultPath string) error { } syncSvc := syncsvc.NewService(db, deviceID) + // File watcher service + watcherSvc := watcher.NewService(abs, nodeRepo, fileSvc, activitySvc) + + // Determine if real-time watching is enabled. + // Priority: CLI --no-watcher > env VERSTAK_NO_WATCHER > config file > default (true) + fileWatcherEnabled := true + if appCfg != nil { + fileWatcherEnabled = appCfg.Vault.FileWatcher + } + // Env override + if os.Getenv("VERSTAK_NO_WATCHER") == "1" { + fileWatcherEnabled = false + log.Println("[watcher] disabled by VERSTAK_NO_WATCHER=1") + } + // CLI override + for _, arg := range os.Args[1:] { + if arg == "--no-watcher" { + fileWatcherEnabled = false + log.Println("[watcher] disabled by --no-watcher") + break + } + } + a.mu.Lock() a.db = db a.nodes = nodeRepo @@ -241,13 +265,26 @@ func (a *App) initVault(vaultPath string) error { a.plugins = pm a.templates = templatesReg a.sync = syncSvc + a.fileWatcher = watcherSvc a.vault = abs a.vaultOpen = true a.mu.Unlock() + // Snapshot scan (always runs). Real-time watcher depends on config. + scanResult, err := watcherSvc.Start(fileWatcherEnabled) + if err != nil { + log.Printf("[watcher] start error: %v", err) + } else { + log.Printf("[watcher] snapshot: %d missing, %d restored, %d modified, %d new", + scanResult.MissingFiles, scanResult.RestoredFiles, scanResult.ModifiedFiles, scanResult.NewFiles) + } + // Start auto-sync loop go a.autoSyncLoop() + // Start bridge server for browser extension integration. + a.startBridge(appCfg) + return nil } @@ -258,6 +295,14 @@ func (a *App) closeVault() { if !a.vaultOpen { return } + // Stop file watcher first. + if a.fileWatcher != nil { + a.fileWatcher.Stop() + } + // Stop bridge server. + if a.bridge != nil { + a.bridge.Stop() + } if a.db != nil { a.db.Close() } @@ -272,6 +317,8 @@ func (a *App) closeVault() { a.plugins = nil a.templates = nil a.sync = nil + a.fileWatcher = nil + a.bridge = nil a.vault = "" a.vaultOpen = false } diff --git a/cmd/verstak-gui/bindings_watcher.go b/cmd/verstak-gui/bindings_watcher.go new file mode 100644 index 0000000..a184e33 --- /dev/null +++ b/cmd/verstak-gui/bindings_watcher.go @@ -0,0 +1,66 @@ +package main + +import ( + "fmt" + + "verstak/internal/core/config" + "verstak/internal/core/watcher" +) + +// WatcherStatus returns whether the real-time file watcher is active. +func (a *App) WatcherStatus() (bool, error) { + if err := a.requireVault(); err != nil { + return false, err + } + return a.fileWatcher.IsWatching(), nil +} + +// RunSnapshotScan performs a one-shot scan and returns results. +func (a *App) RunSnapshotScan() (*watcher.SnapshotResult, error) { + if err := a.requireVault(); err != nil { + return nil, err + } + return a.fileWatcher.RunScanner() +} + +// ToggleFileWatcher enables or disables the real-time file watcher. +// Changing this persists to app config (~/.config/verstak/config.json → vault.file_watcher). +// +// При включении: запускает snapshot scan (сверка диска с БД), затем включает real-time watcher. +// При отключении: останавливает fsnotify, watcher_state в БД сохраняется. +// +// Отключить watcher НАВСЕГДА (независимо от галки): +// export VERSTAK_NO_WATCHER=1 +// +// Отключить на один запуск: +// verstak-gui --no-watcher +// +// Snapshot scan запускается ВСЕГДА при открытии vault, даже при FileWatcher=false. +func (a *App) ToggleFileWatcher(enable bool) error { + if err := a.requireVault(); err != nil { + return err + } + + cfg, err := config.LoadAppConfig() + if err != nil || cfg == nil { + return fmt.Errorf("config: %w", err) + } + cfg.Vault.FileWatcher = enable + if err := config.SaveAppConfig(cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + if enable { + _, err := a.fileWatcher.RunScanner() + if err != nil { + return fmt.Errorf("scan: %w", err) + } + if _, err := a.fileWatcher.Start(true); err != nil { + return fmt.Errorf("start watcher: %w", err) + } + } else { + a.fileWatcher.Stop() + } + + return nil +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 2e99a39..e46ce00 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -193,12 +193,62 @@ Core service extensions: --- -## Ожидающие шаги (17-23) +## Завершённый этап: ШАГ 17 — File Scanner/Watcher -### ШАГ 17 — File Scanner/Watcher -- fsnotify watcher на vault -- snapshot scanner для обнаружения изменений при закрытом приложении -- обнаружение missing файлов +**Статус:** ✅ выполнено + +Что реализовано: +- `internal/core/watcher/scanner.go` — Snapshot scanner: + - Итерирует все ноды с FsPath, сканирует их директории на диске + - Детектит missing файлы (запись в БД есть — файла нет) → MarkMissing + activity event + - Детектит restored файлы (был missing — снова на диске) → MarkMissing(false) + activity event + - Детектит modified файлы (SHA256 не совпал) → обновление размера/SHA в БД + activity event + - Считает new файлы на диске без записи в БД (не авто-добавляет, только счётчик) + - Пропускает `.verstak/` и скрытые директории +- `internal/core/watcher/watcher.go` — fsnotify Watcher: + - Реальное время: CREATE/REMOVE/RENAME/WRITE события + - Debounce 2 секунды для группировки burst-событий + - CREATE → авто-добавление file record + `file_added` event + - REMOVE → MarkMissing(true) + `file_deleted` event + - WRITE → обновление SHA256 + `file_modified` event + - Рекурсивное добавление watcher-ов на поддиректории + - Пропускает скрытые файлы и `.verstak/` +- `internal/core/watcher/service.go` — Объединённый сервис: + - Start(enableWatcher) → snapshot scan + опционально запуск real-time watcher + - Stop() → остановка watcher + - RunScanner() → одноразовое сканирование +- `internal/core/activity/activity.go` — Новые типы событий: `file_modified`, `file_restored` +- `internal/core/storage/migrations_017.sql.go` — Таблица `watcher_state` для трекинга состояния файлов +- `internal/core/config/appconfig.go` — Настройка `Vault.FileWatcher` (по умолчанию true) +- `cmd/verstak-gui/app.go` — Интеграция в App: initVault запускает сканер + watcher, closeVault останавливает +- `cmd/verstak-gui/bindings_watcher.go` — Bindings: WatcherStatus(), RunSnapshotScan(), ToggleFileWatcher() +- `internal/core/watcher/watcher_test.go` — 5 тестов: no changes, missing, restored, modified, hidden filter +- `internal/core/files/file.go` — Новые методы: ListAllVault(), ListAllVaultWithTrashed() +- `internal/core/nodes/repository.go` — Новый метод: ListAllWithFsPath() + +### Как включить/отключить + +| Способ | Команда | Действие | +|--------|---------|----------| +| **GUI** | Settings → "File Watcher" toggle | Вкл/выкл real-time, snapshot всегда | +| **Config** | `~/.config/verstak/config.json` → `vault.file_watcher: false` | Навсегда | +| **Env** | `VERSTAK_NO_WATCHER=1 ./verstak-gui` | На сессию (переопределяет config) | +| **CLI** | `./verstak-gui --no-watcher` | Один запуск (переопределяет всё) | + +Важно: **snapshot scan** (сверка диска с БД) выполняется ВСЕГДА при открытии vault. +Real-time fsnotify watcher управляется отдельно. + +### Как проверить что работает + +После запуска в логе: +``` +[watcher] snapshot: 0 missing, 0 restored, 0 modified, 0 new +``` + +Через GUI: `WatcherStatus()` — true если watcher активен. +Через консоль (отладка): создать/удалить файл в vault → в activity появятся события `file_added`/`file_missing`/`file_modified`. + +## Ожидающие шаги (18-23) ### ШАГ 18 — TUI MVP (Bubble Tea) - Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync diff --git a/go.mod b/go.mod index bb3009c..c6522f5 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module verstak go 1.25.0 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/mattn/go-sqlite3 v1.14.44 + github.com/signintech/gopdf v0.36.1 github.com/wailsapp/wails/v2 v2.12.0 golang.org/x/crypto v0.33.0 gopkg.in/yaml.v3 v3.0.1 @@ -30,7 +32,6 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect - github.com/signintech/gopdf v0.36.1 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect diff --git a/go.sum b/go.sum index 1f157bd..aa07bba 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= diff --git a/internal/core/activity/activity.go b/internal/core/activity/activity.go index dfe6d3e..56cfbcc 100644 --- a/internal/core/activity/activity.go +++ b/internal/core/activity/activity.go @@ -17,6 +17,8 @@ const ( TypeFileRenamed = "file_renamed" TypeFileCopied = "file_copied" TypeFileMoved = "file_moved" + TypeFileModified = "file_modified" + TypeFileRestored = "file_restored" TypeFolderAdded = "folder_added" TypeFolderDeleted = "folder_deleted" TypeFolderRenamed = "folder_renamed" diff --git a/internal/core/bridge/bridge.go b/internal/core/bridge/bridge.go new file mode 100644 index 0000000..d5c95a5 --- /dev/null +++ b/internal/core/bridge/bridge.go @@ -0,0 +1,245 @@ +// Package bridge provides a local HTTP API for browser extension +// integration. The server is started when a vault is opened and +// accepts events pushed by the browser extension. +package bridge + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "log" + "net" + "net/http" + "sync" + "time" +) + +// Server is a lightweight HTTP server for browser extension events. +type Server struct { + mu sync.RWMutex + server *http.Server + listener net.Listener + handler EventHandler + secret string + running bool +} + +// EventHandler is called when an event batch arrives. +type EventHandler func(events []Event) + +// Event represents a single browser event (page visit, note capture, etc.). +type Event struct { + ID string `json:"id"` + Type string `json:"type"` // page_visit, note_capture, screenshot + URL string `json:"url"` + Title string `json:"title"` + Domain string `json:"domain"` + ActiveSeconds int `json:"active_seconds,omitempty"` + TSStart string `json:"ts_start,omitempty"` + TSEnd string `json:"ts_end,omitempty"` + TS string `json:"ts,omitempty"` + SelectedText string `json:"selected_text,omitempty"` + Note string `json:"note,omitempty"` + Screenshot string `json:"screenshot,omitempty"` // base64 data URI +} + +// EventBatch is the payload POSTed by the extension. +type EventBatch struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + Events []Event `json:"events"` +} + +// Config holds bridge server settings. +type Config struct { + Port int `json:"port"` + Secret string `json:"secret"` // auto-generated if empty + AutoGenPort bool `json:"auto_gen_port"` // pick random available port +} + +// DefaultConfig returns sensible defaults. +func DefaultConfig() Config { + return Config{ + Port: 9786, + AutoGenPort: true, + } +} + +// GenerateSecret creates a 32-char hex secret. +func GenerateSecret() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // fallback to time-based + return fmt.Sprintf("vs%x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} + +// NewServer creates a bridge server. If cfg.Secret is empty, one is generated. +func NewServer(cfg Config, handler EventHandler) *Server { + secret := cfg.Secret + if secret == "" { + secret = GenerateSecret() + } + return &Server{ + secret: secret, + handler: handler, + } +} + +// Port returns the actual listening port (useful when AutoGenPort is true). +func (s *Server) Port() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.listener != nil { + return s.listener.Addr().(*net.TCPAddr).Port + } + return 0 +} + +// Secret returns the shared secret. +func (s *Server) Secret() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.secret +} + +// Running returns true if the server is listening. +func (s *Server) Running() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.running +} + +// Start begins listening. Returns the actual port if AutoGenPort is used. +func (s *Server) Start(cfg Config) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + // return existing port without re-reading (we already have the lock) + if s.listener != nil { + return s.listener.Addr().(*net.TCPAddr).Port, nil + } + return 0, nil + } + + addr := fmt.Sprintf("127.0.0.1:%d", cfg.Port) + if cfg.AutoGenPort { + addr = "127.0.0.1:0" + } + + listener, err := net.Listen("tcp", addr) + if err != nil { + return 0, fmt.Errorf("bridge listen: %w", err) + } + + s.listener = listener + actualPort := listener.Addr().(*net.TCPAddr).Port + + mux := http.NewServeMux() + mux.HandleFunc("/api/ping", s.handlePing) + mux.HandleFunc("/api/events", s.withAuth(s.handleEvents)) + + s.server = &http.Server{ + Handler: mux, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + } + + s.running = true + + go func() { + if err := s.server.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("[bridge] serve error: %v", err) + } + }() + + secretPreview := s.secret + if len(secretPreview) > 8 { + secretPreview = secretPreview[:8] + "..." + } + log.Printf("[bridge] listening on 127.0.0.1:%d (secret=%s)", actualPort, secretPreview) + return actualPort, nil +} + +// Stop shuts down the HTTP server. +func (s *Server) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running { + return + } + + if s.server != nil { + _ = s.server.Close() + } + s.server = nil + s.listener = nil + s.running = false + log.Println("[bridge] stopped") +} + +// --- handlers --- + +func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", 405) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "port": s.Port(), + }) +} + +func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + + var batch EventBatch + if err := json.NewDecoder(r.Body).Decode(&batch); err != nil { + http.Error(w, fmt.Sprintf("bad request: %v", err), 400) + return + } + + if batch.Version < 1 { + http.Error(w, "unsupported version", 400) + return + } + + if len(batch.Events) == 0 { + w.WriteHeader(204) + return + } + + if s.handler != nil { + s.handler(batch.Events) + } + + log.Printf("[bridge] received %d events from device %s", len(batch.Events), batch.DeviceID) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "ok", + "count": len(batch.Events), + "version": batch.Version, + }) +} + +// withAuth wraps a handler with shared-secret authentication. +func (s *Server) withAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + auth := r.Header.Get("X-Verstak-Secret") + if auth != s.secret { + http.Error(w, "unauthorized", 401) + return + } + next(w, r) + } +} diff --git a/internal/core/bridge/bridge_test.go b/internal/core/bridge/bridge_test.go new file mode 100644 index 0000000..47aa342 --- /dev/null +++ b/internal/core/bridge/bridge_test.go @@ -0,0 +1,156 @@ +package bridge + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" +) + +func TestServer_Ping(t *testing.T) { + s := NewServer(DefaultConfig(), nil) + port, err := s.Start(DefaultConfig()) + if err != nil { + t.Fatal(err) + } + defer s.Stop() + + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/ping", port)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + var body map[string]interface{} + json.NewDecoder(resp.Body).Decode(&body) + if body["status"] != "ok" { + t.Errorf("expected status ok, got %v", body["status"]) + } +} + +func TestServer_Events_AuthRequired(t *testing.T) { + s := NewServer(Config{Secret: "test-secret"}, nil) + port, err := s.Start(Config{Secret: "test-secret"}) + if err != nil { + t.Fatal(err) + } + defer s.Stop() + + // Without auth header + body := EventBatch{Version: 1, DeviceID: "test", Events: []Event{{ID: "1", Type: "page_visit", URL: "https://example.com"}}} + b, _ := json.Marshal(body) + resp, err := http.Post(fmt.Sprintf("http://127.0.0.1:%d/api/events", port), "application/json", bytes.NewReader(b)) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 401 { + t.Errorf("expected 401 without auth, got %d", resp.StatusCode) + } +} + +func TestServer_Events_Success(t *testing.T) { + received := make(chan []Event, 1) + s := NewServer(Config{Secret: "test-secret"}, func(evts []Event) { + received <- evts + }) + port, err := s.Start(Config{Secret: "test-secret"}) + if err != nil { + t.Fatal(err) + } + defer s.Stop() + + events := []Event{ + {ID: "1", Type: "page_visit", URL: "https://example.com", Title: "Example", Domain: "example.com", ActiveSeconds: 120}, + } + batch := EventBatch{Version: 1, DeviceID: "test-device", Events: events} + b, _ := json.Marshal(batch) + + req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/events", port), bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Verstak-Secret", "test-secret") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Errorf("expected 200, got %d", resp.StatusCode) + } + + select { + case evts := <-received: + if len(evts) != 1 { + t.Errorf("expected 1 event, got %d", len(evts)) + } + if evts[0].ID != "1" { + t.Errorf("expected event ID '1', got %s", evts[0].ID) + } + case <-time.After(time.Second): + t.Fatal("timeout waiting for event handler") + } +} + +func TestServer_Events_EmptyBatch(t *testing.T) { + s := NewServer(Config{Secret: "s"}, nil) + port, err := s.Start(Config{Secret: "s"}) + if err != nil { + t.Fatal(err) + } + defer s.Stop() + + batch := EventBatch{Version: 1, DeviceID: "test", Events: []Event{}} + b, _ := json.Marshal(batch) + + req, _ := http.NewRequest("POST", fmt.Sprintf("http://127.0.0.1:%d/api/events", port), bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Verstak-Secret", "s") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 204 { + t.Errorf("expected 204 for empty batch, got %d", resp.StatusCode) + } +} + +func TestGenerateSecret(t *testing.T) { + s1 := GenerateSecret() + s2 := GenerateSecret() + if s1 == s2 { + t.Error("secrets should be unique") + } + if len(s1) != 32 { + t.Errorf("expected 32 chars, got %d", len(s1)) + } +} + +func TestServer_AutoPort(t *testing.T) { + cfg := DefaultConfig() + s := NewServer(cfg, nil) + port, err := s.Start(cfg) + if err != nil { + t.Fatal(err) + } + defer s.Stop() + + if port == 0 { + t.Error("expected non-zero port") + } + + if s.Port() != port { + t.Errorf("Port() = %d, want %d", s.Port(), port) + } + + if !s.Running() { + t.Error("expected server running") + } +} diff --git a/internal/core/config/appconfig.go b/internal/core/config/appconfig.go index d25e202..82751f5 100644 --- a/internal/core/config/appconfig.go +++ b/internal/core/config/appconfig.go @@ -32,10 +32,23 @@ type WindowConfig struct { } // VaultAppConfig holds per-vault settings in the global config. +// +// FileWatcher controls real-time filesystem monitoring: +// - Включается автоматически при открытии vault (snapshot scan ВСЕГДА запускается, +// real-time watcher только при FileWatcher=true) +// - Snapshot scan детектит missing/restored/modified/new файлы при загрузке +// - Real-time watcher (fsnotify) реагирует на CREATE/REMOVE/WRITE мгновенно +// - Отключение: Settings → "File Watcher" toggle, или правка config.json +// - VERSTAK_NO_WATCHER=1 — выключить watcher через env (переопределяет config) +// - --no-watcher — CLI флаг (переопределяет config и env) +// +// Bridge holds the local HTTP API for browser extension integration. type VaultAppConfig struct { - VaultID string `json:"vault_id,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - Sync SyncSettings `json:"sync,omitempty"` + VaultID string `json:"vault_id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Sync SyncSettings `json:"sync,omitempty"` + FileWatcher bool `json:"file_watcher"` + Bridge BridgeConfig `json:"bridge,omitempty"` } // SyncSettings holds sync configuration for the current vault. @@ -50,6 +63,13 @@ type SyncSettings struct { LastError string `json:"last_error,omitempty"` } +// BridgeConfig holds local HTTP bridge settings for browser extension. +type BridgeConfig struct { + Port int `json:"port"` + Secret string `json:"secret,omitempty"` // shared secret for extension auth + AutoGenPort bool `json:"auto_gen_port,omitempty"` // pick random port if port taken +} + func DefaultAppConfig() *AppConfig { return &AppConfig{ Version: AppConfigVersion, @@ -57,6 +77,9 @@ func DefaultAppConfig() *AppConfig { Language: "ru", EnabledTemplates: []string{"folder.default", "project.default", "client.default", "document.default", "recipe.default"}, EnabledPlugins: []string{}, + Vault: VaultAppConfig{ + FileWatcher: true, + }, } } diff --git a/internal/core/files/file.go b/internal/core/files/file.go index 77af569..139b382 100644 --- a/internal/core/files/file.go +++ b/internal/core/files/file.go @@ -226,6 +226,32 @@ func (s *Service) ListTrashedByNode(nodeID string) ([]Record, error) { return scanRecords(rows) } +// ListAllVault returns all active (non-missing) vault-stored file records. +func (s *Service) ListAllVault() ([]Record, error) { + rows, err := s.db.Query( + `SELECT id,node_id,filename,path,storage_mode,size,sha256,mime, + created_at,updated_at,last_seen_at,missing + FROM files WHERE storage_mode = 'vault' AND missing != 1 ORDER BY path`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecords(rows) +} + +// ListAllVaultWithTrashed returns ALL vault-stored file records, including missing ones. +func (s *Service) ListAllVaultWithTrashed() ([]Record, error) { + rows, err := s.db.Query( + `SELECT id,node_id,filename,path,storage_mode,size,sha256,mime, + created_at,updated_at,last_seen_at,missing + FROM files WHERE storage_mode = 'vault' ORDER BY path`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanRecords(rows) +} + // MarkMissing flags a file as missing. func (s *Service) MarkMissing(id string, missing bool) error { m := 0 diff --git a/internal/core/nodes/repository.go b/internal/core/nodes/repository.go index 5f25719..d96cc5e 100644 --- a/internal/core/nodes/repository.go +++ b/internal/core/nodes/repository.go @@ -169,6 +169,18 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) { return scanNodes(rows) } +// ListAllWithFsPath returns active non-deleted nodes that have a non-empty fs_path. +func (r *Repository) ListAllWithFsPath() ([]Node, error) { + rows, err := r.db.Query( + `SELECT `+nodeColumns+` FROM nodes + WHERE deleted_at IS NULL AND fs_path != '' ORDER BY fs_path`) + if err != nil { + return nil, err + } + defer rows.Close() + return scanNodes(rows) +} + // ListInboxRoots returns active root capture artifacts explicitly marked for inbox. func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) { q := `SELECT ` + nodeColumns + ` FROM nodes diff --git a/internal/core/storage/migrations_017.sql.go b/internal/core/storage/migrations_017.sql.go new file mode 100644 index 0000000..af2e365 --- /dev/null +++ b/internal/core/storage/migrations_017.sql.go @@ -0,0 +1,16 @@ +package storage + +// migration017 — watcher tracking table. +const migration017 = ` +CREATE TABLE IF NOT EXISTS watcher_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fs_path TEXT NOT NULL, + size INTEGER NOT NULL DEFAULT 0, + mod_time TEXT NOT NULL DEFAULT '', + sha256 TEXT NOT NULL DEFAULT '', + last_checked TEXT NOT NULL, + UNIQUE(fs_path) +); + +CREATE INDEX IF NOT EXISTS idx_watcher_state_path ON watcher_state(fs_path); +` diff --git a/internal/core/storage/storage.go b/internal/core/storage/storage.go index 825c3ff..1b1af26 100644 --- a/internal/core/storage/storage.go +++ b/internal/core/storage/storage.go @@ -73,6 +73,7 @@ var migrationFiles = map[int]string{ 14: migration014, 15: migration015, 16: migration016, + 17: migration017, } func (db *DB) runInitialSchema() error { diff --git a/internal/core/watcher/scanner.go b/internal/core/watcher/scanner.go new file mode 100644 index 0000000..8aaddc5 --- /dev/null +++ b/internal/core/watcher/scanner.go @@ -0,0 +1,180 @@ +package watcher + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "io/fs" + "log" + "os" + "path/filepath" + "strings" + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" +) + +// SnapshotResult summarises what the snapshot scanner found. +type SnapshotResult struct { + MissingFiles int `json:"missing_files"` + RestoredFiles int `json:"restored_files"` + ModifiedFiles int `json:"modified_files"` + NewFiles int `json:"new_files"` + NodesScanned int `json:"nodes_scanned"` +} + +// Scanner performs a one-shot scan of the vault filesystem. +type Scanner struct { + vaultRoot string + nodes *nodes.Repository + files *files.Service + activity *activity.Service +} + +// NewScanner creates a snapshot scanner. +func NewScanner(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Scanner { + return &Scanner{ + vaultRoot: vaultRoot, + nodes: nr, + files: fs, + activity: as, + } +} + +// Run performs one full scan and returns a summary. +func (s *Scanner) Run() (*SnapshotResult, error) { + result := &SnapshotResult{} + + // 1. Collect all file records in the DB (including missing). + dbFiles, err := s.files.ListAllVaultWithTrashed() + if err != nil { + return nil, fmt.Errorf("list vault files: %w", err) + } + + // Index by path for O(1) lookup. + byPath := make(map[string]*files.Record, len(dbFiles)) + for i := range dbFiles { + rec := &dbFiles[i] + byPath[rec.Path] = rec + } + + // 2. Scan all nodes with FsPath to discover files on disk. + allNodes, err := s.nodes.ListAllWithFsPath() + if err != nil { + return nil, fmt.Errorf("list nodes: %w", err) + } + + scannedPaths := make(map[string]bool) + + for _, node := range allNodes { + absDir := filepath.Join(s.vaultRoot, node.FsPath) + info, err := os.Stat(absDir) + if err != nil || !info.IsDir() { + continue + } + + result.NodesScanned++ + + err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return nil // skip unreadable entries + } + if d.IsDir() { + // Skip .verstak and hidden dirs. + if strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return nil + } + + rel, err := filepath.Rel(s.vaultRoot, path) + if err != nil { + return nil + } + + scannedPaths[rel] = true + + if rec, exists := byPath[rel]; exists { + if rec.Missing { + // File existed in DB as missing, now found on disk. + _ = s.files.MarkMissing(rec.ID, false) + s.logActivity(rec.NodeID, activity.TypeFileRestored, rec.Filename, rel) + result.RestoredFiles++ + } else { + // File exists in both places — check if content changed. + fi, err := os.Stat(path) + if err != nil { + return nil + } + if fi.Size() != rec.Size || contentChanged(path, rec.SHA256) { + sha, size := hashFile(path) + // Update in DB. We don't have an Update method exposed, + // but we can mark and re-add via the watcher state. + _ = s.updateFileRecord(rec.ID, rec.NodeID, rec.Filename, rel, size, sha) + s.logActivity(rec.NodeID, activity.TypeFileModified, rec.Filename, rel) + result.ModifiedFiles++ + } + } + delete(byPath, rel) + } else { + // File on disk but no record in DB — new file. + result.NewFiles++ + } + return nil + }) + if err != nil { + log.Printf("[watcher] scan node %s error: %v", node.ID, err) + } + } + + // 3. Any remaining byPath entries are files in DB but missing on disk. + for _, rec := range byPath { + if !rec.Missing { + _ = s.files.MarkMissing(rec.ID, true) + s.logActivity(rec.NodeID, activity.TypeFileDeleted, rec.Filename, rec.Path) + result.MissingFiles++ + } + } + + return result, nil +} + +func (s *Scanner) logActivity(nodeID, eventType, title, path string) { + _ = s.activity.Record(nodeID, activity.TargetFile, "", path, eventType, title, "") +} + +func (s *Scanner) updateFileRecord(id, nodeID, filename, path string, size int64, sha string) error { + // Direct SQL update since files.Service doesn't expose an update method. + _, err := s.files.DB().Exec( + `UPDATE files SET size=?, sha256=?, updated_at=?, missing=0 WHERE id=?`, + size, sha, time.Now().UTC().Format(time.RFC3339), id) + return err +} + +// hashFile computes SHA256 and returns size and hex hash. +func hashFile(absPath string) (string, int64) { + f, err := os.Open(absPath) + if err != nil { + return "", 0 + } + defer f.Close() + + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0 + } + return hex.EncodeToString(h.Sum(nil)), n +} + +// contentChanged returns true if the file's SHA256 differs from the stored hash. +func contentChanged(absPath, storedHash string) bool { + if storedHash == "" { + return true + } + h, _ := hashFile(absPath) + return h != storedHash +} diff --git a/internal/core/watcher/service.go b/internal/core/watcher/service.go new file mode 100644 index 0000000..1878dcb --- /dev/null +++ b/internal/core/watcher/service.go @@ -0,0 +1,93 @@ +package watcher + +import ( + "log" + "sync" + + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" +) + +// Service wraps both the snapshot scanner and the fsnotify watcher. +// It provides a single entry point for the app to start/stop file watching. +type Service struct { + vaultRoot string + nodes *nodes.Repository + files *files.Service + activity *activity.Service + + mu sync.Mutex + watcher *Watcher + enabled bool +} + +// NewService creates a combined watcher service. +// It does not start watching until Start is called. +func NewService(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Service { + return &Service{ + vaultRoot: vaultRoot, + nodes: nr, + files: fs, + activity: as, + } +} + +// Start performs a snapshot scan and then starts the real-time watcher. +// If enabled is false, only the snapshot scan runs (one-shot). +func (s *Service) Start(enabled bool) (*SnapshotResult, error) { + s.mu.Lock() + defer s.mu.Unlock() + + s.enabled = enabled + + // Always run snapshot scan first. + scanner := NewScanner(s.vaultRoot, s.nodes, s.files, s.activity) + result, err := scanner.Run() + if err != nil { + log.Printf("[watcher] snapshot scan error: %v", err) + result = &SnapshotResult{} + } + + if result.MissingFiles > 0 || result.RestoredFiles > 0 || result.ModifiedFiles > 0 { + log.Printf("[watcher] snapshot scan: %d missing, %d restored, %d modified, %d new, %d nodes", + result.MissingFiles, result.RestoredFiles, result.ModifiedFiles, result.NewFiles, result.NodesScanned) + } + + if enabled { + w := NewWatcher(s.vaultRoot, s.nodes, s.files, s.activity) + if err := w.Start(); err != nil { + log.Printf("[watcher] failed to start real-time watcher: %v", err) + return result, nil + } + s.watcher = w + log.Printf("[watcher] real-time watcher started") + } + + return result, nil +} + +// Stop shuts down the real-time watcher if running. +func (s *Service) Stop() { + s.mu.Lock() + defer s.mu.Unlock() + + if s.watcher != nil { + s.watcher.Stop() + s.watcher = nil + } + s.enabled = false +} + +// IsWatching returns whether the real-time watcher is active. +func (s *Service) IsWatching() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.watcher != nil && s.watcher.IsWatching() +} + +// RunScanner performs a one-shot snapshot scan (even if watcher is active). +func (s *Service) RunScanner() (*SnapshotResult, error) { + scanner := NewScanner(s.vaultRoot, s.nodes, s.files, s.activity) + return scanner.Run() +} diff --git a/internal/core/watcher/watcher.go b/internal/core/watcher/watcher.go new file mode 100644 index 0000000..98c25df --- /dev/null +++ b/internal/core/watcher/watcher.go @@ -0,0 +1,344 @@ +package watcher + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" +) + +// DebounceWindow is how long we wait for a burst of fsnotify events to settle. +const DebounceWindow = 2 * time.Second + +// Watcher wraps fsnotify to track filesystem changes in real time. +type Watcher struct { + vaultRoot string + nodes *nodes.Repository + files *files.Service + activity *activity.Service + + w *fsnotify.Watcher + mu sync.Mutex + done chan struct{} + watching bool + + // debounce buffers + pending map[string][]fsnotify.Event // key = fsPath + debounceT *time.Timer +} + +// NewWatcher creates but does not start the watcher. +func NewWatcher(vaultRoot string, nr *nodes.Repository, fs *files.Service, as *activity.Service) *Watcher { + return &Watcher{ + vaultRoot: vaultRoot, + nodes: nr, + files: fs, + activity: as, + pending: make(map[string][]fsnotify.Event), + } +} + +// Start begins watching all node directories. Returns an error if fsnotify fails. +func (w *Watcher) Start() error { + w.mu.Lock() + defer w.mu.Unlock() + + if w.watching { + return nil + } + + fw, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("fsnotify: %w", err) + } + w.w = fw + w.done = make(chan struct{}) + + // Collect all node directories to watch. + allNodes, err := w.nodes.ListAllWithFsPath() + if err != nil { + fw.Close() + return fmt.Errorf("list nodes: %w", err) + } + + watched := 0 + for _, node := range allNodes { + absDir := filepath.Join(w.vaultRoot, node.FsPath) + if info, statErr := os.Stat(absDir); statErr == nil && info.IsDir() { + // Watch the directory and its direct subdirectories. + if err := w.addRecursive(absDir); err != nil { + log.Printf("[watcher] add watch %s: %v", node.FsPath, err) + continue + } + watched++ + } + } + + w.watching = true + log.Printf("[watcher] started watching %d directories", watched) + + go w.loop() + return nil +} + +// Stop gracefully shuts down the watcher. +func (w *Watcher) Stop() { + w.mu.Lock() + defer w.mu.Unlock() + + if !w.watching { + return + } + w.watching = false + if w.debounceT != nil { + w.debounceT.Stop() + } + close(w.done) + if w.w != nil { + w.w.Close() + } + log.Printf("[watcher] stopped") +} + +// IsWatching returns whether the watcher is active. +func (w *Watcher) IsWatching() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.watching +} + +// addRecursive adds watchers for dir and all its non-hidden subdirectories. +func (w *Watcher) addRecursive(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if !d.IsDir() { + return nil + } + if d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + return w.w.Add(path) + }) +} + +// loop is the main event processing goroutine. +func (w *Watcher) loop() { + for { + select { + case event, ok := <-w.w.Events: + if !ok { + return + } + w.bufferEvent(event) + + case err, ok := <-w.w.Errors: + if !ok { + return + } + log.Printf("[watcher] error: %v", err) + + case <-w.done: + return + } + } +} + +// bufferEvent adds an event to the debounce buffer. +func (w *Watcher) bufferEvent(event fsnotify.Event) { + w.mu.Lock() + defer w.mu.Unlock() + + // Skip hidden files and .verstak directory. + if isHiddenOrMeta(event.Name) { + return + } + + dir := filepath.Dir(event.Name) + w.pending[dir] = append(w.pending[dir], event) + + if w.debounceT != nil { + w.debounceT.Stop() + } + w.debounceT = time.AfterFunc(DebounceWindow, w.flushPending) +} + +// flushPending processes all buffered events. +func (w *Watcher) flushPending() { + w.mu.Lock() + events := w.pending + w.pending = make(map[string][]fsnotify.Event) + w.mu.Unlock() + + // Group by file path, keeping the most recent event of each kind. + byFile := make(map[string]fsnotify.Event) + for _, evts := range events { + for _, e := range evts { + byFile[e.Name] = e + } + } + + for absPath, ev := range byFile { + rel, err := filepath.Rel(w.vaultRoot, absPath) + if err != nil { + continue + } + w.handleEvent(rel, absPath, ev) + } +} + +// handleEvent processes a single filesystem event. +func (w *Watcher) handleEvent(rel, absPath string, ev fsnotify.Event) { + switch { + case ev.Has(fsnotify.Create): + // File created — check for existing record, add if not present. + fi, err := os.Stat(absPath) + if err != nil { + return + } + if fi.IsDir() { + // New directory appeared — add watcher. + _ = w.addRecursive(absPath) + return + } + + // Find the parent node by FsPath. + parentDir := filepath.Dir(rel) + node, err := w.findNodeByFsPath(parentDir) + if err != nil { + log.Printf("[watcher] no node for path %s: %v", parentDir, err) + return + } + + // Check if file record already exists (race with scanner). + existing, _ := w.files.ListByNode(node.ID) + for _, rec := range existing { + if rec.Path == rel { + // Already tracked — just mark as not missing. + if rec.Missing { + _ = w.files.MarkMissing(rec.ID, false) + w.logActivity(node.ID, activity.TypeFileRestored, rec.Filename, rel) + } + return + } + } + + // New file — create record. + _, err = w.files.CopyIntoVault(node.ID, absPath, parentDir) + if err != nil { + log.Printf("[watcher] auto-add file %s: %v", rel, err) + return + } + w.logActivity(node.ID, activity.TypeFileAdded, fi.Name(), rel) + + case ev.Has(fsnotify.Remove) || ev.Has(fsnotify.Rename): + // File deleted or renamed away. + filename := filepath.Base(rel) + + rec, err := w.findRecordByPath(rel) + if err != nil { + return // unknown file, not tracked + } + if !rec.Missing { + _ = w.files.MarkMissing(rec.ID, true) + w.logActivity(rec.NodeID, activity.TypeFileDeleted, filename, rel) + } + + case ev.Has(fsnotify.Write): + // Content modified. + rec, err := w.findRecordByPath(rel) + if err != nil { + return + } + + sha, size := hashFileFast(absPath) + _ = w.updateFileRecord(rec.ID, size, sha) + w.logActivity(rec.NodeID, activity.TypeFileModified, rec.Filename, rel) + + case ev.Has(fsnotify.Chmod): + // Permission changes — ignore. + } +} + +// findNodeByFsPath finds the node whose FsPath matches the given relative directory. +func (w *Watcher) findNodeByFsPath(relDir string) (*nodes.Node, error) { + all, err := w.nodes.ListAllWithFsPath() + if err != nil { + return nil, err + } + for i := range all { + if all[i].FsPath == relDir { + return &all[i], nil + } + } + return nil, fmt.Errorf("no node with fs_path=%s", relDir) +} + +// findRecordByPath finds a file record by its vault-relative path. +// Checks active records first, then trashed. +func (w *Watcher) findRecordByPath(relPath string) (*files.Record, error) { + all, err := w.files.ListAllVaultWithTrashed() + if err != nil { + return nil, err + } + for i := range all { + if all[i].Path == relPath { + return &all[i], nil + } + } + return nil, fmt.Errorf("no record for path %s", relPath) +} + +func (w *Watcher) logActivity(nodeID, eventType, title, path string) { + _ = w.activity.Record(nodeID, activity.TargetFile, "", path, eventType, title, "") +} + +func (w *Watcher) updateFileRecord(id string, size int64, sha string) error { + _, err := w.files.DB().Exec( + `UPDATE files SET size=?, sha256=?, updated_at=?, missing=0 WHERE id=?`, + size, sha, time.Now().UTC().Format(time.RFC3339), id) + return err +} + +// isHiddenOrMeta returns true for files in .verstak or hidden directories. +func isHiddenOrMeta(path string) bool { + parts := strings.Split(filepath.Clean(path), string(filepath.Separator)) + for _, p := range parts { + if p == ".verstak" { + return true + } + if strings.HasPrefix(p, ".") && p != "." { + return true + } + } + return false +} + +// hashFileFast computes SHA256 without stat (caller already has fi). +func hashFileFast(absPath string) (string, int64) { + f, err := os.Open(absPath) + if err != nil { + return "", 0 + } + defer f.Close() + + h := sha256.New() + n, err := io.Copy(h, f) + if err != nil { + return "", 0 + } + return hex.EncodeToString(h.Sum(nil)), n +} diff --git a/internal/core/watcher/watcher_test.go b/internal/core/watcher/watcher_test.go new file mode 100644 index 0000000..3bc7404 --- /dev/null +++ b/internal/core/watcher/watcher_test.go @@ -0,0 +1,270 @@ +package watcher + +import ( + "os" + "path/filepath" + "testing" + "time" + + "verstak/internal/core/activity" + "verstak/internal/core/files" + "verstak/internal/core/nodes" + "verstak/internal/core/storage" +) + +func setupWatcherTest(t *testing.T) (string, *storage.DB, *nodes.Repository, *files.Service, *activity.Service, func()) { + t.Helper() + + vaultRoot, err := os.MkdirTemp("", "verstak-watcher-*") + if err != nil { + t.Fatal(err) + } + + dbDir := filepath.Join(vaultRoot, ".verstak") + if err := os.MkdirAll(dbDir, 0o750); err != nil { + t.Fatal(err) + } + + db, err := storage.Open(filepath.Join(dbDir, "index.db")) + if err != nil { + os.RemoveAll(vaultRoot) + t.Fatalf("open db: %v", err) + } + + nodeRepo := nodes.NewRepository(db) + fileSvc := files.NewService(db, vaultRoot, nodeRepo) + activitySvc := activity.NewService(db) + + cleanup := func() { + db.Close() + os.RemoveAll(vaultRoot) + } + + return vaultRoot, db, nodeRepo, fileSvc, activitySvc, cleanup +} + +// tempFileOutsideVault creates a temp file not inside the vault for import. +func tempFileOutsideVault(t *testing.T, content string) string { + t.Helper() + f, err := os.CreateTemp("", "verstak-import-*") + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(content); err != nil { + t.Fatal(err) + } + f.Close() + return f.Name() +} + +func TestScanner_NoChanges(t *testing.T) { + vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) + defer cleanup() + + node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") + if err != nil { + t.Fatal(err) + } + + dir := filepath.Join(vaultRoot, "test-folder") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + + // Import a file into vault (creates record + copies file). + src := tempFileOutsideVault(t, "hello world") + defer os.Remove(src) + _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") + if err != nil { + t.Fatal(err) + } + + scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) + result, err := scanner.Run() + if err != nil { + t.Fatal(err) + } + + if result.MissingFiles != 0 { + t.Errorf("expected 0 missing, got %d", result.MissingFiles) + } + if result.RestoredFiles != 0 { + t.Errorf("expected 0 restored, got %d", result.RestoredFiles) + } + if result.ModifiedFiles != 0 { + t.Errorf("expected 0 modified, got %d", result.ModifiedFiles) + } + if result.NewFiles != 0 { + t.Errorf("expected 0 new, got %d", result.NewFiles) + } + if result.NodesScanned != 1 { + t.Errorf("expected 1 node scanned, got %d", result.NodesScanned) + } +} + +func TestScanner_MissingFile(t *testing.T) { + vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) + defer cleanup() + + node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(vaultRoot, "test-folder") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + + // Import a file. + src := tempFileOutsideVault(t, "bye") + defer os.Remove(src) + _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") + if err != nil { + t.Fatal(err) + } + + // Now remove the physical file from vault. + vaultFiles, err := fileSvc.ListByNode(node.ID) + if err != nil || len(vaultFiles) == 0 { + t.Fatal("no vault files found") + } + rec := vaultFiles[0] + absPath := filepath.Join(vaultRoot, rec.Path) + if err := os.Remove(absPath); err != nil { + t.Fatal(err) + } + + scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) + result, err := scanner.Run() + if err != nil { + t.Fatal(err) + } + + if result.MissingFiles != 1 { + t.Errorf("expected 1 missing, got %d", result.MissingFiles) + } + + // Verify the file record is now marked missing. + trashed, err := fileSvc.ListTrashedByNode(node.ID) + if err != nil { + t.Fatal(err) + } + if len(trashed) != 1 { + t.Errorf("expected 1 trashed record, got %d", len(trashed)) + } + if !trashed[0].Missing { + t.Error("expected record to be marked missing") + } +} + +func TestScanner_RestoredFile(t *testing.T) { + vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) + defer cleanup() + + node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(vaultRoot, "test-folder") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + + // Import a file. + src := tempFileOutsideVault(t, "back") + defer os.Remove(src) + rec, err := fileSvc.CopyIntoVault(node.ID, src, "test-folder") + if err != nil { + t.Fatal(err) + } + + // Mark as missing and remove from disk. + if err := fileSvc.MarkMissing(rec.ID, true); err != nil { + t.Fatal(err) + } + absPath := filepath.Join(vaultRoot, rec.Path) + if err := os.Remove(absPath); err != nil { + t.Fatal(err) + } + + // Re-create the file. + if err := os.WriteFile(absPath, []byte("back again"), 0o640); err != nil { + t.Fatal(err) + } + + scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) + result, err := scanner.Run() + if err != nil { + t.Fatal(err) + } + + if result.RestoredFiles != 1 { + t.Errorf("expected 1 restored, got %d", result.RestoredFiles) + } +} + +func TestScanner_ModifiedFile(t *testing.T) { + vaultRoot, _, nodeRepo, fileSvc, activitySvc, cleanup := setupWatcherTest(t) + defer cleanup() + + node, err := nodeRepo.Create(nil, nodes.TypeFolder, "test-folder", 0, "", "test-folder") + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(vaultRoot, "test-folder") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + + // Import a file. + src := tempFileOutsideVault(t, "original") + defer os.Remove(src) + _, err = fileSvc.CopyIntoVault(node.ID, src, "test-folder") + if err != nil { + t.Fatal(err) + } + + // Get the record to know the vault path. + vaultFiles, err := fileSvc.ListByNode(node.ID) + if err != nil || len(vaultFiles) == 0 { + t.Fatal("no vault files found") + } + rec := vaultFiles[0] + absPath := filepath.Join(vaultRoot, rec.Path) + + // Modify the file content. + time.Sleep(10 * time.Millisecond) + if err := os.WriteFile(absPath, []byte("modified"), 0o640); err != nil { + t.Fatal(err) + } + + scanner := NewScanner(vaultRoot, nodeRepo, fileSvc, activitySvc) + result, err := scanner.Run() + if err != nil { + t.Fatal(err) + } + + if result.ModifiedFiles != 1 { + t.Errorf("expected 1 modified, got %d", result.ModifiedFiles) + } +} + +func TestIsHiddenOrMeta(t *testing.T) { + tests := []struct { + path string + expected bool + }{ + {"/vault/.verstak/config.yml", true}, + {"/vault/.verstak/trash/file.txt", true}, + {"/vault/my-project/file.txt", false}, + {"/vault/.hidden/file.txt", true}, + {"/vault/project/.hidden/file.txt", true}, + {"/vault/project/file.txt", false}, + } + for _, tc := range tests { + got := isHiddenOrMeta(tc.path) + if got != tc.expected { + t.Errorf("isHiddenOrMeta(%q) = %v, want %v", tc.path, got, tc.expected) + } + } +}