e973b2ce20
- 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.
250 lines
6.2 KiB
Go
250 lines
6.2 KiB
Go
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
|
|
}
|