feat: ШАГ 1 — Bridge HTTP-сервер для браузерного расширения
- 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
This commit is contained in:
parent
f88376264d
commit
358c649b42
|
|
@ -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
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"verstak/internal/core/actions"
|
"verstak/internal/core/actions"
|
||||||
"verstak/internal/core/activity"
|
"verstak/internal/core/activity"
|
||||||
|
"verstak/internal/core/bridge"
|
||||||
"verstak/internal/core/config"
|
"verstak/internal/core/config"
|
||||||
"verstak/internal/core/files"
|
"verstak/internal/core/files"
|
||||||
"verstak/internal/core/nodes"
|
"verstak/internal/core/nodes"
|
||||||
|
|
@ -22,6 +23,7 @@ import (
|
||||||
"verstak/internal/core/storage"
|
"verstak/internal/core/storage"
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/templates"
|
"verstak/internal/core/templates"
|
||||||
|
"verstak/internal/core/watcher"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -31,18 +33,20 @@ type App struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
vaultOpen bool
|
vaultOpen bool
|
||||||
|
|
||||||
db *storage.DB
|
db *storage.DB
|
||||||
nodes *nodes.Repository
|
nodes *nodes.Repository
|
||||||
templates *templates.Registry
|
templates *templates.Registry
|
||||||
files *files.Service
|
files *files.Service
|
||||||
notes *notes.Service
|
notes *notes.Service
|
||||||
activity *activity.Service
|
activity *activity.Service
|
||||||
actions *actions.Service
|
actions *actions.Service
|
||||||
worklog *worklog.Service
|
worklog *worklog.Service
|
||||||
search *search.Service
|
search *search.Service
|
||||||
plugins *plugins.Manager
|
plugins *plugins.Manager
|
||||||
sync *syncsvc.Service
|
sync *syncsvc.Service
|
||||||
vault string
|
fileWatcher *watcher.Service
|
||||||
|
bridge *bridge.Server
|
||||||
|
vault string
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireVault returns an error if no vault is open and services are not initialized.
|
// requireVault returns an error if no vault is open and services are not initialized.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
syncsvc "verstak/internal/core/sync"
|
syncsvc "verstak/internal/core/sync"
|
||||||
"verstak/internal/core/templates"
|
"verstak/internal/core/templates"
|
||||||
"verstak/internal/core/vault"
|
"verstak/internal/core/vault"
|
||||||
|
"verstak/internal/core/watcher"
|
||||||
"verstak/internal/core/worklog"
|
"verstak/internal/core/worklog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -229,6 +230,29 @@ func (a *App) initVault(vaultPath string) error {
|
||||||
}
|
}
|
||||||
syncSvc := syncsvc.NewService(db, deviceID)
|
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.mu.Lock()
|
||||||
a.db = db
|
a.db = db
|
||||||
a.nodes = nodeRepo
|
a.nodes = nodeRepo
|
||||||
|
|
@ -241,13 +265,26 @@ func (a *App) initVault(vaultPath string) error {
|
||||||
a.plugins = pm
|
a.plugins = pm
|
||||||
a.templates = templatesReg
|
a.templates = templatesReg
|
||||||
a.sync = syncSvc
|
a.sync = syncSvc
|
||||||
|
a.fileWatcher = watcherSvc
|
||||||
a.vault = abs
|
a.vault = abs
|
||||||
a.vaultOpen = true
|
a.vaultOpen = true
|
||||||
a.mu.Unlock()
|
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
|
// Start auto-sync loop
|
||||||
go a.autoSyncLoop()
|
go a.autoSyncLoop()
|
||||||
|
|
||||||
|
// Start bridge server for browser extension integration.
|
||||||
|
a.startBridge(appCfg)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -258,6 +295,14 @@ func (a *App) closeVault() {
|
||||||
if !a.vaultOpen {
|
if !a.vaultOpen {
|
||||||
return
|
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 {
|
if a.db != nil {
|
||||||
a.db.Close()
|
a.db.Close()
|
||||||
}
|
}
|
||||||
|
|
@ -272,6 +317,8 @@ func (a *App) closeVault() {
|
||||||
a.plugins = nil
|
a.plugins = nil
|
||||||
a.templates = nil
|
a.templates = nil
|
||||||
a.sync = nil
|
a.sync = nil
|
||||||
|
a.fileWatcher = nil
|
||||||
|
a.bridge = nil
|
||||||
a.vault = ""
|
a.vault = ""
|
||||||
a.vaultOpen = false
|
a.vaultOpen = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
60
docs/PLAN.md
60
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)
|
### ШАГ 18 — TUI MVP (Bubble Tea)
|
||||||
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
|
- Терминальный интерфейс: дерево дел, поиск, добавление worklog, запуск действий, sync
|
||||||
|
|
|
||||||
3
go.mod
3
go.mod
|
|
@ -3,7 +3,9 @@ module verstak
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.44
|
github.com/mattn/go-sqlite3 v1.14.44
|
||||||
|
github.com/signintech/gopdf v0.36.1
|
||||||
github.com/wailsapp/wails/v2 v2.12.0
|
github.com/wailsapp/wails/v2 v2.12.0
|
||||||
golang.org/x/crypto v0.33.0
|
golang.org/x/crypto v0.33.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
|
@ -30,7 +32,6 @@ require (
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/samber/lo v1.49.1 // 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/tkrajina/go-reflector v0.5.8 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
|
|
|
||||||
2
go.sum
2
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/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
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=
|
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ const (
|
||||||
TypeFileRenamed = "file_renamed"
|
TypeFileRenamed = "file_renamed"
|
||||||
TypeFileCopied = "file_copied"
|
TypeFileCopied = "file_copied"
|
||||||
TypeFileMoved = "file_moved"
|
TypeFileMoved = "file_moved"
|
||||||
|
TypeFileModified = "file_modified"
|
||||||
|
TypeFileRestored = "file_restored"
|
||||||
TypeFolderAdded = "folder_added"
|
TypeFolderAdded = "folder_added"
|
||||||
TypeFolderDeleted = "folder_deleted"
|
TypeFolderDeleted = "folder_deleted"
|
||||||
TypeFolderRenamed = "folder_renamed"
|
TypeFolderRenamed = "folder_renamed"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,10 +32,23 @@ type WindowConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// VaultAppConfig holds per-vault settings in the global config.
|
// 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 {
|
type VaultAppConfig struct {
|
||||||
VaultID string `json:"vault_id,omitempty"`
|
VaultID string `json:"vault_id,omitempty"`
|
||||||
CreatedAt string `json:"created_at,omitempty"`
|
CreatedAt string `json:"created_at,omitempty"`
|
||||||
Sync SyncSettings `json:"sync,omitempty"`
|
Sync SyncSettings `json:"sync,omitempty"`
|
||||||
|
FileWatcher bool `json:"file_watcher"`
|
||||||
|
Bridge BridgeConfig `json:"bridge,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncSettings holds sync configuration for the current vault.
|
// SyncSettings holds sync configuration for the current vault.
|
||||||
|
|
@ -50,6 +63,13 @@ type SyncSettings struct {
|
||||||
LastError string `json:"last_error,omitempty"`
|
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 {
|
func DefaultAppConfig() *AppConfig {
|
||||||
return &AppConfig{
|
return &AppConfig{
|
||||||
Version: AppConfigVersion,
|
Version: AppConfigVersion,
|
||||||
|
|
@ -57,6 +77,9 @@ func DefaultAppConfig() *AppConfig {
|
||||||
Language: "ru",
|
Language: "ru",
|
||||||
EnabledTemplates: []string{"folder.default", "project.default", "client.default", "document.default", "recipe.default"},
|
EnabledTemplates: []string{"folder.default", "project.default", "client.default", "document.default", "recipe.default"},
|
||||||
EnabledPlugins: []string{},
|
EnabledPlugins: []string{},
|
||||||
|
Vault: VaultAppConfig{
|
||||||
|
FileWatcher: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,32 @@ func (s *Service) ListTrashedByNode(nodeID string) ([]Record, error) {
|
||||||
return scanRecords(rows)
|
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.
|
// MarkMissing flags a file as missing.
|
||||||
func (s *Service) MarkMissing(id string, missing bool) error {
|
func (s *Service) MarkMissing(id string, missing bool) error {
|
||||||
m := 0
|
m := 0
|
||||||
|
|
|
||||||
|
|
@ -169,6 +169,18 @@ func (r *Repository) ListRoots(includeDeleted bool) ([]Node, error) {
|
||||||
return scanNodes(rows)
|
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.
|
// ListInboxRoots returns active root capture artifacts explicitly marked for inbox.
|
||||||
func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) {
|
func (r *Repository) ListInboxRoots(includeDeleted bool) ([]Node, error) {
|
||||||
q := `SELECT ` + nodeColumns + ` FROM nodes
|
q := `SELECT ` + nodeColumns + ` FROM nodes
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
`
|
||||||
|
|
@ -73,6 +73,7 @@ var migrationFiles = map[int]string{
|
||||||
14: migration014,
|
14: migration014,
|
||||||
15: migration015,
|
15: migration015,
|
||||||
16: migration016,
|
16: migration016,
|
||||||
|
17: migration017,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) runInitialSchema() error {
|
func (db *DB) runInitialSchema() error {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue