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) +}