mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 15:30:13 +01:00
373 lines
11 KiB
JavaScript
373 lines
11 KiB
JavaScript
/**
|
|
* Delete Duplicate Empty Lists Migration
|
|
*
|
|
* Safely deletes empty duplicate lists from a board:
|
|
* 1. First converts any shared lists to per-swimlane lists
|
|
* 2. Only deletes per-swimlane lists that:
|
|
* - Have no cards
|
|
* - Have another list with the same title on the same board that DOES have cards
|
|
* 3. This prevents deleting unique empty lists and only removes redundant duplicates
|
|
*/
|
|
|
|
import { Meteor } from 'meteor/meteor';
|
|
import { check } from 'meteor/check';
|
|
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import Boards from '/models/boards';
|
|
import Lists from '/models/lists';
|
|
import Cards from '/models/cards';
|
|
import Swimlanes from '/models/swimlanes';
|
|
|
|
class DeleteDuplicateEmptyListsMigration {
|
|
constructor() {
|
|
this.name = 'deleteDuplicateEmptyLists';
|
|
this.version = 1;
|
|
}
|
|
|
|
/**
|
|
* Check if migration is needed for a board
|
|
*/
|
|
needsMigration(boardId) {
|
|
try {
|
|
const lists = ReactiveCache.getLists({ boardId });
|
|
const cards = ReactiveCache.getCards({ boardId });
|
|
|
|
// Check if there are any empty lists that have a duplicate with the same title containing cards
|
|
for (const list of lists) {
|
|
// Skip shared lists
|
|
if (!list.swimlaneId || list.swimlaneId === '') {
|
|
continue;
|
|
}
|
|
|
|
// Check if list is empty
|
|
const listCards = cards.filter(card => card.listId === list._id);
|
|
if (listCards.length === 0) {
|
|
// Check if there's a duplicate list with the same title that has cards
|
|
const duplicateListsWithSameTitle = lists.filter(l =>
|
|
l._id !== list._id &&
|
|
l.title === list.title &&
|
|
l.boardId === boardId
|
|
);
|
|
|
|
for (const duplicateList of duplicateListsWithSameTitle) {
|
|
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
|
|
if (duplicateListCards.length > 0) {
|
|
return true; // Found an empty list with a duplicate that has cards
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (error) {
|
|
console.error('Error checking if deleteDuplicateEmptyLists migration is needed:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute the migration
|
|
*/
|
|
async executeMigration(boardId) {
|
|
try {
|
|
const results = {
|
|
sharedListsConverted: 0,
|
|
listsDeleted: 0,
|
|
errors: []
|
|
};
|
|
|
|
// Step 1: Convert shared lists to per-swimlane lists first
|
|
const conversionResult = await this.convertSharedListsToPerSwimlane(boardId);
|
|
results.sharedListsConverted = conversionResult.listsConverted;
|
|
|
|
// Step 2: Delete empty per-swimlane lists
|
|
const deletionResult = await this.deleteEmptyPerSwimlaneLists(boardId);
|
|
results.listsDeleted = deletionResult.listsDeleted;
|
|
|
|
return {
|
|
success: true,
|
|
changes: [
|
|
`Converted ${results.sharedListsConverted} shared lists to per-swimlane lists`,
|
|
`Deleted ${results.listsDeleted} empty per-swimlane lists`
|
|
],
|
|
results
|
|
};
|
|
} catch (error) {
|
|
console.error('Error executing deleteDuplicateEmptyLists migration:', error);
|
|
return {
|
|
success: false,
|
|
error: error.message
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert shared lists (lists without swimlaneId) to per-swimlane lists
|
|
*/
|
|
async convertSharedListsToPerSwimlane(boardId) {
|
|
const lists = ReactiveCache.getLists({ boardId });
|
|
const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
|
|
const cards = ReactiveCache.getCards({ boardId });
|
|
|
|
let listsConverted = 0;
|
|
|
|
// Find shared lists (lists without swimlaneId)
|
|
const sharedLists = lists.filter(list => !list.swimlaneId || list.swimlaneId === '');
|
|
|
|
if (sharedLists.length === 0) {
|
|
return { listsConverted: 0 };
|
|
}
|
|
|
|
for (const sharedList of sharedLists) {
|
|
// Get cards in this shared list
|
|
const listCards = cards.filter(card => card.listId === sharedList._id);
|
|
|
|
// Group cards by swimlane
|
|
const cardsBySwimlane = {};
|
|
for (const card of listCards) {
|
|
const swimlaneId = card.swimlaneId || 'default';
|
|
if (!cardsBySwimlane[swimlaneId]) {
|
|
cardsBySwimlane[swimlaneId] = [];
|
|
}
|
|
cardsBySwimlane[swimlaneId].push(card);
|
|
}
|
|
|
|
// Create per-swimlane lists for each swimlane that has cards
|
|
for (const swimlane of swimlanes) {
|
|
const swimlaneCards = cardsBySwimlane[swimlane._id] || [];
|
|
|
|
if (swimlaneCards.length > 0) {
|
|
// Check if per-swimlane list already exists
|
|
const existingList = lists.find(l =>
|
|
l.title === sharedList.title &&
|
|
l.swimlaneId === swimlane._id &&
|
|
l._id !== sharedList._id
|
|
);
|
|
|
|
if (!existingList) {
|
|
// Create new per-swimlane list
|
|
const newListId = Lists.insert({
|
|
title: sharedList.title,
|
|
boardId: boardId,
|
|
swimlaneId: swimlane._id,
|
|
sort: sharedList.sort,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
archived: false
|
|
});
|
|
|
|
// Move cards to the new list
|
|
for (const card of swimlaneCards) {
|
|
Cards.update(card._id, {
|
|
$set: {
|
|
listId: newListId,
|
|
swimlaneId: swimlane._id
|
|
}
|
|
});
|
|
}
|
|
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Created per-swimlane list "${sharedList.title}" for swimlane ${swimlane.title || swimlane._id}`);
|
|
}
|
|
} else {
|
|
// Move cards to existing per-swimlane list
|
|
for (const card of swimlaneCards) {
|
|
Cards.update(card._id, {
|
|
$set: {
|
|
listId: existingList._id,
|
|
swimlaneId: swimlane._id
|
|
}
|
|
});
|
|
}
|
|
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Moved cards to existing per-swimlane list "${sharedList.title}" in swimlane ${swimlane.title || swimlane._id}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove the shared list (now that all cards are moved)
|
|
Lists.remove(sharedList._id);
|
|
listsConverted++;
|
|
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Removed shared list "${sharedList.title}"`);
|
|
}
|
|
}
|
|
|
|
return { listsConverted };
|
|
}
|
|
|
|
/**
|
|
* Delete empty per-swimlane lists
|
|
* Only deletes lists that:
|
|
* 1. Have a swimlaneId (are per-swimlane, not shared)
|
|
* 2. Have no cards
|
|
* 3. Have a duplicate list with the same title on the same board that contains cards
|
|
*/
|
|
async deleteEmptyPerSwimlaneLists(boardId) {
|
|
const lists = ReactiveCache.getLists({ boardId });
|
|
const cards = ReactiveCache.getCards({ boardId });
|
|
|
|
let listsDeleted = 0;
|
|
|
|
for (const list of lists) {
|
|
// Safety check 1: List must have a swimlaneId (must be per-swimlane, not shared)
|
|
if (!list.swimlaneId || list.swimlaneId === '') {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Skipping list "${list.title}" - no swimlaneId (shared list)`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Safety check 2: List must have no cards
|
|
const listCards = cards.filter(card => card.listId === list._id);
|
|
if (listCards.length > 0) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Skipping list "${list.title}" - has ${listCards.length} cards`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Safety check 3: There must be another list with the same title on the same board that has cards
|
|
const duplicateListsWithSameTitle = lists.filter(l =>
|
|
l._id !== list._id &&
|
|
l.title === list.title &&
|
|
l.boardId === boardId
|
|
);
|
|
|
|
let hasDuplicateWithCards = false;
|
|
for (const duplicateList of duplicateListsWithSameTitle) {
|
|
const duplicateListCards = cards.filter(card => card.listId === duplicateList._id);
|
|
if (duplicateListCards.length > 0) {
|
|
hasDuplicateWithCards = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!hasDuplicateWithCards) {
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Skipping list "${list.title}" - no duplicate list with same title that has cards`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// All safety checks passed - delete the empty per-swimlane list
|
|
Lists.remove(list._id);
|
|
listsDeleted++;
|
|
|
|
if (process.env.DEBUG === 'true') {
|
|
console.log(`Deleted empty per-swimlane list: "${list.title}" (swimlane: ${list.swimlaneId}) - duplicate with cards exists`);
|
|
}
|
|
}
|
|
|
|
return { listsDeleted };
|
|
}
|
|
|
|
/**
|
|
* Get detailed status of empty lists
|
|
*/
|
|
async getStatus(boardId) {
|
|
const lists = ReactiveCache.getLists({ boardId });
|
|
const cards = ReactiveCache.getCards({ boardId });
|
|
|
|
const sharedLists = [];
|
|
const emptyPerSwimlaneLists = [];
|
|
const nonEmptyLists = [];
|
|
|
|
for (const list of lists) {
|
|
const listCards = cards.filter(card => card.listId === list._id);
|
|
const isShared = !list.swimlaneId || list.swimlaneId === '';
|
|
const isEmpty = listCards.length === 0;
|
|
|
|
if (isShared) {
|
|
sharedLists.push({
|
|
id: list._id,
|
|
title: list.title,
|
|
cardCount: listCards.length
|
|
});
|
|
} else if (isEmpty) {
|
|
emptyPerSwimlaneLists.push({
|
|
id: list._id,
|
|
title: list.title,
|
|
swimlaneId: list.swimlaneId
|
|
});
|
|
} else {
|
|
nonEmptyLists.push({
|
|
id: list._id,
|
|
title: list.title,
|
|
swimlaneId: list.swimlaneId,
|
|
cardCount: listCards.length
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
sharedListsCount: sharedLists.length,
|
|
emptyPerSwimlaneLists: emptyPerSwimlaneLists.length,
|
|
totalLists: lists.length,
|
|
details: {
|
|
sharedLists,
|
|
emptyPerSwimlaneLists,
|
|
nonEmptyLists
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
const deleteDuplicateEmptyListsMigration = new DeleteDuplicateEmptyListsMigration();
|
|
|
|
// Register Meteor methods
|
|
Meteor.methods({
|
|
'deleteDuplicateEmptyLists.needsMigration'(boardId) {
|
|
check(boardId, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
|
}
|
|
|
|
return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
|
|
},
|
|
|
|
'deleteDuplicateEmptyLists.execute'(boardId) {
|
|
check(boardId, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
|
}
|
|
|
|
// Check if user is board admin
|
|
const board = ReactiveCache.getBoard(boardId);
|
|
if (!board) {
|
|
throw new Meteor.Error('board-not-found', 'Board not found');
|
|
}
|
|
|
|
const user = ReactiveCache.getUser(this.userId);
|
|
if (!user) {
|
|
throw new Meteor.Error('user-not-found', 'User not found');
|
|
}
|
|
|
|
// Only board admins can run migrations
|
|
const isBoardAdmin = board.members && board.members.some(
|
|
member => member.userId === this.userId && member.isAdmin
|
|
);
|
|
|
|
if (!isBoardAdmin && !user.isAdmin) {
|
|
throw new Meteor.Error('not-authorized', 'Only board administrators can run migrations');
|
|
}
|
|
|
|
return deleteDuplicateEmptyListsMigration.executeMigration(boardId);
|
|
},
|
|
|
|
'deleteDuplicateEmptyLists.getStatus'(boardId) {
|
|
check(boardId, String);
|
|
|
|
if (!this.userId) {
|
|
throw new Meteor.Error('not-authorized', 'You must be logged in');
|
|
}
|
|
|
|
return deleteDuplicateEmptyListsMigration.getStatus(boardId);
|
|
}
|
|
});
|
|
|
|
export default deleteDuplicateEmptyListsMigration;
|