Reverted New UI Design of WeKan v8.29 and added more fixes and performance improvements.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu 2026-02-08 00:48:39 +02:00
parent d152d8fc1b
commit 1b8b8d2eef
196 changed files with 17659 additions and 10028 deletions

View file

@ -392,7 +392,7 @@ if (Meteor.isServer) {
Notifications.getUsers(watchers).forEach((user) => {
// Skip if user is undefined or doesn't have an _id (e.g., deleted user or invalid ID)
if (!user || !user._id) return;
// Don't notify a user of their own behavior, EXCEPT for self-mentions
const isSelfMention = (user._id === userId && title === 'act-atUserComment');
if (user._id !== userId || isSelfMention) {

View file

@ -18,44 +18,44 @@ AttachmentStorageSettings.attachSchema(
defaultValue: STORAGE_NAME_FILESYSTEM,
label: 'Default Storage Backend'
},
// Storage backend configuration
storageConfig: {
type: Object,
optional: true,
label: 'Storage Configuration'
},
'storageConfig.filesystem': {
type: Object,
optional: true,
label: 'Filesystem Configuration'
},
'storageConfig.filesystem.enabled': {
type: Boolean,
defaultValue: true,
label: 'Filesystem Storage Enabled'
},
'storageConfig.filesystem.path': {
type: String,
optional: true,
label: 'Filesystem Storage Path'
},
'storageConfig.gridfs': {
type: Object,
optional: true,
label: 'GridFS Configuration'
},
'storageConfig.gridfs.enabled': {
type: Boolean,
defaultValue: true,
label: 'GridFS Storage Enabled'
},
// DISABLED: S3 storage configuration removed due to Node.js compatibility
/*
'storageConfig.s3': {
@ -63,81 +63,81 @@ AttachmentStorageSettings.attachSchema(
optional: true,
label: 'S3 Configuration'
},
'storageConfig.s3.enabled': {
type: Boolean,
defaultValue: false,
label: 'S3 Storage Enabled'
},
'storageConfig.s3.endpoint': {
type: String,
optional: true,
label: 'S3 Endpoint'
},
'storageConfig.s3.bucket': {
type: String,
optional: true,
label: 'S3 Bucket'
},
'storageConfig.s3.region': {
type: String,
optional: true,
label: 'S3 Region'
},
'storageConfig.s3.sslEnabled': {
type: Boolean,
defaultValue: true,
label: 'S3 SSL Enabled'
},
'storageConfig.s3.port': {
type: Number,
defaultValue: 443,
label: 'S3 Port'
},
*/
// Upload settings
uploadSettings: {
type: Object,
optional: true,
label: 'Upload Settings'
},
'uploadSettings.maxFileSize': {
type: Number,
optional: true,
label: 'Maximum File Size (bytes)'
},
'uploadSettings.allowedMimeTypes': {
type: Array,
optional: true,
label: 'Allowed MIME Types'
},
'uploadSettings.allowedMimeTypes.$': {
type: String,
label: 'MIME Type'
},
// Migration settings
migrationSettings: {
type: Object,
optional: true,
label: 'Migration Settings'
},
'migrationSettings.autoMigrate': {
type: Boolean,
defaultValue: false,
label: 'Auto Migrate to Default Storage'
},
'migrationSettings.batchSize': {
type: Number,
defaultValue: 10,
@ -145,7 +145,7 @@ AttachmentStorageSettings.attachSchema(
max: 100,
label: 'Migration Batch Size'
},
'migrationSettings.delayMs': {
type: Number,
defaultValue: 1000,
@ -153,7 +153,7 @@ AttachmentStorageSettings.attachSchema(
max: 10000,
label: 'Migration Delay (ms)'
},
'migrationSettings.cpuThreshold': {
type: Number,
defaultValue: 70,
@ -161,7 +161,7 @@ AttachmentStorageSettings.attachSchema(
max: 90,
label: 'CPU Threshold (%)'
},
// Metadata
createdAt: {
type: Date,
@ -176,7 +176,7 @@ AttachmentStorageSettings.attachSchema(
},
label: 'Created At'
},
updatedAt: {
type: Date,
autoValue() {
@ -186,13 +186,13 @@ AttachmentStorageSettings.attachSchema(
},
label: 'Updated At'
},
createdBy: {
type: String,
optional: true,
label: 'Created By'
},
updatedBy: {
type: String,
optional: true,
@ -207,11 +207,11 @@ AttachmentStorageSettings.helpers({
getDefaultStorage() {
return this.defaultStorage || STORAGE_NAME_FILESYSTEM;
},
// Check if storage backend is enabled
isStorageEnabled(storageName) {
if (!this.storageConfig) return false;
switch (storageName) {
case STORAGE_NAME_FILESYSTEM:
return this.storageConfig.filesystem?.enabled !== false;
@ -224,11 +224,11 @@ AttachmentStorageSettings.helpers({
return false;
}
},
// Get storage configuration
getStorageConfig(storageName) {
if (!this.storageConfig) return null;
switch (storageName) {
case STORAGE_NAME_FILESYSTEM:
return this.storageConfig.filesystem;
@ -241,12 +241,12 @@ AttachmentStorageSettings.helpers({
return null;
}
},
// Get upload settings
getUploadSettings() {
return this.uploadSettings || {};
},
// Get migration settings
getMigrationSettings() {
return this.migrationSettings || {};
@ -268,7 +268,7 @@ if (Meteor.isServer) {
}
let settings = AttachmentStorageSettings.findOne({});
if (!settings) {
// Create default settings
settings = {
@ -299,14 +299,14 @@ if (Meteor.isServer) {
createdBy: this.userId,
updatedBy: this.userId
};
AttachmentStorageSettings.insert(settings);
settings = AttachmentStorageSettings.findOne({});
}
return settings;
},
'updateAttachmentStorageSettings'(settings) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
@ -320,7 +320,7 @@ if (Meteor.isServer) {
// Validate settings
const schema = AttachmentStorageSettings.simpleSchema();
schema.validate(settings);
// Update settings
const result = AttachmentStorageSettings.upsert(
{},
@ -332,10 +332,10 @@ if (Meteor.isServer) {
}
}
);
return result;
},
'getDefaultAttachmentStorage'() {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
@ -344,7 +344,7 @@ if (Meteor.isServer) {
const settings = AttachmentStorageSettings.findOne({});
return settings ? settings.getDefaultStorage() : STORAGE_NAME_FILESYSTEM;
},
'setDefaultAttachmentStorage'(storageName) {
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
@ -369,7 +369,7 @@ if (Meteor.isServer) {
}
}
);
return result;
}
});

View file

@ -857,22 +857,12 @@ Boards.helpers({
);
},
listsInSwimlane(swimlaneId) {
return this.lists().filter(e => e.swimlaneId === swimlaneId);
},
/** returns the last list
* @returns Document the last list
*/
getLastList() {
req = { boardId: this._id };
if (this.swimlane && this.swimlane._id != this._id) {
req.swimlaneId = this.swimlane._id;
}
return ReactiveCache.getList(
req,
{ sort: { sort: 'desc' }
});
const ret = ReactiveCache.getList({ boardId: this._id }, { sort: { sort: 'desc' } });
return ret;
},
nullSortLists() {
@ -945,7 +935,8 @@ Boards.helpers({
activeMembers(){
// Depend on the users collection for reactivity when users are loaded
const memberUserIds = _.pluck(this.members, 'userId');
const dummy = Meteor.users.find({ _id: { $in: memberUserIds } }).count();
// Use findOne with limit for reactivity trigger instead of count() which loads all users
const dummy = Meteor.users.findOne({ _id: { $in: memberUserIds } }, { fields: { _id: 1 }, limit: 1 });
const members = _.filter(this.members, m => m.isActive === true);
// Group by userId to handle duplicates
const grouped = _.groupBy(members, 'userId');
@ -1154,7 +1145,10 @@ Boards.helpers({
searchBoards(term) {
check(term, Match.OneOf(String, null, undefined));
const query = { type: 'template-container', archived: false };
const query = { boardId: this._id };
query.type = 'cardType-linkedBoard';
query.archived = false;
const projection = { limit: 10, sort: { createdAt: -1 } };
if (term) {
@ -1163,7 +1157,7 @@ Boards.helpers({
query.$or = [{ title: regex }, { description: regex }];
}
const ret = ReactiveCache.getBoards(query, projection);
const ret = ReactiveCache.getCards(query, projection);
return ret;
},
@ -1651,19 +1645,19 @@ Boards.helpers({
return await Boards.updateAsync(this._id, { $set: { allowsDescriptionText } });
},
async setAllowsDescriptionTextOnMinicard(allowsDescriptionTextOnMinicard) {
async setallowsDescriptionTextOnMinicard(allowsDescriptionTextOnMinicard) {
return await Boards.updateAsync(this._id, { $set: { allowsDescriptionTextOnMinicard } });
},
async setAllowsCoverAttachmentOnMinicard(allowsCoverAttachmentOnMinicard) {
async setallowsCoverAttachmentOnMinicard(allowsCoverAttachmentOnMinicard) {
return await Boards.updateAsync(this._id, { $set: { allowsCoverAttachmentOnMinicard } });
},
async setAllowsBadgeAttachmentOnMinicard(allowsBadgeAttachmentOnMinicard) {
async setallowsBadgeAttachmentOnMinicard(allowsBadgeAttachmentOnMinicard) {
return await Boards.updateAsync(this._id, { $set: { allowsBadgeAttachmentOnMinicard } });
},
async setAllowsCardSortingByNumberOnMinicard(allowsCardSortingByNumberOnMinicard) {
async setallowsCardSortingByNumberOnMinicard(allowsCardSortingByNumberOnMinicard) {
return await Boards.updateAsync(this._id, { $set: { allowsCardSortingByNumberOnMinicard } });
},
@ -1782,7 +1776,7 @@ Boards.userBoards = (
selector.archived = archived;
}
if (!selector.type) {
selector.type = { $in: ['board', 'template-container'] };
selector.type = 'board';
}
selector.$or = [

View file

@ -106,53 +106,40 @@ CardComments.helpers({
},
reactions() {
const reaction = this.reaction();
const cardCommentReactions = ReactiveCache.getCardCommentReaction({cardCommentId: this._id});
return !!cardCommentReactions ? cardCommentReactions.reactions : [];
},
reaction() {
return cardCommentReactions = ReactiveCache.getCardCommentReaction({ cardCommentId: this._id });
},
userReactions(userId) {
const reactions = this.reactions();
return reactions?.filter(r => r.userIds.includes(userId));
},
hasUserReacted(codepoint) {
return this.userReactions(Meteor.userId()).find(e => e.reactionCodepoint === codepoint);
},
toggleReaction(reactionCodepoint) {
if (reactionCodepoint !== sanitizeText(reactionCodepoint)) {
return false;
} else {
const cardCommentReactions = ReactiveCache.getCardCommentReaction({cardCommentId: this._id});
const reactions = !!cardCommentReactions ? cardCommentReactions.reactions : [];
const userId = Meteor.userId();
const reactionDoc = this.reaction();
const reactions = this.reactions();
const reactionTog = reactions.find(r => r.reactionCodepoint === reactionCodepoint);
const reaction = reactions.find(r => r.reactionCodepoint === reactionCodepoint);
// If no reaction is set for the codepoint, add this
if (!reactionTog) {
if (!reaction) {
reactions.push({ reactionCodepoint, userIds: [userId] });
} else {
// toggle user reaction upon previous reaction state
const userHasReacted = reactionTog.userIds.includes(userId);
const userHasReacted = reaction.userIds.includes(userId);
if (userHasReacted) {
reactionTog.userIds.splice(reactionTog.userIds.indexOf(userId), 1);
if (reactionTog.userIds.length === 0) {
reactions.splice(reactions.indexOf(reactionTog), 1);
reaction.userIds.splice(reaction.userIds.indexOf(userId), 1);
if (reaction.userIds.length === 0) {
reactions.splice(reactions.indexOf(reaction), 1);
}
} else {
reactionTog.userIds.push(userId);
reaction.userIds.push(userId);
}
}
// If no reaction doc exists yet create otherwise update reaction set
if (!!reactionDoc) {
return CardCommentReactions.update({ _id: reactionDoc._id }, { $set: { reactions } });
if (!!cardCommentReactions) {
return CardCommentReactions.update({ _id: cardCommentReactions._id }, { $set: { reactions } });
} else {
return CardCommentReactions.insert({
boardId: this.boardId,

View file

@ -2682,21 +2682,16 @@ function cardCustomFields(userId, doc, fieldNames, modifier) {
}
function cardCreation(userId, doc) {
// For any reason some special cards also have
// special data, e.g. linked cards who have list/swimlane ID
// being their own ID
const list = ReactiveCache.getList(doc.listId);
const swim = ReactiveCache.getSwimlane(doc.listId);
Activities.insert({
userId,
activityType: 'createCard',
boardId: doc.boardId,
listName: list?.title,
listId: list ? doc.listId : undefined,
listName: ReactiveCache.getList(doc.listId).title,
listId: doc.listId,
cardId: doc._id,
cardTitle: doc.title,
swimlaneName: swim?.title,
swimlaneId: swim ? doc.swimlaneId : undefined,
swimlaneName: ReactiveCache.getSwimlane(doc.swimlaneId).title,
swimlaneId: doc.swimlaneId,
});
}

View file

@ -103,10 +103,10 @@ export default class FileStoreStrategyFactory {
if (!storage) {
storage = fileObj.versions[versionName].storage;
if (!storage) {
if (fileObj.meta.source == "import" || Object.hasOwnProperty(fileObj.versions[versionName].meta, 'gridFsFileId')) {
if (fileObj.meta.source == "import" || fileObj.versions[versionName].meta.gridFsFileId) {
// uploaded by import, so it's in GridFS (MongoDB)
storage = STORAGE_NAME_GRIDFS;
} else if (fileObj && fileObj.versions && fileObj.versions[versionName] && fileObj.versions[versionName].meta && Object.hasOwnProperty(fileObj.versions[versionName].meta, 'pipePath')) {
} else if (fileObj && fileObj.versions && fileObj.versions[version] && fileObj.versions[version].meta && fileObj.versions[version].meta.pipePath) {
// DISABLED: S3 storage removed due to Node.js compatibility - fallback to filesystem
storage = STORAGE_NAME_FILESYSTEM;
} else {

View file

@ -5,11 +5,11 @@ import { mongodbDriverManager } from './mongodbDriverManager';
/**
* Meteor MongoDB Integration
*
*
* This module integrates the MongoDB driver manager with Meteor's
* built-in MongoDB connection system to provide automatic driver
* selection and version detection.
*
*
* Features:
* - Hooks into Meteor's MongoDB connection process
* - Automatic driver selection based on detected version
@ -58,7 +58,7 @@ class MeteorMongoIntegration {
*/
overrideMeteorConnection() {
const self = this;
// Override Meteor.connect if it exists
if (typeof Meteor.connect === 'function') {
Meteor.connect = async function(url, options) {
@ -110,16 +110,16 @@ class MeteorMongoIntegration {
async createCustomConnection(url, options = {}) {
try {
console.log('Creating custom MongoDB connection...');
// Use our connection manager
const connection = await mongodbConnectionManager.createConnection(url, options);
// Store the custom connection
this.customConnection = connection;
// Create a Meteor-compatible connection object
const meteorConnection = this.createMeteorCompatibleConnection(connection);
console.log('Custom MongoDB connection created successfully');
return meteorConnection;
@ -141,7 +141,7 @@ class MeteorMongoIntegration {
// Basic connection properties
_driver: connection,
_name: 'custom-mongodb-connection',
// Collection creation method
createCollection: function(name, options = {}) {
const db = connection.db();
@ -242,7 +242,7 @@ class MeteorMongoIntegration {
if (this.originalMongoConnect) {
Meteor.connect = this.originalMongoConnect;
}
if (this.originalMongoCollection) {
Mongo.Collection = this.originalMongoCollection;
}
@ -269,7 +269,7 @@ class MeteorMongoIntegration {
const db = this.customConnection.db();
const result = await db.admin().ping();
return {
success: true,
result,

View file

@ -3,10 +3,10 @@ import { mongodbDriverManager } from './mongodbDriverManager';
/**
* MongoDB Connection Manager
*
*
* This module handles MongoDB connections with automatic driver selection
* based on detected MongoDB server version and wire protocol compatibility.
*
*
* Features:
* - Automatic driver selection based on MongoDB version
* - Connection retry with different drivers on wire protocol errors
@ -30,7 +30,7 @@ class MongoDBConnectionManager {
*/
async createConnection(connectionString, options = {}) {
const connectionId = this.generateConnectionId(connectionString);
// Check if we already have a working connection
if (this.connections.has(connectionId)) {
const existingConnection = this.connections.get(connectionId);
@ -66,13 +66,13 @@ class MongoDBConnectionManager {
for (let attempt = 0; attempt < this.retryAttempts; attempt++) {
try {
console.log(`Attempting MongoDB connection with driver: ${currentDriver} (attempt ${attempt + 1})`);
const connection = await this.connectWithDriver(currentDriver, connectionString, options);
// Record successful connection
mongodbDriverManager.recordConnectionAttempt(
currentDriver,
mongodbDriverManager.detectedVersion || 'unknown',
currentDriver,
mongodbDriverManager.detectedVersion || 'unknown',
true
);
@ -113,9 +113,9 @@ class MongoDBConnectionManager {
// Record failed attempt
mongodbDriverManager.recordConnectionAttempt(
currentDriver,
detectedVersion || 'unknown',
false,
currentDriver,
detectedVersion || 'unknown',
false,
error
);
@ -204,7 +204,7 @@ class MongoDBConnectionManager {
async closeAllConnections() {
let closedCount = 0;
const connectionIds = Array.from(this.connections.keys());
for (const connectionId of connectionIds) {
if (await this.closeConnection(connectionId)) {
closedCount++;

View file

@ -2,10 +2,10 @@ import { Meteor } from 'meteor/meteor';
/**
* MongoDB Driver Manager
*
*
* This module provides automatic MongoDB version detection and driver selection
* to support MongoDB versions 3.0 through 8.0 with compatible Node.js drivers.
*
*
* Features:
* - Automatic MongoDB version detection from wire protocol errors
* - Dynamic driver selection based on detected version
@ -113,7 +113,7 @@ class MongoDBDriverManager {
}
const errorMessage = error.message.toLowerCase();
// Check specific version patterns
for (const [version, patterns] of Object.entries(VERSION_ERROR_PATTERNS)) {
for (const pattern of patterns) {

View file

@ -61,10 +61,10 @@ export function cleanFileUrl(url, type) {
// Remove any domain, port, or protocol from the URL
let cleanUrl = url;
// Remove protocol and domain
cleanUrl = cleanUrl.replace(/^https?:\/\/[^\/]+/, '');
// Remove ROOT_URL pathname if present
if (Meteor.isServer && process.env.ROOT_URL) {
try {
@ -79,7 +79,7 @@ export function cleanFileUrl(url, type) {
// Normalize path separators
cleanUrl = cleanUrl.replace(/\/+/g, '/');
// Ensure URL starts with /
if (!cleanUrl.startsWith('/')) {
cleanUrl = '/' + cleanUrl;
@ -176,13 +176,13 @@ export function getAllPossibleUrls(fileId, type) {
}
const urls = [];
// Primary URL
urls.push(generateUniversalFileUrl(fileId, type));
// Fallback URL
urls.push(generateFallbackUrl(fileId, type));
// Legacy URLs for backward compatibility
if (type === 'attachment') {
urls.push(`/cfs/files/attachments/${fileId}`);

View file

@ -26,11 +26,11 @@ export function isValidBoolean(value) {
*/
export function getValidatedNumber(key, boardId, itemId, defaultValue, min, max) {
if (typeof localStorage === 'undefined') return defaultValue;
try {
const stored = localStorage.getItem(key);
if (!stored) return defaultValue;
const data = JSON.parse(stored);
if (data[boardId] && typeof data[boardId][itemId] === 'number') {
const value = data[boardId][itemId];
@ -41,7 +41,7 @@ export function getValidatedNumber(key, boardId, itemId, defaultValue, min, max)
} catch (e) {
console.warn(`Error reading ${key} from localStorage:`, e);
}
return defaultValue;
}
@ -50,22 +50,22 @@ export function getValidatedNumber(key, boardId, itemId, defaultValue, min, max)
*/
export function setValidatedNumber(key, boardId, itemId, value, min, max) {
if (typeof localStorage === 'undefined') return false;
// Validate value
if (typeof value !== 'number' || isNaN(value) || !isFinite(value) || value < min || value > max) {
console.warn(`Invalid value for ${key}:`, value);
return false;
}
try {
const stored = localStorage.getItem(key);
const data = stored ? JSON.parse(stored) : {};
if (!data[boardId]) {
data[boardId] = {};
}
data[boardId][itemId] = value;
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch (e) {
@ -79,11 +79,11 @@ export function setValidatedNumber(key, boardId, itemId, value, min, max) {
*/
export function getValidatedBoolean(key, boardId, itemId, defaultValue) {
if (typeof localStorage === 'undefined') return defaultValue;
try {
const stored = localStorage.getItem(key);
if (!stored) return defaultValue;
const data = JSON.parse(stored);
if (data[boardId] && typeof data[boardId][itemId] === 'boolean') {
return data[boardId][itemId];
@ -91,7 +91,7 @@ export function getValidatedBoolean(key, boardId, itemId, defaultValue) {
} catch (e) {
console.warn(`Error reading ${key} from localStorage:`, e);
}
return defaultValue;
}
@ -100,22 +100,22 @@ export function getValidatedBoolean(key, boardId, itemId, defaultValue) {
*/
export function setValidatedBoolean(key, boardId, itemId, value) {
if (typeof localStorage === 'undefined') return false;
// Validate value
if (typeof value !== 'boolean') {
console.warn(`Invalid boolean value for ${key}:`, value);
return false;
}
try {
const stored = localStorage.getItem(key);
const data = stored ? JSON.parse(stored) : {};
if (!data[boardId]) {
data[boardId] = {};
}
data[boardId][itemId] = value;
localStorage.setItem(key, JSON.stringify(data));
return true;
} catch (e) {

View file

@ -468,21 +468,21 @@ Meteor.methods({
enableSoftLimit(listId) {
check(listId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'You must be logged in.');
}
const list = ReactiveCache.getList(listId);
if (!list) {
throw new Meteor.Error('list-not-found', 'List not found');
}
const board = ReactiveCache.getBoard(list.boardId);
if (!board || !board.hasAdmin(this.userId)) {
throw new Meteor.Error('not-authorized', 'You must be a board admin to modify WIP limits.');
}
list.toggleSoftLimit(!list.getWipLimit('soft'));
},

View file

@ -139,17 +139,33 @@ if (Meteor.isServer) {
LockoutSettings.helpers({
getKnownConfig() {
// Fetch all settings in one query instead of 3 separate queries
const settings = LockoutSettings.find({
_id: { $in: ['known-failuresBeforeLockout', 'known-lockoutPeriod', 'known-failureWindow'] }
}, { fields: { _id: 1, value: 1 } }).fetch();
const settingsMap = {};
settings.forEach(s => { settingsMap[s._id] = s.value; });
return {
failuresBeforeLockout: LockoutSettings.findOne('known-failuresBeforeLockout')?.value || 3,
lockoutPeriod: LockoutSettings.findOne('known-lockoutPeriod')?.value || 60,
failureWindow: LockoutSettings.findOne('known-failureWindow')?.value || 15
failuresBeforeLockout: settingsMap['known-failuresBeforeLockout'] || 3,
lockoutPeriod: settingsMap['known-lockoutPeriod'] || 60,
failureWindow: settingsMap['known-failureWindow'] || 15
};
},
getUnknownConfig() {
// Fetch all settings in one query instead of 3 separate queries
const settings = LockoutSettings.find({
_id: { $in: ['unknown-failuresBeforeLockout', 'unknown-lockoutPeriod', 'unknown-failureWindow'] }
}, { fields: { _id: 1, value: 1 } }).fetch();
const settingsMap = {};
settings.forEach(s => { settingsMap[s._id] = s.value; });
return {
failuresBeforeLockout: LockoutSettings.findOne('unknown-failuresBeforeLockout')?.value || 3,
lockoutPeriod: LockoutSettings.findOne('unknown-lockoutPeriod')?.value || 60,
failureWindow: LockoutSettings.findOne('unknown-failureWindow')?.value || 15
failuresBeforeLockout: settingsMap['unknown-failuresBeforeLockout'] || 3,
lockoutPeriod: settingsMap['unknown-lockoutPeriod'] || 60,
failureWindow: settingsMap['unknown-failureWindow'] || 15
};
}
});

View file

@ -253,7 +253,7 @@ Swimlanes.helpers({
myLists() {
// Return per-swimlane lists: provide lists specific to this swimlane
return ReactiveCache.getLists(
{
{
boardId: this.boardId,
swimlaneId: this._id,
archived: false
@ -690,7 +690,7 @@ Swimlanes.helpers({
hasMovedFromOriginalPosition() {
const history = this.getOriginalPosition();
if (!history) return false;
return history.originalPosition.sort !== this.sort;
},
@ -700,7 +700,7 @@ Swimlanes.helpers({
getOriginalPositionDescription() {
const history = this.getOriginalPosition();
if (!history) return 'No original position data';
return `Original position: ${history.originalPosition.sort || 0}`;
},
});

View file

@ -155,9 +155,9 @@ UserPositionHistory.helpers({
getDescription() {
const entityName = this.entityType;
const action = this.actionType;
let desc = `${action} ${entityName}`;
if (this.actionType === 'move') {
if (this.previousListId && this.newListId && this.previousListId !== this.newListId) {
desc += ' to different list';
@ -167,7 +167,7 @@ UserPositionHistory.helpers({
desc += ' position';
}
}
return desc;
},
@ -201,7 +201,7 @@ UserPositionHistory.helpers({
}
const userId = this.userId;
switch (this.entityType) {
case 'card': {
const card = ReactiveCache.getCard(this.entityId);
@ -211,7 +211,7 @@ UserPositionHistory.helpers({
const swimlaneId = this.previousSwimlaneId || card.swimlaneId;
const listId = this.previousListId || card.listId;
const sort = this.previousSort !== undefined ? this.previousSort : card.sort;
Cards.update(card._id, {
$set: {
boardId,
@ -228,7 +228,7 @@ UserPositionHistory.helpers({
if (list) {
const sort = this.previousSort !== undefined ? this.previousSort : list.sort;
const swimlaneId = this.previousSwimlaneId || list.swimlaneId;
Lists.update(list._id, {
$set: {
sort,
@ -242,7 +242,7 @@ UserPositionHistory.helpers({
const swimlane = ReactiveCache.getSwimlane(this.entityId);
if (swimlane) {
const sort = this.previousSort !== undefined ? this.previousSort : swimlane.sort;
Swimlanes.update(swimlane._id, {
$set: {
sort,
@ -255,7 +255,7 @@ UserPositionHistory.helpers({
const checklist = ReactiveCache.getChecklist(this.entityId);
if (checklist) {
const sort = this.previousSort !== undefined ? this.previousSort : checklist.sort;
Checklists.update(checklist._id, {
$set: {
sort,
@ -270,7 +270,7 @@ UserPositionHistory.helpers({
if (item) {
const sort = this.previousSort !== undefined ? this.previousSort : item.sort;
const checklistId = this.previousState?.checklistId || item.checklistId;
ChecklistItems.update(item._id, {
$set: {
sort,
@ -348,20 +348,20 @@ if (Meteor.isServer) {
* Cleanup old history entries (keep last 1000 per user per board)
*/
UserPositionHistory.cleanup = function() {
const users = Meteor.users.find({}).fetch();
const users = Meteor.users.find({}, { fields: { _id: 1 } }).fetch();
users.forEach(user => {
const boards = Boards.find({ 'members.userId': user._id }).fetch();
const boards = Boards.find({ 'members.userId': user._id }, { fields: { _id: 1 } }).fetch();
boards.forEach(board => {
const history = UserPositionHistory.find(
{ userId: user._id, boardId: board._id, isCheckpoint: { $ne: true } },
{ sort: { createdAt: -1 }, limit: 1000 }
).fetch();
if (history.length >= 1000) {
const oldestToKeep = history[999].createdAt;
// Remove entries older than the 1000th entry (except checkpoints)
UserPositionHistory.remove({
userId: user._id,
@ -391,11 +391,11 @@ Meteor.methods({
'userPositionHistory.createCheckpoint'(boardId, checkpointName) {
check(boardId, String);
check(checkpointName, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
// Create a checkpoint entry
return UserPositionHistory.insert({
userId: this.userId,
@ -413,27 +413,27 @@ Meteor.methods({
'userPositionHistory.undo'(historyId) {
check(historyId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const history = UserPositionHistory.findOne({ _id: historyId, userId: this.userId });
if (!history) {
throw new Meteor.Error('not-found', 'History entry not found');
}
return history.undo();
},
'userPositionHistory.getRecent'(boardId, limit = 50) {
check(boardId, String);
check(limit, Number);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return UserPositionHistory.find(
{ userId: this.userId, boardId },
{ sort: { createdAt: -1 }, limit: Math.min(limit, 100) }
@ -442,11 +442,11 @@ Meteor.methods({
'userPositionHistory.getCheckpoints'(boardId) {
check(boardId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
return UserPositionHistory.find(
{ userId: this.userId, boardId, isCheckpoint: true },
{ sort: { createdAt: -1 } }
@ -455,21 +455,21 @@ Meteor.methods({
'userPositionHistory.restoreToCheckpoint'(checkpointId) {
check(checkpointId, String);
if (!this.userId) {
throw new Meteor.Error('not-authorized', 'Must be logged in');
}
const checkpoint = UserPositionHistory.findOne({
_id: checkpointId,
const checkpoint = UserPositionHistory.findOne({
_id: checkpointId,
userId: this.userId,
isCheckpoint: true,
});
if (!checkpoint) {
throw new Meteor.Error('not-found', 'Checkpoint not found');
}
// Find all changes after this checkpoint and undo them in reverse order
const changesToUndo = UserPositionHistory.find(
{
@ -480,7 +480,7 @@ Meteor.methods({
},
{ sort: { createdAt: -1 } }
).fetch();
let undoneCount = 0;
changesToUndo.forEach(change => {
try {
@ -492,7 +492,7 @@ Meteor.methods({
console.warn('Failed to undo change:', change._id, e);
}
});
return { undoneCount, totalChanges: changesToUndo.length };
},
});

View file

@ -615,6 +615,15 @@ Users.attachSchema(
allowedValues: ['YYYY-MM-DD', 'DD-MM-YYYY', 'MM-DD-YYYY'],
defaultValue: 'YYYY-MM-DD',
},
'profile.zoomLevel': {
/**
* User-specified zoom level for board view (1.0 = 100%, 1.5 = 150%, etc.)
*/
type: Number,
defaultValue: 1.0,
min: 0.5,
max: 3.0,
},
'profile.mobileMode': {
/**
* User-specified mobile/desktop mode toggle
@ -833,6 +842,7 @@ Users.safeFields = {
'profile.fullname': 1,
'profile.avatarUrl': 1,
'profile.initials': 1,
'profile.zoomLevel': 1,
'profile.mobileMode': 1,
'profile.GreyIcons': 1,
orgs: 1,
@ -1772,6 +1782,18 @@ Users.helpers({
current[boardId][swimlaneId] = !!collapsed;
return await Users.updateAsync(this._id, { $set: { 'profile.collapsedSwimlanes': current } });
},
async setZoomLevel(level) {
return await Users.updateAsync(this._id, { $set: { 'profile.zoomLevel': level } });
},
async setMobileMode(enabled) {
return await Users.updateAsync(this._id, { $set: { 'profile.mobileMode': enabled } });
},
async setCardZoom(level) {
return await Users.updateAsync(this._id, { $set: { 'profile.cardZoom': level } });
},
});
Meteor.methods({
@ -1970,7 +1992,7 @@ Meteor.methods({
check(spaceId, String);
if (!this.userId) throw new Meteor.Error('not-logged-in');
const user = Users.findOne(this.userId);
const user = Users.findOne(this.userId, { fields: { 'profile.boardWorkspaceAssignments': 1 } });
const assignments = user.profile?.boardWorkspaceAssignments || {};
assignments[boardId] = spaceId;
@ -1984,7 +2006,7 @@ Meteor.methods({
check(boardId, String);
if (!this.userId) throw new Meteor.Error('not-logged-in');
const user = Users.findOne(this.userId);
const user = Users.findOne(this.userId, { fields: { 'profile.boardWorkspaceAssignments': 1 } });
const assignments = user.profile?.boardWorkspaceAssignments || {};
delete assignments[boardId];
@ -2001,11 +2023,9 @@ Meteor.methods({
const user = ReactiveCache.getCurrentUser();
user.toggleFieldsGrid(user.hasCustomFieldsGrid());
},
/* #FIXME not sure about what I'm doing here, but this methods call an async method AFAIU.
not making it wait to it creates flickering and multiple renderings on client side. */
async toggleCardMaximized() {
toggleCardMaximized() {
const user = ReactiveCache.getCurrentUser();
await user.toggleCardMaximized(user.hasCardMaximized());
user.toggleCardMaximized(user.hasCardMaximized());
},
setCardCollapsed(value) {
check(value, Boolean);
@ -2016,10 +2036,6 @@ Meteor.methods({
const user = ReactiveCache.getCurrentUser();
user.toggleLabelText(user.hasHiddenMinicardLabelText());
},
toggleShowWeekOfYear() {
const user = ReactiveCache.getCurrentUser();
user.toggleShowWeekOfYear(user.isShowWeekOfYear());
},
toggleRescueCardDescription() {
const user = ReactiveCache.getCurrentUser();
user.toggleRescueCardDescription(user.hasRescuedCardDescription());
@ -2100,7 +2116,7 @@ Meteor.methods({
check(height, Number);
const user = ReactiveCache.getCurrentUser();
if (user) {
user.setSwimlaneHeightToStorage(boardId, swimlaneId, parseInt(height));
user.setSwimlaneHeightToStorage(boardId, swimlaneId, height);
}
// For non-logged-in users, the client-side code will handle localStorage
},
@ -2117,6 +2133,11 @@ Meteor.methods({
}
// For non-logged-in users, the client-side code will handle localStorage
},
setZoomLevel(level) {
check(level, Number);
const user = ReactiveCache.getCurrentUser();
user.setZoomLevel(level);
},
setMobileMode(enabled) {
check(enabled, Boolean);
const user = ReactiveCache.getCurrentUser();
@ -3016,7 +3037,7 @@ if (Meteor.isServer) {
// get all boards where the user is member of
let boards = ReactiveCache.getBoards(
{
type: {$in: ['board', 'template-container']},
type: 'board',
'members.userId': req.userId,
},
{
@ -3060,7 +3081,9 @@ if (Meteor.isServer) {
Authentication.checkUserId(req.userId);
JsonRoutes.sendResult(res, {
code: 200,
data: Meteor.users.find({}).map(function (doc) {
data: Meteor.users.find({}, {
fields: { _id: 1, username: 1 }
}).map(function (doc) {
return {
_id: doc._id,
username: doc.username,
@ -3102,7 +3125,7 @@ if (Meteor.isServer) {
// get all boards where the user is member of
let boards = ReactiveCache.getBoards(
{
type: { $in: ['board', 'template-container'] },
type: 'board',
'members.userId': id,
},
{