Files
pad/internal/items/migrate.go
T
xarmian 6daa8eb68b 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
2026-03-28 05:01:40 +00:00

119 lines
3.3 KiB
Go

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
}
}