diff --git a/pkg/modules/migration/wekan/wekan.go b/pkg/modules/migration/wekan/wekan.go
new file mode 100644
index 000000000..5210221ed
--- /dev/null
+++ b/pkg/modules/migration/wekan/wekan.go
@@ -0,0 +1,361 @@
+// Vikunja is a to-do list application to facilitate your life.
+// Copyright 2018-present Vikunja and contributors. All rights reserved.
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+package wekan
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "sort"
+ "time"
+
+ "code.vikunja.io/api/pkg/log"
+ "code.vikunja.io/api/pkg/models"
+ "code.vikunja.io/api/pkg/modules/migration"
+ "code.vikunja.io/api/pkg/user"
+
+ "github.com/yuin/goldmark"
+)
+
+// wekanBoard represents the top-level WeKan board JSON export.
+type wekanBoard struct {
+ ID string `json:"_id"`
+ Title string `json:"title"`
+ Labels []wekanLabel `json:"labels"`
+ Lists []wekanList `json:"lists"`
+ Cards []wekanCard `json:"cards"`
+ // These are flat arrays at root level in the export, linked by IDs.
+ Checklists []wekanChecklist `json:"checklists"`
+ ChecklistItems []wekanChecklistItem `json:"checklistItems"`
+ Comments []wekanComment `json:"comments"`
+}
+
+type wekanLabel struct {
+ ID string `json:"_id"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+type wekanList struct {
+ ID string `json:"_id"`
+ Title string `json:"title"`
+ Sort float64 `json:"sort"`
+ Archived bool `json:"archived"`
+}
+
+type wekanCard struct {
+ ID string `json:"_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ ListID string `json:"listId"`
+ LabelIDs []string `json:"labelIds"`
+ Sort float64 `json:"sort"`
+ Archived bool `json:"archived"`
+ StartAt *time.Time `json:"startAt"`
+ DueAt *time.Time `json:"dueAt"`
+ EndAt *time.Time `json:"endAt"`
+ CreatedAt *time.Time `json:"createdAt"`
+ DateLastActivity *time.Time `json:"dateLastActivity"`
+ ParentID string `json:"parentId"`
+}
+
+type wekanChecklist struct {
+ ID string `json:"_id"`
+ CardID string `json:"cardId"`
+ Title string `json:"title"`
+ Sort float64 `json:"sort"`
+}
+
+type wekanChecklistItem struct {
+ ID string `json:"_id"`
+ ChecklistID string `json:"checklistId"`
+ CardID string `json:"cardId"`
+ Title string `json:"title"`
+ Sort float64 `json:"sort"`
+ IsFinished bool `json:"isFinished"`
+}
+
+type wekanComment struct {
+ ID string `json:"_id"`
+ Text string `json:"text"`
+ CreatedAt *time.Time `json:"createdAt"`
+ CardID string `json:"cardId"`
+}
+
+// wekanColorMap maps WeKan label color names to hex values.
+// Values sourced from WeKan's client/components/cards/labels.css.
+var wekanColorMap = map[string]string{
+ "white": "ffffff",
+ "green": "3cb500",
+ "yellow": "fad900",
+ "orange": "ff9f19",
+ "red": "eb4646",
+ "purple": "a632db",
+ "blue": "0079bf",
+ "sky": "00c2e0",
+ "lime": "51e898",
+ "pink": "ff78cb",
+ "black": "4d4d4d",
+ "silver": "c0c0c0",
+ "peachpuff": "ffdab9",
+ "crimson": "dc143c",
+ "plum": "dda0dd",
+ "darkgreen": "006400",
+ "slateblue": "6a5acd",
+ "magenta": "ff00ff",
+ "gold": "ffd700",
+ "navy": "000080",
+ "gray": "808080",
+ "saddlebrown": "8b4513",
+ "paleturquoise": "afeeee",
+ "mistyrose": "ffe4e1",
+ "indigo": "4b0082",
+}
+
+func convertMarkdownToHTML(input string) (string, error) {
+ var buf bytes.Buffer
+ err := goldmark.Convert([]byte(input), &buf)
+ if err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+func convertWekanToVikunja(board *wekanBoard) []*models.ProjectWithTasksAndBuckets {
+ // Build lookup maps
+ labelsByID := make(map[string]wekanLabel, len(board.Labels))
+ for _, l := range board.Labels {
+ labelsByID[l.ID] = l
+ }
+
+ // Build checklist items grouped by checklist ID
+ checklistItemsByChecklistID := make(map[string][]wekanChecklistItem)
+ for _, item := range board.ChecklistItems {
+ checklistItemsByChecklistID[item.ChecklistID] = append(
+ checklistItemsByChecklistID[item.ChecklistID], item,
+ )
+ }
+ for id := range checklistItemsByChecklistID {
+ items := checklistItemsByChecklistID[id]
+ sort.Slice(items, func(i, j int) bool {
+ return items[i].Sort < items[j].Sort
+ })
+ }
+
+ // Build checklists grouped by card ID
+ checklistsByCardID := make(map[string][]wekanChecklist)
+ for _, cl := range board.Checklists {
+ checklistsByCardID[cl.CardID] = append(checklistsByCardID[cl.CardID], cl)
+ }
+ for id := range checklistsByCardID {
+ cls := checklistsByCardID[id]
+ sort.Slice(cls, func(i, j int) bool {
+ return cls[i].Sort < cls[j].Sort
+ })
+ }
+
+ // Build comments grouped by card ID
+ commentsByCardID := make(map[string][]wekanComment)
+ for _, c := range board.Comments {
+ commentsByCardID[c.CardID] = append(commentsByCardID[c.CardID], c)
+ }
+
+ // Create buckets from lists, maintaining sort order
+ sortedLists := make([]wekanList, len(board.Lists))
+ copy(sortedLists, board.Lists)
+ sort.Slice(sortedLists, func(i, j int) bool {
+ return sortedLists[i].Sort < sortedLists[j].Sort
+ })
+
+ buckets := make([]*models.Bucket, 0, len(sortedLists))
+ listIDToBucketID := make(map[string]int64)
+ for i, l := range sortedLists {
+ bucketID := int64(i + 1)
+ listIDToBucketID[l.ID] = bucketID
+ buckets = append(buckets, &models.Bucket{
+ ID: bucketID,
+ Title: l.Title,
+ })
+ }
+
+ // Convert cards to tasks
+ tasks := make([]*models.TaskWithComments, 0, len(board.Cards))
+ for _, card := range board.Cards {
+ task := &models.TaskWithComments{
+ Task: models.Task{
+ Title: card.Title,
+ Position: card.Sort,
+ Done: card.Archived,
+ BucketID: listIDToBucketID[card.ListID],
+ },
+ }
+
+ if card.Description != "" {
+ html, err := convertMarkdownToHTML(card.Description)
+ if err != nil {
+ log.Errorf("[WeKan migration] Error converting description to HTML for card %s: %s", card.ID, err.Error())
+ task.Description = card.Description
+ } else {
+ task.Description = html
+ }
+ }
+
+ if card.StartAt != nil {
+ task.StartDate = *card.StartAt
+ }
+ if card.DueAt != nil {
+ task.DueDate = *card.DueAt
+ }
+
+ // Labels
+ for _, labelID := range card.LabelIDs {
+ label, exists := labelsByID[labelID]
+ if !exists {
+ continue
+ }
+
+ title := label.Name
+ if title == "" {
+ title = label.Color
+ }
+
+ hexColor := wekanColorMap[label.Color]
+
+ task.Labels = append(task.Labels, &models.Label{
+ Title: title,
+ HexColor: hexColor,
+ })
+ }
+
+ // Checklists → append to description as HTML task list
+ // This follows the same pattern as the Trello importer.
+ if checklists, ok := checklistsByCardID[card.ID]; ok {
+ for _, cl := range checklists {
+ items, hasItems := checklistItemsByChecklistID[cl.ID]
+ if !hasItems || len(items) == 0 {
+ continue
+ }
+ task.Description += "\n\n
" + cl.Title + "
\n\n" + `"
+ }
+ }
+
+ // Comments
+ if comments, ok := commentsByCardID[card.ID]; ok {
+ for _, c := range comments {
+ commentText := c.Text
+ commentHTML, err := convertMarkdownToHTML(c.Text)
+ if err != nil {
+ log.Errorf("[WeKan migration] Error converting comment to HTML for card %s: %s", card.ID, err.Error())
+ } else {
+ commentText = commentHTML
+ }
+ tc := &models.TaskComment{
+ Comment: commentText,
+ }
+ if c.CreatedAt != nil {
+ tc.Created = *c.CreatedAt
+ tc.Updated = *c.CreatedAt
+ }
+ task.Comments = append(task.Comments, tc)
+ }
+ }
+
+ tasks = append(tasks, task)
+ }
+
+ title := board.Title
+ if title == "" {
+ title = "Imported WeKan Board"
+ }
+
+ project := &models.ProjectWithTasksAndBuckets{
+ Project: models.Project{
+ Title: title,
+ },
+ Tasks: tasks,
+ Buckets: buckets,
+ }
+
+ return []*models.ProjectWithTasksAndBuckets{project}
+}
+
+func parseWekanJSON(r io.Reader) (*wekanBoard, error) {
+ var board wekanBoard
+ decoder := json.NewDecoder(r)
+ err := decoder.Decode(&board)
+ if err != nil {
+ return nil, err
+ }
+ return &board, nil
+}
+
+// Migrator is the WeKan migration struct.
+type Migrator struct{}
+
+// Name is used to identify the wekan migration.
+// @Summary Get migration status
+// @Description Returns if the current user already did the migration or not. This is useful to show a confirmation message in the frontend if the user is trying to do the same migration again.
+// @tags migration
+// @Produce json
+// @Security JWTKeyAuth
+// @Success 200 {object} migration.Status "The migration status"
+// @Failure 500 {object} models.Message "Internal server error"
+// @Router /migration/wekan/status [get]
+func (m *Migrator) Name() string {
+ return "wekan"
+}
+
+// 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.
+// @tags migration
+// @Accept x-www-form-urlencoded
+// @Produce json
+// @Security JWTKeyAuth
+// @Param import formData string true "The WeKan board JSON export file."
+// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
+// @Failure 500 {object} models.Message "Internal server error"
+// @Router /migration/wekan/migrate [put]
+func (m *Migrator) Migrate(user *user.User, file io.ReaderAt, size int64) error {
+ if size == 0 {
+ return &migration.ErrFileIsEmpty{}
+ }
+
+ fr := io.NewSectionReader(file, 0, size)
+
+ board, err := parseWekanJSON(fr)
+ if err != nil {
+ return err
+ }
+
+ if board.Title == "" && len(board.Cards) == 0 {
+ return &migration.ErrFileIsEmpty{}
+ }
+
+ vikunjaData := convertWekanToVikunja(board)
+
+ return migration.InsertFromStructure(vikunjaData, user)
+}