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
+249
View File
@@ -0,0 +1,249 @@
package client
import (
"context"
"fmt"
"log/slog"
"time"
"git.franklin.lab/steve.cliff/pcli/model"
)
func (c *Client) ExportProject(ctx context.Context, projectID string, boardFilter string) (*model.ExportEnvelope, error) {
// Fetch users for comment attribution
userMap, err := c.buildUserMap(ctx)
if err != nil {
c.Logger.Warn("Failed to fetch users for comment attribution, will use 'unknown user'",
slog.String("error", err.Error()),
)
userMap = make(map[string]string)
}
// Get project details
project, err := c.GetProject(ctx, projectID)
if err != nil {
return nil, fmt.Errorf("failed to get project: %w", err)
}
// Get all boards for the project
boards, err := c.ListBoards(ctx)
if err != nil {
return nil, fmt.Errorf("failed to list boards: %w", err)
}
// Filter boards to this project
var projectBoards []model.Board
for _, b := range boards {
if b.ProjectID == projectID {
projectBoards = append(projectBoards, b)
}
}
// Apply board filter if specified
if boardFilter != "" {
var filtered []model.Board
for _, b := range projectBoards {
if b.ID == boardFilter || b.Name == boardFilter {
filtered = append(filtered, b)
}
}
if len(filtered) == 0 {
return nil, fmt.Errorf("board %q not found in project %q", boardFilter, project.Name)
}
projectBoards = filtered
}
// Export each board
var exportBoards []model.ExportBoard
for _, b := range projectBoards {
c.Logger.Info("Exporting board", slog.String("board", b.Name))
eb, err := c.exportBoard(ctx, b.ID, userMap)
if err != nil {
return nil, fmt.Errorf("failed to export board %q: %w", b.Name, err)
}
exportBoards = append(exportBoards, *eb)
}
return &model.ExportEnvelope{
Version: 1,
ExportedAt: time.Now().UTC().Format(time.RFC3339),
Project: model.ExportProject{
Name: project.Name,
Description: project.Description,
Boards: exportBoards,
},
}, nil
}
func (c *Client) buildUserMap(ctx context.Context) (map[string]string, error) {
users, err := c.ListUsers(ctx)
if err != nil {
return nil, err
}
userMap := make(map[string]string, len(users))
for _, u := range users {
userMap[u.ID] = u.Name
}
return userMap, nil
}
func (c *Client) exportBoard(ctx context.Context, boardID string, userMap map[string]string) (*model.ExportBoard, error) {
board, err := c.GetBoard(ctx, boardID)
if err != nil {
return nil, err
}
// Build lookup maps from board included data
listMap := make(map[string]string) // id -> name
systemListIDs := make(map[string]bool)
for _, l := range board.Lists {
if l.Type == "archive" || l.Type == "trash" {
systemListIDs[l.ID] = true
continue
}
name := ""
if l.Name != nil {
name = *l.Name
}
listMap[l.ID] = name
}
labelMap := make(map[string]string) // id -> name
for _, l := range board.Labels {
name := ""
if l.Name != nil {
name = *l.Name
}
labelMap[l.ID] = name
}
// Build card -> label names map
cardLabels := make(map[string][]string)
for _, cl := range board.CardLabels {
if name, ok := labelMap[cl.LabelID]; ok {
cardLabels[cl.CardID] = append(cardLabels[cl.CardID], name)
}
}
// Export lists (skip system lists like archive and trash)
var exportLists []model.ExportList
for _, l := range board.Lists {
if l.Type == "archive" || l.Type == "trash" {
continue
}
name := ""
if l.Name != nil {
name = *l.Name
}
pos := float64(0)
if l.Position != nil {
pos = *l.Position
}
exportLists = append(exportLists, model.ExportList{
Name: name,
Position: pos,
Type: l.Type,
Color: l.Color,
})
}
// Export labels
var exportLabels []model.ExportLabel
for _, l := range board.Labels {
exportLabels = append(exportLabels, model.ExportLabel{
Name: l.Name,
Position: l.Position,
Color: l.Color,
})
}
// Export cards with per-card details (skip cards in archive/trash lists)
var exportCards []model.ExportCard
for _, card := range board.Cards {
if systemListIDs[card.ListID] {
continue
}
c.Logger.Debug("Exporting card", slog.String("card", card.Name))
ec, err := c.exportCard(ctx, card, listMap[card.ListID], cardLabels[card.ID], userMap)
if err != nil {
return nil, fmt.Errorf("failed to export card %q: %w", card.Name, err)
}
exportCards = append(exportCards, *ec)
}
return &model.ExportBoard{
Name: board.Name,
Position: board.Position,
Lists: exportLists,
Labels: exportLabels,
Cards: exportCards,
}, nil
}
func (c *Client) exportCard(ctx context.Context, card model.Card, listName string, labelNames []string, userMap map[string]string) (*model.ExportCard, error) {
// Fetch card details (task lists + tasks)
detail, err := c.GetCard(ctx, card.ID)
if err != nil {
return nil, fmt.Errorf("failed to get card details: %w", err)
}
// Fetch comments
comments, err := c.ListComments(ctx, card.ID, 0)
if err != nil {
return nil, fmt.Errorf("failed to get card comments: %w", err)
}
// Build task list ID -> tasks map
tasksByList := make(map[string][]model.Task)
for _, t := range detail.Tasks {
tasksByList[t.TaskListID] = append(tasksByList[t.TaskListID], t)
}
// Export task lists
var exportTaskLists []model.ExportTaskList
for _, tl := range detail.TaskLists {
var exportTasks []model.ExportTask
for _, t := range tasksByList[tl.ID] {
exportTasks = append(exportTasks, model.ExportTask{
Name: t.Name,
Position: t.Position,
IsCompleted: t.IsCompleted,
})
}
exportTaskLists = append(exportTaskLists, model.ExportTaskList{
Name: tl.Name,
Position: tl.Position,
Tasks: exportTasks,
})
}
// Export comments with attribution
var exportComments []model.ExportComment
for _, cm := range comments {
author := "unknown user"
if cm.UserID != nil {
if name, ok := userMap[*cm.UserID]; ok {
author = name
}
}
exportComments = append(exportComments, model.ExportComment{
Text: fmt.Sprintf("(Original comment by %s)\n%s", author, cm.Text),
})
}
return &model.ExportCard{
Name: card.Name,
Position: card.Position,
ListName: listName,
LabelNames: labelNames,
Description: card.Description,
DueDate: card.DueDate,
IsClosed: card.IsClosed,
Type: card.Type,
TaskLists: exportTaskLists,
Comments: exportComments,
}, nil
}