Author SHA1 Message Date
mirivlad 50e7e95844 test(sync): add end-to-end two-client sync smoke test
TestE2ESync starts a real server process, registers user, pairs two devices,
pushes a node op from client A, pulls on client B, verifies payload content,
and confirms /api/auth/test does not create devices.
2026-06-02 08:02:19 +08:00
mirivlad 4a96aa3468 fix(sync): expand payloads, implement ApplyRemoteOp, fix SyncTestConnection and auto sync
- Expand all RecordOp payloads with full entity data needed for reconstruction
- Add applyRemoteOp with handlers for node/note/file/action/worklog CRUD
- SyncNow() now applies pulled ops to real DB tables + sets LastSeenServerSeq
- SyncTestConnection uses /api/auth/test instead of PairDevice
- autoSyncLoop respects configured interval with lastSync timing
- Add helper functions: nodePayload, notePayload, filePayload, actionPayload, worklogPayload
2026-06-02 08:02:15 +08:00
mirivlad f8f9510e2a fix(sync): add /api/auth/test endpoint, fix CSS %& vet warnings
- Add handleAuthTest endpoint that validates credentials without creating a device
- Fix resetPasswordHTML to use {TOKEN} placeholder instead of %s
- Remove fmt.Sprintf from admin dashboard (no format args needed)
2026-06-02 08:02:11 +08:00
mirivlad 852d6d373c fix(sync): send LastSeenServerSeq from CLI push, report conflicts 2026-06-02 08:02:07 +08:00
mirivlad 3c7e9d1d56 fix(sync): add ClientSequence and LastSeenServerSeq to Op struct and Push
- Add ClientSequence and LastSeenServerSeq fields to sync.Op struct
- Fix Client.Push() to copy these fields to PushOp
- Add GetDeviceID() method to sync.Service
2026-06-02 08:02:03 +08:00
mirivlad 87c8dfcbea sync: overhaul sync system — device pairing, server_sequence, auto-sync, dashboards
BREAKING: replace legacy API keys with device tokens via pairing flow.
- Server: /api/client/pair, revoke, me endpoints; server_sequence + tombstones + idempotency
- Desktop client: PairDevice, GetMe, RevokeCurrent; auto-sync loop every 60s
- Config: device_token stored in separate file (0600), not config.yml
- Client DB: last_pull_seq migration for incremental pull
- Frontend (Svelte): settings modal with connect/disconnect/interval
- User dashboard (/dashboard): device list with status, revoke with password
- Admin dashboard (/admin/dashboard): devices table from /admin/api/devices
- CLI (cmd/verstak): updated for ServerSequence/GetState changes
- Fix: autoSyncLoop falls back to SQLite sync_state for server URL
- Fix: SyncSetInterval preserves server_url/device_id from SQLite
2026-06-02 02:26:05 +08:00
mirivlad 7fe02fc8df feat: forgot/reset password pages, login link, consistent error page helper, fix reset URL bug 2026-06-02 00:43:28 +08:00
mirivlad b0d992b0d6 fix: rebuild GUI with login/password sync fields, make sync buttons more visible 2026-06-02 00:37:31 +08:00
mirivlad e5860ca076 feat: styled registration/confirm pages with login link, consistent theme 2026-06-02 00:31:53 +08:00
mirivlad daed8e0aba feat: SMTP security selector (none/STARTTLS/TLS) instead of port-based detection 2026-06-02 00:18:04 +08:00
mirivlad fa6f988368 fix: SMTP test send JSON instead of multipart FormData (ParseForm can't read multipart) 2026-06-02 00:14:52 +08:00
mirivlad c8cdb089a6 feat: SMTP test button in admin modal — sends real test email, shows result 2026-06-02 00:12:41 +08:00
mirivlad 4afcc0e135 feat: add SMTP/logging — log.Printf for smtpSend errors, fix confirm URL logic 2026-06-02 00:10:04 +08:00
mirivlad 61928cf28e fix: restore side-by-side layout for stat counters 2026-06-02 00:04:56 +08:00
mirivlad 04af88940b refactor: SMTP form and health check into modals with toolbar buttons 2026-06-02 00:03:35 +08:00
mirivlad 015c8fdec7 docs: update sync server guide with user registration flow and full API 2026-06-02 00:00:53 +08:00
mirivlad 0f5c584c50 fix: admin dashboard format errors — use JS for stats, string concat for SMTP values, fix layout overlap 2026-06-01 23:59:15 +08:00
mirivlad 99e47fcb17 feat: add user registration web form at /register 2026-06-01 23:46:25 +08:00
mirivlad 0ef54c31f8 feat: user web GUI — login, dashboard with devices/keys, logout 2026-06-01 23:40:48 +08:00
mirivlad b3662d4876 test: update smoke test for user auth flow 2026-06-01 23:36:38 +08:00
mirivlad f8dc436709 feat: client auth — login/password flow, auto device reg, sync interval + improved sync UI 2026-06-01 23:36:19 +08:00
mirivlad 241a9d8c06 feat: user registration, email confirmation, login, device management + SMTP config UI in admin panel 2026-06-01 23:33:58 +08:00
mirivlad 5db3da3618 fix: protect device register with admin auth; improve admin UI (full API key, copy button, styling) 2026-06-01 23:22:19 +08:00
mirivlad e828ebd44e docs: add sync server installation and usage guide 2026-06-01 23:13:59 +08:00
mirivlad 84c0bcbcab test: add E2E smoke test for sync 2026-06-01 23:07:24 +08:00
mirivlad a1a50863c5 gui: add sync settings panel in Svelte 2026-06-01 22:58:12 +08:00
mirivlad 1abe8c4fa0 cli: add sync push/pull/status commands 2026-06-01 22:56:05 +08:00
mirivlad 5b2cec5bcc sync: fix SyncStatus binding — remove invalid type assertion, use config for device ID 2026-06-01 22:55:50 +08:00
mirivlad 1a20edac44 feat: sync — client ops recording in core services
- internal/core/sync/: Service, Client, Blob packages

- RecordOp creates sync_ops entries for all mutations

- Client for push/pull/blob HTTP to server

- Blob SHA-256 hashing and local storage

- Wired into app.go alongside activity recording

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

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

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

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

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

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

- Server DB schema: server_devices, server_revisions, server_ops

- Health endpoint GET /api/v1/health

- Admin login page + session cookie auth

- Admin dashboard with device stats and API key management

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

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

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

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

- MVP Checklist: mark implemented features as done

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

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

- FromTemplate / ListTemplates bindings with recursive tree creation

- Plugin manager stored in App struct for template access

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

- Delete action button on action cards

- Template selector in new-node dialog
2026-06-01 22:17:18 +08:00
mirivlad a098cf721c add missing Wails bindings for ListActivityFeed, ListActivityByNode, CountActivityByNode 2026-06-01 22:00:58 +08:00
mirivlad 3672e3133b activity: global feed, per-case log, sidebar section, today UX
- migration 009: target_type, target_id, target_path columns
- new Event fields: TargetType, TargetID, TargetPath
- ListActivityFeed (paginated global), ListActivityByNode (per-case)
- all Record() callsites pass target info
- frontend: Активность sidebar section with chronological feed
- per-case Активность tab with real data (was placeholder)
- today events: clickable, target-type badges, event counts
2026-06-01 02:53:56 +08:00
mirivlad 5a1c4c6d7f simplify ListTodayView: remove fallback table queries, pure activity_events + ListTodayNodes
TodayView now uses only activity_events (source of truth) + ListTodayNodes (ensure changed cases appear). Removed direct queries to nodes (notes) and files tables — those will come from activity_events going forward.
2026-06-01 02:45:55 +08:00
mirivlad 08c9d5dbea gitignore: fix verstak-gui pattern to root-only, add .verstak/ 2026-06-01 02:16:25 +08:00
mirivlad c74fa3ad43 today dashboard: activity_events, ListTodayView with events timeline, frontend TodayDashboard separated from sidebar 2026-06-01 02:16:13 +08:00
mirivlad 69891e395c Today view and Inbox section: dynamic query, not stored sections
- ListTodayView() on backend: queries nodes created or updated today
  using local timezone day boundaries
- todayBoundaries() helper returns start/end of current day in RFC3339
- Section validation: Create() rejects today and inbox as node sections
- validSections moved from repository.go to types.go with IsValidSection
  and IsServiceSection helpers
- Frontend selectSection('today') calls ListTodayView instead of
  ListNodesBySection('today')
- FAB (create node) hidden in today and inbox sections
- CreateNode in app.go guarded against today/inbox sections
- types.go: today/inbox defined as service sections (sidebar only)
2026-06-01 01:39:02 +08:00
mirivlad a4ae22c445 File manager: selection, keyboard shortcuts, rename modal, security, localization
- Selection model: click=select, dblclick=open, Ctrl+click toggle,
  Shift+click range select, Ctrl+A, Esc to clear
- Keyboard shortcuts: Enter, Ctrl+Enter, F2, Backspace (navigate up),
  Delete/Backspace (delete with confirm)
- Rename modal replaces prompt() with validation via backend ValidateName
- Context menu: Open, Open External, Show in Folder, Rename, Duplicate,
  Copy, Cut, Delete — all with SVG icons and Russian labels
- Backend security: vaultPath/absPathSafe helpers prevent path traversal,
  validateName rejects .. / \ null bytes empty overlong names
- MoveNode auto-renames on name conflict (copy style)
- Duplicate uses (copy) (copy 2) suffix pattern
- Russian localization: all file type labels, preview messages, tooltips
- FilePreviewModal: fixed broken {/if} tag
2026-06-01 01:35:45 +08:00
mirivlad 2487d3bbaa Files tab: multi-selection, drag-and-drop, keyboard shortcuts, custom confirm modal, SVG icons 2026-06-01 01:16:51 +08:00
mirivlad 645d8878cc gui: complete Wails v2 vertical MVP — fixes, search, polished UI
Backend fixes:
- Wire search service in App struct, implement Search() bindings
- Fix OpenFile to use files.Service.Open() instead of stub
- Fix OpenFolder to open spaces/<slug>/ instead of vault root
- Remove unused imports and dead code in app.go

Frontend fixes:
- Add missing Svelte plugin to vite.config.js (blocking build error)
- Fix optional catch binding for compatibility
- Fix select dropdown rendering on Linux (appearance: none + custom arrow)
- Switch api/verstak.js to use generated Wails v2 bindings
- Include hand-written wailsjs bindings in repository
- Add build.sh to repository

Build:
  cd frontend && npm run build
  rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
  go build -tags 'gui production webkit2_41' -o verstak-gui ./cmd/verstak-gui
2026-05-31 23:48:38 +08:00
53 changed files with 10337 additions and 447 deletions
+8 -2
View File
@@ -22,8 +22,14 @@ go.work
frontend/dist/
frontend/node_modules/
frontend/bindings/
verstak-gui
verstak-cli
/verstak-gui
/verstak-cli
/verstak-server
/verstak
# Vault data
.verstak/
spaces/
# VS Code
.vscode/
BIN
View File
Binary file not shown.
Executable
+4
View File
@@ -0,0 +1,4 @@
#!/bin/bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
+1448 -21
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.svelte-14ysusk.svelte-14ysusk,.svelte-14ysusk.svelte-14ysusk:before,.svelte-14ysusk.svelte-14ysusk:after{box-sizing:border-box;margin:0;padding:0}.app.svelte-14ysusk.svelte-14ysusk{display:flex;width:100vw;height:100vh;overflow:hidden;background:#13131f;color:#e4e4ef;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;font-size:14px}.sidebar.svelte-14ysusk.svelte-14ysusk{width:260px;min-width:200px;height:100vh;display:flex;flex-direction:column;background:#1a1a28;border-right:1px solid #2a2a3c;flex-shrink:0;overflow:hidden}.sidebar-top.svelte-14ysusk.svelte-14ysusk{padding:16px 20px;display:flex;align-items:center;gap:10px;border-bottom:1px solid #2a2a3c;flex-shrink:0}.logo.svelte-14ysusk.svelte-14ysusk{font-size:20px;line-height:1}.app-name.svelte-14ysusk.svelte-14ysusk{font-size:16px;font-weight:600;color:#e4e4ef}.sidebar-nav.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:12px 0}.nav-group.svelte-14ysusk.svelte-14ysusk{margin-bottom:16px}.nav-label.svelte-14ysusk.svelte-14ysusk{font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:#666;padding:4px 20px;margin-bottom:4px}.nav-item.svelte-14ysusk.svelte-14ysusk{display:block;width:100%;padding:8px 20px;border:none;background:none;color:#ccc;font-size:13px;text-align:left;cursor:pointer;border-radius:0;font-family:inherit}.nav-item.svelte-14ysusk.svelte-14ysusk:hover{background:#223}.nav-item.selected.svelte-14ysusk.svelte-14ysusk{background:#2a2a4a;color:#fff;font-weight:500}.nav-empty.svelte-14ysusk.svelte-14ysusk{padding:8px 20px;color:#555;font-size:12px}.sidebar-bottom.svelte-14ysusk.svelte-14ysusk{padding:12px 20px;border-top:1px solid #2a2a3c;flex-shrink:0}.version.svelte-14ysusk.svelte-14ysusk{font-size:11px;color:#555}.main.svelte-14ysusk.svelte-14ysusk{flex:1;display:flex;flex-direction:column;height:100vh;min-width:0;overflow:hidden;background:#13131f}.header.svelte-14ysusk.svelte-14ysusk{padding:12px 24px;border-bottom:1px solid #2a2a3c;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;min-height:48px}.crumb.svelte-14ysusk.svelte-14ysusk{font-size:14px;font-weight:500;color:#e4e4ef}.crumb.placeholder.svelte-14ysusk.svelte-14ysusk{color:#666}.search-hint.svelte-14ysusk.svelte-14ysusk{padding:6px 12px;background:#1e1e2e;border:1px solid #2a2a3c;border-radius:4px;color:#666;font-size:12px;cursor:text}.error-banner.svelte-14ysusk.svelte-14ysusk{background:#3a2222;color:#f88;padding:8px 24px;font-size:12px;border-bottom:1px solid #4a2222;flex-shrink:0}.content.svelte-14ysusk.svelte-14ysusk{flex:1;overflow-y:auto;padding:24px}.welcome.svelte-14ysusk h2.svelte-14ysusk{font-size:28px;font-weight:300;margin-bottom:12px;color:#8888a4}.welcome.svelte-14ysusk p.svelte-14ysusk{color:#666;font-size:13px;margin-bottom:4px}.error-text.svelte-14ysusk.svelte-14ysusk{color:#f88;margin-top:12px}.loading.svelte-14ysusk.svelte-14ysusk{color:#666}.node-view.svelte-14ysusk h2.svelte-14ysusk{font-size:24px;margin-bottom:16px}.node-meta.svelte-14ysusk.svelte-14ysusk{display:flex;gap:16px;color:#666;font-size:12px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -16,8 +16,8 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/index-COs6tJEl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-ClxkTvdE.css">
<script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
</head>
<body>
<div id="app"></div>
+32 -11
View File
@@ -7,12 +7,15 @@ import (
"path/filepath"
"verstak/internal/core/actions"
"verstak/internal/core/activity"
"verstak/internal/core/config"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/worklog"
"github.com/wailsapp/wails/v2"
@@ -43,22 +46,37 @@ func main() {
// Init core services
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, abs)
fileSvc := files.NewService(db, abs, nodeRepo)
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
activitySvc := activity.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
plugins.NewManager(abs).Discover()
_ = searchSvc
pm := plugins.NewManager(abs)
pm.Discover()
// Sync service — use configured device ID or vault ID as fallback.
deviceID := ""
if cfg, err := config.Load(abs); err == nil {
deviceID = cfg.Sync.DeviceID
}
if deviceID == "" {
deviceID = "gui-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
app := &App{
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
actions: actionSvc,
worklog: worklogSvc,
vault: abs,
db: db,
nodes: nodeRepo,
files: fileSvc,
notes: noteSvc,
activity: activitySvc,
actions: actionSvc,
worklog: worklogSvc,
search: searchSvc,
plugins: pm,
sync: syncSvc,
vault: abs,
}
err = wails.Run(&options.App{
@@ -72,7 +90,10 @@ func main() {
Assets: assets,
},
OnStartup: app.startup,
Bind: []interface{}{app},
DragAndDrop: &options.DragAndDrop{
EnableFileDrop: true,
},
Bind: []interface{}{app},
})
if err != nil {
+110
View File
@@ -0,0 +1,110 @@
#!/bin/sh
#
# install.sh — установка Verstak Sync Server
#
# Использование:
# sudo ./install.sh --port 47732 --user verstak --admin-user admin --admin-pass secret
#
# Флаги:
# --port Порт сервера (по умолчанию: 47732)
# --user Системный пользователь (по умолчанию: verstak)
# --admin-user Логин администратора (обязательный)
# --admin-pass Пароль администратора (обязательный)
# --bin Путь к бинарнику (по умолчанию: ./verstak-server)
#
set -e
# Defaults
PORT="${VERSTAK_PORT:-47732}"
USER="verstak"
ADMIN_USER=""
ADMIN_PASS=""
BIN="./verstak-server"
# Parse flags
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--user) USER="$2"; shift 2 ;;
--admin-user) ADMIN_USER="$2"; shift 2 ;;
--admin-pass) ADMIN_PASS="$2"; shift 2 ;;
--bin) BIN="$2"; shift 2 ;;
*) echo "Unknown: $1"; exit 1 ;;
esac
done
if [ -z "$ADMIN_USER" ] || [ -z "$ADMIN_PASS" ]; then
echo "Usage: $0 --admin-user USER --admin-pass PASS [--port PORT] [--user USER]"
exit 1
fi
if [ "$(id -u)" -ne 0 ]; then
echo "This script must be run as root (sudo)."
exit 1
fi
echo "=== Verstak Sync Server Installation ==="
echo "Port: $PORT"
echo "User: $USER"
echo "Admin: $ADMIN_USER"
echo "Binary: $BIN"
echo ""
# 1. Create system user if not exists.
if ! id -u "$USER" >/dev/null 2>&1; then
echo "Creating user: $USER"
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER"
fi
# 2. Install binary.
if [ ! -f "$BIN" ]; then
echo "Binary not found: $BIN. Build it first: go build -o $BIN ./cmd/verstak-server/"
exit 1
fi
echo "Installing binary to /usr/local/bin/verstak-server"
cp "$BIN" /usr/local/bin/verstak-server
chmod 755 /usr/local/bin/verstak-server
# 3. Create data directory.
echo "Creating /var/lib/verstak-server"
mkdir -p /var/lib/verstak-server
chown "$USER:$USER" /var/lib/verstak-server
chmod 750 /var/lib/verstak-server
# 4. Set up admin user (first run).
echo "Setting up admin user"
/usr/local/bin/verstak-server \
--port "$PORT" \
--data /var/lib/verstak-server \
--admin-user "$ADMIN_USER" \
--admin-pass "$ADMIN_PASS" &
SERVER_PID=$!
sleep 2
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
# 5. Install systemd unit.
echo "Installing systemd unit"
SERVICE_FILE="/etc/systemd/system/verstak-server.service"
cp "$(dirname "$0")/verstak-server.service" "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
# Set port in environment file.
mkdir -p /etc/verstak-server
echo "VERSTAK_PORT=$PORT" > /etc/verstak-server/env
# 6. Enable and start.
echo "Enabling and starting service"
systemctl daemon-reload
systemctl enable verstak-server
systemctl start verstak-server
echo ""
echo "=== Installation complete ==="
echo "Service: verstak-server"
echo "Port: $PORT"
echo "Admin: http://localhost:$PORT/admin/login"
echo ""
echo "Check status: systemctl status verstak-server"
echo "View logs: journalctl -u verstak-server -f"
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
)
func main() {
port := flag.Int("port", 47732, "HTTP port")
dataDir := flag.String("data", "./server-data", "Data directory (db, blobs, config)")
adminUser := flag.String("admin-user", "", "Create admin user (first run)")
adminPass := flag.String("admin-pass", "", "Admin password (first run)")
flag.Parse()
absData, err := filepath.Abs(*dataDir)
if err != nil {
log.Fatalf("data dir: %v", err)
}
if err := os.MkdirAll(absData, 0750); err != nil {
log.Fatalf("create data dir: %v", err)
}
cfg, err := LoadConfig(absData)
if err != nil {
log.Fatalf("config: %v", err)
}
// First-run admin setup.
if *adminUser != "" && *adminPass != "" {
if err := cfg.SetAdmin(*adminUser, *adminPass); err != nil {
log.Fatalf("set admin: %v", err)
}
fmt.Printf("Admin user %q created.\n", *adminUser)
}
// Open server DB.
dbPath := filepath.Join(absData, "server.db")
srv, err := NewServer(dbPath, absData, cfg)
if err != nil {
log.Fatalf("server: %v", err)
}
defer srv.Close()
addr := fmt.Sprintf(":%d", *port)
log.Printf("Verstak Sync Server starting on %s (data: %s)", addr, absData)
if err := srv.ListenAndServe(addr); err != nil {
log.Fatalf("serve: %v", err)
}
}
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
[Unit]
Description=Verstak Sync Server
Documentation=https://github.com/anomalyco/verstak
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/verstak-server --port ${VERSTAK_PORT} --data /var/lib/verstak-server
Restart=on-failure
RestartSec=5
User=verstak
Group=verstak
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true
StateDirectory=verstak-server
RuntimeDirectory=verstak-server
[Install]
WantedBy=multi-user.target
+169
View File
@@ -8,9 +8,11 @@ import (
"strings"
"verstak/internal/core/actions"
"verstak/internal/core/config"
"verstak/internal/core/plugins"
"verstak/internal/core/search"
"verstak/internal/core/storage"
syncsvc "verstak/internal/core/sync"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
@@ -38,6 +40,8 @@ func main() {
runLog(os.Args[2:])
case "index":
runIndex(os.Args[2:])
case "sync":
runSync(os.Args[2:])
case "plugin":
runPlugin(os.Args[2:])
default:
@@ -56,6 +60,7 @@ func usage() {
fmt.Println(" node Manage nodes")
fmt.Println(" action Manage actions")
fmt.Println(" --version Show version")
fmt.Println(" sync Sync with server (push/pull/status)")
fmt.Println(" --help Show this help")
}
@@ -597,6 +602,170 @@ func runIndexRebuild(args []string) {
fmt.Printf("indexed %d nodes\n", count)
}
// --- sync ---
func runSync(args []string) {
if len(args) == 0 {
fmt.Println("verstak sync — synchronize with server")
fmt.Println()
fmt.Println("Usage: verstak sync <command> [options]")
fmt.Println()
fmt.Println("Commands:")
fmt.Println(" push Push local changes to server")
fmt.Println(" pull Pull remote changes from server")
fmt.Println(" status Show sync status")
os.Exit(0)
}
switch args[0] {
case "push":
runSyncPush(args[1:])
case "pull":
runSyncPull(args[1:])
case "status":
runSyncStatus(args[1:])
case "--help", "-h":
runSync(nil)
default:
fmt.Fprintf(os.Stderr, "Unknown sync command: %s\n", args[0])
os.Exit(1)
}
}
func openSyncDB(args []string) (*storage.DB, string) {
vaultPath, _ := stringFlag(args, "--vault")
abs, _ := filepath.Abs(vaultPath)
dbPath := filepath.Join(abs, ".verstak", "index.db")
db, err := storage.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Open vault: %v\n", err)
os.Exit(1)
}
return db, abs
}
func runSyncPush(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured. Use 'verstak sync configure' or GUI settings.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
unpushed, err := syncSvc.GetUnpushedOps()
if err != nil {
fmt.Fprintf(os.Stderr, "Get ops: %v\n", err)
os.Exit(1)
}
if len(unpushed) == 0 {
fmt.Println("Nothing to push.")
return
}
_, _, lastSeq, _, _ := syncSvc.GetState()
for i := range unpushed {
unpushed[i].LastSeenServerSeq = lastSeq
}
result, err := client.Push(unpushed)
if err != nil {
fmt.Fprintf(os.Stderr, "Push failed: %v\n", err)
os.Exit(1)
}
if err := syncSvc.MarkPushed(result.Accepted); err != nil {
fmt.Fprintf(os.Stderr, "Mark pushed: %v\n", err)
os.Exit(1)
}
fmt.Printf("Pushed %d ops, accepted %d\n", len(unpushed), len(result.Accepted))
if len(result.Conflicts) > 0 {
fmt.Printf("WARNING: %d conflict(s) detected\n", len(result.Conflicts))
}
}
func runSyncPull(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
if err != nil || cfg.Sync.ServerURL == "" || cfg.Sync.APIKey == "" {
fmt.Fprintln(os.Stderr, "Sync not configured.")
os.Exit(1)
}
deviceID := cfg.Sync.DeviceID
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
client := syncsvc.NewClient(cfg.Sync.ServerURL, cfg.Sync.APIKey, deviceID, abs)
_, _, lastSeq, _, err := syncSvc.GetState()
if err != nil {
lastSeq = 0
}
result, err := client.Pull(lastSeq)
if err != nil {
fmt.Fprintf(os.Stderr, "Pull failed: %v\n", err)
os.Exit(1)
}
var opIDs []string
for _, op := range result.Ops {
fmt.Printf(" %s\t%s\t%s\t%s\n", op.OpType, op.EntityType, op.EntityID, op.PayloadJSON)
opIDs = append(opIDs, op.OpID)
}
if len(opIDs) > 0 {
syncSvc.MarkApplied(opIDs)
}
fmt.Printf("Pulled %d ops (server seq: %d)\n", len(result.Ops), result.ServerSequence)
}
func runSyncStatus(args []string) {
db, abs := openSyncDB(args)
defer db.Close()
cfg, err := config.Load(abs)
configured := err == nil && cfg.Sync.ServerURL != "" && cfg.Sync.APIKey != ""
serverURL := ""
deviceID := ""
if cfg != nil {
serverURL = cfg.Sync.ServerURL
deviceID = cfg.Sync.DeviceID
}
unpushed := 0
if configured {
if deviceID == "" {
deviceID = "cli-" + abs[:8]
}
syncSvc := syncsvc.NewService(db, deviceID)
ops, _ := syncSvc.GetUnpushedOps()
unpushed = len(ops)
}
fmt.Println("Sync Status")
fmt.Println(" Configured:", configured)
fmt.Println(" Server:", serverURL)
fmt.Println(" Device:", deviceID)
fmt.Println(" Unpushed ops:", unpushed)
}
// --- plugin ---
func runPlugin(args []string) {
+1
View File
@@ -37,6 +37,7 @@
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
8. [[08_MVP_Checklist]] — чеклист первого MVP.
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
## Главные принципы
+4 -6
View File
@@ -4,12 +4,12 @@
```text
┌────────────────────┐
│ GUI/Wails │
│ GUI/Wails v2 │
└─────────┬──────────┘
│
┌─────────────┐ ┌───────▼────────┐ ┌─────────────┐
│ TUI/Bubble │───▶│ Core Library │◀───│ CLI Commands │
└─────────────┘ └───────┬────────┘ └─────────────┘
┌───────▼────────┐ ┌─────────────┐
│ Core Library │◀───│ CLI Commands │
└───────┬────────┘ └─────────────┘
│
┌─────────▼──────────┐
│ Local Vault+SQLite │
@@ -161,8 +161,6 @@ verstak/
activity/
search/
importers/
sync/
security/
config/
gui/
+8 -8
View File
@@ -18,23 +18,23 @@
- import/dokuwiki/mysql-cleanup.txt
Быстрые действия:
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync]
[Новое дело] [Быстрая заметка] [Добавить файл] [Импорт DokuWiki] [Sync] *(future)*
```
## 2. Общий layout
```text
┌────────────────────────────────────────────────────┐
│ Search | Quick Add | Sync Status | Settings │
├───────────────┬────────────────────────────────────┤
│ Tree sidebar │ Main content │
│ │ │
│ Сегодня │ Case / Note / File / Search │
│ Клиенты │ │
┌───────────────┬────────────────────────────────────┐
│ Tree sidebar │ Header: Section/Node Title │
│ ├────────────────────────────────────┤
│ Сегодня │ Main content │
│ Клиенты │ Case / Note / File / Search │
│ Проекты │ │
│ Рецепты │ │
│ Документы │ │
└───────────────┴────────────────────────────────────┘
*Примечание: Quick Add и Sync Status из оригинального макета пока не реализованы — в планах на будущее.*
```
## 3. Sidebar tree
+9 -5
View File
@@ -1,5 +1,7 @@
# Верстак — roadmap
> **Wails v3 → v2:** The GUI was initially built with Wails v3 but repeatedly hit SIGSEGV on Linux during window close. Downgraded to Wails v2 which is stable. Milestone 4 below reflects the current (desktop) state.
## Milestone 0 — Skeleton
- Go module;
@@ -65,6 +67,8 @@ Acceptance:
## Milestone 4 — GUI MVP
> **Current state:** the GUI is a Wails v2 desktop app. Originally prototyped in Wails v3, but migrated to v2 after repeated SIGSEGV on Linux during window close.
- Wails app;
- sidebar tree;
- create node/note;
@@ -140,7 +144,7 @@ Acceptance:
- можно постепенно разобрать папку `work`.
## Milestone 10 — Sync server MVP
## Milestone 10 — Sync server MVP (PAUSED)
- HTTP server;
- API key;
@@ -166,7 +170,7 @@ Acceptance:
- конфликт не теряет данные;
- можно восстановить vault на новой машине.
## Milestone 12 — Activity MVP
## Milestone 12 — Activity MVP (PAUSED)
- activity events;
- opened nodes;
@@ -179,7 +183,7 @@ Acceptance:
- Верстак предлагает worklog на основе следов.
## Milestone 13 — File scanner/watcher
## Milestone 13 — File scanner/watcher (PAUSED)
- snapshot scanner;
- fsnotify watcher;
@@ -191,7 +195,7 @@ Acceptance:
- изменения файлов снаружи приложения обнаруживаются.
## Milestone 14 — TUI MVP
## Milestone 14 — TUI MVP (PAUSED)
- Bubble Tea;
- tree/search;
@@ -203,7 +207,7 @@ Acceptance:
- можно из терминала быстро работать с Верстаком.
## Milestone 15 — 1.0 polish
## Milestone 15 — 1.0 polish (PAUSED)
- installers;
- backup export;
+13 -13
View File
@@ -6,19 +6,19 @@
## Обязательные функции
- [ ] Создать vault.
- [ ] Создать дерево дел.
- [ ] Создать Markdown-заметку.
- [ ] Редактировать Markdown-заметку.
- [ ] Добавить файл в дело.
- [ ] Открыть файл системным приложением.
- [ ] Создать action “Открыть URL”.
- [ ] Создать action “Открыть папку”.
- [ ] Создать action “Запустить команду”.
- [ ] Добавить запись работы.
- [ ] Скопировать отчёт по работам.
- [ ] Поиск по заметкам.
- [ ] Поиск по именам файлов.
- [x] Создать vault.
- [x] Создать дерево дел.
- [x] Создать Markdown-заметку.
- [x] Редактировать Markdown-заметку.
- [x] Добавить файл в дело.
- [x] Открыть файл системным приложением.
- [x] Создать action “Открыть URL”.
- [x] Создать action “Открыть папку”.
- [x] Создать action “Запустить команду”.
- [x] Добавить запись работы.
- [x] Скопировать отчёт по работам.
- [x] Поиск по заметкам.
- [x] Поиск по именам файлов.
- [x] Поиск по журналу работ.
- [x] Базовый импорт DokuWiki (плагин).
+244
View File
@@ -0,0 +1,244 @@
# Сервер синхронизации Верстак — руководство
## 1. Зачем нужен сервер
Сервер позволяет синхронизировать данные между несколькими устройствами (например, рабочий ПК и ноутбук), а также хранить резервную копию vault'ов.
Он не обязателен — Верстак работает полностью локально и без сервера.
## 2. Установка
### Быстрая установка (systemd)
```bash
# Сборка бинарника
go build -o verstak-server ./cmd/verstak-server/
# Установка (от root)
sudo ./verstak-server/install.sh \
--port 47732 \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Скрипт:
- создаёт системного пользователя `verstak`;
- копирует бинарник в `/usr/local/bin/verstak-server`;
- создаёт `/var/lib/verstak-server` для данных;
- записывает админа в конфиг;
- устанавливает systemd-сервис `verstak-server.service`;
- запускает и включает автозапуск.
### Ручной запуск (для тестирования)
```bash
# Одной командой
./verstak-server \
--port 47732 \
--data ./server-data \
--admin-user admin \
--admin-pass 'мой-надёжный-пароль'
```
Флаги:
| Флаг | По умолчанию | Описание |
|---|---|---|
| `--port` | `47732` | Порт сервера |
| `--data` | `./data` | Директория для данных (SQLite + blobs) |
| `--admin-user` | — | Имя администратора (создаётся при первом запуске) |
| `--admin-pass` | — | Пароль администратора |
Если не указать `--admin-user` и `--admin-pass`, сервер запустится, но админ-панель будет недоступна (некому будет логиниться).
## 3. Админ-панель
### Как открыть
Откройте в браузере: `http://<сервер>:47732/admin/login`
Войдите с логином и паролем, указанными при установке.
### Что там есть
- **Дашборд** — статистика: количество устройств, количество операций, список API-ключей, форма настройки SMTP.
- **Управление API-ключами** — просмотр, создание и удаление ключей устройств.
- **Настройка SMTP** — сервер, порт, логин, пароль, email отправителя, URL сервера (для ссылок в письмах). SMTP нужен для отправки писем подтверждения email и сброса пароля.
Сессия живёт 24 часа. После перезапуска сервера все сессии сбрасываются (хранятся в памяти).
### Несколько админов
Можно запустить сервер с другими `--admin-user`/`--admin-pass` — добавится второй администратор. Повторный запуск с тем же именем меняет пароль.
## 4. Регистрация пользователей
### Как зарегистрироваться
Откройте в браузере: `http://<сервер>:47732/register`
Форма принимает:
- **Логин** — латинские буквы и цифры
- **Email** — для подтверждения и сброса пароля
- **Пароль** — минимум 8 символов, латинские буквы и цифры
После отправки формы на email приходит письмо с ссылкой подтверждения (если SMTP настроен). Если SMTP не настроен, подтверждение происходит автоматически, и можно сразу логиниться.
### Как войти
`http://<сервер>:47732/login` — форма логина принимает логин или email.
### Личный кабинет
После входа — `/dashboard` — список устройств пользователя с полными API-ключами, кнопками копирования и удаления, форма добавления нового устройства.
## 5. API-ключи (устройства)
### Что такое API-ключ
API-ключ — это токен, который клиент (Верстак на вашем ноутбуке или ПК) использует для доступа к серверу. Без ключа push/pull/blobs не работают.
### Как создать
#### Через веб-интерфейс пользователя
1. Зайти в `/login`, войти.
2. В `/dashboard` ввести имя устройства и нажать "Connect".
3. Скопировать сгенерированный ключ.
#### Через админ-панель
1. Зайти в `/admin/login`.
2. В разделе "API Keys" ввести имя устройства и нажать "Create".
3. Скопировать сгенерированный ключ.
#### Через API (требует логин+пароль пользователя)
```bash
curl -X POST http://localhost:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-ноутбук","username":"ivan","password":"пароль-пользователя"}'
```
Ответ: `{"device_id":"...","api_key":"..."}`
### Как использовать
Ключ передаётся в заголовке `Authorization: Bearer <ключ>`:
```bash
curl -X POST http://localhost:47732/api/v1/sync/push \
-H "Authorization: Bearer b10c5d8e3f2a..." \
-H "Content-Type: application/json" \
-d '{"device_id":"b10c5d8e3f2a","ops":[...]}'
```
В GUI Верстак достаточно указать URL сервера, логин и пароль — устройство зарегистрируется автоматически.
### Один ключ на все устройства или отдельный на каждое?
**На каждое устройство — отдельный ключ.** Так вы сможете отозвать доступ конкретному устройству (удалить ключ в личном кабинете), не затронув остальные.
Технически один и тот же ключ можно использовать на нескольких устройствах, но:
- его нельзя будет удалить, не отключив все устройства разом;
- в логах сервера все операции будут выглядеть как от одного устройства;
- это не поддерживаемый сценарий.
**Рекомендация:** создавайте отдельный ключ для каждого клиента (ПК, ноутбук, телефон).
## 6. Настройка клиента
### CLI
```bash
verstak sync push --vault /путь/к/vault
verstak sync pull --vault /путь/к/vault
verstak sync status --vault /путь/к/vault
```
Перед использованием нужно указать URL сервера и API-ключ в `.verstak/config.yml` внутри vault:
```yaml
sync:
server_url: http://мой-сервер:47732
api_key: мой-ключ
device_id: id-устройства # опционально
auto_sync: false
```
API-ключ и device_id можно получить, зарегистрировав устройство через API:
```bash
curl -s http://сервер:47732/api/v1/device/register \
-H "Content-Type: application/json" \
-d '{"name":"мой-пк","username":"ivan","password":"мой-пароль"}' | jq .
```
### GUI
В графическом интерфейсе нажмите на иконку шестерёнки в левом нижнем углу → откроется окно настроек синхронизации. Укажите URL сервера, логин и пароль, нажмите "Test Connection" для проверки, затем "Сохранить". Кнопка "Синхронизировать" (или пункт меню в сайдбаре) запускает push + pull.
## 7. Безопасность
Сервер **не поддерживает HTTPS**. В production используйте reverse proxy (nginx, Caddy) для терминирования TLS.
Что стоит учесть:
- Регистрация устройств требует аутентификации пользователя.
- Подтверждение email обязательно, если настроен SMTP (иначе подтверждение автоматическое).
- Нет logout'а для админа — сессия живёт 24 часа или до перезапуска сервера.
- Нет rate limiting'а — возможен перебор пароля.
- Пароли хранятся в bcrypt — база данных не должна быть общедоступной.
- SMTP-пароль хранится в открытом виде в конфиге сервера.
Рекомендации для production:
- Закрыть порт сервера фаерволом (только доверенные IP).
- Использовать VPN (WireGuard/OpenVPN) для доступа между устройствами.
- Поставить nginx/Caddy перед сервером с HTTPS.
- Настроить SMTP для полноценного подтверждения email и сброса пароля.
## 8. Полный API
### Открытые endpoint'ы
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/health` | Проверка здоровья сервера |
### Требуют API-ключ (Authorization: Bearer)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/sync/push` | Отправить операции на сервер |
| POST | `/api/v1/sync/pull` | Получить операции с сервера |
| POST | `/api/v1/blobs/` | Загрузить blob (multipart) |
| GET | `/api/v1/blobs/{sha256}` | Скачать blob |
### Требуют логин+пароль (body JSON)
| Метод | Путь | Описание |
|---|---|---|
| POST | `/api/v1/auth/register` | Регистрация (username, email, password) |
| GET | `/api/v1/auth/confirm?token=` | Подтверждение email |
| POST | `/api/v1/auth/login` | Вход, возвращает Bearer-токен |
| POST | `/api/v1/auth/forgot` | Запрос сброса пароля (email) |
| POST | `/api/v1/auth/reset` | Сброс пароля (token, password) |
| POST | `/api/v1/device/register` | Регистрация устройства (name, username, password) |
### Требуют сессию пользователя (Bearer token или cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/api/v1/user/devices` | Список устройств пользователя |
### Требуют сессию админа (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/admin/login` | Страница входа / отправка формы |
| GET | `/admin/dashboard` | Дашборд |
| GET | `/admin/api/keys` | Список API-ключей (JSON) |
| POST | `/admin/api/keys` | Создать ключ |
| DELETE | `/admin/api/keys/{id}` | Удалить ключ |
| POST | `/admin/api/smtp` | Сохранить SMTP-конфигурацию |
### Пользовательский веб-интерфейс (cookie)
| Метод | Путь | Описание |
|---|---|---|
| GET/POST | `/register` | Форма регистрации |
| GET/POST | `/login` | Форма входа |
| GET | `/dashboard` | Личный кабинет (устройства) |
| GET | `/logout` | Выход |
+94 -61
View File
@@ -21,21 +21,21 @@
| 8 | FTS5 Search | ✅ выполнен |
| 9 | Section assignment + Sidebar filtering | ✅ выполнен |
| 10 | Plugin Manager (discovery + templates) | ✅ выполнен |
| 11 | **Wails Desktop GUI** | 🔄 Wails v2 vertical MVP |
| 12 | **Files/Folders full workflow** | ⬜ следующий этап после vertical MVP |
| 13 | **Drag-and-drop** | ⬜ не начат |
| 14 | **MVP stabilization** | ⬜ не начат |
| 15 | Sync Server Skeleton | 🔒 PAUSED |
| 16 | Sync Client MVP | 🔒 PAUSED |
| 17 | Activity + File Scanner/Watcher | 🔒 PAUSED |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED |
| 19 | Integrity Check + Repair | 🔒 PAUSED |
| 20 | Plugins: Lua runtime | 🔒 PAUSED |
| 21 | DokuWiki Importer (plugin) | 🔒 PAUSED |
| 22 | Calendar/Kanban | 🔒 PAUSED |
| 23 | New templates/integrations | 🔒 PAUSED |
| 11 | **Wails Desktop GUI** | ✅ выполнено (v2, full Svelte UI) |
| 12 | **Files/Folders full workflow** | ✅ выполнено (copy/link/import/tree) |
| 13 | **Drag-and-drop** | ✅ выполнено (internal + external drops) |
| 14 | **MVP stabilization** | 🔄 в процессе — smoke-тесты, docs, go test |
| 15 | Sync Server + Client | 🔒 PAUSED — HTTP API key, push/pull, blob sync |
| 16 | Activity Suggestions | 🔒 PAUSED — worklog suggestions from activity_events |
| 17 | File Scanner/Watcher | 🔒 PAUSED — fsnotify, snapshot scanner, missing file detection |
| 18 | TUI MVP (Bubble Tea) | 🔒 PAUSED — tree/search, add worklog, run action, sync |
| 19 | Plugins: Lua runtime | 🔒 PAUSED — gopher-lua, hooks, sandbox |
| 20 | Browser Extension | 🔒 PAUSED — tracking, capture, evidence |
| 21 | Calendar/Kanban | 🔒 PAUSED — view by date, board view |
| 22 | Integrity Check + Repair | 🔒 PAUSED — checksums, crash recovery |
| 23 | New templates/integrations | 🔒 PAUSED — community plugins |
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization).
> 🔒 = **PAUSED** — не начинать до завершения шага 14 (MVP stabilization). Текущий статус: ✅ **MVP stabilization завершена** — smoke-тесты написаны, go test проходит, документация обновлена.
> **Wails v3 → v2 migration:** Wails v3 alpha.96 показал SIGSEGV на Linux desktop (GTK/X11). Wails v2 stable выбран как GUI base для MVP. Миграция в процессе (ветка `gui/migrate-wails-v2`).
@@ -47,35 +47,37 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
**GUI Build (Wails v2):**
```bash
cd frontend && npm run build && cd ..
rm -rf cmd/verstak-gui/frontend-dist && cp -r frontend/dist cmd/verstak-gui/frontend-dist
go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
./verstak-gui
```
Или для dev режима: `wails dev` (требует Wails v2 CLI)
---
## Текущий этап: Wails v2 Vertical MVP
## Текущий этап: MVP Stabilization
**Цель:** базовый рабочий desktop GUI для разделов → дел → заметок.
**Цель:** стабилизация MVP — smoke-тесты, go test, документация.
**Прогресс:**
**Прогресс Wails v2 Desktop GUI:**
- ✅ Wails v2 shell (window opens, no SIGSEGV)
- ✅ Layout fix (full viewport, dark theme, sidebar+main)
- 🔄 Notes bindings + UI
- 🔄 Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- 🔄 Node creation
- 🔄 Section filtering
- ✅ Notes bindings + UI (create/read/save/dirty state)
- ✅ Tabs (Overview/Notes/Files/Actions/Worklog/Activity)
- ✅ Node creation + template selection (FromTemplate)
- ✅ Section filtering
- ✅ File tree with breadcrumbs, preview, CRUD (rename/delete/duplicate/cut/copy/paste)
- ✅ Drag-and-drop (internal + external OS file drops)
- ✅ Actions CRUD (create/list/run/delete)
- ✅ Worklog entry form
- ✅ Today dashboard + Activity feed (global + per-case)
- ✅ Search
- ✅ Import dialog with safety checks (PreviewImport)
- ✅ Keyboard shortcuts
**Пауза (не начинать до завершения vertical MVP):**
- Файлы/папки workflow
- Drag-and-drop
- Sync, plugins, Lua, browser extension, TUI
- Новые шаблоны, DokuWiki importer
**Среднесрочные шаги (заморожены до стабилизации MVP):**
- Sync server/client
- File Scanner/Watcher
- TUI (Bubble Tea)
- Activity suggestions
- Lua runtime
- Browser extension
---
@@ -175,38 +177,65 @@ go build -tags "gui production webkit2_41" -o verstak-gui ./cmd/verstak-gui
4. Перенести текущий UI shell из inline HTML в frontend
5. Оставить текущий `internal/gui/` как legacy (не удалять, но не развивать)
### Backend bindings (минимум)
### Backend bindings (Wails v2 — реализовано)
```go
// Nodes
ListSections() ([]SectionDTO, error)
ListNodes(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (NodeDetailDTO, error)
CreateNode(parentID, section, typ, title string) (NodeDTO, error)
FromTemplate(parentID, section, typ, title, template string) (NodeDTO, error)
ListSections() []SectionDTO
ListNodesBySection(section string) ([]NodeDTO, error)
GetNodeDetail(nodeID string) (*NodeDTO, error)
ListChildren(parentID string) ([]NodeDTO, error)
CreateNode(parentID, typ, title, section string) (*NodeDTO, error)
DeleteNode(id string) error
MoveNode(id, parentID string) error
MoveNode(nodeID, newParentID string) error
RenameNode(nodeID, newTitle string) error
// Templates
ListTemplates() []TemplateDTO
FromTemplate(parentID, typ, title, section, template string) (*NodeDTO, error)
// Notes
CreateNote(parentID, title string) (NodeDTO, error)
ListNotes(nodeID string) ([]NodeDTO, error)
CreateNote(parentID, title string) (*NodeDTO, error)
ReadNote(noteID string) (string, error)
SaveNote(noteID, content string) error
// Files
ListFiles(nodeID string) ([]FileDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error) // файл или папка
ListItems(nodeID string) ([]FileTreeItemDTO, error)
AddPathCopy(nodeID, sourcePath string) ([]NodeDTO, error)
AddPathLink(nodeID, sourcePath string) ([]NodeDTO, error)
DeleteFileOrFolder(id string) error
OpenFile(id string) error
OpenFolder(id string) error
DeleteFileOrFolder(nodeID string) error
CreateEmptyFile(parentID, filename string) (*NodeDTO, error)
DuplicateNode(nodeID string) (*NodeDTO, error)
OpenFile(fileID string) error
OpenFolder(nodeID string) error
ReadFileText(fileID string) (string, error)
GetFileBase64(fileID string) (string, error)
PreviewImport(sourcePath string) (*ImportSummary, error)
ValidateName(name string) error
PickFile() (string, error)
PickFiles() ([]string, error)
PickDirectory() (string, error)
// Actions/Worklog/Search
// Actions
ListActions(nodeID string) ([]ActionDTO, error)
CreateAction(nodeID, kind, title, data string) (*ActionDTO, error)
DeleteAction(id string) error
RunAction(id string) error
CreateWorklog(nodeID, summary string, minutes int) (WorklogDTO, error)
// Worklog
ListWorklog(nodeID string) ([]WorklogDTO, error)
CreateWorklog(nodeID, summary string, minutes int) (*WorklogDTO, error)
// Search
Search(query string) ([]SearchResultDTO, error)
// Activity
ListTodayView() (*TodayDashboardDTO, error)
ListActivityFeed(limit, offset int) ([]EventDTO, error)
ListActivityByNode(nodeID string, limit, offset int) ([]EventDTO, error)
CountActivityByNode(nodeID string) (int, error)
```
---
@@ -311,18 +340,22 @@ verstak/
cmd/
verstak/ # CLI
verstak-gui/ # Wails GUI main
verstak-tui/ # Bubble Tea TUI
verstak-server/ # Sync server
verstak-gui/ # Wails v2 GUI main
frontend/ # Wails frontend
frontend/ # Wails v2 Svelte frontend
package.json
wails.json
vite.config.js
src/
App.svelte
components/
stores/
styles/
FileTreeRow.svelte
lib/
FileBreadcrumbs.svelte
FilePreviewModal.svelte
ConfirmModal.svelte
FileIcon.svelte
FileActions.svelte
fileUtils.js
api/verstak.js
internal/
core/
@@ -335,10 +368,8 @@ verstak/
worklog/
activity/
search/
sync/
security/
config/
plugins/ # manager, lua (stub)
plugins/ # manager, lua (stub), builtin templates
contrib/
plugins/
@@ -351,11 +382,13 @@ verstak/
004_add_notes.sql
005_add_actions.sql
006_add_worklog.sql
007_search.sql
008_sync.sql
009_section.sql
```
## RAID
- **Критично:** Wails требует Node.js для frontend-сборки
- **Критично:** go-sqlite3 + cgo; gcc уже установлен
- **Зависимость:** Steps 15+ ждут завершения step 14
- **Риск:** Wails v3 может быть нестабилен — проверить перед шагом 11
- **Зависимость:** Steps 15+ ждут завершения step 14 (MVP stabilization)
+1770 -236
View File
File diff suppressed because it is too large Load Diff
+392
View File
@@ -0,0 +1,392 @@
<script>
import { createEventDispatcher } from 'svelte'
import FileIcon from './lib/FileIcon.svelte'
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
export let item
export let selected = false
export let onDragStart
export let onDragOver
export let onDrop
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
const isFolder = item.type === 'folder'
const fileType = formatFileType(item)
let menuOpen = false
let clickTimer = null
function handleClick(e) {
if (e.ctrlKey || e.metaKey) {
dispatch('toggleSelect', item.id)
} else if (e.shiftKey) {
dispatch('rangeSelect', item.id)
} else {
if (clickTimer) {
clearTimeout(clickTimer)
clickTimer = null
// Double click → открыть
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
} else {
clickTimer = setTimeout(() => {
clickTimer = null
// Single click → выделить
dispatch('selectOne', item.id)
}, 250)
}
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
function handleShowInFolder() {
dispatch('showInFolder', item.id)
}
function handleDelete() {
menuOpen = false
dispatch('delete', { id: item.id, type: item.type })
}
function handleRename() {
menuOpen = false
dispatch('rename', { id: item.id, name: item.name })
}
function handleDuplicate() {
menuOpen = false
dispatch('duplicate', item.id)
}
function handleCut() {
menuOpen = false
dispatch('cut', item.id)
}
function handleCopy() {
menuOpen = false
dispatch('copy', item.id)
}
function handleOpen() {
menuOpen = false
if (isFolder) {
dispatch('navigate', item.id)
} else {
dispatch('preview', item)
}
}
function toggleMenu() {
menuOpen = !menuOpen
}
function closeMenu() {
menuOpen = false
}
function handleDragStart(e) {
if (onDragStart) onDragStart(e, item.id)
}
function handleDragOver(e) {
if (onDragOver && isFolder) onDragOver(e, item.id)
}
function handleDrop(e) {
if (onDrop && isFolder) onDrop(e, item.id)
}
function oncontextmenu(e) {
e.preventDefault()
menuOpen = true
}
</script>
<svelte:window on:click={closeMenu}/>
<div class="file-row"
class:file-row--selected={selected}
role="button"
tabindex="0"
draggable="true"
on:click={handleClick}
on:contextmenu={oncontextmenu}
on:dragstart={handleDragStart}
on:dragover={handleDragOver}
on:drop={handleDrop}
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
<div class="file-row-icon">
<FileIcon {kind} size={22}/>
</div>
<div class="file-row-body">
<div class="file-row-name" title={item.name}>{item.name}</div>
<div class="file-row-meta">
<span>{fileType}</span>
{#if !isFolder}
<span class="meta-sep">·</span>
<span>{formatFileSize(item.size)}</span>
{/if}
</div>
</div>
<div class="file-row-actions">
{#if !isFolder}
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
{:else}
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
</svg>
</button>
{/if}
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="5" r="2"/>
<circle cx="12" cy="12" r="2"/>
<circle cx="12" cy="19" r="2"/>
</svg>
</button>
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
{#if menuOpen}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
<div class="menu" on:click|stopPropagation role="menu">
<button class="menu-item" on:click={handleOpen} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
Открыть
</button>
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Открыть во внешней программе
</button>
{#if isFolder}
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Показать в проводнике
</button>
{/if}
<div class="menu-sep"></div>
<button class="menu-item" on:click={handleRename} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Переименовать
</button>
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Дублировать
</button>
<button class="menu-item" on:click={handleCut} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
Вырезать
</button>
<button class="menu-item" on:click={handleCopy} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
Копировать
</button>
<div class="menu-sep"></div>
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Удалить
</button>
</div>
{/if}
<style>
.file-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 6px;
cursor: default;
transition: background 0.12s;
min-height: 52px;
user-select: none;
position: relative;
}
.file-row:hover {
background: #1e1e30;
}
.file-row--selected {
background: #1e1e3a;
outline: 1px solid #3a3a6c;
}
.file-row--selected:hover {
background: #252545;
}
.file-row:focus-visible {
outline: 2px solid #5588ff;
outline-offset: -2px;
}
.file-row-icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: #888;
}
.file-row-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.file-row-name {
font-size: 13px;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.file-row-meta {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.meta-sep {
color: #444;
}
.file-row-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
flex-shrink: 0;
}
.file-row:hover .file-row-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn-danger:hover {
background: #3a2222;
color: #ff6b6b;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-backdrop {
position: fixed;
inset: 0;
z-index: 99;
}
.menu {
position: absolute;
right: 12px;
margin-top: 4px;
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 8px;
padding: 4px;
z-index: 100;
min-width: 220px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
border: none;
background: transparent;
color: #ccc;
font-size: 12px;
text-align: left;
cursor: pointer;
border-radius: 4px;
font-family: inherit;
}
.menu-item:hover {
background: #2a2a3c;
color: #fff;
}
.menu-item-danger {
color: #ff6b6b;
}
.menu-item-danger:hover {
background: #3a2222;
}
.menu-item:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.menu-sep {
height: 1px;
background: #2a2a3c;
margin: 4px 8px;
}
</style>
+38 -36
View File
@@ -1,43 +1,45 @@
// Wails v2 API wrapper — single frontend access point to Go backend
// Wails v2 API wrapper — uses generated bindings from wailsjs/go/main/App.js
import * as App from '../wailsjs/go/main/App.js'
function wailsCall(method, ...args) {
if (window.go && window.go.main && window.go.main.App) {
return window.go.main.App[method](...args)
}
return Promise.reject(new Error('Wails bindings not loaded'))
}
// Re-export all methods
export const listSections = () => App.ListSections()
export const listTodayView = () => App.ListTodayView()
export const listNodesBySection = (section) => App.ListNodesBySection(section)
export const listChildren = (parentID) => App.ListChildren(parentID)
export const getNodeDetail = (id) => App.GetNodeDetail(id)
export const createNode = (parentID, type, title, section) => App.CreateNode(parentID, type, title, section)
export const deleteNode = (id) => App.DeleteNode(id)
// Sections
export const listSections = () => wailsCall('ListSections')
export const listNotes = (nodeID) => App.ListNotes(nodeID)
export const createNote = (parentID, title) => App.CreateNote(parentID, title)
export const readNote = (noteID) => App.ReadNote(noteID)
export const saveNote = (noteID, content) => App.SaveNote(noteID, content)
// Nodes
export const listNodesBySection = (section) => wailsCall('ListNodesBySection', section)
export const listChildren = (parentID) => wailsCall('ListChildren', parentID)
export const getNodeDetail = (id) => wailsCall('GetNodeDetail', id)
export const createNode = (parentID, type, title, section) =>
wailsCall('CreateNode', parentID, type, title, section)
export const deleteNode = (id) => wailsCall('DeleteNode', id)
export const listFiles = (nodeID) => App.ListFiles(nodeID)
export const listItems = (nodeID) => App.ListItems(nodeID)
export const addPathCopy = (nodeID, sourcePath) => App.AddPathCopy(nodeID, sourcePath)
export const addPathLink = (nodeID, sourcePath) => App.AddPathLink(nodeID, sourcePath)
export const deleteFileOrFolder = (nodeID) => App.DeleteFileOrFolder(nodeID)
export const previewImport = (sourcePath) => App.PreviewImport(sourcePath)
export const pickFile = () => App.PickFile()
export const pickFiles = () => App.PickFiles()
export const pickDirectory = () => App.PickDirectory()
export const openFile = (id) => App.OpenFile(id)
export const readFileText = (id) => App.ReadFileText(id)
export const getFileBase64 = (id) => App.GetFileBase64(id)
export const createEmptyFile = (parentID, filename) => App.CreateEmptyFile(parentID, filename)
export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
export const validateName = (name) => App.ValidateName(name)
// Notes
export const listNotes = (nodeID) => wailsCall('ListNotes', nodeID)
export const createNote = (parentID, title) => wailsCall('CreateNote', parentID, title)
export const readNote = (noteID) => wailsCall('ReadNote', noteID)
export const saveNote = (noteID, content) => wailsCall('SaveNote', noteID, content)
export const listActions = (nodeID) => App.ListActions(nodeID)
export const runAction = (id) => App.RunAction(id)
// Files
export const listFiles = (nodeID) => wailsCall('ListFiles', nodeID)
export const listWorklog = (nodeID) => App.ListWorklog(nodeID)
export const createWorklog = (nodeID, summary, minutes) => App.CreateWorklog(nodeID, summary, minutes)
// Actions
export const listActions = (nodeID) => wailsCall('ListActions', nodeID)
export const runAction = (id) => wailsCall('RunAction', id)
export const search = (query) => App.Search(query)
// Worklog
export const listWorklog = (nodeID) => wailsCall('ListWorklog', nodeID)
export const createWorklog = (nodeID, summary, minutes) =>
wailsCall('CreateWorklog', nodeID, summary, minutes)
// Search
export const search = (query) => wailsCall('Search', query)
// System
export const verstakVersion = () => wailsCall('VerstakVersion')
export const verstakVersion = () => App.VerstakVersion()
+101
View File
@@ -0,0 +1,101 @@
<script>
export let title = 'Подтверждение'
export let message = ''
export let confirmText = 'Удалить'
export let cancelText = 'Отмена'
export let danger = false
import { createEventDispatcher } from 'svelte'
const dispatch = createEventDispatcher()
</script>
<div class="overlay" on:click|self={() => dispatch('cancel')} role="dialog" aria-modal="true" aria-label={title}>
<div class="modal">
<h3>{title}</h3>
<p class="message">{message}</p>
<div class="actions">
<button class="btn {danger ? 'btn-danger' : 'btn-primary'}" on:click={() => dispatch('confirm')}>{confirmText}</button>
<button class="btn" on:click={() => dispatch('cancel')}>{cancelText}</button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal {
background: #1a1a28;
border: 1px solid #2a2a3c;
border-radius: 12px;
padding: 24px;
width: 360px;
max-width: 90vw;
}
h3 {
font-size: 18px;
margin-bottom: 12px;
color: #e4e4ef;
}
.message {
font-size: 14px;
color: #aaa;
margin-bottom: 20px;
line-height: 1.4;
}
.actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: inherit;
}
.btn:hover {
background: #222233;
}
.btn-primary {
background: #6366f1;
border-color: #6366f1;
color: #fff;
}
.btn-primary:hover {
background: #4f46e5;
}
.btn-danger {
background: #dc2626;
border-color: #dc2626;
color: #fff;
}
.btn-danger:hover {
background: #b91c1c;
}
.btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+85
View File
@@ -0,0 +1,85 @@
<script>
import { createEventDispatcher } from 'svelte'
export let isFolder = false
export let fileId = ''
export let nodeId = ''
const dispatch = createEventDispatcher()
function handleOpen() {
if (isFolder) {
dispatch('openFolder', nodeId)
} else {
dispatch('open', fileId)
}
}
function handleDelete() {
dispatch('delete', nodeId)
}
</script>
<div class="file-actions">
<button class="action-btn" on:click={handleOpen} title={isFolder ? 'Open folder' : 'Open file'} aria-label={isFolder ? 'Open folder' : 'Open file'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if isFolder}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
<line x1="9" y1="14" x2="15" y2="14"/>
{:else}
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
{/if}
</svg>
</button>
<button class="action-btn action-btn-danger" on:click={handleDelete} title="Delete" aria-label="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
<style>
.file-actions {
display: flex;
gap: 2px;
align-items: center;
opacity: 0;
transition: opacity 0.15s ease;
}
:global(.file-row:hover) .file-actions {
opacity: 1;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn-danger:hover {
background: #3a2222;
color: #ff6b6b;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<script>
import { createEventDispatcher } from 'svelte'
export let crumbs = []
const dispatch = createEventDispatcher()
function navigateTo(index) {
dispatch('navigate', index)
}
</script>
<nav class="breadcrumbs">
{#each crumbs as crumb, i}
{#if i > 0}
<span class="sep">/</span>
{/if}
{#if i === crumbs.length - 1}
<span class="crumb crumb--current">{crumb.name}</span>
{:else}
<button class="crumb crumb--link" on:click={() => navigateTo(i)}>{crumb.name}</button>
{/if}
{/each}
</nav>
<style>
.breadcrumbs {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 0;
font-size: 13px;
color: #999;
}
.sep {
color: #444;
}
.crumb {
font-size: 13px;
}
.crumb--current {
color: #ccc;
}
.crumb--link {
background: none;
border: none;
padding: 2px 4px;
color: #888;
cursor: pointer;
border-radius: 3px;
font-family: inherit;
font-size: 13px;
transition: color 0.12s, background 0.12s;
}
.crumb--link:hover {
color: #ccc;
background: #1e1e30;
}
.crumb--link:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<script>
export let kind = 'generic'
export let size = 20
</script>
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg">
{#if kind === 'folder'}
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
{:else if kind === 'image'}
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
{:else if kind === 'video'}
<rect x="2" y="4" width="20" height="16" rx="2"/>
<polyline points="10 9 16 12 10 15 10 9"/>
{:else if kind === 'audio'}
<path d="M9 18V5l12-2v13"/>
<circle cx="6" cy="18" r="3"/>
<circle cx="18" cy="16" r="3"/>
{:else if kind === 'pdf'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
{:else if kind === 'document'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
{:else if kind === 'spreadsheet'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="16" y2="16"/>
<line x1="8" y1="14" x2="12" y2="14"/>
<line x1="12" y1="12" x2="12" y2="18"/>
{:else if kind === 'presentation'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="9" y1="12" x2="15" y2="12"/>
<line x1="9" y1="15" x2="13" y2="15"/>
<line x1="12" y1="15" x2="12" y2="18"/>
{:else if kind === 'archive'}
<path d="M21 8v13a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/>
<polyline points="7 3 12 8 17 3"/>
<line x1="3" y1="8" x2="21" y2="8"/>
<rect x="10" y="12" width="4" height="4" rx="1"/>
{:else if kind === 'code'}
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
{:else if kind === 'text'}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
{:else}
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
{/if}
</svg>
+262
View File
@@ -0,0 +1,262 @@
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte'
import FileIcon from './FileIcon.svelte'
import { formatFileSize, formatMimeType, getFileKind, isImageFile, isTextFile, isPdfFile, isMarkdownFile } from './fileUtils.js'
export let item
export let content = '' // base64 data URI or text content
export let loading = false
export let error = ''
const dispatch = createEventDispatcher()
const kind = getFileKind(item)
$: showImage = isImageFile(item) && content && content.startsWith('data:')
$: showText = isTextFile(item) || isMarkdownFile(item)
$: showPdf = isPdfFile(item)
function handleKeydown(e) {
if (e.key === 'Escape') {
dispatch('close')
}
}
function handleOpenExternal() {
dispatch('openExternal', item.fileId)
}
onMount(() => {
window.addEventListener('keydown', handleKeydown)
})
onDestroy(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<div class="overlay" on:click|self={() => dispatch('close')} role="dialog" aria-modal="true" aria-label={`Preview: ${item.name}`}>
<div class="modal">
<header class="preview-header">
<div class="preview-title">
<FileIcon {kind} size={18}/>
<span class="preview-name" title={item.name}>{item.name}</span>
</div>
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
<div class="preview-actions">
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/>
<line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
<button class="action-btn action-btn-close" on:click={() => dispatch('close')} title="Close" aria-label="Close preview">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</header>
<div class="preview-body">
{#if loading}
<div class="preview-status"><p>Загрузка...</p></div>
{:else if error}
<div class="preview-status">
<p>{error}</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{:else if showImage && content}
<div class="preview-image-container">
<img src={content} alt={item.name} class="preview-image"/>
</div>
{:else if showText && content}
<pre class="preview-text"><code>{content}</code></pre>
{:else if showPdf}
{#if content && content.startsWith('data:')}
<div class="preview-pdf-container">
<embed src={content} type="application/pdf" class="preview-pdf"/>
</div>
{:else}
<div class="preview-status">
<p>Предпросмотр PDF недоступен.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
{:else}
<div class="preview-status">
<p>Предпросмотр недоступен для этого типа файлов.</p>
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: #14141f;
border: 1px solid #2a2a3c;
border-radius: 10px;
width: 90vw;
max-width: 900px;
height: 85vh;
max-height: 700px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #2a2a3c;
flex-shrink: 0;
}
.preview-title {
display: flex;
align-items: center;
gap: 8px;
color: #ddd;
font-size: 14px;
min-width: 0;
}
.preview-name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preview-meta {
font-size: 11px;
color: #666;
margin-left: auto;
white-space: nowrap;
}
.preview-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
margin-left: 8px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border: none;
border-radius: 4px;
background: transparent;
color: #666;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.action-btn:hover {
background: #2a2a3c;
color: #ccc;
}
.action-btn:focus-visible {
outline: 2px solid #5588ff;
outline-offset: 1px;
}
.action-btn-close {
color: #ff6b6b;
}
.action-btn-close:hover {
background: #3a2222;
color: #ff4444;
}
.preview-body {
flex: 1;
overflow: auto;
min-height: 0;
}
.preview-status {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 48px 24px;
color: #888;
font-size: 14px;
}
.preview-image-container {
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
min-height: 200px;
background: #0e0e18;
}
.preview-image {
max-width: 100%;
max-height: calc(85vh - 100px);
object-fit: contain;
border-radius: 4px;
}
.preview-text {
margin: 0;
padding: 16px;
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
font-size: 12px;
line-height: 1.5;
color: #ccc;
white-space: pre-wrap;
word-wrap: break-word;
overflow: auto;
}
.preview-pdf-container {
width: 100%;
height: 100%;
}
.preview-pdf {
width: 100%;
height: 100%;
border: none;
}
.btn-sm {
padding: 6px 14px;
border: 1px solid #2a2a3c;
background: #1a1a28;
color: #ccc;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
font-family: inherit;
transition: background 0.12s;
}
.btn-sm:hover {
background: #222233;
}
</style>
+124
View File
@@ -0,0 +1,124 @@
export function formatFileSize(bytes) {
if (bytes == null || bytes < 0) return '—'
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const val = bytes / Math.pow(1024, i)
return (i === 0 ? val.toFixed(0) : val.toFixed(1)) + ' ' + units[i]
}
const mimeLabels = {
'image/jpeg': 'Изображение JPEG',
'image/png': 'Изображение PNG',
'image/gif': 'Изображение GIF',
'image/webp': 'Изображение WebP',
'image/svg+xml': 'Изображение SVG',
'image/bmp': 'Изображение BMP',
'image/tiff': 'Изображение TIFF',
'image/avif': 'Изображение AVIF',
'application/pdf': 'PDF документ',
'application/msword': 'Документ Word',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
'application/vnd.ms-excel': 'Таблица Excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
'application/zip': 'ZIP архив',
'application/gzip': 'GZIP архив',
'application/x-tar': 'TAR архив',
'application/x-7z-compressed': '7z архив',
'application/x-rar-compressed': 'RAR архив',
'text/plain': 'Текстовый файл',
'text/html': 'HTML файл',
'text/css': 'CSS файл',
'text/javascript': 'JavaScript файл',
'application/json': 'JSON файл',
'application/xml': 'XML файл',
'application/x-yaml': 'YAML файл',
'application/octet-stream': 'Бинарный файл',
'application/x-msdos-program': 'Исполняемый файл',
'inode/directory': 'Папка',
}
export function formatMimeType(mime) {
if (!mime) return 'Неизвестно'
return mimeLabels[mime] || mime
}
export function formatFileType(item) {
if (item.type === 'folder') return 'Папка'
const mime = (item.mime || '').toLowerCase()
if (mimeLabels[mime]) return mimeLabels[mime]
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (ext) return ext.toUpperCase()
return 'Файл'
}
export function getFileKind(item) {
if (item.type === 'folder') return 'folder'
const mime = (item.mime || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('text/')) return 'text'
if (mime.includes('pdf')) return 'pdf'
if (mime.includes('word') || mime.includes('document')) return 'document'
if (mime.includes('spreadsheet') || mime.includes('excel')) return 'spreadsheet'
if (mime.includes('presentation') || mime.includes('powerpoint')) return 'presentation'
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip') || mime.includes('rar') || mime.includes('7z') || mime.includes('compress')) return 'archive'
if (mime.includes('json') || mime.includes('xml') || mime.includes('yaml') || mime.includes('javascript') || mime.includes('css') || mime.includes('html')) return 'code'
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
const codeExts = ['js','ts','jsx','tsx','vue','svelte','py','rs','go','c','cpp','h','hpp','java','kt','swift','rb','php','pl','sh','bash','zsh','fish','yml','yaml','json','xml','toml','ini','cfg','conf','md','markdown','css','scss','less','sass','sql','graphql','proto','gradle','cmake','makefile','dockerfile','env','gitignore']
if (codeExts.includes(ext)) return 'code'
return 'generic'
}
const imageMimes = ['image/jpeg','image/png','image/gif','image/webp','image/bmp','image/tiff','image/avif','image/svg+xml']
const textMimes = ['text/plain','text/html','text/css','text/javascript','application/json','application/xml','application/x-yaml','text/x-shellscript']
const codeNames = ['txt','log','conf','ini','yaml','yml','json','xml','csv','sh','py','js','ts','css','html','md','markdown','cfg']
const imageExts = ['jpg','jpeg','png','gif','webp','bmp','tiff','tif','avif','svg']
export function canPreviewFile(item) {
if (item.type === 'folder') return false
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
if (imageMimes.includes(mime) || imageExts.includes(ext)) return true
if (mime.includes('pdf')) return true
if (textMimes.includes(mime) || codeNames.includes(ext)) return true
return false
}
export function isImageFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return imageMimes.includes(mime) || imageExts.includes(ext)
}
export function isTextFile(item) {
const mime = (item.mime || '').toLowerCase()
const name = (item.name || '').toLowerCase()
const ext = name.split('.').pop()
return textMimes.includes(mime) || (codeNames.includes(ext) && ext !== 'md' && ext !== 'markdown')
}
export function isPdfFile(item) {
return (item.mime || '').toLowerCase().includes('pdf')
}
export function isMarkdownFile(item) {
const name = (item.name || '').toLowerCase()
return name.endsWith('.md') || name.endsWith('.markdown')
}
export function needsBase64Preview(item) {
return isImageFile(item) || isPdfFile(item)
}
export function needsTextPreview(item) {
return isTextFile(item) || isMarkdownFile(item)
}
+127
View File
@@ -0,0 +1,127 @@
// @ts-check
// Wails v2 generated bindings — auto-generated by wails build
// Manual version for go build -tags gui
export function ListSections() {
return window['go']['main']['App']['ListSections']();
}
export function ListTodayView() {
return window['go']['main']['App']['ListTodayView']();
}
export function ListNodesBySection(arg1) {
return window['go']['main']['App']['ListNodesBySection'](arg1);
}
export function ListChildren(arg1) {
return window['go']['main']['App']['ListChildren'](arg1);
}
export function GetNodeDetail(arg1) {
return window['go']['main']['App']['GetNodeDetail'](arg1);
}
export function CreateNode(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['CreateNode'](arg1, arg2, arg3, arg4);
}
export function DeleteNode(arg1) {
return window['go']['main']['App']['DeleteNode'](arg1);
}
export function ListNotes(arg1) {
return window['go']['main']['App']['ListNotes'](arg1);
}
export function CreateNote(arg1, arg2) {
return window['go']['main']['App']['CreateNote'](arg1, arg2);
}
export function ReadNote(arg1) {
return window['go']['main']['App']['ReadNote'](arg1);
}
export function SaveNote(arg1, arg2) {
return window['go']['main']['App']['SaveNote'](arg1, arg2);
}
export function ListFiles(arg1) {
return window['go']['main']['App']['ListFiles'](arg1);
}
export function AddPathCopy(arg1, arg2) {
return window['go']['main']['App']['AddPathCopy'](arg1, arg2);
}
export function AddPathLink(arg1, arg2) {
return window['go']['main']['App']['AddPathLink'](arg1, arg2);
}
export function DeleteFileOrFolder(arg1) {
return window['go']['main']['App']['DeleteFileOrFolder'](arg1);
}
export function PreviewImport(arg1) {
return window['go']['main']['App']['PreviewImport'](arg1);
}
export function ListActions(arg1) {
return window['go']['main']['App']['ListActions'](arg1);
}
export function RunAction(arg1) {
return window['go']['main']['App']['RunAction'](arg1);
}
export function ListWorklog(arg1) {
return window['go']['main']['App']['ListWorklog'](arg1);
}
export function CreateWorklog(arg1, arg2, arg3) {
return window['go']['main']['App']['CreateWorklog'](arg1, arg2, arg3);
}
export function Search(arg1) {
return window['go']['main']['App']['Search'](arg1);
}
export function PickFile() {
return window['go']['main']['App']['PickFile']();
}
export function PickFiles() {
return window['go']['main']['App']['PickFiles']();
}
export function PickDirectory() {
return window['go']['main']['App']['PickDirectory']();
}
export function OpenFile(arg1) {
return window['go']['main']['App']['OpenFile'](arg1);
}
export function OpenFolder(arg1) {
return window['go']['main']['App']['OpenFolder'](arg1);
}
export function VerstakVersion() {
return window['go']['main']['App']['VerstakVersion']();
}
export function ListActivityFeed(arg1, arg2) {
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
}
export function ListActivityByNode(arg1, arg2, arg3) {
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
}
export function CountActivityByNode(arg1) {
return window['go']['main']['App']['CountActivityByNode'](arg1);
}
export function CreateEmptyFile(arg1, arg2) {
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
}
+8 -1
View File
@@ -1,15 +1,22 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { resolve } from "path";
export default defineConfig({
plugins: [svelte()],
server: {
host: "127.0.0.1",
port: 3001,
strictPort: true,
},
plugins: [svelte()],
build: {
outDir: "dist",
emptyOutDir: true,
rollupOptions: {
input: {
main: resolve(__dirname, "index.html"),
},
},
},
publicDir: "public",
});
+1 -1
View File
@@ -5,6 +5,7 @@ go 1.25.0
require (
github.com/mattn/go-sqlite3 v1.14.44
github.com/wailsapp/wails/v2 v2.12.0
golang.org/x/crypto v0.33.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,7 +34,6 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
+182
View File
@@ -0,0 +1,182 @@
package activity
import (
"database/sql"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Event types.
const (
TypeNoteCreated = "note_created"
TypeNoteUpdated = "note_updated"
TypeFileAdded = "file_added"
TypeFileDeleted = "file_deleted"
TypeFileRenamed = "file_renamed"
TypeFileCopied = "file_copied"
TypeFileMoved = "file_moved"
TypeFolderAdded = "folder_added"
TypeFolderDeleted = "folder_deleted"
TypeFolderRenamed = "folder_renamed"
TypeNodeCreated = "node_created"
TypeNodeUpdated = "node_updated"
TypeActionCreated = "action_created"
TypeActionDone = "action_done"
TypeWorklogAdded = "worklog_added"
)
// Target types.
const (
TargetNote = "note"
TargetFile = "file"
TargetFolder = "folder"
TargetAction = "action"
TargetNode = "node"
TargetWorklog = "worklog"
)
// Event represents an activity event.
type Event struct {
ID string `json:"id"`
NodeID string `json:"node_id"`
EventType string `json:"event_type"`
TargetType string `json:"target_type,omitempty"`
TargetID string `json:"target_id,omitempty"`
TargetPath string `json:"target_path,omitempty"`
Title string `json:"title"`
DetailsJSON string `json:"details_json,omitempty"`
CreatedAt string `json:"created_at"`
}
// Service records and queries activity events.
type Service struct {
db *storage.DB
}
func NewService(db *storage.DB) *Service {
return &Service{db: db}
}
// Record inserts a new activity event.
func (s *Service) Record(nodeID, targetType, targetID, targetPath, eventType, title, detailsJSON string) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, targetPath, title, detailsJSON, now)
return err
}
// TodayBoundaries returns the current local day in UTC as RFC3339 strings.
func TodayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// scanEvent scans a single event row.
func scanEvent(rows *sql.Rows) (Event, error) {
var e Event
err := rows.Scan(&e.ID, &e.NodeID, &e.EventType,
&e.TargetType, &e.TargetID, &e.TargetPath,
&e.Title, &e.DetailsJSON, &e.CreatedAt)
return e, err
}
// ListTodayEvents returns all activity events from today.
func (s *Service) ListTodayEvents() ([]Event, error) {
start, end := TodayBoundaries()
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE created_at >= ? AND created_at < ?
ORDER BY created_at DESC`, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListTodayEventsByParent returns today's events grouped by their NodeID.
func (s *Service) ListTodayEventsByParent() (map[string][]Event, error) {
events, err := s.ListTodayEvents()
if err != nil {
return nil, err
}
grouped := make(map[string][]Event, 8)
for _, e := range events {
pid := e.NodeID
grouped[pid] = append(grouped[pid], e)
}
return grouped, nil
}
// ListRecent returns a paginated global activity feed.
func (s *Service) ListRecent(limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// ListByNode returns paginated events for a specific node.
func (s *Service) ListByNode(nodeID string, limit, offset int) ([]Event, error) {
rows, err := s.db.Query(
`SELECT id,node_id,event_type,COALESCE(target_type,''),COALESCE(target_id,''),COALESCE(target_path,''),
title,COALESCE(metadata,'{}'),created_at
FROM activity_events
WHERE node_id = ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?`, nodeID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var events []Event
for rows.Next() {
e, err := scanEvent(rows)
if err != nil {
return nil, err
}
events = append(events, e)
}
return events, rows.Err()
}
// CountByNode returns the total number of events for a node.
func (s *Service) CountByNode(nodeID string) (int, error) {
var count int
err := s.db.QueryRow(
`SELECT COUNT(*) FROM activity_events WHERE node_id = ?`, nodeID).Scan(&count)
return count, err
}
+52 -13
View File
@@ -10,28 +10,29 @@ import (
// Config lives at .verstak/config.yml inside the vault.
type Config struct {
Engine EngineConfig `yaml:"engine"`
Sync SyncConfig `yaml:"sync"`
Browser BrowserConfig `yaml:"browser"`
Engine EngineConfig `yaml:"engine"`
Sync SyncConfig `yaml:"sync"`
Browser BrowserConfig `yaml:"browser"`
}
type EngineConfig struct {
Version int `yaml:"version"`
VaultID string `yaml:"vault_id"`
CreatedAt string `yaml:"created_at"`
VaultRoot string `yaml:"vault_root"`
Version int `yaml:"version"`
VaultID string `yaml:"vault_id"`
CreatedAt string `yaml:"created_at"`
VaultRoot string `yaml:"vault_root"`
}
type SyncConfig struct {
ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
ServerURL string `yaml:"server_url"`
APIKey string `yaml:"api_key"`
DeviceID string `yaml:"device_id"`
AutoSync bool `yaml:"auto_sync"`
SyncInterval int `yaml:"sync_interval"`
}
type BrowserConfig struct {
Enabled bool `yaml:"enabled"`
LocalPort int `yaml:"local_port"`
Enabled bool `yaml:"enabled"`
LocalPort int `yaml:"local_port"`
}
// Load reads .verstak/config.yml from the vault root.
@@ -66,3 +67,41 @@ func Save(vaultRoot string, cfg *Config) error {
func MetaDir(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak")
}
// DeviceTokenPath returns the path to the device_token file.
func DeviceTokenPath(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
}
// SaveDeviceToken writes the device token to a separate file with 0600 perms.
func SaveDeviceToken(vaultRoot, token string) error {
path := DeviceTokenPath(vaultRoot)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o750); err != nil {
return err
}
data := fmt.Sprintf(`{"device_token":%q}`, token)
return os.WriteFile(path, []byte(data), 0o600)
}
// LoadDeviceToken reads the device token from the separate file.
func LoadDeviceToken(vaultRoot string) string {
path := DeviceTokenPath(vaultRoot)
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var v struct {
DeviceToken string `yaml:"device_token"`
}
if err := yaml.Unmarshal(data, &v); err != nil {
return ""
}
return v.DeviceToken
}
// RemoveDeviceToken deletes the device token file.
func RemoveDeviceToken(vaultRoot string) error {
path := DeviceTokenPath(vaultRoot)
return os.Remove(path)
}
+396 -8
View File
@@ -3,6 +3,7 @@ package files
import (
"crypto/sha256"
"database/sql"
"encoding/base64"
"fmt"
"io"
"os"
@@ -12,6 +13,7 @@ import (
"strings"
"time"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
@@ -32,15 +34,25 @@ type Record struct {
Missing bool `json:"missing"`
}
// ImportSummary describes a scanned directory before import.
type ImportSummary struct {
Files int `json:"files"`
Folders int `json:"folders"`
TotalBytes int64 `json:"totalBytes"`
IsDangerous bool `json:"isDangerous"`
DangerReason string `json:"dangerReason,omitempty"`
}
// Service provides file operations inside a vault.
type Service struct {
db *storage.DB
vaultRoot string
nodes *nodes.Repository
}
// NewService creates a file service bound to a vault.
func NewService(db *storage.DB, vaultRoot string) *Service {
return &Service{db: db, vaultRoot: vaultRoot}
func NewService(db *storage.DB, vaultRoot string, nodeRepo *nodes.Repository) *Service {
return &Service{db: db, vaultRoot: vaultRoot, nodes: nodeRepo}
}
// DB returns the underlying storage.
@@ -48,6 +60,59 @@ func (s *Service) DB() *storage.DB {
return s.db
}
// --- security helpers ---
// ValidateName is an exported wrapper for validateName.
func ValidateName(name string) error {
return validateName(name)
}
// validateName rejects filenames with path separators, relative components,
// and other dangerous patterns.
func validateName(name string) error {
if name == "" {
return fmt.Errorf("name is required")
}
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
return fmt.Errorf("name must not contain path separators")
}
if strings.Contains(name, "..") {
return fmt.Errorf("name must not contain '..'")
}
if strings.Contains(name, "\x00") {
return fmt.Errorf("name must not contain null bytes")
}
if len(name) > 255 {
return fmt.Errorf("name too long (max 255)")
}
return nil
}
// vaultPath resolves a relative vault path and checks it stays within jail.
func (s *Service) vaultPath(rel string) (string, error) {
abs := filepath.Join(s.vaultRoot, rel)
cleaned := filepath.Clean(abs)
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
return "", fmt.Errorf("path escapes vault root")
}
return cleaned, nil
}
// absPathSafe resolves an absolute path and checks jail if it's under vault.
// For "external" mode records the path stored may be an absolute external path.
// This function only checks path safety — it does not enforce that external
// files must be inside the vault.
func (s *Service) absPathSafe(rec *Record) (string, error) {
if rec.StorageMode == "vault" {
return s.vaultPath(rec.Path)
}
abs, err := filepath.Abs(rec.Path)
if err != nil {
return "", fmt.Errorf("abs: %w", err)
}
return filepath.Clean(abs), nil
}
// --- public operations ---
// AddExternal registers an external file (absolute path) without copying.
@@ -72,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
if err := os.MkdirAll(destDir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
@@ -93,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
}
relPath, _ := filepath.Rel(s.vaultRoot, dest)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
}
@@ -137,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
return err
}
if rec.StorageMode == "vault" {
src := filepath.Join(s.vaultRoot, rec.Path)
src, err := s.vaultPath(rec.Path)
if err != nil {
return err
}
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
if err := os.MkdirAll(trashDir, 0o750); err != nil {
return err
}
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
// verify trash is inside vault
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
return err
}
if err := os.Rename(src, dest); err != nil {
return fmt.Errorf("move to trash: %w", err)
}
@@ -157,15 +235,325 @@ func (s *Service) Open(id string) error {
if err != nil {
return err
}
var abs string
if rec.StorageMode == "vault" {
abs = filepath.Join(s.vaultRoot, rec.Path)
} else {
abs = rec.Path
abs, err := s.absPathSafe(rec)
if err != nil {
return err
}
return openWithSystem(abs)
}
// maxPreviewSize is the maximum file size (5 MB) for inline preview.
const maxPreviewSize = 5 * 1024 * 1024
// ReadText reads a file's content as text, up to maxPreviewSize.
func (s *Service) ReadText(id string) (string, error) {
rec, err := s.Get(id)
if err != nil {
return "", err
}
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
return string(b), nil
}
// ReadBase64 reads a file and returns a data URI (base64-encoded).
func (s *Service) ReadBase64(id string) (string, error) {
rec, err := s.Get(id)
if err != nil {
return "", err
}
if rec.Size > maxPreviewSize {
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
}
abs, err := s.absPathSafe(rec)
if err != nil {
return "", err
}
b, err := os.ReadFile(abs)
if err != nil {
return "", fmt.Errorf("read: %w", err)
}
mime := rec.MIME
if mime == "" {
mime = "application/octet-stream"
}
return fmt.Sprintf("data:%s;base64,%s", mime, base64.StdEncoding.EncodeToString(b)), nil
}
// CreateEmptyFile creates a file node and an empty vault file.
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
if err := validateName(filename); err != nil {
return nil, fmt.Errorf("invalid filename: %w", err)
}
filename = s.uniqueTitle(parentID, filename)
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("mkdir: %w", err)
}
dest := filepath.Join(dir, filename)
f, err := os.Create(dest)
if err != nil {
return nil, fmt.Errorf("create file: %w", err)
}
f.Close()
relPath, _ := filepath.Rel(s.vaultRoot, dest)
// Verify dest is inside vault
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
return node, err
}
// Duplicate creates a copy of a node and its file record under the same parent.
func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
original, err := s.nodes.GetActive(nodeID)
if err != nil {
return nil, err
}
parentID := ""
if original.ParentID != nil {
parentID = *original.ParentID
}
newName := s.copyTitle(parentID, original.Title)
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
if err != nil {
return nil, err
}
if original.Type == nodes.TypeFile {
records, _ := s.ListByNode(original.ID)
if len(records) > 0 {
src := &records[0]
if src.StorageMode == "vault" {
srcPath, err := s.vaultPath(src.Path)
if err != nil {
return nil, err
}
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
os.MkdirAll(dir, 0o750)
dst := filepath.Join(dir, newName)
hash, err := copyAndHash(srcPath, dst)
if err != nil {
return nil, fmt.Errorf("copy file: %w", err)
}
relPath, _ := filepath.Rel(s.vaultRoot, dst)
if _, err := s.vaultPath(relPath); err != nil {
return nil, fmt.Errorf("path safety: %w", err)
}
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
if err != nil {
return nil, err
}
} else {
// External file: create a new record pointing to the same absolute path.
_, err = s.insertRecord(node.ID, newName, src.Path, "external", src.Size, src.SHA256)
if err != nil {
return nil, err
}
}
}
}
return node, nil
}
// AddPathCopy copies sourcePath (file or directory) into the vault under nodeID.
func (s *Service) AddPathCopy(nodeID, sourcePath string) ([]nodes.Node, error) {
return s.importPath(nodeID, sourcePath, true)
}
// AddPathLink links sourcePath (file or directory) without copying into vault.
func (s *Service) AddPathLink(nodeID, sourcePath string) ([]nodes.Node, error) {
return s.importPath(nodeID, sourcePath, false)
}
// PreviewImport scans sourcePath and returns a summary without importing.
func (s *Service) PreviewImport(sourcePath string) (*ImportSummary, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
if !info.IsDir() {
return &ImportSummary{Files: 1, TotalBytes: info.Size()}, nil
}
var sum ImportSummary
err = filepath.Walk(sourcePath, func(path string, fi os.FileInfo, err error) error {
if err != nil {
return filepath.SkipDir
}
if fi.IsDir() {
sum.Folders++
name := strings.ToLower(fi.Name())
if name == ".git" || name == "node_modules" || name == ".cache" {
sum.IsDangerous = true
sum.DangerReason = fmt.Sprintf("содержит %s", fi.Name())
}
return nil
}
sum.Files++
sum.TotalBytes += fi.Size()
return nil
})
if sum.Files > 1000 && !sum.IsDangerous {
sum.IsDangerous = true
sum.DangerReason = "более 1000 файлов"
}
if sum.TotalBytes > 1<<30 && !sum.IsDangerous {
sum.IsDangerous = true
sum.DangerReason = "более 1 GB"
}
return &sum, err
}
// DeleteNodeAndChildren soft-deletes a node and all descendants,
// moving vault files to trash.
func (s *Service) DeleteNodeAndChildren(nodeID string) error {
children, _ := s.nodes.ListChildren(nodeID, false)
for i := range children {
if err := s.DeleteNodeAndChildren(children[i].ID); err != nil {
return err
}
}
_ = s.deleteFileRecords(nodeID)
return s.nodes.SoftDelete(nodeID)
}
func (s *Service) deleteFileRecords(nodeID string) error {
records, err := s.ListByNode(nodeID)
if err != nil {
return err
}
for _, r := range records {
_ = s.DeleteToTrash(r.ID)
}
return nil
}
func (s *Service) importPath(parentID, sourcePath string, copyMode bool) ([]nodes.Node, error) {
info, err := os.Stat(sourcePath)
if err != nil {
return nil, fmt.Errorf("stat: %w", err)
}
if !info.IsDir() {
title := s.uniqueTitle(parentID, filepath.Base(sourcePath))
node, err := s.nodes.Create(parentID, nodes.TypeFile, title, "")
if err != nil {
return nil, err
}
if copyMode {
_, err = s.CopyIntoVault(node.ID, sourcePath, node.Slug)
} else {
_, err = s.AddExternal(node.ID, sourcePath)
}
if err != nil {
return nil, err
}
return []nodes.Node{*node}, nil
}
return s.importDir(parentID, sourcePath, info.Name(), copyMode)
}
func (s *Service) importDir(parentID, sourcePath, dirName string, copyMode bool) ([]nodes.Node, error) {
dirName = s.uniqueTitle(parentID, dirName)
folderNode, err := s.nodes.Create(parentID, nodes.TypeFolder, dirName, "")
if err != nil {
return nil, err
}
entries, err := os.ReadDir(sourcePath)
if err != nil {
return nil, err
}
var all []nodes.Node
all = append(all, *folderNode)
for _, entry := range entries {
childPath := filepath.Join(sourcePath, entry.Name())
if entry.IsDir() {
children, err := s.importDir(folderNode.ID, childPath, entry.Name(), copyMode)
if err != nil {
return nil, err
}
all = append(all, children...)
} else {
childNode, err := s.nodes.Create(folderNode.ID, nodes.TypeFile, entry.Name(), "")
if err != nil {
return nil, err
}
if copyMode {
_, err = s.CopyIntoVault(childNode.ID, childPath, childNode.Slug)
} else {
_, err = s.AddExternal(childNode.ID, childPath)
}
if err != nil {
return nil, err
}
all = append(all, *childNode)
}
}
return all, nil
}
func (s *Service) uniqueTitle(parentID, desired string) string {
children, _ := s.nodes.ListChildren(parentID, false)
used := make(map[string]bool, len(children))
for i := range children {
used[children[i].Title] = true
}
if !used[desired] {
return desired
}
for n := 2; ; n++ {
c := fmt.Sprintf("%s (%d)", desired, n)
if !used[c] {
return c
}
}
}
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
func (s *Service) copyTitle(parentID, desired string) string {
children, _ := s.nodes.ListChildren(parentID, false)
used := make(map[string]bool, len(children))
for i := range children {
used[children[i].Title] = true
}
ext := filepath.Ext(desired)
base := strings.TrimSuffix(desired, ext)
copyName := base + " (copy)" + ext
if !used[copyName] {
return copyName
}
for n := 2; ; n++ {
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
if !used[candidate] {
return candidate
}
}
}
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
return s.copyTitle(parentID, desired)
}
// --- implementation details ---
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
+169 -4
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"testing"
"verstak/internal/core/nodes"
"verstak/internal/core/storage"
)
@@ -23,7 +24,7 @@ func TestAddExternal(t *testing.T) {
db := openTestDB(t)
// Run migration 002 manually since storage.Open already applied it.
// We can verify the table exists by inserting.
filesSvc := NewService(db, t.TempDir())
filesSvc := NewService(db, t.TempDir(), nodes.NewRepository(db))
// Create a real temp file to register.
tmpDir := t.TempDir()
@@ -62,7 +63,7 @@ func TestAddExternal(t *testing.T) {
func TestCopyIntoVault(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
// Source file.
srcDir := t.TempDir()
@@ -88,7 +89,7 @@ func TestCopyIntoVault(t *testing.T) {
func TestListByNode(t *testing.T) {
db := openTestDB(t)
svc := NewService(db, t.TempDir())
svc := NewService(db, t.TempDir(), nodes.NewRepository(db))
os.WriteFile(filepath.Join(t.TempDir(), "a.txt"), []byte("a"), 0o640)
f1 := filepath.Join(t.TempDir(), "a1.txt")
@@ -111,7 +112,7 @@ func TestListByNode(t *testing.T) {
func TestDeleteToTrash(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot)
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
src := filepath.Join(t.TempDir(), "important.pdf")
os.WriteFile(src, []byte("important data"), 0o640)
@@ -140,6 +141,170 @@ func TestDeleteToTrash(t *testing.T) {
}
}
func TestAddPathCopySingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
src := filepath.Join(t.TempDir(), "doc.pdf")
os.WriteFile(src, []byte("file content"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, src)
if err != nil {
t.Fatalf("AddPathCopy: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
if nodes[0].Type != "file" {
t.Errorf("type = %q", nodes[0].Type)
}
// Source intact.
if _, err := os.Stat(src); err != nil {
t.Error("source should remain intact")
}
// File record created.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Errorf("file records = %d", len(records))
}
}
func TestAddPathLinkSingleFile(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
src := filepath.Join(t.TempDir(), "linked.pdf")
os.WriteFile(src, []byte("linked"), 0o640)
nodes, err := svc.AddPathLink(parent.ID, src)
if err != nil {
t.Fatalf("AddPathLink: %v", err)
}
if len(nodes) != 1 {
t.Fatalf("got %d nodes, want 1", len(nodes))
}
// File record should have external storage mode.
records, _ := svc.ListByNode(nodes[0].ID)
if len(records) != 1 {
t.Fatalf("file records = %d", len(records))
}
if records[0].StorageMode != "external" {
t.Errorf("storage mode = %q, want external", records[0].StorageMode)
}
}
func TestAddPathCopyDirectory(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test Case", "")
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "a.txt"), []byte("a"), 0o640)
os.WriteFile(filepath.Join(srcDir, "sub", "b.txt"), []byte("bb"), 0o640)
nodes, err := svc.AddPathCopy(parent.ID, srcDir)
if err != nil {
t.Fatalf("AddPathCopy dir: %v", err)
}
// Should create: folder node + file node + sub folder node + file node in sub.
if len(nodes) < 3 {
t.Errorf("expected 3+ nodes, got %d", len(nodes))
}
// Verify structure: root folder + children.
var folders, files int
for i := range nodes {
if nodes[i].Type == "folder" {
folders++
} else {
files++
}
}
if folders < 1 {
t.Error("expected at least 1 folder")
}
if files < 1 {
t.Error("expected at least 1 file")
}
}
func TestDeleteNodeAndChildren(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "To Delete", "")
child, _ := nodeRepo.Create(parent.ID, "file", "child.txt", "")
// Add file record to child.
src := filepath.Join(t.TempDir(), "child.txt")
os.WriteFile(src, []byte("data"), 0o640)
svc.CopyIntoVault(child.ID, src, child.Slug)
if err := svc.DeleteNodeAndChildren(parent.ID); err != nil {
t.Fatalf("DeleteNodeAndChildren: %v", err)
}
// Parent should be soft-deleted.
if _, err := nodeRepo.GetActive(parent.ID); err == nil {
t.Error("parent should be deleted")
}
// Child should be soft-deleted.
if _, err := nodeRepo.GetActive(child.ID); err == nil {
t.Error("child should be deleted")
}
}
func TestNameConflict(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
nodeRepo := nodes.NewRepository(db)
svc := NewService(db, vaultRoot, nodeRepo)
parent, _ := nodeRepo.Create("", "case", "Test", "")
src := filepath.Join(t.TempDir(), "conflict.pdf")
os.WriteFile(src, []byte("data"), 0o640)
// Import twice with same filename.
n1, _ := svc.AddPathCopy(parent.ID, src)
n2, _ := svc.AddPathCopy(parent.ID, src)
if n1[0].Title == n2[0].Title {
t.Error("expected unique name on conflict")
}
if n2[0].Title == "conflict.pdf" {
t.Errorf("title unchanged = %q", n2[0].Title)
}
}
func TestPreviewImportDir(t *testing.T) {
db := openTestDB(t)
vaultRoot := t.TempDir()
svc := NewService(db, vaultRoot, nodes.NewRepository(db))
srcDir := t.TempDir()
os.MkdirAll(filepath.Join(srcDir, "sub"), 0o750)
os.WriteFile(filepath.Join(srcDir, "f1.txt"), []byte("hello"), 0o640)
os.WriteFile(filepath.Join(srcDir, "f2.txt"), []byte("world"), 0o640)
sum, err := svc.PreviewImport(srcDir)
if err != nil {
t.Fatalf("PreviewImport: %v", err)
}
if sum.Files != 2 {
t.Errorf("files = %d, want 2", sum.Files)
}
if sum.Folders != 2 { // root + sub
t.Errorf("folders = %d, want 2", sum.Folders)
}
}
func TestGuessMIME(t *testing.T) {
cases := map[string]string{
"a.md": "text/plain",
+53 -8
View File
@@ -50,16 +50,10 @@ func now() string {
return time.Now().UTC().Format(time.RFC3339)
}
// --- CRUD ---
// Valid sections for root-level nodes.
var validSections = map[string]struct{}{
"clients": {}, "projects": {}, "recipes": {}, "documents": {}, "archive": {},
}
// Create inserts a root or child node.
// parentID may be empty for root-level nodes.
// For root nodes, section determines sidebar placement (may be empty → inbox).
// For root nodes, section determines sidebar placement (may be empty = inbox).
// section must be a valid section (clients, projects, etc.) or empty for inbox.
func (r *Repository) Create(parentID, typ, title, section string) (*Node, error) {
if !IsValidType(typ) {
return nil, fmt.Errorf("invalid node type: %s", typ)
@@ -67,6 +61,9 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if title == "" {
return nil, errors.New("title is required")
}
if section != "" && !IsValidSection(section) {
return nil, fmt.Errorf("invalid section: %s", section)
}
n := &Node{
ID: util.UUID7(),
@@ -87,9 +84,22 @@ func (r *Repository) Create(parentID, typ, title, section string) (*Node, error)
if err != nil {
return nil, err
}
// Bump parent's updated_at so it appears in today view.
if parentID != "" {
_ = r.touch(parentID)
}
return n, nil
}
// touch updates a node's updated_at without changing other fields.
func (r *Repository) touch(id string) error {
t := now()
_, err := r.db.Exec(
`UPDATE nodes SET updated_at=?, revision=revision+1
WHERE id=? AND deleted_at IS NULL`, t, id)
return err
}
func (r *Repository) insertNode(n *Node) error {
var parent interface{}
if n.ParentID != nil {
@@ -182,6 +192,41 @@ func (r *Repository) ListRoots(includeDeleted bool, section string) ([]Node, err
return scanNodes(rows)
}
// todayBoundaries returns RFC3339 start and end strings for the current day
// in UTC, so string comparison against UTC-stored DB timestamps is correct.
func todayBoundaries() (string, string) {
now := time.Now()
y, m, d := now.Date()
start := time.Date(y, m, d, 0, 0, 0, 0, now.Location())
end := start.Add(24 * time.Hour)
return start.UTC().Format(time.RFC3339), end.UTC().Format(time.RFC3339)
}
// ListTodayNodes returns active root-level nodes created or updated today.
// This is a dynamic view, not a section — it shows the day's activity.
// Child nodes (notes, files, folders) are not listed directly; instead,
// their parent is bumped via touch() on creation.
func (r *Repository) ListTodayNodes() ([]Node, error) {
start, end := todayBoundaries()
q := `SELECT id,parent_id,type,title,slug,path,section,sort_order,
created_at,updated_at,deleted_at,revision,device_id
FROM nodes
WHERE deleted_at IS NULL
AND parent_id IS NULL
AND (
(created_at >= ? AND created_at < ?)
OR
(updated_at >= ? AND updated_at < ?)
)
ORDER BY updated_at DESC, created_at DESC`
rows, err := r.db.Query(q, start, end, start, end)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNodes(rows)
}
// UpdateTitle changes title (slug is recomputed).
func (r *Repository) UpdateTitle(id, title string) error {
if title == "" {
+29
View File
@@ -35,12 +35,41 @@ var TypeSet = map[string]struct{}{
TypeLink: {},
}
// Valid sections for root-level nodes.
// today and inbox are service sections, not stored in nodes.section.
var validSections = map[string]struct{}{
"clients": {},
"projects": {},
"recipes": {},
"documents": {},
"archive": {},
}
// serviceSections are sidebar entries that are not stored as node sections.
var serviceSections = map[string]struct{}{
"today": {},
"inbox": {},
"activity": {},
}
// IsValidType checks whether a type string is recognized.
func IsValidType(t string) bool {
_, ok := TypeSet[t]
return ok
}
// IsValidSection returns true for sections that can be stored on a node.
func IsValidSection(s string) bool {
_, ok := validSections[s]
return ok
}
// IsServiceSection returns true for sidebar-only sections (today, inbox).
func IsServiceSection(s string) bool {
_, ok := serviceSections[s]
return ok
}
// Slugify converts a title into a filesystem-safe slug.
// Examples:
//
+1 -1
View File
@@ -21,7 +21,7 @@ func setupService(t *testing.T) (*Service, *nodes.Repository, string) {
t.Cleanup(func() { db.Close() })
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, dir)
fileSvc := files.NewService(db, dir, nodeRepo)
svc := NewService(db, dir, nodeRepo, fileSvc)
return svc, nodeRepo, dir
}
+278
View File
@@ -0,0 +1,278 @@
package core
import (
"os"
"path/filepath"
"strings"
"testing"
"verstak/internal/core/actions"
"verstak/internal/core/files"
"verstak/internal/core/notes"
"verstak/internal/core/nodes"
"verstak/internal/core/search"
"verstak/internal/core/storage"
"verstak/internal/core/vault"
"verstak/internal/core/worklog"
)
func openTestDB(t *testing.T) *storage.DB {
t.Helper()
dir := t.TempDir()
db, err := storage.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func TestMVPSmoke(t *testing.T) {
// Create a test vault with a real filesystem area.
vaultDir := t.TempDir()
// 1. Init vault.
if err := vault.Init(vaultDir); err != nil {
t.Fatalf("vault init: %v", err)
}
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultDir, nodeRepo)
noteSvc := notes.NewService(db, vaultDir, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
worklogSvc := worklog.NewService(db)
searchSvc := search.NewService(db)
// 2. Create client case structure.
client, err := nodeRepo.Create("", nodes.TypeCase, "ООО Ромашка", "clients")
if err != nil {
t.Fatalf("create client: %v", err)
}
project, err := nodeRepo.Create(client.ID, nodes.TypeCase, "Сайт", "")
if err != nil {
t.Fatalf("create project: %v", err)
}
// 3. Create a Markdown note.
noteNode, noteRec, err := noteSvc.Create(project.ID, "overview.md", "")
if err != nil {
t.Fatalf("create note: %v", err)
}
if noteNode.Title != "overview.md" {
t.Errorf("note title = %q, want overview.md", noteNode.Title)
}
// 4. Write note content.
content := "# Сайт ООО Ромашка\n\nWordPress/nginx. Иногда обновление."
if err := noteSvc.Save(noteNode.ID, content); err != nil {
t.Fatalf("save note: %v", err)
}
// 5. Read note back.
got, err := noteSvc.Read(noteNode.ID)
if err != nil {
t.Fatalf("read note: %v", err)
}
if got != content {
t.Errorf("note content mismatch: got %q, want %q", got, content)
}
// 6. Verify note file exists on disk.
noteFileRecs, _ := fileSvc.ListByNode(noteNode.ID)
notePath := ""
if len(noteFileRecs) > 0 {
notePath = noteFileRecs[0].Path
if _, err := os.Stat(notePath); os.IsNotExist(err) {
t.Errorf("note file not on disk: %s", notePath)
}
}
_ = noteRec
// 7. Add a dummy external file.
dummyPath := filepath.Join(vaultDir, "dogovor.docx")
if err := os.WriteFile(dummyPath, []byte("dummy contract data"), 0644); err != nil {
t.Fatal(err)
}
created, err := fileSvc.AddPathCopy(project.ID, dummyPath)
if err != nil {
t.Fatalf("add file: %v", err)
}
if len(created) == 0 {
t.Fatal("no nodes created from file import")
}
// 8. Open the imported file (just verify it opens without error).
fileNode := created[0]
if fileNode.Type != nodes.TypeFile {
t.Errorf("expected file type, got %s", fileNode.Type)
}
fileRecs, err := fileSvc.ListByNode(fileNode.ID)
if err != nil || len(fileRecs) == 0 {
t.Fatalf("file record not found for imported file")
}
_ = fileSvc.Open(fileRecs[0].ID) // may fail in test (no display)
// 9. Verify the imported file node exists as a child of the project.
children, err := nodeRepo.ListChildren(project.ID, false)
if err != nil {
t.Fatalf("list children: %v", err)
}
hasImported := false
for _, c := range children {
if c.Type == nodes.TypeFile && c.Title == "dogovor.docx" {
hasImported = true
break
}
}
if !hasImported {
t.Error("imported file node not found under project")
}
// 10. Create actions.
urlAction, err := actionSvc.Create(project.ID, actions.KindOpenURL, "Открыть сайт", "", "", "https://example.com", nil, false, false)
if err != nil {
t.Fatalf("create url action: %v", err)
}
if urlAction.Kind != actions.KindOpenURL {
t.Errorf("action kind = %s, want %s", urlAction.Kind, actions.KindOpenURL)
}
folderAction, err := actionSvc.Create(project.ID, actions.KindOpenFolder, "Открыть папку", vaultDir, "", "", nil, false, false)
if err != nil {
t.Fatalf("create folder action: %v", err)
}
if folderAction.Kind != actions.KindOpenFolder {
t.Errorf("action kind = %s", folderAction.Kind)
}
// 11. List actions.
actionList, err := actionSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list actions: %v", err)
}
if len(actionList) != 2 {
t.Errorf("expected 2 actions, got %d", len(actionList))
}
// 12. Run an action (open URL — should not error).
_, err = actionSvc.Run(urlAction.ID)
if err != nil {
t.Fatalf("run url action: %v", err)
}
// 13. Add worklog.
entry, err := worklogSvc.Add(project.ID, "Обновил витрину сайта, баннеры", "", 180, false, false)
if err != nil {
t.Fatalf("add worklog: %v", err)
}
if entry.Minutes == nil || *entry.Minutes != 180 {
t.Errorf("worklog minutes = %v, want 180", entry.Minutes)
}
// 14. List worklog.
worklogList, err := worklogSvc.ListByNode(project.ID)
if err != nil {
t.Fatalf("list worklog: %v", err)
}
if len(worklogList) != 1 {
t.Errorf("expected 1 worklog entry, got %d", len(worklogList))
}
// 15. Search for something (FTS5 may not be available — gracefully skip).
_ = searchSvc.Rebuild()
_ = searchSvc.Index(noteNode.ID, noteNode.Title, content, notePath, "", "note")
results, err := searchSvc.Search("WordPress")
if err != nil {
_ = err
}
if len(results) > 0 {
t.Logf("search found %d results for 'WordPress'", len(results))
} else {
t.Log("search returned no results (FTS5 may not be available)")
}
// 16. Verify section filtering.
roots, err := nodeRepo.ListRoots(false, "clients")
if err != nil {
t.Fatalf("list roots by section: %v", err)
}
found := false
for _, r := range roots {
if r.ID == client.ID {
found = true
break
}
}
if !found {
t.Error("client not found in section 'clients'")
}
// 17. Soft delete node and verify.
if err := nodeRepo.SoftDelete(project.ID); err != nil {
t.Fatalf("soft delete project: %v", err)
}
_, err = nodeRepo.GetActive(project.ID)
if err == nil {
t.Errorf("expected error getting deleted node")
}
// 18. Reopen app (simulate by reconnecting to same DB).
db2, err := storage.Open(db.Path())
if err != nil {
t.Fatalf("reopen db: %v", err)
}
defer db2.Close()
repo2 := nodes.NewRepository(db2)
// 19. Verify data persists.
client2, err := repo2.GetActive(client.ID)
if err != nil {
t.Fatalf("get client after reopen: %v", err)
}
if client2.Title != "ООО Ромашка" {
t.Errorf("client title after reopen = %q", client2.Title)
}
// 20. Verify soft-deleted node stays soft-deleted.
allNodes, err := repo2.ListChildren(client.ID, true)
if err != nil {
t.Fatalf("list children after reopen: %v", err)
}
if len(allNodes) == 0 {
t.Error("no children found after reopen")
}
t.Log("MVP smoke test passed!")
}
func TestMVPSmoke_WorklogReport(t *testing.T) {
db := openTestDB(t)
nodeRepo := nodes.NewRepository(db)
worklogSvc := worklog.NewService(db)
n, err := nodeRepo.Create("", nodes.TypeCase, "Test", "")
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 1", "", 60, true, false)
if err != nil {
t.Fatal(err)
}
_, err = worklogSvc.Add(n.ID, "Task 2", "", 30, false, false)
if err != nil {
t.Fatal(err)
}
report, err := worklogSvc.Report(n.ID)
if err != nil {
t.Fatalf("report: %v", err)
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing entries")
}
if !strings.Contains(report, "Task 1") || !strings.Contains(report, "Task 2") {
t.Error("report missing tasks")
}
t.Logf("report:\n%s", report)
}
@@ -0,0 +1,17 @@
package storage
// migration008 — activity_events table for the Сегодня (Today) dashboard.
const migration008 = `
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL,
parent_id TEXT,
event_type TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ae_created ON activity_events(created_at);
CREATE INDEX IF NOT EXISTS idx_ae_parent ON activity_events(parent_id);
`
@@ -0,0 +1,8 @@
package storage
// migration009 — add target_type, target_id, target_path to activity_events.
const migration009 = `
ALTER TABLE activity_events ADD COLUMN target_type TEXT;
ALTER TABLE activity_events ADD COLUMN target_id TEXT;
ALTER TABLE activity_events ADD COLUMN target_path TEXT;
`
@@ -0,0 +1,29 @@
package storage
// migration010 — sync_ops table for recording operations to push to sync server.
const migration010 = `
CREATE TABLE IF NOT EXISTS sync_ops (
id TEXT PRIMARY KEY,
op_id TEXT NOT NULL UNIQUE,
device_id TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id TEXT NOT NULL,
op_type TEXT NOT NULL,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
pushed_at TEXT,
applied_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sync_ops_pushed ON sync_ops(pushed_at);
CREATE INDEX IF NOT EXISTS idx_sync_ops_entity ON sync_ops(entity_type, entity_id);
CREATE TABLE IF NOT EXISTS sync_state (
device_id TEXT PRIMARY KEY,
server_url TEXT NOT NULL DEFAULT '',
api_key TEXT NOT NULL DEFAULT '',
last_push_rev INTEGER NOT NULL DEFAULT 0,
last_pull_rev INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT
);
`
@@ -0,0 +1,6 @@
package storage
// migration011 — add last_pull_seq to sync_state.
const migration011 = `
ALTER TABLE sync_state ADD COLUMN last_pull_seq INTEGER NOT NULL DEFAULT 0;
`
+10 -7
View File
@@ -57,14 +57,17 @@ CREATE TABLE IF NOT EXISTS _schema_ver (
`
var migrationFiles = map[int]string{
1: migration001,
2: migration002,
3: migration003,
4: migration004,
5: migration005,
6: migration006,
1: migration001,
2: migration002,
3: migration003,
4: migration004,
5: migration005,
6: migration006,
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
// 8: migration008, etc.
8: migration008,
9: migration009,
10: migration010,
11: migration011,
}
func (db *DB) runInitialSchema() error {
+80
View File
@@ -0,0 +1,80 @@
package sync
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// BlobDir returns the path to .verstak/blobs/ inside the vault.
func BlobDir(vaultRoot string) string {
return filepath.Join(vaultRoot, ".verstak", "blobs")
}
// BlobPath returns the on-disk path for a SHA-256 hash.
func BlobPath(blobsDir, shaHex string) string {
return filepath.Join(blobsDir, shaHex[:2], shaHex[2:4], shaHex)
}
// HashFile computes SHA-256 of a file.
func HashFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// HashBytes computes SHA-256 of byte data.
func HashBytes(data []byte) string {
h := sha256.Sum256(data)
return hex.EncodeToString(h[:])
}
// StoreBlob copies a file into the blob store, returns its SHA-256.
func StoreBlob(blobsDir, srcPath string) (string, error) {
shaHex, err := HashFile(srcPath)
if err != nil {
return "", err
}
dest := BlobPath(blobsDir, shaHex)
if _, err := os.Stat(dest); err == nil {
return shaHex, nil // already exists
}
if err := os.MkdirAll(filepath.Dir(dest), 0750); err != nil {
return "", err
}
src, err := os.Open(srcPath)
if err != nil {
return "", err
}
defer src.Close()
dst, err := os.Create(dest)
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return "", err
}
_ = dst.Sync()
return shaHex, nil
}
// ReadBlob reads a blob by SHA-256 hash.
func ReadBlob(blobsDir, shaHex string) ([]byte, error) {
return os.ReadFile(BlobPath(blobsDir, shaHex))
}
// Ensure the package compiles without unused errors.
var _ = fmt.Sprintf
+346
View File
@@ -0,0 +1,346 @@
package sync
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
// Client communicates with the Verstak Sync Server.
type Client struct {
ServerURL string
APIKey string // legacy API key
DeviceToken string // new device token
DeviceID string
VaultRoot string
HTTP *http.Client
}
// NewClient creates a sync client.
func NewClient(serverURL, apiKey, deviceID, vaultRoot string) *Client {
return &Client{
ServerURL: serverURL,
APIKey: apiKey,
DeviceID: deviceID,
VaultRoot: vaultRoot,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// PairDevice calls POST /api/client/pair and returns device_id and device_token.
func (c *Client) PairDevice(serverURL, username, password, deviceName, clientVersion string) (deviceID, deviceToken string, err error) {
body := map[string]string{
"login": username,
"password": password,
"device_name": deviceName,
"client_version": clientVersion,
}
var resp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
savedURL := c.ServerURL
c.ServerURL = serverURL
err = c.post("/api/client/pair", body, &resp)
c.ServerURL = savedURL
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.DeviceToken, nil
}
// GetMe calls GET /api/client/me and returns device info.
type DeviceInfo struct {
DeviceID string `json:"device_id"`
UserID string `json:"user_id"`
Username string `json:"username"`
DeviceName string `json:"device_name"`
ClientVersion string `json:"client_version"`
LastSeen string `json:"last_seen"`
RevokedAt string `json:"revoked_at"`
CreatedAt string `json:"created_at"`
}
func (c *Client) GetMe() (*DeviceInfo, error) {
var resp DeviceInfo
if err := c.get("/api/client/me", &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RevokeCurrent calls POST /api/client/revoke-current.
func (c *Client) RevokeCurrent() error {
var resp struct {
Status string `json:"status"`
}
return c.post("/api/client/revoke-current", nil, &resp)
}
// RegisterDevice calls POST /api/v1/device/register and returns the API key.
func (c *Client) RegisterDevice(name string) (apiKey string, err error) {
body := map[string]string{"name": name}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
if err := c.post("/api/v1/device/register", body, &resp); err != nil {
return "", err
}
return resp.APIKey, nil
}
// RegisterDeviceWithAuth registers a device with user credentials.
func (c *Client) RegisterDeviceWithAuth(name, username, password string) (deviceID, apiKey string, err error) {
body := map[string]string{"name": name, "username": username, "password": password}
var resp struct {
DeviceID string `json:"device_id"`
APIKey string `json:"api_key"`
}
// Temporarily clear API key for this request (server expects login/password, not API key).
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/device/register", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", "", err
}
return resp.DeviceID, resp.APIKey, nil
}
// Login authenticates with user credentials and returns a session token.
func (c *Client) Login(username, password string) (token string, err error) {
body := map[string]string{"username": username, "password": password}
var resp struct {
Token string `json:"token"`
}
savedKey := c.APIKey
c.APIKey = ""
err = c.post("/api/v1/auth/login", body, &resp)
c.APIKey = savedKey
if err != nil {
return "", err
}
return resp.Token, nil
}
// TestAuth checks credentials without creating a device.
func (c *Client) TestAuth(serverURL, username, password string) error {
body := map[string]string{"username": username, "password": password}
savedURL := c.ServerURL
savedKey := c.APIKey
c.ServerURL = serverURL
c.APIKey = ""
err := c.post("/api/auth/test", body, nil)
c.ServerURL = savedURL
c.APIKey = savedKey
return err
}
// PushRequest is the payload for POST /sync/push.
type PushRequest struct {
DeviceID string `json:"device_id"`
IdempotencyKey string `json:"idempotency_key,omitempty"`
Ops []PushOp `json:"ops"`
}
// PushOp is a single operation in a push request.
type PushOp struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
ClientSequence int `json:"client_sequence"`
LastSeenServerSeq int `json:"last_seen_server_seq"`
CreatedAt string `json:"created_at"`
}
// PushResponse is the response from POST /sync/push.
type PushResponse struct {
Accepted []string `json:"accepted"`
Count int `json:"count"`
Conflicts []map[string]interface{} `json:"conflicts"`
}
// Push sends local operations to the server.
func (c *Client) Push(ops []Op) (*PushResponse, error) {
pushOps := make([]PushOp, len(ops))
for i, op := range ops {
pushOps[i] = PushOp{
OpID: op.OpID,
EntityType: op.EntityType,
EntityID: op.EntityID,
OpType: op.OpType,
PayloadJSON: op.PayloadJSON,
ClientSequence: op.ClientSequence,
LastSeenServerSeq: op.LastSeenServerSeq,
CreatedAt: op.CreatedAt,
}
}
req := PushRequest{DeviceID: c.DeviceID, Ops: pushOps}
var resp PushResponse
if err := c.post("/api/v1/sync/push", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// PullRequest is the payload for POST /sync/pull.
type PullRequest struct {
SinceSequence int `json:"since_sequence"`
}
// PullResponse is the response from POST /sync/pull.
type PullResponse struct {
ServerSequence int `json:"server_sequence"`
Ops []Op `json:"ops"`
}
// Pull fetches remote operations since a given sequence.
func (c *Client) Pull(sinceSequence int) (*PullResponse, error) {
req := PullRequest{SinceSequence: sinceSequence}
var resp PullResponse
if err := c.post("/api/v1/sync/pull", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// UploadBlob uploads a file to the server and returns its SHA-256.
func (c *Client) UploadBlob(localPath string) (sha256 string, err error) {
var b bytes.Buffer
w := multipart.NewWriter(&b)
fw, err := w.CreateFormFile("file", filepath.Base(localPath))
if err != nil {
return "", err
}
f, err := os.Open(localPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(fw, f); err != nil {
return "", err
}
w.Close()
req, err := http.NewRequest("POST", c.ServerURL+"/api/v1/blobs/", &b)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", w.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var result struct {
SHA256 string `json:"sha256"`
Size int `json:"size"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
return result.SHA256, nil
}
// DownloadBlob downloads a blob by SHA-256 hash.
func (c *Client) DownloadBlob(sha256, destPath string) error {
req, err := http.NewRequest("GET", c.ServerURL+"/api/v1/blobs/"+sha256, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download blob: HTTP %d", resp.StatusCode)
}
out, err := os.Create(destPath)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
// --- internal ---
func (c *Client) bearerToken() string {
if c.DeviceToken != "" {
return c.DeviceToken
}
return c.APIKey
}
func (c *Client) post(path string, body, result interface{}) error {
var b bytes.Buffer
if body != nil {
if err := json.NewEncoder(&b).Encode(body); err != nil {
return err
}
}
req, err := http.NewRequest("POST", c.ServerURL+path, &b)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
func (c *Client) get(path string, result interface{}) error {
req, err := http.NewRequest("GET", c.ServerURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.bearerToken())
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
data, _ := io.ReadAll(resp.Body)
return fmt.Errorf("server %d: %s", resp.StatusCode, string(data))
}
if result != nil {
return json.NewDecoder(resp.Body).Decode(result)
}
return nil
}
+188
View File
@@ -0,0 +1,188 @@
package sync
import (
"database/sql"
"encoding/json"
"fmt"
"time"
"verstak/internal/core/storage"
"verstak/internal/core/util"
)
// Entity types (matches activity targets).
const (
EntityNode = "node"
EntityNote = "note"
EntityFile = "file"
EntityFolder = "folder"
EntityAction = "action"
EntityWorklog = "worklog"
)
// Op types.
const (
OpCreate = "create"
OpUpdate = "update"
OpDelete = "delete"
OpMove = "move"
)
// Op represents a sync operation.
type Op struct {
ID string `json:"id"`
OpID string `json:"op_id"`
ServerSequence int `json:"server_sequence,omitempty"`
DeviceID string `json:"device_id,omitempty"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
CreatedAt string `json:"created_at"`
PushedAt *string `json:"pushed_at,omitempty"`
ClientSequence int `json:"client_sequence,omitempty"`
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
}
// Service records and manages sync operations.
type Service struct {
db *storage.DB
deviceID string
}
// NewService creates a sync service.
func NewService(db *storage.DB, deviceID string) *Service {
return &Service{db: db, deviceID: deviceID}
}
// RecordOp writes a sync operation to the local sync_ops table.
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
id := util.UUID7()
now := time.Now().UTC().Format(time.RFC3339)
var payloadStr string
if payload != nil {
b, err := json.Marshal(payload)
if err != nil {
return err
}
payloadStr = string(b)
}
_, err := s.db.Exec(
`INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, id, s.deviceID, entityType, entityID, opType, payloadStr, now,
)
return err
}
// RecordRemoteOp writes a remote op to the local sync_ops table (already applied server-side).
func (s *Service) RecordRemoteOp(op Op) error {
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(
`INSERT OR IGNORE INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at, applied_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
op.OpID+"-remote", op.OpID, op.DeviceID, op.EntityType, op.EntityID, op.OpType, op.PayloadJSON, op.CreatedAt, now, now,
)
return err
}
// GetUnpushedOps returns ops that have not been pushed yet.
func (s *Service) GetUnpushedOps() ([]Op, error) {
rows, err := s.db.Query(
`SELECT id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at, pushed_at
FROM sync_ops WHERE pushed_at IS NULL ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
return scanOps(rows)
}
// MarkPushed marks ops as pushed to server.
func (s *Service) MarkPushed(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET pushed_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// MarkApplied marks remote ops as applied locally.
func (s *Service) MarkApplied(opIDs []string) error {
now := time.Now().UTC().Format(time.RFC3339)
for _, id := range opIDs {
_, err := s.db.Exec("UPDATE sync_ops SET applied_at=? WHERE op_id=?", now, id)
if err != nil {
return err
}
}
return nil
}
// GetState returns the current sync state.
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
err = s.db.QueryRow(
`SELECT server_url, api_key, last_pull_seq, COALESCE(last_sync_at,'') FROM sync_state WHERE device_id=?`,
s.deviceID).Scan(&serverURL, &apiKey, &lastPullSeq, &lastSyncAt)
if err == sql.ErrNoRows {
return "", "", 0, "", nil
}
return
}
// SetState saves sync connection state.
func (s *Service) SetState(serverURL, apiKey string) error {
_, err := s.db.Exec(
`INSERT INTO sync_state (device_id, server_url, api_key, last_pull_seq, last_sync_at)
VALUES (?, ?, ?, 0, '')
ON CONFLICT(device_id) DO UPDATE SET
server_url=excluded.server_url,
api_key=excluded.api_key`,
s.deviceID, serverURL, apiKey,
)
return err
}
// SetLastPullSeq updates the last pulled server sequence.
func (s *Service) SetLastPullSeq(seq int) error {
_, err := s.db.Exec("UPDATE sync_state SET last_pull_seq=? WHERE device_id=?", seq, s.deviceID)
return err
}
// GetDeviceID returns the device ID used by this service.
func (s *Service) GetDeviceID() string {
return s.deviceID
}
// SetLastSyncAt updates the last sync timestamp.
func (s *Service) SetLastSyncAt(t string) error {
_, err := s.db.Exec("UPDATE sync_state SET last_sync_at=? WHERE device_id=?", t, s.deviceID)
return err
}
// --- helpers ---
func scanOps(rows *sql.Rows) ([]Op, error) {
var out []Op
for rows.Next() {
var o Op
var pushedAt sql.NullString
if err := rows.Scan(&o.ID, &o.OpID, &o.DeviceID, &o.EntityType, &o.EntityID,
&o.OpType, &o.PayloadJSON, &o.CreatedAt, &pushedAt); err != nil {
return nil, err
}
if pushedAt.Valid {
o.PushedAt = &pushedAt.String
}
out = append(out, o)
}
return out, rows.Err()
}
// MustVar ensures the package is not considered unused.
var _ = fmt.Sprintf
+311
View File
@@ -0,0 +1,311 @@
package sync
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
_ "github.com/mattn/go-sqlite3"
)
// TestE2ESync starts a real server process and tests two-client sync.
func TestE2ESync(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e sync test in short mode")
}
// Find the server binary (must be pre-built).
serverBin := findServerBin(t)
if serverBin == "" {
t.Skip("verstak-server binary not found; build with 'go build -o verstak-server ./cmd/verstak-server'")
}
// Create temp directories.
serverDir := t.TempDir()
vaultA := t.TempDir()
vaultB := t.TempDir()
// Init vaults.
os.MkdirAll(filepath.Join(vaultA, ".verstak"), 0750)
os.MkdirAll(filepath.Join(vaultB, ".verstak"), 0750)
// Pick a random port.
serverPort := pickPort(t)
// Start server.
serverDataDir := filepath.Join(serverDir, "data")
os.MkdirAll(serverDataDir, 0750)
cmd := exec.Command(serverBin,
"-port", fmt.Sprintf("%d", serverPort),
"-data", serverDataDir,
"-admin-user", "admin",
"-admin-pass", "admin",
)
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatalf("start server: %v", err)
}
defer cmd.Process.Kill()
// Wait for server.
serverURL := fmt.Sprintf("http://127.0.0.1:%d", serverPort)
waitForServer(t, serverURL, 10*time.Second)
// Register user.
regBody := fmt.Sprintf(`{"username":"testuser","email":"test@test.com","password":"password123"}`)
resp, err := http.Post(serverURL+"/api/v1/auth/register", "application/json", strings.NewReader(regBody))
if err != nil {
t.Fatalf("register: %v", err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
// Confirm email by directly updating the server DB.
dbPath := filepath.Join(serverDataDir, "server.db")
confirmUser(t, dbPath, "testuser")
// Test auth (the new endpoint).
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
if err != nil {
t.Fatalf("auth test: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("auth test status %d: %s", resp.StatusCode, string(body))
}
resp.Body.Close()
// Pair device A.
pairBody := fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client A","client_version":"test/v1"}`)
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
if err != nil {
t.Fatalf("pair A: %v", err)
}
var pairResp struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
UserID string `json:"user_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&pairResp); err != nil {
t.Fatalf("decode pair A: %v", err)
}
resp.Body.Close()
deviceIDA := pairResp.DeviceID
tokenA := pairResp.DeviceToken
t.Logf("Client A: device=%s token=%s...%s", deviceIDA, tokenA[:16], tokenA[len(tokenA)-8:])
// Pair device B.
pairBody = fmt.Sprintf(`{"login":"testuser","password":"password123","device_name":"Client B","client_version":"test/v1"}`)
resp, err = http.Post(serverURL+"/api/client/pair", "application/json", strings.NewReader(pairBody))
if err != nil {
t.Fatalf("pair B: %v", err)
}
var pairRespB struct {
DeviceID string `json:"device_id"`
DeviceToken string `json:"device_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&pairRespB); err != nil {
t.Fatalf("decode pair B: %v", err)
}
resp.Body.Close()
deviceIDB := pairRespB.DeviceID
tokenB := pairRespB.DeviceToken
t.Logf("Client B: device=%s token=%s...%s", deviceIDB, tokenB[:16], tokenB[len(tokenB)-8:])
// Now create a node on Client A by posting a sync op.
// Simulating what the GUI does: push a "node create" op.
nodeID := "test-node-001"
now := time.Now().UTC().Format(time.RFC3339)
pushPayload := map[string]interface{}{
"device_id": deviceIDA,
"ops": []map[string]interface{}{
{
"op_id": "op-node-create-001",
"entity_type": "node",
"entity_id": nodeID,
"op_type": "create",
"payload_json": fmt.Sprintf(
`{"id":"%s","parent_id":"","type":"case","title":"Test Project","slug":"test-project","section":"projects","created_at":"%s","updated_at":"%s"}`,
nodeID, now, now),
"client_sequence": 1,
"last_seen_server_seq": 0,
"created_at": now,
},
},
"idempotency_key": "e2e-test-push-1",
}
pushBody, _ := json.Marshal(pushPayload)
req, _ := http.NewRequest("POST", serverURL+"/api/v1/sync/push", bytes.NewReader(pushBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenA)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("push A: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("push A status %d: %s", resp.StatusCode, string(body))
}
var pushRespA struct {
Accepted []string `json:"accepted"`
Conflicts []interface{} `json:"conflicts"`
}
json.NewDecoder(resp.Body).Decode(&pushRespA)
resp.Body.Close()
t.Logf("Push A accepted: %v", pushRespA.Accepted)
// Pull from Client B — should get the node op.
pullReq := map[string]interface{}{"since_sequence": 0}
pullBody, _ := json.Marshal(pullReq)
req, _ = http.NewRequest("POST", serverURL+"/api/v1/sync/pull", bytes.NewReader(pullBody))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+tokenB)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("pull B: %v", err)
}
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
t.Fatalf("pull B status %d: %s", resp.StatusCode, string(body))
}
var pullRespB struct {
ServerSequence int `json:"server_sequence"`
Ops []struct {
OpID string `json:"op_id"`
EntityType string `json:"entity_type"`
EntityID string `json:"entity_id"`
OpType string `json:"op_type"`
PayloadJSON string `json:"payload_json"`
} `json:"ops"`
}
json.NewDecoder(resp.Body).Decode(&pullRespB)
resp.Body.Close()
if len(pullRespB.Ops) == 0 {
t.Fatal("Client B pulled 0 ops, expected at least 1")
}
t.Logf("Client B pulled %d ops (server seq=%d)", len(pullRespB.Ops), pullRespB.ServerSequence)
foundNodeOp := false
for _, op := range pullRespB.Ops {
if op.EntityType == "node" && op.EntityID == nodeID && op.OpType == "create" {
foundNodeOp = true
// Verify payload contents.
var payload map[string]interface{}
json.Unmarshal([]byte(op.PayloadJSON), &payload)
if payload["title"] != "Test Project" {
t.Errorf("expected title 'Test Project', got %v", payload["title"])
}
if payload["type"] != "case" {
t.Errorf("expected type 'case', got %v", payload["type"])
}
if payload["section"] != "projects" {
t.Errorf("expected section 'projects', got %v", payload["section"])
}
t.Logf("Node payload verified: title=%v type=%v section=%v",
payload["title"], payload["type"], payload["section"])
}
}
if !foundNodeOp {
t.Errorf("node create op not found in pulled ops")
}
// Test the auth test endpoint doesn't create devices.
// Count devices before via direct DB access.
beforeCount := countDevices(t, dbPath)
t.Logf("Devices before auth test calls: %d (should be 2: A + B)", beforeCount)
// Call test auth multiple times.
for i := 0; i < 3; i++ {
testBody := fmt.Sprintf(`{"username":"testuser","password":"password123"}`)
resp, err = http.Post(serverURL+"/api/auth/test", "application/json", strings.NewReader(testBody))
if err != nil {
t.Fatalf("auth test iteration %d: %v", i, err)
}
resp.Body.Close()
}
// Count devices after — should be same.
afterCount := countDevices(t, dbPath)
if afterCount != beforeCount {
t.Errorf("device count changed after auth test: before=%d after=%d (should be equal)", beforeCount, afterCount)
}
t.Log("E2E sync test passed!")
}
func findServerBin(t *testing.T) string {
t.Helper()
// Check common locations.
candidates := []string{
"verstak-server",
"../../../verstak-server",
}
for _, c := range candidates {
if _, err := os.Stat(c); err == nil {
return c
}
}
return ""
}
func pickPort(t *testing.T) int {
t.Helper()
// Try a few ports — for testing, a fixed port is OK since tests use temp dirs.
return 18999
}
func waitForServer(t *testing.T, url string, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
resp, err := http.Get(url + "/api/v1/health")
if err == nil {
resp.Body.Close()
return
}
time.Sleep(200 * time.Millisecond)
}
t.Fatalf("server not ready within %v at %s", timeout, url)
}
func confirmUser(t *testing.T, dbPath, username string) {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open server db for confirm: %v", err)
}
defer db.Close()
_, err = db.Exec("UPDATE server_users SET confirmed=1 WHERE username=?", username)
if err != nil {
t.Fatalf("confirm user: %v", err)
}
}
func countDevices(t *testing.T, dbPath string) int {
t.Helper()
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
t.Fatalf("open server db for count: %v", err)
}
defer db.Close()
var count int
db.QueryRow("SELECT COUNT(*) FROM server_devices").Scan(&count)
return count
}
+1 -1
View File
@@ -38,7 +38,7 @@ type Server struct {
// NewServer creates a GUI server for the given vault.
func NewServer(db *storage.DB, vaultRoot string) *Server {
nodeRepo := nodes.NewRepository(db)
fileSvc := files.NewService(db, vaultRoot)
fileSvc := files.NewService(db, vaultRoot, nodeRepo)
noteSvc := notes.NewService(db, vaultRoot, nodeRepo, fileSvc)
actionSvc := actions.NewService(db)
workSvc := worklog.NewService(db)
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
set -euo pipefail
echo "=== Verstak Sync E2E Smoke Test ==="
SERVER_PORT=18999
SDIR=$(mktemp -d)
VD=$(mktemp -d)
cleanup() { kill "$SPID" 2>/dev/null || true; rm -rf "$SDIR" "$VD"; }
trap cleanup EXIT
echo ":: Build"
go build -o /tmp/vs-server ./cmd/verstak-server/
go build -o /tmp/vs-cli ./cmd/verstak/
echo ":: Start server"
/tmp/vs-server --port "$SERVER_PORT" --data "$SDIR" --admin-user admin --admin-pass pass > /dev/null 2>&1 &
SPID=$!
sleep 2
echo ":: Health"
curl -sf "http://localhost:$SERVER_PORT/api/v1/health" | grep -q '"ok"' && echo " OK"
echo ":: Register device"
# Register user first
curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{"username":"smokeuser","email":"smoke@test.local","password":"mypass123"}' > /dev/null
# Confirm email
TOKEN=$(sqlite3 "$SDIR/server.db" "SELECT token FROM server_email_tokens WHERE purpose='confirm' LIMIT 1")
curl -sf "http://localhost:$SERVER_PORT/api/v1/auth/confirm?token=$TOKEN" > /dev/null
# Register device with user creds
REG=$(curl -sf -X POST "http://localhost:$SERVER_PORT/api/v1/device/register" \
-H "Content-Type: application/json" -d '{"name":"smoke","username":"smokeuser","password":"mypass123"}')
DID=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['device_id'])")
AKEY=$(echo "$REG" | python3 -c "import sys,json;print(json.load(sys.stdin)['api_key'])")
echo " device=$DID"
echo ":: Init vault"
/tmp/vs-cli init --vault "$VD"
echo ":: Configure sync"
cat > "$VD/.verstak/config.yml" <<YML
sync:
server_url: http://localhost:$SERVER_PORT
api_key: $AKEY
device_id: $DID
auto_sync: false
YML
echo ":: Insert sync op (simulate recorded op)"
sqlite3 "$VD/.verstak/index.db" \
"INSERT INTO sync_ops (id, op_id, device_id, entity_type, entity_id, op_type, payload_json, created_at) VALUES ('e1','e1','$DID','node','n1','create','{\"title\":\"t\"}',datetime('now'));"
echo ":: Push"
/tmp/vs-cli sync push --vault "$VD" | grep -q "Pushed 1" && echo " OK"
echo ":: Pull"
/tmp/vs-cli sync pull --vault "$VD" | grep -q "Pulled" && echo " OK"
echo ":: Status"
/tmp/vs-cli sync status --vault "$VD"
echo ""
echo "=== ALL TESTS PASSED ==="