mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-06-01 19:01:37 +00:00
feat(migration/wekan): import attachments from board export
Parse the top-level `attachments` array in WeKan board JSON exports, group them by card ID, base64-decode the payload, and attach the resulting files to the generated tasks so they land in Vikunja as task attachments. Orphaned attachments (cardId with no matching card) are silently skipped; decode errors are logged and skipped.
This commit is contained in:
@@ -63,5 +63,14 @@
|
||||
"rules": [],
|
||||
"triggers": [],
|
||||
"actions": [],
|
||||
"customFields": []
|
||||
"customFields": [],
|
||||
"attachments": [
|
||||
{
|
||||
"_id": "att-1",
|
||||
"cardId": "card-1",
|
||||
"file": "aGVsbG8gd2VrYW4=",
|
||||
"name": "note.txt",
|
||||
"type": "text/plain"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,11 +18,13 @@ package wekan
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
@@ -42,6 +44,7 @@ type wekanBoard struct {
|
||||
Checklists []wekanChecklist `json:"checklists"`
|
||||
ChecklistItems []wekanChecklistItem `json:"checklistItems"`
|
||||
Comments []wekanComment `json:"comments"`
|
||||
Attachments []wekanAttachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type wekanLabel struct {
|
||||
@@ -96,6 +99,14 @@ type wekanComment struct {
|
||||
CardID string `json:"cardId"`
|
||||
}
|
||||
|
||||
type wekanAttachment struct {
|
||||
ID string `json:"_id"`
|
||||
CardID string `json:"cardId"`
|
||||
File string `json:"file"` // base64-encoded file contents
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // MIME type
|
||||
}
|
||||
|
||||
// wekanColorMap maps WeKan label color names to hex values.
|
||||
// Values sourced from WeKan's client/components/cards/labels.css.
|
||||
var wekanColorMap = map[string]string{
|
||||
@@ -174,6 +185,12 @@ func convertWekanToVikunja(board *wekanBoard) []*models.ProjectWithTasksAndBucke
|
||||
commentsByCardID[c.CardID] = append(commentsByCardID[c.CardID], c)
|
||||
}
|
||||
|
||||
// Build attachments grouped by card ID
|
||||
attachmentsByCardID := make(map[string][]wekanAttachment)
|
||||
for _, a := range board.Attachments {
|
||||
attachmentsByCardID[a.CardID] = append(attachmentsByCardID[a.CardID], a)
|
||||
}
|
||||
|
||||
// Create buckets from lists, maintaining sort order
|
||||
sortedLists := make([]wekanList, len(board.Lists))
|
||||
copy(sortedLists, board.Lists)
|
||||
@@ -283,6 +300,25 @@ func convertWekanToVikunja(board *wekanBoard) []*models.ProjectWithTasksAndBucke
|
||||
}
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if attachments, ok := attachmentsByCardID[card.ID]; ok {
|
||||
for _, a := range attachments {
|
||||
decoded, err := base64.StdEncoding.DecodeString(a.File)
|
||||
if err != nil {
|
||||
log.Errorf("[WeKan migration] Error decoding attachment %s on card %s: %s", a.ID, card.ID, err.Error())
|
||||
continue
|
||||
}
|
||||
task.Attachments = append(task.Attachments, &models.TaskAttachment{
|
||||
File: &files.File{
|
||||
Name: a.Name,
|
||||
Mime: a.Type,
|
||||
Size: uint64(len(decoded)),
|
||||
FileContent: decoded,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tasks = append(tasks, task)
|
||||
}
|
||||
|
||||
@@ -330,7 +366,7 @@ func (m *Migrator) Name() string {
|
||||
|
||||
// Migrate takes a WeKan board JSON export and imports it into Vikunja.
|
||||
// @Summary Import all projects, tasks etc. from a WeKan board export
|
||||
// @Description Imports all projects, tasks, labels, checklists, and comments from a WeKan board JSON export into Vikunja.
|
||||
// @Description Imports all projects, tasks, labels, checklists, comments, and attachments from a WeKan board JSON export into Vikunja.
|
||||
// @tags migration
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
|
||||
@@ -141,6 +141,57 @@ func TestConvertWekanToVikunja(t *testing.T) {
|
||||
assert.Equal(t, int64(1), task3.BucketID) // To Do
|
||||
}
|
||||
|
||||
func TestParseWekanJSON_ParsesAttachments(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"_id": "b1",
|
||||
"title": "B",
|
||||
"lists": [],
|
||||
"cards": [],
|
||||
"attachments": [
|
||||
{"_id": "a1", "cardId": "c1", "file": "aGVsbG8=", "name": "hi.txt", "type": "text/plain"}
|
||||
]
|
||||
}`)
|
||||
|
||||
board, err := parseWekanJSON(bytes.NewReader(raw))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, board.Attachments, 1)
|
||||
assert.Equal(t, "a1", board.Attachments[0].ID)
|
||||
assert.Equal(t, "c1", board.Attachments[0].CardID)
|
||||
assert.Equal(t, "aGVsbG8=", board.Attachments[0].File)
|
||||
assert.Equal(t, "hi.txt", board.Attachments[0].Name)
|
||||
assert.Equal(t, "text/plain", board.Attachments[0].Type)
|
||||
}
|
||||
|
||||
func TestConvertWekanToVikunja_Attachments(t *testing.T) {
|
||||
// "hello" in base64 is "aGVsbG8="
|
||||
board := &wekanBoard{
|
||||
ID: "b1",
|
||||
Title: "B",
|
||||
Lists: []wekanList{{ID: "l1", Title: "L", Sort: 0}},
|
||||
Cards: []wekanCard{{ID: "c1", Title: "Card", ListID: "l1"}},
|
||||
Attachments: []wekanAttachment{
|
||||
{ID: "a1", CardID: "c1", File: "aGVsbG8=", Name: "hi.txt", Type: "text/plain"},
|
||||
{ID: "a2", CardID: "c1", File: "d29ybGQ=", Name: "w.txt", Type: "text/plain"},
|
||||
{ID: "a3", CardID: "missing", File: "aGVsbG8=", Name: "orphan.txt", Type: "text/plain"},
|
||||
},
|
||||
}
|
||||
|
||||
projects := convertWekanToVikunja(board)
|
||||
require.Len(t, projects, 1)
|
||||
require.Len(t, projects[0].Tasks, 1)
|
||||
|
||||
task := projects[0].Tasks[0]
|
||||
require.Len(t, task.Attachments, 2)
|
||||
|
||||
assert.Equal(t, "hi.txt", task.Attachments[0].File.Name)
|
||||
assert.Equal(t, "text/plain", task.Attachments[0].File.Mime)
|
||||
assert.Equal(t, []byte("hello"), task.Attachments[0].File.FileContent)
|
||||
assert.Equal(t, uint64(5), task.Attachments[0].File.Size)
|
||||
|
||||
assert.Equal(t, "w.txt", task.Attachments[1].File.Name)
|
||||
assert.Equal(t, []byte("world"), task.Attachments[1].File.FileContent)
|
||||
}
|
||||
|
||||
func TestMigrateValidJSON(t *testing.T) {
|
||||
validJSON := `{
|
||||
"_id": "board1",
|
||||
@@ -315,6 +366,12 @@ func TestConvertWekanFromFixtureFile(t *testing.T) {
|
||||
require.Len(t, task1.Comments, 1)
|
||||
assert.False(t, task1.Done)
|
||||
|
||||
// Attachment on card 1
|
||||
require.Len(t, task1.Attachments, 1)
|
||||
assert.Equal(t, "note.txt", task1.Attachments[0].File.Name)
|
||||
assert.Equal(t, "text/plain", task1.Attachments[0].File.Mime)
|
||||
assert.Equal(t, []byte("hello wekan"), task1.Attachments[0].File.FileContent)
|
||||
|
||||
// Card 3 - archived
|
||||
task3 := project.Tasks[2]
|
||||
assert.Equal(t, "Update README", task3.Title)
|
||||
|
||||
Reference in New Issue
Block a user