From 4cc771595114ff8e77171093b58d248a72bd30b9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 7 Apr 2026 13:57:58 +0200 Subject: [PATCH] feat(migration): add WeKan board JSON import Add a file-based migration importer that reads WeKan board JSON exports and creates Vikunja projects with kanban buckets, tasks, labels, checklists, and comments. WeKan lists become kanban buckets. Checklists are converted to HTML task lists in the description. Card descriptions and comments are converted from markdown to HTML using goldmark. Label colors are mapped from WeKan's CSS color names to their actual hex values. --- pkg/modules/migration/wekan/wekan.go | 361 +++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 pkg/modules/migration/wekan/wekan.go 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) +}