Files
wekan/client/components/import/import.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

874 lines
28 KiB
JavaScript

import { ReactiveCache } from '/imports/reactiveCache';
import { trelloGetMembersToMap } from './trelloMembersMapper';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import { wekanGetMembersToMap } from './wekanMembersMapper';
import { csvGetMembersToMap } from './csvMembersMapper';
import getSlug from 'limax';
import { UserSearchIndex } from '/models/users';
import { Utils } from '/client/lib/utils';
import { TAPi18n } from '/imports/i18n';
import TrelloImportJobs from '/models/trelloImportJobs';
const Papa = require('papaparse');
Template.importHeaderBar.helpers({
title() {
return `import-board-title-${Session.get('importSource')}`;
},
});
// Helper to find the closest ancestor template instance by name
function findParentTemplateInstance(childTemplateInstance, parentTemplateName) {
let view = childTemplateInstance.view;
while (view) {
if (view.name === `Template.${parentTemplateName}` && view.templateInstance) {
return view.templateInstance();
}
view = view.parentView;
}
return null;
}
function _prepareAdditionalData(dataObject) {
const importSource = Session.get('importSource');
let membersToMap;
switch (importSource) {
case 'trello':
membersToMap = trelloGetMembersToMap(dataObject);
break;
case 'wekan':
membersToMap = wekanGetMembersToMap(dataObject);
break;
case 'csv':
membersToMap = csvGetMembersToMap(dataObject);
break;
}
return membersToMap;
}
// All Trello file/text imports are run on the server over HTTP instead of the
// DDP `importBoard` method. This matters for correctness, not just size: Meteor
// automatically re-sends an unacknowledged method call on every reconnect, so if
// an import is heavy (or the connection hiccups) the WebSocket can enter an
// endless drop/retry loop (the "Invalid frame header" flicker). An HTTP request
// is never auto-retried, and a .zip's attachment bytes never touch the realtime
// connection at all. The body is either a .zip File (application/zip) or a JSON
// string { board, membersMapping } (application/json).
async function postTrelloImport(body, contentType) {
const token =
(window.localStorage && window.localStorage.getItem('Meteor.loginToken')) || '';
const resp = await fetch('/import-trello', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': contentType,
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body,
});
let result = {};
try {
result = await resp.json();
} catch (e) {
result = {};
}
if (!resp.ok || result.error) {
throw new Error(result.error || 'import-trello-failed');
}
return result;
}
// A safe URL slug for an imported board. Trello exports name the board `name`,
// WeKan exports use `title`; getSlug (limax) throws on undefined, so guard it.
function boardSlug(data) {
const raw = (data && (data.title || data.name)) || '';
return (raw && getSlug(raw)) || 'imported-board';
}
// Navigate to a freshly server-imported board. The HTTP import (unlike a DDP
// method) does not push the new board's documents to this client, so we first
// subscribe to the board and wait until its lists/swimlanes/cards are loaded
// into Minimongo — otherwise the board opens with an empty Swimlanes view until
// the page is reloaded. The subscription is left running so the data stays
// available when the board is reopened from All Boards in the same session. A
// timeout is the safety net in case the subscription never signals ready.
function goToImportedBoard(boardId, slug) {
let navigated = false;
const go = () => {
if (navigated) return;
navigated = true;
FlowRouter.go('board', { id: boardId, slug });
};
Meteor.subscribe('board', boardId, false, { onReady: go });
Meteor.setTimeout(go, 5000);
}
// Find a workspace node by name anywhere in the user's personal workspace tree.
function findWorkspaceByName(nodes, name) {
for (const node of nodes || []) {
if (node.name === name) return node;
if (node.children) {
const found = findWorkspaceByName(node.children, name);
if (found) return found;
}
}
return null;
}
// Assign an imported board to a personal workspace named `wsName`, creating the
// workspace (under an optional parent) only if one with that name doesn't exist.
function assignBoardToNamedWorkspace(boardId, wsName, parentId = null) {
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
const existing = findWorkspaceByName(tree, wsName);
if (existing) {
Meteor.call('assignBoardToWorkspace', boardId, existing.id);
return;
}
Meteor.call('createWorkspace', { parentId, name: wsName }, (err, node) => {
if (!err && node) {
Meteor.call('assignBoardToWorkspace', boardId, node.id);
}
});
}
Template.import.onCreated(function () {
this.error = new ReactiveVar('');
this.steps = ['importTextarea', 'importMapMembers'];
this._currentStepIndex = new ReactiveVar(0);
this.importedData = new ReactiveVar();
this.membersToMap = new ReactiveVar([]);
this.importSource = Session.get('importSource');
// True while a Trello .zip package is being uploaded/imported server-side.
this.zipImporting = new ReactiveVar(false);
this.nextStep = () => {
const nextStepIndex = this._currentStepIndex.get() + 1;
if (nextStepIndex >= this.steps.length) {
this.finishImport();
} else {
this._currentStepIndex.set(nextStepIndex);
}
};
this.setError = (error) => {
this.error.set(error);
};
this.importData = async (evt, dataSource) => {
evt.preventDefault();
if (dataSource === 'csv') {
const input = this.find('.js-import-json').value;
const csv = input.indexOf('\t') > 0 ? input.replace(/(\t)/g, ',') : input;
const ret = Papa.parse(csv);
if (ret && ret.data && ret.data.length) this.importedData.set(ret.data);
else throw new Meteor.Error('error-csv-schema');
const membersToMap = _prepareAdditionalData(ret.data);
this.membersToMap.set(membersToMap);
this.nextStep();
return;
}
// Trello: a .zip package (one or more board .json files plus per-board
// attachment subdirectories from the Trello Attachments Downloader) imports
// all of its boards at once, through a separate field from the single .json.
if (dataSource === 'trello') {
const zipEl = this.find('.js-import-zip-file');
if (zipEl && zipEl.files && zipEl.files[0]) {
await this.importTrelloZip(zipEl.files[0]);
return;
}
}
try {
// A single board: JSON may come from an uploaded .json file (large Trello
// exports are awkward to paste) or from the textarea.
let input = this.find('.js-import-json').value;
const jsonFileEl = this.find('.js-import-json-file');
if (jsonFileEl && jsonFileEl.files && jsonFileEl.files[0]) {
input = await jsonFileEl.files[0].text();
}
const dataObject = JSON.parse(input);
this.setError('');
// Trello: remember the target personal-workspace name for finishImport.
this.workspaceName = '';
if (dataSource === 'trello') {
const wsEl = this.find('.js-import-workspace-name');
this.workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
}
this.importedData.set(dataObject);
const membersToMap = _prepareAdditionalData(dataObject);
// store members data and mapping in Session
// (we go deep and 2-way, so storing in data context is not a viable option)
this.membersToMap.set(membersToMap);
this.nextStep();
} catch (e) {
this.setError('error-json-malformed');
}
};
// Upload a Trello .zip package to the server, which extracts it (with
// zip-bomb / path-traversal guards), imports every board and streams each
// attachment to the Default storage, then go to All Boards.
this.importTrelloZip = async (zipFile) => {
this.setError('');
const wsEl = this.find('.js-import-workspace-name');
const workspaceName = wsEl && wsEl.value ? wsEl.value.trim() : '';
this.zipImporting.set(true);
let result;
try {
result = await postTrelloImport(zipFile, 'application/zip');
} catch (e) {
this.zipImporting.set(false);
this.setError((e && e.message) || 'import-trello-failed');
return;
}
this.zipImporting.set(false);
(result.boardIds || []).forEach(boardId => {
if (workspaceName) {
assignBoardToNamedWorkspace(boardId, workspaceName);
}
});
Session.set('fromBoard', null);
// Go to All Boards, where the newly imported boards appear.
FlowRouter.go('home');
};
this.finishImport = async () => {
const membersMapping = this.membersToMap.get();
const mappingById = {};
if (membersMapping) {
membersMapping.forEach(member => {
if (member.wekanId) {
mappingById[member.id] = member.wekanId;
}
});
}
const importedData = this.importedData.get();
// Trello: import over HTTP (see postTrelloImport) so the realtime DDP
// connection is never used for the board payload and can't enter the
// drop/retry "Invalid frame header" flicker loop. Do NOT mutate the still-
// mounted map-members template (e.g. clearing membersToMap) before
// navigating away — that forces an empty re-render mid-teardown and can
// throw "Can't select in removed DomRange". We navigate away, which
// destroys the import templates.
if (this.importSource === 'trello') {
let result;
try {
result = await postTrelloImport(
JSON.stringify({ board: importedData, membersMapping: mappingById }),
'application/json',
);
} catch (e) {
this.setError((e && e.message) || 'import-trello-failed');
return;
}
const boardId = (result.boardIds || [])[0];
if (!boardId) {
this.setError('import-trello-failed');
return;
}
Session.set('fromBoard', null);
if (this.workspaceName) {
assignBoardToNamedWorkspace(boardId, this.workspaceName);
}
goToImportedBoard(boardId, boardSlug(importedData));
return;
}
// wekan / csv: unchanged DDP import.
this.membersToMap.set([]);
Meteor.call(
'importBoard',
importedData,
{ membersMapping: mappingById },
this.importSource,
Session.get('fromBoard'),
(err, res) => {
if (err) {
this.setError(err.error);
} else {
Session.set('fromBoard', null);
FlowRouter.go('board', {
id: res,
slug: boardSlug(importedData),
});
}
},
);
};
});
Template.import.helpers({
error() {
return Template.instance().error;
},
currentTemplate() {
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
},
zipImporting() {
return Template.instance().zipImporting.get();
},
});
Template.importTextarea.helpers({
instruction() {
return `import-board-instruction-${Session.get('importSource')}`;
},
importPlaceHolder() {
const importSource = Session.get('importSource');
if (importSource === 'csv') {
return 'import-csv-placeholder';
} else {
return 'import-json-placeholder';
}
},
isTrelloImport() {
return Session.get('importSource') === 'trello';
},
});
Template.importTextarea.events({
submit(evt, tpl) {
const importTpl = findParentTemplateInstance(tpl, 'import');
if (importTpl) {
return importTpl.importData(evt, Session.get('importSource'));
}
},
});
// Module-level reference so popup children can access importMapMembers methods
let _importMapMembersTpl = null;
Template.importMapMembers.onCreated(function () {
_importMapMembersTpl = this;
this.usersLoaded = new ReactiveVar(false);
this.members = () => {
const importTpl = findParentTemplateInstance(this, 'import');
return importTpl ? importTpl.membersToMap.get() : [];
};
this._refreshMembers = (listOfMembers) => {
const importTpl = findParentTemplateInstance(this, 'import');
if (importTpl) {
importTpl.membersToMap.set(listOfMembers);
}
};
this._setPropertyForMember = (property, value, memberId, unset = false) => {
const listOfMembers = this.members();
let finder = null;
if (memberId) {
finder = member => member.id === memberId;
} else {
finder = member => member.selected;
}
listOfMembers.forEach(member => {
if (finder(member)) {
if (value !== null) {
member[property] = value;
} else {
delete member[property];
}
if (!unset) {
// we shortcut if we don't care about unsetting the others
return false;
}
} else if (unset) {
delete member[property];
}
return true;
});
// Session.get gives us a copy, we have to set it back so it sticks
this._refreshMembers(listOfMembers);
};
this.setSelectedMember = (memberId) => {
return this._setPropertyForMember('selected', true, memberId, true);
};
this.getMember = (memberId = null) => {
const allMembers = this.members();
let finder = null;
if (memberId) {
finder = user => user.id === memberId;
} else {
finder = user => user.selected;
}
return allMembers.find(finder);
};
this.mapSelectedMember = (wekanId) => {
return this._setPropertyForMember('wekanId', wekanId, null);
};
this.unmapMember = (memberId) => {
return this._setPropertyForMember('wekanId', null, memberId);
};
this.autorun(() => {
const handle = this.subscribe(
'user-miniprofile',
this.members().map(member => {
return member.username;
}),
);
Tracker.nonreactive(() => {
Tracker.autorun(() => {
if (
handle.ready() &&
!this.usersLoaded.get() &&
this.members().length
) {
this._refreshMembers(
this.members().map(member => {
if (!member.wekanId) {
let user = ReactiveCache.getUser({ username: member.username });
if (!user) {
user = ReactiveCache.getUser({ importUsernames: member.username });
}
if (user) {
member.wekanId = user._id;
}
}
return member;
}),
);
}
this.usersLoaded.set(handle.ready());
});
});
});
});
Template.importMapMembers.onDestroyed(function () {
if (_importMapMembersTpl === this) {
_importMapMembersTpl = null;
}
});
Template.importMapMembers.helpers({
usersLoaded() {
return Template.instance().usersLoaded;
},
members() {
return Template.instance().members();
},
});
Template.importMapMembers.events({
submit(evt, tpl) {
evt.preventDefault();
const importTpl = findParentTemplateInstance(tpl, 'import');
if (importTpl) {
importTpl.nextStep();
}
},
'click .js-select-member'(evt, tpl) {
const memberToMap = Template.currentData();
if (memberToMap.wekan) {
// todo xxx ask for confirmation?
tpl.unmapMember(memberToMap.id);
} else {
tpl.setSelectedMember(memberToMap.id);
Popup.open('importMapMembersAdd')(evt);
}
},
});
// Global reactive variables for import member popup
const importMemberPopupState = {
searching: new ReactiveVar(false),
searchResults: new ReactiveVar([]),
noResults: new ReactiveVar(false),
searchTimeout: null,
};
Template.importMapMembersAddPopup.onCreated(function () {
this.searching = importMemberPopupState.searching;
this.searchResults = importMemberPopupState.searchResults;
this.noResults = importMemberPopupState.noResults;
this.searchTimeout = null;
this.searching.set(false);
this.searchResults.set([]);
this.noResults.set(false);
});
Template.importMapMembersAddPopup.onRendered(function () {
// Guard against the DOM range being gone (e.g. the popup was closed during a
// re-render) — calling find/$ then throws "Can't select in removed DomRange".
if (this.view && this.view.isDestroyed) return;
const input = this.find('.js-search-member-input');
if (input) input.focus();
});
Template.importMapMembersAddPopup.onDestroyed(function () {
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searching.set(false);
});
function importPerformSearch(tpl, query) {
if (!query || query.length < 2) {
tpl.searchResults.set([]);
tpl.noResults.set(false);
return;
}
tpl.searching.set(true);
tpl.noResults.set(false);
const results = UserSearchIndex.search(query, { limit: 20 }).fetch();
tpl.searchResults.set(results);
tpl.searching.set(false);
if (results.length === 0) {
tpl.noResults.set(true);
}
}
Template.importMapMembersAddPopup.events({
'click .js-select-import'(event, tpl) {
if (_importMapMembersTpl) {
_importMapMembersTpl.mapSelectedMember(Template.currentData().__originalId);
}
Popup.back();
},
'keyup .js-search-member-input'(event, tpl) {
const query = event.target.value.trim();
if (tpl.searchTimeout) {
clearTimeout(tpl.searchTimeout);
}
tpl.searchTimeout = setTimeout(() => {
importPerformSearch(tpl, query);
}, 300);
},
});
Template.importMapMembersAddPopup.helpers({
searchResults() {
return importMemberPopupState.searchResults.get();
},
searching() {
return importMemberPopupState.searching;
},
noResults() {
return importMemberPopupState.noResults;
},
});
// ---------------------------------------------------------------------------
// Live Trello API import: key/token -> list workspaces & boards -> import
// selected boards (with attachments) server-side, placing each under a
// personal workspace named after its Trello workspace.
// ---------------------------------------------------------------------------
function flattenWorkspaceTree(nodes, depth = 0, acc = []) {
(nodes || []).forEach(node => {
acc.push({ id: node.id, label: `${'— '.repeat(depth)}${node.name}` });
if (node.children && node.children.length) {
flattenWorkspaceTree(node.children, depth + 1, acc);
}
});
return acc;
}
// Build the copy-paste-friendly error text for a job: the error log plus a
// summary line per failed board.
function jobErrorText(job) {
if (!job) return '';
const lines = [];
(job.errorLog || []).forEach(line => lines.push(line));
return lines.join('\n');
}
Template.importTrelloApi.onCreated(function () {
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
this.workspaces = new ReactiveVar([]);
this.copied = new ReactiveVar(false);
// Board selection state: map of trelloBoardId -> true. Tracked here (rather
// than via DOM checkboxes) because the UI uses animated .materialCheckBox
// elements, not native inputs.
this.selectedBoards = new ReactiveVar({});
// The import itself runs server-side as a persisted job; watch it reactively
// so progress survives navigating away and back.
this.subscribe('trelloImportJobs');
});
// Collect every board id across all listed workspaces.
function allBoardIds(workspaces) {
const ids = {};
(workspaces || []).forEach(ws => {
(ws.boards || []).forEach(b => {
ids[b.id] = true;
});
});
return ids;
}
Template.importTrelloApi.helpers({
error() {
return Template.instance().error;
},
loading() {
return Template.instance().loading;
},
copied() {
return Template.instance().copied;
},
hasWorkspaces() {
return Template.instance().workspaces.get().length > 0;
},
workspaceList() {
return Template.instance().workspaces.get();
},
boardSelected() {
return !!Template.instance().selectedBoards.get()[this.id];
},
workspaceSelected() {
const sel = Template.instance().selectedBoards.get();
const boards = this.boards || [];
return boards.length > 0 && boards.every(b => sel[b.id]);
},
flatWorkspaceNodes() {
const user = ReactiveCache.getCurrentUser();
const tree = (user && user.profile && user.profile.boardWorkspacesTree) || [];
return flattenWorkspaceTree(tree);
},
credsSaved() {
const user = ReactiveCache.getCurrentUser();
return !!(user && user.profile && user.profile.trelloApiSaved);
},
// --- current background job ---
currentJob() {
return TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
},
jobIsRunning() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && job.status === 'running';
},
jobCanResume() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && (job.status === 'paused' || job.status === 'error');
},
jobIsFinished() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return job && (job.status === 'done' || job.status === 'cancelled');
},
canStartImport() {
// Don't start a second import while one is active (running/paused/error),
// which would create a hidden concurrent job.
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return !job || job.status === 'done' || job.status === 'cancelled';
},
jobProgressText() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return '';
return `${job.currentIndex} / ${job.total}`;
},
jobProgressPercent() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job || !job.total) return 0;
return Math.round((job.currentIndex / job.total) * 100);
},
jobResults() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return (job && job.results) || [];
},
jobHasErrors() {
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
return !!(job && job.errorLog && job.errorLog.length);
},
jobErrorText() {
return jobErrorText(TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } }));
},
});
Template.importTrelloApi.events({
'click .js-trello-save-creds'(evt, tpl) {
evt.preventDefault();
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
if (!key || !token) {
tpl.error.set('trello-api-credentials-required');
return;
}
tpl.error.set('');
Meteor.call('saveTrelloCredentials', key, token, err => {
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
return;
}
// Don't keep the token sitting in the browser; it now lives server-side.
tpl.find('.js-trello-key').value = '';
tpl.find('.js-trello-token').value = '';
});
},
'click .js-trello-delete-creds'(evt, tpl) {
evt.preventDefault();
tpl.error.set('');
Meteor.call('deleteTrelloCredentials', err => {
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
});
tpl.find('.js-trello-key').value = '';
tpl.find('.js-trello-token').value = '';
},
'click .js-trello-list-workspaces'(evt, tpl) {
evt.preventDefault();
// key/token may be empty when saved credentials exist; the server falls
// back to the saved ones and returns an error if neither is available.
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
tpl.error.set('');
tpl.loading.set(true);
Meteor.call('trelloListWorkspaces', key, token, (err, res) => {
tpl.loading.set(false);
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
tpl.workspaces.set([]);
tpl.selectedBoards.set({});
} else {
const workspaces = res || [];
tpl.workspaces.set(workspaces);
// Preselect all boards by default.
tpl.selectedBoards.set(allBoardIds(workspaces));
}
});
},
// Toggle a single board's animated checkbox.
'click .js-toggle-board'(evt, tpl) {
evt.preventDefault();
const id = this.id;
const sel = { ...tpl.selectedBoards.get() };
if (sel[id]) {
delete sel[id];
} else {
sel[id] = true;
}
tpl.selectedBoards.set(sel);
},
// Toggle all boards in a workspace: if all are selected, clear them; else
// select them all.
'click .js-toggle-workspace'(evt, tpl) {
evt.preventDefault();
const boards = this.boards || [];
const sel = { ...tpl.selectedBoards.get() };
const allSelected = boards.length > 0 && boards.every(b => sel[b.id]);
boards.forEach(b => {
if (allSelected) {
delete sel[b.id];
} else {
sel[b.id] = true;
}
});
tpl.selectedBoards.set(sel);
},
'click .js-trello-select-all'(evt, tpl) {
evt.preventDefault();
tpl.selectedBoards.set(allBoardIds(tpl.workspaces.get()));
},
'click .js-trello-unselect-all'(evt, tpl) {
evt.preventDefault();
tpl.selectedBoards.set({});
},
'click .js-trello-import-selected'(evt, tpl) {
evt.preventDefault();
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
const sel = tpl.selectedBoards.get();
const boardIds = Object.keys(sel).filter(id => sel[id]);
if (!boardIds.length) {
tpl.error.set('trello-select-boards');
return;
}
const parentEl = tpl.find('.js-trello-parent-workspace');
const parentId = parentEl && parentEl.value ? parentEl.value : null;
tpl.error.set('');
// Start the server-side job; progress shows up via the subscription. The
// user is free to navigate away and come back.
Meteor.call('trelloStartImport', key, token, boardIds, parentId, err => {
if (err) {
tpl.error.set(err.reason || err.error || 'trello-api-error');
}
});
},
'click .js-trello-resume'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
const key = tpl.find('.js-trello-key').value.trim();
const token = tpl.find('.js-trello-token').value.trim();
if (!key || !token) {
tpl.error.set('trello-api-credentials-required');
return;
}
tpl.error.set('');
Meteor.call('trelloResumeImport', job._id, key, token, err => {
if (err) tpl.error.set(err.reason || err.error || 'trello-api-error');
});
},
'click .js-trello-cancel'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
Meteor.call('trelloCancelImport', job._id, false);
},
'click .js-trello-cancel-delete'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
// eslint-disable-next-line no-alert
if (!window.confirm(TAPi18n.__('trello-cancel-delete-confirm'))) return;
Meteor.call('trelloCancelImport', job._id, true);
},
'click .js-trello-clear'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
if (!job) return;
Meteor.call('trelloClearImportJob', job._id, false);
},
'click .js-trello-copy-errors'(evt, tpl) {
evt.preventDefault();
const job = TrelloImportJobs.findOne({}, { sort: { createdAt: -1 } });
const text = jobErrorText(job);
if (!text) return;
const done = () => {
tpl.copied.set(true);
setTimeout(() => tpl.copied.set(false), 2000);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done, () => {
// Fall back to selecting the textarea so the user can copy manually.
const ta = tpl.find('.js-trello-errors-text');
if (ta) {
ta.focus();
ta.select();
}
});
} else {
const ta = tpl.find('.js-trello-errors-text');
if (ta) {
ta.focus();
ta.select();
try {
document.execCommand('copy');
done();
} catch (e) {
// user can copy manually from the selected textarea
}
}
}
},
});