- 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
969 lines
31 KiB
JavaScript
969 lines
31 KiB
JavaScript
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
|
import { Tracker } from 'meteor/tracker';
|
|
import { findWhere, where, uniqBy, groupBy, indexBy, debounce, once } from '/imports/lib/collectionHelpers';
|
|
import Settings from '/models/settings';
|
|
import Users from '/models/users';
|
|
|
|
export const Utils = {
|
|
async setBackgroundImage(url) {
|
|
const currentBoard = Utils.getCurrentBoard();
|
|
if (currentBoard.backgroundImageURL !== undefined) {
|
|
$(".board-wrapper").css({"background":"url(" + currentBoard.backgroundImageURL + ")","background-size":"cover"});
|
|
$(".swimlane,.swimlane .list,.swimlane .list .list-body,.swimlane .list:first-child .list-body").css({"background-color":"transparent"});
|
|
$(".minicard").css({"opacity": "0.9"});
|
|
} else if (currentBoard["background-color"]) {
|
|
await currentBoard.setColor(currentBoard["background-color"]);
|
|
}
|
|
},
|
|
/** returns the current board id
|
|
* <li> returns the current board id or the board id of the popup card if set
|
|
*/
|
|
getCurrentBoardId() {
|
|
let popupCardBoardId = Session.get('popupCardBoardId');
|
|
let currentBoard = Session.get('currentBoard');
|
|
let ret = currentBoard;
|
|
if (popupCardBoardId) {
|
|
ret = popupCardBoardId;
|
|
}
|
|
return ret;
|
|
},
|
|
getCurrentCardId(ignorePopupCard) {
|
|
let ret = Session.get('currentCard');
|
|
if (!ret && !ignorePopupCard) {
|
|
ret = Utils.getPopupCardId();
|
|
}
|
|
return ret;
|
|
},
|
|
getPopupCardId() {
|
|
const ret = Session.get('popupCardId');
|
|
return ret;
|
|
},
|
|
getCurrentListId() {
|
|
const ret = Session.get('currentList');
|
|
return ret;
|
|
},
|
|
/** returns the current board
|
|
* <li> returns the current board or the board of the popup card if set
|
|
*/
|
|
getCurrentBoard() {
|
|
const boardId = Utils.getCurrentBoardId();
|
|
const ret = ReactiveCache.getBoard(boardId);
|
|
return ret;
|
|
},
|
|
getCurrentCard(ignorePopupCard) {
|
|
const cardId = Utils.getCurrentCardId(ignorePopupCard);
|
|
if (!cardId) {
|
|
return null;
|
|
}
|
|
const ret = ReactiveCache.getCard(cardId);
|
|
return ret;
|
|
},
|
|
|
|
// Zoom and mobile mode utilities
|
|
getZoomLevel() {
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user && user.profile && user.profile.zoomLevel !== undefined) {
|
|
return user.profile.zoomLevel;
|
|
}
|
|
// For non-logged-in users, check localStorage
|
|
const stored = localStorage.getItem('wekan-zoom-level');
|
|
return stored ? parseFloat(stored) : 1.0;
|
|
},
|
|
|
|
setZoomLevel(level) {
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user) {
|
|
// Update user profile
|
|
user.setZoomLevel(level);
|
|
} else {
|
|
// Store in localStorage for non-logged-in users
|
|
localStorage.setItem('wekan-zoom-level', level.toString());
|
|
}
|
|
Utils.applyZoomLevel(level);
|
|
|
|
// Trigger reactive updates for UI components
|
|
Session.set('wekan-zoom-level', level);
|
|
},
|
|
|
|
getMobileMode() {
|
|
// Check localStorage first - user's explicit preference takes priority
|
|
const stored = localStorage.getItem('wekan-mobile-mode');
|
|
if (stored !== null) {
|
|
return stored === 'true';
|
|
}
|
|
|
|
// Then check user profile
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user && user.profile && user.profile.mobileMode !== undefined) {
|
|
return user.profile.mobileMode;
|
|
}
|
|
|
|
// No explicit preference: pick a sensible default from the device.
|
|
// Previously this returned true ONLY for iPhone/iPod, so every other phone
|
|
// (Fairphone 4 postmarketOS Firefox, Ubuntu Touch Morph, Android Firefox,
|
|
// ...) defaulted to desktop-mode. desktop-mode applies large desktop sizing
|
|
// (see .desktop-mode rules in boardHeader.css) which on a small phone screen
|
|
// looks roughly 2x too big. Default to mobile-mode for any phone-sized touch
|
|
// device, not just iPhone, so non-iPhone phones render at the correct size.
|
|
const ua = navigator.userAgent;
|
|
const isIPhone = /iPhone|iPod/i.test(ua);
|
|
const isPhoneUA =
|
|
/Mobile|Android|BlackBerry|IEMobile|Opera Mini/i.test(ua) &&
|
|
!/iPad/i.test(ua);
|
|
const isUbuntuTouch = /Ubuntu/i.test(ua);
|
|
const hasTouch =
|
|
('maxTouchPoints' in navigator && navigator.maxTouchPoints > 0) ||
|
|
(window.matchMedia && window.matchMedia('(pointer: coarse)').matches);
|
|
// A small touch screen (phone-sized) — covers touch browsers whose UA does
|
|
// not contain a phone token. Desktop browsers (no touch, or wide) stay in
|
|
// desktop-mode.
|
|
const isSmallTouchScreen = hasTouch && window.innerWidth <= 800;
|
|
return isIPhone || isPhoneUA || isUbuntuTouch || isSmallTouchScreen;
|
|
},
|
|
|
|
setMobileMode(enabled) {
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user) {
|
|
// Update user profile
|
|
user.setMobileMode(enabled);
|
|
}
|
|
// Always store in localStorage for persistence across sessions
|
|
localStorage.setItem('wekan-mobile-mode', enabled.toString());
|
|
Utils.applyMobileMode(enabled);
|
|
// Trigger reactive updates for UI components
|
|
Session.set('wekan-mobile-mode', enabled);
|
|
// Re-apply zoom level to ensure proper rendering
|
|
const zoomLevel = Utils.getZoomLevel();
|
|
Utils.applyZoomLevel(zoomLevel);
|
|
},
|
|
|
|
getCardZoom() {
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user && user.profile && user.profile.cardZoom !== undefined) {
|
|
return user.profile.cardZoom;
|
|
}
|
|
const stored = localStorage.getItem('wekan-card-zoom');
|
|
return stored ? parseFloat(stored) : 1.0;
|
|
},
|
|
|
|
setCardZoom(level) {
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user) {
|
|
user.setCardZoom(level);
|
|
}
|
|
localStorage.setItem('wekan-card-zoom', level.toString());
|
|
Utils.applyCardZoom(level);
|
|
Session.set('wekan-card-zoom', level);
|
|
},
|
|
|
|
applyCardZoom(level) {
|
|
const cardDetails = document.querySelector('.card-details');
|
|
if (cardDetails) {
|
|
cardDetails.style.fontSize = `${level}em`;
|
|
}
|
|
},
|
|
|
|
applyZoomLevel(level) {
|
|
const boardWrapper = document.querySelector('.board-wrapper');
|
|
const body = document.body;
|
|
const isMobileMode = body.classList.contains('mobile-mode');
|
|
|
|
if (boardWrapper) {
|
|
if (isMobileMode) {
|
|
// On mobile mode, only apply zoom to text and icons, not the entire layout
|
|
// Remove any existing transform from board-wrapper
|
|
boardWrapper.style.transform = '';
|
|
boardWrapper.style.transformOrigin = '';
|
|
|
|
// Apply zoom to text and icon elements instead
|
|
const textElements = boardWrapper.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, div, .minicard, .list-header-name, .board-header-btn, .fa, .icon');
|
|
textElements.forEach(element => {
|
|
element.style.transform = `scale(${level})`;
|
|
element.style.transformOrigin = 'center';
|
|
});
|
|
|
|
// Reset board-canvas height
|
|
const boardCanvas = document.querySelector('.board-canvas');
|
|
if (boardCanvas) {
|
|
boardCanvas.style.height = '';
|
|
}
|
|
} else {
|
|
// 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) {
|
|
boardWrapper.style.width = '100%';
|
|
boardWrapper.style.maxWidth = '100%';
|
|
boardWrapper.style.margin = '0';
|
|
} else {
|
|
// Reset to normal width for higher zoom levels
|
|
boardWrapper.style.width = '';
|
|
boardWrapper.style.maxWidth = '';
|
|
boardWrapper.style.margin = '';
|
|
}
|
|
|
|
// Adjust container height to prevent scroll issues
|
|
const boardCanvas = document.querySelector('.board-canvas');
|
|
if (boardCanvas) {
|
|
boardCanvas.style.height = `${100 / level}%`;
|
|
|
|
// For high zoom levels (200%+), enable both horizontal and vertical scrolling
|
|
if (level >= 2.0) {
|
|
boardCanvas.style.overflowX = 'auto';
|
|
boardCanvas.style.overflowY = 'auto';
|
|
// Ensure the content area can scroll both horizontally and vertically
|
|
const content = document.querySelector('#content');
|
|
if (content) {
|
|
content.style.overflowX = 'auto';
|
|
content.style.overflowY = 'auto';
|
|
}
|
|
} else {
|
|
// Reset overflow for normal zoom levels
|
|
boardCanvas.style.overflowX = '';
|
|
boardCanvas.style.overflowY = '';
|
|
const content = document.querySelector('#content');
|
|
if (content) {
|
|
content.style.overflowX = '';
|
|
content.style.overflowY = '';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
applyMobileMode(enabled) {
|
|
const body = document.body;
|
|
if (enabled) {
|
|
body.classList.add('mobile-mode');
|
|
body.classList.remove('desktop-mode');
|
|
} else {
|
|
body.classList.add('desktop-mode');
|
|
body.classList.remove('mobile-mode');
|
|
}
|
|
},
|
|
|
|
initializeUserSettings() {
|
|
// Apply saved settings on page load
|
|
const zoomLevel = Utils.getZoomLevel();
|
|
const mobileMode = Utils.getMobileMode();
|
|
Utils.applyZoomLevel(zoomLevel);
|
|
Utils.applyMobileMode(mobileMode);
|
|
},
|
|
getCurrentList() {
|
|
const listId = this.getCurrentListId();
|
|
let ret = null;
|
|
if (listId) {
|
|
ret = ReactiveCache.getList(listId);
|
|
}
|
|
return ret;
|
|
},
|
|
getPopupCard() {
|
|
const cardId = Utils.getPopupCardId();
|
|
const ret = ReactiveCache.getCard(cardId);
|
|
return ret;
|
|
},
|
|
canModifyCard() {
|
|
const currentUser = ReactiveCache.getCurrentUser();
|
|
const ret = (
|
|
currentUser &&
|
|
currentUser.isBoardMember() &&
|
|
!currentUser.isCommentOnly() &&
|
|
!currentUser.isWorker() &&
|
|
!currentUser.isReadOnly() &&
|
|
!currentUser.isReadAssignedOnly()
|
|
);
|
|
return ret;
|
|
},
|
|
canMoveCard() {
|
|
const currentUser = ReactiveCache.getCurrentUser();
|
|
const ret = (
|
|
currentUser &&
|
|
currentUser.isBoardMember() &&
|
|
!currentUser.isCommentOnly() &&
|
|
!currentUser.isReadOnly() &&
|
|
!currentUser.isReadAssignedOnly()
|
|
);
|
|
return ret;
|
|
},
|
|
canModifyBoard() {
|
|
const currentUser = ReactiveCache.getCurrentUser();
|
|
const ret = (
|
|
currentUser &&
|
|
currentUser.isBoardMember() &&
|
|
!currentUser.isCommentOnly() &&
|
|
!currentUser.isReadOnly() &&
|
|
!currentUser.isReadAssignedOnly()
|
|
);
|
|
return ret;
|
|
},
|
|
reload() {
|
|
// we move all window.location.reload calls into this function
|
|
// so we can disable it when running tests.
|
|
// This is because we are not allowed to override location.reload but
|
|
// we can override Utils.reload to prevent reload during tests.
|
|
window.location.reload();
|
|
},
|
|
setBoardView(view) {
|
|
const currentUser = ReactiveCache.getCurrentUser();
|
|
|
|
if (currentUser) {
|
|
// Update localStorage first
|
|
window.localStorage.setItem('boardView', view);
|
|
|
|
// Update user profile via Meteor method
|
|
Meteor.call('setBoardView', view, (error) => {
|
|
if (error) {
|
|
console.error('[setBoardView] Update failed:', error);
|
|
} else {
|
|
// Reload to apply the view change
|
|
Utils.reload();
|
|
}
|
|
});
|
|
} else if (view === 'board-view-swimlanes') {
|
|
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
|
|
Utils.reload();
|
|
} else if (view === 'board-view-lists') {
|
|
window.localStorage.setItem('boardView', 'board-view-lists'); //true
|
|
Utils.reload();
|
|
} else if (view === 'board-view-cal') {
|
|
window.localStorage.setItem('boardView', 'board-view-cal'); //true
|
|
Utils.reload();
|
|
} else if (view === 'board-view-gantt') {
|
|
window.localStorage.setItem('boardView', 'board-view-gantt'); //true
|
|
Utils.reload();
|
|
} else {
|
|
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
|
|
Utils.reload();
|
|
}
|
|
},
|
|
|
|
unsetBoardView() {
|
|
window.localStorage.removeItem('boardView');
|
|
window.localStorage.removeItem('collapseSwimlane');
|
|
},
|
|
|
|
boardView() {
|
|
const currentUser = ReactiveCache.getCurrentUser();
|
|
if (currentUser) {
|
|
return (currentUser.profile || {}).boardView;
|
|
} else if (
|
|
window.localStorage.getItem('boardView') === 'board-view-swimlanes'
|
|
) {
|
|
return 'board-view-swimlanes';
|
|
} else if (
|
|
window.localStorage.getItem('boardView') === 'board-view-lists'
|
|
) {
|
|
return 'board-view-lists';
|
|
} else if (window.localStorage.getItem('boardView') === 'board-view-cal') {
|
|
return 'board-view-cal';
|
|
} else if (window.localStorage.getItem('boardView') === 'board-view-gantt') {
|
|
return 'board-view-gantt';
|
|
} else {
|
|
window.localStorage.setItem('boardView', 'board-view-swimlanes'); //true
|
|
Utils.reload();
|
|
return 'board-view-swimlanes';
|
|
}
|
|
},
|
|
|
|
getListCollapseState(list) {
|
|
if (!list) return false;
|
|
const key = `collapsedList-${list._id}`;
|
|
const sessionVal = Session.get(key);
|
|
if (typeof sessionVal === 'boolean') {
|
|
return sessionVal;
|
|
}
|
|
|
|
const user = ReactiveCache.getCurrentUser();
|
|
let stored = null;
|
|
if (user && user.getCollapsedListFromStorage) {
|
|
stored = user.getCollapsedListFromStorage(list.boardId, list._id);
|
|
} else if (Users.getPublicCollapsedList) {
|
|
stored = Users.getPublicCollapsedList(list.boardId, list._id);
|
|
}
|
|
|
|
if (typeof stored === 'boolean') {
|
|
Session.setDefault(key, stored);
|
|
return stored;
|
|
}
|
|
|
|
const fallback = typeof list.collapsed === 'boolean' ? list.collapsed : false;
|
|
Session.setDefault(key, fallback);
|
|
return fallback;
|
|
},
|
|
|
|
setListCollapseState(list, collapsed) {
|
|
if (!list) return;
|
|
const key = `collapsedList-${list._id}`;
|
|
Session.set(key, !!collapsed);
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user) {
|
|
Meteor.call('setListCollapsedState', list.boardId, list._id, !!collapsed);
|
|
} else if (Users.setPublicCollapsedList) {
|
|
Users.setPublicCollapsedList(list.boardId, list._id, !!collapsed);
|
|
}
|
|
},
|
|
|
|
getSwimlaneCollapseState(swimlane) {
|
|
if (!swimlane) return false;
|
|
const key = `collapsedSwimlane-${swimlane._id}`;
|
|
const sessionVal = Session.get(key);
|
|
if (typeof sessionVal === 'boolean') {
|
|
return sessionVal;
|
|
}
|
|
|
|
const user = ReactiveCache.getCurrentUser();
|
|
let stored = null;
|
|
if (user && user.getCollapsedSwimlaneFromStorage) {
|
|
stored = user.getCollapsedSwimlaneFromStorage(
|
|
swimlane.boardId,
|
|
swimlane._id,
|
|
);
|
|
} else if (Users.getPublicCollapsedSwimlane) {
|
|
stored = Users.getPublicCollapsedSwimlane(swimlane.boardId, swimlane._id);
|
|
}
|
|
|
|
if (typeof stored === 'boolean') {
|
|
Session.setDefault(key, stored);
|
|
return stored;
|
|
}
|
|
|
|
const fallback = typeof swimlane.collapsed === 'boolean' ? swimlane.collapsed : false;
|
|
Session.setDefault(key, fallback);
|
|
return fallback;
|
|
},
|
|
|
|
setSwimlaneCollapseState(swimlane, collapsed) {
|
|
if (!swimlane) return;
|
|
const key = `collapsedSwimlane-${swimlane._id}`;
|
|
Session.set(key, !!collapsed);
|
|
const user = ReactiveCache.getCurrentUser();
|
|
if (user) {
|
|
Meteor.call('setSwimlaneCollapsedState', swimlane.boardId, swimlane._id, !!collapsed);
|
|
} else if (Users.setPublicCollapsedSwimlane) {
|
|
Users.setPublicCollapsedSwimlane(swimlane.boardId, swimlane._id, !!collapsed);
|
|
}
|
|
},
|
|
|
|
myCardsSort() {
|
|
let sort = window.localStorage.getItem('myCardsSort');
|
|
|
|
if (!sort || !['board', 'dueAt'].includes(sort)) {
|
|
sort = 'board';
|
|
}
|
|
|
|
return sort;
|
|
},
|
|
|
|
myCardsSortToggle() {
|
|
if (this.myCardsSort() === 'board') {
|
|
this.setMyCardsSort('dueAt');
|
|
} else {
|
|
this.setMyCardsSort('board');
|
|
}
|
|
},
|
|
|
|
setMyCardsSort(sort) {
|
|
window.localStorage.setItem('myCardsSort', sort);
|
|
Utils.reload();
|
|
},
|
|
|
|
archivedBoardIds() {
|
|
const ret = ReactiveCache.getBoards({ archived: false }).map(board => board._id);
|
|
return ret;
|
|
},
|
|
|
|
dueCardsView() {
|
|
let view = window.localStorage.getItem('dueCardsView');
|
|
|
|
if (!view || !['me', 'all'].includes(view)) {
|
|
view = 'me';
|
|
}
|
|
|
|
return view;
|
|
},
|
|
|
|
setDueCardsView(view) {
|
|
window.localStorage.setItem('dueCardsView', view);
|
|
Utils.reload();
|
|
},
|
|
|
|
myCardsView() {
|
|
let view = window.localStorage.getItem('myCardsView');
|
|
|
|
if (!view || !['boards', 'table'].includes(view)) {
|
|
view = 'boards';
|
|
}
|
|
|
|
return view;
|
|
},
|
|
|
|
setMyCardsView(view) {
|
|
window.localStorage.setItem('myCardsView', view);
|
|
Utils.reload();
|
|
},
|
|
|
|
// XXX We should remove these two methods
|
|
goBoardId(_id) {
|
|
const board = ReactiveCache.getBoard(_id);
|
|
return (
|
|
board &&
|
|
FlowRouter.go('board', {
|
|
id: board._id,
|
|
slug: board.slug,
|
|
})
|
|
);
|
|
},
|
|
|
|
goCardId(_id) {
|
|
const card = ReactiveCache.getCard(_id);
|
|
const board = ReactiveCache.getBoard(card.boardId);
|
|
return (
|
|
board &&
|
|
FlowRouter.go('card', {
|
|
cardId: card._id,
|
|
boardId: board._id,
|
|
slug: board.slug,
|
|
swimlaneId: card.swimlaneId,
|
|
listId: card.listId,
|
|
})
|
|
);
|
|
},
|
|
getCommonAttachmentMetaFrom(card) {
|
|
const meta = {};
|
|
if (card.isLinkedCard()) {
|
|
meta.boardId = ReactiveCache.getCard(card.linkedId).boardId;
|
|
meta.cardId = card.linkedId;
|
|
} else {
|
|
meta.boardId = card.boardId;
|
|
meta.swimlaneId = card.swimlaneId;
|
|
meta.listId = card.listId;
|
|
meta.cardId = card._id;
|
|
}
|
|
return meta;
|
|
},
|
|
// Collection helpers (replacing underscore.js)
|
|
findWhere,
|
|
where,
|
|
uniqBy,
|
|
groupBy,
|
|
indexBy,
|
|
debounce,
|
|
once,
|
|
|
|
MAX_IMAGE_PIXEL: Meteor.settings.public.MAX_IMAGE_PIXEL,
|
|
COMPRESS_RATIO: Meteor.settings.public.IMAGE_COMPRESS_RATIO,
|
|
shrinkImage(options) {
|
|
// shrink image to certain size
|
|
const dataurl = options.dataurl,
|
|
callback = options.callback,
|
|
toBlob = options.toBlob;
|
|
let canvas = document.createElement('canvas'),
|
|
image = document.createElement('img');
|
|
const maxSize = options.maxSize || 1024;
|
|
const ratio = options.ratio || 1.0;
|
|
const next = function (result) {
|
|
image = null;
|
|
canvas = null;
|
|
if (typeof callback === 'function') {
|
|
callback(result);
|
|
}
|
|
};
|
|
image.onload = function () {
|
|
let width = this.width,
|
|
height = this.height;
|
|
let changed = false;
|
|
if (width > height) {
|
|
if (width > maxSize) {
|
|
height *= maxSize / width;
|
|
width = maxSize;
|
|
changed = true;
|
|
}
|
|
} else if (height > maxSize) {
|
|
width *= maxSize / height;
|
|
height = maxSize;
|
|
changed = true;
|
|
}
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
canvas.getContext('2d').drawImage(this, 0, 0, width, height);
|
|
if (changed === true) {
|
|
const type = 'image/jpeg';
|
|
if (toBlob) {
|
|
canvas.toBlob(next, type, ratio);
|
|
} else {
|
|
next(canvas.toDataURL(type, ratio));
|
|
}
|
|
} else {
|
|
next(changed);
|
|
}
|
|
};
|
|
image.onerror = function () {
|
|
next(false);
|
|
};
|
|
image.src = dataurl;
|
|
},
|
|
capitalize(string) {
|
|
return string.charAt(0).toUpperCase() + string.slice(1);
|
|
},
|
|
|
|
windowResizeDep: new Tracker.Dependency(),
|
|
// in fact, what we really care is screen size
|
|
// large mobile device like iPad or android Pad has a big screen, it should also behave like a desktop
|
|
// in a small window (even on desktop), Wekan run in compact mode.
|
|
// we can easily debug with a small window of desktop browser. :-)
|
|
isMiniScreen() {
|
|
this.windowResizeDep.depend();
|
|
// Also depend on mobile mode changes to make this reactive
|
|
Session.get('wekan-mobile-mode');
|
|
|
|
// Show mobile view when:
|
|
// 1. Screen width is 800px or less (matches CSS media queries)
|
|
// 2. Mobile phones in portrait mode
|
|
// 3. iPad in very small screens (≤ 600px)
|
|
// 4. All iPhone models by default (including largest models), but respect user preference
|
|
const isSmallScreen = window.innerWidth <= 800;
|
|
const isVerySmallScreen = window.innerWidth <= 600;
|
|
const isPortrait = window.innerWidth < window.innerHeight || window.matchMedia("(orientation: portrait)").matches;
|
|
const isMobilePhone = /Mobile|Android|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) && !/iPad/i.test(navigator.userAgent);
|
|
const isIPhone = /iPhone|iPod/i.test(navigator.userAgent);
|
|
const isIPad = /iPad/i.test(navigator.userAgent);
|
|
const isUbuntuTouch = /Ubuntu/i.test(navigator.userAgent);
|
|
|
|
// Check if user has explicitly set mobile mode preference
|
|
const userMobileMode = this.getMobileMode();
|
|
|
|
// For iPhone: default to mobile view, but respect user's mobile mode toggle preference
|
|
// This ensures all iPhone models (including iPhone 15 Pro Max, 14 Pro Max, etc.) start with mobile view
|
|
// but users can still switch to desktop mode if they prefer
|
|
if (isIPhone) {
|
|
// If user has explicitly set a preference, respect it
|
|
if (userMobileMode !== null && userMobileMode !== undefined) {
|
|
return userMobileMode;
|
|
}
|
|
// Otherwise, default to mobile view for iPhones
|
|
return true;
|
|
} else if (isMobilePhone) {
|
|
return isPortrait; // Other mobile phones: portrait = mobile, landscape = desktop
|
|
} else if (isIPad) {
|
|
return isVerySmallScreen; // iPad: only very small screens get mobile view
|
|
} else if (isUbuntuTouch) {
|
|
// Ubuntu Touch: smartphones (≤ 600px) behave like mobile phones, tablets (> 600px) like iPad
|
|
if (isVerySmallScreen) {
|
|
return isPortrait; // Ubuntu Touch smartphone: portrait = mobile, landscape = desktop
|
|
} else {
|
|
return isVerySmallScreen; // Ubuntu Touch tablet: only very small screens get mobile view
|
|
}
|
|
} else {
|
|
return isSmallScreen; // Desktop: based on 800px screen width
|
|
}
|
|
},
|
|
|
|
isTouchScreen() {
|
|
// NEW TOUCH DEVICE DETECTION:
|
|
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Browser_detection_using_the_user_agent
|
|
var hasTouchScreen = false;
|
|
if ("maxTouchPoints" in navigator) {
|
|
hasTouchScreen = navigator.maxTouchPoints > 0;
|
|
} else if ("msMaxTouchPoints" in navigator) {
|
|
hasTouchScreen = navigator.msMaxTouchPoints > 0;
|
|
} else {
|
|
var mQ = window.matchMedia && matchMedia("(pointer:coarse)");
|
|
if (mQ && mQ.media === "(pointer:coarse)") {
|
|
hasTouchScreen = !!mQ.matches;
|
|
} else if ('orientation' in window) {
|
|
hasTouchScreen = true; // deprecated, but good fallback
|
|
} else {
|
|
// Only as a last resort, fall back to user agent sniffing
|
|
var UA = navigator.userAgent;
|
|
hasTouchScreen = (
|
|
/\b(BlackBerry|webOS|iPhone|IEMobile)\b/i.test(UA) ||
|
|
/\b(Android|Windows Phone|iPad|iPod)\b/i.test(UA)
|
|
);
|
|
}
|
|
}
|
|
return hasTouchScreen;
|
|
},
|
|
|
|
// returns if desktop drag handles are enabled
|
|
isShowDesktopDragHandles() {
|
|
const currentUser = Meteor.user();
|
|
if (currentUser) {
|
|
return currentUser.hasShowDesktopDragHandles();
|
|
} else {
|
|
// For non-logged-in users, check localStorage
|
|
return window.localStorage.getItem('showDesktopDragHandles') === 'true';
|
|
}
|
|
},
|
|
|
|
// returns if mini screen or desktop drag handles
|
|
isTouchScreenOrShowDesktopDragHandles() {
|
|
return Utils.isTouchScreen() || Utils.isShowDesktopDragHandles();
|
|
},
|
|
|
|
calculateIndexData(prevData, nextData, nItems = 1) {
|
|
let base, increment;
|
|
// If we drop the card to an empty column
|
|
if (!prevData && !nextData) {
|
|
base = 0;
|
|
increment = 1;
|
|
// If we drop the card in the first position
|
|
} else if (!prevData) {
|
|
const nextSortIndex = nextData.sort;
|
|
const ceil = Math.ceil(nextSortIndex - 1);
|
|
if (ceil < nextSortIndex) {
|
|
increment = nextSortIndex - ceil;
|
|
base = nextSortIndex - increment;
|
|
} else {
|
|
base = nextData.sort - 1;
|
|
increment = -1;
|
|
}
|
|
// If we drop the card in the last position
|
|
} else if (!nextData) {
|
|
const prevSortIndex = prevData.sort;
|
|
const floor = Math.floor(prevSortIndex + 1);
|
|
if (floor > prevSortIndex) {
|
|
increment = prevSortIndex - floor;
|
|
base = prevSortIndex - increment;
|
|
} else {
|
|
base = prevData.sort + 1;
|
|
increment = 1;
|
|
}
|
|
}
|
|
// In the general case take the average of the previous and next element
|
|
// sort indexes.
|
|
else {
|
|
const prevSortIndex = prevData.sort;
|
|
const nextSortIndex = nextData.sort;
|
|
if (nItems == 1 ) {
|
|
if (prevSortIndex < 0 ) {
|
|
const ceil = Math.ceil(nextSortIndex - 1);
|
|
if (ceil < nextSortIndex && ceil > prevSortIndex) {
|
|
increment = ceil - prevSortIndex;
|
|
}
|
|
} else {
|
|
const floor = Math.floor(nextSortIndex - 1);
|
|
if (floor < nextSortIndex && floor > prevSortIndex) {
|
|
increment = floor - prevSortIndex;
|
|
}
|
|
}
|
|
}
|
|
if (!increment) {
|
|
increment = (nextSortIndex - prevSortIndex) / (nItems + 1);
|
|
}
|
|
if (!base) {
|
|
base = prevSortIndex + increment;
|
|
}
|
|
}
|
|
// XXX Return a generator that yield values instead of a base with a
|
|
// increment number.
|
|
return {
|
|
base,
|
|
increment,
|
|
};
|
|
},
|
|
|
|
// Determine the new sort index
|
|
calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) {
|
|
let prevData = null;
|
|
let nextData = null;
|
|
if (prevCardDomElement) {
|
|
prevData = Blaze.getData(prevCardDomElement)
|
|
}
|
|
if (nextCardDomElement) {
|
|
nextData = Blaze.getData(nextCardDomElement);
|
|
}
|
|
const ret = Utils.calculateIndexData(prevData, nextData, nCards);
|
|
return ret;
|
|
},
|
|
|
|
manageCustomUI() {
|
|
// Subscribe to custom UI settings (published from server)
|
|
Meteor.subscribe('customUI');
|
|
// Reactive helper will be called when Settings data changes
|
|
Tracker.autorun(() => {
|
|
const settings = Settings.findOne({});
|
|
if (settings) {
|
|
Utils.setCustomUI(settings);
|
|
}
|
|
});
|
|
},
|
|
|
|
setCustomUI(data) {
|
|
const productName = (data && data.productName) ? data.productName : 'Wekan';
|
|
const currentBoard = Utils.getCurrentBoard();
|
|
if (currentBoard) {
|
|
document.title = `${currentBoard.title} - ${productName}`;
|
|
} else {
|
|
document.title = productName;
|
|
}
|
|
},
|
|
|
|
setMatomo(data) {
|
|
window._paq = window._paq || [];
|
|
window._paq.push(['setDoNotTrack', data.doNotTrack]);
|
|
if (data.withUserName) {
|
|
window._paq.push(['setUserId', ReactiveCache.getCurrentUser().username]);
|
|
}
|
|
window._paq.push(['trackPageView']);
|
|
window._paq.push(['enableLinkTracking']);
|
|
|
|
(function () {
|
|
window._paq.push(['setTrackerUrl', `${data.address}piwik.php`]);
|
|
window._paq.push(['setSiteId', data.siteId]);
|
|
|
|
const script = document.createElement('script');
|
|
Object.assign(script, {
|
|
id: 'scriptMatomo',
|
|
type: 'text/javascript',
|
|
async: 'true',
|
|
defer: 'true',
|
|
src: `${data.address}piwik.js`,
|
|
});
|
|
|
|
const s = document.getElementsByTagName('script')[0];
|
|
s.parentNode.insertBefore(script, s);
|
|
})();
|
|
|
|
Session.set('matomo', true);
|
|
},
|
|
|
|
manageMatomo() {
|
|
// Subscribe to Matomo configuration (published from server)
|
|
Meteor.subscribe('matomoConfig');
|
|
// Reactive helper will be called when Settings data changes
|
|
Tracker.autorun(() => {
|
|
const matomo = Session.get('matomo');
|
|
if (matomo === undefined) {
|
|
const settings = Settings.findOne({});
|
|
if (settings && settings.matomoURL && settings.matomoSiteId) {
|
|
const matomoConfig = {
|
|
address: settings.matomoURL,
|
|
siteId: settings.matomoSiteId,
|
|
doNotTrack: settings.matomoDoNotTrack || false,
|
|
withUserName: settings.matomoWithUserName || false
|
|
};
|
|
Utils.setMatomo(matomoConfig);
|
|
} else {
|
|
Session.set('matomo', false);
|
|
}
|
|
} else if (matomo) {
|
|
window._paq = window._paq || [];
|
|
window._paq.push(['trackPageView']);
|
|
}
|
|
});
|
|
},
|
|
|
|
getTriggerActionDesc(event, tempInstance) {
|
|
const jqueryEl = tempInstance.$(event.currentTarget.parentNode);
|
|
const triggerEls = jqueryEl.find('.trigger-content').children();
|
|
let finalString = '';
|
|
for (let i = 0; i < triggerEls.length; i++) {
|
|
const element = tempInstance.$(triggerEls[i]);
|
|
if (element.hasClass('trigger-text')) {
|
|
finalString += element.text().toLowerCase();
|
|
} else if (element.hasClass('user-details')) {
|
|
let username = element.find('input').val();
|
|
if (username === undefined || username === '') {
|
|
username = '*';
|
|
}
|
|
finalString += `${element
|
|
.find('.trigger-text')
|
|
.text()
|
|
.toLowerCase()} ${username}`;
|
|
} else if (element.find('select').length > 0) {
|
|
finalString += element
|
|
.find('select option:selected')
|
|
.text()
|
|
.toLowerCase();
|
|
} else if (element.find('input').length > 0) {
|
|
let inputvalue = element.find('input').val();
|
|
if (inputvalue === undefined || inputvalue === '') {
|
|
inputvalue = '*';
|
|
}
|
|
finalString += inputvalue;
|
|
}
|
|
// Add space
|
|
if (i !== length - 1) {
|
|
finalString += ' ';
|
|
}
|
|
}
|
|
return finalString;
|
|
},
|
|
|
|
fallbackCopyTextToClipboard(text) {
|
|
var textArea = document.createElement("textarea");
|
|
textArea.value = text;
|
|
|
|
// Avoid scrolling to bottom
|
|
textArea.style.top = "0";
|
|
textArea.style.left = "0";
|
|
textArea.style.position = "fixed";
|
|
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
|
|
try {
|
|
document.execCommand('copy');
|
|
return Promise.resolve(true);
|
|
} catch (e) {
|
|
return Promise.reject(false);
|
|
} finally {
|
|
document.body.removeChild(textArea);
|
|
}
|
|
},
|
|
|
|
/** copy the text to the clipboard
|
|
* @see https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript/30810322#30810322
|
|
* @param string copy this text to the clipboard
|
|
* @return Promise
|
|
*/
|
|
copyTextToClipboard(text) {
|
|
let ret;
|
|
if (navigator.clipboard) {
|
|
ret = navigator.clipboard.writeText(text).then(function () {
|
|
}, function (err) {
|
|
console.error('Async: Could not copy text: ', err);
|
|
});
|
|
} else {
|
|
ret = Utils.fallbackCopyTextToClipboard(text);
|
|
}
|
|
return ret;
|
|
},
|
|
|
|
/** show the "copied!" message
|
|
* @param promise the promise of Utils.copyTextToClipboard
|
|
* @param $tooltip jQuery tooltip element
|
|
*/
|
|
showCopied(promise, $tooltip) {
|
|
if (promise) {
|
|
promise.then(() => {
|
|
$tooltip.show(100);
|
|
setTimeout(() => $tooltip.hide(100), 1000);
|
|
}, (err) => {
|
|
console.error("error: ", err);
|
|
});
|
|
}
|
|
},
|
|
};
|
|
|
|
// A simple tracker dependency that we invalidate every time the window is
|
|
// resized. This is used to reactively re-calculate the popup position in case
|
|
// of a window resize. This is the equivalent of a "Signal" in some other
|
|
// programming environments (eg, elm).
|
|
$(window).on('resize', () => Utils.windowResizeDep.changed());
|