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:
kolaente
2026-04-13 14:46:07 +02:00
committed by kolaente
parent cff690fb5f
commit 85836076be
3 changed files with 104 additions and 2 deletions

View File

@@ -63,5 +63,14 @@
"rules": [],
"triggers": [],
"actions": [],
"customFields": []
"customFields": [],
"attachments": [
{
"_id": "att-1",
"cardId": "card-1",
"file": "aGVsbG8gd2VrYW4=",
"name": "note.txt",
"type": "text/plain"
}
]
}

View File

@@ -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

View File

@@ -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)