diff --git a/internal/api/app.go b/internal/api/app.go index 46af268..2d86096 100644 --- a/internal/api/app.go +++ b/internal/api/app.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "log" + "net/url" "os" "path/filepath" "strings" @@ -34,6 +35,9 @@ var newSyncClient = syncsvc.NewClient var emitFrontendEvent = runtime.EventsEmit const pluginEventRuntimeName = "verstak:plugin-event" +const activityGlobalKey = "events:global" +const activityWorkspacePrefix = "events:workspace:" +const maxActivityEvents = 250 // App is the main application struct exposed to the Wails frontend. type App struct { @@ -53,6 +57,7 @@ type App struct { workspace *workspace.Manager syncSvc *syncsvc.Service debug bool + activityEvents map[string]bool } type externalOpenService interface { @@ -76,7 +81,7 @@ func NewApp( syncService *syncsvc.Service, debugEnabled bool, ) *App { - return &App{ + app := &App{ capRegistry: capReg, contribRegistry: contribReg, permRegistry: permReg, @@ -92,7 +97,10 @@ func NewApp( workspace: workspaceMgr, syncSvc: syncService, debug: debugEnabled, + activityEvents: make(map[string]bool), } + app.ensureActivityProviderSubscriptions() + return app } func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences { @@ -125,6 +133,7 @@ func (a *App) ensureWorkbench() *coreworkbench.Router { // Startup is called when the app starts. Sets the Wails context for dialogs. func (a *App) Startup(ctx context.Context) { a.ctx = ctx + a.ensureActivityProviderSubscriptions() log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins)) } @@ -171,6 +180,135 @@ func hasString(items []string, want string) bool { return false } +func (a *App) ensureActivityProviderSubscriptions() { + if a.eventBus == nil || a.contribRegistry == nil { + return + } + if a.activityEvents == nil { + a.activityEvents = make(map[string]bool) + } + for _, provider := range a.contribRegistry.ActivityProviders() { + for _, eventName := range provider.Item.Events { + eventName = strings.TrimSpace(eventName) + if eventName == "" || a.activityEvents[eventName] { + continue + } + a.activityEvents[eventName] = true + a.eventBus.Subscribe(eventName, func(event events.Event) { + a.recordActivityProviderEvent(event) + }) + } + } +} + +func (a *App) recordActivityProviderEvent(event events.Event) { + if a.storage == nil || a.contribRegistry == nil { + return + } + for _, provider := range a.contribRegistry.ActivityProviders() { + if !hasString(provider.Item.Events, event.Name) { + continue + } + if _, err := a.requirePluginAccess(provider.PluginID, "storage.namespace"); err != nil { + continue + } + if err := a.appendActivityEvent(provider.PluginID, activityFromEvent(event)); err != nil { + log.Printf("[api] activity provider %s failed to record %s: %v", provider.PluginID, event.Name, err) + } + } +} + +func (a *App) appendActivityEvent(pluginID string, activity map[string]interface{}) error { + settings, err := a.storage.ReadPluginSettings(pluginID) + if err != nil { + return err + } + key := activityGlobalKey + if workspace, _ := activity["workspaceRootPath"].(string); strings.TrimSpace(workspace) != "" { + key = activityWorkspacePrefix + url.QueryEscape(strings.TrimSpace(workspace)) + key = strings.ReplaceAll(key, "+", "%20") + } + eventsList := []interface{}{activity} + if existing, ok := settings[key].([]interface{}); ok { + eventsList = append(eventsList, existing...) + } else if existingMaps, ok := settings[key].([]map[string]interface{}); ok { + for _, item := range existingMaps { + eventsList = append(eventsList, item) + } + } + if len(eventsList) > maxActivityEvents { + eventsList = eventsList[:maxActivityEvents] + } + settings[key] = eventsList + return a.storage.WritePluginSettings(pluginID, settings) +} + +func activityFromEvent(event events.Event) map[string]interface{} { + payload := eventPayloadMap(event.Payload) + now := time.Now().UTC().Format(time.RFC3339Nano) + occurredAt := firstPayloadText(payload, "occurredAt", "capturedAt") + if occurredAt == "" { + occurredAt = event.Timestamp + } + if occurredAt == "" { + occurredAt = now + } + workspaceRoot := firstPayloadText(payload, "workspaceRootPath", "workspaceName", "workspaceNodeId") + if workspaceRoot == "" { + workspaceRoot = workspaceRootFromRelativePath(firstPayloadText(payload, "path")) + } + return map[string]interface{}{ + "activityId": fmt.Sprintf("activity-%d", time.Now().UnixNano()), + "type": event.Name, + "title": activityTitle(event.Name, payload), + "summary": activitySummary(event.Name, payload), + "occurredAt": occurredAt, + "receivedAt": now, + "sourcePluginId": firstPayloadText(payload, "pluginId", "sourcePluginId"), + "workspaceRootPath": workspaceRoot, + "payload": payload, + } +} + +func eventPayloadMap(payload interface{}) map[string]interface{} { + switch value := payload.(type) { + case map[string]interface{}: + return value + case map[string]string: + result := make(map[string]interface{}, len(value)) + for key, item := range value { + result[key] = item + } + return result + default: + return map[string]interface{}{} + } +} + +func firstPayloadText(payload map[string]interface{}, keys ...string) string { + for _, key := range keys { + value := strings.TrimSpace(fmt.Sprint(payload[key])) + if value != "" && value != "" { + return value + } + } + return "" +} + +func activityTitle(eventName string, payload map[string]interface{}) string { + if title := firstPayloadText(payload, "title", "name", "path", "url", "captureId"); title != "" { + return title + } + return eventName +} + +func activitySummary(eventName string, payload map[string]interface{}) string { + if summary := firstPayloadText(payload, "text", "summary", "description", "path", "url", "domain"); summary != "" { + return summary + } + return eventName +} + // ─── Plugin Manager API ───────────────────────────────────── // GetPlugins returns all discovered plugins. @@ -461,6 +599,7 @@ func (a *App) ReloadPlugins() (int, string) { } a.plugins = plugins + a.ensureActivityProviderSubscriptions() var buf strings.Builder buf.WriteString("discovery complete") @@ -706,6 +845,9 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string, }); err != nil { return err.Error() } + a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{ + "operation": opType, + }) return "" } @@ -725,6 +867,10 @@ func (a *App) CreateVaultFolder(pluginID, relativePath string) string { }); err != nil { return err.Error() } + a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{ + "operation": syncsvc.OpCreate, + "type": string(corefiles.FileTypeFolder), + }) return "" } @@ -749,6 +895,11 @@ func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath st }); err != nil { return err.Error() } + a.publishFileActivity("file.changed", pluginID, toRelativePath, map[string]interface{}{ + "operation": syncsvc.OpMove, + "fromPath": fromRelativePath, + "type": string(meta.Type), + }) return "" } @@ -773,6 +924,10 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu }); err != nil { return corefiles.TrashResult{}, err.Error() } + a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{ + "operation": syncsvc.OpDelete, + "type": string(meta.Type), + }) return result, "" } @@ -827,6 +982,38 @@ func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload inte return a.syncSvc.RecordOp(entityType, entityID, opType, payload) } +func (a *App) publishFileActivity(eventName, pluginID, relativePath string, extra map[string]interface{}) { + if a.eventBus == nil { + return + } + path := strings.TrimSpace(filepath.ToSlash(relativePath)) + payload := map[string]interface{}{ + "path": path, + "title": path, + "workspaceRootPath": workspaceRootFromRelativePath(path), + "pluginId": pluginID, + } + for key, value := range extra { + payload[key] = value + } + a.eventBus.Publish(events.Event{ + Name: eventName, + Timestamp: time.Now().UTC().Format(time.RFC3339Nano), + Payload: payload, + }) +} + +func workspaceRootFromRelativePath(relativePath string) string { + path := strings.Trim(strings.TrimSpace(filepath.ToSlash(relativePath)), "/") + if path == "" { + return "" + } + if idx := strings.Index(path, "/"); idx >= 0 { + return path[:idx] + } + return path +} + func syncEntityTypeForFileType(fileType corefiles.FileType) string { if fileType == corefiles.FileTypeFolder { return syncsvc.EntityFolder diff --git a/internal/api/app_test.go b/internal/api/app_test.go index 5dab1ea..303ee47 100644 --- a/internal/api/app_test.go +++ b/internal/api/app_test.go @@ -390,6 +390,111 @@ func TestFilesBridgeReadWriteListMoveTrash(t *testing.T) { } } +func TestFilesBridgeWritePublishesFileChangedActivityEvent(t *testing.T) { + app, _ := newFilesTestApp(t, []string{"files.write"}) + bus := events.NewBus() + app.eventBus = bus + + if errStr := app.CreateVaultFolder("files.plugin", "Project"); errStr != "" { + t.Fatalf("CreateVaultFolder Project: %s", errStr) + } + if errStr := app.CreateVaultFolder("files.plugin", "Project/Notes"); errStr != "" { + t.Fatalf("CreateVaultFolder Project/Notes: %s", errStr) + } + + var received []events.Event + bus.Subscribe("file.changed", func(event events.Event) { + received = append(received, event) + }) + + if errStr := app.WriteVaultTextFile("files.plugin", "Project/Notes/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { + t.Fatalf("WriteVaultTextFile: %s", errStr) + } + + if len(received) != 1 { + t.Fatalf("received %d file.changed events, want 1", len(received)) + } + payload, ok := received[0].Payload.(map[string]interface{}) + if !ok { + t.Fatalf("payload = %#v, want map[string]interface{}", received[0].Payload) + } + if payload["path"] != "Project/Notes/one.txt" { + t.Fatalf("payload path = %#v", payload["path"]) + } + if payload["workspaceRootPath"] != "Project" { + t.Fatalf("payload workspaceRootPath = %#v, want Project", payload["workspaceRootPath"]) + } + if payload["pluginId"] != "files.plugin" { + t.Fatalf("payload pluginId = %#v, want files.plugin", payload["pluginId"]) + } + if received[0].Timestamp == "" { + t.Fatal("event timestamp is empty") + } +} + +func TestActivityProviderRecordsFileChangedWithoutMountedView(t *testing.T) { + app, _ := newFilesTestApp(t, []string{"files.write"}) + app.eventBus = events.NewBus() + app.storage = storage.New(app.vault) + app.contribRegistry = contribution.NewRegistry() + app.plugins = append(app.plugins, plugin.Plugin{ + Manifest: plugin.Manifest{ + ID: "verstak.activity", + Name: "Activity", + Version: "1.0.0", + Provides: []string{"activity.log"}, + Permissions: []string{"storage.namespace"}, + }, + Status: plugin.StatusLoaded, + Enabled: true, + }) + + if errStr := app.CreateVaultFolder("files.plugin", "Project"); errStr != "" { + t.Fatalf("CreateVaultFolder Project: %s", errStr) + } + if errStr := app.CreateVaultFolder("files.plugin", "Project/Notes"); errStr != "" { + t.Fatalf("CreateVaultFolder Project/Notes: %s", errStr) + } + + app.contribRegistry.Register("verstak.activity", &plugin.Contributions{ + ActivityProviders: []plugin.ContributionActivityProvider{{ + ID: "verstak.activity.log", + Events: []string{"file.changed"}, + Handler: "recordActivityEvent", + }}, + }) + app.ensureActivityProviderSubscriptions() + + if errStr := app.WriteVaultTextFile("files.plugin", "Project/Notes/one.txt", "hello", corefiles.WriteOptions{CreateIfMissing: true}); errStr != "" { + t.Fatalf("WriteVaultTextFile: %s", errStr) + } + + settings, err := app.storage.ReadPluginSettings("verstak.activity") + if err != nil { + t.Fatalf("ReadPluginSettings: %v", err) + } + stored, ok := settings["events:workspace:Project"].([]interface{}) + if !ok { + t.Fatalf("events:workspace:Project = %#v, want []interface{}", settings["events:workspace:Project"]) + } + if len(stored) != 1 { + t.Fatalf("stored %d activity events, want 1", len(stored)) + } + activity, ok := stored[0].(map[string]interface{}) + if !ok { + t.Fatalf("activity = %#v, want map[string]interface{}", stored[0]) + } + if activity["type"] != "file.changed" { + t.Fatalf("activity type = %#v, want file.changed", activity["type"]) + } + if activity["workspaceRootPath"] != "Project" { + t.Fatalf("activity workspaceRootPath = %#v, want Project", activity["workspaceRootPath"]) + } + if activity["sourcePluginId"] != "files.plugin" { + t.Fatalf("activity sourcePluginId = %#v, want files.plugin", activity["sourcePluginId"]) + } +} + func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) { app, root := newFilesTestApp(t, []string{"files.openExternal"}) filePath := filepath.Join(root, "Docs", "one.txt") diff --git a/internal/core/contribution/registry.go b/internal/core/contribution/registry.go index e22a419..625b7a7 100644 --- a/internal/core/contribution/registry.go +++ b/internal/core/contribution/registry.go @@ -316,6 +316,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider { return result } +func (r *Registry) ActivityProviders() []ContributionActivityProvider { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]ContributionActivityProvider, len(r.activityProviders)) + copy(result, r.activityProviders) + sort.Slice(result, func(i, j int) bool { + if result[i].PluginID != result[j].PluginID { + return result[i].PluginID < result[j].PluginID + } + return result[i].Item.ID < result[j].Item.ID + }) + return result +} + func (r *Registry) OpenProviders() []ContributionOpenProvider { r.mu.RLock() defer r.mu.RUnlock()