feat: add project export and import functionality

- Implemented `pcli project export` command to export project hierarchy as JSON.
- Added `pcli project import` command to import project data from JSON.
- Created user client to fetch user details for comment attribution.
- Introduced new data structures for export and import processes.
- Ensured name-based references in exports and handled conflicts during imports.
- Added versioning and progress reporting for import operations.
- Updated documentation and specifications for new features.
This commit is contained in:
2026-03-04 19:53:55 +00:00
parent e352fd530f
commit e973b2ce20
49 changed files with 1492 additions and 3303 deletions
+244
View File
@@ -0,0 +1,244 @@
package client
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"git.franklin.lab/steve.cliff/pcli/model"
)
// ImportProgress tracks creation counts during import.
type ImportProgress struct {
Boards int
Lists int
Labels int
Cards int
TaskLists int
Tasks int
Comments int
}
func (p ImportProgress) String() string {
return fmt.Sprintf("boards: %d, lists: %d, labels: %d, cards: %d, task lists: %d, tasks: %d, comments: %d",
p.Boards, p.Lists, p.Labels, p.Cards, p.TaskLists, p.Tasks, p.Comments)
}
func (c *Client) ImportProject(ctx context.Context, export *model.ExportEnvelope, stderr io.Writer) (*ImportProgress, error) {
progress := &ImportProgress{}
// Resolve or create project
projectID, err := c.resolveOrCreateProject(ctx, export.Project.Name, export.Project.Description)
if err != nil {
return nil, fmt.Errorf("failed to resolve/create project: %w", err)
}
// Check for board name conflicts before creating anything
if err := c.checkBoardConflicts(ctx, projectID, export.Project); err != nil {
return nil, err
}
// Import each board
for _, board := range export.Project.Boards {
fmt.Fprintf(stderr, "Importing board %q...\n", board.Name)
if err := c.importBoard(ctx, projectID, board, progress); err != nil {
return progress, fmt.Errorf("failed to import board %q: %w", board.Name, err)
}
}
return progress, nil
}
func (c *Client) resolveOrCreateProject(ctx context.Context, name string, description *string) (string, error) {
projects, err := c.ListProjects(ctx)
if err != nil {
return "", fmt.Errorf("failed to list projects: %w", err)
}
for _, p := range projects {
if strings.EqualFold(p.Name, name) {
c.Logger.Info("Using existing project", slog.String("project", name), slog.String("id", p.ID))
return p.ID, nil
}
}
// Create new project
fields := ProjectCreateFields{
Type: "private",
Name: name,
}
if description != nil {
fields.Description = description
}
project, err := c.CreateProject(ctx, fields)
if err != nil {
return "", fmt.Errorf("failed to create project: %w", err)
}
c.Logger.Info("Created project", slog.String("project", name), slog.String("id", project.ID))
return project.ID, nil
}
func (c *Client) checkBoardConflicts(ctx context.Context, projectID string, export model.ExportProject) error {
boards, err := c.ListBoards(ctx)
if err != nil {
return fmt.Errorf("failed to list boards: %w", err)
}
existingNames := make(map[string]bool)
for _, b := range boards {
if b.ProjectID == projectID {
existingNames[strings.ToLower(b.Name)] = true
}
}
var conflicts []string
for _, b := range export.Boards {
if existingNames[strings.ToLower(b.Name)] {
conflicts = append(conflicts, b.Name)
}
}
if len(conflicts) > 0 {
return fmt.Errorf("board(s) already exist in project %q: %s", export.Name, strings.Join(conflicts, ", "))
}
return nil
}
func (c *Client) importBoard(ctx context.Context, projectID string, board model.ExportBoard, progress *ImportProgress) error {
// Create board
createdBoard, err := c.CreateBoard(ctx, projectID, BoardCreateFields{
Name: board.Name,
Position: board.Position,
})
if err != nil {
return fmt.Errorf("failed to create board: %w", err)
}
progress.Boards++
// Create lists, build name -> ID map (skip system lists)
listMap := make(map[string]string)
for _, l := range board.Lists {
if l.Type == "archive" || l.Type == "trash" {
continue
}
created, err := c.CreateList(ctx, createdBoard.ID, ListCreateFields{
Name: l.Name,
Position: l.Position,
Type: l.Type,
})
if err != nil {
return fmt.Errorf("failed to create list %q: %w", l.Name, err)
}
listMap[l.Name] = created.ID
progress.Lists++
}
// Create labels, build name -> ID map
// Labels are keyed by name only (Planka scopes labels to a board)
labelMap := make(map[string]string)
for _, l := range board.Labels {
fields := map[string]any{
"position": l.Position,
"color": l.Color,
}
if l.Name != nil {
fields["name"] = *l.Name
}
created, err := c.CreateLabel(ctx, createdBoard.ID, fields)
if err != nil {
return fmt.Errorf("failed to create label: %w", err)
}
name := ""
if l.Name != nil {
name = *l.Name
}
labelMap[name] = created.ID
progress.Labels++
}
// Create cards
for _, card := range board.Cards {
listID, ok := listMap[card.ListName]
if !ok {
return fmt.Errorf("card %q references unknown list %q", card.Name, card.ListName)
}
cardFields := map[string]any{
"name": card.Name,
"type": card.Type,
}
if card.Position != nil {
cardFields["position"] = *card.Position
}
if card.Description != nil {
cardFields["description"] = *card.Description
}
if card.DueDate != nil {
cardFields["dueDate"] = *card.DueDate
}
if card.IsClosed {
cardFields["isClosed"] = true
}
createdCard, err := c.CreateCard(ctx, listID, cardFields)
if err != nil {
return fmt.Errorf("failed to create card %q: %w", card.Name, err)
}
progress.Cards++
// Add card labels
for _, labelName := range card.LabelNames {
labelID, ok := labelMap[labelName]
if !ok {
c.Logger.Warn("Label not found for card, skipping",
slog.String("card", card.Name),
slog.String("label", labelName),
)
continue
}
if err := c.AddCardLabel(ctx, createdCard.ID, labelID); err != nil {
return fmt.Errorf("failed to add label %q to card %q: %w", labelName, card.Name, err)
}
}
// Create task lists and tasks
for _, tl := range card.TaskLists {
tlFields := map[string]any{
"name": tl.Name,
"position": tl.Position,
}
createdTL, err := c.CreateTaskList(ctx, createdCard.ID, tlFields)
if err != nil {
return fmt.Errorf("failed to create task list %q: %w", tl.Name, err)
}
progress.TaskLists++
for _, t := range tl.Tasks {
tFields := map[string]any{
"name": t.Name,
"position": t.Position,
"isCompleted": t.IsCompleted,
}
if _, err := c.CreateTask(ctx, createdTL.ID, tFields); err != nil {
return fmt.Errorf("failed to create task %q: %w", t.Name, err)
}
progress.Tasks++
}
}
// Create comments
for _, cm := range card.Comments {
if _, err := c.CreateComment(ctx, createdCard.ID, cm.Text); err != nil {
return fmt.Errorf("failed to create comment on card %q: %w", card.Name, err)
}
progress.Comments++
}
}
return nil
}