Compare commits
46
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 |
+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.
+1301
-4
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -16,8 +16,8 @@
|
||||
background: #13131f;
|
||||
}
|
||||
</style>
|
||||
<script type="module" crossorigin src="/assets/main-a-M2pafQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-6cuAgDnH.css">
|
||||
<script type="module" crossorigin src="/assets/main-CvznySlT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Bkv7FuGB.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+19
-1
@@ -7,12 +7,15 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
@@ -46,18 +49,33 @@ func main() {
|
||||
fileSvc := files.NewService(db, abs, nodeRepo)
|
||||
noteSvc := notes.NewService(db, abs, nodeRepo, fileSvc)
|
||||
actionSvc := actions.NewService(db)
|
||||
activitySvc := activity.NewService(db)
|
||||
worklogSvc := worklog.NewService(db)
|
||||
searchSvc := search.NewService(db)
|
||||
plugins.NewManager(abs).Discover()
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
// Sync service — use configured device ID or vault ID as fallback.
|
||||
deviceID := ""
|
||||
if cfg, err := config.Load(abs); err == nil {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "gui-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
|
||||
app := &App{
|
||||
db: db,
|
||||
nodes: nodeRepo,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
activity: activitySvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
plugins: pm,
|
||||
sync: syncSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
|
||||
+729
-32
@@ -26,6 +26,12 @@
|
||||
// ===== State =====
|
||||
let sections = []
|
||||
let nodes = []
|
||||
let todayDashboard = null
|
||||
let activityFeed = []
|
||||
let activityOffset = 0
|
||||
let activityHasMore = true
|
||||
let activityLoading = false
|
||||
let caseActivity = []
|
||||
let version = ''
|
||||
let error = ''
|
||||
let selectedSection = ''
|
||||
@@ -41,8 +47,23 @@
|
||||
let showCreateNode = false
|
||||
let newNodeTitle = ''
|
||||
let newNodeSection = 'clients'
|
||||
let newNodeTemplate = ''
|
||||
let templates = []
|
||||
let showCreateNote = false
|
||||
let newNoteTitle = ''
|
||||
let showCreateAction = false
|
||||
let newActionTitle = ''
|
||||
let newActionKind = 'open_url'
|
||||
let newActionData = ''
|
||||
let actionKinds = [
|
||||
{ id: 'open_url', label: 'Открыть URL' },
|
||||
{ id: 'open_file', label: 'Открыть файл' },
|
||||
{ id: 'open_folder', label: 'Открыть папку' },
|
||||
{ id: 'run_command', label: 'Запустить команду' },
|
||||
{ id: 'run_script', label: 'Запустить скрипт' },
|
||||
{ id: 'open_terminal', label: 'Открыть терминал' },
|
||||
{ id: 'launch_app', label: 'Запустить приложение' },
|
||||
]
|
||||
let loading = true
|
||||
let importing = false
|
||||
let importSummary = null
|
||||
@@ -72,6 +93,21 @@
|
||||
let confirmAction = null
|
||||
let cancelAction = null
|
||||
|
||||
let showRename = false
|
||||
let renameId = ''
|
||||
let renameValue = ''
|
||||
let renameError = ''
|
||||
|
||||
// ===== Sync state =====
|
||||
let showSettings = false
|
||||
let syncStatus = null
|
||||
let syncLoading = false
|
||||
let syncServerUrl = ''
|
||||
let syncUsername = ''
|
||||
let syncPassword = ''
|
||||
let syncInterval = 0
|
||||
let syncResult = ''
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Обзор' },
|
||||
{ id: 'notes', label: 'Заметки' },
|
||||
@@ -94,6 +130,7 @@
|
||||
sections = [
|
||||
{ id: 'today', label: 'Сегодня' },
|
||||
{ id: 'inbox', label: 'Неразобранное' },
|
||||
{ id: 'activity', label: 'Активность' },
|
||||
{ id: 'clients', label: 'Клиенты' },
|
||||
{ id: 'projects', label: 'Проекты' },
|
||||
{ id: 'recipes', label: 'Рецепты' },
|
||||
@@ -111,6 +148,7 @@
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
|
||||
loading = false
|
||||
loadSyncStatus()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
@@ -129,11 +167,26 @@
|
||||
worklog = []
|
||||
showCreateNode = false
|
||||
error = ''
|
||||
todayDashboard = null
|
||||
activityFeed = []
|
||||
activityOffset = 0
|
||||
activityHasMore = true
|
||||
nodes = []
|
||||
try {
|
||||
if (id === 'today') {
|
||||
todayDashboard = await wailsCall('ListTodayView') || { cases: [] }
|
||||
} else if (id === 'activity') {
|
||||
activityFeed = await wailsCall('ListActivityFeed', 50, 0) || []
|
||||
activityOffset = activityFeed.length
|
||||
activityHasMore = activityFeed.length === 50
|
||||
} else {
|
||||
nodes = await wailsCall('ListNodesBySection', id) || []
|
||||
}
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
nodes = []
|
||||
todayDashboard = { cases: [] }
|
||||
activityFeed = []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +211,7 @@
|
||||
showCreateNode = false
|
||||
showCreateNote = false
|
||||
error = ''
|
||||
caseActivity = []
|
||||
await loadTabData(node.id)
|
||||
}
|
||||
|
||||
@@ -166,6 +220,7 @@
|
||||
try { files = await wailsCall('ListFiles', nodeID) || [] } catch(e) {}
|
||||
try { actions = await wailsCall('ListActions', nodeID) || [] } catch(e) {}
|
||||
try { worklog = await wailsCall('ListWorklog', nodeID) || [] } catch(e) {}
|
||||
try { caseActivity = await wailsCall('ListActivityByNode', nodeID, 50, 0) || [] } catch(e) {}
|
||||
}
|
||||
|
||||
async function loadTree(nodeID) {
|
||||
@@ -282,19 +337,8 @@
|
||||
}
|
||||
|
||||
function renameItem(id) {
|
||||
const name = prompt('Новое имя:')
|
||||
if (!name || !name.trim()) return
|
||||
renameSubmit(id, name.trim())
|
||||
}
|
||||
|
||||
async function renameSubmit(id, newName) {
|
||||
try {
|
||||
await wailsCall('RenameNode', id, newName)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
const item = fileItems.find(x => x.id === id)
|
||||
if (item) openRename(item.id, item.name)
|
||||
}
|
||||
|
||||
function cutItem(id) {
|
||||
@@ -343,6 +387,20 @@
|
||||
selectedIds = fileItems.map(x => x.id)
|
||||
}
|
||||
|
||||
function rangeSelect(id) {
|
||||
if (fileItems.length === 0) return
|
||||
const lastId = selectedIds.length > 0 ? selectedIds[selectedIds.length - 1] : fileItems[0].id
|
||||
const lastIdx = fileItems.findIndex(x => x.id === lastId)
|
||||
const curIdx = fileItems.findIndex(x => x.id === id)
|
||||
if (lastIdx === -1 || curIdx === -1) return
|
||||
const start = Math.min(lastIdx, curIdx)
|
||||
const end = Math.max(lastIdx, curIdx)
|
||||
const range = fileItems.slice(start, end + 1).map(x => x.id)
|
||||
const set = new Set(selectedIds)
|
||||
range.forEach(x => set.add(x))
|
||||
selectedIds = [...set]
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedIds = []
|
||||
}
|
||||
@@ -421,13 +479,104 @@
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
|
||||
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'c') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a') { e.preventDefault(); selectAll() }
|
||||
if (e.key === 'c' || e.key === 'C') { e.preventDefault(); copySelected() }
|
||||
else if (e.key === 'x' || e.key === 'X') { e.preventDefault(); cutSelected() }
|
||||
else if (e.key === 'v' || e.key === 'V') { e.preventDefault(); pasteItem() }
|
||||
else if (e.key === 'a' || e.key === 'A') { e.preventDefault(); selectAll() }
|
||||
else if (e.key === 'o' || e.key === 'O') { e.preventDefault(); openSelectedExternal() }
|
||||
else if (e.key === 'Enter') { e.preventDefault(); openSelected() }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
openSelected()
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected() }
|
||||
if (previewItem) { e.preventDefault(); closePreview(); return }
|
||||
if (selectedIds.length > 0) { e.preventDefault(); deleteSelected(); return }
|
||||
// Backspace without selection → navigate up
|
||||
if (e.key === 'Backspace' && folderStack.length > 0) {
|
||||
e.preventDefault()
|
||||
navigateBack()
|
||||
return
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
if (previewItem) { closePreview(); return }
|
||||
if (selectedIds.length > 0) { clearSelection(); return }
|
||||
} else if (e.key === 'F2') {
|
||||
e.preventDefault()
|
||||
openRenameForSelection()
|
||||
}
|
||||
}
|
||||
|
||||
function openSelected() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
if (item.type === 'folder') {
|
||||
navigateToFolder(item.id)
|
||||
} else {
|
||||
openPreview(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openSelectedExternal() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item && item.fileId) {
|
||||
wailsCall('OpenFile', item.fileId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Rename modal =====
|
||||
|
||||
function openRename(id, currentName) {
|
||||
renameId = id
|
||||
renameValue = currentName
|
||||
renameError = ''
|
||||
showRename = true
|
||||
}
|
||||
|
||||
function openRenameForSelection() {
|
||||
if (selectedIds.length === 1) {
|
||||
const item = fileItems.find(x => x.id === selectedIds[0])
|
||||
if (item) {
|
||||
openRename(item.id, item.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRename() {
|
||||
const name = renameValue.trim()
|
||||
if (!name) { renameError = 'Имя не может быть пустым'; return }
|
||||
// Validate name via backend
|
||||
try {
|
||||
await wailsCall('ValidateName', name)
|
||||
} catch (e) {
|
||||
renameError = 'Недопустимое имя'
|
||||
return
|
||||
}
|
||||
showRename = false
|
||||
renameId = ''
|
||||
try {
|
||||
await wailsCall('RenameNode', renameId, name)
|
||||
const parentId = currentFolderId || selectedNode.id
|
||||
await loadFolder(parentId)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
showRename = false
|
||||
renameId = ''
|
||||
renameValue = ''
|
||||
renameError = ''
|
||||
}
|
||||
|
||||
function onRenameKeydown(e) {
|
||||
if (e.key === 'Enter') submitRename()
|
||||
else renameError = ''
|
||||
}
|
||||
|
||||
// ===== Confirm modal =====
|
||||
@@ -463,14 +612,22 @@
|
||||
showCreateNode = true
|
||||
newNodeTitle = ''
|
||||
newNodeSection = selectedSection || 'clients'
|
||||
newNodeTemplate = ''
|
||||
wailsCall('ListTemplates').then(t => { templates = t || [] }).catch(() => { templates = [] })
|
||||
}
|
||||
function cancelCreateNode() { showCreateNode = false; newNodeTitle = '' }
|
||||
async function submitCreateNode() {
|
||||
if (!newNodeTitle.trim()) return
|
||||
try {
|
||||
const node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
||||
let node
|
||||
if (newNodeTemplate) {
|
||||
node = await wailsCall('FromTemplate', '', 'case', newNodeTitle.trim(), newNodeSection, newNodeTemplate)
|
||||
} else {
|
||||
node = await wailsCall('CreateNode', '', 'case', newNodeTitle.trim(), newNodeSection)
|
||||
}
|
||||
showCreateNode = false
|
||||
newNodeTitle = ''
|
||||
newNodeTemplate = ''
|
||||
await selectSection(newNodeSection)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
@@ -652,10 +809,182 @@
|
||||
|
||||
// ===== Helpers =====
|
||||
function tabClass(id) { return activeTab === id ? 'tab active' : 'tab' }
|
||||
function eventLabel(type) {
|
||||
const labels = {
|
||||
'note_created': 'Заметка создана',
|
||||
'note_updated': 'Заметка изменена',
|
||||
'file_added': 'Файл добавлен',
|
||||
'file_deleted': 'Файл удалён',
|
||||
'file_renamed': 'Файл переименован',
|
||||
'file_copied': 'Файл скопирован',
|
||||
'file_moved': 'Файл перемещён',
|
||||
'folder_added': 'Папка добавлена',
|
||||
'folder_deleted': 'Папка удалена',
|
||||
'folder_renamed': 'Папка переименована',
|
||||
'node_created': 'Дело создано',
|
||||
'node_updated': 'Дело изменено',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
function eventIcon(type) {
|
||||
if (type === 'note_created' || type === 'file_added' || type === 'folder_added' || type === 'node_created') return '+'
|
||||
if (type === 'file_deleted' || type === 'folder_deleted') return '×'
|
||||
if (type === 'file_renamed' || type === 'folder_renamed' || type === 'note_updated' || type === 'node_updated') return '~'
|
||||
if (type === 'file_copied') return '⧉'
|
||||
if (type === 'file_moved') return '→'
|
||||
return '•'
|
||||
}
|
||||
function formatTime(str) {
|
||||
if (!str) return ''
|
||||
try { return new Date(str).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }) } catch (e) { return '' }
|
||||
}
|
||||
function formatDate(str) {
|
||||
if (!str) return ''
|
||||
try { return new Date(str).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' }) } catch (e) { return str }
|
||||
}
|
||||
function nodeKindLabel(kind) {
|
||||
const labels = { 'project': 'Проект', 'client': 'Клиент', 'document': 'Документ', 'recipe': 'Рецепт', 'archive': 'Архив', 'case': 'Дело' }
|
||||
return labels[kind] || kind || 'Дело'
|
||||
}
|
||||
function pluralize(n, one, few, many) {
|
||||
n = Math.abs(n) % 100
|
||||
if (n >= 5 && n <= 20) return many
|
||||
n %= 10
|
||||
if (n === 1) return one
|
||||
if (n >= 2 && n <= 4) return few
|
||||
return many
|
||||
}
|
||||
// ===== Actions =====
|
||||
function openCreateAction() {
|
||||
showCreateAction = true
|
||||
newActionTitle = ''
|
||||
newActionKind = 'open_url'
|
||||
newActionData = ''
|
||||
}
|
||||
function cancelCreateAction() { showCreateAction = false; newActionTitle = ''; newActionData = '' }
|
||||
async function submitCreateAction() {
|
||||
if (!newActionTitle.trim() || !newActionData.trim() || !selectedNode) return
|
||||
try {
|
||||
const action = await wailsCall('CreateAction', selectedNode.id, newActionKind, newActionTitle.trim(), newActionData.trim())
|
||||
if (action && action.id) {
|
||||
actions = [...actions, action]
|
||||
}
|
||||
showCreateAction = false
|
||||
newActionTitle = ''
|
||||
newActionData = ''
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
async function deleteAction(id) {
|
||||
try {
|
||||
await wailsCall('DeleteAction', id)
|
||||
actions = actions.filter(a => a.id !== id)
|
||||
} catch (e) { error = String(e) }
|
||||
}
|
||||
function actionKindLabel(kind) {
|
||||
const k = actionKinds.find(k => k.id === kind)
|
||||
return k ? k.label : kind
|
||||
}
|
||||
|
||||
async function openNodeById(id) {
|
||||
try {
|
||||
const node = await wailsCall('GetNodeDetail', id)
|
||||
if (node) selectNode(node)
|
||||
} catch (e) {
|
||||
error = String(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Sync =====
|
||||
async function loadSyncStatus() {
|
||||
try {
|
||||
syncStatus = await wailsCall('SyncStatus')
|
||||
} catch (e) {
|
||||
syncStatus = { configured: false, serverUrl: '', deviceId: '', unpushedOps: 0, lastSyncAt: '', syncInterval: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
showSettings = true
|
||||
syncServerUrl = syncStatus?.serverUrl || ''
|
||||
syncUsername = ''
|
||||
syncPassword = ''
|
||||
syncInterval = syncStatus?.syncInterval || 0
|
||||
syncResult = ''
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
showSettings = false
|
||||
syncResult = ''
|
||||
}
|
||||
|
||||
async function saveSyncConfig() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncConfigure', syncServerUrl, syncUsername, syncPassword)
|
||||
if (syncInterval > 0) {
|
||||
await wailsCall('SyncSetInterval', syncInterval)
|
||||
}
|
||||
syncPassword = ''
|
||||
syncUsername = ''
|
||||
await loadSyncStatus()
|
||||
showSettings = false
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function saveSyncInterval() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncSetInterval', syncInterval)
|
||||
syncResult = 'интервал сохранён'
|
||||
await loadSyncStatus()
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncTestConnection', syncServerUrl, syncUsername, syncPassword)
|
||||
syncResult = 'connection ok'
|
||||
} catch (e) {
|
||||
syncResult = 'connection failed: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function disconnectSync() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
await wailsCall('SyncDisconnect')
|
||||
syncResult = 'disconnected'
|
||||
await loadSyncStatus()
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
|
||||
async function runSyncNow() {
|
||||
syncLoading = true
|
||||
syncResult = ''
|
||||
try {
|
||||
const r = await wailsCall('SyncNow')
|
||||
syncResult = 'pushed ' + r.pushed + ', pulled ' + r.pulled + ' (seq ' + r.serverSequence + ')'
|
||||
await loadSyncStatus()
|
||||
} catch (e) {
|
||||
syncResult = 'err: ' + String(e)
|
||||
}
|
||||
syncLoading = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app">
|
||||
@@ -675,7 +1004,7 @@
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if selectedSection}
|
||||
{#if selectedSection && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="nav-group">
|
||||
<div class="nav-label">Дела {#if nodes.length > 0}({nodes.length}){/if}</div>
|
||||
{#each nodes as node}
|
||||
@@ -688,7 +1017,14 @@
|
||||
</div>
|
||||
{/if}
|
||||
</nav>
|
||||
<div class="sidebar-footer"><span class="version">{version}</span></div>
|
||||
<div class="sidebar-footer">
|
||||
<button class="sidebar-sync-btn" on:click={openSettings} title="Настройки синхронизации">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
<span class="sync-dot" class:active={syncStatus?.configured}></span>
|
||||
<span class="sidebar-sync-label">Синхронизация</span>
|
||||
</button>
|
||||
<span class="version">{version}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main -->
|
||||
@@ -704,6 +1040,16 @@
|
||||
<span class="crumb placeholder">Выберите раздел или дело</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{#if syncStatus?.configured}
|
||||
<button class="header-sync-btn" on:click={runSyncNow} disabled={syncLoading} title="Синхронизировать">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||
{#if syncStatus.unpushedOps > 0}
|
||||
<span class="sync-badge">{syncStatus.unpushedOps}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
@@ -757,7 +1103,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
|
||||
Добавить файл
|
||||
</button>
|
||||
<button class="qa-btn" disabled title="Следующий этап">
|
||||
<button class="qa-btn" on:click={openCreateAction}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
|
||||
Добавить действие
|
||||
</button>
|
||||
@@ -832,7 +1178,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
{#if folderStack.length > 0}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }, ...folderStack]} on:navigate={(e) => {
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }, ...folderStack]} on:navigate={(e) => {
|
||||
const i = e.detail
|
||||
if (i === 0) {
|
||||
folderStack = []
|
||||
@@ -847,7 +1193,7 @@
|
||||
Back
|
||||
</button>
|
||||
{:else}
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Files' }]}/>
|
||||
<FileBreadcrumbs crumbs={[{ name: 'Файлы' }]}/>
|
||||
{/if}
|
||||
|
||||
{#if fileItems.length === 0}
|
||||
@@ -877,6 +1223,7 @@
|
||||
on:navigate={(e) => navigateToFolder(e.detail)}
|
||||
on:preview={(e) => openPreview(e.detail)}
|
||||
on:openExternal={(e) => wailsCall('OpenFile', e.detail)}
|
||||
on:showInFolder={(e) => wailsCall('OpenFolder', e.detail)}
|
||||
on:delete={(e) => deleteFile(e.detail)}
|
||||
on:rename={(e) => renameItem(e.detail.id)}
|
||||
on:duplicate={(e) => duplicateItem(e.detail)}
|
||||
@@ -884,6 +1231,7 @@
|
||||
on:copy={(e) => copyItem(e.detail)}
|
||||
on:selectOne={(e) => selectOne(e.detail)}
|
||||
on:toggleSelect={(e) => toggleSelection(e.detail)}
|
||||
on:rangeSelect={(e) => rangeSelect(e.detail)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -907,16 +1255,30 @@
|
||||
{/if}
|
||||
|
||||
{:else if activeTab === 'actions'}
|
||||
<div class="actions-tab">
|
||||
<div class="tab-toolbar">
|
||||
<button class="btn btn-primary" on:click={openCreateAction}>+ Добавить действие</button>
|
||||
</div>
|
||||
{#if actions.length === 0}
|
||||
<div class="empty-state"><p>Действий пока нет</p></div>
|
||||
{:else}
|
||||
{#each actions as action}
|
||||
<div class="action-card">
|
||||
<span>{action.title}</span><span class="action-type">{action.type}</span>
|
||||
<div class="action-info">
|
||||
<span class="action-title">{action.title}</span>
|
||||
<span class="action-type">{actionKindLabel(action.type)}</span>
|
||||
<span class="action-data">{action.data}</span>
|
||||
</div>
|
||||
<div class="action-btns">
|
||||
<button class="btn btn-sm" on:click={() => wailsCall('RunAction', action.id)}>Запустить</button>
|
||||
<button class="btn btn-sm btn-danger" on:click={() => deleteAction(action.id)}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'worklog'}
|
||||
<div class="worklog-tab">
|
||||
@@ -939,7 +1301,111 @@
|
||||
</div>
|
||||
|
||||
{:else if activeTab === 'activity'}
|
||||
<div class="empty-state"><p>Активность появится позже</p></div>
|
||||
<div class="activity-tab">
|
||||
{#if caseActivity.length === 0}
|
||||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||
{:else}
|
||||
<div class="activity-events">
|
||||
{#each caseActivity as ev}
|
||||
<div class="activity-event">
|
||||
<span class="activity-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="activity-event-title">{ev.title}</span>
|
||||
<span class="activity-event-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||
<span class="activity-event-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'today' && todayDashboard}
|
||||
<div class="today-dashboard">
|
||||
<div class="today-header">
|
||||
<h2>Сегодня</h2>
|
||||
<span class="today-date">{todayDashboard.date}</span>
|
||||
</div>
|
||||
{#if todayDashboard.summary}
|
||||
<div class="today-summary">
|
||||
{#if todayDashboard.summary.changedCases > 0}<span class="summary-chip">{todayDashboard.summary.changedCases} {pluralize(todayDashboard.summary.changedCases, 'дело', 'дела', 'дел')}</span>{/if}
|
||||
{#if todayDashboard.summary.notes > 0}<span class="summary-chip">{todayDashboard.summary.notes} {pluralize(todayDashboard.summary.notes, 'заметка', 'заметки', 'заметок')}</span>{/if}
|
||||
{#if todayDashboard.summary.files > 0}<span class="summary-chip">{todayDashboard.summary.files} {pluralize(todayDashboard.summary.files, 'файл', 'файла', 'файлов')}</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if todayDashboard.groups && todayDashboard.groups.length > 0}
|
||||
{#each todayDashboard.groups as group}
|
||||
<div class="today-case">
|
||||
<div class="today-case-header" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||
<span class="today-case-title">{group.nodeTitle}</span>
|
||||
<span class="today-case-type">{nodeKindLabel(group.nodeKind)}</span>
|
||||
{#if group.events}<span class="today-case-count">{group.events.length} {pluralize(group.events.length, 'событие', 'события', 'событий')}</span>{/if}
|
||||
<span class="today-case-time">{formatTime(group.lastActivityAt)}</span>
|
||||
</div>
|
||||
{#if group.events && group.events.length > 0}
|
||||
<div class="today-events">
|
||||
{#each group.events as ev}
|
||||
<div class="today-event" role="button" tabindex="0" on:click={() => openNodeById(group.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(group.nodeId)}>
|
||||
<span class="today-event-icon">{eventIcon(ev.eventType)}</span>
|
||||
<span class="today-event-title">{ev.title}</span>
|
||||
<span class="today-event-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-event-target">{ev.targetType}</span>{/if}
|
||||
<span class="today-event-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="today-events-empty">Изменён сегодня</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if todayDashboard.events && todayDashboard.events.length > 0}
|
||||
<div class="today-timeline">
|
||||
<h3>Лента за сегодня</h3>
|
||||
{#each todayDashboard.events as ev}
|
||||
<div class="timeline-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<span class="timeline-dot"></span>
|
||||
<span class="timeline-title">{ev.title}</span>
|
||||
<span class="timeline-type">{eventLabel(ev.eventType)}</span>
|
||||
<span class="timeline-time">{formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="today-empty">
|
||||
<p>Сегодня пока тихо</p>
|
||||
<p class="hint">Здесь появятся дела, заметки, файлы и действия, с которыми вы работали сегодня.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else if selectedSection === 'activity'}
|
||||
<div class="activity-feed">
|
||||
<div class="activity-feed-header">
|
||||
<h2>Активность</h2>
|
||||
</div>
|
||||
{#if activityFeed.length === 0}
|
||||
<div class="empty-state"><p>Активность пока не зафиксирована</p></div>
|
||||
{:else}
|
||||
<div class="activity-feed-events">
|
||||
{#each activityFeed as ev}
|
||||
<div class="activity-feed-event" role="button" tabindex="0" on:click={() => openNodeById(ev.nodeId)} on:keydown={(e) => e.key === 'Enter' && openNodeById(ev.nodeId)}>
|
||||
<span class="activity-feed-icon">{eventIcon(ev.eventType)}</span>
|
||||
<div class="activity-feed-body">
|
||||
<span class="activity-feed-title">{ev.title}</span>
|
||||
<div class="activity-feed-meta">
|
||||
<span class="activity-feed-type">{eventLabel(ev.eventType)}</span>
|
||||
{#if ev.targetType}<span class="activity-feed-target">{ev.targetType}</span>{/if}
|
||||
<span class="activity-feed-time">{formatDate(ev.createdAt)} {formatTime(ev.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -954,7 +1420,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !noteEditor && !selectedNode}
|
||||
{#if !noteEditor && !selectedNode && selectedSection !== 'today' && selectedSection !== 'inbox' && selectedSection !== 'activity'}
|
||||
<div class="fab" on:click={openCreateNode} title="Добавить дело">+</div>
|
||||
{/if}
|
||||
|
||||
@@ -970,11 +1436,22 @@
|
||||
<div class="form-group">
|
||||
<label>Раздел</label>
|
||||
<select bind:value={newNodeSection}>
|
||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox') as s}
|
||||
{#each sections.filter(s => s.id !== 'today' && s.id !== 'inbox' && s.id !== 'activity') as s}
|
||||
<option value={s.id}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if templates.length > 0}
|
||||
<div class="form-group">
|
||||
<label>Шаблон (опционально)</label>
|
||||
<select bind:value={newNodeTemplate}>
|
||||
<option value="">Без шаблона</option>
|
||||
{#each templates as t}
|
||||
<option value={t.name}>{t.name}{t.description ? ' — ' + t.description : ''}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitCreateNode}>Создать</button>
|
||||
<button class="btn" on:click={cancelCreateNode}>Отмена</button>
|
||||
@@ -983,6 +1460,37 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showCreateAction}
|
||||
<div class="modal-overlay" on:click|self={cancelCreateAction}>
|
||||
<div class="modal">
|
||||
<h3>Новое действие</h3>
|
||||
<div class="form-group">
|
||||
<label>Название</label>
|
||||
<input type="text" placeholder="Например: Открыть сайт" bind:value={newActionTitle}
|
||||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Тип</label>
|
||||
<select bind:value={newActionKind}>
|
||||
{#each actionKinds as k}
|
||||
<option value={k.id}>{k.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>{newActionKind === 'open_url' ? 'URL' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? 'Путь' : 'Команда'}</label>
|
||||
<input type="text" placeholder={newActionKind === 'open_url' ? 'https://example.com' : newActionKind === 'open_folder' || newActionKind === 'open_file' ? '/path/to/file' : 'команда'}
|
||||
bind:value={newActionData}
|
||||
on:keydown={(e) => e.key === 'Enter' && submitCreateAction()} />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitCreateAction}>Создать</button>
|
||||
<button class="btn" on:click={cancelCreateAction}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showImportDialog && importSummary}
|
||||
<div class="modal-overlay" on:click|self={cancelImport}>
|
||||
<div class="modal">
|
||||
@@ -1007,6 +1515,26 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showRename}
|
||||
<div class="modal-overlay" on:click|self={cancelRename}>
|
||||
<div class="modal">
|
||||
<h3>Переименовать</h3>
|
||||
<div class="form-group">
|
||||
<label>Новое имя</label>
|
||||
<input type="text" bind:value={renameValue}
|
||||
on:keydown={onRenameKeydown} />
|
||||
</div>
|
||||
{#if renameError}
|
||||
<div class="rename-error">{renameError}</div>
|
||||
{/if}
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-primary" on:click={submitRename}>Переименовать</button>
|
||||
<button class="btn" on:click={cancelRename}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showConfirm}
|
||||
<ConfirmModal
|
||||
title={confirmTitle}
|
||||
@@ -1017,6 +1545,85 @@
|
||||
on:cancel={handleCancel}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if showSettings}
|
||||
<div class="modal-overlay" on:click|self={closeSettings}>
|
||||
<div class="modal modal-sync">
|
||||
<h3>Настройки синхронизации</h3>
|
||||
{#if syncStatus}
|
||||
<div class="sync-status">
|
||||
<div class="sync-row">
|
||||
<span class="sync-label">Статус</span>
|
||||
<span class="sync-value">
|
||||
{#if syncStatus.revoked}
|
||||
<span style="color:#ff6b6b">Отозвано</span>
|
||||
{:else if syncStatus.connected}
|
||||
<span style="color:#34d399">Подключено</span>
|
||||
{:else if syncStatus.configured}
|
||||
<span style="color:#f59e0b">Не подключено</span>
|
||||
{:else}
|
||||
<span style="color:#666">Отключена</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if syncStatus.serverUrl}
|
||||
<div class="sync-row"><span class="sync-label">Сервер</span><span class="sync-value mono">{syncStatus.serverUrl}</span></div>
|
||||
{/if}
|
||||
{#if syncStatus.deviceName}
|
||||
<div class="sync-row"><span class="sync-label">Устройство</span><span class="sync-value">{syncStatus.deviceName}</span></div>
|
||||
{/if}
|
||||
{#if syncStatus.deviceId && !syncStatus.deviceName}
|
||||
<div class="sync-row"><span class="sync-label">ID устройства</span><span class="sync-value mono">{syncStatus.deviceId}</span></div>
|
||||
{/if}
|
||||
<div class="sync-row"><span class="sync-label">Неотправлено</span><span class="sync-value">{syncStatus.unpushedOps}</span></div>
|
||||
{#if syncStatus.lastSyncAt}
|
||||
<div class="sync-row"><span class="sync-label">Последняя синх.</span><span class="sync-value">{syncStatus.lastSyncAt}</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if syncStatus?.configured}
|
||||
<div class="sync-connected-actions">
|
||||
<button class="btn" on:click={runSyncNow} disabled={syncLoading}>Синхронизировать</button>
|
||||
<button class="btn btn-danger" on:click={disconnectSync} disabled={syncLoading}>Отключиться</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="form-group">
|
||||
<label>URL сервера</label>
|
||||
<input type="text" placeholder="https://example.com:47732" bind:value={syncServerUrl} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Логин</label>
|
||||
<input type="text" placeholder="username" bind:value={syncUsername} />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Пароль</label>
|
||||
<input type="password" placeholder="password" bind:value={syncPassword} />
|
||||
</div>
|
||||
<div class="modal-actions" style="margin-top:12px">
|
||||
<button class="btn" on:click={testConnection} disabled={syncLoading || !syncServerUrl}>Проверить</button>
|
||||
<button class="btn btn-primary" on:click={saveSyncConfig} disabled={syncLoading}>Подключиться</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div style="margin-top:16px;padding-top:16px;border-top:1px solid #2a2a3c">
|
||||
<div class="form-group">
|
||||
<label>Автосинхронизация (мин, 0 = отключено)</label>
|
||||
<input type="number" placeholder="0" bind:value={syncInterval} min="0" />
|
||||
</div>
|
||||
<button class="btn" on:click={saveSyncInterval} disabled={syncLoading}>Сохранить интервал</button>
|
||||
</div>
|
||||
|
||||
{#if syncResult}
|
||||
<div class="sync-result" style="margin-top:8px">{syncResult}</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-actions" style="margin-top:12px">
|
||||
<button class="btn" on:click={closeSettings}>Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1036,12 +1643,18 @@
|
||||
.nav-item:hover { background: #222233; }
|
||||
.nav-item.selected { background: #2a2a4a; color: #fff; font-weight: 500; }
|
||||
.nav-empty { padding: 8px 20px; color: #555; font-size: 12px; }
|
||||
.sidebar-footer { padding: 12px 20px; border-top: 1px solid #2a2a3c; flex-shrink: 0; }
|
||||
.version { font-size: 11px; color: #555; }
|
||||
.sidebar-footer { padding: 8px 12px; border-top: 1px solid #2a2a3c; flex-shrink: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.version { font-size: 11px; color: #555; text-align: center; }
|
||||
|
||||
/* Main */
|
||||
.main { flex: 1; display: flex; flex-direction: column; height: 100vh; min-width: 0; overflow: hidden; background: #13131f; }
|
||||
.header { padding: 12px 24px; border-bottom: 1px solid #2a2a3c; display: flex; align-items: center; flex-shrink: 0; min-height: 48px; }
|
||||
.header-left { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.header-right { display: flex; align-items: center; gap: 8px; }
|
||||
.header-sync-btn { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 6px 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; color: #c0c0f0; font-family: inherit; font-size: 13px; position: relative; }
|
||||
.header-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
|
||||
.header-sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.sync-badge { background: #6366f1; color: #fff; font-size: 10px; border-radius: 50%; width: 16px; height: 16px; display: inline-flex; align-items: center; justify-content: center; position: absolute; top: -6px; right: -6px; }
|
||||
.crumb { font-size: 14px; font-weight: 500; }
|
||||
.crumb.placeholder { color: #666; }
|
||||
.crumb-type { font-size: 11px; color: #555; background: #1e1e2e; padding: 2px 8px; border-radius: 10px; margin-left: 8px; }
|
||||
@@ -1107,8 +1720,15 @@
|
||||
.wl-meta { font-size: 11px; color: #555; margin-top: 2px; }
|
||||
|
||||
/* Actions */
|
||||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
|
||||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||
.actions-tab { padding: 24px; }
|
||||
.action-card { background: #1a1a28; padding: 12px 16px; border-radius: 8px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
|
||||
.action-info { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
|
||||
.action-title { font-weight: 500; }
|
||||
.action-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; white-space: nowrap; }
|
||||
.action-data { font-size: 11px; color: #555; font-family: 'SF Mono', 'Fira Code', monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 200px; }
|
||||
.action-btns { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.action-btns .btn-danger { color: #ff6b6b; border-color: #4a2222; padding: 4px 8px; }
|
||||
.action-btns .btn-danger:hover { background: #3a2222; }
|
||||
|
||||
/* Empty states */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 24px; text-align: center; }
|
||||
@@ -1159,4 +1779,81 @@
|
||||
.import-summary { margin-bottom: 16px; }
|
||||
.summary-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; border-bottom: 1px solid #2a2a3c; }
|
||||
.summary-warn { margin-top: 8px; padding: 8px 12px; background: #3a2a22; border-radius: 6px; color: #ffaa66; font-size: 13px; }
|
||||
|
||||
.rename-error { color: #ff6b6b; font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* Today Dashboard */
|
||||
.today-dashboard { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.today-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 16px; }
|
||||
.today-header h2 { font-size: 24px; }
|
||||
.today-date { font-size: 13px; color: #666; }
|
||||
.today-summary { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
.summary-chip { font-size: 12px; color: #b0b0c0; background: #1a1a28; border: 1px solid #2a2a3c; padding: 4px 12px; border-radius: 16px; }
|
||||
.today-case { background: #1a1a28; border: 1px solid #2a2a3c; border-radius: 8px; margin-bottom: 12px; overflow: hidden; }
|
||||
.today-case-header { padding: 12px 16px; display: flex; align-items: center; gap: 8px; border-bottom: 1px solid #2a2a3c; cursor: pointer; }
|
||||
.today-case-header:hover { background: #1e1e30; }
|
||||
.today-case-title { font-weight: 500; }
|
||||
.today-case-type { font-size: 11px; color: #888; background: #222233; padding: 2px 8px; border-radius: 10px; }
|
||||
.today-case-count { font-size: 11px; color: #6366f1; margin-left: 4px; }
|
||||
.today-case-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||
.today-events { padding: 8px 16px; }
|
||||
.today-event { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||
.today-event:hover { color: #e4e4ef; }
|
||||
.today-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; }
|
||||
.today-event-title { flex: 1; }
|
||||
.today-event-type { font-size: 11px; color: #666; }
|
||||
.today-event-time { font-size: 11px; color: #555; margin-left: auto; }
|
||||
.today-events-empty { padding: 8px 16px; font-size: 13px; color: #666; font-style: italic; }
|
||||
.today-empty { padding: 48px 24px; text-align: center; }
|
||||
.today-empty p { color: #666; font-size: 14px; margin: 0; }
|
||||
.today-empty .hint { font-size: 12px; color: #555; margin-top: 8px; }
|
||||
.today-timeline { margin-top: 24px; }
|
||||
.today-timeline h3 { font-size: 13px; color: #666; text-transform: uppercase; margin-bottom: 12px; }
|
||||
.timeline-event { display: flex; align-items: center; gap: 10px; padding: 6px 0; font-size: 13px; color: #b0b0c0; border-left: 2px solid #2a2a3c; padding-left: 16px; margin-left: 4px; cursor: pointer; }
|
||||
.timeline-event:hover { color: #e4e4ef; }
|
||||
.timeline-dot { width: 6px; height: 6px; border-radius: 50%; background: #6366f1; margin-left: -19px; flex-shrink: 0; }
|
||||
.timeline-title { flex: 1; }
|
||||
.timeline-type { font-size: 11px; color: #666; }
|
||||
.timeline-time { font-size: 11px; color: #555; }
|
||||
|
||||
/* Activity tab (per-case) */
|
||||
.activity-tab { padding: 24px; }
|
||||
.activity-events { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-event { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 13px; color: #b0b0c0; cursor: pointer; }
|
||||
.activity-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||
.activity-event-icon { width: 18px; text-align: center; color: #6366f1; font-size: 13px; flex-shrink: 0; }
|
||||
.activity-event-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-event-type { font-size: 11px; color: #666; flex-shrink: 0; }
|
||||
.activity-event-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; flex-shrink: 0; }
|
||||
.activity-event-time { font-size: 11px; color: #555; margin-left: 8px; flex-shrink: 0; }
|
||||
|
||||
/* Activity feed (global section) */
|
||||
.activity-feed { padding: 24px; overflow-y: auto; flex: 1; }
|
||||
.activity-feed-header { margin-bottom: 20px; }
|
||||
.activity-feed-header h2 { font-size: 24px; }
|
||||
.activity-feed-events { display: flex; flex-direction: column; gap: 2px; }
|
||||
.activity-feed-event { display: flex; align-items: flex-start; gap: 10px; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
|
||||
.activity-feed-event:hover { background: #1a1a28; color: #e4e4ef; }
|
||||
.activity-feed-icon { width: 20px; text-align: center; color: #6366f1; font-size: 14px; flex-shrink: 0; margin-top: 1px; }
|
||||
.activity-feed-body { flex: 1; min-width: 0; }
|
||||
.activity-feed-title { font-size: 14px; color: #e4e4ef; }
|
||||
.activity-feed-meta { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
|
||||
.activity-feed-type { font-size: 11px; color: #666; }
|
||||
.activity-feed-target { font-size: 10px; color: #555; background: #1e1e2e; padding: 1px 6px; border-radius: 8px; }
|
||||
.activity-feed-time { font-size: 11px; color: #555; }
|
||||
|
||||
/* Sync */
|
||||
.sidebar-sync-btn { background: #1e1e38; border: 1px solid #6366f1; border-radius: 8px; padding: 8px 12px; cursor: pointer; width: 100%; display: flex; align-items: center; gap: 8px; color: #c0c0f0; font-family: inherit; font-size: 13px; }
|
||||
.sidebar-sync-btn:hover { background: #2a2a50; color: #e4e4ef; border-color: #818cf8; }
|
||||
.sidebar-sync-label { flex: 1; text-align: left; }
|
||||
.sync-dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a4a; flex-shrink: 0; }
|
||||
.sync-dot.active { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
|
||||
.modal-sync { width: 460px; }
|
||||
.sync-status { background: #13131f; border-radius: 8px; padding: 12px; margin-bottom: 16px; }
|
||||
.sync-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 13px; }
|
||||
.sync-label { color: #666; }
|
||||
.sync-value { color: #e4e4ef; }
|
||||
.sync-value.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||
.sync-result { font-size: 12px; color: #6366f1; padding: 4px 0; }
|
||||
.sync-connected-actions { display: flex; gap: 8px; margin-bottom: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import FileIcon from './lib/FileIcon.svelte'
|
||||
import { formatFileSize, formatMimeType, getFileKind } from './lib/fileUtils.js'
|
||||
import { formatFileSize, formatFileType, getFileKind } from './lib/fileUtils.js'
|
||||
|
||||
export let item
|
||||
export let selected = false
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
const kind = getFileKind(item)
|
||||
const isFolder = item.type === 'folder'
|
||||
const fileType = formatFileType(item)
|
||||
let menuOpen = false
|
||||
let clickTimer = null
|
||||
|
||||
@@ -25,7 +26,7 @@
|
||||
if (clickTimer) {
|
||||
clearTimeout(clickTimer)
|
||||
clickTimer = null
|
||||
// Double click
|
||||
// Double click → открыть
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
@@ -34,37 +35,23 @@
|
||||
} else {
|
||||
clickTimer = setTimeout(() => {
|
||||
clickTimer = null
|
||||
// Single click: select
|
||||
if (selected) {
|
||||
// Already selected: navigate/preview
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
} else {
|
||||
// Single click → выделить
|
||||
dispatch('selectOne', item.id)
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Enter') {
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenExternal() {
|
||||
dispatch('openExternal', item.fileId)
|
||||
}
|
||||
|
||||
function handleShowInFolder() {
|
||||
dispatch('showInFolder', item.id)
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
menuOpen = false
|
||||
dispatch('delete', { id: item.id, type: item.type })
|
||||
}
|
||||
|
||||
@@ -88,6 +75,15 @@
|
||||
dispatch('copy', item.id)
|
||||
}
|
||||
|
||||
function handleOpen() {
|
||||
menuOpen = false
|
||||
if (isFolder) {
|
||||
dispatch('navigate', item.id)
|
||||
} else {
|
||||
dispatch('preview', item)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen = !menuOpen
|
||||
}
|
||||
@@ -107,48 +103,48 @@
|
||||
function handleDrop(e) {
|
||||
if (onDrop && isFolder) onDrop(e, item.id)
|
||||
}
|
||||
|
||||
function oncontextmenu(e) {
|
||||
e.preventDefault()
|
||||
menuOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeMenu}/>
|
||||
|
||||
<div class="file-row"
|
||||
class:file-row--selected={selected}
|
||||
class:file-row--dragover={false}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleKeydown}
|
||||
on:contextmenu={oncontextmenu}
|
||||
on:dragstart={handleDragStart}
|
||||
on:dragover={handleDragOver}
|
||||
on:drop={handleDrop}
|
||||
aria-label={isFolder ? `Folder ${item.name}` : `File ${item.name}`}>
|
||||
aria-label={isFolder ? `Папка ${item.name}` : `Файл ${item.name}`}>
|
||||
<div class="file-row-icon">
|
||||
<FileIcon {kind} size={22}/>
|
||||
</div>
|
||||
<div class="file-row-body">
|
||||
<div class="file-row-name" title={item.name}>{item.name}</div>
|
||||
<div class="file-row-meta">
|
||||
{#if isFolder}
|
||||
<span>Folder</span>
|
||||
{:else}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{#if item.mime}
|
||||
<span>{fileType}</span>
|
||||
{#if !isFolder}
|
||||
<span class="meta-sep">·</span>
|
||||
<span>{formatMimeType(item.mime)}</span>
|
||||
{/if}
|
||||
<span>{formatFileSize(item.size)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-row-actions">
|
||||
{#if !isFolder}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Preview" aria-label="Preview">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('preview', item)} title="Предпросмотр" aria-label="Предпросмотр">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<button class="action-btn" on:click|stopPropagation={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -156,21 +152,21 @@
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Open folder" aria-label="Open folder">
|
||||
<button class="action-btn" on:click|stopPropagation={() => dispatch('navigate', item.id)} title="Открыть папку" aria-label="Открыть папку">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
|
||||
<line x1="9" y1="14" x2="15" y2="14"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="More actions" aria-label="More actions" aria-expanded={menuOpen}>
|
||||
<button class="action-btn" on:click|stopPropagation={toggleMenu} title="Ещё" aria-label="Ещё" aria-expanded={menuOpen}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="5" r="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<circle cx="12" cy="19" r="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Delete" aria-label="Delete">
|
||||
<button class="action-btn action-btn-danger" on:click|stopPropagation={handleDelete} title="Удалить" aria-label="Удалить">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
@@ -183,10 +179,42 @@
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div class="menu-backdrop" on:click|stopPropagation={closeMenu} role="presentation"></div>
|
||||
<div class="menu" on:click|stopPropagation role="menu">
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">Rename</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">Duplicate</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">Cut</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">Copy</button>
|
||||
<button class="menu-item" on:click={handleOpen} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
Открыть
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleOpenExternal} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
Открыть во внешней программе
|
||||
</button>
|
||||
{#if isFolder}
|
||||
<button class="menu-item" on:click={handleShowInFolder} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
Показать в проводнике
|
||||
</button>
|
||||
{/if}
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item" on:click={handleRename} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Переименовать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleDuplicate} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Дублировать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCut} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><line x1="20" y1="4" x2="8.12" y2="15.88"/><line x1="8.12" y1="8.12" x2="20" y2="20"/></svg>
|
||||
Вырезать
|
||||
</button>
|
||||
<button class="menu-item" on:click={handleCopy} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Копировать
|
||||
</button>
|
||||
<div class="menu-sep"></div>
|
||||
<button class="menu-item menu-item-danger" on:click={handleDelete} role="menuitem">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -201,6 +229,7 @@
|
||||
transition: background 0.12s;
|
||||
min-height: 52px;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.file-row:hover {
|
||||
@@ -314,17 +343,19 @@
|
||||
margin-top: 4px;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3c;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
z-index: 100;
|
||||
min-width: 140px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
||||
min-width: 220px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #ccc;
|
||||
@@ -337,10 +368,25 @@
|
||||
|
||||
.menu-item:hover {
|
||||
background: #2a2a3c;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.menu-item-danger {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.menu-item-danger:hover {
|
||||
background: #3a2222;
|
||||
}
|
||||
|
||||
.menu-item:focus-visible {
|
||||
outline: 2px solid #5588ff;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.menu-sep {
|
||||
height: 1px;
|
||||
background: #2a2a3c;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as App from '../wailsjs/go/main/App.js'
|
||||
|
||||
// Re-export all methods
|
||||
export const listSections = () => App.ListSections()
|
||||
export const listTodayView = () => App.ListTodayView()
|
||||
export const listNodesBySection = (section) => App.ListNodesBySection(section)
|
||||
export const listChildren = (parentID) => App.ListChildren(parentID)
|
||||
export const getNodeDetail = (id) => App.GetNodeDetail(id)
|
||||
@@ -31,6 +32,7 @@ export const duplicateNode = (nodeID) => App.DuplicateNode(nodeID)
|
||||
export const renameNode = (nodeID, newTitle) => App.RenameNode(nodeID, newTitle)
|
||||
export const moveNode = (nodeID, newParentID) => App.MoveNode(nodeID, newParentID)
|
||||
export const openFolder = (nodeID) => App.OpenFolder(nodeID)
|
||||
export const validateName = (name) => App.ValidateName(name)
|
||||
|
||||
export const listActions = (nodeID) => App.ListActions(nodeID)
|
||||
export const runAction = (id) => App.RunAction(id)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
</div>
|
||||
<div class="preview-meta">{formatFileSize(item.size)} · {formatMimeType(item.mime)}</div>
|
||||
<div class="preview-actions">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Open in external program" aria-label="Open externally">
|
||||
<button class="action-btn" on:click={handleOpenExternal} title="Открыть во внешней программе" aria-label="Открыть внешне">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
||||
<polyline points="15 3 21 3 21 9"/>
|
||||
@@ -60,11 +60,11 @@
|
||||
</header>
|
||||
<div class="preview-body">
|
||||
{#if loading}
|
||||
<div class="preview-status"><p>Loading preview...</p></div>
|
||||
<div class="preview-status"><p>Загрузка...</p></div>
|
||||
{:else if error}
|
||||
<div class="preview-status">
|
||||
<p>{error}</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{:else if showImage && content}
|
||||
<div class="preview-image-container">
|
||||
@@ -79,14 +79,14 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>PDF preview is not available in this environment.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр PDF недоступен.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="preview-status">
|
||||
<p>Preview is not available for this file type.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Open in external program</button>
|
||||
<p>Предпросмотр недоступен для этого типа файлов.</p>
|
||||
<button class="btn btn-sm" on:click={handleOpenExternal}>Открыть во внешней программе</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -8,43 +8,53 @@ export function formatFileSize(bytes) {
|
||||
}
|
||||
|
||||
const mimeLabels = {
|
||||
'image/jpeg': 'JPEG image',
|
||||
'image/png': 'PNG image',
|
||||
'image/gif': 'GIF image',
|
||||
'image/webp': 'WebP image',
|
||||
'image/svg+xml': 'SVG image',
|
||||
'image/bmp': 'BMP image',
|
||||
'image/tiff': 'TIFF image',
|
||||
'image/avif': 'AVIF image',
|
||||
'application/pdf': 'PDF document',
|
||||
'application/msword': 'Word document',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word document',
|
||||
'application/vnd.ms-excel': 'Excel spreadsheet',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel spreadsheet',
|
||||
'application/vnd.ms-powerpoint': 'PowerPoint presentation',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint presentation',
|
||||
'application/zip': 'ZIP archive',
|
||||
'application/gzip': 'GZIP archive',
|
||||
'application/x-tar': 'TAR archive',
|
||||
'application/x-7z-compressed': '7z archive',
|
||||
'application/x-rar-compressed': 'RAR archive',
|
||||
'text/plain': 'Text file',
|
||||
'text/html': 'HTML file',
|
||||
'text/css': 'CSS file',
|
||||
'text/javascript': 'JavaScript file',
|
||||
'application/json': 'JSON file',
|
||||
'application/xml': 'XML file',
|
||||
'application/x-yaml': 'YAML file',
|
||||
'application/octet-stream': 'Binary file',
|
||||
'application/x-msdos-program': 'Executable',
|
||||
'inode/directory': 'Folder',
|
||||
'image/jpeg': 'Изображение JPEG',
|
||||
'image/png': 'Изображение PNG',
|
||||
'image/gif': 'Изображение GIF',
|
||||
'image/webp': 'Изображение WebP',
|
||||
'image/svg+xml': 'Изображение SVG',
|
||||
'image/bmp': 'Изображение BMP',
|
||||
'image/tiff': 'Изображение TIFF',
|
||||
'image/avif': 'Изображение AVIF',
|
||||
'application/pdf': 'PDF документ',
|
||||
'application/msword': 'Документ Word',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Документ Word',
|
||||
'application/vnd.ms-excel': 'Таблица Excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Таблица Excel',
|
||||
'application/vnd.ms-powerpoint': 'Презентация PowerPoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'Презентация PowerPoint',
|
||||
'application/zip': 'ZIP архив',
|
||||
'application/gzip': 'GZIP архив',
|
||||
'application/x-tar': 'TAR архив',
|
||||
'application/x-7z-compressed': '7z архив',
|
||||
'application/x-rar-compressed': 'RAR архив',
|
||||
'text/plain': 'Текстовый файл',
|
||||
'text/html': 'HTML файл',
|
||||
'text/css': 'CSS файл',
|
||||
'text/javascript': 'JavaScript файл',
|
||||
'application/json': 'JSON файл',
|
||||
'application/xml': 'XML файл',
|
||||
'application/x-yaml': 'YAML файл',
|
||||
'application/octet-stream': 'Бинарный файл',
|
||||
'application/x-msdos-program': 'Исполняемый файл',
|
||||
'inode/directory': 'Папка',
|
||||
}
|
||||
|
||||
export function formatMimeType(mime) {
|
||||
if (!mime) return 'Unknown'
|
||||
if (!mime) return 'Неизвестно'
|
||||
return mimeLabels[mime] || mime
|
||||
}
|
||||
|
||||
export function formatFileType(item) {
|
||||
if (item.type === 'folder') return 'Папка'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
if (mimeLabels[mime]) return mimeLabels[mime]
|
||||
const name = (item.name || '').toLowerCase()
|
||||
const ext = name.split('.').pop()
|
||||
if (ext) return ext.toUpperCase()
|
||||
return 'Файл'
|
||||
}
|
||||
|
||||
export function getFileKind(item) {
|
||||
if (item.type === 'folder') return 'folder'
|
||||
const mime = (item.mime || '').toLowerCase()
|
||||
|
||||
@@ -6,6 +6,10 @@ export function ListSections() {
|
||||
return window['go']['main']['App']['ListSections']();
|
||||
}
|
||||
|
||||
export function ListTodayView() {
|
||||
return window['go']['main']['App']['ListTodayView']();
|
||||
}
|
||||
|
||||
export function ListNodesBySection(arg1) {
|
||||
return window['go']['main']['App']['ListNodesBySection'](arg1);
|
||||
}
|
||||
@@ -105,3 +109,19 @@ export function OpenFolder(arg1) {
|
||||
export function VerstakVersion() {
|
||||
return window['go']['main']['App']['VerstakVersion']();
|
||||
}
|
||||
|
||||
export function ListActivityFeed(arg1, arg2) {
|
||||
return window['go']['main']['App']['ListActivityFeed'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function ListActivityByNode(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['ListActivityByNode'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function CountActivityByNode(arg1) {
|
||||
return window['go']['main']['App']['CountActivityByNode'](arg1);
|
||||
}
|
||||
|
||||
export function CreateEmptyFile(arg1, arg2) {
|
||||
return window['go']['main']['App']['CreateEmptyFile'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+120
-18
@@ -60,6 +60,59 @@ func (s *Service) DB() *storage.DB {
|
||||
return s.db
|
||||
}
|
||||
|
||||
// --- security helpers ---
|
||||
|
||||
// ValidateName is an exported wrapper for validateName.
|
||||
func ValidateName(name string) error {
|
||||
return validateName(name)
|
||||
}
|
||||
|
||||
// validateName rejects filenames with path separators, relative components,
|
||||
// and other dangerous patterns.
|
||||
func validateName(name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "\\") {
|
||||
return fmt.Errorf("name must not contain path separators")
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
return fmt.Errorf("name must not contain '..'")
|
||||
}
|
||||
if strings.Contains(name, "\x00") {
|
||||
return fmt.Errorf("name must not contain null bytes")
|
||||
}
|
||||
if len(name) > 255 {
|
||||
return fmt.Errorf("name too long (max 255)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultPath resolves a relative vault path and checks it stays within jail.
|
||||
func (s *Service) vaultPath(rel string) (string, error) {
|
||||
abs := filepath.Join(s.vaultRoot, rel)
|
||||
cleaned := filepath.Clean(abs)
|
||||
if !strings.HasPrefix(cleaned, filepath.Clean(s.vaultRoot)) {
|
||||
return "", fmt.Errorf("path escapes vault root")
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// absPathSafe resolves an absolute path and checks jail if it's under vault.
|
||||
// For "external" mode records the path stored may be an absolute external path.
|
||||
// This function only checks path safety — it does not enforce that external
|
||||
// files must be inside the vault.
|
||||
func (s *Service) absPathSafe(rec *Record) (string, error) {
|
||||
if rec.StorageMode == "vault" {
|
||||
return s.vaultPath(rec.Path)
|
||||
}
|
||||
abs, err := filepath.Abs(rec.Path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("abs: %w", err)
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
// --- public operations ---
|
||||
|
||||
// AddExternal registers an external file (absolute path) without copying.
|
||||
@@ -84,6 +137,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
||||
}
|
||||
|
||||
destDir := filepath.Join(s.vaultRoot, "spaces", nodeSlug)
|
||||
if _, err := s.vaultPath(filepath.Join("spaces", nodeSlug)); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o750); err != nil {
|
||||
return nil, fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
@@ -105,6 +161,9 @@ func (s *Service) CopyIntoVault(nodeID, absPath, nodeSlug string) (*Record, erro
|
||||
}
|
||||
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
return s.insertRecord(nodeID, filename, relPath, "vault", info.Size(), hash)
|
||||
}
|
||||
|
||||
@@ -149,12 +208,19 @@ func (s *Service) DeleteToTrash(id string) error {
|
||||
return err
|
||||
}
|
||||
if rec.StorageMode == "vault" {
|
||||
src := filepath.Join(s.vaultRoot, rec.Path)
|
||||
src, err := s.vaultPath(rec.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(s.vaultRoot, ".verstak", "trash")
|
||||
if err := os.MkdirAll(trashDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
dest := filepath.Join(trashDir, rec.ID+"_"+rec.Filename)
|
||||
// verify trash is inside vault
|
||||
if _, err := s.vaultPath(filepath.Join(".verstak", "trash", rec.ID+"_"+rec.Filename)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(src, dest); err != nil {
|
||||
return fmt.Errorf("move to trash: %w", err)
|
||||
}
|
||||
@@ -169,11 +235,9 @@ func (s *Service) Open(id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return openWithSystem(abs)
|
||||
}
|
||||
@@ -190,11 +254,9 @@ func (s *Service) ReadText(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
@@ -212,11 +274,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
||||
if rec.Size > maxPreviewSize {
|
||||
return "", fmt.Errorf("file too large for preview (%d bytes)", rec.Size)
|
||||
}
|
||||
var abs string
|
||||
if rec.StorageMode == "vault" {
|
||||
abs = filepath.Join(s.vaultRoot, rec.Path)
|
||||
} else {
|
||||
abs = rec.Path
|
||||
abs, err := s.absPathSafe(rec)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
@@ -231,6 +291,9 @@ func (s *Service) ReadBase64(id string) (string, error) {
|
||||
|
||||
// CreateEmptyFile creates a file node and an empty vault file.
|
||||
func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error) {
|
||||
if err := validateName(filename); err != nil {
|
||||
return nil, fmt.Errorf("invalid filename: %w", err)
|
||||
}
|
||||
filename = s.uniqueTitle(parentID, filename)
|
||||
node, err := s.nodes.Create(parentID, nodes.TypeFile, filename, "")
|
||||
if err != nil {
|
||||
@@ -247,6 +310,10 @@ func (s *Service) CreateEmptyFile(parentID, filename string) (*nodes.Node, error
|
||||
}
|
||||
f.Close()
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dest)
|
||||
// Verify dest is inside vault
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, filename, relPath, "vault", 0, "")
|
||||
return node, err
|
||||
}
|
||||
@@ -261,7 +328,7 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if original.ParentID != nil {
|
||||
parentID = *original.ParentID
|
||||
}
|
||||
newName := s.uniqueTitle(parentID, original.Title)
|
||||
newName := s.copyTitle(parentID, original.Title)
|
||||
node, err := s.nodes.Create(parentID, original.Type, newName, original.Section)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -271,7 +338,10 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
if len(records) > 0 {
|
||||
src := &records[0]
|
||||
if src.StorageMode == "vault" {
|
||||
srcPath := filepath.Join(s.vaultRoot, src.Path)
|
||||
srcPath, err := s.vaultPath(src.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := filepath.Join(s.vaultRoot, "spaces", node.Slug)
|
||||
os.MkdirAll(dir, 0o750)
|
||||
dst := filepath.Join(dir, newName)
|
||||
@@ -280,6 +350,9 @@ func (s *Service) Duplicate(nodeID string) (*nodes.Node, error) {
|
||||
return nil, fmt.Errorf("copy file: %w", err)
|
||||
}
|
||||
relPath, _ := filepath.Rel(s.vaultRoot, dst)
|
||||
if _, err := s.vaultPath(relPath); err != nil {
|
||||
return nil, fmt.Errorf("path safety: %w", err)
|
||||
}
|
||||
_, err = s.insertRecord(node.ID, newName, relPath, "vault", src.Size, hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -452,6 +525,35 @@ func (s *Service) uniqueTitle(parentID, desired string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// copyTitle generates a unique "Name (copy).ext" style name for duplicates.
|
||||
// For files with extensions: "photo.jpg" → "photo (copy).jpg", "photo (copy 2).jpg"
|
||||
// For folders: "Docs" → "Docs (copy)", "Docs (copy 2)"
|
||||
func (s *Service) copyTitle(parentID, desired string) string {
|
||||
children, _ := s.nodes.ListChildren(parentID, false)
|
||||
used := make(map[string]bool, len(children))
|
||||
for i := range children {
|
||||
used[children[i].Title] = true
|
||||
}
|
||||
|
||||
ext := filepath.Ext(desired)
|
||||
base := strings.TrimSuffix(desired, ext)
|
||||
copyName := base + " (copy)" + ext
|
||||
if !used[copyName] {
|
||||
return copyName
|
||||
}
|
||||
for n := 2; ; n++ {
|
||||
candidate := fmt.Sprintf("%s (copy %d)%s", base, n, ext)
|
||||
if !used[candidate] {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UniqueTitleCopy returns a copy-style unique name for use in conflict resolution.
|
||||
func (s *Service) UniqueTitleCopy(parentID, desired string) string {
|
||||
return s.copyTitle(parentID, desired)
|
||||
}
|
||||
|
||||
// --- implementation details ---
|
||||
|
||||
func (s *Service) insertRecord(nodeID, filename, path, mode string, size int64, sha string) (*Record, error) {
|
||||
|
||||
@@ -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:
|
||||
//
|
||||
|
||||
@@ -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
|
||||
}
|
||||
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