feat: host activity providers
This commit is contained in:
parent
93597a2c45
commit
9729b432d6
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -34,6 +35,9 @@ var newSyncClient = syncsvc.NewClient
|
||||||
var emitFrontendEvent = runtime.EventsEmit
|
var emitFrontendEvent = runtime.EventsEmit
|
||||||
|
|
||||||
const pluginEventRuntimeName = "verstak:plugin-event"
|
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.
|
// App is the main application struct exposed to the Wails frontend.
|
||||||
type App struct {
|
type App struct {
|
||||||
|
|
@ -53,6 +57,7 @@ type App struct {
|
||||||
workspace *workspace.Manager
|
workspace *workspace.Manager
|
||||||
syncSvc *syncsvc.Service
|
syncSvc *syncsvc.Service
|
||||||
debug bool
|
debug bool
|
||||||
|
activityEvents map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type externalOpenService interface {
|
type externalOpenService interface {
|
||||||
|
|
@ -76,7 +81,7 @@ func NewApp(
|
||||||
syncService *syncsvc.Service,
|
syncService *syncsvc.Service,
|
||||||
debugEnabled bool,
|
debugEnabled bool,
|
||||||
) *App {
|
) *App {
|
||||||
return &App{
|
app := &App{
|
||||||
capRegistry: capReg,
|
capRegistry: capReg,
|
||||||
contribRegistry: contribReg,
|
contribRegistry: contribReg,
|
||||||
permRegistry: permReg,
|
permRegistry: permReg,
|
||||||
|
|
@ -92,7 +97,10 @@ func NewApp(
|
||||||
workspace: workspaceMgr,
|
workspace: workspaceMgr,
|
||||||
syncSvc: syncService,
|
syncSvc: syncService,
|
||||||
debug: debugEnabled,
|
debug: debugEnabled,
|
||||||
|
activityEvents: make(map[string]bool),
|
||||||
}
|
}
|
||||||
|
app.ensureActivityProviderSubscriptions()
|
||||||
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
func workbenchPrefsFromSettings(m *appsettings.Manager) coreworkbench.Preferences {
|
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.
|
// Startup is called when the app starts. Sets the Wails context for dialogs.
|
||||||
func (a *App) Startup(ctx context.Context) {
|
func (a *App) Startup(ctx context.Context) {
|
||||||
a.ctx = ctx
|
a.ctx = ctx
|
||||||
|
a.ensureActivityProviderSubscriptions()
|
||||||
log.Printf("[api] App.Startup: initialized with %d plugins", len(a.plugins))
|
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
|
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 ─────────────────────────────────────
|
// ─── Plugin Manager API ─────────────────────────────────────
|
||||||
|
|
||||||
// GetPlugins returns all discovered plugins.
|
// GetPlugins returns all discovered plugins.
|
||||||
|
|
@ -461,6 +599,7 @@ func (a *App) ReloadPlugins() (int, string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
a.plugins = plugins
|
a.plugins = plugins
|
||||||
|
a.ensureActivityProviderSubscriptions()
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
buf.WriteString("discovery complete")
|
buf.WriteString("discovery complete")
|
||||||
|
|
@ -706,6 +845,9 @@ func (a *App) WriteVaultTextFile(pluginID, relativePath string, content string,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
|
||||||
|
"operation": opType,
|
||||||
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -725,6 +867,10 @@ func (a *App) CreateVaultFolder(pluginID, relativePath string) string {
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
|
||||||
|
"operation": syncsvc.OpCreate,
|
||||||
|
"type": string(corefiles.FileTypeFolder),
|
||||||
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -749,6 +895,11 @@ func (a *App) MoveVaultPath(pluginID, fromRelativePath string, toRelativePath st
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err.Error()
|
return err.Error()
|
||||||
}
|
}
|
||||||
|
a.publishFileActivity("file.changed", pluginID, toRelativePath, map[string]interface{}{
|
||||||
|
"operation": syncsvc.OpMove,
|
||||||
|
"fromPath": fromRelativePath,
|
||||||
|
"type": string(meta.Type),
|
||||||
|
})
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -773,6 +924,10 @@ func (a *App) TrashVaultPath(pluginID, relativePath string) (corefiles.TrashResu
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return corefiles.TrashResult{}, err.Error()
|
return corefiles.TrashResult{}, err.Error()
|
||||||
}
|
}
|
||||||
|
a.publishFileActivity("file.changed", pluginID, relativePath, map[string]interface{}{
|
||||||
|
"operation": syncsvc.OpDelete,
|
||||||
|
"type": string(meta.Type),
|
||||||
|
})
|
||||||
return result, ""
|
return result, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -827,6 +982,38 @@ func (a *App) recordFileSyncOp(entityType, entityID, opType string, payload inte
|
||||||
return a.syncSvc.RecordOp(entityType, entityID, opType, payload)
|
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 {
|
func syncEntityTypeForFileType(fileType corefiles.FileType) string {
|
||||||
if fileType == corefiles.FileTypeFolder {
|
if fileType == corefiles.FileTypeFolder {
|
||||||
return syncsvc.EntityFolder
|
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) {
|
func TestFilesBridgeOpenExternalUsesVaultPathPolicyAndPermission(t *testing.T) {
|
||||||
app, root := newFilesTestApp(t, []string{"files.openExternal"})
|
app, root := newFilesTestApp(t, []string{"files.openExternal"})
|
||||||
filePath := filepath.Join(root, "Docs", "one.txt")
|
filePath := filepath.Join(root, "Docs", "one.txt")
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,20 @@ func (r *Registry) SearchProviders() []ContributionSearchProvider {
|
||||||
return result
|
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 {
|
func (r *Registry) OpenProviders() []ContributionOpenProvider {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue