feat: retain archived browser inbox captures

This commit is contained in:
mirivlad 2026-07-12 17:43:12 +08:00
parent bcf49ed32c
commit fca9fff4b4
2 changed files with 183 additions and 5 deletions

View File

@ -188,15 +188,21 @@ func (a *App) ensureBrowserInboxSubscriptions() {
if a.browserInboxEvents == nil { if a.browserInboxEvents == nil {
a.browserInboxEvents = make(map[string]bool) a.browserInboxEvents = make(map[string]bool)
} }
for _, eventName := range []string{browserInboxMutationEvent} { for _, eventName := range []string{browserInboxMutationEvent, workspaceRenamedEventName, workspaceTrashedEventName, workspaceRestoredEventName, workspacePurgedEventName} {
if a.browserInboxEvents[eventName] { if a.browserInboxEvents[eventName] {
continue continue
} }
a.browserInboxEvents[eventName] = true a.browserInboxEvents[eventName] = true
a.eventBus.Subscribe(eventName, func(event events.Event) { a.eventBus.Subscribe(eventName, func(event events.Event) {
if event.Name == browserInboxMutationEvent {
if err := a.mutateBrowserInboxCapture(event); err != nil { if err := a.mutateBrowserInboxCapture(event); err != nil {
log.Printf("[api] browser inbox mutation failed: %v", err) log.Printf("[api] browser inbox mutation failed: %v", err)
} }
return
}
if err := a.updateBrowserInboxWorkspaceLifecycle(event); err != nil {
log.Printf("[api] browser inbox workspace lifecycle failed: %v", err)
}
}) })
} }
} }
@ -295,6 +301,10 @@ func (a *App) recordBrowserCapture(event events.Event) error {
if firstPayloadText(capture, "capturedAt") == "" { if firstPayloadText(capture, "capturedAt") == "" {
capture["capturedAt"] = event.Timestamp capture["capturedAt"] = event.Timestamp
} }
if firstPayloadText(capture, "globalState") == "" {
capture["globalState"] = "inbox"
}
a.annotateBrowserCaptureWorkspace(capture)
capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano) capture["receivedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} { return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
for _, stored := range captures { for _, stored := range captures {
@ -315,7 +325,7 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
} }
action := firstPayloadText(payload, "action") action := firstPayloadText(payload, "action")
switch action { switch action {
case "migrate", "assign", "delete", "processed": case "migrate", "assign", "archive", "restore", "delete", "processed":
default: default:
return fmt.Errorf("unsupported browser inbox mutation %q", action) return fmt.Errorf("unsupported browser inbox mutation %q", action)
} }
@ -345,10 +355,17 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
switch action { switch action {
case "delete": case "delete":
continue continue
case "archive":
capture["globalState"] = "archived"
case "restore":
capture["globalState"] = "inbox"
case "assign": case "assign":
workspaceRoot := firstPayloadText(payload, "workspaceRootPath") workspaceRoot := firstPayloadText(payload, "workspaceRootPath")
capture["workspaceRootPath"] = workspaceRoot capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot capture["workspaceName"] = workspaceRoot
delete(capture, "workspaceId")
delete(capture, "workspaceTrashId")
a.annotateBrowserCaptureWorkspace(capture)
case "processed": case "processed":
capture["processed"], _ = payload["processed"].(bool) capture["processed"], _ = payload["processed"].(bool)
} }
@ -358,6 +375,67 @@ func (a *App) mutateBrowserInboxCapture(event events.Event) error {
}) })
} }
func (a *App) annotateBrowserCaptureWorkspace(capture map[string]interface{}) {
if capture == nil {
return
}
workspaceRoot := firstPayloadText(capture, "workspaceRootPath")
if workspaceRoot == "" {
capture["workspaceState"] = "unassigned"
delete(capture, "workspaceId")
delete(capture, "workspaceTrashId")
return
}
if firstPayloadText(capture, "workspaceId") != "" {
if firstPayloadText(capture, "workspaceState") == "" {
capture["workspaceState"] = "active"
}
return
}
if a.workspace == nil {
capture["workspaceState"] = "unavailable"
return
}
identity, err := a.workspace.GetWorkspaceIdentity(workspaceRoot)
if err != nil {
capture["workspaceState"] = "unavailable"
return
}
capture["workspaceId"] = identity.WorkspaceID
capture["workspaceRootPath"] = identity.RootPath
capture["workspaceName"] = identity.RootPath
capture["workspaceState"] = identity.State
}
func (a *App) updateBrowserInboxWorkspaceLifecycle(event events.Event) error {
payload := eventPayloadMap(event.Payload)
workspaceID := firstPayloadText(payload, "workspaceId")
if workspaceID == "" {
return nil
}
return a.updateBrowserInboxCaptures(func(captures []map[string]interface{}) []map[string]interface{} {
for _, capture := range captures {
if firstPayloadText(capture, "workspaceId") != workspaceID {
continue
}
switch event.Name {
case workspaceRenamedEventName, workspaceRestoredEventName:
capture["workspaceRootPath"] = firstPayloadText(payload, "workspaceRootPath")
capture["workspaceName"] = firstPayloadText(payload, "workspaceName", "workspaceRootPath")
capture["workspaceState"] = "active"
delete(capture, "workspaceTrashId")
case workspaceTrashedEventName:
capture["workspaceState"] = "trashed"
capture["workspaceTrashId"] = firstPayloadText(payload, "trashId")
case workspacePurgedEventName:
capture["workspaceState"] = "orphaned"
delete(capture, "workspaceTrashId")
}
}
return captures
})
}
func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error { func (a *App) updateBrowserInboxCaptures(update func([]map[string]interface{}) []map[string]interface{}) error {
if !a.browserInboxAvailable() { if !a.browserInboxAvailable() {
return fmt.Errorf("browser inbox unavailable") return fmt.Errorf("browser inbox unavailable")
@ -411,6 +489,9 @@ func browserInboxCaptures(settings map[string]interface{}) ([]map[string]interfa
continue continue
} }
seen[captureID] = true seen[captureID] = true
if firstPayloadText(capture, "globalState") == "" {
capture["globalState"] = "inbox"
}
if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" { if firstPayloadText(capture, "workspaceRootPath") == "" && workspaceRoot != "" {
capture["workspaceRootPath"] = workspaceRoot capture["workspaceRootPath"] = workspaceRoot
capture["workspaceName"] = workspaceRoot capture["workspaceName"] = workspaceRoot

View File

@ -699,6 +699,9 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{ bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true, "pluginId": browserInboxPluginID, "action": "processed", "captureId": "capture-background", "processed": true,
}}) }})
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "capture-background",
}})
if err := app.recordBrowserCapture(events.Event{ if err := app.recordBrowserCapture(events.Event{
Name: "browser.capture.page", Name: "browser.capture.page",
Payload: map[string]interface{}{ Payload: map[string]interface{}{
@ -721,11 +724,105 @@ func TestBrowserInboxRecordsCaptureWithoutMountedView(t *testing.T) {
if !ok { if !ok {
t.Fatalf("capture = %#v, want map[string]interface{}", stored[0]) t.Fatalf("capture = %#v, want map[string]interface{}", stored[0])
} }
if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true { if capture["captureId"] != "capture-background" || capture["title"] != "Background capture" || capture["workspaceRootPath"] != "Project" || capture["processed"] != true || capture["globalState"] != "archived" {
t.Fatalf("stored capture = %#v", capture) t.Fatalf("stored capture = %#v", capture)
} }
} }
func TestBrowserInboxArchiveKeepsAssignmentUntilExplicitPermanentDelete(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "archive-1", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "archive", "captureId": "archive-1",
}})
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
captures, _ := settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 1 {
t.Fatalf("captures after archive = %#v, want retained capture", captures)
}
archived := captures[0].(map[string]interface{})
if archived["workspaceRootPath"] != "Project" || archived["globalState"] != "archived" {
t.Fatalf("archived capture = %#v, want preserved assignment", archived)
}
bus.Publish(events.Event{Name: browserInboxMutationEvent, Payload: map[string]interface{}{
"pluginId": browserInboxPluginID, "action": "delete", "captureId": "archive-1", "permanent": true,
}})
settings, err = app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings after delete: %v", err)
}
captures, _ = settings[browserInboxGlobalKey].([]interface{})
if len(captures) != 0 {
t.Fatalf("captures after permanent delete = %#v, want none", captures)
}
}
func TestBrowserInboxWorkspaceReferenceSurvivesRenameAndTrash(t *testing.T) {
v := vault.NewVault(nil)
if err := v.CreateVault(t.TempDir()); err != nil {
t.Fatalf("CreateVault: %v", err)
}
manager := workspace.NewManager(v.GetVaultPath())
created, err := manager.CreateWorkspace("Project", "minimal")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
bus := events.NewBus()
app := &App{
eventBus: bus,
storage: storage.New(v),
vault: v,
workspace: manager,
plugins: []plugin.Plugin{{
Manifest: plugin.Manifest{ID: browserInboxPluginID, Permissions: []string{"storage.namespace"}},
Status: plugin.StatusLoaded,
Enabled: true,
}},
}
app.ensureBrowserInboxSubscriptions()
if err := app.recordBrowserCapture(events.Event{Name: "browser.capture.page", Payload: map[string]interface{}{
"captureId": "workspace-ref", "capturedAt": "2026-07-12T10:00:00Z", "url": "https://example.com", "workspaceRootPath": "Project",
}}); err != nil {
t.Fatalf("recordBrowserCapture: %v", err)
}
if errStr := app.RenameWorkspace("Project", "Client"); errStr != "" {
t.Fatalf("RenameWorkspace: %s", errStr)
}
if _, errStr := app.TrashWorkspace("Client"); errStr != "" {
t.Fatalf("TrashWorkspace: %s", errStr)
}
settings, err := app.storage.ReadPluginSettings(browserInboxPluginID)
if err != nil {
t.Fatalf("ReadPluginSettings: %v", err)
}
capture := settings[browserInboxGlobalKey].([]interface{})[0].(map[string]interface{})
if capture["workspaceId"] != created.ID || capture["workspaceRootPath"] != "Client" || capture["workspaceState"] != "trashed" || capture["workspaceTrashId"] == "" {
t.Fatalf("workspace reference = %#v", capture)
}
}
func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) { func TestBrowserInboxRejectsCaptureWithoutOpenVault(t *testing.T) {
v := vault.NewVault(nil) v := vault.NewVault(nil)
app := &App{ app := &App{