From 6daa8eb68b94c25efd21eb0f7f5cd49b77424a05 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sat, 28 Mar 2026 05:01:40 +0000 Subject: [PATCH] Add move item between collections with field migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-stack feature: move items between collections (e.g., idea → task) with automatic field migration. Backend: - Field migration engine (items/migrate.go) maps matching fields, handles type conversions, drops incompatible fields, applies defaults - Store method updates collection_id and assigns new item_number - POST /api/v1/workspaces/{ws}/items/{slug}/move endpoint - Activity logging with "moved" action and from/to metadata - 6 migration unit tests covering type matching, conversion, and edge cases CLI: - pad move [--field key=value ...] - Accepts singular collection names (task, idea, bug, etc.) Web UI: - "Move to..." dropdown on item detail page - Shows all collections except current with icons - Redirects to the item's new URL after move Field migration rules: - Same type: transfer directly (validate select options) - Compatible types (text↔url, number→text, select→text): auto-convert - Incompatible types: drop silently - Missing required target fields: apply defaults or error --- cmd/pad/main.go | 52 ++++++++ embed.go | 2 +- internal/cli/client.go | 5 + internal/items/migrate.go | 118 ++++++++++++++++++ internal/items/migrate_test.go | 117 +++++++++++++++++ internal/models/activity.go | 2 +- internal/server/handlers_items.go | 117 +++++++++++++++++ internal/server/server.go | 1 + internal/store/items.go | 34 +++++ web/src/lib/api/client.ts | 10 ++ .../[collection]/[slug]/+page.svelte | 71 +++++++++++ 11 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 internal/items/migrate.go create mode 100644 internal/items/migrate_test.go diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 034693e6..5c1008c9 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -61,6 +61,7 @@ func main() { showCmd(), updateCmd(), deleteCmd(), + moveCmd(), searchCmd(), statusCmd(), nextCmd(), @@ -1530,6 +1531,57 @@ func deleteCmd() *cobra.Command { } } +// --- move --- + +func moveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "move ", + Short: "Move an item to a different collection", + Long: `Move an item to a different collection with automatic field migration. + +Fields with matching names and compatible types transfer automatically. +Incompatible fields are dropped. Use --field to set values for target-specific fields. + +Examples: + pad move fix-oauth bugs # Move to bugs collection + pad move my-idea tasks --field priority=high # Move idea to tasks with priority`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, _ := getClient() + ws := getWorkspace() + + input := map[string]any{ + "target_collection": normalizeCollectionSlug(args[1]), + "actor": "user", + "source": "cli", + } + + // Parse field overrides + fieldFlags, _ := cmd.Flags().GetStringArray("field") + if len(fieldFlags) > 0 { + overrides := map[string]any{} + for _, f := range fieldFlags { + parts := strings.SplitN(f, "=", 2) + if len(parts) == 2 { + overrides[parts[0]] = parts[1] + } + } + input["field_overrides"] = overrides + } + + moved, err := client.MoveItem(ws, args[0], input) + if err != nil { + return err + } + + fmt.Printf("Moved %q to %s\n", moved.Title, args[1]) + return nil + }, + } + cmd.Flags().StringArray("field", nil, "set field values in target collection (key=value)") + return cmd +} + // --- comments --- func commentCmd() *cobra.Command { diff --git a/embed.go b/embed.go index 7b4581e2..4d596a26 100644 --- a/embed.go +++ b/embed.go @@ -8,4 +8,4 @@ var WebUI embed.FS //go:embed skills/pad/SKILL.md var PadSkill []byte -// embed cache bust: 1774673423 +// embed cache bust: 1774674060 diff --git a/internal/cli/client.go b/internal/cli/client.go index 9a5595f1..d191b592 100644 --- a/internal/cli/client.go +++ b/internal/cli/client.go @@ -133,6 +133,11 @@ func (c *Client) DeleteItem(wsSlug, itemSlug string) error { return c.delete("/workspaces/" + wsSlug + "/items/" + itemSlug) } +func (c *Client) MoveItem(wsSlug, itemSlug string, input map[string]any) (*models.Item, error) { + var result models.Item + return &result, c.post("/workspaces/"+wsSlug+"/items/"+itemSlug+"/move", input, &result) +} + // --- Links --- func (c *Client) GetItemLinks(wsSlug, itemSlug string) ([]models.ItemLink, error) { diff --git a/internal/items/migrate.go b/internal/items/migrate.go new file mode 100644 index 00000000..f50b3085 --- /dev/null +++ b/internal/items/migrate.go @@ -0,0 +1,118 @@ +package items + +import ( + "fmt" + "strconv" + + "github.com/xarmian/pad/internal/models" +) + +// MigrateResult holds the outcome of field migration between collection schemas. +type MigrateResult struct { + // Fields contains the migrated field values for the target schema. + Fields map[string]any + // Dropped lists field keys that were dropped during migration (no matching target field or incompatible types). + Dropped []string + // Errors lists required target fields that have no value after migration. + Errors []string +} + +// MigrateFields maps field values from a source schema to a target schema. +// Fields with matching keys and compatible types are transferred. +// Incompatible or missing fields are dropped. Required target fields without +// values after migration are reported as errors. +func MigrateFields( + currentFields map[string]any, + sourceSchema []models.FieldDef, + targetSchema []models.FieldDef, +) MigrateResult { + result := MigrateResult{ + Fields: make(map[string]any), + } + + // Build lookup of target fields by key + targetDefs := make(map[string]models.FieldDef) + for _, f := range targetSchema { + targetDefs[f.Key] = f + } + + // Build lookup of source fields by key + sourceDefs := make(map[string]models.FieldDef) + for _, f := range sourceSchema { + sourceDefs[f.Key] = f + } + + // Migrate each current field value + for key, value := range currentFields { + targetField, exists := targetDefs[key] + if !exists { + result.Dropped = append(result.Dropped, key) + continue + } + + sourceField := sourceDefs[key] + migrated, ok := migrateValue(value, sourceField.Type, targetField) + if ok { + result.Fields[key] = migrated + } else { + result.Dropped = append(result.Dropped, key) + } + } + + // Apply defaults for target fields not yet present + for _, f := range targetSchema { + if _, exists := result.Fields[f.Key]; exists { + continue + } + if f.Default != nil && f.Default != "" { + result.Fields[f.Key] = f.Default + } else if f.Required { + result.Errors = append(result.Errors, fmt.Sprintf("required field %q has no value", f.Key)) + } + } + + return result +} + +// migrateValue attempts to convert a value from sourceType to the targetField's type. +// Returns the migrated value and true if successful, or zero-value and false if incompatible. +func migrateValue(value any, sourceType string, target models.FieldDef) (any, bool) { + targetType := target.Type + + // Same type — validate further for select fields + if sourceType == targetType { + if targetType == "select" && target.Options != nil { + strVal := fmt.Sprintf("%v", value) + for _, opt := range target.Options { + if opt == strVal { + return value, true + } + } + // Value not in target options — drop it + return nil, false + } + return value, true + } + + // Compatible type conversions + strVal := fmt.Sprintf("%v", value) + switch { + case sourceType == "text" && targetType == "url": + return value, true + case sourceType == "url" && targetType == "text": + return value, true + case sourceType == "number" && targetType == "text": + return strVal, true + case sourceType == "select" && targetType == "text": + return strVal, true + case sourceType == "checkbox" && targetType == "text": + return strVal, true + case sourceType == "text" && targetType == "number": + if _, err := strconv.ParseFloat(strVal, 64); err == nil { + return value, true + } + return nil, false + default: + return nil, false + } +} diff --git a/internal/items/migrate_test.go b/internal/items/migrate_test.go new file mode 100644 index 00000000..d707c967 --- /dev/null +++ b/internal/items/migrate_test.go @@ -0,0 +1,117 @@ +package items + +import ( + "testing" + + "github.com/xarmian/pad/internal/models" +) + +func TestMigrateFields_MatchingTypes(t *testing.T) { + source := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"open", "done"}}, + {Key: "priority", Type: "select", Options: []string{"low", "high"}}, + } + target := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"open", "closed"}, Required: true}, + {Key: "priority", Type: "select", Options: []string{"low", "medium", "high"}}, + } + fields := map[string]any{"status": "open", "priority": "high"} + + result := MigrateFields(fields, source, target) + + if result.Fields["status"] != "open" { + t.Errorf("status: got %v, want 'open'", result.Fields["status"]) + } + if result.Fields["priority"] != "high" { + t.Errorf("priority: got %v, want 'high'", result.Fields["priority"]) + } + if len(result.Dropped) != 0 { + t.Errorf("dropped: got %v, want none", result.Dropped) + } +} + +func TestMigrateFields_SelectValueNotInTarget(t *testing.T) { + source := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"open", "in-progress", "done"}}, + } + target := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"todo", "doing", "done"}, Required: true, Default: "todo"}, + } + fields := map[string]any{"status": "in-progress"} + + result := MigrateFields(fields, source, target) + + // "in-progress" is not in target options, should be dropped and default applied + if result.Fields["status"] != "todo" { + t.Errorf("status: got %v, want 'todo' (default after drop)", result.Fields["status"]) + } +} + +func TestMigrateFields_DropsExtraFields(t *testing.T) { + source := []models.FieldDef{ + {Key: "severity", Type: "select", Options: []string{"low", "high"}}, + {Key: "browser", Type: "text"}, + } + target := []models.FieldDef{ + {Key: "priority", Type: "select", Options: []string{"low", "high"}}, + } + fields := map[string]any{"severity": "high", "browser": "Chrome"} + + result := MigrateFields(fields, source, target) + + if len(result.Dropped) != 2 { + t.Errorf("dropped: got %d, want 2", len(result.Dropped)) + } +} + +func TestMigrateFields_TypeConversion(t *testing.T) { + source := []models.FieldDef{ + {Key: "count", Type: "number"}, + {Key: "status", Type: "select", Options: []string{"open"}}, + } + target := []models.FieldDef{ + {Key: "count", Type: "text"}, + {Key: "status", Type: "text"}, + } + fields := map[string]any{"count": 42, "status": "open"} + + result := MigrateFields(fields, source, target) + + if result.Fields["count"] != "42" { + t.Errorf("count: got %v, want '42'", result.Fields["count"]) + } + if result.Fields["status"] != "open" { + t.Errorf("status: got %v, want 'open'", result.Fields["status"]) + } +} + +func TestMigrateFields_RequiredFieldMissing(t *testing.T) { + source := []models.FieldDef{} + target := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"open"}, Required: true}, + } + fields := map[string]any{} + + result := MigrateFields(fields, source, target) + + if len(result.Errors) != 1 { + t.Errorf("errors: got %d, want 1", len(result.Errors)) + } +} + +func TestMigrateFields_DefaultApplied(t *testing.T) { + source := []models.FieldDef{} + target := []models.FieldDef{ + {Key: "status", Type: "select", Options: []string{"open", "done"}, Required: true, Default: "open"}, + } + fields := map[string]any{} + + result := MigrateFields(fields, source, target) + + if result.Fields["status"] != "open" { + t.Errorf("status: got %v, want 'open'", result.Fields["status"]) + } + if len(result.Errors) != 0 { + t.Errorf("errors: got %v, want none", result.Errors) + } +} diff --git a/internal/models/activity.go b/internal/models/activity.go index 112b9196..648819c7 100644 --- a/internal/models/activity.go +++ b/internal/models/activity.go @@ -4,7 +4,7 @@ import "time" // Valid actions var ValidActions = []string{ - "created", "updated", "archived", "restored", "read", "searched", + "created", "updated", "archived", "restored", "moved", "read", "searched", } type Activity struct { diff --git a/internal/server/handlers_items.go b/internal/server/handlers_items.go index 7961ed9c..a4bca507 100644 --- a/internal/server/handlers_items.go +++ b/internal/server/handlers_items.go @@ -318,6 +318,123 @@ func (s *Server) handleRestoreItem(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, restored) } +// handleMoveItem moves an item to a different collection with field migration. +func (s *Server) handleMoveItem(w http.ResponseWriter, r *http.Request) { + workspaceID, ok := s.getWorkspaceID(w, r) + if !ok { + return + } + + itemSlug := chi.URLParam(r, "itemSlug") + item, err := s.store.ResolveItem(workspaceID, itemSlug) + if err != nil || item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var input struct { + TargetCollection string `json:"target_collection"` + FieldOverrides map[string]any `json:"field_overrides"` + Actor string `json:"actor"` + Source string `json:"source"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid_body", "Invalid JSON body") + return + } + if input.TargetCollection == "" { + writeError(w, http.StatusBadRequest, "missing_field", "target_collection is required") + return + } + if input.Actor == "" { + input.Actor = "user" + } + if input.Source == "" { + input.Source = "web" + } + + // Get target collection + targetColl, err := s.store.GetCollectionBySlug(workspaceID, input.TargetCollection) + if err != nil || targetColl == nil { + writeError(w, http.StatusBadRequest, "invalid_collection", "Target collection not found") + return + } + + // Don't move to the same collection + if targetColl.ID == item.CollectionID { + writeError(w, http.StatusBadRequest, "same_collection", "Item is already in this collection") + return + } + + // Get source collection for schema + sourceColl, err := s.store.GetCollection(item.CollectionID) + if err != nil || sourceColl == nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get source collection") + return + } + + // Parse schemas + var sourceSchema, targetSchema models.CollectionSchema + if err := json.Unmarshal([]byte(sourceColl.Schema), &sourceSchema); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse source schema") + return + } + if err := json.Unmarshal([]byte(targetColl.Schema), &targetSchema); err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to parse target schema") + return + } + + // Parse current fields + var currentFields map[string]any + if err := json.Unmarshal([]byte(item.Fields), ¤tFields); err != nil { + currentFields = make(map[string]any) + } + + // Migrate fields + result := items.MigrateFields(currentFields, sourceSchema.Fields, targetSchema.Fields) + + // Apply overrides + for k, v := range input.FieldOverrides { + result.Fields[k] = v + } + + // Check for required field errors (after overrides) + if len(result.Errors) > 0 { + writeError(w, http.StatusBadRequest, "missing_required_fields", + fmt.Sprintf("Required fields missing: %s", strings.Join(result.Errors, ", "))) + return + } + + // Serialize migrated fields + fieldsJSON, err := json.Marshal(result.Fields) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to serialize fields") + return + } + + // Move the item + moved, err := s.store.MoveItem(item.ID, targetColl.ID, string(fieldsJSON)) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", err.Error()) + return + } + + // Log activity with metadata about the move + _ = s.store.CreateActivity(models.Activity{ + WorkspaceID: workspaceID, + DocumentID: moved.ID, + Action: "moved", + Actor: input.Actor, + Source: input.Source, + Metadata: fmt.Sprintf(`{"from_collection":"%s","to_collection":"%s"}`, sourceColl.Slug, targetColl.Slug), + }) + + // Publish events for both old and new collections + s.publishItemEvent(events.ItemUpdated, workspaceID, moved.ID, moved.Title, targetColl.Slug, input.Actor, input.Source) + + writeJSON(w, http.StatusOK, moved) +} + // publishItemEvent publishes a real-time event for item changes. func (s *Server) publishItemEvent(eventType, workspaceID, itemID, title, collection, actor, source string) { if s.events == nil { diff --git a/internal/server/server.go b/internal/server/server.go index 28a301f1..ef2adef4 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -126,6 +126,7 @@ func (s *Server) setupRouter() { r.Patch("/", s.handleUpdateItem) r.Delete("/", s.handleDeleteItem) r.Post("/restore", s.handleRestoreItem) + r.Post("/move", s.handleMoveItem) r.Get("/versions", s.handleListItemVersions) r.Post("/versions/{versionID}/restore", s.handleRestoreItemVersion) r.Get("/links", s.handleGetItemLinks) diff --git a/internal/store/items.go b/internal/store/items.go index c16dbaf3..50f46c31 100644 --- a/internal/store/items.go +++ b/internal/store/items.go @@ -800,6 +800,40 @@ func (s *Store) GetTasksForPhase(phaseItemID string) ([]models.Item, error) { return scanItems(rows) } +// MoveItem moves an item to a different collection within the same workspace. +// It updates the collection_id, assigns a new item_number in the target collection, +// and updates the fields JSON. +func (s *Store) MoveItem(itemID, targetCollectionID, newFieldsJSON string) (*models.Item, error) { + tx, err := s.db.Begin() + if err != nil { + return nil, err + } + defer tx.Rollback() + + // Get next item_number in the target collection + var nextNumber int + err = tx.QueryRow(`SELECT COALESCE(MAX(item_number), 0) + 1 FROM items WHERE collection_id = ?`, targetCollectionID).Scan(&nextNumber) + if err != nil { + return nil, fmt.Errorf("get next item number: %w", err) + } + + // Update the item + _, err = tx.Exec(` + UPDATE items + SET collection_id = ?, fields = ?, item_number = ?, updated_at = ? + WHERE id = ? AND deleted_at IS NULL`, + targetCollectionID, newFieldsJSON, nextNumber, time.Now().UTC().Format(time.RFC3339), itemID) + if err != nil { + return nil, fmt.Errorf("move item: %w", err) + } + + if err := tx.Commit(); err != nil { + return nil, err + } + + return s.GetItem(itemID) +} + // --- Helpers --- // validSortField matches safe field names (alphanumeric + underscore, starting with a letter). diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 78d1d6e6..4cf12654 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -158,6 +158,16 @@ export const api = { method: 'POST' }), + move: (ws: string, slug: string, targetCollection: string, fieldOverrides?: Record) => + request(`/workspaces/${ws}/items/${slug}/move`, { + method: 'POST', + body: JSON.stringify({ + target_collection: targetCollection, + field_overrides: fieldOverrides, + source: 'web' + }) + }), + /** Get tasks linked to a phase item */ tasks: (ws: string, slug: string) => request(`/workspaces/${ws}/items/${slug}/tasks`), diff --git a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte index be05f5e7..dd1449d7 100644 --- a/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte +++ b/web/src/routes/[workspace]/[collection]/[slug]/+page.svelte @@ -58,6 +58,8 @@ let confirmDelete = $state(false); let deleting = $state(false); let rawMode = $state(false); + let showMoveMenu = $state(false); + let moving = $state(false); $effect(() => { if (wsSlug && collSlug && itemSlug) { @@ -233,6 +235,24 @@ confirmDelete = false; } } + + let allCollections = $derived(collectionStore.collections ?? []); + let moveTargets = $derived(allCollections.filter(c => c.slug !== collSlug)); + + async function handleMove(targetSlug: string) { + if (!item || moving) return; + moving = true; + showMoveMenu = false; + try { + const moved = await api.items.move(wsSlug, item.slug, targetSlug); + toastStore.show(`Moved to ${targetSlug}`, 'success'); + goto(`/${wsSlug}/${targetSlug}/${moved.slug}`); + } catch (e: any) { + toastStore.show(e.message ?? 'Failed to move item', 'error'); + } finally { + moving = false; + } + } {#if loading} @@ -291,6 +311,21 @@ > History +
+ + {#if showMoveMenu} +
+ {#each moveTargets as target (target.slug)} + + {/each} +
+ {/if} +
{#if confirmDelete} Delete this item? @@ -693,6 +728,42 @@ border-color: var(--accent-blue); color: #fff; } + .move-wrapper { + position: relative; + } + .move-dropdown { + position: absolute; + top: 100%; + right: 0; + margin-top: var(--space-1); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 100; + min-width: 180px; + padding: var(--space-1); + } + .move-option { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + background: none; + border: none; + color: var(--text-primary); + font-size: 0.85em; + cursor: pointer; + border-radius: var(--radius-sm); + text-align: left; + } + .move-option:hover { + background: var(--bg-hover); + } + .move-icon { + font-size: 1.1em; + } .delete-btn:hover { color: var(--accent-orange); border-color: var(--accent-orange);