feat: host activity providers
This commit is contained in:
parent
93597a2c45
commit
9729b432d6
|
|
@ -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 != "<nil>" {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in New Issue