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:
2026-06-03 23:09:40 +08:00
parent e30a75c5a0
commit f92394e3d7
26 changed files with 2747 additions and 327 deletions
+28 -11
View File
@@ -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
}
+393
View File
@@ -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
}
+3
View File
@@ -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 {
+111 -19
View File
@@ -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"
}
+171 -36
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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()
}