diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 86d32c4ea..1be4a30cd 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -56,10 +56,15 @@ BlazeComponent.extendComponent({ const swimlanes = board.swimlanes(); if (swimlanes.length === 0) { - const swimlaneId = Swimlanes.insert({ - title: 'Default', - boardId: boardId, - }); + // Check if any swimlane exists in the database to avoid race conditions + const existingSwimlanes = ReactiveCache.getSwimlanes({ boardId }); + if (existingSwimlanes.length === 0) { + const swimlaneId = Swimlanes.insert({ + title: 'Default', + boardId: boardId, + }); + console.log(`Created default swimlane ${swimlaneId} for board ${boardId}`); + } this._swimlaneCreated.add(boardId); } else { this._swimlaneCreated.add(boardId); @@ -197,28 +202,41 @@ BlazeComponent.extendComponent({ }); if (!existingList) { - // Create a new list in this swimlane - const newListData = { - title: sharedList.title, + // Double-check to avoid race conditions + const doubleCheckList = ReactiveCache.getList({ boardId: boardId, swimlaneId: swimlane._id, - sort: sharedList.sort || 0, - archived: sharedList.archived || false, // Preserve archived state from original list - createdAt: new Date(), - modifiedAt: new Date() - }; + title: sharedList.title + }); - // Copy other properties if they exist - if (sharedList.color) newListData.color = sharedList.color; - if (sharedList.wipLimit) newListData.wipLimit = sharedList.wipLimit; - if (sharedList.wipLimitEnabled) newListData.wipLimitEnabled = sharedList.wipLimitEnabled; - if (sharedList.wipLimitSoft) newListData.wipLimitSoft = sharedList.wipLimitSoft; + if (!doubleCheckList) { + // Create a new list in this swimlane + const newListData = { + title: sharedList.title, + boardId: boardId, + swimlaneId: swimlane._id, + sort: sharedList.sort || 0, + archived: sharedList.archived || false, // Preserve archived state from original list + createdAt: new Date(), + modifiedAt: new Date() + }; - Lists.insert(newListData); - - if (process.env.DEBUG === 'true') { - const archivedStatus = sharedList.archived ? ' (archived)' : ' (active)'; - console.log(`Created list "${sharedList.title}"${archivedStatus} for swimlane ${swimlane.title || swimlane._id}`); + // Copy other properties if they exist + if (sharedList.color) newListData.color = sharedList.color; + if (sharedList.wipLimit) newListData.wipLimit = sharedList.wipLimit; + if (sharedList.wipLimitEnabled) newListData.wipLimitEnabled = sharedList.wipLimitEnabled; + if (sharedList.wipLimitSoft) newListData.wipLimitSoft = sharedList.wipLimitSoft; + + Lists.insert(newListData); + + if (process.env.DEBUG === 'true') { + const archivedStatus = sharedList.archived ? ' (archived)' : ' (active)'; + console.log(`Created list "${sharedList.title}"${archivedStatus} for swimlane ${swimlane.title || swimlane._id}`); + } + } else { + if (process.env.DEBUG === 'true') { + console.log(`List "${sharedList.title}" already exists in swimlane ${swimlane.title || swimlane._id} (double-check), skipping`); + } } } else { if (process.env.DEBUG === 'true') { diff --git a/client/lib/fixDuplicateLists.js b/client/lib/fixDuplicateLists.js new file mode 100644 index 000000000..d9602b70d --- /dev/null +++ b/client/lib/fixDuplicateLists.js @@ -0,0 +1,93 @@ +import { Meteor } from 'meteor/meteor'; + +/** + * Client-side interface for fixing duplicate lists + */ +export const fixDuplicateLists = { + + /** + * Get a report of all boards with duplicate lists/swimlanes + */ + async getReport() { + try { + const result = await Meteor.callAsync('fixDuplicateLists.getReport'); + return result; + } catch (error) { + console.error('Error getting duplicate lists report:', error); + throw error; + } + }, + + /** + * Fix duplicate lists for a specific board + */ + async fixBoard(boardId) { + try { + const result = await Meteor.callAsync('fixDuplicateLists.fixBoard', boardId); + console.log(`Fixed duplicate lists for board ${boardId}:`, result); + return result; + } catch (error) { + console.error(`Error fixing board ${boardId}:`, error); + throw error; + } + }, + + /** + * Fix duplicate lists for all boards + */ + async fixAllBoards() { + try { + console.log('Starting fix for all boards...'); + const result = await Meteor.callAsync('fixDuplicateLists.fixAllBoards'); + console.log('Fix completed:', result); + return result; + } catch (error) { + console.error('Error fixing all boards:', error); + throw error; + } + }, + + /** + * Interactive fix with user confirmation + */ + async interactiveFix() { + try { + // Get report first + console.log('Getting duplicate lists report...'); + const report = await this.getReport(); + + if (report.boardsWithDuplicates === 0) { + console.log('No duplicate lists found!'); + return { message: 'No duplicate lists found!' }; + } + + console.log(`Found ${report.boardsWithDuplicates} boards with duplicate lists:`); + report.report.forEach(board => { + console.log(`- Board "${board.boardTitle}" (${board.boardId}): ${board.duplicateSwimlanes} duplicate swimlanes, ${board.duplicateLists} duplicate lists`); + }); + + // Ask for confirmation + const confirmed = confirm( + `Found ${report.boardsWithDuplicates} boards with duplicate lists. ` + + `This will fix ${report.report.reduce((sum, board) => sum + board.duplicateSwimlanes + board.duplicateLists, 0)} duplicates. ` + + 'Continue?' + ); + + if (!confirmed) { + return { message: 'Fix cancelled by user' }; + } + + // Perform the fix + const result = await this.fixAllBoards(); + return result; + } catch (error) { + console.error('Error in interactive fix:', error); + throw error; + } + } +}; + +// Make it available globally for console access +if (typeof window !== 'undefined') { + window.fixDuplicateLists = fixDuplicateLists; +} diff --git a/fix-duplicate-lists.js b/fix-duplicate-lists.js new file mode 100644 index 000000000..741777b66 --- /dev/null +++ b/fix-duplicate-lists.js @@ -0,0 +1,286 @@ +#!/usr/bin/env node + +/** + * Standalone script to fix duplicate lists created by WeKan 8.10 + * + * Usage: + * node fix-duplicate-lists.js + * + * This script will: + * 1. Connect to the MongoDB database + * 2. Identify boards with duplicate lists/swimlanes + * 3. Fix the duplicates by merging them + * 4. Report the results + */ + +const { MongoClient } = require('mongodb'); + +// Configuration - adjust these for your setup +const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017/wekan'; +const DB_NAME = process.env.MONGO_DB_NAME || 'wekan'; + +class DuplicateListsFixer { + constructor() { + this.client = null; + this.db = null; + } + + async connect() { + console.log('Connecting to MongoDB...'); + this.client = new MongoClient(MONGO_URL); + await this.client.connect(); + this.db = this.client.db(DB_NAME); + console.log('Connected to MongoDB'); + } + + async disconnect() { + if (this.client) { + await this.client.close(); + console.log('Disconnected from MongoDB'); + } + } + + async getReport() { + console.log('Analyzing boards for duplicate lists...'); + + const boards = await this.db.collection('boards').find({}).toArray(); + const report = []; + + for (const board of boards) { + const swimlanes = await this.db.collection('swimlanes').find({ boardId: board._id }).toArray(); + const lists = await this.db.collection('lists').find({ boardId: board._id }).toArray(); + + // Check for duplicate swimlanes + const swimlaneGroups = {}; + swimlanes.forEach(swimlane => { + const key = swimlane.title || 'Default'; + if (!swimlaneGroups[key]) { + swimlaneGroups[key] = []; + } + swimlaneGroups[key].push(swimlane); + }); + + // Check for duplicate lists + const listGroups = {}; + lists.forEach(list => { + const key = `${list.swimlaneId || 'null'}-${list.title}`; + if (!listGroups[key]) { + listGroups[key] = []; + } + listGroups[key].push(list); + }); + + const duplicateSwimlanes = Object.values(swimlaneGroups).filter(group => group.length > 1); + const duplicateLists = Object.values(listGroups).filter(group => group.length > 1); + + if (duplicateSwimlanes.length > 0 || duplicateLists.length > 0) { + report.push({ + boardId: board._id, + boardTitle: board.title, + duplicateSwimlanes: duplicateSwimlanes.length, + duplicateLists: duplicateLists.length, + totalSwimlanes: swimlanes.length, + totalLists: lists.length + }); + } + } + + return { + totalBoards: boards.length, + boardsWithDuplicates: report.length, + report + }; + } + + async fixBoard(boardId) { + console.log(`Fixing duplicate lists for board ${boardId}...`); + + // Fix duplicate swimlanes + const swimlaneResult = await this.fixDuplicateSwimlanes(boardId); + + // Fix duplicate lists + const listResult = await this.fixDuplicateLists(boardId); + + return { + boardId, + fixedSwimlanes: swimlaneResult.fixed, + fixedLists: listResult.fixed, + fixed: swimlaneResult.fixed + listResult.fixed + }; + } + + async fixDuplicateSwimlanes(boardId) { + const swimlanes = await this.db.collection('swimlanes').find({ boardId }).toArray(); + const swimlaneGroups = {}; + let fixed = 0; + + // Group swimlanes by title + swimlanes.forEach(swimlane => { + const key = swimlane.title || 'Default'; + if (!swimlaneGroups[key]) { + swimlaneGroups[key] = []; + } + swimlaneGroups[key].push(swimlane); + }); + + // For each group with duplicates, keep the oldest and remove the rest + for (const [title, group] of Object.entries(swimlaneGroups)) { + if (group.length > 1) { + // Sort by creation date, keep the oldest + group.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const keepSwimlane = group[0]; + const removeSwimlanes = group.slice(1); + + console.log(`Found ${group.length} duplicate swimlanes with title "${title}", keeping oldest (${keepSwimlane._id})`); + + // Move all lists from duplicate swimlanes to the kept swimlane + for (const swimlane of removeSwimlanes) { + const lists = await this.db.collection('lists').find({ swimlaneId: swimlane._id }).toArray(); + for (const list of lists) { + // Check if a list with the same title already exists in the kept swimlane + const existingList = await this.db.collection('lists').findOne({ + boardId, + swimlaneId: keepSwimlane._id, + title: list.title + }); + + if (existingList) { + // Move cards to existing list + await this.db.collection('cards').updateMany( + { listId: list._id }, + { $set: { listId: existingList._id } } + ); + // Remove duplicate list + await this.db.collection('lists').deleteOne({ _id: list._id }); + console.log(`Moved cards from duplicate list "${list.title}" to existing list in kept swimlane`); + } else { + // Move list to kept swimlane + await this.db.collection('lists').updateOne( + { _id: list._id }, + { $set: { swimlaneId: keepSwimlane._id } } + ); + console.log(`Moved list "${list.title}" to kept swimlane`); + } + } + + // Remove duplicate swimlane + await this.db.collection('swimlanes').deleteOne({ _id: swimlane._id }); + fixed++; + } + } + } + + return { fixed }; + } + + async fixDuplicateLists(boardId) { + const lists = await this.db.collection('lists').find({ boardId }).toArray(); + const listGroups = {}; + let fixed = 0; + + // Group lists by title and swimlaneId + lists.forEach(list => { + const key = `${list.swimlaneId || 'null'}-${list.title}`; + if (!listGroups[key]) { + listGroups[key] = []; + } + listGroups[key].push(list); + }); + + // For each group with duplicates, keep the oldest and remove the rest + for (const [key, group] of Object.entries(listGroups)) { + if (group.length > 1) { + // Sort by creation date, keep the oldest + group.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const keepList = group[0]; + const removeLists = group.slice(1); + + console.log(`Found ${group.length} duplicate lists with title "${keepList.title}" in swimlane ${keepList.swimlaneId}, keeping oldest (${keepList._id})`); + + // Move all cards from duplicate lists to the kept list + for (const list of removeLists) { + await this.db.collection('cards').updateMany( + { listId: list._id }, + { $set: { listId: keepList._id } } + ); + + // Remove duplicate list + await this.db.collection('lists').deleteOne({ _id: list._id }); + fixed++; + console.log(`Moved cards from duplicate list "${list.title}" to kept list`); + } + } + } + + return { fixed }; + } + + async fixAllBoards() { + console.log('Starting duplicate lists fix for all boards...'); + + const allBoards = await this.db.collection('boards').find({}).toArray(); + let totalFixed = 0; + let totalBoardsProcessed = 0; + + for (const board of allBoards) { + try { + const result = await this.fixBoard(board._id); + totalFixed += result.fixed; + totalBoardsProcessed++; + + if (result.fixed > 0) { + console.log(`Fixed ${result.fixed} duplicate lists in board "${board.title}" (${board._id})`); + } + } catch (error) { + console.error(`Error fixing board ${board._id}:`, error); + } + } + + console.log(`Duplicate lists fix completed. Processed ${totalBoardsProcessed} boards, fixed ${totalFixed} duplicate lists.`); + + return { + message: `Fixed ${totalFixed} duplicate lists across ${totalBoardsProcessed} boards`, + totalFixed, + totalBoardsProcessed + }; + } +} + +// Main execution +async function main() { + const fixer = new DuplicateListsFixer(); + + try { + await fixer.connect(); + + // Get report first + const report = await fixer.getReport(); + + if (report.boardsWithDuplicates === 0) { + console.log('No duplicate lists found!'); + return; + } + + console.log(`Found ${report.boardsWithDuplicates} boards with duplicate lists:`); + report.report.forEach(board => { + console.log(`- Board "${board.boardTitle}" (${board.boardId}): ${board.duplicateSwimlanes} duplicate swimlanes, ${board.duplicateLists} duplicate lists`); + }); + + // Perform the fix + const result = await fixer.fixAllBoards(); + console.log('Fix completed:', result); + + } catch (error) { + console.error('Error:', error); + process.exit(1); + } finally { + await fixer.disconnect(); + } +} + +// Run if called directly +if (require.main === module) { + main(); +} + +module.exports = DuplicateListsFixer; diff --git a/models/boards.js b/models/boards.js index bcb456c6c..1ff0b0fb4 100644 --- a/models/boards.js +++ b/models/boards.js @@ -1235,13 +1235,20 @@ Boards.helpers({ getDefaultSwimline() { let result = ReactiveCache.getSwimlane({ boardId: this._id }); if (result === undefined) { - // Use fallback title if i18n is not available (e.g., during migration) - const title = TAPi18n && TAPi18n.i18n ? TAPi18n.__('default') : 'Default'; - Swimlanes.insert({ - title: title, - boardId: this._id, - }); - result = ReactiveCache.getSwimlane({ boardId: this._id }); + // Check if any swimlane exists for this board to avoid duplicates + const existingSwimlanes = ReactiveCache.getSwimlanes({ boardId: this._id }); + if (existingSwimlanes.length > 0) { + // Use the first existing swimlane + result = existingSwimlanes[0]; + } else { + // Use fallback title if i18n is not available (e.g., during migration) + const title = TAPi18n && TAPi18n.i18n ? TAPi18n.__('default') : 'Default'; + Swimlanes.insert({ + title: title, + boardId: this._id, + }); + result = ReactiveCache.getSwimlane({ boardId: this._id }); + } } return result; }, diff --git a/server/methods/fixDuplicateLists.js b/server/methods/fixDuplicateLists.js new file mode 100644 index 000000000..558636d03 --- /dev/null +++ b/server/methods/fixDuplicateLists.js @@ -0,0 +1,234 @@ +import { Meteor } from 'meteor/meteor'; +import { check } from 'meteor/check'; +import Boards from '/models/boards'; +import Lists from '/models/lists'; +import Swimlanes from '/models/swimlanes'; +import Cards from '/models/cards'; + +/** + * Fix duplicate lists and swimlanes created by WeKan 8.10 + * This method identifies and removes duplicate lists while preserving cards + */ +Meteor.methods({ + 'fixDuplicateLists.fixAllBoards'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + console.log('Starting duplicate lists fix for all boards...'); + + const allBoards = Boards.find({}).fetch(); + let totalFixed = 0; + let totalBoardsProcessed = 0; + + for (const board of allBoards) { + try { + const result = this.fixDuplicateListsForBoard(board._id); + totalFixed += result.fixed; + totalBoardsProcessed++; + + if (result.fixed > 0) { + console.log(`Fixed ${result.fixed} duplicate lists in board "${board.title}" (${board._id})`); + } + } catch (error) { + console.error(`Error fixing board ${board._id}:`, error); + } + } + + console.log(`Duplicate lists fix completed. Processed ${totalBoardsProcessed} boards, fixed ${totalFixed} duplicate lists.`); + + return { + message: `Fixed ${totalFixed} duplicate lists across ${totalBoardsProcessed} boards`, + totalFixed, + totalBoardsProcessed + }; + }, + + 'fixDuplicateLists.fixBoard'(boardId) { + check(boardId, String); + + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + return this.fixDuplicateListsForBoard(boardId); + }, + + fixDuplicateListsForBoard(boardId) { + console.log(`Fixing duplicate lists for board ${boardId}...`); + + // First, fix duplicate swimlanes + const swimlaneResult = this.fixDuplicateSwimlanes(boardId); + + // Then, fix duplicate lists + const listResult = this.fixDuplicateLists(boardId); + + return { + boardId, + fixedSwimlanes: swimlaneResult.fixed, + fixedLists: listResult.fixed, + fixed: swimlaneResult.fixed + listResult.fixed + }; + }, + + fixDuplicateSwimlanes(boardId) { + const swimlanes = Swimlanes.find({ boardId }).fetch(); + const swimlaneGroups = {}; + let fixed = 0; + + // Group swimlanes by title + swimlanes.forEach(swimlane => { + const key = swimlane.title || 'Default'; + if (!swimlaneGroups[key]) { + swimlaneGroups[key] = []; + } + swimlaneGroups[key].push(swimlane); + }); + + // For each group with duplicates, keep the oldest and remove the rest + Object.keys(swimlaneGroups).forEach(title => { + const group = swimlaneGroups[title]; + if (group.length > 1) { + // Sort by creation date, keep the oldest + group.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const keepSwimlane = group[0]; + const removeSwimlanes = group.slice(1); + + console.log(`Found ${group.length} duplicate swimlanes with title "${title}", keeping oldest (${keepSwimlane._id})`); + + // Move all lists from duplicate swimlanes to the kept swimlane + removeSwimlanes.forEach(swimlane => { + const lists = Lists.find({ swimlaneId: swimlane._id }).fetch(); + lists.forEach(list => { + // Check if a list with the same title already exists in the kept swimlane + const existingList = Lists.findOne({ + boardId, + swimlaneId: keepSwimlane._id, + title: list.title + }); + + if (existingList) { + // Move cards to existing list + Cards.update( + { listId: list._id }, + { $set: { listId: existingList._id } }, + { multi: true } + ); + // Remove duplicate list + Lists.remove(list._id); + console.log(`Moved cards from duplicate list "${list.title}" to existing list in kept swimlane`); + } else { + // Move list to kept swimlane + Lists.update(list._id, { $set: { swimlaneId: keepSwimlane._id } }); + console.log(`Moved list "${list.title}" to kept swimlane`); + } + }); + + // Remove duplicate swimlane + Swimlanes.remove(swimlane._id); + fixed++; + }); + } + }); + + return { fixed }; + }, + + fixDuplicateLists(boardId) { + const lists = Lists.find({ boardId }).fetch(); + const listGroups = {}; + let fixed = 0; + + // Group lists by title and swimlaneId + lists.forEach(list => { + const key = `${list.swimlaneId || 'null'}-${list.title}`; + if (!listGroups[key]) { + listGroups[key] = []; + } + listGroups[key].push(list); + }); + + // For each group with duplicates, keep the oldest and remove the rest + Object.keys(listGroups).forEach(key => { + const group = listGroups[key]; + if (group.length > 1) { + // Sort by creation date, keep the oldest + group.sort((a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0)); + const keepList = group[0]; + const removeLists = group.slice(1); + + console.log(`Found ${group.length} duplicate lists with title "${keepList.title}" in swimlane ${keepList.swimlaneId}, keeping oldest (${keepList._id})`); + + // Move all cards from duplicate lists to the kept list + removeLists.forEach(list => { + Cards.update( + { listId: list._id }, + { $set: { listId: keepList._id } }, + { multi: true } + ); + + // Remove duplicate list + Lists.remove(list._id); + fixed++; + console.log(`Moved cards from duplicate list "${list.title}" to kept list`); + }); + } + }); + + return { fixed }; + }, + + 'fixDuplicateLists.getReport'() { + if (!this.userId) { + throw new Meteor.Error('not-authorized'); + } + + const allBoards = Boards.find({}).fetch(); + const report = []; + + for (const board of allBoards) { + const swimlanes = Swimlanes.find({ boardId: board._id }).fetch(); + const lists = Lists.find({ boardId: board._id }).fetch(); + + // Check for duplicate swimlanes + const swimlaneGroups = {}; + swimlanes.forEach(swimlane => { + const key = swimlane.title || 'Default'; + if (!swimlaneGroups[key]) { + swimlaneGroups[key] = []; + } + swimlaneGroups[key].push(swimlane); + }); + + // Check for duplicate lists + const listGroups = {}; + lists.forEach(list => { + const key = `${list.swimlaneId || 'null'}-${list.title}`; + if (!listGroups[key]) { + listGroups[key] = []; + } + listGroups[key].push(list); + }); + + const duplicateSwimlanes = Object.values(swimlaneGroups).filter(group => group.length > 1); + const duplicateLists = Object.values(listGroups).filter(group => group.length > 1); + + if (duplicateSwimlanes.length > 0 || duplicateLists.length > 0) { + report.push({ + boardId: board._id, + boardTitle: board.title, + duplicateSwimlanes: duplicateSwimlanes.length, + duplicateLists: duplicateLists.length, + totalSwimlanes: swimlanes.length, + totalLists: lists.length + }); + } + } + + return { + totalBoards: allBoards.length, + boardsWithDuplicates: report.length, + report + }; + } +});