feat: retain archived browser inbox captures
This commit is contained in:
parent
bcf49ed32c
commit
fca9fff4b4
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue