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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-04
|
||||
@@ -0,0 +1,93 @@
|
||||
## Context
|
||||
|
||||
pcli currently supports CRUD operations on individual Planka resources but has no way to bulk export or import project data. The Planka API returns nested data for boards (lists, cards, labels, card-labels, memberships) but requires per-card fetches for comments, task lists, and tasks. There is no existing user client — we need one to resolve comment author userIds to names.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Export an entire project (or single board) to a portable JSON file via stdout
|
||||
- Import from that JSON file, creating all resources with fresh IDs on the target instance
|
||||
- Use names (not IDs) as portable identity throughout the export format
|
||||
- Preserve comment authorship textually via `(Original comment by <username>)` prefix
|
||||
- Fail safely on import if project+board combination already exists
|
||||
|
||||
**Non-Goals:**
|
||||
- Attachments/file export (binary assets — out of scope)
|
||||
- Merge/update import (replace-only for now)
|
||||
- Multi-board filter on export (single `--board` flag only)
|
||||
- User/membership migration (not portable across instances)
|
||||
- Incremental/differential export
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. Export format: flat nested JSON, no ID references
|
||||
|
||||
**Decision**: Export uses a fully nested structure where child objects are inline under their parent. Cards reference lists and labels by name, not ID.
|
||||
|
||||
**Rationale**: Avoids needing an ID remapping table in the export file. Makes the format human-readable and trivially parseable. Names are the natural portable identifier.
|
||||
|
||||
**Alternative considered**: Flat arrays with ID cross-references (like the Planka API response format). Rejected — adds complexity for both export and import with no benefit since IDs are regenerated anyway.
|
||||
|
||||
### 2. User resolution: one-time fetch during export
|
||||
|
||||
**Decision**: Export fetches the full user list once at the start, builds a userId→name map, and uses it to prefix comments.
|
||||
|
||||
**Rationale**: Comment API returns userId but not the user's name. A single bulk fetch is more efficient than per-comment lookups. Falls back to "unknown user" for unresolvable IDs.
|
||||
|
||||
**Implementation**: New `client/users.go` with `ListUsers()` method hitting `GET /api/users` (Planka v2 endpoint).
|
||||
|
||||
### 3. Import conflict detection: project+board name pair
|
||||
|
||||
**Decision**: Import checks if the target Planka instance already has a board with the same name under the same project. If so, it fails immediately before creating anything.
|
||||
|
||||
**Rationale**: This is the simplest safe behavior. Project existing alone is fine (we add the board to it). Board name collision means data would overlap, so we fail rather than risk duplicates.
|
||||
|
||||
**Implementation flow**:
|
||||
1. List projects, find by name → use existing or create new
|
||||
2. Get project's boards (via board list filtered by project)
|
||||
3. For each board in export: check name against existing boards → fail if collision
|
||||
4. Proceed with creation only after all boards pass the check
|
||||
|
||||
### 4. Export data gathering strategy
|
||||
|
||||
**Decision**: Use the existing board GET response (includes lists, cards, labels, card-labels) as the base, then fetch per-card details (comments, task lists, tasks) individually.
|
||||
|
||||
**Rationale**: Board GET already returns most of what we need in one call. Only comments and task lists/tasks require additional fetches. This minimizes API calls while getting complete data.
|
||||
|
||||
**Sequence per board**:
|
||||
1. `GET /api/boards/<id>` → lists, cards, labels, card-labels
|
||||
2. For each card: `GET /api/cards/<id>` → task lists, tasks
|
||||
3. For each card: `GET /api/cards/<id>/comments` → comments
|
||||
|
||||
### 5. Command structure: subcommands under project
|
||||
|
||||
**Decision**: `pcli project export` and `pcli project import` as subcommands of the existing project command.
|
||||
|
||||
**Rationale**: Export/import are project-level operations. Keeps the CLI hierarchy clean and discoverable.
|
||||
|
||||
### 6. Import input: stdin and --file flag
|
||||
|
||||
**Decision**: Support both `pcli project import < file.json` and `pcli project import --file file.json`.
|
||||
|
||||
**Rationale**: stdin is natural for piping; `--file` is explicit and clearer in scripts. Minimal implementation cost to support both.
|
||||
|
||||
### 7. Import creation order
|
||||
|
||||
**Decision**: Create resources top-down: project → board → lists → labels → cards → card-labels → task lists → tasks → comments. Build name→ID maps as each level is created.
|
||||
|
||||
**Rationale**: Each child resource needs its parent's ID. Creating top-down and maintaining maps (e.g., listName→listID) lets us resolve references as we go.
|
||||
|
||||
**ID mapping during import**:
|
||||
- `listName → listID` (created when lists are created)
|
||||
- `labelName+color → labelID` (created when labels are created)
|
||||
- Cards are created with the resolved listID, then card-label associations are created separately
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Slow export for large projects]** → Accept the cost. Per-card fetches for comments/tasks could be slow with hundreds of cards. Could be optimized later with concurrency but keeping it simple (sequential) for now.
|
||||
|
||||
**[Comment attribution may be inaccurate]** → If users have been deleted or the user list endpoint requires admin permissions, some comments may show "unknown user". This is acceptable — textual attribution is best-effort.
|
||||
|
||||
**[No atomicity on import]** → If import fails partway through (e.g., API error on card 50 of 100), partial data will exist on the target. Mitigation: the conflict check happens upfront before any creation starts, so the most likely failure mode (name collision) is caught early. For API failures mid-import, the user would need to manually clean up or re-run after fixing the issue.
|
||||
|
||||
**[Label matching by name+color]** → Two labels with the same name but different colors would be treated as distinct. This matches Planka's model where labels are board-scoped and identified by name+color.
|
||||
@@ -0,0 +1,92 @@
|
||||
## Why
|
||||
|
||||
There's no way to backup, migrate, or clone project data between Planka instances using pcli. Users need to export a project (or specific board) to a portable file and import it elsewhere — useful for backups, instance migration, and sharing project templates.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add `pcli project export` command: exports a project (optionally filtered to a specific board) as a self-contained JSON file to stdout
|
||||
- Add `pcli project import` command: reads a JSON export file and creates the project/board(s) on the target instance
|
||||
- Export uses **names** (not IDs) as the portable identity — IDs are Planka-instance-specific and regenerated on import
|
||||
- Export includes the full hierarchy: project → boards → lists → labels → cards → card-labels → task lists → tasks → comments
|
||||
- Import uses **fail-if-exists** strategy: if the project+board name combination already exists on the target, import stops with an error. Project existing alone is fine (board gets added to it).
|
||||
- User-specific references (cardMemberships, creatorUserId) are **excluded** from export — these are instance-specific and not portable
|
||||
- Comments are exported with **text attribution**: each comment is prefixed with `(Original comment by <username>)` so authorship is preserved textually even though the userId is not portable. This requires a one-time user list fetch during export to resolve userId→name.
|
||||
|
||||
### Export format
|
||||
|
||||
Single JSON object to stdout. Structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"exportedAt": "2026-03-04T...",
|
||||
"project": {
|
||||
"name": "...",
|
||||
"description": "...",
|
||||
"boards": [
|
||||
{
|
||||
"name": "...",
|
||||
"position": 1,
|
||||
"lists": [...],
|
||||
"labels": [...],
|
||||
"cards": [
|
||||
{
|
||||
"name": "...",
|
||||
"listName": "...",
|
||||
"labelNames": ["..."],
|
||||
"description": "...",
|
||||
"taskLists": [
|
||||
{
|
||||
"name": "...",
|
||||
"tasks": [{ "name": "...", "isCompleted": false }]
|
||||
}
|
||||
],
|
||||
"comments": [{ "text": "(Original comment by jsmith)\nActual comment text here" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Key design choices:
|
||||
- **Name-based references**: cards reference `listName` and `labelNames` instead of IDs
|
||||
- **Nested hierarchy**: no ID cross-references to resolve — everything is inline
|
||||
- **Version field**: allows future format changes
|
||||
- Positions preserved to maintain ordering
|
||||
- User fields stripped (not portable) except for textual attribution on comments
|
||||
- Comments prefixed with `(Original comment by <username>)` — requires fetching user list during export to resolve userIds to names. If a userId can't be resolved, falls back to `(Original comment by unknown user)`
|
||||
|
||||
### Import behavior
|
||||
|
||||
1. Look up project by name — create if it doesn't exist
|
||||
2. For each board in the export: check if project already has a board with that name → **fail with error** if so
|
||||
3. Create board, then lists, labels, cards (mapping listName→list ID, labelNames→label IDs), task lists, tasks, comments — all with fresh Planka IDs
|
||||
4. Ordering maintained via position fields from the export
|
||||
|
||||
### CLI interface
|
||||
|
||||
```
|
||||
pcli project export <project-id-or-name> [--board <name-or-id>] > backup.json
|
||||
pcli project import < backup.json
|
||||
# or
|
||||
pcli project import --file backup.json
|
||||
```
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `project-export`: Export project hierarchy to portable JSON (project → boards → lists → labels → cards → task lists → tasks → comments)
|
||||
- `project-import`: Import project hierarchy from JSON export file, creating all resources with new IDs
|
||||
|
||||
### Modified Capabilities
|
||||
_(none — these are new commands added to the existing project resource)_
|
||||
|
||||
## Impact
|
||||
|
||||
- **New files**: `client/export.go`, `client/import.go`, `client/users.go`, `cmd/export.go`, `cmd/import.go`
|
||||
- **Model changes**: new export-specific types in `model/types.go` (portable structs without IDs/user references), new User type
|
||||
- **API usage**: export requires multiple API calls (user list for name resolution, board GET for bulk data, then per-card fetches for comments/tasks) — could be slow for large projects
|
||||
- **No breaking changes**: purely additive new subcommands
|
||||
- **Dependencies**: none new (standard library JSON encoding)
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Export project hierarchy to JSON
|
||||
The system SHALL export a complete project hierarchy as a JSON object to stdout. The export SHALL include: project metadata, boards, lists, labels, cards (with list and label name references), task lists, tasks, and comments.
|
||||
|
||||
#### Scenario: Export entire project
|
||||
- **WHEN** user runs `pcli project export <project-id-or-name>`
|
||||
- **THEN** the system outputs a JSON object to stdout containing the project and all its boards with their full hierarchy
|
||||
|
||||
#### Scenario: Export with board filter
|
||||
- **WHEN** user runs `pcli project export <project-id-or-name> --board <board-name-or-id>`
|
||||
- **THEN** the system outputs a JSON object containing only the specified board (and its full hierarchy) under the project
|
||||
|
||||
#### Scenario: Board filter does not match
|
||||
- **WHEN** user runs `pcli project export <project> --board <nonexistent>`
|
||||
- **THEN** the system exits with an error indicating the board was not found
|
||||
|
||||
### Requirement: Export format uses version envelope
|
||||
The export JSON SHALL contain a `version` field (integer, currently `1`), an `exportedAt` timestamp (ISO 8601), and a `project` object.
|
||||
|
||||
#### Scenario: Export envelope structure
|
||||
- **WHEN** an export is generated
|
||||
- **THEN** the root JSON object contains exactly `version`, `exportedAt`, and `project` keys
|
||||
- **THEN** `version` is `1`
|
||||
- **THEN** `exportedAt` is a valid ISO 8601 timestamp
|
||||
|
||||
### Requirement: Export uses name-based references
|
||||
The export SHALL use names instead of IDs for all cross-references. Cards SHALL reference their list by `listName` and their labels by `labelNames` (array of label name strings). No Planka IDs SHALL appear in the export.
|
||||
|
||||
#### Scenario: Card references list by name
|
||||
- **WHEN** a card belongs to a list named "In Progress"
|
||||
- **THEN** the exported card has `"listName": "In Progress"` instead of a list ID
|
||||
|
||||
#### Scenario: Card references labels by name
|
||||
- **WHEN** a card has labels "Bug" and "Urgent"
|
||||
- **THEN** the exported card has `"labelNames": ["Bug", "Urgent"]`
|
||||
|
||||
### Requirement: Export preserves ordering
|
||||
The export SHALL include `position` fields for boards, lists, labels, and cards to maintain their display ordering on import.
|
||||
|
||||
#### Scenario: List ordering preserved
|
||||
- **WHEN** a board has lists "Todo" (position 1), "Doing" (position 2), "Done" (position 3)
|
||||
- **THEN** the exported lists include their position values
|
||||
|
||||
### Requirement: Export includes comment attribution
|
||||
Each exported comment SHALL have its text prefixed with `(Original comment by <username>)\n` where `<username>` is resolved from the comment's userId. If the userId cannot be resolved, the prefix SHALL use `unknown user`.
|
||||
|
||||
#### Scenario: Comment with known author
|
||||
- **WHEN** a comment was authored by user "jsmith"
|
||||
- **THEN** the exported comment text starts with `(Original comment by jsmith)\n`
|
||||
|
||||
#### Scenario: Comment with unresolvable author
|
||||
- **WHEN** a comment's userId does not match any known user
|
||||
- **THEN** the exported comment text starts with `(Original comment by unknown user)\n`
|
||||
|
||||
### Requirement: Export excludes user-specific data
|
||||
The export SHALL NOT include cardMemberships, creatorUserId, or userId fields. Only textual comment attribution (as a text prefix) SHALL preserve user information.
|
||||
|
||||
#### Scenario: Card memberships excluded
|
||||
- **WHEN** a card has members assigned
|
||||
- **THEN** the exported card does not contain membership data
|
||||
|
||||
### Requirement: Export includes task lists and tasks
|
||||
The export SHALL include task lists and their tasks for each card. Each task list SHALL contain its tasks inline. Tasks SHALL include `name` and `isCompleted` fields.
|
||||
|
||||
#### Scenario: Card with task lists
|
||||
- **WHEN** a card has a task list "Checklist" with tasks "Item 1" (complete) and "Item 2" (incomplete)
|
||||
- **THEN** the exported card includes the task list with both tasks and their completion status
|
||||
|
||||
### Requirement: Project identified by name or ID
|
||||
The `<project-id-or-name>` argument SHALL accept either a Planka project ID or a project name. If a name is provided, the system SHALL look up the project by name. If multiple projects match the name, the system SHALL exit with an error.
|
||||
|
||||
#### Scenario: Project by name
|
||||
- **WHEN** user provides a project name that matches exactly one project
|
||||
- **THEN** the system exports that project
|
||||
|
||||
#### Scenario: Ambiguous project name
|
||||
- **WHEN** user provides a project name that matches multiple projects
|
||||
- **THEN** the system exits with an error listing the matches
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Import project hierarchy from JSON
|
||||
The system SHALL read a JSON export file and create all resources (project, boards, lists, labels, cards, card-labels, task lists, tasks, comments) on the target Planka instance with fresh IDs.
|
||||
|
||||
#### Scenario: Import from stdin
|
||||
- **WHEN** user runs `pcli project import < backup.json`
|
||||
- **THEN** the system reads the JSON from stdin and creates all resources
|
||||
|
||||
#### Scenario: Import from file flag
|
||||
- **WHEN** user runs `pcli project import --file backup.json`
|
||||
- **THEN** the system reads the JSON from the specified file and creates all resources
|
||||
|
||||
#### Scenario: No input provided
|
||||
- **WHEN** user runs `pcli project import` with no stdin and no --file flag
|
||||
- **THEN** the system exits with an error indicating input is required
|
||||
|
||||
### Requirement: Import creates project if not exists
|
||||
The system SHALL look up the project by name. If no project with that name exists, the system SHALL create it. If the project already exists, the system SHALL use the existing project.
|
||||
|
||||
#### Scenario: Project does not exist
|
||||
- **WHEN** importing a project named "proj1" and no project with that name exists
|
||||
- **THEN** the system creates a new project named "proj1"
|
||||
|
||||
#### Scenario: Project already exists
|
||||
- **WHEN** importing a project named "proj1" and a project with that name already exists
|
||||
- **THEN** the system uses the existing project (does not create a duplicate)
|
||||
|
||||
### Requirement: Import fails if board name conflicts
|
||||
Before creating any resources, the system SHALL check that none of the boards in the export file have names that conflict with existing boards in the target project. If any board name already exists under the target project, the system SHALL exit with an error naming the conflicting board(s) and create nothing.
|
||||
|
||||
#### Scenario: No board conflict
|
||||
- **WHEN** importing boards "board1" and "board2" into project "proj1" which has no boards
|
||||
- **THEN** the import proceeds and creates both boards
|
||||
|
||||
#### Scenario: Board name conflict
|
||||
- **WHEN** importing board "board1" into project "proj1" which already has a board named "board1"
|
||||
- **THEN** the system exits with an error: board "board1" already exists in project "proj1"
|
||||
- **THEN** no resources are created
|
||||
|
||||
#### Scenario: Partial conflict
|
||||
- **WHEN** importing boards "board1" and "board2" into project "proj1" which already has "board2"
|
||||
- **THEN** the system exits with an error about "board2" conflicting
|
||||
- **THEN** no resources are created (not even "board1")
|
||||
|
||||
### Requirement: Import creates resources in dependency order
|
||||
The system SHALL create resources top-down: board → lists → labels → cards → card-label associations → task lists → tasks → comments. Name-to-ID maps SHALL be maintained at each level to resolve references for child resources.
|
||||
|
||||
#### Scenario: Card references resolved on import
|
||||
- **WHEN** a card in the export has `"listName": "In Progress"` and `"labelNames": ["Bug"]`
|
||||
- **THEN** the system creates the card in the list named "In Progress" and associates it with the label named "Bug" using the IDs generated during import
|
||||
|
||||
### Requirement: Import preserves ordering
|
||||
The system SHALL use position values from the export to maintain the original display ordering of boards, lists, labels, and cards.
|
||||
|
||||
#### Scenario: List positions preserved
|
||||
- **WHEN** the export contains lists with positions 1, 2, 3
|
||||
- **THEN** the imported lists are created with those same position values
|
||||
|
||||
### Requirement: Import validates export version
|
||||
The system SHALL check the `version` field of the export JSON. If the version is not supported (currently only version `1`), the system SHALL exit with an error.
|
||||
|
||||
#### Scenario: Supported version
|
||||
- **WHEN** the export file has `"version": 1`
|
||||
- **THEN** the import proceeds normally
|
||||
|
||||
#### Scenario: Unsupported version
|
||||
- **WHEN** the export file has `"version": 99`
|
||||
- **THEN** the system exits with an error indicating the version is not supported
|
||||
|
||||
### Requirement: Import reports progress
|
||||
The system SHALL output progress information to stderr as it creates resources, including the count of each resource type created.
|
||||
|
||||
#### Scenario: Progress reporting
|
||||
- **WHEN** an import completes successfully
|
||||
- **THEN** stderr shows a summary: boards created, lists created, cards created, etc.
|
||||
|
||||
### Requirement: Import handles missing list reference
|
||||
If a card references a `listName` that does not exist in the board's lists, the system SHALL exit with an error identifying the card and the missing list name.
|
||||
|
||||
#### Scenario: Invalid list reference
|
||||
- **WHEN** a card references `"listName": "Nonexistent"` and no list with that name exists in the board
|
||||
- **THEN** the system exits with an error: card "<name>" references unknown list "Nonexistent"
|
||||
@@ -0,0 +1,24 @@
|
||||
## 1. Model & Types
|
||||
|
||||
- [x] 1.1 Add User struct to `model/types.go` (id, name, username, email)
|
||||
- [x] 1.2 Add export-specific portable types to `model/types.go`: ExportEnvelope, ExportProject, ExportBoard, ExportList, ExportLabel, ExportCard, ExportTaskList, ExportTask, ExportComment
|
||||
|
||||
## 2. User Client
|
||||
|
||||
- [x] 2.1 Create `client/users.go` with `ListUsers()` method (GET /api/users)
|
||||
|
||||
## 3. Project Export
|
||||
|
||||
- [x] 3.1 Create `client/export.go` with `ExportProject(ctx, projectID, boardFilter)` method that orchestrates data gathering: fetch users (build userId→name map), fetch board(s) with included data, fetch per-card comments/task lists/tasks, assemble into portable export types
|
||||
- [x] 3.2 Create `cmd/export.go` with `project export <project-id-or-name> [--board <name-or-id>]` command: resolve project by name or ID, call ExportProject, marshal JSON to stdout
|
||||
- [x] 3.3 Add project name/ID resolution helper: list projects, match by ID or exact name, error on ambiguous match
|
||||
|
||||
## 4. Project Import
|
||||
|
||||
- [x] 4.1 Create `client/import.go` with `ImportProject(ctx, export)` method that: resolves/creates project by name, checks board name conflicts (fail-fast), creates boards → lists → labels → cards → card-labels → task lists → tasks → comments in order, maintains name→ID maps at each level, reports progress to stderr
|
||||
- [x] 4.2 Create `cmd/import.go` with `project import [--file <path>]` command: read JSON from stdin or file, unmarshal, validate version, call ImportProject
|
||||
|
||||
## 5. Wire Up & Integration
|
||||
|
||||
- [x] 5.1 Register export and import subcommands under project command in `cmd/root.go` or `cmd/project.go`
|
||||
- [x] 5.2 End-to-end test: export a project, import to same instance under different project name, verify structure matches
|
||||
Reference in New Issue
Block a user