mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
feat: archived collections can be listed, restored and undone; the archive confirm shows the count (TASK-2189) (#1874)
* feat: archived collections can be listed and restored (TASK-2189, server, CLI, MCP v0.72) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * feat(web): archive shows the item count, guards big collections, and can be undone or restored (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * fix: list-archived is read-only, an unknown count needs typing, instructions say v0.72; store tests on both dialects incl. a restore race (TASK-2189) codex r1. Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS * test: README tool-surface version 0.72; classify the archive Undo's restore continuations (TASK-2189) Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
This commit is contained in:
@@ -390,13 +390,13 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
|
||||
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
|
||||
the per-user clients (including Codex) and skip it.
|
||||
|
||||
**Tool catalog (v0.71)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
**Tool catalog (v0.72)** — ten resource × action tools plus `pad_set_workspace`. Undeclared input keys are rejected with a structured error rather than silently dropped, and field values are typed against the collection schema on the server:
|
||||
|
||||
| Tool | Actions |
|
||||
|---|---|
|
||||
| `pad_item` | `create`, `update`, `delete`, `get`, `list`, `move`, `restore`, `link`, `unlink`, `deps`, `star`, `unstar`, `starred`, `comment`, `list-comments`, `edit-comment`, `delete-comment`, `backlinks`, `bulk-update`, `note`, `decide`, `export`, `import`, `history`, `remind`, `ack-reminder`, `claim`, `release` |
|
||||
| `pad_workspace` | `list`, `members`, `invite`, `storage`, `audit-log`, `create`, `claim`, `deleted`, `restore` |
|
||||
| `pad_collection` | `list`, `create`, `update`, `delete` |
|
||||
| `pad_collection` | `list`, `create`, `update`, `delete`, `list-archived`, `restore` |
|
||||
| `pad_project` | `dashboard`, `next`, `ready`, `stale`, `standup`, `changelog`, `report`, `activity` |
|
||||
| `pad_role` | `list`, `create`, `update`, `delete` |
|
||||
| `pad_search` | `query` |
|
||||
@@ -414,7 +414,7 @@ Plus resources at `pad://workspaces`, `pad://workspace/{ws}/dashboard`,
|
||||
and `pad://_meta/version`.
|
||||
|
||||
**Stability contract.** The catalog is versioned
|
||||
(`tool_surface_version: "0.71"`) and advertised in the initialize handshake and at
|
||||
(`tool_surface_version: "0.72"`) and advertised in the initialize handshake and at
|
||||
`pad://_meta/version`, so external agents can pin against it; the per-version
|
||||
changelog lives in [`internal/mcp/version.go`](internal/mcp/version.go). Errors
|
||||
come back as structured envelopes (`{error: {code, message, hint, ...}}`) from a
|
||||
|
||||
@@ -389,7 +389,7 @@ issue-ID equivalent for collections themselves.`,
|
||||
func collectionsDeleteCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "delete <slug>",
|
||||
Short: "Soft-delete a non-default collection (owner-only, irreversible from CLI)",
|
||||
Short: "Archive a non-default collection and hide its items (owner-only; restore with 'pad collection restore')",
|
||||
Long: `Soft-delete a collection by slug.
|
||||
|
||||
Constraints:
|
||||
@@ -398,11 +398,11 @@ Constraints:
|
||||
marked is_default=true). For seeded collections, use
|
||||
'pad collection update' to adapt them (rename, reshape schema,
|
||||
swap icon) instead.
|
||||
- Items in the collection are NOT cascaded — they remain in the
|
||||
database with the soft-deleted collection_id. The web UI hides
|
||||
them; the API still surfaces them if queried directly.
|
||||
- No 'undelete' subcommand or restore endpoint exists; recovery
|
||||
is only possible from a database backup.`,
|
||||
- Items in the collection are NOT deleted — they stay in the
|
||||
database, hidden with the collection.
|
||||
- Reversible: 'pad collection archived' lists archived collections
|
||||
and 'pad collection restore <slug>' brings one back with its items
|
||||
(TASK-2189).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
@@ -411,7 +411,59 @@ Constraints:
|
||||
if err := client.DeleteCollection(ws, collSlug); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Deleted collection %s\n", collSlug)
|
||||
fmt.Printf("Archived collection %s (restore with: pad collection restore %s)\n", collSlug, collSlug)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- archived / restore (TASK-2189) ---
|
||||
|
||||
func collectionsArchivedCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "archived",
|
||||
Short: "List archived collections and their item counts (owner-only)",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
list, err := client.ListArchivedCollections(ws)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if formatFlag == "json" {
|
||||
return cli.PrintJSON(list)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
fmt.Println("No archived collections.")
|
||||
return nil
|
||||
}
|
||||
for _, a := range list {
|
||||
fmt.Printf("%-24s %-8s %5d items archived %s\n", a.Slug, a.Prefix, a.ItemCount, a.ArchivedAt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func collectionsRestoreCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "restore <slug>",
|
||||
Short: "Restore an archived collection, with its items (owner-only)",
|
||||
Long: `Restore an archived collection by its slug (or id). Its items come back
|
||||
with it: archiving never deleted them. See 'pad collection archived'.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
client, _ := getClient()
|
||||
ws := getWorkspace()
|
||||
coll, err := client.RestoreCollection(ws, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if formatFlag == "json" {
|
||||
return cli.PrintJSON(coll)
|
||||
}
|
||||
fmt.Printf("Restored collection %s\n", coll.Slug)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -169,6 +169,8 @@ func collectionCmd() *cobra.Command {
|
||||
collectionsCreateCmd(),
|
||||
collectionsUpdateCmd(),
|
||||
collectionsDeleteCmd(),
|
||||
collectionsArchivedCmd(),
|
||||
collectionsRestoreCmd(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+4
-2
@@ -77,7 +77,7 @@
|
||||
"idempotentHint": false,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "Collection management — list, create, update, and delete collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Soft-delete a collection. Owner-only. No restore endpoint\n exists — recovery requires a database backup. Required:\n workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT cascaded — they remain\n in the database with the soft-deleted collection_id.\n The web UI hides them; raw API queries still surface\n them.\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
|
||||
"description": "Collection management — list, create, update, archive (delete) and restore collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Archive a collection: it and its items leave every view. Owner-only.\n Reversible with restore. Required: workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT deleted — they stay in the\n database, hidden with the collection, and come back with it.\n list-archived — The workspace's archived collections, each with its slug,\n id, prefix, item_count and archived_at. Owner-only.\n Required: workspace.\n restore — Restore an archived collection, with its items. Owner-only.\n Required: workspace, slug (the archived collection's slug from\n list-archived, or its id).\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -86,6 +86,8 @@
|
||||
"create",
|
||||
"delete",
|
||||
"list",
|
||||
"list-archived",
|
||||
"restore",
|
||||
"update"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -138,7 +140,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"slug": {
|
||||
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
|
||||
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+4
-2
@@ -77,7 +77,7 @@
|
||||
"idempotentHint": false,
|
||||
"openWorldHint": false
|
||||
},
|
||||
"description": "Collection management — list, create, update, and delete collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Soft-delete a collection. Owner-only. No restore endpoint\n exists — recovery requires a database backup. Required:\n workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT cascaded — they remain\n in the database with the soft-deleted collection_id.\n The web UI hides them; raw API queries still surface\n them.\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
|
||||
"description": "Collection management — list, create, update, archive (delete) and restore collection types.\n\nActions:\n list — List collections in the workspace with their schemas + counts.\n Required: workspace.\n create — Create a new collection.\n Required: workspace, name.\n Optional: fields OR schema (mutually exclusive), icon, description,\n layout, default_view, board_group_by.\n DSL example: fields=\"status:select:open,done; priority:select:high,medium,low\"\n Relation in the DSL: the third part is the TARGET COLLECTION slug,\n not an options list, and it is required —\n fields=\"reviewer:relation:people\". A relation with no target is\n refused rather than built, because such a field cannot accept any\n value.\n Schema example: schema={\"fields\":[{\"key\":\"status\",\"type\":\"select\",\"options\":[\"new\",\"done\"],\"terminal_options\":[\"done\"]}]}\n Prefer schema when terminal_options matters (driving dashboard\n active/complete counts) or when fields need defaults, computed\n flags, or suffixes — the DSL cannot express those.\n update — Update an existing collection's name, icon, description, prefix,\n schema, or sort order. Workspace owner only.\n Required: workspace, slug.\n Optional: name, icon, description, prefix, fields OR schema\n (mutually exclusive), sort_order. Only fields you\n explicitly set are mutated; everything else is left\n untouched.\n This is the adaptation primitive for the onboarding playbook\n (/pad onboard) — rewrite seeded collections to match the\n project's actual vocabulary instead of template defaults.\n delete — Archive a collection: it and its items leave every view. Owner-only.\n Reversible with restore. Required: workspace, slug.\n Constraints:\n - Cannot delete a default (template-seeded) collection.\n Adapt those via update instead (rename, reshape schema).\n - Items in the collection are NOT deleted — they stay in the\n database, hidden with the collection, and come back with it.\n list-archived — The workspace's archived collections, each with its slug,\n id, prefix, item_count and archived_at. Owner-only.\n Required: workspace.\n restore — Restore an archived collection, with its items. Owner-only.\n Required: workspace, slug (the archived collection's slug from\n list-archived, or its id).\n\nSchema for an individual collection is included in the list response — read it\nfrom there rather than calling list again. v0.2 does not expose a dedicated\nread-by-slug action; if you need that, file a feature request.\n\nUse pad_collection when an agent needs to discover collection types or set up\na new one. For item-level CRUD inside a collection, use pad_item.",
|
||||
"inputSchema": {
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -86,6 +86,8 @@
|
||||
"create",
|
||||
"delete",
|
||||
"list",
|
||||
"list-archived",
|
||||
"restore",
|
||||
"update"
|
||||
],
|
||||
"type": "string"
|
||||
@@ -138,7 +140,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"slug": {
|
||||
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
|
||||
"description": "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
|
||||
"type": "string"
|
||||
},
|
||||
"sort_order": {
|
||||
|
||||
@@ -230,6 +230,23 @@ func (c *Client) DeleteCollection(wsSlug, collSlug string) error {
|
||||
return c.delete("/workspaces/" + wsSlug + "/collections/" + collSlug)
|
||||
}
|
||||
|
||||
// ListArchivedCollections returns the workspace's archived collections
|
||||
// (TASK-2189).
|
||||
func (c *Client) ListArchivedCollections(wsSlug string) ([]models.ArchivedCollection, error) {
|
||||
var out []models.ArchivedCollection
|
||||
err := c.get("/workspaces/"+wsSlug+"/archived-collections", &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RestoreCollection un-archives a collection by slug or id (TASK-2189).
|
||||
func (c *Client) RestoreCollection(wsSlug, collRef string) (*models.Collection, error) {
|
||||
var out models.Collection
|
||||
if err := c.post("/workspaces/"+wsSlug+"/archived-collections/"+url.PathEscape(collRef)+"/restore", nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// --- Items ---
|
||||
|
||||
// ListItems returns items across all collections in a workspace.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package mcp
|
||||
|
||||
// padCollectionTool exposes collection management. Four actions:
|
||||
// list (read-only), create (admin-mutating), update (admin-mutating),
|
||||
// delete (admin-mutating).
|
||||
// padCollectionTool exposes collection management. Six actions:
|
||||
// list and list-archived (read-only), create, update, delete (archive) and
|
||||
// restore (admin-mutating). restore and list-archived are TASK-2189: an
|
||||
// agent's archive is recoverable by an agent.
|
||||
//
|
||||
// `update` (TASK-1510) and `delete` (TASK-1511) are the adaptation
|
||||
// primitives for the `/pad onboard` playbook (PLAN-1496): the agent
|
||||
@@ -30,7 +31,7 @@ var padCollectionTool = ToolDef{
|
||||
{
|
||||
Name: "slug",
|
||||
Type: "string",
|
||||
Description: "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update and action=delete; identifies which collection to mutate.",
|
||||
Description: "Collection slug (e.g. \"tasks\", \"conventions\"). Required for action=update, action=delete and action=restore; identifies which collection to mutate. For restore, the archived collection's slug (from list-archived) or its id.",
|
||||
},
|
||||
{
|
||||
Name: "name",
|
||||
@@ -91,10 +92,13 @@ var padCollectionTool = ToolDef{
|
||||
"create": passThrough([]string{"collection", "create"}),
|
||||
"update": passThrough([]string{"collection", "update"}),
|
||||
"delete": passThrough([]string{"collection", "delete"}),
|
||||
// TASK-2189: an agent's archive is recoverable by an agent.
|
||||
"list-archived": passThrough([]string{"collection", "archived"}),
|
||||
"restore": passThrough([]string{"collection", "restore"}),
|
||||
},
|
||||
}
|
||||
|
||||
const padCollectionToolDescription = `Collection management — list, create, update, and delete collection types.
|
||||
const padCollectionToolDescription = `Collection management — list, create, update, archive (delete) and restore collection types.
|
||||
|
||||
Actions:
|
||||
list — List collections in the workspace with their schemas + counts.
|
||||
@@ -123,16 +127,19 @@ Actions:
|
||||
This is the adaptation primitive for the onboarding playbook
|
||||
(/pad onboard) — rewrite seeded collections to match the
|
||||
project's actual vocabulary instead of template defaults.
|
||||
delete — Soft-delete a collection. Owner-only. No restore endpoint
|
||||
exists — recovery requires a database backup. Required:
|
||||
workspace, slug.
|
||||
delete — Archive a collection: it and its items leave every view. Owner-only.
|
||||
Reversible with restore. Required: workspace, slug.
|
||||
Constraints:
|
||||
- Cannot delete a default (template-seeded) collection.
|
||||
Adapt those via update instead (rename, reshape schema).
|
||||
- Items in the collection are NOT cascaded — they remain
|
||||
in the database with the soft-deleted collection_id.
|
||||
The web UI hides them; raw API queries still surface
|
||||
them.
|
||||
- Items in the collection are NOT deleted — they stay in the
|
||||
database, hidden with the collection, and come back with it.
|
||||
list-archived — The workspace's archived collections, each with its slug,
|
||||
id, prefix, item_count and archived_at. Owner-only.
|
||||
Required: workspace.
|
||||
restore — Restore an archived collection, with its items. Owner-only.
|
||||
Required: workspace, slug (the archived collection's slug from
|
||||
list-archived, or its id).
|
||||
|
||||
Schema for an individual collection is included in the list response — read it
|
||||
from there rather than calling list again. v0.2 does not expose a dedicated
|
||||
|
||||
@@ -95,6 +95,9 @@ func TestReadOnlyCatalog_ActionsMatchCmdhelp(t *testing.T) {
|
||||
{"pad_collection", "create"}: {"collection", "create"},
|
||||
{"pad_collection", "update"}: {"collection", "update"},
|
||||
{"pad_collection", "delete"}: {"collection", "delete"},
|
||||
// TASK-2189: archive is reversible.
|
||||
{"pad_collection", "list-archived"}: {"collection", "archived"},
|
||||
{"pad_collection", "restore"}: {"collection", "restore"},
|
||||
|
||||
{"pad_project", "dashboard"}: {"project", "dashboard"},
|
||||
{"pad_project", "next"}: {"project", "next"},
|
||||
@@ -258,6 +261,9 @@ func TestReadOnlyCatalog_ActionsDispatchExpectedCmdPath(t *testing.T) {
|
||||
{"pad_collection", "create"}: {"collection", "create"},
|
||||
{"pad_collection", "update"}: {"collection", "update"},
|
||||
{"pad_collection", "delete"}: {"collection", "delete"},
|
||||
// TASK-2189: archive is reversible.
|
||||
{"pad_collection", "list-archived"}: {"collection", "archived"},
|
||||
{"pad_collection", "restore"}: {"collection", "restore"},
|
||||
|
||||
{"pad_project", "dashboard"}: {"project", "dashboard"},
|
||||
{"pad_project", "next"}: {"project", "next"},
|
||||
@@ -528,6 +534,12 @@ func liveCmdhelpDoc(t *testing.T) *cmdhelp.Document {
|
||||
Args: mkArgs("slug"),
|
||||
Flags: mkFlags("workspace"),
|
||||
},
|
||||
"collection archived": {Summary: "list archived cols", Flags: mkFlags("workspace")},
|
||||
"collection restore": {
|
||||
Summary: "restore col",
|
||||
Args: mkArgs("slug"),
|
||||
Flags: mkFlags("workspace"),
|
||||
},
|
||||
// Project
|
||||
"project dashboard": {Summary: "dash", Flags: mkFlags("workspace")},
|
||||
"project next": {Summary: "next", Flags: mkFlags("workspace")},
|
||||
|
||||
@@ -309,53 +309,55 @@ var ChatGPTCatalog = []ChatGPTTool{
|
||||
// ChatGPTExclusions are the /mcp operations deliberately not on the ChatGPT
|
||||
// surface, each with its reason (TASK-3321 U1-T, lead-ruled).
|
||||
var ChatGPTExclusions = map[ChatGPTSource]string{
|
||||
{Tool: chatGPTSetWorkspaceSource}: "on this shared server it persists no session workspace, so it would only duplicate get_workspace_overview, the connect step",
|
||||
{"pad_item", "bulk-update"}: "batch overwrite with a large blast radius and little chat value",
|
||||
{"pad_item", "move"}: "drops fields the target collection does not declare; confusing in chat",
|
||||
{"pad_item", "delete-comment"}: "destructive, rarely needed",
|
||||
{"pad_item", "edit-comment"}: "overwrite; deferred to v2",
|
||||
{"pad_item", "unlink"}: "deferred to v2",
|
||||
{"pad_item", "claim"}: "agent lease semantics, not a chat concept",
|
||||
{"pad_item", "release"}: "agent lease semantics, not a chat concept",
|
||||
{"pad_item", "remind"}: "deferred to v2",
|
||||
{"pad_item", "ack-reminder"}: "deferred to v2",
|
||||
{"pad_item", "star"}: "personal UI state",
|
||||
{"pad_item", "unstar"}: "personal UI state",
|
||||
{"pad_item", "starred"}: "personal UI state",
|
||||
{"pad_item", "note"}: "structured implementation-note writer for coding agents",
|
||||
{"pad_item", "decide"}: "structured decision-log writer for coding agents",
|
||||
{"pad_item", "import"}: "artifact files, not chat",
|
||||
{"pad_item", "export"}: "artifact files, not chat",
|
||||
{"pad_item", "backlinks"}: "deferred to v2",
|
||||
{"pad_workspace", "create"}: "cloud signup creates the first workspace; a user with none is pointed at app.getpad.dev",
|
||||
{"pad_workspace", "invite"}: "sends email to an arbitrary address; kept to the web app",
|
||||
{"pad_workspace", "claim"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "restore"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "deleted"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "members"}: "returns member emails (fails data minimization)",
|
||||
{"pad_workspace", "audit-log"}: "returns IP addresses and user agents (fails data minimization)",
|
||||
{"pad_workspace", "storage"}: "administration",
|
||||
{"pad_collection", "create"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "update"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "delete"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "create"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "update"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "delete"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "list"}: "agent-role configuration, not used in chat",
|
||||
{"pad_project", "changelog"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "standup"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "report"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "stale"}: "overlaps ready_items and the dashboard's attention list; v2",
|
||||
{"pad_library", "list"}: "workspace configuration",
|
||||
{"pad_library", "get"}: "workspace configuration",
|
||||
{"pad_library", "activate"}: "workspace configuration (creates items)",
|
||||
{"pad_meta", "server-info"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_meta", "tool-surface"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_meta", "version"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_playbook", "match"}: "needs the decision provider and sends text to it; v2",
|
||||
{"pad_playbook", "run"}: "returns a body for an agent to execute; get_playbook serves read-and-follow",
|
||||
{"pad_attachment", "list"}: "metadata only with no display story yet; v2",
|
||||
{"pad_attachment", "show"}: "metadata only with no display story yet; v2",
|
||||
{Tool: chatGPTSetWorkspaceSource}: "on this shared server it persists no session workspace, so it would only duplicate get_workspace_overview, the connect step",
|
||||
{"pad_item", "bulk-update"}: "batch overwrite with a large blast radius and little chat value",
|
||||
{"pad_item", "move"}: "drops fields the target collection does not declare; confusing in chat",
|
||||
{"pad_item", "delete-comment"}: "destructive, rarely needed",
|
||||
{"pad_item", "edit-comment"}: "overwrite; deferred to v2",
|
||||
{"pad_item", "unlink"}: "deferred to v2",
|
||||
{"pad_item", "claim"}: "agent lease semantics, not a chat concept",
|
||||
{"pad_item", "release"}: "agent lease semantics, not a chat concept",
|
||||
{"pad_item", "remind"}: "deferred to v2",
|
||||
{"pad_item", "ack-reminder"}: "deferred to v2",
|
||||
{"pad_item", "star"}: "personal UI state",
|
||||
{"pad_item", "unstar"}: "personal UI state",
|
||||
{"pad_item", "starred"}: "personal UI state",
|
||||
{"pad_item", "note"}: "structured implementation-note writer for coding agents",
|
||||
{"pad_item", "decide"}: "structured decision-log writer for coding agents",
|
||||
{"pad_item", "import"}: "artifact files, not chat",
|
||||
{"pad_item", "export"}: "artifact files, not chat",
|
||||
{"pad_item", "backlinks"}: "deferred to v2",
|
||||
{"pad_workspace", "create"}: "cloud signup creates the first workspace; a user with none is pointed at app.getpad.dev",
|
||||
{"pad_workspace", "invite"}: "sends email to an arbitrary address; kept to the web app",
|
||||
{"pad_workspace", "claim"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "restore"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "deleted"}: "workspace lifecycle; web app",
|
||||
{"pad_workspace", "members"}: "returns member emails (fails data minimization)",
|
||||
{"pad_workspace", "audit-log"}: "returns IP addresses and user agents (fails data minimization)",
|
||||
{"pad_workspace", "storage"}: "administration",
|
||||
{"pad_collection", "create"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "update"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "delete"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "list-archived"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_collection", "restore"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "create"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "update"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "delete"}: "workspace configuration; web app or onboarding",
|
||||
{"pad_role", "list"}: "agent-role configuration, not used in chat",
|
||||
{"pad_project", "changelog"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "standup"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "report"}: "deferred to v2 (long output)",
|
||||
{"pad_project", "stale"}: "overlaps ready_items and the dashboard's attention list; v2",
|
||||
{"pad_library", "list"}: "workspace configuration",
|
||||
{"pad_library", "get"}: "workspace configuration",
|
||||
{"pad_library", "activate"}: "workspace configuration (creates items)",
|
||||
{"pad_meta", "server-info"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_meta", "tool-surface"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_meta", "version"}: "operator diagnostics (fails data minimization)",
|
||||
{"pad_playbook", "match"}: "needs the decision provider and sends text to it; v2",
|
||||
{"pad_playbook", "run"}: "returns a body for an agent to execute; get_playbook serves read-and-follow",
|
||||
{"pad_attachment", "list"}: "metadata only with no display story yet; v2",
|
||||
{"pad_attachment", "show"}: "metadata only with no display story yet; v2",
|
||||
}
|
||||
|
||||
// ChatGPTExperimentalCapabilities is the ChatGPT server's
|
||||
|
||||
@@ -355,6 +355,15 @@ func init() {
|
||||
method: http.MethodDelete,
|
||||
pathTemplate: "/api/v1/workspaces/{workspace}/collections/{slug}",
|
||||
}.toRouteMapper(),
|
||||
// TASK-2189: archive is now reversible, by an agent too.
|
||||
"collection archived": routeSpec{
|
||||
method: http.MethodGet,
|
||||
pathTemplate: "/api/v1/workspaces/{workspace}/archived-collections",
|
||||
}.toRouteMapper(),
|
||||
"collection restore": routeSpec{
|
||||
method: http.MethodPost,
|
||||
pathTemplate: "/api/v1/workspaces/{workspace}/archived-collections/{slug}/restore",
|
||||
}.toRouteMapper(),
|
||||
"role list": routeSpec{
|
||||
method: http.MethodGet,
|
||||
pathTemplate: "/api/v1/workspaces/{workspace}/agent-roles",
|
||||
|
||||
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
|
||||
|
||||
If the user is asking general code questions with no project-management thread, you don't need this server.
|
||||
|
||||
## Tool surface (v0.71)
|
||||
## Tool surface (v0.72)
|
||||
|
||||
Ten resource × action tools, plus `pad_set_workspace`. Eleven tools total.
|
||||
|
||||
@@ -36,7 +36,7 @@ If ANY element fails, the whole write is refused and nothing is stored — never
|
||||
In `relation_targets`, a `multi_relation` key carries a JSON **ARRAY** of the same `{id, ref, title}` objects, in stored order and one per stored element — including an `id`-only entry for an element that names nothing, so a position in the list lines up with the same position in `fields`. A scalar key is unchanged: still a single object, byte-identical to v0.31. Decide which shape to expect from the field's declared type, not by probing.
|
||||
|
||||
- `pad_workspace` — Workspaces: list / members / invite / storage / audit-log / create / claim / deleted / restore.
|
||||
- `pad_collection` — Collections: list / create / update / delete.
|
||||
- `pad_collection` — Collections: list / create / update / delete / list-archived / restore. `delete` archives: the collection and its items leave every view, and `restore` brings them back.
|
||||
- `pad_project` — Project intelligence: dashboard / next / ready / stale / standup / changelog / report / activity. Use `ready` for the ranked top items to work on next (blocked work is left out) and `stale` for items needing attention; `activity` to catch up on what other agents/users changed since you last worked (non-streaming feed with item refs + change details).
|
||||
- `pad_role` — Agent roles: list / create / update / delete.
|
||||
- `pad_search` — Full-text search across items: query.
|
||||
|
||||
@@ -94,6 +94,8 @@ var readOnlyActions = map[string]map[string]bool{
|
||||
},
|
||||
"pad_collection": {
|
||||
"list": true,
|
||||
// TASK-2189: restore is a write; list-archived is read-only.
|
||||
"list-archived": true,
|
||||
},
|
||||
"pad_project": {
|
||||
"dashboard": true,
|
||||
|
||||
+10
-1
@@ -1738,6 +1738,15 @@ const CmdhelpVersion = "0.1"
|
||||
// caller cannot read embeds nothing; a failed fetch stays non-fatal,
|
||||
// as on stdio. Stdio is unchanged.
|
||||
//
|
||||
// 0.72 — TASK-2189 (lead ruling). ADDITIVE bump on the v0.8 / v0.13
|
||||
// grounds (existing CLI verbs wired onto the catalog): `pad_collection`
|
||||
// gains `list-archived` (read-only: the workspace's archived
|
||||
// collections with slug, id, prefix, item_count and archived_at) and
|
||||
// `restore` (owner-only: un-archive a collection, named by `slug` or
|
||||
// id, with its items, which the archive never deleted). The `delete`
|
||||
// description no longer says "No restore endpoint exists": an agent's
|
||||
// archive is now recoverable by an agent. Nothing existing moved.
|
||||
//
|
||||
// 0.71 — TASK-3462 U3a (lead ruling). ADDITIVE bump on the v0.63 /
|
||||
// v0.53 grounds: the bootstrap every bootstrap door returns
|
||||
// (`pad_set_workspace`'s embed, `pad_meta.action=bootstrap`, the
|
||||
@@ -1853,7 +1862,7 @@ const CmdhelpVersion = "0.1"
|
||||
// this surface can receive it; the entry exists so a future action does
|
||||
// not collapse it to permission_denied. When an action that can reach
|
||||
// it is added, that addition is the contract change and owns the bump.
|
||||
const ToolSurfaceVersion = "0.71"
|
||||
const ToolSurfaceVersion = "0.72"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
@@ -289,3 +289,16 @@ func (u *CollectionUpdate) UnmarshalJSON(data []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ArchivedCollection is one archived (soft-deleted) collection, as the
|
||||
// restore surfaces list it (TASK-2189). ItemCount counts its live items: an
|
||||
// archive hides them behind the collection's deleted_at and deletes none.
|
||||
type ArchivedCollection struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Prefix string `json:"prefix"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
ItemCount int `json:"item_count"`
|
||||
ArchivedAt string `json:"archived_at"`
|
||||
}
|
||||
|
||||
@@ -714,3 +714,93 @@ func uniqueCollectionConflictMessage(err error) string {
|
||||
return "This change conflicts with an existing unique value"
|
||||
}
|
||||
}
|
||||
|
||||
// handleListArchivedCollections answers GET /workspaces/{ws}/archived-collections
|
||||
// (TASK-2189): the workspace's archived collections with their item counts.
|
||||
// Owner-only, like the archive it lists.
|
||||
func (s *Server) handleListArchivedCollections(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMinRole(w, r, "owner") {
|
||||
return
|
||||
}
|
||||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListArchivedCollections(workspaceID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
// The live list's visibility rule (a restricted owner sees only the
|
||||
// collections granted to them), so this is not a door onto hidden ones.
|
||||
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if visibleIDs != nil {
|
||||
kept := list[:0]
|
||||
for _, a := range list {
|
||||
if isCollectionVisible(a.ID, visibleIDs) {
|
||||
kept = append(kept, a)
|
||||
}
|
||||
}
|
||||
list = kept
|
||||
}
|
||||
writeJSON(w, http.StatusOK, list)
|
||||
}
|
||||
|
||||
// handleRestoreCollection answers POST
|
||||
// /workspaces/{ws}/archived-collections/{collRef}/restore (TASK-2189), by id or
|
||||
// slug: it
|
||||
// un-archives the collection, and its items come back with it, since the
|
||||
// archive never deleted them. 404 for an id that is not an archived collection
|
||||
// of this workspace. Owner-only, like the archive it reverses.
|
||||
func (s *Server) handleRestoreCollection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMinRole(w, r, "owner") {
|
||||
return
|
||||
}
|
||||
workspaceID, ok := s.getWorkspaceID(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
const notArchived = "No archived collection with that slug or id in this workspace"
|
||||
id, err := s.store.ResolveArchivedCollection(workspaceID, chi.URLParam(r, "collRef"))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", notArchived)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
// The archive's own visibility rule: a collection the caller could not
|
||||
// archive (hidden from a restricted owner) is not theirs to restore, and
|
||||
// answers the same 404 as one that does not exist.
|
||||
visible, err := s.checkCollectionFullyVisible(r, workspaceID, id)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if !visible {
|
||||
writeError(w, http.StatusNotFound, "not_found", notArchived)
|
||||
return
|
||||
}
|
||||
coll, err := s.store.RestoreCollection(workspaceID, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeError(w, http.StatusNotFound, "not_found", notArchived)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
// The archive's own side effect, reversed: the collection's grants apply
|
||||
// again and its items re-enter views, so connections re-check (TASK-3365).
|
||||
// The event tells open clients to refetch collections; the item-sync
|
||||
// epoch fingerprints the live collection set, so delta clients resync and
|
||||
// receive the restored items (BUG-3428).
|
||||
s.invalidateWorkspaceAccess(workspaceID)
|
||||
s.publishCollectionEvent(events.CollectionUpdated, workspaceID, coll.ID, coll.Slug, "", true)
|
||||
writeJSON(w, http.StatusOK, coll)
|
||||
}
|
||||
|
||||
@@ -2081,6 +2081,13 @@ func (s *Server) setupRouter() {
|
||||
})
|
||||
|
||||
// Collections (v2)
|
||||
// Archived collections (TASK-2189): list and restore. A
|
||||
// sibling of /collections rather than inside it, because
|
||||
// /collections/{collSlug} resolves LIVE collections only
|
||||
// and a collection could itself be slugged "archived".
|
||||
r.Get("/archived-collections", s.handleListArchivedCollections)
|
||||
r.Post("/archived-collections/{collRef}/restore", s.handleRestoreCollection)
|
||||
|
||||
r.Route("/collections", func(r chi.Router) {
|
||||
r.Get("/", s.handleListCollections)
|
||||
r.Post("/", s.handleCreateCollection)
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// TASK-2189: archiving a collection hides it and its items; nothing could
|
||||
// bring them back. GET archived-collections lists what is archived and POST
|
||||
// .../restore brings one back, items included (the archive never deleted them).
|
||||
|
||||
func archivedList(t *testing.T, srv *Server, ws string) []models.ArchivedCollection {
|
||||
t.Helper()
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/archived-collections", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list archived: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out []models.ArchivedCollection
|
||||
parseJSON(t, rr, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func seedCollectionWithItems(t *testing.T, srv *Server, ws, name string, n int) models.Collection {
|
||||
t.Helper()
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections", map[string]any{"name": name})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create collection: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var coll models.Collection
|
||||
parseJSON(t, rr, &coll)
|
||||
for i := 0; i < n; i++ {
|
||||
irr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug+"/items", map[string]any{"title": name + " item"})
|
||||
if irr.Code != http.StatusCreated {
|
||||
t.Fatalf("create item: %d %s", irr.Code, irr.Body.String())
|
||||
}
|
||||
}
|
||||
return coll
|
||||
}
|
||||
|
||||
func itemCount(t *testing.T, srv *Server, ws, collSlug string) (int, int) {
|
||||
t.Helper()
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/collections/"+collSlug+"/items", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
return rr.Code, -1
|
||||
}
|
||||
var items []models.Item
|
||||
parseJSON(t, rr, &items)
|
||||
return rr.Code, len(items)
|
||||
}
|
||||
|
||||
func TestRestoreCollection_TASK2189_RoundTripWithItems(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
ws := createWSWithCollections(t, srv)
|
||||
coll := seedCollectionWithItems(t, srv, ws, "Field Notes", 3)
|
||||
|
||||
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug, nil); rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("archive: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if code, _ := itemCount(t, srv, ws, coll.Slug); code != http.StatusNotFound {
|
||||
t.Fatalf("archived collection's items answered %d, want 404", code)
|
||||
}
|
||||
list := archivedList(t, srv, ws)
|
||||
if len(list) != 1 || list[0].ID != coll.ID || list[0].Slug != coll.Slug || list[0].ItemCount != 3 || list[0].ArchivedAt == "" {
|
||||
t.Fatalf("archived list = %+v, want the collection with 3 items", list)
|
||||
}
|
||||
|
||||
// The sync epoch fingerprints the live collection set: a restore must move
|
||||
// it, so delta clients resync and receive the restored items (BUG-3428).
|
||||
epoch := func() string {
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+ws+"/items-changes?since=0", nil)
|
||||
var body struct {
|
||||
AccessEpoch string `json:"access_epoch"`
|
||||
}
|
||||
parseJSON(t, rr, &body)
|
||||
return body.AccessEpoch
|
||||
}
|
||||
before := epoch()
|
||||
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.Slug+"/restore", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("restore by slug: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if code, n := itemCount(t, srv, ws, coll.Slug); code != http.StatusOK || n != 3 {
|
||||
t.Fatalf("after restore: %d with %d items, want 200 with 3", code, n)
|
||||
}
|
||||
if len(archivedList(t, srv, ws)) != 0 {
|
||||
t.Fatal("a restored collection is still listed as archived")
|
||||
}
|
||||
if after := epoch(); after == before {
|
||||
t.Fatalf("the item-sync epoch did not move on restore (%q): delta clients would never see the items", after)
|
||||
}
|
||||
|
||||
// By id, too; and a second restore of a live collection is a 404.
|
||||
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+coll.Slug, nil); rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("re-archive: %d", rr.Code)
|
||||
}
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.ID+"/restore", nil); rr.Code != http.StatusOK {
|
||||
t.Fatalf("restore by id: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+coll.Slug+"/restore", nil); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("restoring a LIVE collection answered %d, want 404", rr.Code)
|
||||
}
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/no-such-thing/restore", nil); rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("restoring an unknown ref answered %d, want 404", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A newer collection with the archived one's name took `x-2`, so a restore
|
||||
// brings the old one back beside it, both reachable.
|
||||
func TestRestoreCollection_TASK2189_BesideANewerSameNamedCollection(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
ws := createWSWithCollections(t, srv)
|
||||
old := seedCollectionWithItems(t, srv, ws, "Notes", 1)
|
||||
if rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+ws+"/collections/"+old.Slug, nil); rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("archive: %d", rr.Code)
|
||||
}
|
||||
newer := seedCollectionWithItems(t, srv, ws, "Notes", 2)
|
||||
if newer.Slug == old.Slug {
|
||||
t.Fatalf("the newer collection reused the archived slug %q", old.Slug)
|
||||
}
|
||||
if rr := doRequest(srv, "POST", "/api/v1/workspaces/"+ws+"/archived-collections/"+old.Slug+"/restore", nil); rr.Code != http.StatusOK {
|
||||
t.Fatalf("restore: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
if _, n := itemCount(t, srv, ws, old.Slug); n != 1 {
|
||||
t.Fatalf("restored collection has %d items, want 1", n)
|
||||
}
|
||||
if _, n := itemCount(t, srv, ws, newer.Slug); n != 2 {
|
||||
t.Fatalf("newer collection has %d items, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// A restricted owner can neither list nor restore an archived collection they
|
||||
// could not see live; the visible one works.
|
||||
func TestRestoreCollection_TASK2189_RestrictedOwnerVisibility(t *testing.T) {
|
||||
f := newRestrictedOwnerVisibilityFixture(t)
|
||||
for _, c := range []*models.Collection{f.hiddenColl, f.visibleColl} {
|
||||
if err := f.srv.store.DeleteCollection(c.ID, ""); err != nil {
|
||||
t.Fatalf("archive %s: %v", c.Slug, err)
|
||||
}
|
||||
}
|
||||
rr := doRequestWithHeaders(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections", nil, f.bearerHeaders())
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var list []models.ArchivedCollection
|
||||
parseJSON(t, rr, &list)
|
||||
if len(list) != 1 || list[0].ID != f.visibleColl.ID {
|
||||
t.Fatalf("restricted owner's archived list = %+v, want only the visible collection", list)
|
||||
}
|
||||
hidden := doRequestWithHeaders(f.srv, "POST", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections/"+f.hiddenColl.ID+"/restore", nil, f.bearerHeaders())
|
||||
if hidden.Code != http.StatusNotFound {
|
||||
t.Fatalf("restoring a hidden collection answered %d, want 404", hidden.Code)
|
||||
}
|
||||
still, err := f.srv.store.ListArchivedCollections(f.ws.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hiddenStillArchived := false
|
||||
for _, a := range still {
|
||||
hiddenStillArchived = hiddenStillArchived || a.ID == f.hiddenColl.ID
|
||||
}
|
||||
if !hiddenStillArchived {
|
||||
t.Fatal("the hidden collection was restored by a caller who cannot see it")
|
||||
}
|
||||
visible := doRequestWithHeaders(f.srv, "POST", "/api/v1/workspaces/"+f.ws.Slug+"/archived-collections/"+f.visibleColl.ID+"/restore", nil, f.bearerHeaders())
|
||||
if visible.Code != http.StatusOK {
|
||||
t.Fatalf("restoring the visible collection answered %d: %s", visible.Code, visible.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// TASK-2189: archiving a collection (DeleteCollection) is a soft delete of the
|
||||
// collection row; its items are untouched and only hidden by that row's
|
||||
// deleted_at, and nothing purges an archived collection. These two make that
|
||||
// reversible: list what is archived, and restore one by clearing deleted_at.
|
||||
//
|
||||
// A restore cannot collide: slugs are allocated unique across archived rows
|
||||
// too (uniqueSlugQ), so a newer collection with the same name took `x-2`; and
|
||||
// prefixes are not unique even among live collections (item numbers are
|
||||
// workspace-unique, so refs stay unambiguous).
|
||||
|
||||
// ListArchivedCollections returns the workspace's archived collections, most
|
||||
// recently archived first, each with its count of live items.
|
||||
func (s *Store) ListArchivedCollections(workspaceID string) ([]models.ArchivedCollection, error) {
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT c.id, c.name, c.slug, c.prefix, c.icon, c.deleted_at,
|
||||
(SELECT COUNT(*) FROM items i WHERE i.collection_id = c.id AND i.deleted_at IS NULL)
|
||||
FROM collections c
|
||||
WHERE c.workspace_id = ? AND c.deleted_at IS NOT NULL
|
||||
ORDER BY c.deleted_at DESC, c.id`), workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list archived collections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []models.ArchivedCollection{}
|
||||
for rows.Next() {
|
||||
var a models.ArchivedCollection
|
||||
var icon sql.NullString
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Slug, &a.Prefix, &icon, &a.ArchivedAt, &a.ItemCount); err != nil {
|
||||
return nil, fmt.Errorf("scan archived collection: %w", err)
|
||||
}
|
||||
a.Icon = icon.String
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ResolveArchivedCollection returns the id of this workspace's ARCHIVED
|
||||
// collection named by ref, its id or its slug (slugs are unique per workspace
|
||||
// across archived rows too, so either names at most one). sql.ErrNoRows when
|
||||
// ref names none (unknown, another workspace's, or live), so a caller cannot
|
||||
// tell those apart.
|
||||
func (s *Store) ResolveArchivedCollection(workspaceID, ref string) (string, error) {
|
||||
var id string
|
||||
err := s.db.QueryRow(s.q(`
|
||||
SELECT id FROM collections
|
||||
WHERE workspace_id = ? AND (id = ? OR slug = ?) AND deleted_at IS NOT NULL`), workspaceID, ref, ref).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// RestoreCollection un-archives this workspace's archived collection id.
|
||||
// sql.ErrNoRows when it is not one (already restored by a racing call, or
|
||||
// never archived).
|
||||
func (s *Store) RestoreCollection(workspaceID, id string) (*models.Collection, error) {
|
||||
ts := now()
|
||||
res, err := s.db.Exec(s.q(`
|
||||
UPDATE collections SET deleted_at = NULL, updated_at = ?
|
||||
WHERE id = ? AND workspace_id = ? AND deleted_at IS NOT NULL`), ts, id, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore collection: %w", err)
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return nil, fmt.Errorf("restore collection: %w", err)
|
||||
} else if n == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return s.GetCollection(id)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package store_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
"github.com/PerpetualSoftware/pad/internal/store"
|
||||
"github.com/PerpetualSoftware/pad/internal/store/storetest"
|
||||
)
|
||||
|
||||
// TASK-2189: list and restore archived collections, on both dialects (the
|
||||
// deleted_at scan and the item-count subquery), and a restore race.
|
||||
|
||||
func TestArchivedCollections_SQLite(t *testing.T) { archivedCollections(t, storetest.NewSQLite(t)) }
|
||||
func TestArchivedCollections_Postgres(t *testing.T) { archivedCollections(t, storetest.NewPostgres(t)) }
|
||||
|
||||
func archivedCollections(t *testing.T, s *store.Store) {
|
||||
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Archive 2189"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Field Notes"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if _, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{Title: "note"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := s.DeleteCollection(coll.ID, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
list, err := s.ListArchivedCollections(ws.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != coll.ID || list[0].ItemCount != 2 || list[0].ArchivedAt == "" {
|
||||
t.Fatalf("archived = %+v, want the collection with 2 items and a time", list)
|
||||
}
|
||||
|
||||
// A live slug, an unknown ref and another workspace's ref name nothing.
|
||||
other, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Other 2189"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range []struct{ ws, ref string }{{ws.ID, "nope"}, {other.ID, coll.ID}, {other.ID, coll.Slug}} {
|
||||
if _, err := s.ResolveArchivedCollection(c.ws, c.ref); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("ResolveArchivedCollection(%s, %s) = %v, want ErrNoRows", c.ws, c.ref, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Two concurrent restores: exactly one wins, the other is told there is
|
||||
// nothing archived to restore.
|
||||
id, err := s.ResolveArchivedCollection(ws.ID, coll.Slug)
|
||||
if err != nil || id != coll.ID {
|
||||
t.Fatalf("resolve by slug = %q, %v", id, err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
wins, losses := 0, 0
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := s.RestoreCollection(ws.ID, id)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err == nil:
|
||||
wins++
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
losses++
|
||||
default:
|
||||
t.Errorf("restore: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if wins != 1 || losses != 1 {
|
||||
t.Fatalf("concurrent restores: %d won, %d lost; want exactly one of each", wins, losses)
|
||||
}
|
||||
live, err := s.GetCollection(coll.ID)
|
||||
if err != nil || live == nil || live.DeletedAt != nil {
|
||||
t.Fatalf("after restore: %+v %v, want the live collection", live, err)
|
||||
}
|
||||
if list, _ := s.ListArchivedCollections(ws.ID); len(list) != 0 {
|
||||
t.Fatalf("still listed as archived: %+v", list)
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,8 @@ import type {
|
||||
TutorialsResponse,
|
||||
UIDismissalKey,
|
||||
UIDismissalsResponse,
|
||||
ImportOutcome
|
||||
ImportOutcome,
|
||||
ArchivedCollection
|
||||
} from '$lib/types';
|
||||
import { reportWorkspaceWrite } from './workspaceWrites';
|
||||
import { noteServerDate } from './serverClock';
|
||||
@@ -1533,6 +1534,15 @@ export const api = {
|
||||
list: (ws: string) =>
|
||||
request<Collection[]>(`/workspaces/${ws}/collections`),
|
||||
|
||||
// TASK-2189: archive is reversible. Owner-only, like the archive.
|
||||
archived: (ws: string) =>
|
||||
request<ArchivedCollection[]>(`/workspaces/${ws}/archived-collections`),
|
||||
// By id or slug; its items come back with it.
|
||||
restore: (ws: string, ref: string) =>
|
||||
request<Collection>(`/workspaces/${ws}/archived-collections/${encodeURIComponent(ref)}/restore`, {
|
||||
method: 'POST'
|
||||
}),
|
||||
|
||||
create: (ws: string, data: CollectionCreate) =>
|
||||
request<Collection>(`/workspaces/${ws}/collections`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
// TASK-2189: the workspace's archived collections, each restorable with its
|
||||
// items (an archive never deleted them). Owner-only, like the archive; the
|
||||
// caller renders it only for owners and the server enforces it.
|
||||
import { api } from '$lib/api/client';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { ArchivedCollection } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
wsSlug: string;
|
||||
/** Changes whenever the live collection list does, so an archive or a
|
||||
* restore elsewhere refreshes this list. */
|
||||
refreshKey: string;
|
||||
onrestored?: () => void;
|
||||
}
|
||||
let { wsSlug, refreshKey, onrestored }: Props = $props();
|
||||
|
||||
let archived = $state<ArchivedCollection[]>([]);
|
||||
let loaded = $state(false);
|
||||
let restoring = $state<string | null>(null);
|
||||
let latest = 0;
|
||||
|
||||
$effect(() => {
|
||||
const ws = wsSlug;
|
||||
void refreshKey;
|
||||
const token = ++latest;
|
||||
const isSameIdentity = authStore.identityFence();
|
||||
if (!ws) return;
|
||||
api.collections
|
||||
.archived(ws)
|
||||
.then((list) => {
|
||||
if (token !== latest || !isSameIdentity()) return;
|
||||
archived = list;
|
||||
loaded = true;
|
||||
})
|
||||
.catch(() => {
|
||||
if (token !== latest || !isSameIdentity()) return;
|
||||
loaded = true;
|
||||
});
|
||||
return () => {
|
||||
latest++;
|
||||
};
|
||||
});
|
||||
|
||||
async function restore(a: ArchivedCollection) {
|
||||
const ws = wsSlug;
|
||||
const isSameIdentity = authStore.identityFence();
|
||||
restoring = a.id;
|
||||
try {
|
||||
await api.collections.restore(ws, a.id);
|
||||
if (!isSameIdentity() || ws !== wsSlug) return;
|
||||
archived = archived.filter((x) => x.id !== a.id);
|
||||
toastStore.show(`Restored "${a.name}" with its ${a.item_count} ${a.item_count === 1 ? 'item' : 'items'}`, 'success');
|
||||
onrestored?.();
|
||||
} catch {
|
||||
if (!isSameIdentity()) return;
|
||||
toastStore.show(`Failed to restore "${a.name}"`, 'error');
|
||||
} finally {
|
||||
if (restoring === a.id) restoring = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if loaded && archived.length > 0}
|
||||
<div class="archived" data-testid="archived-collections">
|
||||
<h3 class="archived-title">Archived collections</h3>
|
||||
<p class="archived-hint">Archived collections and their items are hidden, not deleted. Restore brings both back.</p>
|
||||
<ul class="archived-list">
|
||||
{#each archived as a (a.id)}
|
||||
<li class="archived-row">
|
||||
<span class="archived-icon" aria-hidden="true">{a.icon || '#'}</span>
|
||||
<span class="archived-name">{a.name}</span>
|
||||
<span class="archived-slug mono">/{a.slug}</span>
|
||||
<span class="archived-count">{a.item_count} {a.item_count === 1 ? 'item' : 'items'}</span>
|
||||
<span class="archived-date" title={new Date(a.archived_at).toLocaleString()}
|
||||
>archived {new Date(a.archived_at).toLocaleDateString()}</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-restore"
|
||||
disabled={restoring === a.id}
|
||||
onclick={() => restore(a)}
|
||||
aria-label={`Restore ${a.name}`}
|
||||
>
|
||||
{restoring === a.id ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.archived {
|
||||
margin-top: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.archived-title {
|
||||
margin: 0;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
.archived-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.archived-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
.archived-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.archived-name {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.archived-slug,
|
||||
.archived-count,
|
||||
.archived-date {
|
||||
font-size: 0.8em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-restore {
|
||||
margin-left: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -76,12 +76,24 @@
|
||||
|
||||
let confirmArchive = $state(false);
|
||||
let archiving = $state(false);
|
||||
// TASK-2189: archiving a big collection asks for its name to be typed, as
|
||||
// deleting a workspace asks for its slug. Below the threshold, the count
|
||||
// in the confirm is the guard.
|
||||
const TYPED_ARCHIVE_THRESHOLD = 25;
|
||||
let archiveTyped = $state('');
|
||||
const archiveItemCount = $derived(collection.item_count);
|
||||
// An UNKNOWN count fails closed (codex r1): the confirm cannot say how
|
||||
// much is going, so the typed guard stands in for the number.
|
||||
const archiveNeedsTyping = $derived(archiveItemCount === undefined || archiveItemCount >= TYPED_ARCHIVE_THRESHOLD);
|
||||
const archiveConfirmed = $derived(!archiveNeedsTyping || archiveTyped.trim() === collection.name);
|
||||
|
||||
async function handleArchive() {
|
||||
if (!confirmArchive) {
|
||||
confirmArchive = true;
|
||||
archiveTyped = '';
|
||||
return;
|
||||
}
|
||||
if (!archiveConfirmed) return;
|
||||
archiving = true;
|
||||
// Target the SEEDED collection identity, NOT the live props — the props
|
||||
// may now identify a different collection while this form still shows
|
||||
@@ -100,8 +112,27 @@
|
||||
// delete -> list -> delete, so the second DELETE — destructive — would
|
||||
// otherwise be issued on behalf of a user who is gone.
|
||||
const isSameIdentity = authStore.identityFence();
|
||||
// The Undo outlives this modal, so it carries what it restores and the
|
||||
// identity it was offered to, checked again at CLICK time: the workspace
|
||||
// delete's pattern (BUG-3006), since the toast store is global.
|
||||
const epochAtArchive = authStore.identityEpoch;
|
||||
const finishArchived = () => {
|
||||
toastStore.show(`Archived "${editedCollectionName}"`, 'success');
|
||||
toastStore.show(`Archived "${editedCollectionName}"`, 'success', 12000, undefined, {
|
||||
label: 'Undo',
|
||||
onAction: () => {
|
||||
if (authStore.identityEpoch !== epochAtArchive) return;
|
||||
api.collections
|
||||
.restore(editedWsSlug, editedCollectionId)
|
||||
.then(() => {
|
||||
if (authStore.identityEpoch !== epochAtArchive) return;
|
||||
toastStore.show(`Restored "${editedCollectionName}"`, 'success');
|
||||
})
|
||||
.catch(() => {
|
||||
if (authStore.identityEpoch !== epochAtArchive) return;
|
||||
toastStore.show(`Failed to restore "${editedCollectionName}"`, 'error');
|
||||
});
|
||||
}
|
||||
});
|
||||
onupdated(undefined, editedCollectionId, editedCollectionSlug, editedWsSlug);
|
||||
onclose();
|
||||
};
|
||||
@@ -794,22 +825,39 @@
|
||||
<header class="danger-zone-header">
|
||||
<h3 id="danger-zone-heading" class="danger-zone-title">Danger zone</h3>
|
||||
<p class="danger-zone-hint">
|
||||
Archiving removes this collection and all its items from the workspace.
|
||||
This can't be undone from the UI.
|
||||
Archiving hides this collection and all its items from the workspace.
|
||||
Nothing is deleted: restore it any time from Settings › Collections.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if confirmArchive}
|
||||
<div class="danger-zone-confirm" role="alertdialog" aria-labelledby="archive-confirm-msg">
|
||||
<p id="archive-confirm-msg" class="danger-zone-confirm-msg">
|
||||
Archive <strong>"{collection.name}"</strong> and all its items?
|
||||
{#if archiveItemCount !== undefined}
|
||||
Archive <strong>"{collection.name}"</strong> and its
|
||||
{archiveItemCount} {archiveItemCount === 1 ? 'item' : 'items'}?
|
||||
{:else}
|
||||
Archive <strong>"{collection.name}"</strong> and all its items?
|
||||
{/if}
|
||||
</p>
|
||||
{#if archiveNeedsTyping}
|
||||
<label class="danger-zone-typed">
|
||||
Type <strong>{collection.name}</strong> to confirm
|
||||
<input
|
||||
type="text"
|
||||
bind:value={archiveTyped}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label={`Type ${collection.name} to confirm archiving`}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
<div class="danger-zone-confirm-actions">
|
||||
<button
|
||||
class="btn-archive-confirm"
|
||||
type="button"
|
||||
onclick={handleArchive}
|
||||
disabled={archiving}
|
||||
disabled={archiving || !archiveConfirmed}
|
||||
>
|
||||
{archiving ? 'Archiving…' : 'Yes, archive'}
|
||||
</button>
|
||||
@@ -1249,6 +1297,21 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.danger-zone-typed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
font-size: 0.85em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.danger-zone-typed input {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
font: inherit;
|
||||
}
|
||||
.danger-zone-confirm-msg {
|
||||
margin: 0;
|
||||
font-size: 0.9em;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync, mount, unmount, tick } from 'svelte';
|
||||
import type { ArchivedCollection, Collection } from '$lib/types';
|
||||
|
||||
/**
|
||||
* TASK-2189: archiving a collection showed no count, had a two-click guard
|
||||
* whatever its size, and could not be undone. Now the confirm names the item
|
||||
* count, a big collection needs its name typed, the success toast carries an
|
||||
* Undo that restores it, and Settings lists archived collections to restore.
|
||||
*/
|
||||
const api = vi.hoisted(() => ({
|
||||
del: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
archived: vi.fn()
|
||||
}));
|
||||
const toastShow = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
api: {
|
||||
collections: {
|
||||
update: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
delete: (...a: unknown[]) => api.del(...a),
|
||||
restore: (...a: unknown[]) => api.restore(...a),
|
||||
archived: (...a: unknown[]) => api.archived(...a)
|
||||
},
|
||||
items: { listByCollection: vi.fn().mockResolvedValue([]) }
|
||||
},
|
||||
isConflictOrNotFound: () => false
|
||||
}));
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
||||
toastStore: { show: toastShow, dismiss: vi.fn(), get toasts() { return []; } }
|
||||
}));
|
||||
|
||||
const { default: EditCollectionModal } = await import('./EditCollectionModal.svelte');
|
||||
const { default: ArchivedCollections } = await import('./ArchivedCollections.svelte');
|
||||
|
||||
let host: HTMLElement;
|
||||
let app: Record<string, unknown> | null = null;
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
flushSync();
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await Promise.resolve();
|
||||
await tick();
|
||||
}
|
||||
flushSync();
|
||||
}
|
||||
|
||||
function coll(itemCount: number): Collection {
|
||||
return {
|
||||
id: 'c1',
|
||||
slug: 'field-notes',
|
||||
name: 'Field Notes',
|
||||
icon: '',
|
||||
description: '',
|
||||
prefix: 'FN',
|
||||
schema: JSON.stringify({ fields: [] }),
|
||||
settings: '{}',
|
||||
is_default: false,
|
||||
item_count: itemCount,
|
||||
updated_at: '2026-10-08T00:00:00Z'
|
||||
} as unknown as Collection;
|
||||
}
|
||||
|
||||
function button(text: RegExp): HTMLButtonElement | undefined {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')].find((b) => text.test(b.textContent?.trim() ?? ''));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
api.del.mockReset().mockResolvedValue(undefined);
|
||||
api.restore.mockReset().mockResolvedValue({ id: 'c1', slug: 'field-notes', name: 'Field Notes' });
|
||||
api.archived.mockReset();
|
||||
toastShow.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (app) unmount(app as never);
|
||||
app = null;
|
||||
host.remove();
|
||||
});
|
||||
|
||||
async function openConfirm(itemCount: number) {
|
||||
app = mount(EditCollectionModal, {
|
||||
target: host,
|
||||
props: { open: true, collection: coll(itemCount), wsSlug: 'ws', onclose: () => {}, onupdated: () => {} }
|
||||
}) as Record<string, unknown>;
|
||||
await settle();
|
||||
button(/^Archive collection$/)!.click();
|
||||
await settle();
|
||||
}
|
||||
|
||||
describe('archive confirm', () => {
|
||||
it('names the item count, and a small collection needs no typing', async () => {
|
||||
await openConfirm(3);
|
||||
expect(document.body.textContent?.replace(/\s+/g, ' ')).toContain('Archive "Field Notes" and its 3 items?');
|
||||
expect(document.body.textContent).not.toMatch(/can't be undone/);
|
||||
expect(document.body.textContent).toContain('restore it any time');
|
||||
expect(document.querySelector('.danger-zone-typed')).toBeNull();
|
||||
expect(button(/^Yes, archive$/)!.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('a collection of 25 or more items needs its name typed first', async () => {
|
||||
await openConfirm(25);
|
||||
const yes = button(/^Yes, archive$/)!;
|
||||
expect(yes.disabled).toBe(true);
|
||||
const input = document.querySelector<HTMLInputElement>('.danger-zone-typed input')!;
|
||||
input.value = 'Field Note';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
expect(yes.disabled).toBe(true);
|
||||
input.value = 'Field Notes';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await settle();
|
||||
expect(yes.disabled).toBe(false);
|
||||
yes.click();
|
||||
await settle();
|
||||
expect(api.del).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('an UNKNOWN item count fails closed: the name must be typed', async () => {
|
||||
await openConfirm(undefined as unknown as number);
|
||||
expect(document.querySelector('.danger-zone-typed')).not.toBeNull();
|
||||
expect(button(/^Yes, archive$/)!.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('the success toast carries an Undo that restores the collection by id', async () => {
|
||||
await openConfirm(2);
|
||||
button(/^Yes, archive$/)!.click();
|
||||
await settle();
|
||||
const call = toastShow.mock.calls.find((c) => String(c[0]).startsWith('Archived'));
|
||||
expect(call, 'an Archived toast').toBeTruthy();
|
||||
const action = call![4] as { label: string; onAction: () => void };
|
||||
expect(action.label).toBe('Undo');
|
||||
action.onAction();
|
||||
await settle();
|
||||
expect(api.restore).toHaveBeenCalledWith('ws', 'c1');
|
||||
expect(toastShow.mock.calls.some((c) => String(c[0]).startsWith('Restored'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings › Collections › Archived collections', () => {
|
||||
const archived: ArchivedCollection[] = [
|
||||
{ id: 'c1', name: 'Field Notes', slug: 'field-notes', prefix: 'FN', item_count: 214, archived_at: '2026-10-08T00:00:00Z' }
|
||||
];
|
||||
|
||||
it('lists archived collections with their counts and restores one', async () => {
|
||||
api.archived.mockResolvedValue(archived);
|
||||
const onrestored = vi.fn();
|
||||
app = mount(ArchivedCollections, { target: host, props: { wsSlug: 'ws', refreshKey: 'a', onrestored } }) as Record<string, unknown>;
|
||||
await settle();
|
||||
expect(document.body.textContent).toContain('Field Notes');
|
||||
expect(document.body.textContent).toContain('214 items');
|
||||
button(/^Restore$/)!.click();
|
||||
await settle();
|
||||
expect(api.restore).toHaveBeenCalledWith('ws', 'c1');
|
||||
expect(onrestored).toHaveBeenCalledOnce();
|
||||
expect(document.querySelector('[data-testid="archived-collections"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders nothing when nothing is archived', async () => {
|
||||
api.archived.mockResolvedValue([]);
|
||||
app = mount(ArchivedCollections, { target: host, props: { wsSlug: 'ws', refreshKey: 'a' } }) as Record<string, unknown>;
|
||||
await settle();
|
||||
expect(document.querySelector('[data-testid="archived-collections"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -297,6 +297,14 @@ const KNOWN_CONTINUATIONS: Record<string, string> = {
|
||||
'lib/components/items/CopyItemDialog.svelte::<callback of $effect>::tick.then': 'ui: focus',
|
||||
'lib/components/items/CopyItemDialog.svelte::resetForOpen::loadDestCollections.then':
|
||||
'fenced: flowGen and identityFence() captured in resetForOpen; selects the BUG-3200 preset only from the list loadDestCollections fenced',
|
||||
// TASK-2189: the archive toast's Undo. The toast outlives the modal, so it
|
||||
// carries the identity epoch captured at archive time, as the workspace
|
||||
// delete's Undo does (BUG-3006), and both continuations re-check it before
|
||||
// toasting.
|
||||
'lib/components/collections/EditCollectionModal.svelte::onAction::api.collections.restore.then':
|
||||
'fenced: authStore.identityEpoch compared with the epoch captured at archive time (TASK-2189)',
|
||||
'lib/components/collections/EditCollectionModal.svelte::onAction::api.collections.restore.catch':
|
||||
'fenced: authStore.identityEpoch compared with the epoch captured at archive time (TASK-2189)',
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -3167,3 +3167,18 @@ export interface ImportOutcome {
|
||||
workspace_name?: string;
|
||||
owner_username?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An archived collection, from `GET /workspaces/{ws}/archived-collections`
|
||||
* (TASK-2189). Archiving hides the collection and its items; nothing is
|
||||
* deleted, and `api.collections.restore` brings both back.
|
||||
*/
|
||||
export interface ArchivedCollection {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
prefix: string;
|
||||
icon?: string;
|
||||
item_count: number;
|
||||
archived_at: string;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import { parseSchema } from '$lib/types';
|
||||
import CreateCollectionModal from '$lib/components/collections/CreateCollectionModal.svelte';
|
||||
import EditCollectionModal from '$lib/components/collections/EditCollectionModal.svelte';
|
||||
import ArchivedCollections from '$lib/components/collections/ArchivedCollections.svelte';
|
||||
import StorageTab from '$lib/components/settings/StorageTab.svelte';
|
||||
import AppsTab from '$lib/components/settings/apps/AppsTab.svelte';
|
||||
import Chip from '$lib/components/common/Chip.svelte';
|
||||
@@ -1236,6 +1237,13 @@
|
||||
onclose={() => (showCreateModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
{#if isOwner}
|
||||
<ArchivedCollections
|
||||
{wsSlug}
|
||||
refreshKey={collections.map((c) => c.id).join(',')}
|
||||
onrestored={() => void refreshCollections(wsSlug)}
|
||||
/>
|
||||
{/if}
|
||||
{#if editingCollection && isOwner}
|
||||
<EditCollectionModal
|
||||
open={true}
|
||||
|
||||
Reference in New Issue
Block a user