diff --git a/pkg/modules/migration/wekan/testdata_wekan_export.json b/pkg/modules/migration/wekan/testdata_wekan_export.json index 008a217c0..022bddcef 100644 --- a/pkg/modules/migration/wekan/testdata_wekan_export.json +++ b/pkg/modules/migration/wekan/testdata_wekan_export.json @@ -63,5 +63,14 @@ "rules": [], "triggers": [], "actions": [], - "customFields": [] + "customFields": [], + "attachments": [ + { + "_id": "att-1", + "cardId": "card-1", + "file": "aGVsbG8gd2VrYW4=", + "name": "note.txt", + "type": "text/plain" + } + ] } diff --git a/pkg/modules/migration/wekan/wekan.go b/pkg/modules/migration/wekan/wekan.go index 5210221ed..69f2f8682 100644 --- a/pkg/modules/migration/wekan/wekan.go +++ b/pkg/modules/migration/wekan/wekan.go @@ -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 diff --git a/pkg/modules/migration/wekan/wekan_test.go b/pkg/modules/migration/wekan/wekan_test.go index 942af8dda..32afe8372 100644 --- a/pkg/modules/migration/wekan/wekan_test.go +++ b/pkg/modules/migration/wekan/wekan_test.go @@ -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)