Files
wekan/models/wekanCreator.js
T
Lauri Ojansivu 899617bd4d Greatly improved import from Trello to WeKan.
- Attachments now import. Trello attachment URLs require OAuth to download, so
  WeKan could not fetch them. The Trello import page now has two separate file
    fields: a **.json** field for a single Trello board, and a
    **.zip** field for a package produced by the
    [Trello Card Attachments Downloader](https://github.com/wekan/trello-attachments-downloader).
    The .zip may contain one *or more* board .json files together with each
    board's attachments in a board-name subfolder. The .zip is uploaded over HTTP
    and processed entirely on the server (the attachment bytes never travel over
    the realtime WebSocket, which previously overflowed the connection and made
    the page flicker): WeKan detects every board .json, matches each board to its
    attachment subfolder by name, streams each file straight into the Default
    file storage, imports all boards, then opens All Boards. The upload is guarded
    against abuse — it rejects oversized uploads, zip bombs (too many files or too
    much uncompressed data, per-file size caps) and unsafe entry paths (absolute
    paths or `..` traversal), and never overwrites existing files (stored under
    generated ids). The importer also reads attachments from `card.attachments`
    (newer Trello exports), not only from the action log, and uses the correct
    Meteor-Files insert API (the previous calls were no-ops on Meteor 3). Single
    board imports (the .json file or the pasted JSON, after the map-members step)
    now run through the same server-side HTTP path rather than the realtime
    `importBoard` method, so a heavy import can no longer drop the WebSocket and
    get retried in an endless loop (the page flickering with "Invalid frame
    header" / repeated "logged out" errors). When importing only a single board
    .json and you have saved Trello API key and token, WeKan uses them on the
    server to download that board's attachments, background image, member
    avatars and card stickers from Trello (a .json export often omits stickers;
    avatars only fill in mapped users who have none — an existing avatar is never
    overwritten); each download is best-effort and a failure never aborts the
    import. After a single-board import the new board's data is subscribed and
    loaded before opening it, so it shows its swimlanes, lists and cards
    immediately instead of needing a browser page reload (the HTTP import, unlike
    the old realtime method, does not push the written documents to the client by
    itself).
  - Import Trello-only card features: stickers (shown as Font Awesome icons on
    minicards and the card detail), card cover (color or attachment), card
    location (name, address, coordinates with an OpenStreetMap link), and the
    due-date "complete" checkbox shown on the minicard. Trello's two named
    sticker packs are renamed and highlighted (the icon keeps its normal colour)
    so they stay distinct: the "taco" pack becomes "mascot" with an underlined
    icon, and the "pete" pack becomes "computer" with a ring around the icon,
    e.g. "pete-ghost" imports as "computer ghost" with a Snapchat-ghost icon.
    Several named stickers that used to share an icon now get their own Font
    Awesome 4.7 icon (mascot active => heartbeat, pixel => qrcode, proto =>
    flask, embarrassed => meh-o, clean => bath, computer shipped => truck).
  - Live Trello API import: paste your Trello API key and token on the Trello
    import page to list all your Trello workspaces and boards, then import all
    or selected boards together with their attachments (downloaded
    server-side). Imported boards are placed under a personal workspace named
    after their Trello workspace, created by name if it does not exist, under an
    optional parent workspace you choose. The API client respects Trello's rate
    limits: it spaces out requests, honours `Retry-After` on HTTP 429, retries
    transient 5xx and network errors with capped exponential backoff, and
    reports a clear error on an invalid key/token (401). After a successful API
    import you are taken to the All Boards page where the new boards appear.
  - The live API import runs as a background job on the server: you can navigate
    away and come back to the Trello import page to watch progress, see
    per-board results, and read the full error log. Errors are shown in a
    selectable text box with a one-click "Copy to clipboard" button (and you can
    still select and copy just part of the text) so they are easy to share when
    fixing issues. If the import stops on a fatal API error (invalid token, or
    rate limit still failing after retries) you can fix the cause and Resume
    from where it left off, and a server restart leaves the job paused for
    resuming rather than lost. You can also Cancel the import, or Cancel and
    delete the boards already imported, to start the whole process over cleanly.
  - You can optionally save your Trello API key and token with the Save and
    Delete buttons on the import page. When saved, they are stored only on the
    server, never sent back to the browser, and are reused automatically when
    you list workspaces or import (so you do not have to paste them again); a
    "saved" indicator is shown, Save can overwrite them with new values, and
    Delete clears them from the database. If you do not save them, they are kept
    only in server memory for the running import and re-supplied when you resume.
  - Larger Trello JSON exports can be selected as a file instead of pasted.
  - Keep imported usernames for later mapping. Imported members that match an
    existing WeKan user (by username, or by a previously recorded imported
    username) are mapped automatically, and the imported username is remembered
    on that user so future imports map too. Members with no matching WeKan user
    are no longer lost: their usernames are kept on the board, so an admin can
    map them to real users later in Admin Panel / People (Imported Usernames),
    without having to map everyone up front at import time.
  Thanks to xet7 and Claude.
- Board background images can now be stored in WeKan. A new `backgrounds` file
  storage directory is created alongside `attachments` and `avatars` (using the
  current default storage backend), and Board menu → Board backgrounds lets a
  board admin upload background images, set one as the active board background,
  download, and delete them. Board export now includes the board's background
  images and re-imports them, and a Trello board's background image is
  downloaded and stored on import (so it keeps working even if the original
  Trello URL later changes). Thanks to xet7 and Claude.
- Stickers can be added to and removed from cards directly in WeKan, chosen from
  a set of Font Awesome icons similar to Trello's stickers (previously stickers
  only arrived via Trello import). The picker also includes every "mascot"
  (underlined) and "computer" (ringed) highlighted sticker that a Trello import
  can produce — generated from the same icon mapping the importer uses — so any
  imported sticker can also be added by hand. Stickers show on the minicard and
  in the card detail. Thanks to xet7 and Claude.
- The Trello-style "complete" checkbox (mark a card complete/incomplete,
  independent of the due date) is shown as an animated green checkbox to the
  left of the card title, both on the minicard and in the opened card, with
  "Mark as complete" / "Mark as incomplete" tooltips. It uses the same animated
  checkbox style as Admin Panel / Settings / Announcements and is vertically
  centered with the title text, and the two stay in sync. The minicard and the
  opened-card checkboxes use the same checked style (the board theme's colour, or
  green when no theme is set), so they always look identical. Subtask checkboxes
  and the card-detail custom-field checkbox now use the same animated checkbox as
  checklist items (they previously used static square icons), so every checkbox
  on a card animates consistently. Thanks to xet7 and Claude.
- Cards can now have multiple locations, similar to multiple members. The card
  detail shows a Location section (after Labels and Stickers) listing each
  location with its name, address and an OpenStreetMap link, with an "Add
  location" button to add more and a button to edit or remove each one. A single
  location imported from Trello keeps working and is shown in the same list. The
  Add/Edit location popup can also detect a location from any map link (Google
  Maps, OpenStreetMap, Bing Maps, Apple Maps, or generic `?q=`/`?ll=` links):
  paste the link, press "Detect", and the latitude, longitude and (when present)
  the place name/address are filled in automatically. The popup also has an
  "Open map links at" setting (OpenStreetMap by default, or Google/Bing/Apple
  Maps) saved to the user profile, controlling which map service the location
  "Open in map" links use. Thanks to xet7 and Claude.
- The opened card now docks to the top of the window, overlaying the global and
  board header bars, instead of opening downward from the clicked minicard, and
  it can be dragged all the way to the top without its top hiding behind those
  bars. (At 100% zoom the board wrapper no longer sets a `transform: scale(1)`,
  which had made it the containing block for the fixed card and trapped it below
  the headers.) Thanks to xet7 and Claude.

Fixes #5727,
fixes #2457,
fixes #5818,
fixes #755
2026-06-13 17:19:51 +03:00

1187 lines
40 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import Actions from '/models/actions';
import Activities from '/models/activities';
import Attachments from '/models/attachments';
import Boards from '/models/boards';
import Users from '/models/users';
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
import CardComments from '/models/cardComments';
import Cards from '/models/cards';
import ChecklistItems from '/models/checklistItems';
import Checklists from '/models/checklists';
import CustomFields from './customFields';
import Lists from '/models/lists';
import Rules from '/models/rules';
import Swimlanes from '/models/swimlanes';
import Triggers from '/models/triggers';
import {
formatDateTime,
formatDate,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
import getSlug from 'limax';
import { validateAttachmentUrl } from './lib/attachmentUrlValidation';
const DateString = Match.Where(function(dateAsString) {
check(dateAsString, String);
return isValidDate(new Date(dateAsString));
});
export class WekanCreator {
constructor(data) {
// we log current date, to use the same timestamp for all our actions.
// this helps to retrieve all elements performed by the same import.
this._nowDate = new Date();
// The object creation dates, indexed by Wekan id
// (so we only parse actions once!)
this.createdAt = {
board: null,
cards: {},
lists: {},
swimlanes: {},
customFields: {},
};
// The object creator Wekan Id, indexed by the object Wekan id
// (so we only parse actions once!)
this.createdBy = {
cards: {}, // only cards have a field for that
};
// Map of labels Wekan ID => Wekan ID
this.labels = {};
// Map of swimlanes Wekan ID => Wekan ID
this.swimlanes = {};
// Map of lists Wekan ID => Wekan ID
this.lists = {};
// Map of cards Wekan ID => Wekan ID
this.cards = {};
// Map of custom fields Wekan ID => Wekan ID
this.customFields = {};
// Map of comments Wekan ID => Wekan ID
this.commentIds = {};
// Map of attachments Wekan ID => Wekan ID
this.attachmentIds = {};
// Map of checklists Wekan ID => Wekan ID
this.checklists = {};
// Map of checklistItems Wekan ID => Wekan ID
this.checklistItems = {};
// The comments, indexed by Wekan card id (to map when importing cards)
this.comments = {};
// Map of rules Wekan ID => Wekan ID
this.rules = {};
// the members, indexed by Wekan member id => Wekan user ID
this.members = data.membersMapping ? data.membersMapping : {};
// Map of triggers Wekan ID => Wekan ID
this.triggers = {};
// Map of actions Wekan ID => Wekan ID
this.actions = {};
// maps a wekanCardId to an array of wekanAttachments
this.attachments = {};
// default swimlane id created during import if necessary
this._defaultSwimlaneId = null;
// first list created during import, used as a fallback target for cards
// whose listId points at a list missing from the export (dangling ref)
this._defaultListId = null;
// Normalize possible exported id fields: some exports may use `id` instead of `_id`.
// Ensure every item we rely on has an `_id` so mappings work consistently.
const normalizeIds = arr => {
if (!arr) return;
arr.forEach(item => {
if (item && item.id && !item._id) {
item._id = item.id;
}
});
};
normalizeIds(data.lists);
normalizeIds(data.cards);
normalizeIds(data.swimlanes);
normalizeIds(data.checklists);
normalizeIds(data.checklistItems);
normalizeIds(data.triggers);
normalizeIds(data.actions);
normalizeIds(data.labels);
normalizeIds(data.customFields);
normalizeIds(data.comments);
normalizeIds(data.activities);
normalizeIds(data.rules);
}
/**
* If dateString is provided,
* return the Date it represents.
* If not, will return the date when it was first called.
* This is useful for us, as we want all import operations to
* have the exact same date for easier later retrieval.
*
* @param {String} dateString a properly formatted Date
*/
_now(dateString) {
if (dateString) {
return new Date(dateString);
}
if (!this._nowDate) {
this._nowDate = new Date();
}
return this._nowDate;
}
/**
* if wekanUserId is provided and we have a mapping,
* return it.
* Otherwise return current logged user.
* @param wekanUserId
* @private
*/
_user(wekanUserId) {
if (wekanUserId && this.members[wekanUserId]) {
return this.members[wekanUserId];
}
return Meteor.userId();
}
checkActivities(wekanActivities) {
check(wekanActivities, [
Match.ObjectIncluding({
activityType: String,
createdAt: DateString,
}),
]);
// XXX we could perform more thorough checks based on action type
}
checkBoard(wekanBoard) {
check(
wekanBoard,
Match.ObjectIncluding({
archived: Boolean,
title: String,
// XXX refine control by validating 'color' against a list of
// allowed values (is it worth the maintenance?)
color: String,
permission: Match.Where(value => {
return ['private', 'public'].indexOf(value) >= 0;
}),
}),
);
}
checkCards(wekanCards) {
check(wekanCards, [
Match.ObjectIncluding({
archived: Boolean,
dateLastActivity: DateString,
labelIds: [String],
title: String,
sort: Number,
}),
]);
}
checkLabels(wekanLabels) {
check(wekanLabels, [
Match.ObjectIncluding({
// XXX refine control by validating 'color' against a list of allowed
// values (is it worth the maintenance?)
color: String,
}),
]);
}
checkLists(wekanLists) {
check(wekanLists, [
Match.ObjectIncluding({
archived: Boolean,
title: String,
}),
]);
}
checkSwimlanes(wekanSwimlanes) {
check(wekanSwimlanes, [
Match.ObjectIncluding({
archived: Boolean,
title: String,
}),
]);
}
checkChecklists(wekanChecklists) {
check(wekanChecklists, [
Match.ObjectIncluding({
cardId: String,
title: String,
}),
]);
}
checkChecklistItems(wekanChecklistItems) {
check(wekanChecklistItems, [
Match.ObjectIncluding({
cardId: String,
title: String,
}),
]);
}
checkRules(wekanRules) {
check(wekanRules, [
Match.ObjectIncluding({
triggerId: String,
actionId: String,
title: String,
}),
]);
}
checkTriggers(wekanTriggers) {
// XXX More check based on trigger type
check(wekanTriggers, [
Match.ObjectIncluding({
activityType: String,
desc: String,
}),
]);
}
async getMembersToMap(data) {
// we will work on the list itself (an ordered array of objects) when a
// mapping is done, we add a 'wekan' field to the object representing the
// imported member
const membersToMap = data.members || [];
const users = data.users || [];
// auto-map based on username
const mappable = [];
for (const importedMember of membersToMap) {
importedMember.id = importedMember.userId;
delete importedMember.userId;
const user = users.filter(user => {
return user._id === importedMember.id;
})[0];
// Skip dangling user references (e.g. a board member whose account was
// deleted): the export only includes users that still exist, so `user`
// can be undefined here. Dereferencing it would throw and abort import.
if (!user) {
continue;
}
if (user.profile && user.profile.fullname) {
importedMember.fullName = user.profile.fullname;
}
importedMember.username = user.username;
const wekanUser = await ReactiveCache.getUser({ username: importedMember.username });
if (wekanUser) {
importedMember.wekanId = wekanUser._id;
}
mappable.push(importedMember);
}
return mappable;
}
checkActions(wekanActions) {
// XXX More check based on action type
check(wekanActions, [
Match.ObjectIncluding({
actionType: String,
desc: String,
}),
]);
}
// You must call parseActions before calling this one.
async createBoardAndLabels(boardToImport) {
const boardToCreate = {
archived: boardToImport.archived,
color: boardToImport.color,
// very old boards won't have a creation activity so no creation date
createdAt: this._now(boardToImport.createdAt),
labels: [],
members: [
{
userId: Meteor.userId(),
wekanId: Meteor.userId(),
isActive: true,
isAdmin: true,
isNoComments: false,
isCommentOnly: false,
swimlaneId: false,
},
],
presentParentTask: boardToImport.presentParentTask,
// Standalone Export has modifiedAt missing, adding modifiedAt to fix it
modifiedAt: this._now(boardToImport.modifiedAt),
permission: boardToImport.permission,
slug: getSlug(boardToImport.title) || 'board',
stars: 0,
title: await Boards.uniqueTitle(boardToImport.title),
};
// Carry over an external background image URL. Stored backgrounds (with a
// backgroundImageId) are re-created and re-pointed in recreateBackgrounds().
if (boardToImport.backgroundImageURL && !boardToImport.backgroundImageId) {
boardToCreate.backgroundImageURL = boardToImport.backgroundImageURL;
}
// now add other members
if (boardToImport.members) {
boardToImport.members.forEach(wekanMember => {
// is it defined and do we already have it in our list?
if (
wekanMember.wekanId &&
!boardToCreate.members.some(
member => member.wekanId === wekanMember.wekanId,
)
)
boardToCreate.members.push({
...wekanMember,
userId: wekanMember.wekanId,
});
});
}
if (boardToImport.labels) {
boardToImport.labels.forEach(label => {
const labelToCreate = {
_id: Random.id(6),
color: label.color,
name: label.name,
};
// We need to remember them by Wekan ID, as this is the only ref we have
// when importing cards.
this.labels[label._id] = labelToCreate._id;
boardToCreate.labels.push(labelToCreate);
});
}
const boardId = await Boards.direct.insertAsync(boardToCreate);
await Boards.direct.updateAsync(boardId, {
$set: {
modifiedAt: this._now(),
},
});
// log activity
await Activities.direct.insertAsync({
activityType: 'importBoard',
boardId,
createdAt: this._now(),
source: {
id: boardToImport.id,
system: 'Wekan',
},
// We attribute the import to current user,
// not the author from the original object.
userId: this._user(),
});
return boardId;
}
/**
* Create the Wekan cards corresponding to the supplied Wekan cards,
* as well as all linked data: activities, comments, and attachments
* @param wekanCards
* @param boardId
* @returns {Array}
*/
async createCards(wekanCards, boardId) {
const result = [];
// .direct.insertAsync below bypasses the before.insert hook that normally
// assigns cardNumber, so we must handle the number ourselves: preserve the
// number from the export when present, otherwise allocate a fresh one.
const boardObj = await ReactiveCache.getBoard(boardId);
for (const card of wekanCards) {
// A card whose listId points at a list missing from the export (a
// dangling reference) would otherwise be inserted with an undefined
// listId and never render. Fall back to the first imported list, and if
// the export had no lists at all, create one default list to hold them.
let listId = this.lists[card.listId] || this._defaultListId;
if (!listId) {
listId = await this._createDefaultList(boardId);
}
const cardToCreate = {
archived: card.archived,
boardId,
cardNumber: card.cardNumber || (await boardObj.getNextCardNumber()),
// very old boards won't have a creation activity so no creation date
createdAt: this._now(this.createdAt.cards[card._id]),
dateLastActivity: this._now(),
description: card.description,
listId,
swimlaneId: this.swimlanes[card.swimlaneId] || this._defaultSwimlaneId,
sort: card.sort,
title: card.title,
// we attribute the card to its creator if available
userId: this._user(this.createdBy.cards[card._id]),
isOvertime: card.isOvertime || false,
startAt: card.startAt ? this._now(card.startAt) : null,
dueAt: card.dueAt ? this._now(card.dueAt) : null,
spentTime: card.spentTime || null,
};
// add labels
if (card.labelIds) {
cardToCreate.labelIds = card.labelIds.map(wekanId => {
return this.labels[wekanId];
});
}
// add members {
if (card.members) {
const wekanMembers = [];
// we can't just map, as some members may not have been mapped
card.members.forEach(sourceMemberId => {
if (this.members[sourceMemberId]) {
const wekanId = this.members[sourceMemberId];
// we may map multiple Wekan members to the same wekan user
// in which case we risk adding the same user multiple times
if (!wekanMembers.find(wId => wId === wekanId)) {
wekanMembers.push(wekanId);
}
}
return true;
});
if (wekanMembers.length > 0) {
cardToCreate.members = wekanMembers;
}
}
// add assignees
if (card.assignees) {
const wekanAssignees = [];
// we can't just map, as some members may not have been mapped
card.assignees.forEach(sourceMemberId => {
if (this.members[sourceMemberId]) {
const wekanId = this.members[sourceMemberId];
// we may map multiple Wekan members to the same wekan user
// in which case we risk adding the same user multiple times
if (!wekanAssignees.find(wId => wId === wekanId)) {
wekanAssignees.push(wekanId);
}
}
return true;
});
if (wekanAssignees.length > 0) {
cardToCreate.assignees = wekanAssignees;
}
}
// set color
if (card.color) {
cardToCreate.color = card.color;
}
// add custom fields
if (card.customFields) {
cardToCreate.customFields = card.customFields.map(field => {
return {
_id: this.customFields[field._id],
value: field.value,
};
});
}
// insert card
const cardId = await Cards.direct.insertAsync(cardToCreate);
// keep track of Wekan id => Wekan id
this.cards[card._id] = cardId;
// // log activity
// Activities.direct.insert({
// activityType: 'importCard',
// boardId,
// cardId,
// createdAt: this._now(),
// listId: cardToCreate.listId,
// source: {
// id: card._id,
// system: 'Wekan',
// },
// // we attribute the import to current user,
// // not the author of the original card
// userId: this._user(),
// });
// add comments
const comments = this.comments[card._id];
if (comments) {
for (const comment of comments) {
const commentToCreate = {
boardId,
cardId,
createdAt: this._now(comment.createdAt),
text: comment.text,
// we attribute the comment to the original author, default to current user
userId: this._user(comment.userId),
};
// dateLastActivity will be set from activity insert, no need to
// update it ourselves
const commentId = await CardComments.direct.insertAsync(commentToCreate);
this.commentIds[comment._id] = commentId;
// Activities.direct.insert({
// activityType: 'addComment',
// boardId: commentToCreate.boardId,
// cardId: commentToCreate.cardId,
// commentId,
// createdAt: this._now(commentToCreate.createdAt),
// // we attribute the addComment (not the import)
// // to the original author - it is needed by some UI elements.
// userId: commentToCreate.userId,
// });
}
}
const attachments = this.attachments[card._id];
const wekanCoverId = card.coverId;
if (attachments && Meteor.isServer) {
for (const att of attachments) {
const meta = { boardId, cardId, source: 'import' };
const setCover = async newId => {
if (!newId) return;
this.attachmentIds[att._id] = newId;
if (wekanCoverId === att._id) {
await Cards.direct.updateAsync(cardId, {
$set: { coverId: newId },
});
}
};
try {
if (att.file) {
// WeKan exports embed attachment bytes as base64. Insert them
// with the server-side Meteor-Files API writeAsync (insertAsync
// is client-only; the older Attachments.insert(..., cb, true) call
// was a no-op on Meteor 3), so exported attachments now import.
const buffer = Buffer.from(att.file, 'base64');
const fileRef = await Attachments.writeAsync(
buffer,
{
fileName: att.name || 'attachment',
type: att.type || 'application/octet-stream',
userId: this._user(att.userId),
meta,
},
true,
);
await setCover(fileRef && fileRef._id);
} else if (att.url) {
const validation = await validateAttachmentUrl(att.url);
if (!validation.valid) {
if (process.env.DEBUG === 'true') {
console.warn(
'Blocked attachment URL during Wekan import:',
validation.reason,
att.url,
);
}
// Skip just this attachment (a bare return would abort
// importing all remaining cards).
continue;
}
const fileRef = await Attachments.loadAsync(
att.url,
{ meta, fileName: att.name },
true,
);
await setCover(fileRef && fileRef._id);
}
} catch (e) {
if (process.env.DEBUG === 'true') {
console.warn('Failed to import WeKan attachment', att.name, e && e.message);
}
// One failed attachment must not abort the whole import.
}
}
}
result.push(cardId);
}
return result;
}
/**
* Create the Wekan custom fields corresponding to the supplied Wekan
* custom fields.
* @param wekanCustomFields
* @param boardId
*/
async createCustomFields(wekanCustomFields, boardId) {
for (const field of wekanCustomFields) {
const fieldToCreate = {
boardIds: [boardId],
name: field.name,
type: field.type,
settings: field.settings,
showOnCard: field.showOnCard,
showLabelOnMiniCard: field.showLabelOnMiniCard,
automaticallyOnCard: field.automaticallyOnCard,
alwaysOnCard: field.alwaysOnCard,
//use date "now" if now created at date is provided (e.g. for very old boards)
createdAt: this._now(this.createdAt.customFields[field._id]),
modifiedAt: field.modifiedAt,
};
//insert copy of custom field
const fieldId = await CustomFields.direct.insertAsync(fieldToCreate);
//set modified date to now
await CustomFields.direct.updateAsync(fieldId, {
$set: {
modifiedAt: this._now(),
},
});
//store mapping of old id to new id
this.customFields[field._id] = fieldId;
}
}
// Create labels if they do not exist and load this.labels.
createLabels(wekanLabels, board) {
wekanLabels.forEach(label => {
const color = label.color;
const name = label.name;
const existingLabel = board.getLabel(name, color);
if (existingLabel) {
this.labels[label.id] = existingLabel._id;
} else {
const idLabelCreated = board.pushLabel(name, color);
this.labels[label.id] = idLabelCreated;
}
});
}
// Create a single fallback list for cards that have no valid list to live
// in (export contained no lists, or only dangling listId references).
async _createDefaultList(boardId) {
const listId = await Lists.direct.insertAsync({
archived: false,
boardId,
createdAt: this._now(),
title: 'Default',
sort: 0,
});
await Lists.direct.updateAsync(listId, {
$set: { updatedAt: this._now() },
});
this._defaultListId = listId;
return listId;
}
async createLists(wekanLists, boardId) {
for (const [listIndex, list] of wekanLists.entries()) {
const listToCreate = {
archived: list.archived,
boardId,
// We are being defensing here by providing a default date (now) if the
// creation date wasn't found on the action log. This happen on old
// Wekan boards (eg from 2013) that didn't log the 'createList' action
// we require.
createdAt: this._now(this.createdAt.lists[list.id]),
title: list.title,
sort: list.sort ? list.sort : listIndex,
};
const listId = await Lists.direct.insertAsync(listToCreate);
await Lists.direct.updateAsync(listId, {
$set: {
updatedAt: this._now(),
},
});
this.lists[list._id] = listId;
if (!this._defaultListId) {
this._defaultListId = listId;
}
// // log activity
// Activities.direct.insert({
// activityType: 'importList',
// boardId,
// createdAt: this._now(),
// listId,
// source: {
// id: list._id,
// system: 'Wekan',
// },
// // We attribute the import to current user,
// // not the creator of the original object
// userId: this._user(),
// });
}
}
async createSwimlanes(wekanSwimlanes, boardId) {
// If no swimlanes provided, create a default so cards still render
if (!wekanSwimlanes || wekanSwimlanes.length === 0) {
const swimlaneToCreate = {
archived: false,
boardId,
createdAt: this._now(),
title: 'Default',
sort: 0,
};
const created = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(created, {
$set: {
updatedAt: this._now(),
},
});
this._defaultSwimlaneId = created;
return;
}
for (const [swimlaneIndex, swimlane] of wekanSwimlanes.entries()) {
const swimlaneToCreate = {
archived: swimlane.archived,
boardId,
// We are being defensing here by providing a default date (now) if the
// creation date wasn't found on the action log. This happen on old
// Wekan boards (eg from 2013) that didn't log the 'createList' action
// we require.
createdAt: this._now(this.createdAt.swimlanes[swimlane._id]),
title: swimlane.title,
sort: swimlane.sort ? swimlane.sort : swimlaneIndex,
};
// set color
if (swimlane.color) {
swimlaneToCreate.color = swimlane.color;
}
const swimlaneId = await Swimlanes.direct.insertAsync(swimlaneToCreate);
await Swimlanes.direct.updateAsync(swimlaneId, {
$set: {
updatedAt: this._now(),
},
});
this.swimlanes[swimlane._id] = swimlaneId;
if (!this._defaultSwimlaneId) {
this._defaultSwimlaneId = swimlaneId;
}
}
}
async createSubtasks(wekanCards) {
for (const card of wekanCards) {
// get new id of card (in created / new board)
const cardIdInNewBoard = this.cards[card._id];
//If there is a mapped parent card, use the mapped card
// this means, the card and parent were in the same source board
//If there is no mapped parent card, use the original parent id,
// this should handle cases where source and parent are in different boards
// Note: This can only handle board cloning (within the same wekan instance).
// When importing boards between instances the IDs are definitely
// lost if source and parent are two different boards
// This is not the place to fix it, the entire subtask system needs to be rethought there.
const parentIdInNewBoard = this.cards[card.parentId]
? this.cards[card.parentId]
: card.parentId;
//if the parent card exists, proceed
if (await ReactiveCache.getCard(parentIdInNewBoard)) {
//set parent id of the card in the new board to the new id of the parent
await Cards.direct.updateAsync(cardIdInNewBoard, {
$set: {
parentId: parentIdInNewBoard,
},
});
}
}
}
async createChecklists(wekanChecklists, boardId) {
const result = [];
for (const [checklistIndex, checklist] of wekanChecklists.entries()) {
// Skip orphaned checklists whose card is missing from the export.
// Otherwise the checklist would be created with an undefined cardId and
// be unreachable (the same guard createChecklistItems already applies).
if (!this.cards[checklist.cardId]) {
continue;
}
// Create the checklist
const checklistToCreate = {
cardId: this.cards[checklist.cardId],
// Denormalized boardId; .direct.insert bypasses the before.insert hook
// that would otherwise derive it, so set it explicitly here.
boardId,
title: checklist.title,
createdAt: checklist.createdAt,
sort: checklist.sort ? checklist.sort : checklistIndex,
};
const checklistId = await Checklists.direct.insertAsync(checklistToCreate);
this.checklists[checklist._id] = checklistId;
result.push(checklistId);
}
return result;
}
async createTriggers(wekanTriggers, boardId) {
for (const trigger of wekanTriggers) {
if (trigger.hasOwnProperty('labelId')) {
trigger.labelId = this.labels[trigger.labelId];
}
if (trigger.hasOwnProperty('memberId')) {
trigger.memberId = this.members[trigger.memberId];
}
trigger.boardId = boardId;
const oldId = trigger._id;
delete trigger._id;
this.triggers[oldId] = await Triggers.direct.insertAsync(trigger);
}
}
async createActions(wekanActions, boardId) {
for (const action of wekanActions) {
if (action.hasOwnProperty('labelId')) {
action.labelId = this.labels[action.labelId];
}
if (action.hasOwnProperty('memberId')) {
action.memberId = this.members[action.memberId];
}
action.boardId = boardId;
const oldId = action._id;
delete action._id;
this.actions[oldId] = await Actions.direct.insertAsync(action);
}
}
async createRules(wekanRules, boardId) {
for (const rule of wekanRules) {
// Create the rule
rule.boardId = boardId;
rule.triggerId = this.triggers[rule.triggerId];
rule.actionId = this.actions[rule.actionId];
delete rule._id;
await Rules.direct.insertAsync(rule);
}
}
async createChecklistItems(wekanChecklistItems, boardId) {
for (const [checklistitemIndex, checklistitem] of wekanChecklistItems.entries()) {
//Check if the checklist for this item (still) exists
//If a checklist was deleted, but items remain, the import would error out here
//Leading to no further checklist items being imported
if (this.checklists[checklistitem.checklistId]) {
// Create the checklistItem
const checklistItemTocreate = {
title: checklistitem.title,
checklistId: this.checklists[checklistitem.checklistId],
cardId: this.cards[checklistitem.cardId],
boardId,
sort: checklistitem.sort ? checklistitem.sort : checklistitemIndex,
isFinished: checklistitem.isFinished,
};
const checklistItemId = await ChecklistItems.direct.insertAsync(
checklistItemTocreate,
);
this.checklistItems[checklistitem._id] = checklistItemId;
}
}
}
parseActivities(wekanBoard) {
wekanBoard.activities.forEach(activity => {
switch (activity.activityType) {
case 'addAttachment': {
// We have to be cautious, because the attachment could have been removed later.
// In that case Wekan still reports its addition, but removes its 'url' field.
// So we test for that
const wekanAttachment = wekanBoard.attachments.filter(attachment => {
return attachment._id === activity.attachmentId;
})[0];
if (typeof wekanAttachment !== 'undefined' && wekanAttachment) {
if (wekanAttachment.url || wekanAttachment.file) {
// we cannot actually create the Wekan attachment, because we don't yet
// have the cards to attach it to, so we store it in the instance variable.
const wekanCardId = activity.cardId;
if (!this.attachments[wekanCardId]) {
this.attachments[wekanCardId] = [];
}
this.attachments[wekanCardId].push(wekanAttachment);
}
}
break;
}
case 'addComment': {
const wekanComment = wekanBoard.comments.filter(comment => {
return comment._id === activity.commentId;
})[0];
const id = activity.cardId;
if (!this.comments[id]) {
this.comments[id] = [];
}
this.comments[id].push(wekanComment);
break;
}
case 'createBoard': {
this.createdAt.board = activity.createdAt;
break;
}
case 'createCard': {
const cardId = activity.cardId;
this.createdAt.cards[cardId] = activity.createdAt;
this.createdBy.cards[cardId] = activity.userId;
break;
}
case 'createList': {
const listId = activity.listId;
this.createdAt.lists[listId] = activity.createdAt;
break;
}
case 'createSwimlane': {
const swimlaneId = activity.swimlaneId;
this.createdAt.swimlanes[swimlaneId] = activity.createdAt;
break;
}
case 'createCustomField': {
const customFieldId = activity.customFieldId;
this.createdAt.customFields[customFieldId] = activity.createdAt;
break;
}
}
});
}
async importActivities(activities, boardId) {
for (const activity of activities) {
switch (activity.activityType) {
// Board related activities
// TODO: addBoardMember, removeBoardMember
case 'createBoard': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'board',
activityTypeId: boardId,
activityType: activity.activityType,
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
// List related activities
// TODO: removeList, archivedList
case 'createList': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'list',
activityType: activity.activityType,
listId: this.lists[activity.listId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
// Card related activities
// TODO: archivedCard, restoredCard, joinMember, unjoinMember
case 'createCard': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
listId: this.lists[activity.listId],
cardId: this.cards[activity.cardId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
case 'moveCard': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
oldListId: this.lists[activity.oldListId],
activityType: activity.activityType,
listId: this.lists[activity.listId],
cardId: this.cards[activity.cardId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
// Comment related activities
case 'addComment': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
commentId: this.commentIds[activity.commentId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
// Attachment related activities
case 'addAttachment': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
type: 'card',
activityType: activity.activityType,
attachmentId: this.attachmentIds[activity.attachmentId],
cardId: this.cards[activity.cardId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
// Checklist related activities
case 'addChecklist': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
checklistId: this.checklists[activity.checklistId],
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
case 'addChecklistItem': {
await Activities.direct.insertAsync({
userId: this._user(activity.userId),
activityType: activity.activityType,
cardId: this.cards[activity.cardId],
checklistId: this.checklists[activity.checklistId],
checklistItemId: activity.checklistItemId.replace(
activity.checklistId,
this.checklists[activity.checklistId],
),
boardId,
createdAt: this._now(activity.createdAt),
});
break;
}
}
}
}
//check(board) {
check() {
//try {
// check(data, {
// membersMapping: Match.Optional(Object),
// });
// this.checkActivities(board.activities);
// this.checkBoard(board);
// this.checkLabels(board.labels);
// this.checkLists(board.lists);
// this.checkSwimlanes(board.swimlanes);
// this.checkCards(board.cards);
//this.checkChecklists(board.checklists);
// this.checkRules(board.rules);
// this.checkActions(board.actions);
//this.checkTriggers(board.triggers);
//this.checkChecklistItems(board.checklistItems);
//} catch (e) {
// throw new Meteor.Error('error-json-schema');
// }
}
async create(board, currentBoardId) {
// TODO : Make isSandstorm variable global
const isSandstorm =
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && currentBoardId) {
const currentBoard = await ReactiveCache.getBoard(currentBoardId);
await currentBoard.archive();
}
this.parseActivities(board);
const boardId = await this.createBoardAndLabels(board);
await this.createLists(board.lists, boardId);
await this.createSwimlanes(board.swimlanes, boardId);
await this.createCustomFields(board.customFields, boardId);
await this.createCards(board.cards, boardId);
await this.createSubtasks(board.cards);
await this.createChecklists(board.checklists, boardId);
await this.createChecklistItems(board.checklistItems, boardId);
await this.importActivities(board.activities, boardId);
await this.createTriggers(board.triggers, boardId);
await this.createActions(board.actions, boardId);
await this.createRules(board.rules, boardId);
await this.recordImportedUsernames(board, boardId);
await this.recreateBackgrounds(board, boardId);
// XXX add members
return boardId;
}
// Re-create the board's background images, which are exported as board-level
// attachments (meta.source === 'board-background', no cardId), as new
// board-level Attachments for the imported board, and re-point the active
// background (backgroundImageId) to the new attachment id.
async recreateBackgrounds(board, boardId) {
if (!Meteor.isServer) return;
const backgrounds = (board.attachments || []).filter(
att => att && att.source === 'board-background' && att.file && !att.cardId,
);
if (!backgrounds.length) return;
const idMap = {};
for (const bg of backgrounds) {
try {
const buffer = Buffer.from(bg.file, 'base64');
const fileRef = await Attachments.writeAsync(
buffer,
{
fileName: bg.name || 'background',
type: bg.type || 'image/jpeg',
userId: this._user(),
meta: { boardId, source: 'board-background' },
},
true,
);
if (fileRef && fileRef._id && bg._id) {
idMap[bg._id] = fileRef._id;
}
} catch (e) {
if (process.env.DEBUG === 'true') {
console.warn('Failed to import board background', bg.name, e && e.message);
}
}
}
// Re-point the active background to the new attachment id.
const activeNewId = board.backgroundImageId && idMap[board.backgroundImageId];
if (activeNewId) {
await Boards.direct.updateAsync(boardId, {
$set: {
backgroundImageId: activeNewId,
backgroundImageURL: generateUniversalAttachmentUrl(activeNewId),
},
});
}
}
// Keep the original imported usernames so user mapping can happen later:
// members mapped to a WeKan user get the imported username added to that
// user's importUsernames (so future imports auto-map); unmapped members'
// usernames are stored on the board's importUsernames for an admin to assign
// to real users later via the People panel.
async recordImportedUsernames(board, boardId) {
if (!Meteor.isServer) return;
const usersById = {};
(board.users || []).forEach(user => {
usersById[user._id] = user;
});
const unmapped = [];
for (const member of board.members || []) {
// the client member mapper renames userId -> id before import
const sourceId = member.id || member.userId;
const sourceUser = usersById[sourceId];
const username = (sourceUser && sourceUser.username) || member.username;
if (!username) continue;
const wekanId = this.members[sourceId];
if (wekanId) {
await Users.updateAsync(wekanId, {
$addToSet: { importUsernames: username },
});
} else if (!unmapped.includes(username)) {
unmapped.push(username);
}
}
if (unmapped.length) {
await Boards.direct.updateAsync(boardId, {
$addToSet: { importUsernames: { $each: unmapped } },
});
}
}
}