diff --git a/internal/api/app.go b/internal/api/app.go index 35aa267..11acf09 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -22,6 +22,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" "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" coreworkbench "github.com/verstak/verstak-desktop/internal/core/workbench" "github.com/verstak/verstak-desktop/internal/core/workspace" @@ -43,6 +44,7 @@ type App struct { pluginState *pluginstate.Manager workbench *coreworkbench.Router workspace *workspace.Manager + syncSvc *syncsvc.Service debug bool } @@ -59,6 +61,7 @@ func NewApp( appSettingsMgr *appsettings.Manager, pluginStateMgr *pluginstate.Manager, workspaceMgr *workspace.Manager, + syncService *syncsvc.Service, debugEnabled bool, ) *App { return &App{ @@ -74,6 +77,7 @@ func NewApp( pluginState: pluginStateMgr, workbench: coreworkbench.NewRouter(workbenchPrefsFromSettings(appSettingsMgr)), workspace: workspaceMgr, + syncSvc: syncService, debug: debugEnabled, } } @@ -950,7 +954,105 @@ func (a *App) SetCurrentVault(path string) string { // ─── 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{} { if a.workspace == nil || !a.workspace.IsInitialized() { 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{} { if a.workspace == nil { 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, "type": string(node.Type), "title": node.Title, + "name": node.Name, + "rootPath": node.RootPath, "status": string(node.Status), "order": node.Order, "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 { if a.workspace == nil { return "workspace not initialized" @@ -996,7 +1102,8 @@ func (a *App) RenameWorkspaceNode(id, title string) string { 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 { if a.workspace == nil { return "workspace not initialized" @@ -1007,7 +1114,8 @@ func (a *App) MoveWorkspaceNode(id, newParentID string) string { 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 { if a.workspace == nil { return "workspace not initialized" @@ -1018,7 +1126,8 @@ func (a *App) ArchiveWorkspaceNode(id string) string { 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{} { if a.workspace == nil { 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{}{ - "id": node.ID, - "type": string(node.Type), - "title": node.Title, - "status": string(node.Status), + "id": node.ID, + "type": string(node.Type), + "title": node.Title, + "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 { if a.workspace == nil { return "workspace not initialized" @@ -1201,3 +1313,318 @@ func (a *App) GetPluginAssetContent(pluginID, assetPath string) (string, string) } 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 +} diff --git a/internal/core/appsettings/manager.go b/internal/core/appsettings/manager.go index 800c4c2..b275200 100644 --- a/internal/core/appsettings/manager.go +++ b/internal/core/appsettings/manager.go @@ -21,6 +21,7 @@ type Config struct { DevMode bool `json:"devMode"` UserPluginsDir string `json:"userPluginsDir"` Workbench WorkbenchPreferences `json:"workbench,omitempty"` + Sync SyncSettings `json:"sync,omitempty"` WindowState *WindowState `json:"windowState,omitempty"` LastOpenedAt string `json:"lastOpenedAt"` } @@ -31,6 +32,18 @@ type WorkbenchPreferences struct { 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. type WindowState struct { Width int `json:"width"` @@ -234,6 +247,7 @@ func copyConfig(c *Config) *Config { DevMode: c.DevMode, UserPluginsDir: c.UserPluginsDir, Workbench: c.Workbench, + Sync: c.Sync, LastOpenedAt: c.LastOpenedAt, } if c.WindowState != nil { diff --git a/internal/core/sync/client.go b/internal/core/sync/client.go new file mode 100644 index 0000000..91f8435 --- /dev/null +++ b/internal/core/sync/client.go @@ -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 +} diff --git a/internal/core/sync/service.go b/internal/core/sync/service.go new file mode 100644 index 0000000..e13c93b --- /dev/null +++ b/internal/core/sync/service.go @@ -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) +} diff --git a/main.go b/main.go index 92599c5..caf2336 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( "github.com/verstak/verstak-desktop/internal/core/plugin" "github.com/verstak/verstak-desktop/internal/core/pluginstate" "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/workspace" "github.com/verstak/verstak-desktop/internal/shell/debug" @@ -245,7 +246,11 @@ func main() { // Create the App struct storageService := storage.New(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 ─────────────────────────────────────────── err := wails.Run(&options.App{