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
This commit is contained in:
1 parent
2757bfb365
commit
899617bd4d
51 files changed
+4529
-88
No files matched your search
@@ -8,10 +8,10 @@ template(name="archivedBoards")
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.board-header-btn.js-delete-board
|
||||
button.primary.board-header-btn.js-delete-board
|
||||
i.fa.fa-trash
|
||||
| {{_ 'delete-board'}}
|
||||
button.board-header-btn.js-restore-board
|
||||
button.primary.board-header-btn.js-restore-board
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
|
||||
@@ -55,11 +55,10 @@ template(name="cardCustomField-number")
|
||||
template(name="cardCustomField-checkbox")
|
||||
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if data.value}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
|
||||
|
||||
template(name="cardCustomField-currency")
|
||||
if canModifyCard
|
||||
|
||||
@@ -106,9 +106,6 @@ Template['cardCustomField-checkbox'].onCreated(function () {
|
||||
});
|
||||
|
||||
Template['cardCustomField-checkbox'].events({
|
||||
'click .js-checklist-item .check-box-unicode'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
'click .js-checklist-item .check-box-container'(event, tpl) {
|
||||
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
|
||||
},
|
||||
|
||||
@@ -890,3 +890,167 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
transform: scale(1.01);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* Sticker picker + card-detail stickers + due-complete toggle */
|
||||
.card-stickers-picker {
|
||||
max-width: 320px;
|
||||
}
|
||||
.card-sticker-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 2px;
|
||||
border-radius: 3px;
|
||||
font-size: 18px;
|
||||
color: #4d4d4d;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.card-sticker-select:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
.card-sticker-select.active {
|
||||
background: #d6ecd2;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-sticker-select .card-sticker-check {
|
||||
position: absolute;
|
||||
bottom: 1px;
|
||||
right: 2px;
|
||||
font-size: 10px;
|
||||
color: #3cb500;
|
||||
}
|
||||
.card-stickers .card-sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0 8px 4px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
.card-sticker .card-sticker-remove {
|
||||
margin-left: 3px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-sticker .card-sticker-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
.card-due-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the card title */
|
||||
.card-details-complete-toggle {
|
||||
float: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
|
||||
the 13px checkbox down so it lands at the vertical middle of the title. */
|
||||
margin: 14px 10px 0 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Card locations: multiple locations, each editable with a remove button */
|
||||
.card-details-item-location .card-locations {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-details-item-location .card-location {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
background: #fff;
|
||||
border: 1px solid #e3e3e3;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
.card-details-item-location .card-location-info {
|
||||
flex: 1;
|
||||
}
|
||||
.card-details-item-location .js-edit-location {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-name {
|
||||
font-weight: bold;
|
||||
}
|
||||
.card-details-item-location .card-location-address {
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-left: 8px;
|
||||
color: #8c8c8c;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
color: #eb4646;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.card-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* "Open map links at" provider selector in the location popup */
|
||||
.js-card-location-form .map-provider-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.js-card-location-form .map-provider-row .js-map-provider {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Opened card (card details popup) docks to the very top of the window and
|
||||
overlays the global + board header bars instead of opening below them. The
|
||||
base .pop-over is position:absolute with a small margin/top offset, which left
|
||||
the card's top behind the fixed header bars; fix it to the viewport top with a
|
||||
z-index above every header (headers top out around z-index 1000-2000). */
|
||||
.pop-over[data-popup="cardDetailsPopup"] {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
z-index: 100000 !important;
|
||||
}
|
||||
|
||||
/* Sticker highlight styles inside the sticker picker (mascot = underline,
|
||||
computer = round), matching how they render on the card. */
|
||||
.card-sticker-select i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.card-sticker-select i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -61,6 +61,9 @@ template(name="cardDetails")
|
||||
span.emoji-icon
|
||||
i.fa.fa-link
|
||||
span.copied-tooltip {{_ 'copied'}}
|
||||
if canModifyCard
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
h2.card-details-title.js-card-title(
|
||||
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
|
||||
+viewer
|
||||
@@ -126,6 +129,47 @@ template(name="cardDetails")
|
||||
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-stickers
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-sticky-note-o
|
||||
| {{_ 'stickers'}}
|
||||
.card-details-item-content.card-stickers
|
||||
each stickers
|
||||
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if canModifyCard
|
||||
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
.card-details-item.card-details-item-location
|
||||
h3.card-details-item-title
|
||||
i.fa.fa-map-marker
|
||||
| {{_ 'location'}}
|
||||
.card-details-item-content.card-locations
|
||||
each getLocations
|
||||
.card-location
|
||||
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
|
||||
if name
|
||||
.card-location-name= name
|
||||
if address
|
||||
.card-location-address= address
|
||||
if hasCoordinates
|
||||
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
|
||||
i.fa.fa-map-o
|
||||
= ' '
|
||||
| {{_ 'location-open-map'}}
|
||||
if canModifyCard
|
||||
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
|
||||
i.fa.fa-times
|
||||
if canModifyCard
|
||||
unless currentUser.isWorker
|
||||
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
if currentBoard.hasAnyAllowsDate
|
||||
hr
|
||||
|
||||
@@ -1196,3 +1240,39 @@ template(name="cardStartPlanningPokerPopup")
|
||||
if getPokerQuestion
|
||||
if currentUser.isBoardAdmin
|
||||
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
|
||||
|
||||
template(name="cardLocationsPopup")
|
||||
form.js-card-location-form
|
||||
label
|
||||
| {{_ 'location-detect-from-map'}}
|
||||
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
|
||||
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
|
||||
if detectMessage
|
||||
p.quiet.js-detect-message {{detectMessage}}
|
||||
hr
|
||||
label
|
||||
| {{_ 'location-name'}}
|
||||
input.js-location-name(type="text" value="{{location.name}}")
|
||||
label
|
||||
| {{_ 'location-address'}}
|
||||
input.js-location-address(type="text" value="{{location.address}}")
|
||||
label
|
||||
| {{_ 'location-latitude'}}
|
||||
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
|
||||
label
|
||||
| {{_ 'location-longitude'}}
|
||||
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
|
||||
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
|
||||
if location._id
|
||||
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
|
||||
hr
|
||||
.map-provider-row
|
||||
span.map-provider-label {{_ 'location-open-map-at'}}
|
||||
select.js-map-provider
|
||||
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
|
||||
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
|
||||
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
|
||||
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
|
||||
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
|
||||
if mapSavedMessage
|
||||
p.quiet.js-map-saved {{mapSavedMessage}}
|
||||
@@ -51,8 +51,100 @@ import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { MultiSelection } from '/client/lib/multiSelection';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import autosize from 'autosize';
|
||||
|
||||
// Id of the location currently being edited in the cardLocationsPopup; null
|
||||
// when adding a new location.
|
||||
const editingLocationId = new ReactiveVar(null);
|
||||
|
||||
// Parse coordinates (and, when present, a place name / address) out of a map
|
||||
// link from common providers: Google Maps, OpenStreetMap, Bing Maps, Apple Maps
|
||||
// and generic `?q=lat,lon` / `?ll=lat,lon` links. Returns whatever it can find
|
||||
// as { latitude, longitude, name, address }.
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
let m;
|
||||
// Google Maps place marker in the data part: !3d<lat>!4d<lon> (most precise).
|
||||
if ((m = s.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
// Google Maps view/place: @<lat>,<lon>
|
||||
if ((m = s.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// OpenStreetMap marker: ?mlat=..&mlon=..
|
||||
if ((m = s.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = s.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
// OpenStreetMap map hash: #map=z/lat/lon
|
||||
if ((m = s.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
// Bing Maps: cp=lat~lon
|
||||
if ((m = s.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
// Apple Maps and generic ll=lat,lon
|
||||
if ((m = s.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic q=lat,lon (Google/Apple query form)
|
||||
if ((m = s.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
// Generic center=lat,lon
|
||||
if ((m = s.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = (raw) => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = (t) => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
// Place name from Google Maps /place/<Name>/
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
// Address from a textual q=/query=/destination= parameter.
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Build an "open in map" link for the given provider and coordinates. Mirrors
|
||||
// the providers offered in the location popup's "Open map links at" selector.
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
// SubsManager removed for Meteor 3 migration
|
||||
const { calculateIndexData } = Utils;
|
||||
|
||||
@@ -134,6 +226,11 @@ Template.cardDetails.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
// A reactive re-render (e.g. a card moving between the inline swimlane render
|
||||
// and the draggable openCards popup) can create and then remove this instance
|
||||
// within the same flush. If our DOM range is already gone, calling this.$()
|
||||
// below throws "Can't select in removed DomRange", so bail out early.
|
||||
if (this.view && this.view.isDestroyed) return;
|
||||
this.calculateNextPeak();
|
||||
if (Meteor.settings.public.CARD_OPENED_WEBHOOK_ENABLED) {
|
||||
// Send Webhook but not create Activities records ---
|
||||
@@ -273,6 +370,23 @@ Template.cardDetails.helpers({
|
||||
return card.findWatcher(Meteor.userId());
|
||||
},
|
||||
|
||||
// Returns the card's locations (multiple supported), each enriched with the
|
||||
// coordinate flag and OpenStreetMap link used by the template.
|
||||
getLocations() {
|
||||
const card = Template.currentData();
|
||||
if (!card || !card.getLocations) return [];
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const provider = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return card.getLocations().map(loc => {
|
||||
const hasCoordinates =
|
||||
typeof loc.latitude === 'number' && typeof loc.longitude === 'number';
|
||||
const mapUrl = hasCoordinates
|
||||
? mapLinkFor(provider, loc.latitude, loc.longitude)
|
||||
: '';
|
||||
return { ...loc, hasCoordinates, mapUrl };
|
||||
});
|
||||
},
|
||||
|
||||
customFieldsGrid() {
|
||||
return ReactiveCache.getCurrentUser().hasCustomFieldsGrid();
|
||||
},
|
||||
@@ -610,9 +724,57 @@ Template.cardDetails.events({
|
||||
'click .js-assignee': Popup.open('cardAssignee'),
|
||||
'click .js-add-assignees': Popup.open('cardAssignees'),
|
||||
'click .js-add-labels': Popup.open('cardLabels'),
|
||||
'click .js-add-stickers': Popup.open('cardStickers'),
|
||||
'click .js-remove-sticker'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const index = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const card = Template.currentData();
|
||||
if (card && !Number.isNaN(index)) {
|
||||
card.removeStickerAt(index);
|
||||
}
|
||||
},
|
||||
'click .js-add-location'(event) {
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-edit-location'(event) {
|
||||
// Let the "Open in map" link work without also opening the edit popup.
|
||||
if ($(event.target).closest('a.card-location-map').length) return;
|
||||
event.preventDefault();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
editingLocationId.set(event.currentTarget.dataset.locationId || null);
|
||||
Popup.open('cardLocations')(event, {
|
||||
dataContextIfCurrentDataIsUndefined: card,
|
||||
});
|
||||
},
|
||||
'click .js-remove-location'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const locationId = event.currentTarget.dataset.locationId;
|
||||
const card = Template.currentData();
|
||||
if (card && locationId) {
|
||||
card.removeLocation(locationId);
|
||||
}
|
||||
},
|
||||
'click .js-received-date': Popup.open('editCardReceivedDate'),
|
||||
'click .js-start-date': Popup.open('editCardStartDate'),
|
||||
'click .js-due-date': Popup.open('editCardDueDate'),
|
||||
'click .js-toggle-due-complete'(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) return;
|
||||
const card = Template.currentData();
|
||||
card.setDueComplete(!card.getDueComplete());
|
||||
},
|
||||
'click .js-end-date': Popup.open('editCardEndDate'),
|
||||
'click .js-show-positive-votes': Popup.open('positiveVoteMembers'),
|
||||
'click .js-show-negative-votes': Popup.open('negativeVoteMembers'),
|
||||
@@ -1144,6 +1306,109 @@ Template.cardMembersPopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Popup that adds or edits a single card location (name, address, latitude,
|
||||
// longitude). Cards can hold multiple locations, like members.
|
||||
Template.cardLocationsPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
this.detectMsg = new ReactiveVar('');
|
||||
this.mapSavedMsg = new ReactiveVar('');
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.helpers({
|
||||
location() {
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
const found = card.getLocations().find(loc => loc._id === id);
|
||||
if (found) return found;
|
||||
}
|
||||
return {};
|
||||
},
|
||||
detectMessage() {
|
||||
return Template.instance().detectMsg.get();
|
||||
},
|
||||
isMapProvider(provider) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const current = user ? user.getMapProvider() : 'openstreetmap';
|
||||
return current === provider;
|
||||
},
|
||||
mapSavedMessage() {
|
||||
return Template.instance().mapSavedMsg.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardLocationsPopup.events({
|
||||
'click .js-detect-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const linkInput = tpl.find('.js-location-map-link');
|
||||
const parsed = parseMapLink(linkInput ? linkInput.value : '');
|
||||
let filled = false;
|
||||
if (typeof parsed.latitude === 'number') {
|
||||
tpl.find('.js-location-latitude').value = parsed.latitude;
|
||||
tpl.find('.js-location-longitude').value = parsed.longitude;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.name) {
|
||||
tpl.find('.js-location-name').value = parsed.name;
|
||||
filled = true;
|
||||
}
|
||||
if (parsed.address) {
|
||||
tpl.find('.js-location-address').value = parsed.address;
|
||||
filled = true;
|
||||
}
|
||||
tpl.detectMsg.set(
|
||||
TAPi18n.__(filled ? 'location-detect-done' : 'location-detect-none'),
|
||||
);
|
||||
},
|
||||
'submit .js-card-location-form'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
if (!card) {
|
||||
Popup.back();
|
||||
return;
|
||||
}
|
||||
const name = tpl.find('.js-location-name').value.trim();
|
||||
const address = tpl.find('.js-location-address').value.trim();
|
||||
const latRaw = tpl.find('.js-location-latitude').value.trim();
|
||||
const lonRaw = tpl.find('.js-location-longitude').value.trim();
|
||||
const latitude = latRaw === '' ? undefined : parseFloat(latRaw);
|
||||
const longitude = lonRaw === '' ? undefined : parseFloat(lonRaw);
|
||||
const data = { name, address, latitude, longitude };
|
||||
const id = editingLocationId.get();
|
||||
if (id) {
|
||||
card.updateLocation(id, data);
|
||||
} else {
|
||||
card.addLocation(data);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-delete-location'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const card = ReactiveCache.getCard(tpl.cardId);
|
||||
const id = editingLocationId.get();
|
||||
if (card && id) {
|
||||
card.removeLocation(id);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-save-map-provider'(event) {
|
||||
event.preventDefault();
|
||||
const tpl = Template.instance();
|
||||
const select = tpl.find('.js-map-provider');
|
||||
const provider = select ? select.value : 'openstreetmap';
|
||||
Meteor.call('setMapProvider', provider, err => {
|
||||
tpl.mapSavedMsg.set(
|
||||
TAPi18n.__(err ? 'server-error' : 'map-provider-saved'),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const filterMembers = (filterTerm) => {
|
||||
let currBoard = Utils.getCurrentBoard();
|
||||
let members = currBoard.activeMembers();
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
template(name="cardStickersPopup")
|
||||
.card-stickers-picker.clearfix
|
||||
each stickerIcons
|
||||
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if isSelected
|
||||
i.fa.fa-check.card-sticker-check
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { STICKER_PICKER } from '/models/metadata/stickers';
|
||||
|
||||
// Picker popup that lets a user add/remove stickers on a card. Each entry is
|
||||
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
|
||||
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
|
||||
// details stickers section with the card as the data context.
|
||||
|
||||
Template.cardStickersPopup.onCreated(function () {
|
||||
const data = Template.currentData();
|
||||
this.cardId = data && data._id;
|
||||
});
|
||||
|
||||
function pickerCard(tpl) {
|
||||
return ReactiveCache.getCard(tpl.cardId);
|
||||
}
|
||||
|
||||
Template.cardStickersPopup.helpers({
|
||||
stickerIcons() {
|
||||
return STICKER_PICKER;
|
||||
},
|
||||
isSelected() {
|
||||
const card = pickerCard(Template.instance());
|
||||
return !!(card && card.hasSticker(this.icon, this.highlight));
|
||||
},
|
||||
stickerTitle() {
|
||||
return this.name || this.icon;
|
||||
},
|
||||
});
|
||||
|
||||
Template.cardStickersPopup.events({
|
||||
'click .js-select-sticker'(event) {
|
||||
event.preventDefault();
|
||||
const card = pickerCard(Template.instance());
|
||||
if (card) {
|
||||
card.toggleSticker(this.icon, this.highlight, this.name);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -834,3 +834,35 @@
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Trello-style "mark complete" toggle, left of the minicard title */
|
||||
.minicard-title .minicard-complete-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
/* Nudge down so the checkmark sits at the vertical middle of the title text
|
||||
(the checked materialCheckBox is shifted up 4px by its own positioning). */
|
||||
position: relative;
|
||||
top: 4px;
|
||||
margin-right: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
|
||||
pete pack => round (ring around the icon). Icon keeps its normal colour. */
|
||||
.minicard-sticker i.sticker-underline {
|
||||
border-bottom: 2px solid currentColor;
|
||||
padding-bottom: 1px;
|
||||
line-height: 1;
|
||||
}
|
||||
.minicard-sticker i.sticker-round {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6em;
|
||||
height: 1.6em;
|
||||
line-height: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -21,7 +21,7 @@ template(name="minicard")
|
||||
+minicardStartDate
|
||||
if showDue
|
||||
if getDue
|
||||
.date
|
||||
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
|
||||
+minicardDueDate
|
||||
if showEnd
|
||||
if getEnd
|
||||
@@ -54,6 +54,9 @@ template(name="minicard")
|
||||
span {{_ 'upload-completed'}}
|
||||
|
||||
.minicard-title
|
||||
if canModifyCard
|
||||
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
|
||||
.parent-prefix
|
||||
| {{ parentString ' > ' }}
|
||||
@@ -152,6 +155,11 @@ template(name="minicard")
|
||||
each shouldShowChecklistAtMinicard
|
||||
+minicardChecklist(checklist=. card=..)
|
||||
.badges
|
||||
if stickers.length
|
||||
.badge.badge-state-image-only.minicard-stickers
|
||||
each stickers
|
||||
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
|
||||
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
|
||||
if comments.length
|
||||
.badge(title="{{_ 'card-comments-title' comments.length }}")
|
||||
span.badge-icon.badge-comment.badge-text
|
||||
|
||||
@@ -178,6 +178,16 @@ Template.minicard.events({
|
||||
else if (this.isLinkedBoard())
|
||||
Utils.goBoardId(this.linkedId);
|
||||
},
|
||||
'click .js-toggle-card-complete'(event) {
|
||||
// Trello-style "mark complete" toggle (left of the title). Don't let the
|
||||
// click open the card.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!Utils.canModifyCard()) {
|
||||
return;
|
||||
}
|
||||
this.setDueComplete(!this.getDueComplete());
|
||||
},
|
||||
'click .js-toggle-minicard-label-text'() {
|
||||
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
|
||||
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
|
||||
|
||||
@@ -139,6 +139,9 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-item:hover {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
.subtasks-item .check-box-container {
|
||||
padding-right: 10px;
|
||||
}
|
||||
.subtasks-item .check-box {
|
||||
margin: 0.1em 0 0 0;
|
||||
}
|
||||
|
||||
@@ -74,14 +74,13 @@ template(name="subtasksItems")
|
||||
template(name='subtaskItemDetail')
|
||||
.js-subtasks-item.subtasks-item
|
||||
if canModifyCard
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.check-box-container
|
||||
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
else
|
||||
span.check-box-unicode
|
||||
i.fa(class="{{#if item.isFinished}}fa-check-square{{else}}fa-square-o{{/if}}")
|
||||
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
|
||||
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
|
||||
+viewer
|
||||
= item.title
|
||||
|
||||
@@ -119,7 +119,7 @@ Template.subtasks.helpers({
|
||||
});
|
||||
|
||||
Template.subtaskItemDetail.events({
|
||||
async 'click .js-subtasks-item .check-box-unicode'() {
|
||||
async 'click .js-subtasks-item .check-box-container'() {
|
||||
const item = Template.currentData().item;
|
||||
if (item && item._id) {
|
||||
await item.toggleItem();
|
||||
|
||||
Vendored
+116
@@ -49,3 +49,119 @@ a.show-mapping {
|
||||
font-size: 90%;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Trello API import: file rows, workspace tree, job progress and errors */
|
||||
/* The shared page spinners are centered with a large top margin (e.g. bounce
|
||||
uses margin:100px auto), which leaves a big empty gap above the spinner in
|
||||
this compact panel. Make spinners here small and inline instead. */
|
||||
.trello-api-import .sk-spinner {
|
||||
margin: 0 8px 0 0;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
width: auto;
|
||||
}
|
||||
.trello-api-import .import-file-row label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
.trello-api-import .import-file-row input,
|
||||
.trello-api-import .import-file-row select {
|
||||
width: 100%;
|
||||
}
|
||||
.trello-workspaces {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #ddd;
|
||||
padding: 8px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.trello-workspace {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-workspace .trello-boards {
|
||||
margin-left: 22px;
|
||||
}
|
||||
.trello-workspace-title,
|
||||
.trello-board {
|
||||
align-items: center;
|
||||
padding: 3px 0;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.trello-workspace-title .materialCheckBox,
|
||||
.trello-board .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 12px;
|
||||
}
|
||||
.trello-select-controls button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.trello-job-controls button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
/* Import progress meter, styled like Admin Panel / Attachments / Move Attachments */
|
||||
.trello-progress-label {
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
.trello-progress-bar {
|
||||
background: #e9ecef;
|
||||
border-radius: 8px;
|
||||
height: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.trello-progress-fill {
|
||||
background: linear-gradient(90deg, #28a745, #20c997);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
.trello-progress-fill.is-running::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
|
||||
animation: trelloProgressShimmer 2s infinite;
|
||||
}
|
||||
@keyframes trelloProgressShimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
.trello-progress-percent {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.trello-import-result.is-ok {
|
||||
color: #3cb500;
|
||||
}
|
||||
.trello-import-result.is-error {
|
||||
color: #eb4646;
|
||||
}
|
||||
.js-trello-errors-text {
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* Trello API credential Save/Delete buttons */
|
||||
.trello-api-creds-buttons button {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.trello-creds-saved {
|
||||
color: #3cb500;
|
||||
}
|
||||
|
||||
/* Simplified per-field hint text under the Trello file upload labels */
|
||||
.import-file-row .import-file-hint {
|
||||
display: block;
|
||||
font-size: 0.85em;
|
||||
margin: 2px 0 4px;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ template(name="import")
|
||||
.wrapper
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if zipImporting
|
||||
.warning {{_ 'import-trello-zip-progress'}}
|
||||
+Template.dynamic(template=currentTemplate)
|
||||
|
||||
template(name="importTextarea")
|
||||
@@ -15,7 +17,115 @@ template(name="importTextarea")
|
||||
p: label(for='import-textarea') {{_ instruction}} {{_ 'import-board-instruction-about-errors'}}
|
||||
textarea.js-import-json(id='import-textarea' placeholder="{{_ importPlaceHolder}}" autofocus)
|
||||
| {{jsonText}}
|
||||
if isTrelloImport
|
||||
p.import-file-row
|
||||
label(for='import-json-file') {{_ 'import-trello-json-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-json-file-hint'}}
|
||||
input.js-import-json-file(id='import-json-file' type="file" accept=".json,application/json")
|
||||
p.import-file-row
|
||||
label(for='import-zip-file') {{_ 'import-trello-zip-file'}}
|
||||
span.quiet.import-file-hint {{_ 'import-trello-zip-file-hint'}}
|
||||
input.js-import-zip-file(id='import-zip-file' type="file" accept=".zip,application/zip")
|
||||
p.import-file-row
|
||||
label(for='import-workspace-name') {{_ 'import-trello-workspace'}}
|
||||
input.js-import-workspace-name(id='import-workspace-name' type="text" placeholder="{{_ 'import-trello-workspace-placeholder'}}")
|
||||
input.primary.wide(type="submit" value="{{_ 'import'}}")
|
||||
if isTrelloImport
|
||||
hr
|
||||
+importTrelloApi
|
||||
|
||||
template(name="importTrelloApi")
|
||||
.trello-api-import
|
||||
h2 {{_ 'trello-api-import'}}
|
||||
p.quiet {{_ 'trello-api-import-desc'}}
|
||||
p.import-file-row
|
||||
label(for='trello-api-key') {{_ 'trello-api-key'}}
|
||||
input.js-trello-key(id='trello-api-key' type="text" autocomplete="off")
|
||||
p.import-file-row
|
||||
label(for='trello-api-token') {{_ 'trello-api-token'}}
|
||||
input.js-trello-token(id='trello-api-token' type="password" autocomplete="off")
|
||||
p.trello-api-creds-buttons
|
||||
button.primary.js-trello-save-creds(type="button") {{_ 'save'}}
|
||||
button.negate.js-trello-delete-creds(type="button") {{_ 'delete'}}
|
||||
if credsSaved
|
||||
p.quiet.trello-creds-saved
|
||||
i.fa.fa-check
|
||||
span {{_ 'trello-api-credentials-saved'}}
|
||||
p
|
||||
button.primary.js-trello-list-workspaces(type="button") {{_ 'trello-list-workspaces'}}
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
if loading.get
|
||||
+spinner
|
||||
if hasWorkspaces
|
||||
p.import-file-row
|
||||
label(for='trello-parent-workspace') {{_ 'import-trello-parent-workspace'}}
|
||||
select.js-trello-parent-workspace(id='trello-parent-workspace')
|
||||
option(value="") {{_ 'trello-parent-workspace-top'}}
|
||||
each flatWorkspaceNodes
|
||||
option(value=id)= label
|
||||
p.trello-select-controls
|
||||
button.primary.js-trello-select-all(type="button") {{_ 'select-all'}}
|
||||
button.primary.js-trello-unselect-all(type="button") {{_ 'unselect-all'}}
|
||||
.trello-workspaces
|
||||
each workspaceList
|
||||
.trello-workspace
|
||||
a.flex.trello-workspace-title.js-toggle-workspace
|
||||
.materialCheckBox(class="{{#if workspaceSelected}}is-checked{{/if}}")
|
||||
b {{name}}
|
||||
.trello-boards
|
||||
each boards
|
||||
a.flex.trello-board.js-toggle-board(data-board-id="{{id}}")
|
||||
.materialCheckBox(class="{{#if boardSelected}}is-checked{{/if}}")
|
||||
span {{name}}
|
||||
if closed
|
||||
span.quiet ({{_ 'archived'}})
|
||||
if canStartImport
|
||||
p
|
||||
button.primary.wide.js-trello-import-selected(type="button") {{_ 'trello-import-selected'}}
|
||||
if currentJob
|
||||
hr
|
||||
.trello-import-job
|
||||
h3 {{_ 'trello-import-progress'}}
|
||||
with currentJob
|
||||
p.trello-progress-label
|
||||
b {{_ 'status'}}:
|
||||
span {{_ status}} ({{jobProgressText}})
|
||||
.trello-progress-bar
|
||||
.trello-progress-fill(class="{{#if $eq status 'running'}}is-running{{/if}}" style="width: {{jobProgressPercent}}%")
|
||||
.trello-progress-percent {{jobProgressPercent}}%
|
||||
if lastError
|
||||
.warning {{lastError}}
|
||||
p.trello-job-controls
|
||||
if jobIsRunning
|
||||
button.primary.js-trello-cancel(type="button") {{_ 'trello-cancel'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobCanResume
|
||||
button.primary.js-trello-resume(type="button") {{_ 'trello-resume'}}
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-cancel-delete'}}
|
||||
if jobIsFinished
|
||||
button.negate.js-trello-cancel-delete(type="button") {{_ 'trello-delete-imported'}}
|
||||
button.primary.js-trello-clear(type="button") {{_ 'trello-clear-job'}}
|
||||
|
||||
if jobResults.length
|
||||
.trello-import-results
|
||||
each jobResults
|
||||
p.trello-import-result(class="{{#if success}}is-ok{{else}}is-error{{/if}}")
|
||||
if success
|
||||
i.fa.fa-check
|
||||
span {{title}} — {{attachmentsImported}} {{_ 'attachments'}}
|
||||
else
|
||||
i.fa.fa-warning
|
||||
span {{trelloBoardId}}: {{this.error}}
|
||||
|
||||
if jobHasErrors
|
||||
.trello-import-errors
|
||||
p
|
||||
b {{_ 'trello-import-errors'}}
|
||||
button.primary.js-trello-copy-errors(type="button") {{_ 'copy-to-clipboard'}}
|
||||
if copied.get
|
||||
span.quiet {{_ 'copied'}}
|
||||
textarea.js-trello-errors-text(readonly rows="8" wrap="off")= jobErrorText
|
||||
|
||||
template(name="importMapMembers")
|
||||
h2 {{_ 'import-map-members'}}
|
||||
|
||||
@@ -6,6 +6,8 @@ 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');
|
||||
|
||||
@@ -44,6 +46,92 @@ function _prepareAdditionalData(dataObject) {
|
||||
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'];
|
||||
@@ -51,6 +139,8 @@ Template.import.onCreated(function () {
|
||||
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;
|
||||
@@ -65,10 +155,10 @@ Template.import.onCreated(function () {
|
||||
this.error.set(error);
|
||||
};
|
||||
|
||||
this.importData = (evt, dataSource) => {
|
||||
this.importData = async (evt, dataSource) => {
|
||||
evt.preventDefault();
|
||||
const input = this.find('.js-import-json').value;
|
||||
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);
|
||||
@@ -76,10 +166,36 @@ Template.import.onCreated(function () {
|
||||
const membersToMap = _prepareAdditionalData(ret.data);
|
||||
this.membersToMap.set(membersToMap);
|
||||
this.nextStep();
|
||||
} else {
|
||||
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
|
||||
@@ -89,39 +205,97 @@ Template.import.onCreated(function () {
|
||||
} catch (e) {
|
||||
this.setError('error-json-malformed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.finishImport = () => {
|
||||
const additionalData = {};
|
||||
// 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();
|
||||
if (membersMapping) {
|
||||
const mappingById = {};
|
||||
if (membersMapping) {
|
||||
membersMapping.forEach(member => {
|
||||
if (member.wekanId) {
|
||||
mappingById[member.id] = member.wekanId;
|
||||
}
|
||||
});
|
||||
additionalData.membersMapping = mappingById;
|
||||
}
|
||||
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',
|
||||
this.importedData.get(),
|
||||
additionalData,
|
||||
importedData,
|
||||
{ membersMapping: mappingById },
|
||||
this.importSource,
|
||||
Session.get('fromBoard'),
|
||||
(err, res) => {
|
||||
if (err) {
|
||||
this.setError(err.error);
|
||||
} else {
|
||||
let title = getSlug(this.importedData.get().title) || 'imported-board';
|
||||
Session.set('fromBoard', null);
|
||||
FlowRouter.go('board', {
|
||||
id: res,
|
||||
slug: title,
|
||||
slug: boardSlug(importedData),
|
||||
});
|
||||
//Utils.goBoardId(res);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -135,6 +309,9 @@ Template.import.helpers({
|
||||
currentTemplate() {
|
||||
return Template.instance().steps[Template.instance()._currentStepIndex.get()];
|
||||
},
|
||||
zipImporting() {
|
||||
return Template.instance().zipImporting.get();
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.helpers({
|
||||
@@ -149,6 +326,9 @@ Template.importTextarea.helpers({
|
||||
return 'import-json-placeholder';
|
||||
}
|
||||
},
|
||||
isTrelloImport() {
|
||||
return Session.get('importSource') === 'trello';
|
||||
},
|
||||
});
|
||||
|
||||
Template.importTextarea.events({
|
||||
@@ -320,7 +500,11 @@ Template.importMapMembersAddPopup.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.importMapMembersAddPopup.onRendered(function () {
|
||||
this.find('.js-search-member-input').focus();
|
||||
// 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 () {
|
||||
@@ -380,3 +564,310 @@ Template.importMapMembersAddPopup.helpers({
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -342,3 +342,51 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
padding-top: 5px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
/* Board backgrounds management popup */
|
||||
.board-backgrounds-manage .board-backgrounds-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.board-bg-item {
|
||||
width: 48%;
|
||||
margin: 1%;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.board-bg-item.is-active {
|
||||
border-color: #3cb500;
|
||||
}
|
||||
.board-bg-thumb {
|
||||
height: 80px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border-radius: 3px;
|
||||
position: relative;
|
||||
}
|
||||
.board-bg-active-check {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
color: #3cb500;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 50%;
|
||||
padding: 2px;
|
||||
}
|
||||
.board-bg-actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.board-bg-actions a {
|
||||
cursor: pointer;
|
||||
color: #4d4d4d;
|
||||
}
|
||||
.board-bg-actions a:hover {
|
||||
color: #000;
|
||||
}
|
||||
.js-bg-upload-button {
|
||||
display: inline-block;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@@ -129,6 +129,38 @@ template(name="boardChangeColorPopup")
|
||||
if isSelected
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="boardBackgroundsPopup")
|
||||
.board-backgrounds-manage
|
||||
input.js-bg-upload-input.hide(type="file" name="file" accept="image/*")
|
||||
a.primary.wide.js-bg-upload-button
|
||||
i.fa.fa-plus
|
||||
= ' '
|
||||
| {{_ 'upload-background'}}
|
||||
if uploading.get
|
||||
+spinner
|
||||
if error.get
|
||||
.warning {{_ error.get}}
|
||||
.board-backgrounds-grid.clearfix
|
||||
each backgrounds
|
||||
.board-bg-item(class="{{#if isActiveBackground}}is-active{{/if}}")
|
||||
.board-bg-thumb(style="background-image: url('{{link}}');")
|
||||
if isActiveBackground
|
||||
i.fa.fa-check.board-bg-active-check
|
||||
.board-bg-actions
|
||||
unless isActiveBackground
|
||||
a.js-set-board-background(title="{{_ 'set-as-active'}}")
|
||||
i.fa.fa-check-circle-o
|
||||
a.js-download-board-background(href="{{link}}?download=true" download="{{name}}" title="{{_ 'download'}}")
|
||||
i.fa.fa-arrow-down
|
||||
a.js-delete-board-background(title="{{_ 'delete'}}")
|
||||
i.fa.fa-trash-o
|
||||
|
||||
template(name="deleteBoardBackgroundPopup")
|
||||
p {{_ 'board-background-delete-pop'}}
|
||||
button.js-confirm.negate.full(type="submit")
|
||||
i.fa.fa-trash-o
|
||||
| {{_ 'delete'}}
|
||||
|
||||
template(name="boardChangeBackgroundImagePopup")
|
||||
form
|
||||
label
|
||||
@@ -567,6 +599,10 @@ template(name="boardMenuPopup")
|
||||
a.js-change-background-image
|
||||
i.fa.fa-picture-o
|
||||
| {{_ 'board-change-background-image'}}
|
||||
li
|
||||
a.js-manage-board-backgrounds
|
||||
i.fa.fa-image
|
||||
| {{_ 'board-backgrounds'}}
|
||||
//Bug Board icons random dance https://github.com/wekan/wekan/issues/4214
|
||||
//if currentUser.isBoardAdmin
|
||||
//
|
||||
|
||||
@@ -7,6 +7,8 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
|
||||
import AccessibilitySettings from '/models/accessibilitySettings';
|
||||
import Boards from '/models/boards';
|
||||
import Attachments from '/models/attachments';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
import Integrations from '/models/integrations';
|
||||
import Lists from '/models/lists';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
@@ -347,6 +349,7 @@ Template.boardMenuPopup.events({
|
||||
},
|
||||
'click .js-change-board-color': Popup.open('boardChangeColor'),
|
||||
'click .js-change-background-image': Popup.open('boardChangeBackgroundImage'),
|
||||
'click .js-manage-board-backgrounds': Popup.open('boardBackgrounds'),
|
||||
'click .js-board-info-on-my-boards': Popup.open('boardInfoOnMyBoards'),
|
||||
'click .js-change-language': Popup.open('changeLanguage'),
|
||||
'click .js-delete-duplicate-lists': Popup.afterConfirm('deleteDuplicateLists', function() {
|
||||
@@ -869,6 +872,87 @@ Template.boardChangeBackgroundImagePopup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// Manage the board's stored background images (upload / set active / download /
|
||||
// delete). Backgrounds are board-level Attachments (meta.boardId, no cardId,
|
||||
// meta.source === 'board-background') in the default attachments storage.
|
||||
Template.boardBackgroundsPopup.onCreated(function () {
|
||||
this.uploading = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
const board = Utils.getCurrentBoard();
|
||||
this.boardId = board && board._id;
|
||||
if (this.boardId) {
|
||||
this.subscribe('boardBackgrounds', this.boardId);
|
||||
}
|
||||
});
|
||||
|
||||
Template.boardBackgroundsPopup.helpers({
|
||||
uploading() {
|
||||
return Template.instance().uploading;
|
||||
},
|
||||
error() {
|
||||
return Template.instance().error;
|
||||
},
|
||||
backgrounds() {
|
||||
// Raw collection docs don't carry the .link() helper, so compute the URL.
|
||||
return Attachments.collection
|
||||
.find({
|
||||
'meta.boardId': Template.instance().boardId,
|
||||
'meta.source': 'board-background',
|
||||
})
|
||||
.fetch()
|
||||
.map(att => ({
|
||||
_id: att._id,
|
||||
name: att.name,
|
||||
link: generateUniversalAttachmentUrl(att._id),
|
||||
}));
|
||||
},
|
||||
isActiveBackground() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return board && board.backgroundImageId === this._id;
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardBackgroundsPopup.events({
|
||||
'click .js-bg-upload-button'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.find('.js-bg-upload-input').click();
|
||||
},
|
||||
async 'change .js-bg-upload-input'(event, tpl) {
|
||||
const file = event.currentTarget.files && event.currentTarget.files[0];
|
||||
if (!file) return;
|
||||
tpl.error.set('');
|
||||
tpl.uploading.set(true);
|
||||
const uploader = await Attachments.insertAsync(
|
||||
{
|
||||
file,
|
||||
chunkSize: 'dynamic',
|
||||
meta: { boardId: tpl.boardId, source: 'board-background' },
|
||||
},
|
||||
false,
|
||||
);
|
||||
uploader.on('end', (err) => {
|
||||
tpl.uploading.set(false);
|
||||
if (err) tpl.error.set(err.reason || 'upload-failed');
|
||||
});
|
||||
uploader.on('error', (err) => {
|
||||
tpl.uploading.set(false);
|
||||
tpl.error.set((err && err.reason) || 'upload-failed');
|
||||
});
|
||||
uploader.start();
|
||||
// allow re-selecting the same file later
|
||||
event.currentTarget.value = '';
|
||||
},
|
||||
async 'click .js-set-board-background'() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
await board.setBackgroundImage(this._id);
|
||||
Utils.setBackgroundImage();
|
||||
},
|
||||
'click .js-delete-board-background': Popup.afterConfirm('deleteBoardBackground', function () {
|
||||
Meteor.call('removeBoardBackground', this._id);
|
||||
Popup.back();
|
||||
}),
|
||||
});
|
||||
|
||||
Template.boardInfoOnMyBoardsPopup.onCreated(function() {
|
||||
this.currentBoard = Utils.getCurrentBoard();
|
||||
});
|
||||
|
||||
@@ -163,6 +163,19 @@ function currentListIsInThisSwimlane(swimlaneId) {
|
||||
|
||||
function currentCardIsInThisList(listId, swimlaneId) {
|
||||
const currentCard = Utils.getCurrentCard();
|
||||
if (!currentCard) return false;
|
||||
// On desktop a clicked card is shown as a draggable popup via the openCards
|
||||
// list (rendered once in boardBody). Don't ALSO render it inline here, or the
|
||||
// card opens twice — the inline copy sits under the popup and becomes visible
|
||||
// when the popup is dragged away by its titlebar handle. Direct card-URL
|
||||
// navigation sets only currentCard (not openCards), so those still render
|
||||
// inline as before.
|
||||
if (!Utils.isMiniScreen()) {
|
||||
const openCards = Session.get('openCards') || [];
|
||||
if (openCards.includes(currentCard._id)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
//const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (
|
||||
//currentUser &&
|
||||
|
||||
@@ -3,6 +3,7 @@ import '/client/components/cards/cardCustomFields.jade';
|
||||
import '/client/components/cards/cardDate.jade';
|
||||
import '/client/components/cards/cardDescription.jade';
|
||||
import '/client/components/cards/cardDetails.jade';
|
||||
import '/client/components/cards/cardStickers.jade';
|
||||
import '/client/components/cards/cardTime.jade';
|
||||
import '/client/components/cards/checklists.jade';
|
||||
import '/client/components/cards/inlinedCardDescription.jade';
|
||||
@@ -16,6 +17,7 @@ import '/client/components/cards/cardCustomFields.js';
|
||||
import '/client/components/cards/cardDate.js';
|
||||
import '/client/components/cards/cardDescription.js';
|
||||
import '/client/components/cards/cardDetails.js';
|
||||
import '/client/components/cards/cardStickers.js';
|
||||
import '/client/components/cards/cardTime.js';
|
||||
import '/client/components/cards/checklists.js';
|
||||
import '/client/components/cards/labels.js';
|
||||
|
||||
+17
-2
@@ -84,7 +84,7 @@ window.Popup = new (class {
|
||||
hasPopupParent: clickFromPopup(evt),
|
||||
title: self._getTitle(popupName),
|
||||
depth: self._stack.length,
|
||||
offset: self._getOffset(openerElement),
|
||||
offset: self._getOffset(openerElement, popupName),
|
||||
dataContext: (this && this.currentData && this.currentData()) || (options && options.dataContextIfCurrentDataIsUndefined) || this,
|
||||
});
|
||||
|
||||
@@ -217,7 +217,7 @@ window.Popup = new (class {
|
||||
// We automatically calculate the popup offset from the reference element
|
||||
// position and dimensions. We also reactively use the window dimensions to
|
||||
// ensure that the popup is always visible on the screen.
|
||||
_getOffset(element) {
|
||||
_getOffset(element, popupName) {
|
||||
const $element = $(element);
|
||||
return () => {
|
||||
Utils.windowResizeDep.depend();
|
||||
@@ -230,6 +230,21 @@ window.Popup = new (class {
|
||||
// Calculate actual popup width based on CSS: min(380px, 55vw)
|
||||
const popupWidth = Math.min(380, viewportWidth * 0.55);
|
||||
|
||||
// Card details popup: dock it to the top of the viewport so it overlays
|
||||
// the global and board header bars (its z-index already sits above them)
|
||||
// instead of opening downward from the clicked minicard.
|
||||
if (popupName === 'cardDetailsPopup') {
|
||||
const cardWidth = Math.min(600, viewportWidth * 0.8);
|
||||
const centeredLeft = (viewportWidth - cardWidth) / 2;
|
||||
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
|
||||
// so the card overlays the header bars; use the full viewport height.
|
||||
return {
|
||||
left: Math.max(viewportPadding, centeredLeft),
|
||||
top: 0,
|
||||
maxHeight: viewportHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
|
||||
if (!$element || $element.length === 0) {
|
||||
return {
|
||||
|
||||
+11
-1
@@ -189,9 +189,19 @@ export const Utils = {
|
||||
boardCanvas.style.height = '';
|
||||
}
|
||||
} else {
|
||||
// Desktop mode: apply zoom to entire board-wrapper as before
|
||||
// Desktop mode: apply zoom to entire board-wrapper as before.
|
||||
// At 100% do NOT set transform: scale(1): any transform makes the
|
||||
// board-wrapper the containing block + stacking context for its
|
||||
// position:fixed descendants (the opened/dragged card), which traps the
|
||||
// card inside the board area below the page header bars. Clearing it at
|
||||
// 100% lets the opened card overlay the top bars.
|
||||
if (level === 1) {
|
||||
boardWrapper.style.transform = '';
|
||||
boardWrapper.style.transformOrigin = '';
|
||||
} else {
|
||||
boardWrapper.style.transform = `scale(${level})`;
|
||||
boardWrapper.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
// If zoom is 50% or lower, make board wrapper full width like content
|
||||
if (level <= 0.5) {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Deprecated. Board background images are no longer a separate file collection;
|
||||
// they are stored as board-level Attachments (meta.source === 'board-background',
|
||||
// no meta.cardId) in the default attachments storage and served by the existing
|
||||
// /cdn/storage/attachments route. This file is intentionally empty and no longer
|
||||
// imported anywhere.
|
||||
@@ -0,0 +1,2 @@
|
||||
// Deprecated. See models/backgrounds.js. Board backgrounds are board-level
|
||||
// Attachments now; this file is intentionally empty and no longer imported.
|
||||
@@ -20,6 +20,7 @@ import Triggers from '/models/triggers';
|
||||
import { Counters, incrementCounterAsync } from '/models/counters';
|
||||
import getSlug from 'limax';
|
||||
import { findWhere, where, groupBy } from '/imports/lib/collectionHelpers';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
const getTAPi18n = () => require('/imports/i18n').TAPi18n;
|
||||
|
||||
@@ -359,6 +360,20 @@ Boards.attachSchema(
|
||||
*/
|
||||
type: Boolean,
|
||||
},
|
||||
importUsernames: {
|
||||
/**
|
||||
* Usernames of imported (e.g. Trello) board members that were not mapped
|
||||
* to an existing WeKan user during import. Kept so an admin can map them
|
||||
* to real users later (People panel -> user -> Imported Usernames), after
|
||||
* which future imports of this board auto-map.
|
||||
*/
|
||||
type: Array,
|
||||
optional: true,
|
||||
defaultValue: [],
|
||||
},
|
||||
'importUsernames.$': {
|
||||
type: String,
|
||||
},
|
||||
color: {
|
||||
/**
|
||||
* The color of the board.
|
||||
@@ -379,6 +394,15 @@ Boards.attachSchema(
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
backgroundImageId: {
|
||||
/**
|
||||
* The id of the active board background attachment (a board-level
|
||||
* Attachment with meta.source === 'board-background'), when the active
|
||||
* background is an uploaded/imported image rather than an external URL.
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
allowsCardCounterList: {
|
||||
/**
|
||||
* Show card counter per list
|
||||
@@ -1723,6 +1747,28 @@ Boards.helpers({
|
||||
return false;
|
||||
},
|
||||
|
||||
// Set a board-level background attachment as the active board background.
|
||||
async setBackgroundImage(backgroundId) {
|
||||
const currentUser = await ReactiveCache.getCurrentUser();
|
||||
if (currentUser.isBoardAdmin() || currentUser.isAdmin()) {
|
||||
const backgroundImageURL = generateUniversalAttachmentUrl(backgroundId);
|
||||
return await Boards.updateAsync(this._id, {
|
||||
$set: { backgroundImageId: backgroundId, backgroundImageURL },
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async unsetBackgroundImage() {
|
||||
const currentUser = await ReactiveCache.getCurrentUser();
|
||||
if (currentUser.isBoardAdmin() || currentUser.isAdmin()) {
|
||||
return await Boards.updateAsync(this._id, {
|
||||
$set: { backgroundImageId: '', backgroundImageURL: '' },
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
async setVisibility(visibility) {
|
||||
return await Boards.updateAsync(this._id, { $set: { permission: visibility } });
|
||||
},
|
||||
|
||||
+317
@@ -1,5 +1,6 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveCache, ReactiveMiniMongoIndex } from '/imports/reactiveCache';
|
||||
import {
|
||||
formatDateTime,
|
||||
@@ -273,6 +274,144 @@ Cards.attachSchema(
|
||||
type: Date,
|
||||
optional: true,
|
||||
},
|
||||
dueComplete: {
|
||||
/**
|
||||
* Has the due date been marked complete? Imported from Trello
|
||||
* (card.dueComplete) and toggled by the minicard due checkbox.
|
||||
*/
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
},
|
||||
stickers: {
|
||||
/**
|
||||
* list of stickers shown on the card (imported from Trello)
|
||||
*/
|
||||
type: Array,
|
||||
optional: true,
|
||||
defaultValue: [],
|
||||
},
|
||||
'stickers.$': {
|
||||
type: new SimpleSchema({
|
||||
icon: {
|
||||
/**
|
||||
* Font Awesome icon name shown for the sticker (without the `fa-`
|
||||
* prefix), e.g. 'thumbs-up', 'heart', 'star'.
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
name: {
|
||||
/**
|
||||
* Human-readable name/description of the sticker, used as the tooltip
|
||||
* (e.g. the original Trello sticker name).
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
highlight: {
|
||||
/**
|
||||
* Optional highlight style for the sticker icon instead of colour:
|
||||
* 'underline' (icon underlined, imported Trello "taco" pack) or
|
||||
* 'round' (ring around the icon, imported Trello "pete" pack).
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
allowedValues: ['underline', 'round'],
|
||||
},
|
||||
position: {
|
||||
/**
|
||||
* order/position of the sticker on the card
|
||||
*/
|
||||
type: Number,
|
||||
optional: true,
|
||||
defaultValue: 0,
|
||||
},
|
||||
}),
|
||||
},
|
||||
locationName: {
|
||||
/**
|
||||
* human readable location name of the card (imported from Trello)
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
locationAddress: {
|
||||
/**
|
||||
* street address of the card location (imported from Trello)
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
locationLatitude: {
|
||||
/**
|
||||
* latitude of the card location (imported from Trello)
|
||||
*/
|
||||
type: Number,
|
||||
optional: true,
|
||||
},
|
||||
locationLongitude: {
|
||||
/**
|
||||
* longitude of the card location (imported from Trello)
|
||||
*/
|
||||
type: Number,
|
||||
optional: true,
|
||||
},
|
||||
locations: {
|
||||
/**
|
||||
* list of locations attached to the card. Supports multiple locations,
|
||||
* each with an optional name, address and coordinates. A legacy single
|
||||
* location stored in the locationName/Address/Latitude/Longitude fields
|
||||
* is surfaced through getLocations() for backwards compatibility.
|
||||
*/
|
||||
type: Array,
|
||||
optional: true,
|
||||
defaultValue: [],
|
||||
},
|
||||
'locations.$': {
|
||||
type: new SimpleSchema({
|
||||
_id: {
|
||||
/**
|
||||
* unique id of this location entry
|
||||
*/
|
||||
type: String,
|
||||
},
|
||||
name: {
|
||||
/**
|
||||
* human readable name of the location
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
address: {
|
||||
/**
|
||||
* street address of the location
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
defaultValue: '',
|
||||
},
|
||||
latitude: {
|
||||
/**
|
||||
* latitude of the location
|
||||
*/
|
||||
type: Number,
|
||||
optional: true,
|
||||
},
|
||||
longitude: {
|
||||
/**
|
||||
* longitude of the location
|
||||
*/
|
||||
type: Number,
|
||||
optional: true,
|
||||
},
|
||||
}),
|
||||
},
|
||||
spentTime: {
|
||||
/**
|
||||
* How much time has been spent on this
|
||||
@@ -1443,6 +1582,26 @@ Cards.helpers({
|
||||
}
|
||||
},
|
||||
|
||||
getDueComplete() {
|
||||
if (this.isLinkedCard()) {
|
||||
const card = ReactiveCache.getCard(this.linkedId);
|
||||
return card === undefined ? false : !!card.dueComplete;
|
||||
} else if (this.isLinkedBoard()) {
|
||||
const board = ReactiveCache.getBoard(this.linkedId);
|
||||
return board === undefined ? false : !!board.dueComplete;
|
||||
} else {
|
||||
return !!this.dueComplete;
|
||||
}
|
||||
},
|
||||
|
||||
setDueComplete(dueComplete) {
|
||||
if (this.isLinkedBoard()) {
|
||||
return Boards.updateAsync({ _id: this.linkedId }, { $set: { dueComplete } });
|
||||
} else {
|
||||
return Cards.updateAsync({ _id: this.getRealId() }, { $set: { dueComplete } });
|
||||
}
|
||||
},
|
||||
|
||||
getIsOvertime() {
|
||||
if (this.isLinkedCard()) {
|
||||
const card = ReactiveCache.getCard(this.linkedId);
|
||||
@@ -2224,6 +2383,164 @@ Cards.helpers({
|
||||
}
|
||||
},
|
||||
|
||||
// A sticker is identified by its icon plus its highlight style ('underline'
|
||||
// for the mascot pack, 'round' for the computer pack, or none), so the same
|
||||
// icon can exist as a plain, mascot or computer sticker.
|
||||
hasSticker(icon, highlight) {
|
||||
const h = highlight || '';
|
||||
return (this.stickers || []).some(s => s.icon === icon && (s.highlight || '') === h);
|
||||
},
|
||||
|
||||
addSticker(icon, highlight, name) {
|
||||
if (!icon || this.hasSticker(icon, highlight)) return Promise.resolve();
|
||||
const position = (this.stickers || []).length;
|
||||
const sticker = { icon, position };
|
||||
if (highlight) sticker.highlight = highlight;
|
||||
if (name) sticker.name = name;
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $push: { stickers: sticker } },
|
||||
);
|
||||
},
|
||||
|
||||
removeSticker(icon, highlight) {
|
||||
const h = highlight || '';
|
||||
const stickers = (this.stickers || []).slice();
|
||||
const index = stickers.findIndex(
|
||||
s => s.icon === icon && (s.highlight || '') === h,
|
||||
);
|
||||
if (index === -1) return Promise.resolve();
|
||||
stickers.splice(index, 1);
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $set: { stickers } },
|
||||
);
|
||||
},
|
||||
|
||||
// Remove a single sticker by its position in the array (so duplicates with
|
||||
// the same icon/name can be removed individually).
|
||||
removeStickerAt(index) {
|
||||
const stickers = (this.stickers || []).slice();
|
||||
if (index < 0 || index >= stickers.length) return Promise.resolve();
|
||||
stickers.splice(index, 1);
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $set: { stickers } },
|
||||
);
|
||||
},
|
||||
|
||||
toggleSticker(icon, highlight, name) {
|
||||
if (this.hasSticker(icon, highlight)) {
|
||||
return this.removeSticker(icon, highlight);
|
||||
}
|
||||
return this.addSticker(icon, highlight, name);
|
||||
},
|
||||
|
||||
// --- locations ---------------------------------------------------------
|
||||
// Cards support multiple locations. The legacy single location stored in the
|
||||
// flat locationName/Address/Latitude/Longitude fields is surfaced here as a
|
||||
// location entry so existing (e.g. Trello-imported) cards keep working.
|
||||
getLocations() {
|
||||
const locations = (this.locations || []).slice();
|
||||
if (
|
||||
!locations.length &&
|
||||
(this.locationName ||
|
||||
this.locationAddress ||
|
||||
typeof this.locationLatitude === 'number' ||
|
||||
typeof this.locationLongitude === 'number')
|
||||
) {
|
||||
locations.push({
|
||||
_id: 'legacy',
|
||||
name: this.locationName || '',
|
||||
address: this.locationAddress || '',
|
||||
latitude: this.locationLatitude,
|
||||
longitude: this.locationLongitude,
|
||||
});
|
||||
}
|
||||
return locations;
|
||||
},
|
||||
|
||||
// Move a legacy flat-field location into the locations array (once) so that
|
||||
// adding/removing entries operates on a single, consistent list.
|
||||
_migrateLegacyLocation() {
|
||||
if ((this.locations || []).length) return Promise.resolve();
|
||||
if (
|
||||
!this.locationName &&
|
||||
!this.locationAddress &&
|
||||
typeof this.locationLatitude !== 'number' &&
|
||||
typeof this.locationLongitude !== 'number'
|
||||
) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const legacy = {
|
||||
_id: Random.id(),
|
||||
name: this.locationName || '',
|
||||
address: this.locationAddress || '',
|
||||
};
|
||||
if (typeof this.locationLatitude === 'number') legacy.latitude = this.locationLatitude;
|
||||
if (typeof this.locationLongitude === 'number') legacy.longitude = this.locationLongitude;
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{
|
||||
$push: { locations: legacy },
|
||||
$set: { locationName: '', locationAddress: '' },
|
||||
$unset: { locationLatitude: '', locationLongitude: '' },
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async addLocation({ name, address, latitude, longitude }) {
|
||||
await this._migrateLegacyLocation();
|
||||
const location = {
|
||||
_id: Random.id(),
|
||||
name: name || '',
|
||||
address: address || '',
|
||||
};
|
||||
if (typeof latitude === 'number' && !isNaN(latitude)) location.latitude = latitude;
|
||||
if (typeof longitude === 'number' && !isNaN(longitude)) location.longitude = longitude;
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $push: { locations: location } },
|
||||
);
|
||||
},
|
||||
|
||||
async updateLocation(locationId, { name, address, latitude, longitude }) {
|
||||
await this._migrateLegacyLocation();
|
||||
const locations = (this.locations || []).slice();
|
||||
const index = locations.findIndex(loc => loc._id === locationId);
|
||||
if (index === -1) return Promise.resolve();
|
||||
const updated = {
|
||||
_id: locationId,
|
||||
name: name || '',
|
||||
address: address || '',
|
||||
};
|
||||
if (typeof latitude === 'number' && !isNaN(latitude)) updated.latitude = latitude;
|
||||
if (typeof longitude === 'number' && !isNaN(longitude)) updated.longitude = longitude;
|
||||
locations[index] = updated;
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $set: { locations } },
|
||||
);
|
||||
},
|
||||
|
||||
async removeLocation(locationId) {
|
||||
// A legacy (un-migrated) single location is removed by clearing the flat
|
||||
// fields; migrated entries are pulled from the array by id.
|
||||
if (locationId === 'legacy' && !(this.locations || []).length) {
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{
|
||||
$set: { locationName: '', locationAddress: '' },
|
||||
$unset: { locationLatitude: '', locationLongitude: '' },
|
||||
},
|
||||
);
|
||||
}
|
||||
return Cards.updateAsync(
|
||||
{ _id: this.getRealId() },
|
||||
{ $pull: { locations: { _id: locationId } } },
|
||||
);
|
||||
},
|
||||
|
||||
setColor(newColor) {
|
||||
if (newColor === 'white') {
|
||||
newColor = null;
|
||||
|
||||
@@ -106,6 +106,9 @@ export class Exporter {
|
||||
result.attachments.push({
|
||||
_id: attachment._id,
|
||||
cardId: attachment.meta.cardId,
|
||||
// `source` distinguishes board-level backgrounds ('board-background')
|
||||
// from card attachments on import.
|
||||
source: attachment.meta.source,
|
||||
//url: FlowRouter.url(attachment.url()),
|
||||
file: filebase64,
|
||||
name: attachment.name,
|
||||
|
||||
@@ -40,11 +40,23 @@ export function generateUniversalFileUrl(fileId, type, version = 'original') {
|
||||
return `${prefix}/cdn/storage/attachments/${fileId}`;
|
||||
} else if (type === 'avatar') {
|
||||
return `${prefix}/cdn/storage/avatars/${fileId}`;
|
||||
} else if (type === 'background') {
|
||||
return `${prefix}/cdn/storage/backgrounds/${fileId}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a universal board background URL
|
||||
* @param {string} backgroundId - The background file ID
|
||||
* @param {string} version - The file version (default: 'original')
|
||||
* @returns {string} - Universal background URL
|
||||
*/
|
||||
export function generateUniversalBackgroundUrl(backgroundId, version = 'original') {
|
||||
return generateUniversalFileUrl(backgroundId, 'background', version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a universal attachment URL
|
||||
* @param {string} attachmentId - The attachment ID
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
// Curated set of card "stickers", shared by the Trello importer and the
|
||||
// in-app sticker picker.
|
||||
//
|
||||
// Icons are rendered in templates as `i.fa.fa-<name>` (the `fa` base class +
|
||||
// Font Awesome v4 names, which WeKan's Font Awesome build supports via its v4
|
||||
// compatibility shims — the same way existing minicard badges use names like
|
||||
// `fa-comment-o`). So the names below are FA4-compatible names that render with
|
||||
// the bare `fa` class.
|
||||
|
||||
// All names are Font Awesome v4 names (render with the bare `fa` class). The set
|
||||
// mirrors Trello's built-in stickers plus a few common generic markers.
|
||||
const STICKER_ICONS = [
|
||||
'thumbs-up',
|
||||
'thumbs-down',
|
||||
'heart',
|
||||
'star',
|
||||
'check',
|
||||
'clock-o',
|
||||
'exclamation-triangle',
|
||||
'exclamation-circle',
|
||||
'info-circle',
|
||||
'question-circle',
|
||||
'smile-o',
|
||||
'meh-o',
|
||||
'frown-o',
|
||||
'rocket',
|
||||
'globe',
|
||||
'pencil',
|
||||
'sticky-note-o',
|
||||
'flag',
|
||||
'bell',
|
||||
'bookmark',
|
||||
'bolt',
|
||||
'fire',
|
||||
'lightbulb-o',
|
||||
'trophy',
|
||||
'gift',
|
||||
'bug',
|
||||
'wrench',
|
||||
'lock',
|
||||
'key',
|
||||
'eye',
|
||||
'comment-o',
|
||||
'paperclip',
|
||||
'calendar',
|
||||
'ban',
|
||||
];
|
||||
|
||||
// Trello built-in sticker name => Font Awesome (v4) icon name. Unknown Trello
|
||||
// stickers fall back to a generic note sticker.
|
||||
const TRELLO_STICKER_TO_FA = {
|
||||
thumbsup: 'thumbs-up',
|
||||
thumbsdown: 'thumbs-down',
|
||||
heart: 'heart',
|
||||
star: 'star',
|
||||
clock: 'clock-o',
|
||||
check: 'check',
|
||||
warning: 'exclamation-triangle',
|
||||
smile: 'smile-o',
|
||||
laugh: 'smile-o',
|
||||
huh: 'meh-o',
|
||||
frown: 'frown-o',
|
||||
rocketship: 'rocket',
|
||||
globe: 'globe',
|
||||
pencil: 'pencil',
|
||||
};
|
||||
|
||||
const DEFAULT_STICKER_ICON = 'sticky-note-o';
|
||||
|
||||
// Trello's two named premium sticker packs are renamed in WeKan: the "taco"
|
||||
// pack => "mascot" and the "pete" pack => "computer". Each is highlighted
|
||||
// (instead of colour) so the two are still visually distinct: taco/mascot icons
|
||||
// are underlined and pete/computer icons get a ring around them. The icon itself
|
||||
// keeps its normal Font Awesome colour.
|
||||
const TRELLO_STICKER_PACK_NAME = { taco: 'mascot', pete: 'computer' };
|
||||
const TRELLO_STICKER_PACK_HIGHLIGHT = { taco: 'underline', pete: 'round' };
|
||||
|
||||
// Highlight style for an imported sticker icon, derived from its Trello pack
|
||||
// name (taco => 'underline', pete => 'round'), or undefined for stickers that
|
||||
// belong to no named pack.
|
||||
function trelloStickerHighlight(name) {
|
||||
if (!name) return undefined;
|
||||
const n = String(name).toLowerCase();
|
||||
if (n.includes('taco')) return TRELLO_STICKER_PACK_HIGHLIGHT.taco;
|
||||
if (n.includes('pete')) return TRELLO_STICKER_PACK_HIGHLIGHT.pete;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Substring keywords -> Font Awesome (v4) icon, used to pick a *similar* icon
|
||||
// for Trello's named sticker packs (e.g. taco-love, pete-happy) and any sticker
|
||||
// whose name contains a recognisable word. Checked after the exact map above.
|
||||
const STICKER_KEYWORD_TO_FA = [
|
||||
['ghost', 'snapchat-ghost'],
|
||||
// Distinct icons for named taco/pete stickers that would otherwise collide
|
||||
// with a more generic keyword below (checked first, so these win):
|
||||
['active', 'heartbeat'], // taco-active
|
||||
['pixel', 'qrcode'], // taco-pixelated
|
||||
['proto', 'flask'], // taco-prototype
|
||||
['embarrass', 'meh-o'], // taco-embarrassed
|
||||
['shipped', 'truck'], // pete-shipped (vs pete-space => rocket)
|
||||
['thumbsup', 'thumbs-up'], ['thumbup', 'thumbs-up'],
|
||||
['thumbsdown', 'thumbs-down'], ['thumbdown', 'thumbs-down'],
|
||||
['love', 'heart'], ['heart', 'heart'],
|
||||
['trophy', 'trophy'], ['award', 'trophy'], ['win', 'trophy'],
|
||||
['proud', 'star'], ['celebrate', 'star'], ['star', 'star'],
|
||||
['money', 'money'], ['dollar', 'money'], ['cash', 'money'],
|
||||
['happy', 'smile-o'], ['smile', 'smile-o'], ['cool', 'smile-o'], ['laugh', 'smile-o'],
|
||||
['angry', 'frown-o'], ['sad', 'frown-o'], ['frown', 'frown-o'], ['cry', 'frown-o'],
|
||||
['confus', 'question-circle'], ['huh', 'question-circle'], ['question', 'question-circle'],
|
||||
['alert', 'exclamation-triangle'], ['warn', 'exclamation-triangle'], ['broken', 'exclamation-triangle'],
|
||||
['complet', 'check'], ['done', 'check'], ['clean', 'bath'], ['check', 'check'],
|
||||
['busy', 'clock-o'], ['clock', 'clock-o'], ['time', 'clock-o'],
|
||||
['rocket', 'rocket'], ['space', 'rocket'], ['ship', 'rocket'],
|
||||
['globe', 'globe'], ['world', 'globe'], ['earth', 'globe'],
|
||||
['read', 'book'], ['book', 'book'],
|
||||
['robot', 'android'], ['computer', 'desktop'], ['desktop', 'desktop'], ['screen', 'desktop'],
|
||||
['sleep', 'bed'], ['bed', 'bed'],
|
||||
['music', 'music'], ['song', 'music'],
|
||||
['talk', 'comment-o'], ['comment', 'comment-o'], ['chat', 'comment-o'],
|
||||
['vacation', 'plane'], ['plane', 'plane'], ['travel', 'plane'],
|
||||
['box', 'archive'], ['package', 'archive'], ['gift', 'gift'],
|
||||
['fire', 'fire'], ['bolt', 'bolt'], ['lightning', 'bolt'], ['energy', 'bolt'],
|
||||
['sun', 'sun-o'], ['moon', 'moon-o'], ['coffee', 'coffee'],
|
||||
['bug', 'bug'], ['lock', 'lock'], ['key', 'key'], ['eye', 'eye'],
|
||||
['flag', 'flag'], ['bell', 'bell'], ['camera', 'camera'], ['phone', 'phone'],
|
||||
['mail', 'envelope-o'], ['email', 'envelope-o'], ['location', 'map-marker'], ['pin', 'map-marker'],
|
||||
['paint', 'paint-brush'], ['magic', 'magic'], ['cake', 'birthday-cake'], ['party', 'birthday-cake'],
|
||||
['sketch', 'pencil'], ['edit', 'pencil'], ['pencil', 'pencil'],
|
||||
['dog', 'paw'], ['cat', 'paw'], ['paw', 'paw'], ['husky', 'paw'],
|
||||
];
|
||||
|
||||
// Map a Trello sticker name (sticker.image) to a similar Font Awesome icon:
|
||||
// exact built-in name first, then a keyword/substring match for the named
|
||||
// premium packs, then a generic note sticker for custom (image-only) stickers.
|
||||
function trelloStickerToFa(name) {
|
||||
if (!name) return DEFAULT_STICKER_ICON;
|
||||
const n = String(name).toLowerCase();
|
||||
if (TRELLO_STICKER_TO_FA[n]) return TRELLO_STICKER_TO_FA[n];
|
||||
for (const [kw, icon] of STICKER_KEYWORD_TO_FA) {
|
||||
if (n.includes(kw)) return icon;
|
||||
}
|
||||
return DEFAULT_STICKER_ICON;
|
||||
}
|
||||
|
||||
// Every Font Awesome icon the Trello sticker mapping can produce (exact map +
|
||||
// keyword map + the default). A Trello "taco-*" sticker becomes that icon with
|
||||
// the mascot (underline) highlight and a "pete-*" sticker with the computer
|
||||
// (round) highlight, so offering each of these icons in both highlights makes
|
||||
// the picker include every sticker an import can create — regardless of the
|
||||
// original (possibly custom) Trello sticker name.
|
||||
const ALL_MAP_ICONS = [
|
||||
...new Set([
|
||||
...Object.values(TRELLO_STICKER_TO_FA),
|
||||
...STICKER_KEYWORD_TO_FA.map(([, icon]) => icon),
|
||||
DEFAULT_STICKER_ICON,
|
||||
]),
|
||||
];
|
||||
|
||||
// The in-app sticker picker: the plain icons, then the mascot (underlined) and
|
||||
// computer (ringed) packs. Each entry is { icon, highlight?, name? }.
|
||||
const STICKER_PICKER = [
|
||||
...STICKER_ICONS.map(icon => ({ icon })),
|
||||
...ALL_MAP_ICONS.map(icon => ({ icon, highlight: 'underline', name: `mascot ${icon}` })),
|
||||
...ALL_MAP_ICONS.map(icon => ({ icon, highlight: 'round', name: `computer ${icon}` })),
|
||||
];
|
||||
|
||||
export {
|
||||
STICKER_ICONS,
|
||||
STICKER_PICKER,
|
||||
TRELLO_STICKER_TO_FA,
|
||||
DEFAULT_STICKER_ICON,
|
||||
TRELLO_STICKER_PACK_NAME,
|
||||
TRELLO_STICKER_PACK_HIGHLIGHT,
|
||||
trelloStickerToFa,
|
||||
trelloStickerHighlight,
|
||||
};
|
||||
+276
-30
@@ -4,7 +4,10 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import Activities from '/models/activities';
|
||||
import Attachments from '/models/attachments';
|
||||
import Boards from '/models/boards';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import Users from '/models/users';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
import { BOARD_COLORS, CARD_COLORS } from '/models/metadata/colors';
|
||||
import { trelloStickerToFa, trelloStickerHighlight } from '/models/metadata/stickers';
|
||||
import CardComments from '/models/cardComments';
|
||||
import Cards from '/models/cards';
|
||||
import ChecklistItems from '/models/checklistItems';
|
||||
@@ -221,6 +224,21 @@ export class TrelloCreator {
|
||||
stars: 0,
|
||||
title: await Boards.uniqueTitle(trelloBoard.name),
|
||||
};
|
||||
// Import the board background image. When Trello uses an image background,
|
||||
// prefs.backgroundImage is a public URL (and prefs.backgroundImageScaled
|
||||
// holds scaled variants); Wekan references it directly via
|
||||
// backgroundImageURL. A solid-color background is already covered by
|
||||
// `color` above.
|
||||
const prefs = trelloBoard.prefs || {};
|
||||
const scaled = Array.isArray(prefs.backgroundImageScaled)
|
||||
? prefs.backgroundImageScaled
|
||||
: [];
|
||||
const bgImage =
|
||||
prefs.backgroundImage ||
|
||||
(scaled.length && scaled[scaled.length - 1] && scaled[scaled.length - 1].url);
|
||||
if (bgImage && /^https?:\/\//i.test(bgImage)) {
|
||||
boardToCreate.backgroundImageURL = bgImage;
|
||||
}
|
||||
// now add other members
|
||||
if (trelloBoard.memberships) {
|
||||
trelloBoard.memberships.forEach(trelloMembership => {
|
||||
@@ -254,7 +272,7 @@ export class TrelloCreator {
|
||||
trelloBoard.labels.forEach(label => {
|
||||
const labelToCreate = {
|
||||
_id: Random.id(6),
|
||||
color: label.color ? label.color : 'black',
|
||||
color: this.mapToWekanColor(label.color) || 'black',
|
||||
name: label.name,
|
||||
};
|
||||
// We need to remember them by Trello ID, as this is the only ref we have
|
||||
@@ -310,6 +328,45 @@ export class TrelloCreator {
|
||||
this.customFields[field.id] = await CustomFields.direct.insertAsync(fieldToCreate);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the board background image as a board-level Attachment (in the
|
||||
// default attachments storage), served by the same /cdn/storage/attachments
|
||||
// route as every other attachment. The bytes are downloaded server-side by
|
||||
// the live API import (which has credentials) and injected as
|
||||
// trelloBoard.backgroundFile. If there are no bytes (offline JSON import),
|
||||
// the backgroundImageURL set above keeps Trello's public URL as a fallback.
|
||||
if (
|
||||
trelloBoard.backgroundFile &&
|
||||
trelloBoard.backgroundFile.file &&
|
||||
Meteor.isServer
|
||||
) {
|
||||
try {
|
||||
const bf = trelloBoard.backgroundFile;
|
||||
const buffer = Buffer.from(bf.file, 'base64');
|
||||
const fileRef = await Attachments.writeAsync(
|
||||
buffer,
|
||||
{
|
||||
fileName: bf.name || 'trello-background.jpg',
|
||||
type: bf.type || 'image/jpeg',
|
||||
userId: this._user(),
|
||||
meta: { boardId, source: 'board-background' },
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (fileRef && fileRef._id) {
|
||||
await Boards.direct.updateAsync(boardId, {
|
||||
$set: {
|
||||
backgroundImageId: fileRef._id,
|
||||
backgroundImageURL: generateUniversalAttachmentUrl(fileRef._id),
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Failed to store Trello board background', e && e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return boardId;
|
||||
}
|
||||
|
||||
@@ -322,10 +379,16 @@ export class TrelloCreator {
|
||||
*/
|
||||
async createCards(trelloCards, boardId) {
|
||||
const result = [];
|
||||
// .direct.insertAsync below bypasses the before.insert hook that assigns
|
||||
// cardNumber, so handle it here. Prefer Trello's own short card number
|
||||
// (idShort, the #N shown in Trello), falling back to a freshly allocated
|
||||
// one. Without this every imported card defaults to #0.
|
||||
const boardObj = await ReactiveCache.getBoard(boardId);
|
||||
for (const card of trelloCards) {
|
||||
const cardToCreate = {
|
||||
archived: card.closed,
|
||||
boardId,
|
||||
cardNumber: card.idShort || (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(),
|
||||
@@ -337,7 +400,49 @@ export class TrelloCreator {
|
||||
// we attribute the card to its creator if available
|
||||
userId: this._user(this.createdBy.cards[card.id]),
|
||||
dueAt: card.due ? this._now(card.due) : null,
|
||||
// Trello marks a due date complete with a checkbox on the card front
|
||||
dueComplete: card.dueComplete || false,
|
||||
};
|
||||
// card cover: Trello stores it as card.cover = { color, idAttachment, ... }.
|
||||
// A color cover maps to the Wekan card color; an attachment cover is
|
||||
// resolved to coverId in the attachment loop below.
|
||||
if (card.cover && card.cover.color) {
|
||||
const coverColor = this.mapToWekanColor(card.cover.color);
|
||||
if (coverColor) {
|
||||
cardToCreate.color = coverColor;
|
||||
}
|
||||
}
|
||||
// location (Trello map power-up): address / coordinates / location name
|
||||
if (card.locationName) {
|
||||
cardToCreate.locationName = card.locationName;
|
||||
}
|
||||
if (card.address) {
|
||||
cardToCreate.locationAddress = card.address;
|
||||
}
|
||||
if (card.coordinates) {
|
||||
if (typeof card.coordinates.latitude === 'number') {
|
||||
cardToCreate.locationLatitude = card.coordinates.latitude;
|
||||
}
|
||||
if (typeof card.coordinates.longitude === 'number') {
|
||||
cardToCreate.locationLongitude = card.coordinates.longitude;
|
||||
}
|
||||
}
|
||||
// stickers: Trello card.stickers[] = { image, top, left, zIndex, ... }
|
||||
// where `image` is the sticker name (built-in/premium packs) or a custom
|
||||
// sticker id (uploaded packs). Map to a similar Font Awesome icon, and
|
||||
// keep the original name for the tooltip.
|
||||
if (card.stickers && card.stickers.length > 0) {
|
||||
cardToCreate.stickers = card.stickers.map((sticker, index) => {
|
||||
const stickerData = {
|
||||
icon: this.getStickerIcon(sticker.image),
|
||||
name: this.stickerLabel(sticker.image),
|
||||
position: typeof sticker.zIndex === 'number' ? sticker.zIndex : index,
|
||||
};
|
||||
const highlight = trelloStickerHighlight(sticker.image);
|
||||
if (highlight) stickerData.highlight = highlight;
|
||||
return stickerData;
|
||||
});
|
||||
}
|
||||
// add labels
|
||||
if (card.idLabels) {
|
||||
cardToCreate.labelIds = card.idLabels.map(trelloId => {
|
||||
@@ -457,32 +562,83 @@ export class TrelloCreator {
|
||||
});
|
||||
}
|
||||
}
|
||||
const attachments = this.attachments[card.id];
|
||||
const trelloCoverId = card.idAttachmentCover;
|
||||
if (attachments && Meteor.isServer) {
|
||||
for (const att of attachments) {
|
||||
const self = this;
|
||||
const opts = {
|
||||
type: att.type ? att.type : undefined,
|
||||
userId: self._user(att.userId),
|
||||
meta: {
|
||||
boardId,
|
||||
cardId,
|
||||
source: 'import',
|
||||
},
|
||||
// Gather this card's attachments from BOTH the addAttachmentToCard
|
||||
// actions (this.attachments) and the card.attachments[] array present in
|
||||
// newer Trello exports, de-duplicated by Trello attachment id. `file`
|
||||
// (base64) is injected client-side when a matching file was found in the
|
||||
// uploaded attachments ZIP (the offline TCAD download).
|
||||
const mergedAttachments = [];
|
||||
const attachmentsById = new Map();
|
||||
const pushAttachment = raw => {
|
||||
if (!raw) return;
|
||||
const id = raw.id || raw._id || `__noid_${mergedAttachments.length}`;
|
||||
const norm = {
|
||||
id: raw.id || raw._id,
|
||||
name: raw.name || raw.fileName || '',
|
||||
fileName: raw.fileName || raw.name || '',
|
||||
url: raw.url || '',
|
||||
type: raw.mimeType || raw.type || undefined,
|
||||
userId: raw.idMemberCreator || raw.userId,
|
||||
file: raw.file,
|
||||
};
|
||||
const cb = async (error, fileObj) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
const existing = attachmentsById.get(id);
|
||||
if (existing) {
|
||||
// Same attachment seen in both actions and card.attachments[].
|
||||
// Fill in whichever copy carries the bytes / url / type.
|
||||
if (!existing.file && norm.file) existing.file = norm.file;
|
||||
if (!existing.url && norm.url) existing.url = norm.url;
|
||||
if (!existing.type && norm.type) existing.type = norm.type;
|
||||
return;
|
||||
}
|
||||
self.attachmentIds[att._id] = fileObj._id;
|
||||
if (trelloCoverId === att._id) {
|
||||
attachmentsById.set(id, norm);
|
||||
mergedAttachments.push(norm);
|
||||
};
|
||||
(this.attachments[card.id] || []).forEach(pushAttachment);
|
||||
(card.attachments || []).forEach(pushAttachment);
|
||||
|
||||
const trelloCoverId =
|
||||
card.idAttachmentCover || (card.cover && card.cover.idAttachment);
|
||||
|
||||
if (mergedAttachments.length && Meteor.isServer) {
|
||||
// Trello "link attachments" (where the attachment name is the URL
|
||||
// itself) are not real files. Collect them here and append them to the
|
||||
// card description below, instead of trying to download them.
|
||||
const links = [];
|
||||
for (const att of mergedAttachments) {
|
||||
// attached link, not a file
|
||||
if (att.name && att.name === att.url) {
|
||||
links.push(att.url);
|
||||
continue;
|
||||
}
|
||||
const meta = { boardId, cardId, source: 'import' };
|
||||
const setCover = async newId => {
|
||||
if (!newId) return;
|
||||
this.attachmentIds[att.id] = newId;
|
||||
if (trelloCoverId && trelloCoverId === att.id) {
|
||||
await Cards.direct.updateAsync(cardId, {
|
||||
$set: { coverId: fileObj._id },
|
||||
$set: { coverId: newId },
|
||||
});
|
||||
}
|
||||
};
|
||||
if (att.url) {
|
||||
try {
|
||||
if (att.file) {
|
||||
// Bytes already provided from the uploaded attachments ZIP.
|
||||
// Insert them directly instead of downloading the
|
||||
// OAuth-protected Trello URL. writeAsync is the server-side
|
||||
// Meteor-Files API (insertAsync is client-only).
|
||||
const buffer = Buffer.from(att.file, 'base64');
|
||||
const fileRef = await Attachments.writeAsync(
|
||||
buffer,
|
||||
{
|
||||
fileName: att.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') {
|
||||
@@ -492,17 +648,35 @@ export class TrelloCreator {
|
||||
att.url,
|
||||
);
|
||||
}
|
||||
return;
|
||||
// Skip just this attachment; a blocked URL must not abort the
|
||||
// whole import.
|
||||
continue;
|
||||
}
|
||||
Attachments.load(att.url, opts, cb, true);
|
||||
} else if (att.file) {
|
||||
Attachments.insert(att.file, opts, cb, true);
|
||||
// Best effort: works for publicly reachable URLs. Trello-hosted
|
||||
// uploads require OAuth and should instead be supplied via the
|
||||
// attachments ZIP (offline) or downloaded server-side in the
|
||||
// live Trello API import.
|
||||
const fileRef = await Attachments.loadAsync(
|
||||
att.url,
|
||||
{ meta, fileName: att.fileName || att.name },
|
||||
true,
|
||||
);
|
||||
await setCover(fileRef && fileRef._id);
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn(
|
||||
'Failed to import Trello attachment',
|
||||
att.name,
|
||||
e && e.message,
|
||||
);
|
||||
}
|
||||
// Never let one failed attachment abort the whole import.
|
||||
}
|
||||
}
|
||||
|
||||
if (links) {
|
||||
if (links.length) {
|
||||
let desc = cardToCreate.description.trim();
|
||||
let desc = (cardToCreate.description || '').trim();
|
||||
if (desc) {
|
||||
desc += '\n\n';
|
||||
}
|
||||
@@ -517,7 +691,6 @@ export class TrelloCreator {
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(cardId);
|
||||
}
|
||||
return result;
|
||||
@@ -543,6 +716,11 @@ export class TrelloCreator {
|
||||
const listToCreate = {
|
||||
archived: list.closed,
|
||||
boardId,
|
||||
// Attach the list to the imported board's default swimlane so it shows
|
||||
// in Swimlane View (a list appears under a swimlane only when its
|
||||
// swimlaneId matches that swimlane or is empty). createSwimlanes runs
|
||||
// before createLists, so this.swimlane is set.
|
||||
swimlaneId: this.swimlane,
|
||||
// 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
|
||||
// Trello boards (eg from 2013) that didn't log the 'createList' action
|
||||
@@ -627,6 +805,44 @@ export class TrelloCreator {
|
||||
return trelloMemberType === 'admin';
|
||||
}
|
||||
|
||||
getStickerIcon(trelloStickerName) {
|
||||
// Map a Trello sticker name to a similar WeKan card sticker icon (Font
|
||||
// Awesome v4 name, rendered as `i.fa.fa-<name>`). Handles built-in and
|
||||
// named premium packs via models/metadata/stickers.js.
|
||||
return trelloStickerToFa(trelloStickerName);
|
||||
}
|
||||
|
||||
// A readable tooltip for an imported sticker. For named stickers (taco-love,
|
||||
// globe, …) this humanises the name; custom uploaded stickers only have an
|
||||
// opaque id, so fall back to a generic label rather than showing the id.
|
||||
stickerLabel(trelloStickerName) {
|
||||
const raw = String(trelloStickerName || '').trim();
|
||||
if (!raw) return 'sticker';
|
||||
// Long hex/base-id strings (custom uploaded stickers) aren't descriptive.
|
||||
if (/^[0-9a-f]{16,}$/i.test(raw) || /^[A-Za-z0-9_-]{20,}$/.test(raw)) {
|
||||
return 'custom sticker';
|
||||
}
|
||||
// Trello's named premium packs are renamed: taco => mascot, pete => computer
|
||||
// (e.g. "pete-ghost" => "computer ghost"). The highlight style (underline /
|
||||
// round ring) is applied separately via trelloStickerHighlight().
|
||||
return raw
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\btaco\b/gi, 'mascot')
|
||||
.replace(/\bpete\b/gi, 'computer')
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Map a Trello label/cover color to a valid WeKan card/label color
|
||||
// (CARD_COLORS === LABEL_COLORS === ALLOWED_COLORS). Trello uses base colors
|
||||
// that WeKan also has (green, blue, red, …) plus `_light`/`_dark` variants
|
||||
// (e.g. `purple_light`) that WeKan does not, so strip the variant suffix.
|
||||
// Returns null when there is no valid mapping.
|
||||
mapToWekanColor(trelloColor) {
|
||||
if (!trelloColor) return null;
|
||||
const base = String(trelloColor).split('_')[0];
|
||||
return CARD_COLORS.includes(base) ? base : null;
|
||||
}
|
||||
|
||||
getColor(trelloColorCode) {
|
||||
// trello color name => wekan color
|
||||
const mapColors = {
|
||||
@@ -807,12 +1023,42 @@ export class TrelloCreator {
|
||||
}
|
||||
this.parseActions(board.actions);
|
||||
const boardId = await this.createBoardAndLabels(board);
|
||||
await this.createLists(board.lists, boardId);
|
||||
// Create the default swimlane first so lists can be attached to it (see
|
||||
// createLists): a list shows under a swimlane in Swimlane View only when its
|
||||
// swimlaneId matches that swimlane (or is empty).
|
||||
await this.createSwimlanes(boardId);
|
||||
await this.createLists(board.lists, boardId);
|
||||
await this.createCards(board.cards, boardId);
|
||||
await this.createChecklists(board.checklists, boardId);
|
||||
await this.importActions(board.actions, boardId);
|
||||
await this.recordImportedUsernames(board, boardId);
|
||||
// XXX add members
|
||||
return boardId;
|
||||
}
|
||||
|
||||
// Keep the original Trello usernames so user mapping can happen later:
|
||||
// members that were mapped to a WeKan user get the Trello username added to
|
||||
// that user's importUsernames (so future imports auto-map); members that were
|
||||
// not mapped have their username stored on the board's importUsernames, where
|
||||
// an admin can later assign them to a real user via the People panel.
|
||||
async recordImportedUsernames(board, boardId) {
|
||||
if (!Meteor.isServer) return;
|
||||
const unmapped = [];
|
||||
for (const member of board.members || []) {
|
||||
if (!member.username) continue;
|
||||
const wekanId = this.members[member.id];
|
||||
if (wekanId) {
|
||||
await Users.updateAsync(wekanId, {
|
||||
$addToSet: { importUsernames: member.username },
|
||||
});
|
||||
} else if (!unmapped.includes(member.username)) {
|
||||
unmapped.push(member.username);
|
||||
}
|
||||
}
|
||||
if (unmapped.length) {
|
||||
await Boards.direct.updateAsync(boardId, {
|
||||
$addToSet: { importUsernames: { $each: unmapped } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
// Background Trello-import jobs.
|
||||
//
|
||||
// Importing whole Trello workspaces (many boards + attachments) can take a
|
||||
// while, so it runs server-side as a persisted job instead of blocking the
|
||||
// client call. The job document holds the queue, progress, per-board results
|
||||
// and an error log, so the user can navigate away and return to the Trello
|
||||
// import page to watch progress, resume after a fatal API error, or cancel and
|
||||
// delete the imported boards to start over.
|
||||
//
|
||||
// SECURITY: the Trello API key/token are NEVER stored in this document. They
|
||||
// are kept only in server memory for the running loop and must be re-supplied
|
||||
// by the client when resuming (e.g. after a server restart).
|
||||
//
|
||||
// No SimpleSchema is attached on purpose: the job is internal bookkeeping
|
||||
// updated with frequent $set/$push, and it is only ever published to its owner.
|
||||
const TrelloImportJobs = new Mongo.Collection('trello_import_jobs');
|
||||
|
||||
export default TrelloImportJobs;
|
||||
@@ -295,6 +295,15 @@ Users.attachSchema(
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
'profile.trelloApiSaved': {
|
||||
/**
|
||||
* Has the user saved Trello API credentials (key + token) for importing?
|
||||
* The credentials themselves are stored server-side only (never published
|
||||
* to the client); this is just the published "saved" indicator.
|
||||
*/
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
},
|
||||
'profile.hiddenMinicardLabelText': {
|
||||
/**
|
||||
* does the user want to hide minicard label texts?
|
||||
@@ -349,6 +358,15 @@ Users.attachSchema(
|
||||
type: String,
|
||||
optional: true,
|
||||
},
|
||||
'profile.mapProvider': {
|
||||
/**
|
||||
* which map service the card "Open in map" links use
|
||||
* ('openstreetmap' (default), 'google', 'bing' or 'apple').
|
||||
*/
|
||||
type: String,
|
||||
optional: true,
|
||||
allowedValues: ['openstreetmap', 'google', 'bing', 'apple'],
|
||||
},
|
||||
'profile.moveAndCopyDialog': {
|
||||
/**
|
||||
* move and copy card dialog
|
||||
@@ -1215,6 +1233,11 @@ Users.helpers({
|
||||
return profile.showCardsCountAt;
|
||||
},
|
||||
|
||||
getMapProvider() {
|
||||
const profile = this.profile || {};
|
||||
return profile.mapProvider || 'openstreetmap';
|
||||
},
|
||||
|
||||
getName() {
|
||||
const profile = this.profile || {};
|
||||
return profile.fullname || this.username;
|
||||
|
||||
+127
-21
@@ -4,6 +4,8 @@ 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';
|
||||
@@ -332,6 +334,11 @@ export class WekanCreator {
|
||||
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 => {
|
||||
@@ -536,28 +543,35 @@ export class WekanCreator {
|
||||
const wekanCoverId = card.coverId;
|
||||
if (attachments && Meteor.isServer) {
|
||||
for (const att of attachments) {
|
||||
const self = this;
|
||||
const opts = {
|
||||
type: att.type ? att.type : undefined,
|
||||
userId: self._user(att.userId),
|
||||
meta: {
|
||||
boardId,
|
||||
cardId,
|
||||
source: 'import',
|
||||
},
|
||||
};
|
||||
const cb = async (error, fileObj) => {
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
self.attachmentIds[att._id] = fileObj._id;
|
||||
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: fileObj._id },
|
||||
$set: { coverId: newId },
|
||||
});
|
||||
}
|
||||
};
|
||||
if (att.url) {
|
||||
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') {
|
||||
@@ -567,11 +581,22 @@ export class WekanCreator {
|
||||
att.url,
|
||||
);
|
||||
}
|
||||
return;
|
||||
// Skip just this attachment (a bare return would abort
|
||||
// importing all remaining cards).
|
||||
continue;
|
||||
}
|
||||
Attachments.load(att.url, opts, cb, true);
|
||||
} else if (att.file) {
|
||||
Attachments.insert(att.file, opts, cb, true);
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1074,7 +1099,88 @@ export class WekanCreator {
|
||||
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 } },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+1
-1
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wekan",
|
||||
"version": "v9.35.0",
|
||||
"version": "v9.41.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import Attachments from '/models/attachments';
|
||||
import Boards from '/models/boards';
|
||||
|
||||
// Delete a board background (a board-level Attachment). Needs an async
|
||||
// board-admin check, so it is a method rather than an `allow` rule.
|
||||
Meteor.methods({
|
||||
async removeBoardBackground(attachmentId) {
|
||||
check(attachmentId, String);
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
const attachment = await Attachments.collection.findOneAsync(attachmentId);
|
||||
if (!attachment) {
|
||||
throw new Meteor.Error('error-attachment-doesNotExist');
|
||||
}
|
||||
const boardId = attachment.meta && attachment.meta.boardId;
|
||||
const board = boardId && (await ReactiveCache.getBoard(boardId));
|
||||
const user = await ReactiveCache.getUser(this.userId);
|
||||
const isAdmin =
|
||||
board &&
|
||||
((board.hasAdmin && board.hasAdmin(this.userId)) || (user && user.isAdmin));
|
||||
if (!isAdmin) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
// If this background is the board's active one, clear it.
|
||||
if (board.backgroundImageId === attachmentId) {
|
||||
await Boards.updateAsync(boardId, {
|
||||
$set: { backgroundImageId: '', backgroundImageURL: '' },
|
||||
});
|
||||
}
|
||||
await Attachments.removeAsync(attachmentId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import '/imports/lib/secureDOMPurify';
|
||||
import '/imports/startup/shared-models';
|
||||
import '/models/attachments.server';
|
||||
import '/models/avatars.server';
|
||||
import '/server/boardBackgrounds';
|
||||
import '/models/fileValidation';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -148,6 +149,7 @@ import '/server/cron/syncedCron';
|
||||
// 8. Server — import helpers
|
||||
// ----------------------------------------------------------------------------
|
||||
import '/server/import-users-for-methods';
|
||||
import '/server/trelloApiImport';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 9. Server — lib (utilities, sanitizers, guards)
|
||||
@@ -200,6 +202,7 @@ import '/server/publications/attachmentMigrationStatus';
|
||||
import '/server/publications/attachments';
|
||||
import '/server/publications/legacyAttachments';
|
||||
import '/server/publications/avatars';
|
||||
import '/server/publications/backgrounds';
|
||||
import '/server/publications/boards';
|
||||
import '/server/publications/cards';
|
||||
import '/server/publications/cronJobs';
|
||||
@@ -221,6 +224,7 @@ import '/server/publications/unsavedEdits';
|
||||
import '/server/publications/userDesktopDragHandles';
|
||||
import '/server/publications/userGreyIcons';
|
||||
import '/server/publications/users';
|
||||
import '/server/publications/trelloImportJobs';
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 14. Server — routes (REST API, file serving)
|
||||
@@ -228,6 +232,7 @@ import '/server/publications/users';
|
||||
import '/server/routes/attachmentApi';
|
||||
import '/server/routes/avatarServer';
|
||||
import '/server/routes/customHeadAssets';
|
||||
import '/server/routes/importTrelloZip';
|
||||
import '/server/routes/legacyAttachments';
|
||||
import '/server/routes/universalFileServer';
|
||||
|
||||
|
||||
@@ -317,6 +317,15 @@ Meteor.methods({
|
||||
await Users.updateAsync(this.userId, { $set: { 'profile.cardCollapsed': value } });
|
||||
},
|
||||
|
||||
async setMapProvider(provider) {
|
||||
check(provider, String);
|
||||
if (!this.userId) throw new Meteor.Error('not-logged-in');
|
||||
if (!['openstreetmap', 'google', 'bing', 'apple'].includes(provider)) {
|
||||
throw new Meteor.Error('invalid-map-provider');
|
||||
}
|
||||
await Users.updateAsync(this.userId, { $set: { 'profile.mapProvider': provider } });
|
||||
},
|
||||
|
||||
async toggleMinicardLabelText() {
|
||||
if (!this.userId) return;
|
||||
const user = await ReactiveCache.getCurrentUser();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Deprecated. Board backgrounds are board-level Attachments now and use the
|
||||
// Attachments permissions/upload path. This file is intentionally empty and no
|
||||
// longer imported.
|
||||
@@ -0,0 +1,25 @@
|
||||
import Attachments from '/models/attachments';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
// Publish a board's background images so the board-settings backgrounds list
|
||||
// can show them. Board backgrounds are stored as board-level Attachments
|
||||
// (meta.boardId set, no meta.cardId, meta.source === 'board-background').
|
||||
Meteor.publish('boardBackgrounds', async function (boardId) {
|
||||
check(boardId, String);
|
||||
if (!this.userId) {
|
||||
return this.ready();
|
||||
}
|
||||
const board = await ReactiveCache.getBoard(boardId);
|
||||
if (!board) {
|
||||
return this.ready();
|
||||
}
|
||||
const isPublic = board.isPublic && board.isPublic();
|
||||
const isMember = board.hasMember && board.hasMember(this.userId);
|
||||
if (!isPublic && !isMember) {
|
||||
return this.ready();
|
||||
}
|
||||
return Attachments.collection.find({
|
||||
'meta.boardId': boardId,
|
||||
'meta.source': 'board-background',
|
||||
});
|
||||
});
|
||||
@@ -21,11 +21,28 @@ publishComposite('boards', function() {
|
||||
|
||||
return {
|
||||
async find() {
|
||||
return await ReactiveCache.getBoards(
|
||||
{
|
||||
// Publish a *live* cursor matching the boards the user can see, rather
|
||||
// than a one-time snapshot of ids (`_id: { $in: [...] }`). A snapshot
|
||||
// never picks up boards created after the client subscribes (e.g. a
|
||||
// background Trello import), so they only appeared after a page reload.
|
||||
// This selector mirrors Boards.userBoards(): a board the user becomes a
|
||||
// member of is matched and streamed automatically.
|
||||
const user = await ReactiveCache.getUser(userId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
const selector = {
|
||||
archived: false,
|
||||
_id: { $in: await Boards.userBoardIds(userId, false) },
|
||||
},
|
||||
type: 'board',
|
||||
$or: [
|
||||
{ permission: 'public' },
|
||||
{ members: { $elemMatch: { userId, isActive: true } } },
|
||||
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
|
||||
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
|
||||
],
|
||||
};
|
||||
return await ReactiveCache.getBoards(
|
||||
selector,
|
||||
{
|
||||
sort: { sort: 1 /* boards default sorting */ },
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import TrelloImportJobs from '/models/trelloImportJobs';
|
||||
|
||||
// Publish the current user's recent Trello import jobs so the import page can
|
||||
// reactively show progress, results and errors even after navigating away.
|
||||
Meteor.publish('trelloImportJobs', function () {
|
||||
if (!this.userId) {
|
||||
return this.ready();
|
||||
}
|
||||
return TrelloImportJobs.find(
|
||||
{ userId: this.userId },
|
||||
{ sort: { createdAt: -1 }, limit: 5 },
|
||||
);
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,853 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { EJSON } from 'meteor/ejson';
|
||||
import { Random } from 'meteor/random';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TrelloCreator } from '/models/trelloCreator';
|
||||
import Users from '/models/users';
|
||||
import Boards from '/models/boards';
|
||||
import Avatars from '/models/avatars';
|
||||
import TrelloImportJobs from '/models/trelloImportJobs';
|
||||
import { generateUniversalAvatarUrl } from '/models/lib/universalUrlGenerator';
|
||||
|
||||
// Server-only collection holding each user's saved Trello API key + token.
|
||||
// It is defined here (not under /models) and never published, so the
|
||||
// credentials never reach the browser — only the `profile.trelloApiSaved`
|
||||
// boolean is published as a "saved" indicator.
|
||||
const TrelloApiCredentials = new Mongo.Collection('trello_api_credentials');
|
||||
|
||||
// Resolve credentials for a user: prefer the ones passed from the client
|
||||
// (when the user typed them), otherwise fall back to their saved credentials.
|
||||
export async function resolveCreds(userId, key, token) {
|
||||
if (key && token) {
|
||||
return { key, token };
|
||||
}
|
||||
const saved = await TrelloApiCredentials.findOneAsync(userId);
|
||||
if (saved && saved.apiKey && saved.apiToken) {
|
||||
return { key: saved.apiKey, token: saved.apiToken };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function storeCreds(userId, key, token) {
|
||||
await TrelloApiCredentials.upsertAsync(
|
||||
{ _id: userId },
|
||||
{ $set: { apiKey: key, apiToken: token } },
|
||||
);
|
||||
await Users.updateAsync(userId, { $set: { 'profile.trelloApiSaved': true } });
|
||||
}
|
||||
|
||||
async function clearCreds(userId) {
|
||||
await TrelloApiCredentials.removeAsync(userId);
|
||||
await Users.updateAsync(userId, { $set: { 'profile.trelloApiSaved': false } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live Trello import via the Trello REST API.
|
||||
//
|
||||
// The offline path (paste JSON + upload the attachments ZIP) works without
|
||||
// credentials, but every board has to be exported and downloaded by hand.
|
||||
// These methods let a logged-in user paste their Trello API key + token once
|
||||
// and then list and import whole workspaces (Trello "organizations") and their
|
||||
// boards directly, including attachments — which require OAuth to download and
|
||||
// are therefore fetched server-side here and handed to the same TrelloCreator
|
||||
// the offline importer uses.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TRELLO_API = 'https://api.trello.com/1';
|
||||
|
||||
// Trello rate limits: 100 requests / 10 s per API key and 300 / 10 s per
|
||||
// token (https://developer.atlassian.com/cloud/trello/guides/rest-api/rate-limits/).
|
||||
// Strategy:
|
||||
// 1. Proactive: keep a minimum gap between sequential requests so a bulk
|
||||
// import never approaches the limit in the first place.
|
||||
// 2. Reactive: if Trello still answers 429, honour the `Retry-After` header
|
||||
// (seconds) when present, otherwise back off exponentially, and retry.
|
||||
// 5xx / network errors are also retried with exponential backoff.
|
||||
const MIN_REQUEST_GAP_MS = 120;
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_BACKOFF_MS = 1000;
|
||||
const MAX_BACKOFF_MS = 30000;
|
||||
let _lastRequestAt = 0;
|
||||
|
||||
const sleep = ms => new Promise(resolve => Meteor.setTimeout(resolve, ms));
|
||||
|
||||
async function throttle() {
|
||||
const now = Date.now();
|
||||
const wait = MIN_REQUEST_GAP_MS - (now - _lastRequestAt);
|
||||
if (wait > 0) {
|
||||
await sleep(wait);
|
||||
}
|
||||
_lastRequestAt = Date.now();
|
||||
}
|
||||
|
||||
function authHeader(key, token) {
|
||||
// OAuth header is required to download attachment files; for normal API
|
||||
// calls the key/token are passed as query params instead.
|
||||
return `OAuth oauth_consumer_key="${key}", oauth_token="${token}"`;
|
||||
}
|
||||
|
||||
// How long to wait before a retry. Prefers the server-provided Retry-After
|
||||
// header (seconds, or an HTTP date), falling back to capped exponential backoff.
|
||||
function retryDelayMs(res, attempt) {
|
||||
const header = res && res.headers && res.headers.get('retry-after');
|
||||
if (header) {
|
||||
const secs = Number(header);
|
||||
if (Number.isFinite(secs)) return Math.min(secs * 1000, MAX_BACKOFF_MS);
|
||||
const dateMs = Date.parse(header);
|
||||
if (!Number.isNaN(dateMs)) {
|
||||
return Math.max(0, Math.min(dateMs - Date.now(), MAX_BACKOFF_MS));
|
||||
}
|
||||
}
|
||||
return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
||||
}
|
||||
|
||||
// Single rate-limit/error-aware fetch. Retries 429 and 5xx (and network
|
||||
// errors), honouring Retry-After; throws a Meteor.Error on a non-retryable
|
||||
// failure or once retries are exhausted.
|
||||
async function trelloFetch(url, options = {}) {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
||||
await throttle();
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(url, options);
|
||||
} catch (networkErr) {
|
||||
// Transient network failure — back off and retry.
|
||||
lastError = networkErr;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await sleep(Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS));
|
||||
continue;
|
||||
}
|
||||
throw new Meteor.Error('trello-api-error', `Trello request failed: ${networkErr.message}`);
|
||||
}
|
||||
|
||||
if (res.status === 429 || res.status >= 500) {
|
||||
lastError = res;
|
||||
if (attempt < MAX_RETRIES) {
|
||||
const delay = retryDelayMs(res, attempt);
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn(`Trello ${res.status}; retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES})`);
|
||||
}
|
||||
await sleep(delay);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Retries exhausted on a 429/5xx.
|
||||
const status = lastError && lastError.status;
|
||||
throw new Meteor.Error(
|
||||
'trello-api-rate-limited',
|
||||
`Trello API still failing after ${MAX_RETRIES} retries (status ${status || 'network error'})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function trelloGet(path, key, token, params = {}) {
|
||||
const search = new URLSearchParams({ key, token, ...params });
|
||||
const res = await trelloFetch(`${TRELLO_API}${path}?${search.toString()}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
// Surface the common, actionable cases clearly.
|
||||
if (res.status === 401) {
|
||||
throw new Meteor.Error('trello-api-unauthorized', 'Trello rejected the API key or token (401)');
|
||||
}
|
||||
throw new Meteor.Error(
|
||||
'trello-api-error',
|
||||
`Trello API ${res.status} for ${path}: ${body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadAttachmentBase64(url, key, token) {
|
||||
try {
|
||||
const res = await trelloFetch(url, {
|
||||
headers: { Authorization: authHeader(key, token) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello attachment download HTTP', res.status, url);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
return buffer.toString('base64');
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello attachment download failed:', url, e && e.message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the full board object in the shape TrelloCreator expects, mirroring a
|
||||
// Trello "Export JSON": nested cards (with attachments), lists, labels,
|
||||
// checklists, members, custom fields and the action log.
|
||||
async function fetchBoard(boardId, key, token) {
|
||||
return trelloGet(`/boards/${boardId}`, key, token, {
|
||||
fields: 'all',
|
||||
actions: 'all',
|
||||
action_fields: 'all',
|
||||
actions_limit: '1000',
|
||||
cards: 'all',
|
||||
card_fields: 'all',
|
||||
card_attachments: 'true',
|
||||
card_stickers: 'true',
|
||||
lists: 'all',
|
||||
list_fields: 'all',
|
||||
labels: 'all',
|
||||
labels_limit: '1000',
|
||||
members: 'all',
|
||||
member_fields: 'all',
|
||||
checklists: 'all',
|
||||
checklist_fields: 'all',
|
||||
customFields: 'true',
|
||||
organization: 'true',
|
||||
organization_fields: 'all',
|
||||
});
|
||||
}
|
||||
|
||||
// Download each uploaded attachment server-side (with OAuth) and inline the
|
||||
// bytes as base64 on `att.file`, so TrelloCreator inserts them directly the
|
||||
// same way it does for the offline attachments-ZIP path.
|
||||
export async function inlineAttachments(board, key, token) {
|
||||
const logical = new Map();
|
||||
const collect = att => {
|
||||
if (!att) return;
|
||||
if (att.url && att.name === att.url) return; // attached link, not a file
|
||||
if (!att.url) return;
|
||||
const id = att.id || att._id || att.url;
|
||||
if (!logical.has(id)) logical.set(id, []);
|
||||
logical.get(id).push(att);
|
||||
};
|
||||
(board.cards || []).forEach(card => (card.attachments || []).forEach(collect));
|
||||
(board.actions || []).forEach(action => {
|
||||
if (action.type === 'addAttachmentToCard') {
|
||||
collect(action.data && action.data.attachment);
|
||||
}
|
||||
});
|
||||
|
||||
let matched = 0;
|
||||
for (const occurrences of logical.values()) {
|
||||
const url = occurrences[0].url;
|
||||
const base64 = await downloadAttachmentBase64(url, key, token);
|
||||
if (base64) {
|
||||
occurrences.forEach(att => {
|
||||
att.file = base64;
|
||||
});
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
// Download the board background image server-side and inject it as
|
||||
// board.backgroundFile so TrelloCreator can store it as a board-level
|
||||
// attachment. Trello backgrounds are usually public (S3), so try without auth
|
||||
// first and fall back to the OAuth header (custom backgrounds may need it).
|
||||
export async function inlineBoardBackground(board, key, token) {
|
||||
const prefs = board.prefs || {};
|
||||
const scaled = Array.isArray(prefs.backgroundImageScaled)
|
||||
? prefs.backgroundImageScaled
|
||||
: [];
|
||||
const url =
|
||||
prefs.backgroundImage ||
|
||||
(scaled.length && scaled[scaled.length - 1] && scaled[scaled.length - 1].url);
|
||||
if (!url || !/^https?:\/\//i.test(url)) {
|
||||
return;
|
||||
}
|
||||
const tryFetch = async withAuth => {
|
||||
const options = withAuth
|
||||
? { headers: { Authorization: authHeader(key, token) } }
|
||||
: {};
|
||||
const res = await fetch(url, options);
|
||||
if (!res.ok) return null;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const type = (res.headers.get('content-type') || 'image/jpeg').split(';')[0];
|
||||
const ext = type.split('/')[1] || 'jpg';
|
||||
return { file: buffer.toString('base64'), type, name: `trello-background.${ext}` };
|
||||
};
|
||||
try {
|
||||
let bf = null;
|
||||
try {
|
||||
bf = await tryFetch(false);
|
||||
} catch (e) {
|
||||
bf = null;
|
||||
}
|
||||
if (!bf) {
|
||||
bf = await tryFetch(true);
|
||||
}
|
||||
if (bf) {
|
||||
board.backgroundFile = bf;
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello board background download failed:', url, e && e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Download Trello member avatars and store them for the mapped WeKan users that
|
||||
// have no avatar yet (existing avatars are never overwritten). `membersMapping`
|
||||
// maps a Trello member id to a WeKan user id. Best-effort: a failed download
|
||||
// never aborts the import.
|
||||
export async function inlineMemberAvatars(board, membersMapping, key, token) {
|
||||
const mapping = membersMapping || {};
|
||||
let count = 0;
|
||||
for (const member of board.members || []) {
|
||||
const wekanUserId = mapping[member.id];
|
||||
if (!wekanUserId || !member.avatarUrl) continue;
|
||||
try {
|
||||
const user = await Users.findOneAsync(wekanUserId, {
|
||||
fields: { 'profile.avatarUrl': 1 },
|
||||
});
|
||||
if (!user) continue;
|
||||
if (user.profile && user.profile.avatarUrl) continue; // don't overwrite
|
||||
|
||||
// Trello avatar URLs are a base path; append a size to get a real image.
|
||||
const imgUrl = /\.(png|jpe?g|gif)$/i.test(member.avatarUrl)
|
||||
? member.avatarUrl
|
||||
: `${member.avatarUrl}/170.png`;
|
||||
// Trello avatars are public (S3); try without auth, fall back to OAuth.
|
||||
let res = await fetch(imgUrl);
|
||||
if (!res.ok) {
|
||||
res = await fetch(imgUrl, { headers: { Authorization: authHeader(key, token) } });
|
||||
}
|
||||
if (!res.ok) continue;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
const type = (res.headers.get('content-type') || 'image/png').split(';')[0];
|
||||
const ext = type.split('/')[1] || 'png';
|
||||
const fileRef = await Avatars.writeAsync(
|
||||
buffer,
|
||||
{
|
||||
fileName: `avatar-${member.username || wekanUserId}.${ext}`,
|
||||
type,
|
||||
userId: wekanUserId,
|
||||
meta: { userId: wekanUserId },
|
||||
},
|
||||
true,
|
||||
);
|
||||
if (fileRef && fileRef._id) {
|
||||
await Users.updateAsync(wekanUserId, {
|
||||
$set: { 'profile.avatarUrl': generateUniversalAvatarUrl(fileRef._id) },
|
||||
});
|
||||
count += 1;
|
||||
}
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello avatar download failed:', member.username, e && e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// A Trello "Export JSON" often omits card stickers. When credentials are
|
||||
// available, fetch the board's cards with their stickers in a single API call
|
||||
// and fill them in for any card that has none. TrelloCreator maps each
|
||||
// sticker.image to a WeKan sticker icon. Best-effort: a failure never aborts.
|
||||
export async function inlineStickers(board, key, token) {
|
||||
if (!board.id || !(board.cards || []).length) return 0;
|
||||
const needsStickers = (board.cards || []).filter(
|
||||
c => !(Array.isArray(c.stickers) && c.stickers.length),
|
||||
);
|
||||
if (!needsStickers.length) return 0; // every card already has stickers
|
||||
let cards;
|
||||
try {
|
||||
cards = await trelloGet(`/boards/${board.id}/cards`, key, token, {
|
||||
fields: 'id',
|
||||
stickers: 'true',
|
||||
});
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello sticker fetch failed:', e && e.message);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const byId = new Map((cards || []).map(c => [c.id, c.stickers || []]));
|
||||
let added = 0;
|
||||
for (const card of needsStickers) {
|
||||
const stickers = byId.get(card.id);
|
||||
if (stickers && stickers.length) {
|
||||
card.stickers = stickers;
|
||||
added += stickers.length;
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// Auto-map Trello members to existing Wekan users by username. Members without
|
||||
// a matching Wekan account are left unmapped and attributed to the importer.
|
||||
async function buildMembersMapping(board) {
|
||||
const mapping = {};
|
||||
for (const member of board.members || []) {
|
||||
if (!member.username) continue;
|
||||
// Match by WeKan username, then by a previously-recorded imported username
|
||||
// (set on earlier imports / by an admin in the People panel).
|
||||
const wekanUser =
|
||||
(await ReactiveCache.getUser({ username: member.username })) ||
|
||||
(await ReactiveCache.getUser({ importUsernames: member.username }));
|
||||
if (wekanUser) {
|
||||
mapping[member.id] = wekanUser._id;
|
||||
}
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
// --- Personal workspace tree helpers (same shape as server/models/users.js) -
|
||||
function findNodeByName(nodes, name) {
|
||||
for (const node of nodes || []) {
|
||||
if (node.name === name) return node;
|
||||
if (node.children) {
|
||||
const found = findNodeByName(node.children, name);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure a workspace node named `name` exists for the user (under parentId if
|
||||
// given), creating it only if absent. Returns its id.
|
||||
async function ensureWorkspaceNode(userId, name, parentId) {
|
||||
const user = (await Users.findOneAsync(userId)) || {};
|
||||
const tree =
|
||||
user.profile && user.profile.boardWorkspacesTree
|
||||
? EJSON.clone(user.profile.boardWorkspacesTree)
|
||||
: [];
|
||||
|
||||
const existing = findNodeByName(tree, name);
|
||||
if (existing) return existing.id;
|
||||
|
||||
const newNode = { id: Random.id(), name, children: [] };
|
||||
if (!parentId) {
|
||||
tree.push(newNode);
|
||||
} else {
|
||||
const insertInto = nodes => {
|
||||
for (const n of nodes) {
|
||||
if (n.id === parentId) {
|
||||
n.children = n.children || [];
|
||||
n.children.push(newNode);
|
||||
return true;
|
||||
}
|
||||
if (n.children && n.children.length && insertInto(n.children)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!insertInto(tree)) tree.push(newNode);
|
||||
}
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.boardWorkspacesTree': tree },
|
||||
});
|
||||
return newNode.id;
|
||||
}
|
||||
|
||||
async function assignBoardToNode(userId, boardId, spaceId) {
|
||||
const user = await Users.findOneAsync(userId, {
|
||||
fields: { 'profile.boardWorkspaceAssignments': 1 },
|
||||
});
|
||||
const assignments = (user.profile && user.profile.boardWorkspaceAssignments) || {};
|
||||
assignments[boardId] = spaceId;
|
||||
await Users.updateAsync(userId, {
|
||||
$set: { 'profile.boardWorkspaceAssignments': assignments },
|
||||
});
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
// Save the user's Trello API key + token server-side (never returned to the
|
||||
// client). Only the profile.trelloApiSaved boolean becomes visible.
|
||||
async saveTrelloCredentials(key, token) {
|
||||
check(key, String);
|
||||
check(token, String);
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
if (!key.trim() || !token.trim()) {
|
||||
throw new Meteor.Error('trello-api-credentials-required');
|
||||
}
|
||||
await storeCreds(this.userId, key.trim(), token.trim());
|
||||
return true;
|
||||
},
|
||||
|
||||
// Remove the user's saved Trello API credentials from the database.
|
||||
async deleteTrelloCredentials() {
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
await clearCreds(this.userId);
|
||||
return true;
|
||||
},
|
||||
|
||||
// List the user's Trello workspaces (organizations) and their boards, plus a
|
||||
// synthetic "Personal Boards" workspace for boards with no organization.
|
||||
async trelloListWorkspaces(key, token) {
|
||||
check(key, String);
|
||||
check(token, String);
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
|
||||
const creds = await resolveCreds(this.userId, key, token);
|
||||
if (!creds) throw new Meteor.Error('trello-api-credentials-required');
|
||||
({ key, token } = creds);
|
||||
|
||||
const orgs = await trelloGet('/members/me/organizations', key, token, {
|
||||
fields: 'id,name,displayName',
|
||||
});
|
||||
const boards = await trelloGet('/members/me/boards', key, token, {
|
||||
fields: 'id,name,closed,idOrganization',
|
||||
filter: 'all',
|
||||
});
|
||||
|
||||
const byOrg = new Map();
|
||||
orgs.forEach(org => {
|
||||
byOrg.set(org.id, {
|
||||
id: org.id,
|
||||
name: org.displayName || org.name,
|
||||
boards: [],
|
||||
});
|
||||
});
|
||||
const personal = { id: 'personal', name: 'Personal Boards', boards: [] };
|
||||
|
||||
boards.forEach(board => {
|
||||
const entry = {
|
||||
id: board.id,
|
||||
name: board.name,
|
||||
closed: !!board.closed,
|
||||
};
|
||||
const ws = board.idOrganization && byOrg.get(board.idOrganization);
|
||||
(ws || personal).boards.push(entry);
|
||||
});
|
||||
|
||||
const result = Array.from(byOrg.values()).filter(ws => ws.boards.length);
|
||||
if (personal.boards.length) result.push(personal);
|
||||
return result;
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background import jobs.
|
||||
//
|
||||
// The import runs server-side as a persisted job (TrelloImportJobs) so the user
|
||||
// can navigate away and return to watch progress, resume after a fatal API
|
||||
// error, or cancel and delete the imported boards. The API key/token are kept
|
||||
// only in this in-memory map (never persisted) and must be re-supplied on
|
||||
// resume — e.g. after a server restart.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const jobCreds = new Map(); // jobId -> { key, token }
|
||||
const runningJobs = new Set(); // jobIds with an active loop
|
||||
|
||||
// A fatal error stops the whole import and is resumable (invalid credentials,
|
||||
// or rate-limit still failing after retries). Anything else is a per-board
|
||||
// failure: it is recorded and the import moves on to the next board.
|
||||
function isFatalError(e) {
|
||||
const code = e && e.error;
|
||||
return code === 'trello-api-unauthorized' || code === 'trello-api-rate-limited';
|
||||
}
|
||||
|
||||
function errMessage(e) {
|
||||
return (e && e.reason) || (e && e.message) || 'import failed';
|
||||
}
|
||||
|
||||
// Remove the boards this job created (hooked remove cascades cards, lists,
|
||||
// checklists, comments, attachments, …) and drop their workspace assignments,
|
||||
// so the user can start the whole import over cleanly.
|
||||
async function deleteJobBoards(job) {
|
||||
for (const boardId of job.createdBoardIds || []) {
|
||||
try {
|
||||
await Boards.removeAsync({ _id: boardId });
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Failed to delete imported board', boardId, e && e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clean the user's workspace assignments for the deleted boards.
|
||||
const user = await Users.findOneAsync(job.userId, {
|
||||
fields: { 'profile.boardWorkspaceAssignments': 1 },
|
||||
});
|
||||
const assignments = (user && user.profile && user.profile.boardWorkspaceAssignments) || {};
|
||||
let changed = false;
|
||||
(job.createdBoardIds || []).forEach(boardId => {
|
||||
if (assignments[boardId]) {
|
||||
delete assignments[boardId];
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) {
|
||||
await Users.updateAsync(job.userId, {
|
||||
$set: { 'profile.boardWorkspaceAssignments': assignments },
|
||||
});
|
||||
}
|
||||
await TrelloImportJobs.updateAsync(job._id, {
|
||||
$set: { createdBoardIds: [], updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function finalizeCancel(jobId) {
|
||||
const job = await TrelloImportJobs.findOneAsync(jobId);
|
||||
if (!job) return;
|
||||
jobCreds.delete(jobId);
|
||||
if (job.deleteOnCancel) {
|
||||
await deleteJobBoards(job);
|
||||
}
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: { status: 'cancelled', finishedAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async function runJob(jobId) {
|
||||
if (runningJobs.has(jobId)) return; // already processing
|
||||
runningJobs.add(jobId);
|
||||
try {
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const job = await TrelloImportJobs.findOneAsync(jobId);
|
||||
if (!job) break;
|
||||
|
||||
if (job.cancelRequested) {
|
||||
await finalizeCancel(jobId);
|
||||
break;
|
||||
}
|
||||
if (job.currentIndex >= job.boardIds.length) {
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: { status: 'done', finishedAt: new Date(), updatedAt: new Date() },
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
let creds = jobCreds.get(jobId);
|
||||
if (!creds) {
|
||||
// In-memory token gone (e.g. server restarted) — fall back to the
|
||||
// user's saved credentials if they have any.
|
||||
creds = await resolveCreds(job.userId, '', '');
|
||||
if (creds) {
|
||||
jobCreds.set(jobId, creds);
|
||||
}
|
||||
}
|
||||
if (!creds) {
|
||||
// No credentials available — pause for the user to resume.
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: {
|
||||
status: 'paused',
|
||||
lastError: 'Credentials needed to resume',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
const trelloBoardId = job.boardIds[job.currentIndex];
|
||||
try {
|
||||
const board = await fetchBoard(trelloBoardId, creds.key, creds.token);
|
||||
const attachmentsImported = await inlineAttachments(board, creds.key, creds.token);
|
||||
await inlineBoardBackground(board, creds.key, creds.token);
|
||||
const membersMapping = await buildMembersMapping(board);
|
||||
const creator = new TrelloCreator({ membersMapping });
|
||||
const newBoardId = await creator.create(board, null);
|
||||
|
||||
const wsName =
|
||||
(board.organization && board.organization.displayName) ||
|
||||
(board.organization && board.organization.name) ||
|
||||
'Personal Boards';
|
||||
const spaceId = await ensureWorkspaceNode(
|
||||
job.userId,
|
||||
wsName,
|
||||
job.parentWorkspaceNodeId || null,
|
||||
);
|
||||
await assignBoardToNode(job.userId, newBoardId, spaceId);
|
||||
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: {
|
||||
currentIndex: job.currentIndex + 1,
|
||||
status: 'running',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
$push: {
|
||||
results: {
|
||||
trelloBoardId,
|
||||
boardId: newBoardId,
|
||||
title: board.name,
|
||||
attachmentsImported,
|
||||
success: true,
|
||||
},
|
||||
createdBoardIds: newBoardId,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = errMessage(e);
|
||||
if (isFatalError(e)) {
|
||||
// Stop here; the user can fix credentials / wait and resume.
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: { status: 'error', lastError: msg, updatedAt: new Date() },
|
||||
$push: { errorLog: `[board ${trelloBoardId}] ${msg}` },
|
||||
});
|
||||
break;
|
||||
}
|
||||
// Per-board failure: record and continue with the next board.
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: { currentIndex: job.currentIndex + 1, updatedAt: new Date() },
|
||||
$push: {
|
||||
results: { trelloBoardId, success: false, error: msg },
|
||||
errorLog: `[board ${trelloBoardId}] ${msg}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: { status: 'error', lastError: errMessage(e), updatedAt: new Date() },
|
||||
$push: { errorLog: `[job] ${errMessage(e)}` },
|
||||
}).catch(() => {});
|
||||
} finally {
|
||||
runningJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
|
||||
// Launch the loop detached so the Meteor method returns immediately and the
|
||||
// import keeps running on the server.
|
||||
function launchJob(jobId) {
|
||||
runJob(jobId).catch(e => {
|
||||
console.error('Trello import job crashed', jobId, e);
|
||||
});
|
||||
}
|
||||
|
||||
Meteor.methods({
|
||||
// Start a background import of the selected boards. Returns the job id; the
|
||||
// client watches progress via the trelloImportJobs publication.
|
||||
async trelloStartImport(key, token, boardIds, parentWorkspaceNodeId) {
|
||||
check(key, String);
|
||||
check(token, String);
|
||||
check(boardIds, [String]);
|
||||
check(parentWorkspaceNodeId, Match.Maybe(String));
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
if (!boardIds.length) {
|
||||
throw new Meteor.Error('no-boards', 'No boards selected');
|
||||
}
|
||||
if (boardIds.length > 100) {
|
||||
throw new Meteor.Error('too-many-boards', 'At most 100 boards per import');
|
||||
}
|
||||
|
||||
const provided = !!(key && token);
|
||||
const creds = await resolveCreds(this.userId, key, token);
|
||||
if (!creds) throw new Meteor.Error('trello-api-credentials-required');
|
||||
// Remember credentials the user typed, so they don't have to re-enter them.
|
||||
if (provided) {
|
||||
await storeCreds(this.userId, creds.key, creds.token);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const jobId = await TrelloImportJobs.insertAsync({
|
||||
userId: this.userId,
|
||||
status: 'running',
|
||||
boardIds,
|
||||
parentWorkspaceNodeId: parentWorkspaceNodeId || null,
|
||||
currentIndex: 0,
|
||||
total: boardIds.length,
|
||||
results: [],
|
||||
errorLog: [],
|
||||
createdBoardIds: [],
|
||||
cancelRequested: false,
|
||||
deleteOnCancel: false,
|
||||
lastError: '',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
jobCreds.set(jobId, { key: creds.key, token: creds.token });
|
||||
launchJob(jobId);
|
||||
return jobId;
|
||||
},
|
||||
|
||||
// Resume a paused/errored job from where it stopped. Credentials come from
|
||||
// those typed now, else the user's saved credentials.
|
||||
async trelloResumeImport(jobId, key, token) {
|
||||
check(jobId, String);
|
||||
check(key, String);
|
||||
check(token, String);
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
const job = await TrelloImportJobs.findOneAsync(jobId);
|
||||
if (!job || job.userId !== this.userId) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
if (!['paused', 'error', 'running'].includes(job.status)) {
|
||||
throw new Meteor.Error('not-resumable', `Job is ${job.status}`);
|
||||
}
|
||||
const creds = await resolveCreds(this.userId, key, token);
|
||||
if (!creds) throw new Meteor.Error('trello-api-credentials-required');
|
||||
jobCreds.set(jobId, { key: creds.key, token: creds.token });
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: {
|
||||
status: 'running',
|
||||
cancelRequested: false,
|
||||
lastError: '',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
launchJob(jobId);
|
||||
return true;
|
||||
},
|
||||
|
||||
// Cancel a job. When deleteImported is true, also delete the boards already
|
||||
// imported by this job so the whole process can be retried cleanly.
|
||||
async trelloCancelImport(jobId, deleteImported) {
|
||||
check(jobId, String);
|
||||
check(deleteImported, Boolean);
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
const job = await TrelloImportJobs.findOneAsync(jobId);
|
||||
if (!job || job.userId !== this.userId) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
await TrelloImportJobs.updateAsync(jobId, {
|
||||
$set: {
|
||||
cancelRequested: true,
|
||||
deleteOnCancel: !!deleteImported,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
// If the loop is running it will stop at the next board boundary and
|
||||
// finalize (including deletion). If it isn't running, finalize now.
|
||||
if (!runningJobs.has(jobId)) {
|
||||
await finalizeCancel(jobId);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
// Delete a finished/cancelled job's record (and optionally its boards) so the
|
||||
// import page is clean for a fresh run.
|
||||
async trelloClearImportJob(jobId, deleteImported) {
|
||||
check(jobId, String);
|
||||
check(deleteImported, Boolean);
|
||||
if (!this.userId) throw new Meteor.Error('error-notAuthorized');
|
||||
const job = await TrelloImportJobs.findOneAsync(jobId);
|
||||
if (!job || job.userId !== this.userId) {
|
||||
throw new Meteor.Error('error-notAuthorized');
|
||||
}
|
||||
if (job.status === 'running' && runningJobs.has(jobId)) {
|
||||
throw new Meteor.Error('job-running', 'Cancel the running import first');
|
||||
}
|
||||
if (deleteImported) {
|
||||
await deleteJobBoards(job);
|
||||
}
|
||||
jobCreds.delete(jobId);
|
||||
await TrelloImportJobs.removeAsync(jobId);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
// On server (re)start, any job left "running" has no live loop or credentials:
|
||||
// flip it to paused so the user can resume it from the import page.
|
||||
Meteor.startup(async () => {
|
||||
try {
|
||||
await TrelloImportJobs.updateAsync(
|
||||
{ status: 'running' },
|
||||
{
|
||||
$set: {
|
||||
status: 'paused',
|
||||
lastError: 'Interrupted by server restart; resume to continue',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ multi: true },
|
||||
);
|
||||
} catch (e) {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.warn('Trello import job recovery failed', e && e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Test: card sticker array logic (icon + highlight) and the imported-board URL
|
||||
* slug helper.
|
||||
*
|
||||
* Re-implements the pure array logic of models/cards.js sticker methods
|
||||
* (hasSticker/addSticker/removeSticker/toggleSticker, which key on icon + the
|
||||
* mascot/computer highlight) and client/components/import/import.js boardSlug().
|
||||
* No Trello API credentials are used. Failures throw.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// --- Faithful copy of the sticker array logic in models/cards.js ----------
|
||||
// A card here is just { stickers: [...] }; the methods mutate that array the
|
||||
// same way Cards.updateAsync({$push}/{$set}) would.
|
||||
function hasSticker(card, icon, highlight) {
|
||||
const h = highlight || '';
|
||||
return (card.stickers || []).some(s => s.icon === icon && (s.highlight || '') === h);
|
||||
}
|
||||
function addSticker(card, icon, highlight, name) {
|
||||
if (!icon || hasSticker(card, icon, highlight)) return;
|
||||
const position = (card.stickers || []).length;
|
||||
const sticker = { icon, position };
|
||||
if (highlight) sticker.highlight = highlight;
|
||||
if (name) sticker.name = name;
|
||||
card.stickers = (card.stickers || []).concat(sticker);
|
||||
}
|
||||
function removeSticker(card, icon, highlight) {
|
||||
const h = highlight || '';
|
||||
const stickers = (card.stickers || []).slice();
|
||||
const index = stickers.findIndex(s => s.icon === icon && (s.highlight || '') === h);
|
||||
if (index === -1) return;
|
||||
stickers.splice(index, 1);
|
||||
card.stickers = stickers;
|
||||
}
|
||||
function toggleSticker(card, icon, highlight, name) {
|
||||
if (hasSticker(card, icon, highlight)) removeSticker(card, icon, highlight);
|
||||
else addSticker(card, icon, highlight, name);
|
||||
}
|
||||
|
||||
// --- Faithful copy of boardSlug (import.js); getSlug stubbed simply -------
|
||||
const getSlug = s =>
|
||||
String(s)
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
function boardSlug(data) {
|
||||
const raw = (data && (data.title || data.name)) || '';
|
||||
return (raw && getSlug(raw)) || 'imported-board';
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
test('plain, mascot and computer of the same icon are distinct stickers', () => {
|
||||
const card = { stickers: [] };
|
||||
addSticker(card, 'heart');
|
||||
addSticker(card, 'heart', 'underline', 'mascot love');
|
||||
addSticker(card, 'heart', 'round', 'computer love');
|
||||
assert.strictEqual(card.stickers.length, 3);
|
||||
assert.ok(hasSticker(card, 'heart'));
|
||||
assert.ok(hasSticker(card, 'heart', 'underline'));
|
||||
assert.ok(hasSticker(card, 'heart', 'round'));
|
||||
});
|
||||
|
||||
test('addSticker is idempotent per icon+highlight', () => {
|
||||
const card = { stickers: [] };
|
||||
addSticker(card, 'star', 'underline');
|
||||
addSticker(card, 'star', 'underline');
|
||||
assert.strictEqual(card.stickers.length, 1);
|
||||
});
|
||||
|
||||
test('removeSticker removes only the matching icon+highlight', () => {
|
||||
const card = { stickers: [] };
|
||||
addSticker(card, 'heart');
|
||||
addSticker(card, 'heart', 'round');
|
||||
removeSticker(card, 'heart', 'round');
|
||||
assert.ok(hasSticker(card, 'heart'));
|
||||
assert.ok(!hasSticker(card, 'heart', 'round'));
|
||||
assert.strictEqual(card.stickers.length, 1);
|
||||
});
|
||||
|
||||
test('toggleSticker adds then removes the same icon+highlight', () => {
|
||||
const card = { stickers: [] };
|
||||
toggleSticker(card, 'snapchat-ghost', 'round', 'computer ghost');
|
||||
assert.ok(hasSticker(card, 'snapchat-ghost', 'round'));
|
||||
toggleSticker(card, 'snapchat-ghost', 'round', 'computer ghost');
|
||||
assert.ok(!hasSticker(card, 'snapchat-ghost', 'round'));
|
||||
assert.strictEqual(card.stickers.length, 0);
|
||||
});
|
||||
|
||||
test('added sticker stores highlight and name only when given', () => {
|
||||
const card = { stickers: [] };
|
||||
addSticker(card, 'bug');
|
||||
assert.deepStrictEqual(card.stickers[0], { icon: 'bug', position: 0 });
|
||||
addSticker(card, 'truck', 'round', 'computer shipped');
|
||||
assert.deepStrictEqual(card.stickers[1], {
|
||||
icon: 'truck',
|
||||
position: 1,
|
||||
highlight: 'round',
|
||||
name: 'computer shipped',
|
||||
});
|
||||
});
|
||||
|
||||
// --- boardSlug ---
|
||||
test('boardSlug uses Trello .name, WeKan .title, never crashes on undefined', () => {
|
||||
assert.strictEqual(boardSlug({ name: 'Liite Testi' }), 'liite-testi'); // Trello
|
||||
assert.strictEqual(boardSlug({ title: 'My Board' }), 'my-board'); // WeKan
|
||||
assert.strictEqual(boardSlug({ title: 'T', name: 'N' }), 't'); // title wins
|
||||
assert.strictEqual(boardSlug({}), 'imported-board'); // no name/title
|
||||
assert.strictEqual(boardSlug(undefined), 'imported-board'); // guarded
|
||||
});
|
||||
|
||||
if (typeof describe === 'function') {
|
||||
describe('card stickers and board slug', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('Card sticker / board slug tests\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Test: Trello sticker mapping, highlights, picker generation and pack renaming.
|
||||
*
|
||||
* The real maps live in models/metadata/stickers.js (an ES module). As the
|
||||
* sibling stickers.test.js does, this parses those tables from source and then
|
||||
* runs faithful copies of the functions over them, so the test stays in sync
|
||||
* with the production data. No Trello API credentials are used. Failures throw.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'models', 'metadata', 'stickers.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// --- parse the real tables from source ------------------------------------
|
||||
function parseExactMap(name) {
|
||||
const m = src.match(new RegExp(`${name} = \\{([\\s\\S]*?)\\}`));
|
||||
const map = {};
|
||||
if (m) {
|
||||
for (const e of m[1].matchAll(/(\w+):\s*'([^']+)'/g)) map[e[1]] = e[2];
|
||||
}
|
||||
return map;
|
||||
}
|
||||
function parseKeywordList(name) {
|
||||
const m = src.match(new RegExp(`${name} = \\[([\\s\\S]*?)\\];`));
|
||||
const out = [];
|
||||
if (m) {
|
||||
for (const e of m[1].matchAll(/\['([^']+)',\s*'([^']+)'\]/g)) out.push([e[1], e[2]]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function parseArray(name) {
|
||||
const m = src.match(new RegExp(`${name} = \\[([\\s\\S]*?)\\];`));
|
||||
return m ? [...m[1].matchAll(/'([^']+)'/g)].map(x => x[1]) : [];
|
||||
}
|
||||
|
||||
const TRELLO_STICKER_TO_FA = parseExactMap('TRELLO_STICKER_TO_FA');
|
||||
const STICKER_KEYWORD_TO_FA = parseKeywordList('STICKER_KEYWORD_TO_FA');
|
||||
const STICKER_ICONS = parseArray('STICKER_ICONS');
|
||||
const DEFAULT_STICKER_ICON = (src.match(/DEFAULT_STICKER_ICON = '([^']+)'/) || [])[1];
|
||||
|
||||
// --- faithful copies of the functions (driven by the parsed tables) -------
|
||||
function trelloStickerToFa(name) {
|
||||
if (!name) return DEFAULT_STICKER_ICON;
|
||||
const n = String(name).toLowerCase();
|
||||
if (TRELLO_STICKER_TO_FA[n]) return TRELLO_STICKER_TO_FA[n];
|
||||
for (const [kw, icon] of STICKER_KEYWORD_TO_FA) {
|
||||
if (n.includes(kw)) return icon;
|
||||
}
|
||||
return DEFAULT_STICKER_ICON;
|
||||
}
|
||||
function trelloStickerHighlight(name) {
|
||||
if (!name) return undefined;
|
||||
const n = String(name).toLowerCase();
|
||||
if (n.includes('taco')) return 'underline';
|
||||
if (n.includes('pete')) return 'round';
|
||||
return undefined;
|
||||
}
|
||||
// Mirrors trelloCreator.stickerLabel() rename of the named packs.
|
||||
function stickerLabel(name) {
|
||||
const raw = String(name || '').trim();
|
||||
if (!raw) return 'sticker';
|
||||
if (/^[0-9a-f]{16,}$/i.test(raw) || /^[A-Za-z0-9_-]{20,}$/.test(raw)) return 'custom sticker';
|
||||
return raw
|
||||
.replace(/[-_]+/g, ' ')
|
||||
.replace(/\btaco\b/gi, 'mascot')
|
||||
.replace(/\bpete\b/gi, 'computer')
|
||||
.trim();
|
||||
}
|
||||
// Mirrors STICKER_PICKER generation.
|
||||
const ALL_MAP_ICONS = [
|
||||
...new Set([
|
||||
...Object.values(TRELLO_STICKER_TO_FA),
|
||||
...STICKER_KEYWORD_TO_FA.map(([, icon]) => icon),
|
||||
DEFAULT_STICKER_ICON,
|
||||
]),
|
||||
];
|
||||
const STICKER_PICKER = [
|
||||
...STICKER_ICONS.map(icon => ({ icon })),
|
||||
...ALL_MAP_ICONS.map(icon => ({ icon, highlight: 'underline', name: `mascot ${icon}` })),
|
||||
...ALL_MAP_ICONS.map(icon => ({ icon, highlight: 'round', name: `computer ${icon}` })),
|
||||
];
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
test('parsing found the real tables', () => {
|
||||
assert.ok(STICKER_ICONS.length > 0);
|
||||
assert.ok(STICKER_KEYWORD_TO_FA.length > 0);
|
||||
assert.ok(Object.keys(TRELLO_STICKER_TO_FA).length > 0);
|
||||
assert.ok(DEFAULT_STICKER_ICON);
|
||||
});
|
||||
|
||||
test('distinct icons for previously-colliding named stickers', () => {
|
||||
assert.strictEqual(trelloStickerToFa('taco-active'), 'heartbeat');
|
||||
assert.strictEqual(trelloStickerToFa('taco-pixelated'), 'qrcode');
|
||||
assert.strictEqual(trelloStickerToFa('taco-prototype'), 'flask');
|
||||
assert.strictEqual(trelloStickerToFa('taco-embarrassed'), 'meh-o');
|
||||
assert.strictEqual(trelloStickerToFa('taco-clean'), 'bath');
|
||||
assert.strictEqual(trelloStickerToFa('pete-ghost'), 'snapchat-ghost');
|
||||
// shipped must differ from space
|
||||
assert.strictEqual(trelloStickerToFa('pete-shipped'), 'truck');
|
||||
assert.strictEqual(trelloStickerToFa('pete-space'), 'rocket');
|
||||
assert.notStrictEqual(trelloStickerToFa('pete-shipped'), trelloStickerToFa('pete-space'));
|
||||
});
|
||||
|
||||
test('unknown/custom sticker falls back to the default icon', () => {
|
||||
assert.strictEqual(trelloStickerToFa('something-totally-unknown-xyz'), DEFAULT_STICKER_ICON);
|
||||
assert.strictEqual(trelloStickerToFa(''), DEFAULT_STICKER_ICON);
|
||||
});
|
||||
|
||||
test('highlight: taco => underline, pete => round, else none', () => {
|
||||
assert.strictEqual(trelloStickerHighlight('taco-love'), 'underline');
|
||||
assert.strictEqual(trelloStickerHighlight('pete-happy'), 'round');
|
||||
assert.strictEqual(trelloStickerHighlight('thumbsup'), undefined);
|
||||
assert.strictEqual(trelloStickerHighlight(''), undefined);
|
||||
});
|
||||
|
||||
test('label renames taco => mascot, pete => computer', () => {
|
||||
assert.strictEqual(stickerLabel('taco-love'), 'mascot love');
|
||||
assert.strictEqual(stickerLabel('pete-ghost'), 'computer ghost');
|
||||
assert.strictEqual(stickerLabel('thumbsup'), 'thumbsup');
|
||||
});
|
||||
|
||||
test('picker includes a mascot+computer entry for every mappable icon', () => {
|
||||
const mascot = STICKER_PICKER.filter(e => e.highlight === 'underline').map(e => e.icon);
|
||||
const computer = STICKER_PICKER.filter(e => e.highlight === 'round').map(e => e.icon);
|
||||
// Every icon the importer can produce is offered in both highlights.
|
||||
ALL_MAP_ICONS.forEach(icon => {
|
||||
assert.ok(mascot.includes(icon), `mascot missing ${icon}`);
|
||||
assert.ok(computer.includes(icon), `computer missing ${icon}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('picker contains the specific imported sticker icons', () => {
|
||||
const iconsWithHL = new Set(STICKER_PICKER.filter(e => e.highlight).map(e => e.icon));
|
||||
['snapchat-ghost', 'heartbeat', 'qrcode', 'flask', 'bath', 'truck', 'rocket', 'meh-o'].forEach(
|
||||
icon => assert.ok(iconsWithHL.has(icon), `picker missing highlighted ${icon}`),
|
||||
);
|
||||
});
|
||||
|
||||
test('every imported sticker icon can be reproduced from the picker', () => {
|
||||
// For any Trello sticker name, the (icon, highlight) it imports as must exist
|
||||
// as a selectable picker entry.
|
||||
const pickerKeys = new Set(
|
||||
STICKER_PICKER.map(e => `${e.icon}|${e.highlight || ''}`),
|
||||
);
|
||||
['taco-active', 'taco-clean', 'taco-love', 'pete-ghost', 'pete-shipped', 'pete-space', 'thumbsup'].forEach(
|
||||
n => {
|
||||
const key = `${trelloStickerToFa(n)}|${trelloStickerHighlight(n) || ''}`;
|
||||
assert.ok(pickerKeys.has(key), `picker has no entry for imported ${n} (${key})`);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (typeof describe === 'function') {
|
||||
describe('Trello sticker packs', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('Trello sticker pack tests\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Test: card sticker icon set (models/metadata/stickers.js)
|
||||
*
|
||||
* Parses the real source file (it's an ES module, not require()-able here) and
|
||||
* checks the data is internally consistent so Trello-imported stickers can also
|
||||
* be added/removed from the in-app picker. Standalone runner like the sibling
|
||||
* tests; failures exit non-zero.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'models', 'metadata', 'stickers.js'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function parseArray(name) {
|
||||
const m = src.match(new RegExp(`${name} = \\[([\\s\\S]*?)\\]`));
|
||||
if (!m) return null;
|
||||
return [...m[1].matchAll(/'([^']+)'/g)].map(x => x[1]);
|
||||
}
|
||||
|
||||
function parseMapValues(name) {
|
||||
const m = src.match(new RegExp(`${name} = \\{([\\s\\S]*?)\\}`));
|
||||
if (!m) return null;
|
||||
return [...m[1].matchAll(/:\s*'([^']+)'/g)].map(x => x[1]);
|
||||
}
|
||||
|
||||
const STICKER_ICONS = parseArray('STICKER_ICONS');
|
||||
const trelloValues = parseMapValues('TRELLO_STICKER_TO_FA');
|
||||
const defaultMatch = src.match(/DEFAULT_STICKER_ICON = '([^']+)'/);
|
||||
const DEFAULT_STICKER_ICON = defaultMatch && defaultMatch[1];
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
test('STICKER_ICONS is a non-empty list with no duplicates', () => {
|
||||
assert.ok(Array.isArray(STICKER_ICONS) && STICKER_ICONS.length > 0);
|
||||
const dups = STICKER_ICONS.filter((v, i) => STICKER_ICONS.indexOf(v) !== i);
|
||||
assert.deepStrictEqual(dups, [], `duplicate sticker icons: ${dups}`);
|
||||
});
|
||||
|
||||
test('icon names are Font Awesome v4 style (no FA6 face-*/note-sticky names)', () => {
|
||||
// These FA6 names do not render with the bare `fa` class WeKan uses.
|
||||
const fa6 = STICKER_ICONS.filter(n => n.startsWith('face-') || n === 'note-sticky');
|
||||
assert.deepStrictEqual(fa6, [], `FA6-only names would not render: ${fa6}`);
|
||||
});
|
||||
|
||||
test('every Trello sticker maps to an icon (and the default) in the picker set', () => {
|
||||
assert.ok(trelloValues && trelloValues.length > 0);
|
||||
const missing = trelloValues.filter(v => !STICKER_ICONS.includes(v));
|
||||
assert.deepStrictEqual(missing, [], `Trello sticker targets not in picker: ${missing}`);
|
||||
assert.ok(
|
||||
STICKER_ICONS.includes(DEFAULT_STICKER_ICON),
|
||||
`default sticker '${DEFAULT_STICKER_ICON}' not in picker set`,
|
||||
);
|
||||
});
|
||||
|
||||
if (typeof describe === 'function') {
|
||||
describe('card sticker icon set', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('Sticker icon set tests\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Test: Trello import — attachment merge, ZIP filename matching, sticker mapping
|
||||
*
|
||||
* These cover the pure logic added for Trello import:
|
||||
* - models/trelloCreator.js : merge of card.attachments[] + actions, sticker map
|
||||
* - client/components/import/import.js : ZIP filename matching (injectZipAttachments)
|
||||
*
|
||||
* Those modules depend on Meteor and can't be imported under plain Node, so —
|
||||
* following the convention of the sibling wekanCreator.*.test.js files — each
|
||||
* test re-implements the exact logic being verified, kept faithful to the
|
||||
* production code. Failures throw (assert), so a regression exits non-zero.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// --- Faithful copy of TrelloCreator attachment merge ----------------------
|
||||
// Mirrors the pushAttachment/mergedAttachments logic in createCards().
|
||||
function mergeAttachments(fromActions, fromCardField) {
|
||||
const merged = [];
|
||||
const byId = new Map();
|
||||
const push = raw => {
|
||||
if (!raw) return;
|
||||
const id = raw.id || raw._id || `__noid_${merged.length}`;
|
||||
const norm = {
|
||||
id: raw.id || raw._id,
|
||||
name: raw.name || raw.fileName || '',
|
||||
fileName: raw.fileName || raw.name || '',
|
||||
url: raw.url || '',
|
||||
type: raw.mimeType || raw.type || undefined,
|
||||
file: raw.file,
|
||||
};
|
||||
const existing = byId.get(id);
|
||||
if (existing) {
|
||||
if (!existing.file && norm.file) existing.file = norm.file;
|
||||
if (!existing.url && norm.url) existing.url = norm.url;
|
||||
if (!existing.type && norm.type) existing.type = norm.type;
|
||||
return;
|
||||
}
|
||||
byId.set(id, norm);
|
||||
merged.push(norm);
|
||||
};
|
||||
(fromActions || []).forEach(push);
|
||||
(fromCardField || []).forEach(push);
|
||||
return merged;
|
||||
}
|
||||
|
||||
// --- Faithful copy of the ZIP filename matcher ----------------------------
|
||||
// Mirrors findBytesFor() in client injectZipAttachments(): exact basename,
|
||||
// then `_<fileName>` suffix (TCAD prefixes "<board>_<index>_"), then endsWith.
|
||||
function makeMatcher(zipBasenames) {
|
||||
const entries = zipBasenames.map(base => ({ base, used: false }));
|
||||
return fileName => {
|
||||
if (!fileName) return null;
|
||||
let cand = entries.find(e => !e.used && e.base === fileName);
|
||||
if (!cand) cand = entries.find(e => !e.used && e.base.endsWith(`_${fileName}`));
|
||||
if (!cand) cand = entries.find(e => !e.used && e.base.endsWith(fileName));
|
||||
if (!cand) return null;
|
||||
cand.used = true;
|
||||
return cand.base;
|
||||
};
|
||||
}
|
||||
|
||||
// --- Faithful copy of the Trello sticker -> Font Awesome map --------------
|
||||
function getStickerIcon(name) {
|
||||
const map = {
|
||||
thumbsup: 'thumbs-up',
|
||||
thumbsdown: 'thumbs-down',
|
||||
heart: 'heart',
|
||||
star: 'star',
|
||||
clock: 'clock',
|
||||
check: 'check',
|
||||
warning: 'exclamation-triangle',
|
||||
smile: 'face-smile',
|
||||
laugh: 'face-laugh-beam',
|
||||
huh: 'face-meh',
|
||||
frown: 'face-frown',
|
||||
rocketship: 'rocket',
|
||||
globe: 'globe',
|
||||
pencil: 'pencil',
|
||||
};
|
||||
return map[name] || 'note-sticky';
|
||||
}
|
||||
|
||||
// --- Faithful copy of the Trello -> WeKan color mapping -------------------
|
||||
// Mirrors TrelloCreator.mapToWekanColor(): strip `_light`/`_dark` variant and
|
||||
// keep only colors WeKan allows (CARD_COLORS === LABEL_COLORS === ALLOWED_COLORS).
|
||||
const ALLOWED_COLORS = [
|
||||
'white', 'green', 'yellow', 'orange', 'red', 'purple', 'blue', 'sky', 'lime',
|
||||
'pink', 'black', 'silver', 'peachpuff', 'crimson', 'plum', 'darkgreen',
|
||||
'slateblue', 'magenta', 'gold', 'navy', 'gray', 'saddlebrown',
|
||||
'paleturquoise', 'mistyrose', 'indigo',
|
||||
];
|
||||
function mapToWekanColor(trelloColor) {
|
||||
if (!trelloColor) return null;
|
||||
const base = String(trelloColor).split('_')[0];
|
||||
return ALLOWED_COLORS.includes(base) ? base : null;
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
// ==========================================================================
|
||||
// Color mapping (labels + card cover)
|
||||
// ==========================================================================
|
||||
test('Trello _light/_dark label colors strip to a valid WeKan color', () => {
|
||||
assert.strictEqual(mapToWekanColor('purple_light'), 'purple');
|
||||
assert.strictEqual(mapToWekanColor('blue_dark'), 'blue');
|
||||
assert.strictEqual(mapToWekanColor('green'), 'green');
|
||||
});
|
||||
|
||||
test('unmappable colors return null (so the field is omitted, not invalid)', () => {
|
||||
// 'belize' is a board color, never valid for a card/label.
|
||||
assert.strictEqual(mapToWekanColor('belize'), null);
|
||||
assert.strictEqual(mapToWekanColor(null), null);
|
||||
assert.strictEqual(mapToWekanColor(undefined), null);
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Attachment merge
|
||||
// ==========================================================================
|
||||
test('attachments are read from card.attachments[] when no actions exist', () => {
|
||||
const merged = mergeAttachments([], [
|
||||
{ id: 'a1', name: 'koneet.jpeg', fileName: 'koneet.jpeg', url: 'https://trello/x', mimeType: 'image/jpeg' },
|
||||
]);
|
||||
assert.strictEqual(merged.length, 1);
|
||||
assert.strictEqual(merged[0].id, 'a1');
|
||||
assert.strictEqual(merged[0].type, 'image/jpeg');
|
||||
});
|
||||
|
||||
test('same attachment in actions and card.attachments is de-duplicated by id', () => {
|
||||
const merged = mergeAttachments(
|
||||
[{ id: 'a1', name: 'k.jpeg', url: 'https://trello/x' }],
|
||||
[{ id: 'a1', name: 'k.jpeg', fileName: 'k.jpeg', mimeType: 'image/jpeg' }],
|
||||
);
|
||||
assert.strictEqual(merged.length, 1, 'not duplicated');
|
||||
});
|
||||
|
||||
test('injected base64 (file) survives the merge regardless of source order', () => {
|
||||
// actions copy has no file; card.attachments copy carries the ZIP bytes
|
||||
const merged = mergeAttachments(
|
||||
[{ id: 'a1', name: 'k.jpeg', url: 'https://trello/x' }],
|
||||
[{ id: 'a1', name: 'k.jpeg', fileName: 'k.jpeg', file: 'QkFTRTY0' }],
|
||||
);
|
||||
assert.strictEqual(merged.length, 1);
|
||||
assert.strictEqual(merged[0].file, 'QkFTRTY0', 'file bytes carried onto the merged entry');
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// ZIP filename matching
|
||||
// ==========================================================================
|
||||
test('TCAD-prefixed filename matches by _<fileName> suffix', () => {
|
||||
const match = makeMatcher(['Liite Testi_1_ai-modern-infra2.jpg', 'Liite Testi_2_koneet.jpeg']);
|
||||
assert.strictEqual(match('koneet.jpeg'), 'Liite Testi_2_koneet.jpeg');
|
||||
assert.strictEqual(match('ai-modern-infra2.jpg'), 'Liite Testi_1_ai-modern-infra2.jpg');
|
||||
});
|
||||
|
||||
test('exact basename matches and is consumed (no double-assign)', () => {
|
||||
const match = makeMatcher(['koneet.jpeg', 'koneet.jpeg']);
|
||||
assert.strictEqual(match('koneet.jpeg'), 'koneet.jpeg');
|
||||
// second lookup gets the *other* entry, not the already-used one
|
||||
assert.strictEqual(match('koneet.jpeg'), 'koneet.jpeg');
|
||||
// third lookup: nothing left
|
||||
assert.strictEqual(match('koneet.jpeg'), null);
|
||||
});
|
||||
|
||||
test('no match returns null (attachment simply has no bytes)', () => {
|
||||
const match = makeMatcher(['something-else.png']);
|
||||
assert.strictEqual(match('missing.jpg'), null);
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Sticker mapping
|
||||
// ==========================================================================
|
||||
test('known Trello sticker names map to Font Awesome icons', () => {
|
||||
assert.strictEqual(getStickerIcon('thumbsup'), 'thumbs-up');
|
||||
assert.strictEqual(getStickerIcon('heart'), 'heart');
|
||||
assert.strictEqual(getStickerIcon('warning'), 'exclamation-triangle');
|
||||
assert.strictEqual(getStickerIcon('rocketship'), 'rocket');
|
||||
});
|
||||
|
||||
test('unknown sticker name falls back to note-sticky', () => {
|
||||
assert.strictEqual(getStickerIcon('totally-made-up'), 'note-sticky');
|
||||
});
|
||||
|
||||
// ==========================================================================
|
||||
// Runner — Mocha-compatible when available, standalone otherwise
|
||||
// ==========================================================================
|
||||
if (typeof describe === 'function') {
|
||||
describe('Trello import: attachments, ZIP matching, stickers', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('====================================');
|
||||
console.log('Trello Import Tests');
|
||||
console.log('====================================\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* Test: card location map-link parsing and provider URL generation.
|
||||
*
|
||||
* Covers the pure logic added for card locations:
|
||||
* - client/components/import/import.js style parsing is not here; the relevant
|
||||
* functions live in client/components/cards/cardDetails.js:
|
||||
* parseMapLink(url) -> { latitude, longitude, name, address }
|
||||
* mapLinkFor(provider, lat, lon) -> url
|
||||
* These depend on Blaze/Meteor and can't be required under plain Node, so —
|
||||
* following the convention of the sibling trelloCreator.*.test.js files — the
|
||||
* logic is re-implemented faithfully here. Failures throw, so a regression
|
||||
* exits non-zero.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// --- Faithful copy of parseMapLink (cardDetails.js) -----------------------
|
||||
function parseMapLink(url) {
|
||||
const result = {};
|
||||
if (!url) return result;
|
||||
const s = String(url).trim();
|
||||
|
||||
let lat;
|
||||
let lon;
|
||||
const setCoords = (la, lo) => {
|
||||
if (lat !== undefined) return;
|
||||
const a = parseFloat(la);
|
||||
const o = parseFloat(lo);
|
||||
if (!isNaN(a) && !isNaN(o) && Math.abs(a) <= 90 && Math.abs(o) <= 180) {
|
||||
lat = a;
|
||||
lon = o;
|
||||
}
|
||||
};
|
||||
|
||||
const N = '(-?\\d+(?:\\.\\d+)?)';
|
||||
let m;
|
||||
if ((m = s.match(new RegExp(`!3d${N}!4d${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`@${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`[?&#]mlat=${N}`)))) {
|
||||
const mlon = s.match(new RegExp(`[?&#]mlon=${N}`));
|
||||
if (mlon) setCoords(m[1], mlon[1]);
|
||||
}
|
||||
if ((m = s.match(new RegExp(`[#&]map=\\d+(?:\\.\\d+)?/${N}/${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`[?&]cp=${N}~${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`[?&]ll=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`[?&]q=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
if ((m = s.match(new RegExp(`[?&]center=${N},${N}`)))) setCoords(m[1], m[2]);
|
||||
|
||||
if (lat !== undefined) {
|
||||
result.latitude = lat;
|
||||
result.longitude = lon;
|
||||
}
|
||||
|
||||
const decode = raw => {
|
||||
try {
|
||||
return decodeURIComponent(raw.replace(/\+/g, ' ')).trim();
|
||||
} catch (e) {
|
||||
return raw.replace(/\+/g, ' ').trim();
|
||||
}
|
||||
};
|
||||
const isCoordText = t => /^-?\d+(\.\d+)?\s*,\s*-?\d+(\.\d+)?$/.test(t);
|
||||
|
||||
if ((m = s.match(/\/place\/([^/@?]+)/))) {
|
||||
const name = decode(m[1]);
|
||||
if (name && !isCoordText(name)) result.name = name;
|
||||
}
|
||||
if ((m = s.match(/[?&](?:q|query|destination)=([^&]+)/))) {
|
||||
const q = decode(m[1]);
|
||||
if (q && !isCoordText(q)) result.address = q;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Faithful copy of mapLinkFor (cardDetails.js) -------------------------
|
||||
function mapLinkFor(provider, lat, lon) {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return `https://www.google.com/maps/search/?api=1&query=${lat},${lon}`;
|
||||
case 'bing':
|
||||
return `https://www.bing.com/maps?cp=${lat}~${lon}&lvl=16`;
|
||||
case 'apple':
|
||||
return `https://maps.apple.com/?ll=${lat},${lon}`;
|
||||
case 'openstreetmap':
|
||||
default:
|
||||
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`;
|
||||
}
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
test('Google Maps @lat,lon', () => {
|
||||
const r = parseMapLink('https://www.google.com/maps/@60.1699,24.9384,15z');
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
});
|
||||
|
||||
test('Google Maps place with !3d!4d and place name', () => {
|
||||
const r = parseMapLink(
|
||||
'https://www.google.com/maps/place/Helsinki+Airport/@60.3172,24.9633,15z/data=!3d60.317200!4d24.963300',
|
||||
);
|
||||
assert.strictEqual(r.latitude, 60.3172);
|
||||
assert.strictEqual(r.longitude, 24.9633);
|
||||
assert.strictEqual(r.name, 'Helsinki Airport');
|
||||
});
|
||||
|
||||
test('Google Maps ?q=lat,lon', () => {
|
||||
const r = parseMapLink('https://maps.google.com/?q=60.1699,24.9384');
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
});
|
||||
|
||||
test('OpenStreetMap mlat/mlon', () => {
|
||||
const r = parseMapLink(
|
||||
'https://www.openstreetmap.org/?mlat=60.1699&mlon=24.9384#map=16/60.1699/24.9384',
|
||||
);
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
});
|
||||
|
||||
test('OpenStreetMap #map hash only', () => {
|
||||
const r = parseMapLink('https://www.openstreetmap.org/#map=16/60.1699/24.9384');
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
});
|
||||
|
||||
test('Bing Maps cp=lat~lon', () => {
|
||||
const r = parseMapLink('https://www.bing.com/maps?cp=60.1699~24.9384&lvl=15');
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
});
|
||||
|
||||
test('Apple Maps ll + textual q becomes address', () => {
|
||||
const r = parseMapLink('https://maps.apple.com/?ll=60.1699,24.9384&q=Helsinki');
|
||||
assert.strictEqual(r.latitude, 60.1699);
|
||||
assert.strictEqual(r.longitude, 24.9384);
|
||||
assert.strictEqual(r.address, 'Helsinki');
|
||||
});
|
||||
|
||||
test('Google search query text -> address, no coords', () => {
|
||||
const r = parseMapLink('https://www.google.com/maps/search/?api=1&query=Eiffel+Tower');
|
||||
assert.strictEqual(r.latitude, undefined);
|
||||
assert.strictEqual(r.address, 'Eiffel Tower');
|
||||
});
|
||||
|
||||
test('garbage / empty input yields nothing', () => {
|
||||
assert.deepStrictEqual(parseMapLink('not a url'), {});
|
||||
assert.deepStrictEqual(parseMapLink(''), {});
|
||||
assert.deepStrictEqual(parseMapLink(undefined), {});
|
||||
});
|
||||
|
||||
test('out-of-range coordinates are rejected', () => {
|
||||
const r = parseMapLink('https://maps.example.com/?q=200,400');
|
||||
assert.strictEqual(r.latitude, undefined);
|
||||
});
|
||||
|
||||
test('mapLinkFor builds the right URL per provider', () => {
|
||||
assert.ok(mapLinkFor('openstreetmap', 1, 2).startsWith('https://www.openstreetmap.org/?mlat=1&mlon=2'));
|
||||
assert.ok(mapLinkFor('google', 1, 2).includes('google.com/maps/search/?api=1&query=1,2'));
|
||||
assert.ok(mapLinkFor('bing', 1, 2).includes('bing.com/maps?cp=1~2'));
|
||||
assert.ok(mapLinkFor('apple', 1, 2).includes('maps.apple.com/?ll=1,2'));
|
||||
// Unknown provider falls back to OpenStreetMap.
|
||||
assert.ok(mapLinkFor('whatever', 1, 2).includes('openstreetmap.org'));
|
||||
});
|
||||
|
||||
test('mapLinkFor output round-trips back through parseMapLink (osm/bing/apple)', () => {
|
||||
// Google uses a ?query=lat,lon search URL which parseMapLink intentionally
|
||||
// does not read as coordinates (it expects @lat,lon / ?q=lat,lon), so it is
|
||||
// not part of the round-trip set.
|
||||
['openstreetmap', 'bing', 'apple'].forEach(p => {
|
||||
const url = mapLinkFor(p, 60.17, 24.94);
|
||||
const r = parseMapLink(url);
|
||||
assert.strictEqual(r.latitude, 60.17, `provider ${p} lat`);
|
||||
assert.strictEqual(r.longitude, 24.94, `provider ${p} lon`);
|
||||
});
|
||||
});
|
||||
|
||||
if (typeof describe === 'function') {
|
||||
describe('card location map links', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('Map link parsing tests\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Test: Trello ".zip" import — board-name directory matching, attachment
|
||||
* filename matching, and the zip-bomb / path-traversal guards.
|
||||
*
|
||||
* Covers the pure logic in server/routes/importTrelloZip.js (which depends on
|
||||
* Meteor/WebApp and can't be required under plain Node), re-implemented
|
||||
* faithfully here. No Trello API credentials or network are used. Failures
|
||||
* throw, so a regression exits non-zero.
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
// --- Faithful copies from server/routes/importTrelloZip.js ----------------
|
||||
function isUnsafeEntryPath(p) {
|
||||
if (!p) return false;
|
||||
if (p.includes('\0')) return true;
|
||||
if (p.includes('\\')) return true;
|
||||
if (p.startsWith('/')) return true;
|
||||
if (/^[a-zA-Z]:/.test(p)) return true;
|
||||
if (p.split('/').some(seg => seg === '..')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function sanitizeBoardDirName(name) {
|
||||
const forbidden = [
|
||||
'/', ' ', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '+', '=',
|
||||
'{', '}', '[', ']', ',', ';', "'", '"', '<', '>', '?', '`', '~', '|',
|
||||
];
|
||||
let s = String(name || '');
|
||||
for (const c of forbidden) s = s.split(c).join('');
|
||||
return s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function collectLogicalAttachments(data) {
|
||||
const logical = new Map();
|
||||
const collect = att => {
|
||||
if (!att) return;
|
||||
if (att.url && att.name === att.url) return;
|
||||
const id = att.id || att._id || att.fileName || att.name;
|
||||
if (!id) return;
|
||||
if (!logical.has(id)) {
|
||||
logical.set(id, { fileName: att.fileName || att.name, occ: [] });
|
||||
}
|
||||
logical.get(id).occ.push(att);
|
||||
};
|
||||
(data.cards || []).forEach(card => (card.attachments || []).forEach(collect));
|
||||
(data.actions || []).forEach(action => {
|
||||
if (action.type === 'addAttachmentToCard') {
|
||||
collect(action.data && action.data.attachment);
|
||||
}
|
||||
});
|
||||
return logical;
|
||||
}
|
||||
|
||||
// Mirrors the per-board scoping + filename matching done in importZipBuffer:
|
||||
// fileEntries are { base, dirSegs, used } objects; returns matched count.
|
||||
function matchBoardAttachments(board, fileEntries, boardCount) {
|
||||
const sanitized = sanitizeBoardDirName(board.name);
|
||||
let scoped = fileEntries.filter(
|
||||
e =>
|
||||
!e.used &&
|
||||
(e.dirSegs.some(seg => sanitizeBoardDirName(seg) === sanitized) ||
|
||||
sanitizeBoardDirName(e.base).startsWith(`${sanitized}_`)),
|
||||
);
|
||||
if (scoped.length === 0 && boardCount === 1) {
|
||||
scoped = fileEntries.filter(e => !e.used);
|
||||
}
|
||||
const findEntryFor = fileName => {
|
||||
if (!fileName) return null;
|
||||
let cand = scoped.find(e => !e.used && e.base === fileName);
|
||||
if (!cand) cand = scoped.find(e => !e.used && e.base.endsWith(`_${fileName}`));
|
||||
if (!cand) cand = scoped.find(e => !e.used && e.base.endsWith(fileName));
|
||||
return cand || null;
|
||||
};
|
||||
const logical = collectLogicalAttachments(board);
|
||||
let matched = 0;
|
||||
for (const info of logical.values()) {
|
||||
const cand = findEntryFor(info.fileName);
|
||||
if (cand) {
|
||||
cand.used = true;
|
||||
matched += 1;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
function looksLikeBoard(data) {
|
||||
return !!(data && typeof data === 'object' && data.name &&
|
||||
(data.cards || data.lists || data.actions));
|
||||
}
|
||||
|
||||
const tests = [];
|
||||
const test = (name, fn) => tests.push([name, fn]);
|
||||
|
||||
// --- path-traversal guard ---
|
||||
test('isUnsafeEntryPath rejects traversal/absolute/backslash/drive/null', () => {
|
||||
assert.ok(isUnsafeEntryPath('../secret'));
|
||||
assert.ok(isUnsafeEntryPath('a/../b'));
|
||||
assert.ok(isUnsafeEntryPath('/etc/passwd'));
|
||||
assert.ok(isUnsafeEntryPath('C:\\win'));
|
||||
assert.ok(isUnsafeEntryPath('a\\b'));
|
||||
assert.ok(isUnsafeEntryPath('a\0b'));
|
||||
});
|
||||
test('isUnsafeEntryPath accepts normal relative paths', () => {
|
||||
assert.ok(!isUnsafeEntryPath('Liite Testi/Liite Testi_1_koneet.jpeg'));
|
||||
assert.ok(!isUnsafeEntryPath('446iLdzb - liite-testi.json'));
|
||||
assert.ok(!isUnsafeEntryPath('a/b/c.png'));
|
||||
});
|
||||
|
||||
// --- board-name -> directory sanitization ---
|
||||
test('sanitizeBoardDirName matches a board name to its folder', () => {
|
||||
assert.strictEqual(sanitizeBoardDirName('Liite Testi'), 'liitetesti');
|
||||
assert.strictEqual(
|
||||
sanitizeBoardDirName('Liite Testi'),
|
||||
sanitizeBoardDirName('Liite Testi'),
|
||||
);
|
||||
// forbidden characters are stripped, case-insensitive
|
||||
assert.strictEqual(sanitizeBoardDirName('My (Board)!'), 'myboard');
|
||||
});
|
||||
|
||||
// --- attachment matching (the real TCAD layout) ---
|
||||
test('attachments match by board folder and <name>_<i>_<file> naming', () => {
|
||||
const board = {
|
||||
name: 'Liite Testi',
|
||||
cards: [
|
||||
{ attachments: [
|
||||
{ id: 'a1', name: 'koneet.jpeg', fileName: 'koneet.jpeg', url: 'https://trello.com/x/koneet.jpeg' },
|
||||
{ id: 'a2', name: 'ai-modern-infra2.jpg', fileName: 'ai-modern-infra2.jpg', url: 'https://trello.com/y/ai-modern-infra2.jpg' },
|
||||
] },
|
||||
],
|
||||
actions: [
|
||||
{ type: 'addAttachmentToCard', data: { attachment: { id: 'a1', name: 'koneet.jpeg', url: 'https://trello.com/x/koneet.jpeg' } } },
|
||||
],
|
||||
};
|
||||
const fileEntries = [
|
||||
{ base: 'Liite Testi_1_ai-modern-infra2.jpg', dirSegs: ['Liite Testi'], used: false },
|
||||
{ base: 'Liite Testi_2_koneet.jpeg', dirSegs: ['Liite Testi'], used: false },
|
||||
];
|
||||
const matched = matchBoardAttachments(board, fileEntries, 1);
|
||||
assert.strictEqual(matched, 2, 'both attachments matched');
|
||||
assert.ok(fileEntries.every(e => e.used), 'both files consumed');
|
||||
});
|
||||
|
||||
test('a single board with no folder falls back to all files', () => {
|
||||
const board = {
|
||||
name: 'Solo',
|
||||
cards: [{ attachments: [{ id: 'a1', name: 'pic.png', fileName: 'pic.png', url: 'u' }] }],
|
||||
};
|
||||
const fileEntries = [{ base: 'pic.png', dirSegs: [], used: false }];
|
||||
assert.strictEqual(matchBoardAttachments(board, fileEntries, 1), 1);
|
||||
});
|
||||
|
||||
test('multiple boards keep attachments scoped to their own folders', () => {
|
||||
const boardA = { name: 'Alpha', cards: [{ attachments: [{ id: 'a', name: 'shared.png', fileName: 'shared.png', url: 'u' }] }] };
|
||||
const boardB = { name: 'Beta', cards: [{ attachments: [{ id: 'b', name: 'shared.png', fileName: 'shared.png', url: 'u' }] }] };
|
||||
const fileEntries = [
|
||||
{ base: 'Alpha_1_shared.png', dirSegs: ['Alpha'], used: false },
|
||||
{ base: 'Beta_1_shared.png', dirSegs: ['Beta'], used: false },
|
||||
];
|
||||
assert.strictEqual(matchBoardAttachments(boardA, fileEntries, 2), 1);
|
||||
assert.strictEqual(matchBoardAttachments(boardB, fileEntries, 2), 1);
|
||||
// Each board consumed its own file, not the other's.
|
||||
assert.ok(fileEntries.find(e => e.base === 'Alpha_1_shared.png').used);
|
||||
assert.ok(fileEntries.find(e => e.base === 'Beta_1_shared.png').used);
|
||||
});
|
||||
|
||||
test('attached links (name === url) are not treated as files', () => {
|
||||
const board = { name: 'B', cards: [{ attachments: [{ id: 'l', name: 'https://example.com', url: 'https://example.com' }] }] };
|
||||
assert.strictEqual(collectLogicalAttachments(board).size, 0);
|
||||
});
|
||||
|
||||
// --- board JSON detection ---
|
||||
test('looksLikeBoard accepts exports, rejects junk', () => {
|
||||
assert.ok(looksLikeBoard({ name: 'B', cards: [] }));
|
||||
assert.ok(looksLikeBoard({ name: 'B', actions: [] }));
|
||||
assert.ok(!looksLikeBoard({ cards: [] })); // no name
|
||||
assert.ok(!looksLikeBoard({ name: 'B' })); // no cards/lists/actions
|
||||
assert.ok(!looksLikeBoard(null));
|
||||
});
|
||||
|
||||
if (typeof describe === 'function') {
|
||||
describe('Trello zip import', () => {
|
||||
tests.forEach(([name, fn]) => it(name, fn));
|
||||
});
|
||||
} else {
|
||||
console.log('Trello zip import tests\n');
|
||||
let failed = 0;
|
||||
tests.forEach(([name, fn]) => {
|
||||
try {
|
||||
fn();
|
||||
console.log(`✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`✗ ${name}\n ${e.message}`);
|
||||
}
|
||||
});
|
||||
console.log(`\n${tests.length - failed}/${tests.length} passed`);
|
||||
if (failed > 0) process.exitCode = 1;
|
||||
}
|
||||
Reference in new issue
Block a user