Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50e7e95844 | ||
|
|
4a96aa3468 | ||
|
|
f8f9510e2a | ||
|
|
852d6d373c | ||
|
|
3c7e9d1d56 | ||
|
|
87c8dfcbea | ||
|
|
7fe02fc8df | ||
|
|
b0d992b0d6 | ||
|
|
e5860ca076 | ||
|
|
daed8e0aba | ||
|
|
fa6f988368 | ||
|
|
c8cdb089a6 | ||
|
|
4afcc0e135 | ||
|
|
61928cf28e | ||
|
|
04af88940b | ||
|
|
015c8fdec7 | ||
|
|
0f5c584c50 | ||
|
|
99e47fcb17 | ||
|
|
0ef54c31f8 | ||
|
|
b3662d4876 | ||
|
|
f8dc436709 | ||
|
|
241a9d8c06 | ||
|
|
5db3da3618 | ||
|
|
e828ebd44e | ||
|
|
84c0bcbcab | ||
|
|
a1a50863c5 | ||
|
|
1abe8c4fa0 | ||
|
|
5b2cec5bcc | ||
|
|
1a20edac44 | ||
|
|
ad684eb118 | ||
|
|
10c6d06e38 | ||
|
|
ec928e3be6 | ||
|
|
c5e0060fee | ||
|
|
834b5ef0d4 | ||
|
|
4145b4d74a | ||
|
|
edc708a106 | ||
|
|
ee708d30bb | ||
|
|
305158ecc6 | ||
|
|
996322f3a9 | ||
|
|
a098cf721c | ||
|
|
3672e3133b | ||
|
|
5a1c4c6d7f | ||
|
|
08c9d5dbea | ||
|
|
c74fa3ad43 | ||
|
|
69891e395c | ||
|
|
a4ae22c445 | ||
|
|
2487d3bbaa | ||
|
|
645d8878cc |
+8
-2
@@ -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/
|
||||
|
||||
Binary file not shown.
@@ -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
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
@@ -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>
|
||||
|
||||
+24
-3
@@ -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,21 +46,36 @@ 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,
|
||||
activity: activitySvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
plugins: pm,
|
||||
sync: syncSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
@@ -72,6 +90,9 @@ func main() {
|
||||
Assets: assets,
|
||||
},
|
||||
OnStartup: app.startup,
|
||||
DragAndDrop: &options.DragAndDrop{
|
||||
EnableFileDrop: true,
|
||||
},
|
||||
Bind: []interface{}{app},
|
||||
})
|
||||
|
||||
|
||||
Executable
+110
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
7. [[07_AI_Coder_Prompts]] — промпты для ИИ-кодера.
|
||||
8. [[08_MVP_Checklist]] — чеклист первого MVP.
|
||||
9. [[09_Extensibility]] — архитектура плагинов (Lua + шаблоны дел).
|
||||
10. [[10_Sync_Server_Guide]] — установка и настройка сервера синхронизации.
|
||||
|
||||
## Главные принципы
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 (плагин).
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
+1758
-224
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type SyncConfig struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
DeviceID string `yaml:"device_id"`
|
||||
AutoSync bool `yaml:"auto_sync"`
|
||||
SyncInterval int `yaml:"sync_interval"`
|
||||
}
|
||||
|
||||
type BrowserConfig struct {
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
`
|
||||
@@ -64,7 +64,10 @@ var migrationFiles = map[int]string{
|
||||
5: migration005,
|
||||
6: migration006,
|
||||
// 7: migration007 (FTS5) — created lazily by search.Rebuild()
|
||||
// 8: migration008, etc.
|
||||
8: migration008,
|
||||
9: migration009,
|
||||
10: migration010,
|
||||
11: migration011,
|
||||
}
|
||||
|
||||
func (db *DB) runInitialSchema() error {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Executable
+65
@@ -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 ==="
|
||||
Reference in New Issue
Block a user