feat: add sync backend methods
This commit is contained in:
parent
5c979174f1
commit
ed69746332
|
|
@ -22,6 +22,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
|
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
|
|
@ -43,6 +44,7 @@ type App struct {
|
||||||
pluginState *pluginstate.Manager
|
pluginState *pluginstate.Manager
|
||||||
workbench *coreworkbench.Router
|
workbench *coreworkbench.Router
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
|
syncSvc *syncsvc.Service
|
||||||
debug bool
|
debug bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,6 +61,7 @@ func NewApp(
|
||||||
appSettingsMgr *appsettings.Manager,
|
appSettingsMgr *appsettings.Manager,
|
||||||
pluginStateMgr *pluginstate.Manager,
|
pluginStateMgr *pluginstate.Manager,
|
||||||
workspaceMgr *workspace.Manager,
|
workspaceMgr *workspace.Manager,
|
||||||
|
syncService *syncsvc.Service,
|
||||||
debugEnabled bool,
|
debugEnabled bool,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
return &App{
|
||||||
|
|
@ -74,6 +77,7 @@ func NewApp(
|
||||||
pluginState: pluginStateMgr,
|
pluginState: pluginStateMgr,
|
||||||
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)),
|
||||||
workspace: workspaceMgr,
|
workspace: workspaceMgr,
|
||||||
|
syncSvc: syncService,
|
||||||
debug: debugEnabled,
|
debug: debugEnabled,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -950,7 +954,105 @@ func (a *App) SetCurrentVault(path string) string {
|
||||||
|
|
||||||
// ─── Workspace API ─────────────────────────────────────────
|
// ─── Workspace API ─────────────────────────────────────────
|
||||||
|
|
||||||
// GetWorkspaceTree returns the full workspace tree.
|
// ListWorkspaces returns top-level physical workspace folders.
|
||||||
|
func (a *App) ListWorkspaces() ([]workspace.Workspace, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return nil, "workspace not initialized"
|
||||||
|
}
|
||||||
|
workspaces, err := a.workspace.ListWorkspaces()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err.Error()
|
||||||
|
}
|
||||||
|
return workspaces, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWorkspace creates a top-level physical workspace folder.
|
||||||
|
func (a *App) CreateWorkspace(name, templateID string) (workspace.Workspace, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Workspace{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
ws, err := a.workspace.CreateWorkspace(name, templateID)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Workspace{}, err.Error()
|
||||||
|
}
|
||||||
|
return ws, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenameWorkspace physically renames a top-level workspace folder.
|
||||||
|
func (a *App) RenameWorkspace(oldName, newName string) string {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return "workspace not initialized"
|
||||||
|
}
|
||||||
|
if err := a.workspace.RenameWorkspace(oldName, newName); err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrashWorkspace moves a top-level workspace folder to internal trash.
|
||||||
|
func (a *App) TrashWorkspace(name string) (workspace.TrashResult, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.TrashResult{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
result, err := a.workspace.TrashWorkspace(name)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.TrashResult{}, err.Error()
|
||||||
|
}
|
||||||
|
return result, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorkspaceMetadata returns metadata or a generic fallback for a workspace.
|
||||||
|
func (a *App) GetWorkspaceMetadata(name string) (workspace.Metadata, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Metadata{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
meta, err := a.workspace.GetWorkspaceMetadata(name)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Metadata{}, err.Error()
|
||||||
|
}
|
||||||
|
return meta, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateWorkspaceMetadata merges metadata for an existing workspace.
|
||||||
|
func (a *App) UpdateWorkspaceMetadata(name string, patch workspace.MetadataPatch) (workspace.Metadata, string) {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return workspace.Metadata{}, "workspace not initialized"
|
||||||
|
}
|
||||||
|
meta, err := a.workspace.UpdateWorkspaceMetadata(name, patch)
|
||||||
|
if err != nil {
|
||||||
|
return workspace.Metadata{}, err.Error()
|
||||||
|
}
|
||||||
|
return meta, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentWorkspace returns the currently selected top-level workspace.
|
||||||
|
func (a *App) GetCurrentWorkspace() map[string]interface{} {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
}
|
||||||
|
node, err := a.workspace.GetCurrentNode()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]interface{}{"error": err.Error()}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCurrentWorkspace stores the selected top-level workspace name as UI state.
|
||||||
|
func (a *App) SetCurrentWorkspace(name string) string {
|
||||||
|
if a.workspace == nil {
|
||||||
|
return "workspace not initialized"
|
||||||
|
}
|
||||||
|
if err := a.workspace.SetCurrentNode(name); err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer ListWorkspaces.
|
||||||
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||||
if a.workspace == nil || !a.workspace.IsInitialized() {
|
if a.workspace == nil || !a.workspace.IsInitialized() {
|
||||||
return map[string]interface{}{"status": "not initialized"}
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
|
@ -964,7 +1066,8 @@ func (a *App) GetWorkspaceTree() map[string]interface{} {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateWorkspaceNode creates a new workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer CreateWorkspace.
|
||||||
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
|
func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]interface{} {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return map[string]interface{}{"error": "workspace not initialized"}
|
return map[string]interface{}{"error": "workspace not initialized"}
|
||||||
|
|
@ -978,6 +1081,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
||||||
"parentId": node.ParentID,
|
"parentId": node.ParentID,
|
||||||
"type": string(node.Type),
|
"type": string(node.Type),
|
||||||
"title": node.Title,
|
"title": node.Title,
|
||||||
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
"status": string(node.Status),
|
"status": string(node.Status),
|
||||||
"order": node.Order,
|
"order": node.Order,
|
||||||
"createdAt": node.CreatedAt,
|
"createdAt": node.CreatedAt,
|
||||||
|
|
@ -985,7 +1090,8 @@ func (a *App) CreateWorkspaceNode(parentID, nodeType, title string) map[string]i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenameWorkspaceNode renames a workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer RenameWorkspace.
|
||||||
func (a *App) RenameWorkspaceNode(id, title string) string {
|
func (a *App) RenameWorkspaceNode(id, title string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -996,7 +1102,8 @@ func (a *App) RenameWorkspaceNode(id, title string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveWorkspaceNode moves a node to a new parent.
|
// Deprecated: compatibility wrapper retained only to reject old nested tree
|
||||||
|
// moves. The corrected workspace model is top-level folders only.
|
||||||
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1007,7 +1114,8 @@ func (a *App) MoveWorkspaceNode(id, newParentID string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// ArchiveWorkspaceNode archives a workspace node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer TrashWorkspace.
|
||||||
func (a *App) ArchiveWorkspaceNode(id string) string {
|
func (a *App) ArchiveWorkspaceNode(id string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1018,7 +1126,8 @@ func (a *App) ArchiveWorkspaceNode(id string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentWorkspaceNode returns the currently selected node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer GetCurrentWorkspace.
|
||||||
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return map[string]interface{}{"status": "not initialized"}
|
return map[string]interface{}{"status": "not initialized"}
|
||||||
|
|
@ -1028,14 +1137,17 @@ func (a *App) GetCurrentWorkspaceNode() map[string]interface{} {
|
||||||
return map[string]interface{}{"error": err.Error()}
|
return map[string]interface{}{"error": err.Error()}
|
||||||
}
|
}
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"id": node.ID,
|
"id": node.ID,
|
||||||
"type": string(node.Type),
|
"type": string(node.Type),
|
||||||
"title": node.Title,
|
"title": node.Title,
|
||||||
"status": string(node.Status),
|
"name": node.Name,
|
||||||
|
"rootPath": node.RootPath,
|
||||||
|
"status": string(node.Status),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetCurrentWorkspaceNode sets the currently selected node.
|
// Deprecated: compatibility wrapper over the flat top-level folder workspace
|
||||||
|
// model. Prefer SetCurrentWorkspace.
|
||||||
func (a *App) SetCurrentWorkspaceNode(id string) string {
|
func (a *App) SetCurrentWorkspaceNode(id string) string {
|
||||||
if a.workspace == nil {
|
if a.workspace == nil {
|
||||||
return "workspace not initialized"
|
return "workspace not initialized"
|
||||||
|
|
@ -1201,3 +1313,318 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string)
|
||||||
}
|
}
|
||||||
return string(data), ""
|
return string(data), ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Sync API ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (a *App) requireVault() error {
|
||||||
|
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||||
|
return fmt.Errorf("vault not open")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) vaultPath() string {
|
||||||
|
if a.vault == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return a.vault.GetVaultPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatusDTO holds sync status information for the frontend.
|
||||||
|
type SyncStatusDTO struct {
|
||||||
|
Configured bool `json:"configured"`
|
||||||
|
ServerURL string `json:"serverUrl"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
TokenStored bool `json:"tokenStored"`
|
||||||
|
UnpushedOps int `json:"unpushedOps"`
|
||||||
|
LastSyncAt string `json:"lastSyncAt"`
|
||||||
|
SyncInterval int `json:"syncInterval"`
|
||||||
|
LastError string `json:"lastError"`
|
||||||
|
StatusLabel string `json:"statusLabel"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncStatus returns the current sync status.
|
||||||
|
func (a *App) SyncStatus() (*SyncStatusDTO, error) {
|
||||||
|
if a.vault == nil || a.vault.GetVaultStatus() != vault.StatusOpen {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
if a.syncSvc == nil {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
serverURL, apiKey, _, lastSyncAt, err := a.syncSvc.GetState()
|
||||||
|
if err != nil {
|
||||||
|
return &SyncStatusDTO{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
|
||||||
|
dto := &SyncStatusDTO{
|
||||||
|
Configured: serverURL != "" && (apiKey != "" || deviceToken != ""),
|
||||||
|
ServerURL: serverURL,
|
||||||
|
LastSyncAt: lastSyncAt,
|
||||||
|
UnpushedOps: 0,
|
||||||
|
TokenStored: deviceToken != "",
|
||||||
|
SyncInterval: cfg.Sync.SyncInterval,
|
||||||
|
LastError: cfg.Sync.LastError,
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
dto.DeviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
unpushed, _ := a.syncSvc.GetUnpushedOps()
|
||||||
|
dto.UnpushedOps = len(unpushed)
|
||||||
|
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
client.DeviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
if info, err := client.GetMe(); err == nil {
|
||||||
|
dto.DeviceName = info.DeviceName
|
||||||
|
dto.DeviceID = info.DeviceID
|
||||||
|
dto.Connected = true
|
||||||
|
if info.RevokedAt != "" {
|
||||||
|
dto.Revoked = true
|
||||||
|
dto.Connected = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case dto.Revoked:
|
||||||
|
dto.StatusLabel = "revoked"
|
||||||
|
case dto.Connected:
|
||||||
|
dto.StatusLabel = "connected"
|
||||||
|
case dto.Configured:
|
||||||
|
dto.StatusLabel = "disconnected"
|
||||||
|
default:
|
||||||
|
dto.StatusLabel = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Sync.LastSyncAt != lastSyncAt || cfg.Sync.LastStatus != dto.StatusLabel {
|
||||||
|
cfg.Sync.LastSyncAt = lastSyncAt
|
||||||
|
cfg.Sync.LastStatus = dto.StatusLabel
|
||||||
|
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncConfigure pairs the device with a sync server.
|
||||||
|
func (a *App) SyncConfigure(serverURL, username, password string) error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
if hostname == "" {
|
||||||
|
hostname = "unknown"
|
||||||
|
}
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
deviceID, deviceToken, err := client.PairDevice(serverURL, username, password, hostname, "verstak-desktop/v2")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("pair: %w", err)
|
||||||
|
}
|
||||||
|
if err := syncsvc.SaveDeviceToken(vaultPath, deviceToken); err != nil {
|
||||||
|
return fmt.Errorf("save token: %w", err)
|
||||||
|
}
|
||||||
|
if err := a.syncSvc.SetState(serverURL, ""); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.Enabled = true
|
||||||
|
cfg.Sync.ServerURL = serverURL
|
||||||
|
cfg.Sync.DeviceID = deviceID
|
||||||
|
cfg.Sync.DeviceName = hostname
|
||||||
|
cfg.Sync.LastStatus = "connected"
|
||||||
|
_ = a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncDisconnect disconnects from the sync server and revokes the device token.
|
||||||
|
func (a *App) SyncDisconnect() error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
|
||||||
|
if deviceToken != "" {
|
||||||
|
client := syncsvc.NewClient(cfg.Sync.ServerURL, "", "", vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
_ = client.RevokeCurrent()
|
||||||
|
}
|
||||||
|
_ = syncsvc.RemoveDeviceToken(vaultPath)
|
||||||
|
|
||||||
|
cfg.Sync.Enabled = false
|
||||||
|
cfg.Sync.ServerURL = ""
|
||||||
|
cfg.Sync.DeviceID = ""
|
||||||
|
cfg.Sync.DeviceName = ""
|
||||||
|
cfg.Sync.LastStatus = "disabled"
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
if err := a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.syncSvc.SetState("", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTestConnection tests the connection to a sync server with the given credentials.
|
||||||
|
func (a *App) SyncTestConnection(serverURL, username, password string) error {
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
if vaultPath == "" {
|
||||||
|
vaultPath = "/tmp"
|
||||||
|
}
|
||||||
|
client := syncsvc.NewClient(serverURL, "", "", vaultPath)
|
||||||
|
return client.TestAuth(serverURL, username, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncSetInterval sets the auto-sync interval in minutes.
|
||||||
|
func (a *App) SyncSetInterval(minutes int) error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.SyncInterval = minutes
|
||||||
|
if cfg.Sync.DeviceID == "" && a.syncSvc != nil {
|
||||||
|
cfg.Sync.DeviceID = a.syncSvc.GetDeviceID()
|
||||||
|
}
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncNow triggers an immediate sync cycle (push local ops, pull remote ops).
|
||||||
|
func (a *App) SyncNow() (map[string]interface{}, error) {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
vaultPath := a.vaultPath()
|
||||||
|
|
||||||
|
serverURL, apiKey, lastPullSeq, _, err := a.syncSvc.GetState()
|
||||||
|
deviceToken := syncsvc.LoadDeviceToken(vaultPath)
|
||||||
|
if err != nil || serverURL == "" || (apiKey == "" && deviceToken == "") {
|
||||||
|
return nil, fmt.Errorf("sync not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceID := ""
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
if cfg.Sync.DeviceID != "" {
|
||||||
|
deviceID = cfg.Sync.DeviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
client := syncsvc.NewClient(serverURL, apiKey, deviceID, vaultPath)
|
||||||
|
client.DeviceToken = deviceToken
|
||||||
|
|
||||||
|
unpushed, err := a.syncSvc.GetUnpushedOps()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get ops: %w", err)
|
||||||
|
}
|
||||||
|
for i := range unpushed {
|
||||||
|
unpushed[i].LastSeenServerSeq = lastPullSeq
|
||||||
|
}
|
||||||
|
pushResult := &syncsvc.PushResponse{}
|
||||||
|
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.syncSvc.MarkPushed(pushResult.Accepted); err != nil {
|
||||||
|
return nil, fmt.Errorf("mark pushed: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pullResult, err := client.Pull(lastPullSeq)
|
||||||
|
if err != nil {
|
||||||
|
_ = a.updateSyncError(fmt.Sprintf("pull: %v", err))
|
||||||
|
return nil, fmt.Errorf("pull: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyErrors []string
|
||||||
|
for _, op := range pullResult.Ops {
|
||||||
|
if err := a.applyRemoteOp(op); err != nil {
|
||||||
|
applyErrors = append(applyErrors, fmt.Sprintf("%s/%s: %v", op.EntityType, op.OpID, err))
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.RecordRemoteOp(op)
|
||||||
|
}
|
||||||
|
if len(pullResult.Ops) > 0 {
|
||||||
|
opIDs := make([]string, len(pullResult.Ops))
|
||||||
|
for i, op := range pullResult.Ops {
|
||||||
|
opIDs[i] = op.OpID
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.MarkApplied(opIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(pushResult.Conflicts) > 0 {
|
||||||
|
log.Printf("[sync] %d conflict(s) detected on push", len(pushResult.Conflicts))
|
||||||
|
for _, c := range pushResult.Conflicts {
|
||||||
|
log.Printf("[sync] conflict: op=%v entity=%v/%v",
|
||||||
|
c["op_id"], c["entity_type"], c["entity_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if pullResult.ServerSequence > lastPullSeq {
|
||||||
|
_ = a.syncSvc.SetLastPullSeq(pullResult.ServerSequence)
|
||||||
|
}
|
||||||
|
_ = a.syncSvc.SetLastSyncAt(time.Now().UTC().Format(time.RFC3339))
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
a.updateSyncSuccess(now)
|
||||||
|
|
||||||
|
result := map[string]interface{}{
|
||||||
|
"pushed": len(pushResult.Accepted),
|
||||||
|
"pulled": len(pullResult.Ops),
|
||||||
|
"serverSequence": pullResult.ServerSequence,
|
||||||
|
}
|
||||||
|
if len(applyErrors) > 0 {
|
||||||
|
result["applyErrors"] = applyErrors
|
||||||
|
}
|
||||||
|
if len(pushResult.Conflicts) > 0 {
|
||||||
|
result["conflicts"] = pushResult.Conflicts
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetSyncKey clears the device token and resets sync state.
|
||||||
|
func (a *App) ResetSyncKey() error {
|
||||||
|
if err := a.requireVault(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = syncsvc.RemoveDeviceToken(a.vaultPath())
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastStatus = "disabled"
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSyncError(errMsg string) error {
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastError = errMsg
|
||||||
|
cfg.Sync.LastStatus = "error"
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) updateSyncSuccess(lastSyncAt string) error {
|
||||||
|
cfg := a.appSettings.Get()
|
||||||
|
cfg.Sync.LastError = ""
|
||||||
|
cfg.Sync.LastStatus = "connected"
|
||||||
|
cfg.Sync.LastSyncAt = lastSyncAt
|
||||||
|
return a.appSettings.Update(&appsettings.Config{Sync: cfg.Sync})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) applyRemoteOp(op syncsvc.Op) error {
|
||||||
|
if a.debug {
|
||||||
|
log.Printf("[sync] applyRemoteOp: type=%s entity=%s/%s", op.OpType, op.EntityType, op.EntityID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type Config struct {
|
||||||
DevMode bool `json:"devMode"`
|
DevMode bool `json:"devMode"`
|
||||||
UserPluginsDir string `json:"userPluginsDir"`
|
UserPluginsDir string `json:"userPluginsDir"`
|
||||||
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
Workbench WorkbenchPreferences `json:"workbench,omitempty"`
|
||||||
|
Sync SyncSettings `json:"sync,omitempty"`
|
||||||
WindowState *WindowState `json:"windowState,omitempty"`
|
WindowState *WindowState `json:"windowState,omitempty"`
|
||||||
LastOpenedAt string `json:"lastOpenedAt"`
|
LastOpenedAt string `json:"lastOpenedAt"`
|
||||||
}
|
}
|
||||||
|
|
@ -31,6 +32,18 @@ type WorkbenchPreferences struct {
|
||||||
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
DefaultNotesMarkdownEditorProvider string `json:"defaultNotesMarkdownEditorProvider,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncSettings holds sync configuration for the current vault.
|
||||||
|
type SyncSettings 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,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// WindowState stores the last window position and size.
|
// WindowState stores the last window position and size.
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
|
|
@ -234,6 +247,7 @@ func copyConfig(c *Config) *Config {
|
||||||
DevMode: c.DevMode,
|
DevMode: c.DevMode,
|
||||||
UserPluginsDir: c.UserPluginsDir,
|
UserPluginsDir: c.UserPluginsDir,
|
||||||
Workbench: c.Workbench,
|
Workbench: c.Workbench,
|
||||||
|
Sync: c.Sync,
|
||||||
LastOpenedAt: c.LastOpenedAt,
|
LastOpenedAt: c.LastOpenedAt,
|
||||||
}
|
}
|
||||||
if c.WindowState != nil {
|
if c.WindowState != nil {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceTokenPath returns the path to the device_token file inside the vault.
|
||||||
|
func DeviceTokenPath(vaultRoot string) string {
|
||||||
|
return filepath.Join(vaultRoot, ".verstak", "device_token.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveDeviceToken writes the device token to a 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 vault.
|
||||||
|
func LoadDeviceToken(vaultRoot string) string {
|
||||||
|
path := DeviceTokenPath(vaultRoot)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var v struct {
|
||||||
|
DeviceToken string `json:"device_token"`
|
||||||
|
}
|
||||||
|
if err := json.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client communicates with the Verstak Sync Server.
|
||||||
|
type Client struct {
|
||||||
|
ServerURL string
|
||||||
|
APIKey string
|
||||||
|
DeviceToken string
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceInfo holds device information from the server.
|
||||||
|
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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMe calls GET /api/client/me and returns device info.
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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,289 @@
|
||||||
|
package sync
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EntityNode = "node"
|
||||||
|
EntityNote = "note"
|
||||||
|
EntityFile = "file"
|
||||||
|
EntityFolder = "folder"
|
||||||
|
EntityAction = "action"
|
||||||
|
EntityWorklog = "worklog"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"`
|
||||||
|
AppliedAt *string `json:"applied_at,omitempty"`
|
||||||
|
ClientSequence int `json:"client_sequence,omitempty"`
|
||||||
|
LastSeenServerSeq int `json:"last_seen_server_seq,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncState persists connection state to JSON file.
|
||||||
|
type syncState struct {
|
||||||
|
ServerURL string `json:"server_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
LastPullSeq int `json:"last_pull_seq"`
|
||||||
|
LastSyncAt string `json:"last_sync_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service records and manages sync operations using JSON file storage.
|
||||||
|
type Service struct {
|
||||||
|
vaultRoot string
|
||||||
|
deviceID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a sync service.
|
||||||
|
func NewService(vaultRoot, deviceID string) *Service {
|
||||||
|
return &Service{vaultRoot: vaultRoot, deviceID: deviceID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) syncDir() string {
|
||||||
|
return filepath.Join(s.vaultRoot, ".verstak", "sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) opsPath() string {
|
||||||
|
return filepath.Join(s.syncDir(), "ops.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) statePath() string {
|
||||||
|
return filepath.Join(s.syncDir(), "state.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureDir() error {
|
||||||
|
return os.MkdirAll(s.syncDir(), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOp writes a sync operation to the local ops file.
|
||||||
|
func (s *Service) RecordOp(entityType, entityID, opType string, payload interface{}) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
id := uuid.New().String()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
op := Op{
|
||||||
|
ID: id,
|
||||||
|
OpID: id,
|
||||||
|
DeviceID: s.deviceID,
|
||||||
|
EntityType: entityType,
|
||||||
|
EntityID: entityID,
|
||||||
|
OpType: opType,
|
||||||
|
PayloadJSON: payloadStr,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ops = append(ops, op)
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRemoteOp writes a remote op to the local ops file.
|
||||||
|
func (s *Service) RecordRemoteOp(op Op) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteID := op.OpID + "-remote"
|
||||||
|
for _, existing := range ops {
|
||||||
|
if existing.ID == remoteID {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
op.ID = remoteID
|
||||||
|
op.PushedAt = &now
|
||||||
|
op.AppliedAt = &now
|
||||||
|
ops = append(ops, op)
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUnpushedOps returns ops that have not been pushed yet.
|
||||||
|
func (s *Service) GetUnpushedOps() ([]Op, error) {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var unpushed []Op
|
||||||
|
for _, op := range ops {
|
||||||
|
if op.PushedAt == nil {
|
||||||
|
unpushed = append(unpushed, op)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unpushed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkPushed marks ops as pushed to server.
|
||||||
|
func (s *Service) MarkPushed(opIDs []string) error {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
idSet := make(map[string]bool, len(opIDs))
|
||||||
|
for _, id := range opIDs {
|
||||||
|
idSet[id] = true
|
||||||
|
}
|
||||||
|
for i := range ops {
|
||||||
|
if idSet[ops[i].OpID] {
|
||||||
|
ops[i].PushedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkApplied marks remote ops as applied locally.
|
||||||
|
func (s *Service) MarkApplied(opIDs []string) error {
|
||||||
|
ops, err := s.loadOps()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
idSet := make(map[string]bool, len(opIDs))
|
||||||
|
for _, id := range opIDs {
|
||||||
|
idSet[id] = true
|
||||||
|
}
|
||||||
|
for i := range ops {
|
||||||
|
if idSet[ops[i].OpID] {
|
||||||
|
ops[i].AppliedAt = &now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.saveOps(ops)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetState returns the current sync state.
|
||||||
|
func (s *Service) GetState() (serverURL, apiKey string, lastPullSeq int, lastSyncAt string, err error) {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", 0, "", err
|
||||||
|
}
|
||||||
|
return st.ServerURL, st.APIKey, st.LastPullSeq, st.LastSyncAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetState saves sync connection state.
|
||||||
|
func (s *Service) SetState(serverURL, apiKey string) error {
|
||||||
|
if err := s.ensureDir(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
st = &syncState{}
|
||||||
|
}
|
||||||
|
st.ServerURL = serverURL
|
||||||
|
st.APIKey = apiKey
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastPullSeq updates the last pulled server sequence.
|
||||||
|
func (s *Service) SetLastPullSeq(seq int) error {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.LastPullSeq = seq
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetLastSyncAt updates the last sync timestamp.
|
||||||
|
func (s *Service) SetLastSyncAt(t string) error {
|
||||||
|
st, err := s.loadState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.LastSyncAt = t
|
||||||
|
return s.saveState(st)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceID returns the device ID used by this service.
|
||||||
|
func (s *Service) GetDeviceID() string {
|
||||||
|
return s.deviceID
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- file helpers ---
|
||||||
|
|
||||||
|
func (s *Service) loadOps() ([]Op, error) {
|
||||||
|
data, err := os.ReadFile(s.opsPath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read ops: %w", err)
|
||||||
|
}
|
||||||
|
var ops []Op
|
||||||
|
if err := json.Unmarshal(data, &ops); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ops: %w", err)
|
||||||
|
}
|
||||||
|
return ops, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveOps(ops []Op) error {
|
||||||
|
data, err := json.MarshalIndent(ops, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal ops: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.opsPath(), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadState() (*syncState, error) {
|
||||||
|
data, err := os.ReadFile(s.statePath())
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return &syncState{}, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("read state: %w", err)
|
||||||
|
}
|
||||||
|
var st syncState
|
||||||
|
if err := json.Unmarshal(data, &st); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse state: %w", err)
|
||||||
|
}
|
||||||
|
return &st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveState(st *syncState) error {
|
||||||
|
data, err := json.MarshalIndent(st, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal state: %w", err)
|
||||||
|
}
|
||||||
|
return os.WriteFile(s.statePath(), data, 0o644)
|
||||||
|
}
|
||||||
7
main.go
7
main.go
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
"github.com/verstak/verstak-desktop/internal/core/plugin"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
"github.com/verstak/verstak-desktop/internal/core/pluginstate"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/storage"
|
"github.com/verstak/verstak-desktop/internal/core/storage"
|
||||||
|
syncsvc "github.com/verstak/verstak-desktop/internal/core/sync"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/vault"
|
"github.com/verstak/verstak-desktop/internal/core/vault"
|
||||||
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
"github.com/verstak/verstak-desktop/internal/core/workspace"
|
||||||
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
"github.com/verstak/verstak-desktop/internal/shell/debug"
|
||||||
|
|
@ -245,7 +246,11 @@ func main() {
|
||||||
// Create the App struct
|
// Create the App struct
|
||||||
storageService := storage.New(vaultService)
|
storageService := storage.New(vaultService)
|
||||||
filesService := corefiles.NewService(vaultService)
|
filesService := corefiles.NewService(vaultService)
|
||||||
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, debugEnabled)
|
var syncService *syncsvc.Service
|
||||||
|
if vaultService.GetVaultStatus() == vault.StatusOpen {
|
||||||
|
syncService = syncsvc.NewService(vaultService.GetVaultPath(), "")
|
||||||
|
}
|
||||||
|
app := api.NewApp(capRegistry, contribRegistry, permRegistry, eventBus, plugins, vaultService, storageService, filesService, appSettingsMgr, pluginStateMgr, workspaceMgr, syncService, debugEnabled)
|
||||||
|
|
||||||
// ─── Wails App ───────────────────────────────────────────
|
// ─── Wails App ───────────────────────────────────────────
|
||||||
err := wails.Run(&options.App{
|
err := wails.Run(&options.App{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue