feat: settings window polish, sync widget fix, dark form controls
- Fix: settings overlay uses on:click|self so sidebar clicks don't close it - Fix: openSettings(section) supports opening at specific tab - Fix: 'Настроить' opens Settings → Синхронизация instead of Общие - Style: dark theme select with custom arrow, global :global() CSS - Style: settings cards, section descriptions, button/layout polish - Style: settings gear buttons (icon-button pattern, 32px, soft hover) - Style: settings sidebar with disabled stubs, consistent icons - i18n: add generalDesc, workspaceDesc, appearance, localization keys
This commit is contained in:
+28
-11
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
@@ -26,6 +27,9 @@ import (
|
||||
// App is the Wails v2 application adapter. It wraps core services.
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
mu sync.RWMutex
|
||||
vaultOpen bool
|
||||
|
||||
db *storage.DB
|
||||
nodes *nodes.Repository
|
||||
templates *templates.Registry
|
||||
@@ -48,33 +52,45 @@ func (a *App) startup(ctx context.Context) {
|
||||
wailsruntime.EventsEmit(ctx, "files-dropped", paths)
|
||||
}
|
||||
})
|
||||
go a.autoSyncLoop()
|
||||
}
|
||||
|
||||
func (a *App) autoSyncLoop() {
|
||||
// Wait for vault to be ready
|
||||
time.Sleep(5 * time.Second)
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
|
||||
const checkInterval = 60 * time.Second
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
defer ticker.Stop()
|
||||
log.Printf("[autosync] started, vault=%s", a.vault)
|
||||
log.Printf("[autosync] started")
|
||||
var lastSync time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
serverURL := ""
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err == nil {
|
||||
serverURL = cfg.Sync.ServerURL
|
||||
if !a.IsReady() {
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
vaultPath := a.vault
|
||||
a.mu.RUnlock()
|
||||
|
||||
serverURL, _, _, _, _ := a.sync.GetState()
|
||||
if serverURL == "" {
|
||||
sURL, _, _, _, _ := a.sync.GetState()
|
||||
serverURL = sURL
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && appCfg.Vault.Sync.ServerURL != "" {
|
||||
serverURL = appCfg.Vault.Sync.ServerURL
|
||||
}
|
||||
}
|
||||
if serverURL == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
interval := 0
|
||||
if cfg != nil {
|
||||
interval = cfg.Sync.SyncInterval
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil {
|
||||
interval = appCfg.Vault.Sync.SyncInterval
|
||||
}
|
||||
if interval <= 0 {
|
||||
continue
|
||||
@@ -82,7 +98,8 @@ func (a *App) autoSyncLoop() {
|
||||
if !lastSync.IsZero() && time.Since(lastSync) < time.Duration(interval)*time.Minute {
|
||||
continue
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
|
||||
deviceToken := config.LoadDeviceToken(vaultPath)
|
||||
if deviceToken == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/vault"
|
||||
"verstak/internal/core/worklog"
|
||||
)
|
||||
|
||||
// StartupStatus describes the application startup state.
|
||||
type StartupStatus struct {
|
||||
Status string `json:"status"` // "first_run", "recovery", "ready"
|
||||
VaultPath string `json:"vaultPath"` // configured or default vault path
|
||||
VaultExists bool `json:"vaultExists"` // whether vault.db exists at the path
|
||||
DefaultPath string `json:"defaultPath"` // default vault path suggestion
|
||||
Error string `json:"error,omitempty"`
|
||||
AppConfig *config.AppConfig `json:"appConfig,omitempty"`
|
||||
}
|
||||
|
||||
// GetStartupStatus checks the global config and vault state.
|
||||
func (a *App) GetStartupStatus() (*StartupStatus, error) {
|
||||
defaultPath, _ := config.DefaultVaultPath()
|
||||
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
Error: fmt.Sprintf("config load error: %v", err),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// No config at all → first run
|
||||
if appCfg == nil {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config says not completed → first run
|
||||
if !appCfg.FirstRunCompleted {
|
||||
return &StartupStatus{
|
||||
Status: "first_run",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Config has no vault path → first run
|
||||
if appCfg.VaultPath == "" {
|
||||
appCfg.VaultPath = defaultPath
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// Check if vault exists
|
||||
vaultExists := vaultExistsAt(appCfg.VaultPath)
|
||||
if !vaultExists {
|
||||
return &StartupStatus{
|
||||
Status: "recovery",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
DefaultPath: defaultPath,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: appCfg.VaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func vaultExistsAt(vaultPath string) bool {
|
||||
dbPath := filepath.Join(vaultPath, ".verstak", "index.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CreateVault creates a new vault at the given path and initializes all services.
|
||||
func (a *App) CreateVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
|
||||
// Create vault directories and database
|
||||
if err := vault.Init(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("create vault: %w", err)
|
||||
}
|
||||
|
||||
// Initialize services for this vault
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault services: %w", err)
|
||||
}
|
||||
|
||||
// Save global config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault created at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// OpenVault opens an existing vault and initializes services.
|
||||
func (a *App) OpenVault(vaultPath string) (*StartupStatus, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("vault path is empty")
|
||||
}
|
||||
if !vaultExistsAt(vaultPath) {
|
||||
return nil, fmt.Errorf("vault not found at %s", vaultPath)
|
||||
}
|
||||
|
||||
if err := a.initVault(vaultPath); err != nil {
|
||||
return nil, fmt.Errorf("init vault: %w", err)
|
||||
}
|
||||
|
||||
// Update config
|
||||
appCfg, err := config.LoadAppConfig()
|
||||
if err != nil || appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
appCfg.VaultPath = vaultPath
|
||||
appCfg.FirstRunCompleted = true
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return nil, fmt.Errorf("save config: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[startup] vault opened at %s", vaultPath)
|
||||
return &StartupStatus{
|
||||
Status: "ready",
|
||||
VaultPath: vaultPath,
|
||||
VaultExists: true,
|
||||
AppConfig: appCfg,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// initVault opens the vault DB and initializes all core services.
|
||||
func (a *App) initVault(vaultPath string) error {
|
||||
// Close previous vault if any
|
||||
a.closeVault()
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
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)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
if err := templatesReg.LoadSystem(); err != nil {
|
||||
log.Printf("warning: failed to load system templates: %v", err)
|
||||
}
|
||||
|
||||
// Apply enabled templates from config
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabledSet := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
for _, t := range templatesReg.All() {
|
||||
if !enabledSet[t.ID] {
|
||||
_ = templatesReg.Disable(t.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync service
|
||||
deviceID := ""
|
||||
_ = appCfg // will store sync settings
|
||||
if appCfg != nil && appCfg.Vault.Sync.DeviceID != "" {
|
||||
deviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
if deviceID == "" {
|
||||
deviceID = "gui-" + abs[:8]
|
||||
}
|
||||
syncSvc := syncsvc.NewService(db, deviceID)
|
||||
|
||||
a.mu.Lock()
|
||||
a.db = db
|
||||
a.nodes = nodeRepo
|
||||
a.files = fileSvc
|
||||
a.notes = noteSvc
|
||||
a.activity = activitySvc
|
||||
a.actions = actionSvc
|
||||
a.worklog = worklogSvc
|
||||
a.search = searchSvc
|
||||
a.plugins = pm
|
||||
a.templates = templatesReg
|
||||
a.sync = syncSvc
|
||||
a.vault = abs
|
||||
a.vaultOpen = true
|
||||
a.mu.Unlock()
|
||||
|
||||
// Start auto-sync loop
|
||||
go a.autoSyncLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// closeVault shuts down current vault services if any.
|
||||
func (a *App) closeVault() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.vaultOpen {
|
||||
return
|
||||
}
|
||||
if a.db != nil {
|
||||
a.db.Close()
|
||||
}
|
||||
a.db = nil
|
||||
a.nodes = nil
|
||||
a.files = nil
|
||||
a.notes = nil
|
||||
a.activity = nil
|
||||
a.actions = nil
|
||||
a.worklog = nil
|
||||
a.search = nil
|
||||
a.plugins = nil
|
||||
a.templates = nil
|
||||
a.sync = nil
|
||||
a.vault = ""
|
||||
a.vaultOpen = false
|
||||
}
|
||||
|
||||
// IsReady returns true if a vault is open and services are initialized.
|
||||
func (a *App) IsReady() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.vaultOpen
|
||||
}
|
||||
|
||||
// GetAppConfig returns the current global app config.
|
||||
func (a *App) GetAppConfig() (*config.AppConfig, error) {
|
||||
cfg, err := config.LoadAppConfig()
|
||||
if err != nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
if cfg == nil {
|
||||
return config.DefaultAppConfig(), nil
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// SaveAppConfig saves the global app config.
|
||||
func (a *App) SaveAppConfig(cfg *config.AppConfig) error {
|
||||
return config.SaveAppConfig(cfg)
|
||||
}
|
||||
|
||||
// GetDefaultVaultPath returns the default vault path.
|
||||
func (a *App) GetDefaultVaultPath() (string, error) {
|
||||
return config.DefaultVaultPath()
|
||||
}
|
||||
|
||||
// CheckVaultPath checks whether a given path is usable as a vault.
|
||||
type CheckVaultPathResult struct {
|
||||
Exists bool `json:"exists"`
|
||||
HasVault bool `json:"hasVault"`
|
||||
Writable bool `json:"writable"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (a *App) CheckVaultPath(vaultPath string) (*CheckVaultPathResult, error) {
|
||||
if vaultPath == "" {
|
||||
return nil, fmt.Errorf("path is empty")
|
||||
}
|
||||
info, err := os.Stat(vaultPath)
|
||||
exists := err == nil
|
||||
hasVault := false
|
||||
writable := false
|
||||
|
||||
if exists {
|
||||
if info.IsDir() {
|
||||
writable = checkDirWritable(vaultPath)
|
||||
hasVault = vaultExistsAt(vaultPath)
|
||||
}
|
||||
} else {
|
||||
// Path doesn't exist - check if parent is writable
|
||||
parent := filepath.Dir(vaultPath)
|
||||
parentInfo, pErr := os.Stat(parent)
|
||||
if pErr == nil && parentInfo.IsDir() {
|
||||
writable = checkDirWritable(parent)
|
||||
}
|
||||
}
|
||||
|
||||
desc := describeVaultPath(vaultPath, exists, hasVault)
|
||||
|
||||
return &CheckVaultPathResult{
|
||||
Exists: exists,
|
||||
HasVault: hasVault,
|
||||
Writable: writable,
|
||||
Description: desc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func checkDirWritable(dir string) bool {
|
||||
testFile := filepath.Join(dir, ".verstak-write-test")
|
||||
if err := os.WriteFile(testFile, []byte{}, 0o640); err != nil {
|
||||
return false
|
||||
}
|
||||
os.Remove(testFile)
|
||||
return true
|
||||
}
|
||||
|
||||
func describeVaultPath(path string, exists, hasVault bool) string {
|
||||
if !exists {
|
||||
return "Путь не существует. Будет создан новый vault."
|
||||
}
|
||||
if hasVault {
|
||||
return "Найден существующий vault. Можно подключиться."
|
||||
}
|
||||
return "Папка существует, но vault не найден. Можно создать новый vault."
|
||||
}
|
||||
|
||||
// VaultInfo returns information about the currently open vault.
|
||||
type VaultInfo struct {
|
||||
Path string `json:"path"`
|
||||
DBPath string `json:"dbPath"`
|
||||
FilesPath string `json:"filesPath"`
|
||||
TrashPath string `json:"trashPath"`
|
||||
Healthy bool `json:"healthy"`
|
||||
NodeCount int `json:"nodeCount"`
|
||||
FileCount int `json:"fileCount"`
|
||||
}
|
||||
|
||||
func (a *App) GetVaultInfo() (*VaultInfo, error) {
|
||||
if !a.IsReady() {
|
||||
return nil, fmt.Errorf("vault not open")
|
||||
}
|
||||
a.mu.RLock()
|
||||
vp := a.vault
|
||||
nodesCount := 0
|
||||
if a.nodes != nil {
|
||||
roots, _ := a.nodes.ListRoots(true)
|
||||
nodesCount = len(roots)
|
||||
}
|
||||
fileCount := 0
|
||||
_ = a.db.QueryRow("SELECT COUNT(*) FROM files").Scan(&fileCount)
|
||||
a.mu.RUnlock()
|
||||
|
||||
return &VaultInfo{
|
||||
Path: vp,
|
||||
DBPath: filepath.Join(vp, ".verstak", "index.db"),
|
||||
FilesPath: filepath.Join(vp, "spaces"),
|
||||
TrashPath: filepath.Join(vp, ".verstak", "trash"),
|
||||
Healthy: true,
|
||||
NodeCount: nodesCount,
|
||||
FileCount: fileCount,
|
||||
}, nil
|
||||
}
|
||||
@@ -881,6 +881,9 @@ func (a *App) moveNoteFileNode(nodeID string, node *nodes.Node, parent *nodes.No
|
||||
}
|
||||
|
||||
func (a *App) ListEnabledTemplates() ([]TemplateDTO, error) {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return []TemplateDTO{}, nil
|
||||
}
|
||||
list := a.templates.Enabled()
|
||||
result := make([]TemplateDTO, len(list))
|
||||
for i, t := range list {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/i18n"
|
||||
@@ -12,6 +14,86 @@ import (
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
|
||||
// ===== Template management =====
|
||||
|
||||
// AllTemplates returns all registered templates with their enabled status.
|
||||
type TemplateWithStatus struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
func (a *App) AllTemplates() ([]TemplateWithStatus, error) {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return nil, fmt.Errorf("vault not ready")
|
||||
}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
enabledSet := make(map[string]bool)
|
||||
if appCfg != nil {
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
enabledSet[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
all := a.templates.All()
|
||||
result := make([]TemplateWithStatus, len(all))
|
||||
for i, t := range all {
|
||||
// If config has explicit list, use it; otherwise default to true
|
||||
enabled := true
|
||||
if appCfg != nil && len(appCfg.EnabledTemplates) > 0 {
|
||||
enabled = enabledSet[t.ID]
|
||||
}
|
||||
result[i] = TemplateWithStatus{
|
||||
ID: t.ID,
|
||||
Title: t.Title,
|
||||
Type: t.Type,
|
||||
Icon: t.Icon,
|
||||
Enabled: enabled,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) SetTemplateEnabled(templateID string, enabled bool) error {
|
||||
if !a.IsReady() || a.templates == nil {
|
||||
return fmt.Errorf("vault not ready")
|
||||
}
|
||||
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
|
||||
// Update enabled templates list
|
||||
existing := make(map[string]bool)
|
||||
for _, id := range appCfg.EnabledTemplates {
|
||||
existing[id] = true
|
||||
}
|
||||
if enabled {
|
||||
existing[templateID] = true
|
||||
} else {
|
||||
delete(existing, templateID)
|
||||
}
|
||||
|
||||
appCfg.EnabledTemplates = make([]string, 0, len(existing))
|
||||
for id := range existing {
|
||||
appCfg.EnabledTemplates = append(appCfg.EnabledTemplates, id)
|
||||
}
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update in-memory registry
|
||||
if enabled {
|
||||
_ = a.templates.Enable(templateID)
|
||||
} else {
|
||||
_ = a.templates.Disable(templateID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ListTemplates() []TemplateDTO {
|
||||
templates := a.plugins.Templates()
|
||||
out := make([]TemplateDTO, 0, len(templates))
|
||||
@@ -63,25 +145,7 @@ func (a *App) FromTemplate(parentID, nodeType, title, section, template string)
|
||||
return &dto, nil
|
||||
}
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
// ===== File picking =====
|
||||
|
||||
func (a *App) PickFile() (string, error) {
|
||||
return wailsruntime.OpenFileDialog(a.ctx, wailsruntime.OpenDialogOptions{
|
||||
@@ -141,6 +205,34 @@ func (a *App) OpenFolder(nodeID string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) OpenVaultFolder() error {
|
||||
if !a.IsReady() {
|
||||
return fmt.Errorf("vault not open")
|
||||
}
|
||||
cmd := exec.Command("xdg-open", a.vault)
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func (a *App) Search(query string) ([]SearchResultDTO, error) {
|
||||
if query == "" {
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
results, err := a.search.Search(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]SearchResultDTO, len(results))
|
||||
for i, r := range results {
|
||||
out[i] = SearchResultDTO{
|
||||
NodeID: r.NodeID,
|
||||
Title: r.Title,
|
||||
Snippet: r.Snippet,
|
||||
Type: r.Type,
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) VerstakVersion() string {
|
||||
return "verstak-gui/v2"
|
||||
}
|
||||
|
||||
@@ -21,15 +21,22 @@ type SyncStatusDTO struct {
|
||||
UnpushedOps int `json:"unpushedOps"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastError string `json:"lastError"`
|
||||
StatusLabel string `json:"statusLabel"` // human-readable status
|
||||
}
|
||||
|
||||
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
if !a.IsReady() {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
|
||||
serverURL, apiKey, _, lastSyncAt, err := a.sync.GetState()
|
||||
if err != nil {
|
||||
return &SyncStatusDTO{}, nil
|
||||
}
|
||||
cfg, _ := config.Load(a.vault)
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
|
||||
dto := &SyncStatusDTO{
|
||||
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||
ServerURL: serverURL,
|
||||
@@ -37,18 +44,20 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
UnpushedOps: 0,
|
||||
TokenStored: deviceToken != "",
|
||||
}
|
||||
if cfg != nil {
|
||||
dto.DeviceID = cfg.Sync.DeviceID
|
||||
dto.SyncInterval = cfg.Sync.SyncInterval
|
||||
if appCfg != nil {
|
||||
dto.DeviceID = appCfg.Vault.Sync.DeviceID
|
||||
dto.SyncInterval = appCfg.Vault.Sync.SyncInterval
|
||||
dto.LastError = appCfg.Vault.Sync.LastError
|
||||
}
|
||||
|
||||
unpushed, _ := a.sync.GetUnpushedOps()
|
||||
dto.UnpushedOps = len(unpushed)
|
||||
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(serverURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
if cfg != nil {
|
||||
client.DeviceID = cfg.Sync.DeviceID
|
||||
if appCfg != nil {
|
||||
client.DeviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
if info, err := client.GetMe(); err == nil {
|
||||
dto.DeviceName = info.DeviceName
|
||||
@@ -60,9 +69,69 @@ func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build status label
|
||||
switch {
|
||||
case dto.Revoked:
|
||||
dto.StatusLabel = "revoked"
|
||||
case dto.Connected:
|
||||
dto.StatusLabel = "connected"
|
||||
case dto.Configured:
|
||||
dto.StatusLabel = "disconnected"
|
||||
default:
|
||||
dto.StatusLabel = "disabled"
|
||||
}
|
||||
|
||||
// Update config with latest status
|
||||
if appCfg != nil {
|
||||
changed := false
|
||||
if dto.LastSyncAt != "" && appCfg.Vault.Sync.LastSyncAt != dto.LastSyncAt {
|
||||
appCfg.Vault.Sync.LastSyncAt = dto.LastSyncAt
|
||||
changed = true
|
||||
}
|
||||
if appCfg.Vault.Sync.LastStatus != dto.StatusLabel {
|
||||
appCfg.Vault.Sync.LastStatus = dto.StatusLabel
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
}
|
||||
}
|
||||
|
||||
return dto, nil
|
||||
}
|
||||
|
||||
type SyncSettingsDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ServerURL string `json:"serverUrl"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
SyncInterval int `json:"syncInterval"`
|
||||
LastStatus string `json:"lastStatus"`
|
||||
LastSyncAt string `json:"lastSyncAt"`
|
||||
LastError string `json:"lastError"`
|
||||
TokenStored bool `json:"tokenStored"`
|
||||
}
|
||||
|
||||
func (a *App) GetSyncSettings() (*SyncSettingsDTO, error) {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
return &SyncSettingsDTO{
|
||||
Enabled: appCfg.Vault.Sync.Enabled,
|
||||
ServerURL: appCfg.Vault.Sync.ServerURL,
|
||||
DeviceID: appCfg.Vault.Sync.DeviceID,
|
||||
DeviceName: appCfg.Vault.Sync.DeviceName,
|
||||
SyncInterval: appCfg.Vault.Sync.SyncInterval,
|
||||
LastStatus: appCfg.Vault.Sync.LastStatus,
|
||||
LastSyncAt: appCfg.Vault.Sync.LastSyncAt,
|
||||
LastError: appCfg.Vault.Sync.LastError,
|
||||
TokenStored: deviceToken != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
hostname, _ := os.Hostname()
|
||||
if hostname == "" {
|
||||
@@ -79,32 +148,42 @@ func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||
if err := a.sync.SetState(serverURL, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
|
||||
// Update global config
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
cfg.Sync.ServerURL = serverURL
|
||||
cfg.Sync.DeviceID = deviceID
|
||||
cfg.Sync.APIKey = ""
|
||||
return config.Save(a.vault, cfg)
|
||||
appCfg.Vault.Sync.Enabled = true
|
||||
appCfg.Vault.Sync.ServerURL = serverURL
|
||||
appCfg.Vault.Sync.DeviceID = deviceID
|
||||
appCfg.Vault.Sync.DeviceName = hostname
|
||||
appCfg.Vault.Sync.LastStatus = "connected"
|
||||
_ = config.SaveAppConfig(appCfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) SyncDisconnect() error {
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
if deviceToken != "" {
|
||||
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", a.vault)
|
||||
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", "", a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
_ = client.RevokeCurrent()
|
||||
}
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
cfg.Sync.ServerURL = ""
|
||||
cfg.Sync.DeviceID = ""
|
||||
cfg.Sync.APIKey = ""
|
||||
if err := config.Save(a.vault, cfg); err != nil {
|
||||
|
||||
appCfg.Vault.Sync.Enabled = false
|
||||
appCfg.Vault.Sync.ServerURL = ""
|
||||
appCfg.Vault.Sync.DeviceID = ""
|
||||
appCfg.Vault.Sync.DeviceName = ""
|
||||
appCfg.Vault.Sync.LastStatus = "disabled"
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
if err := config.SaveAppConfig(appCfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return a.sync.SetState("", "")
|
||||
@@ -116,21 +195,15 @@ func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||
}
|
||||
|
||||
func (a *App) SyncSetInterval(minutes int) error {
|
||||
cfg, err := config.Load(a.vault)
|
||||
if err != nil {
|
||||
cfg = &config.Config{}
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
appCfg = config.DefaultAppConfig()
|
||||
}
|
||||
if cfg.Sync.ServerURL == "" {
|
||||
sURL, _, _, _, _ := a.sync.GetState()
|
||||
if sURL != "" {
|
||||
cfg.Sync.ServerURL = sURL
|
||||
}
|
||||
appCfg.Vault.Sync.SyncInterval = minutes
|
||||
if appCfg.Vault.Sync.DeviceID == "" && a.sync != nil {
|
||||
appCfg.Vault.Sync.DeviceID = a.sync.GetDeviceID()
|
||||
}
|
||||
if cfg.Sync.DeviceID == "" {
|
||||
cfg.Sync.DeviceID = a.sync.GetDeviceID()
|
||||
}
|
||||
cfg.Sync.SyncInterval = minutes
|
||||
return config.Save(a.vault, cfg)
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
@@ -141,8 +214,9 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
|
||||
deviceID := ""
|
||||
if cfg, err := config.Load(a.vault); err == nil {
|
||||
deviceID = cfg.Sync.DeviceID
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg != nil {
|
||||
deviceID = appCfg.Vault.Sync.DeviceID
|
||||
}
|
||||
|
||||
client := syncsvc.NewClient(serverURL, apiKey, deviceID, a.vault)
|
||||
@@ -159,6 +233,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
if len(unpushed) > 0 {
|
||||
pushResult, err = client.Push(unpushed)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("push: %v", err))
|
||||
return nil, fmt.Errorf("push: %w", err)
|
||||
}
|
||||
if err := a.sync.MarkPushed(pushResult.Accepted); err != nil {
|
||||
@@ -168,6 +243,7 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
|
||||
pullResult, err := client.Pull(lastPullSeq)
|
||||
if err != nil {
|
||||
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
|
||||
return nil, fmt.Errorf("pull: %w", err)
|
||||
}
|
||||
|
||||
@@ -199,6 +275,10 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
_ = a.sync.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||
|
||||
// Update config with success
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
a.updateSyncSuccess(now)
|
||||
|
||||
result := map[string]interface{}{
|
||||
"pushed": len(pushResult.Accepted),
|
||||
"pulled": len(pullResult.Ops),
|
||||
@@ -212,3 +292,58 @@ func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (a *App) updateSyncError(errMsg string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastError = errMsg
|
||||
appCfg.Vault.Sync.LastStatus = "error"
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
func (a *App) updateSyncSuccess(lastSyncAt string) error {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
appCfg.Vault.Sync.LastStatus = "connected"
|
||||
appCfg.Vault.Sync.LastSyncAt = lastSyncAt
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
// CheckSyncConnection tests the current sync connection.
|
||||
func (a *App) CheckSyncConnection() (bool, string) {
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil || !appCfg.Vault.Sync.Enabled {
|
||||
return false, "sync not configured"
|
||||
}
|
||||
deviceToken := config.LoadDeviceToken(a.vault)
|
||||
if deviceToken == "" {
|
||||
return false, "no device token"
|
||||
}
|
||||
client := syncsvc.NewClient(appCfg.Vault.Sync.ServerURL, "", appCfg.Vault.Sync.DeviceID, a.vault)
|
||||
client.DeviceToken = deviceToken
|
||||
info, err := client.GetMe()
|
||||
if err != nil {
|
||||
return false, err.Error()
|
||||
}
|
||||
if info.RevokedAt != "" {
|
||||
return false, "device revoked"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// ResetSyncKey clears the device token and resets sync state.
|
||||
func (a *App) ResetSyncKey() error {
|
||||
config.RemoveDeviceToken(a.vault)
|
||||
appCfg, _ := config.LoadAppConfig()
|
||||
if appCfg == nil {
|
||||
return nil
|
||||
}
|
||||
appCfg.Vault.Sync.LastStatus = "disabled"
|
||||
appCfg.Vault.Sync.LastError = ""
|
||||
return config.SaveAppConfig(appCfg)
|
||||
}
|
||||
|
||||
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-D_8wOpYY.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
|
||||
<script type="module" crossorigin src="/assets/main-DS67FqQ2.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-oJnEtKWF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
+5
-71
@@ -3,21 +3,8 @@ package main
|
||||
import (
|
||||
"embed"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"verstak/internal/core/actions"
|
||||
"verstak/internal/core/activity"
|
||||
"verstak/internal/core/config"
|
||||
"verstak/internal/core/files"
|
||||
"verstak/internal/core/nodes"
|
||||
"verstak/internal/core/notes"
|
||||
"verstak/internal/core/plugins"
|
||||
"verstak/internal/core/search"
|
||||
"verstak/internal/core/storage"
|
||||
syncsvc "verstak/internal/core/sync"
|
||||
"verstak/internal/core/templates"
|
||||
"verstak/internal/core/worklog"
|
||||
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
@@ -28,65 +15,9 @@ import (
|
||||
var assets embed.FS
|
||||
|
||||
func main() {
|
||||
vaultPath := "."
|
||||
if len(os.Args) > 1 {
|
||||
vaultPath = os.Args[1]
|
||||
}
|
||||
app := &App{}
|
||||
|
||||
abs, err := filepath.Abs(vaultPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(abs, ".verstak", "index.db")
|
||||
db, err := storage.Open(dbPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Open vault: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Init core services
|
||||
nodeRepo := nodes.NewRepository(db)
|
||||
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)
|
||||
pm := plugins.NewManager(abs)
|
||||
pm.Discover()
|
||||
|
||||
templatesReg := templates.NewRegistry()
|
||||
if err := templatesReg.LoadSystem(); err != nil {
|
||||
log.Printf("warning: failed to load system templates: %v", err)
|
||||
}
|
||||
|
||||
// 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,
|
||||
templates: templatesReg,
|
||||
files: fileSvc,
|
||||
notes: noteSvc,
|
||||
activity: activitySvc,
|
||||
actions: actionSvc,
|
||||
worklog: worklogSvc,
|
||||
search: searchSvc,
|
||||
plugins: pm,
|
||||
sync: syncSvc,
|
||||
vault: abs,
|
||||
}
|
||||
|
||||
err = wails.Run(&options.App{
|
||||
err := wails.Run(&options.App{
|
||||
Title: "Верстак",
|
||||
Width: 1280,
|
||||
Height: 800,
|
||||
@@ -106,4 +37,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Ensure config dir exists for logging/cli usage
|
||||
config.EnsureConfigDir()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user