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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.franklin.lab/steve.cliff/pcli/model"
|
||||
)
|
||||
|
||||
func (c *Client) ListUsers(ctx context.Context) ([]model.User, error) {
|
||||
data, err := c.DoNoBody(ctx, "GET", "/api/users")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Items []model.User `json:"items"`
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &response); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal users response: %w", err)
|
||||
}
|
||||
|
||||
return response.Items, nil
|
||||
}
|
||||
Reference in New Issue
Block a user