Add move item between collections with field migration

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 <slug> <target-collection> [--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
This commit is contained in:
xarmian
2026-03-28 05:01:40 +00:00
parent d41842ef7a
commit 6daa8eb68b
11 changed files with 527 additions and 2 deletions
+52
View File
@@ -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 <slug> <target-collection>",
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 {
+1 -1
View File
@@ -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
+5
View File
@@ -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) {
+118
View File
@@ -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
}
}
+117
View File
@@ -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)
}
}
+1 -1
View File
@@ -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 {
+117
View File
@@ -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), &currentFields); 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 {
+1
View File
@@ -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)
+34
View File
@@ -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).
+10
View File
@@ -158,6 +158,16 @@ export const api = {
method: 'POST'
}),
move: (ws: string, slug: string, targetCollection: string, fieldOverrides?: Record<string, any>) =>
request<Item>(`/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<Item[]>(`/workspaces/${ws}/items/${slug}/tasks`),
@@ -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;
}
}
</script>
{#if loading}
@@ -291,6 +311,21 @@
>
History
</button>
<div class="move-wrapper">
<button class="history-btn" onclick={() => { showMoveMenu = !showMoveMenu; }} disabled={moving}>
{moving ? 'Moving...' : 'Move to...'}
</button>
{#if showMoveMenu}
<div class="move-dropdown">
{#each moveTargets as target (target.slug)}
<button class="move-option" onclick={() => handleMove(target.slug)}>
{#if target.icon}<span class="move-icon">{target.icon}</span>{/if}
{target.name}
</button>
{/each}
</div>
{/if}
</div>
{#if confirmDelete}
<span class="delete-confirm">
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);