mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 23:40:13 +01:00
When opening board, migrate from Shared Lists to Per-Swimlane Lists.
Thanks to xet7 ! Fixes #5952
This commit is contained in:
parent
48b645ee1e
commit
1e6252de7f
8 changed files with 112 additions and 219 deletions
|
|
@ -112,6 +112,9 @@ BlazeComponent.extendComponent({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert shared lists to per-swimlane lists if needed
|
||||||
|
await this.convertSharedListsToPerSwimlane(boardId);
|
||||||
|
|
||||||
// Start attachment migration in background if needed
|
// Start attachment migration in background if needed
|
||||||
this.startAttachmentMigrationIfNeeded(boardId);
|
this.startAttachmentMigrationIfNeeded(boardId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -139,6 +142,100 @@ BlazeComponent.extendComponent({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async convertSharedListsToPerSwimlane(boardId) {
|
||||||
|
try {
|
||||||
|
const board = ReactiveCache.getBoard(boardId);
|
||||||
|
if (!board) return;
|
||||||
|
|
||||||
|
// Check if board has already been processed for shared lists conversion
|
||||||
|
if (board.hasSharedListsConverted) {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Board ${boardId} has already been processed for shared lists conversion`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all lists for this board
|
||||||
|
const allLists = board.lists();
|
||||||
|
const swimlanes = board.swimlanes();
|
||||||
|
|
||||||
|
if (swimlanes.length === 0) {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Board ${boardId} has no swimlanes, skipping shared lists conversion`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find shared lists (lists with empty swimlaneId or null swimlaneId)
|
||||||
|
const sharedLists = allLists.filter(list => !list.swimlaneId || list.swimlaneId === '');
|
||||||
|
|
||||||
|
if (sharedLists.length === 0) {
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Board ${boardId} has no shared lists to convert`);
|
||||||
|
}
|
||||||
|
// Mark as processed even if no shared lists
|
||||||
|
Meteor.call('boards.update', boardId, { $set: { hasSharedListsConverted: true } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Converting ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each shared list to per-swimlane lists
|
||||||
|
for (const sharedList of sharedLists) {
|
||||||
|
// Create a copy of the list for each swimlane
|
||||||
|
for (const swimlane of swimlanes) {
|
||||||
|
// Check if this list already exists in this swimlane
|
||||||
|
const existingList = Lists.findOne({
|
||||||
|
boardId: boardId,
|
||||||
|
swimlaneId: swimlane._id,
|
||||||
|
title: sharedList.title
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingList) {
|
||||||
|
// 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,
|
||||||
|
createdAt: new Date(),
|
||||||
|
modifiedAt: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archive or remove the original shared list
|
||||||
|
Lists.update(sharedList._id, {
|
||||||
|
$set: {
|
||||||
|
archived: true,
|
||||||
|
modifiedAt: new Date()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark board as processed
|
||||||
|
Meteor.call('boards.update', boardId, { $set: { hasSharedListsConverted: true } });
|
||||||
|
|
||||||
|
if (process.env.DEBUG === 'true') {
|
||||||
|
console.log(`Successfully converted ${sharedLists.length} shared lists to per-swimlane lists for board ${boardId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error converting shared lists to per-swimlane:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async startAttachmentMigrationIfNeeded(boardId) {
|
async startAttachmentMigrationIfNeeded(boardId) {
|
||||||
try {
|
try {
|
||||||
// Check if board has already been migrated
|
// Check if board has already been migrated
|
||||||
|
|
|
||||||
|
|
@ -130,11 +130,6 @@ template(name="boardHeaderBar")
|
||||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||||
| ❌
|
| ❌
|
||||||
|
|
||||||
if currentUser.isBoardAdmin
|
|
||||||
a.board-header-btn.js-restore-legacy-lists(title="{{_ 'restore-legacy-lists'}}")
|
|
||||||
| 🔄
|
|
||||||
| {{_ 'legacy-lists'}}
|
|
||||||
|
|
||||||
.separator
|
.separator
|
||||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||||
| ☰
|
| ☰
|
||||||
|
|
|
||||||
|
|
@ -152,32 +152,10 @@ BlazeComponent.extendComponent({
|
||||||
'click .js-log-in'() {
|
'click .js-log-in'() {
|
||||||
FlowRouter.go('atSignIn');
|
FlowRouter.go('atSignIn');
|
||||||
},
|
},
|
||||||
'click .js-restore-legacy-lists'() {
|
|
||||||
this.restoreLegacyLists();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
restoreLegacyLists() {
|
|
||||||
// Show confirmation dialog
|
|
||||||
if (confirm('Are you sure you want to restore legacy lists to their original shared state? This will make them appear in all swimlanes.')) {
|
|
||||||
// Call cron method to restore legacy lists
|
|
||||||
Meteor.call('cron.triggerRestoreLegacyLists', (error, result) => {
|
|
||||||
if (error) {
|
|
||||||
console.error('Error restoring legacy lists:', error);
|
|
||||||
alert(`Error: ${error.message}`);
|
|
||||||
} else {
|
|
||||||
console.log('Successfully triggered restore legacy lists migration:', result);
|
|
||||||
alert(`Migration triggered successfully. Job ID: ${result.jobId}`);
|
|
||||||
// Refresh the board to show the restored lists
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}).register('boardHeaderBar');
|
}).register('boardHeaderBar');
|
||||||
|
|
||||||
Template.boardHeaderBar.helpers({
|
Template.boardHeaderBar.helpers({
|
||||||
|
|
|
||||||
|
|
@ -1488,10 +1488,5 @@
|
||||||
"weight": "Weight",
|
"weight": "Weight",
|
||||||
"idle": "Idle",
|
"idle": "Idle",
|
||||||
"complete": "Complete",
|
"complete": "Complete",
|
||||||
"cron": "Cron",
|
"cron": "Cron"
|
||||||
"legacy-lists": "Legacy Lists",
|
|
||||||
"restore-legacy-lists": "Restore Legacy Lists",
|
|
||||||
"legacy-lists-restore": "Legacy Lists Restore",
|
|
||||||
"legacy-lists-restore-description": "Restore legacy lists to their original shared state across all swimlanes",
|
|
||||||
"restoring-legacy-lists": "Restoring legacy lists"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -778,10 +778,11 @@ Boards.helpers({
|
||||||
return this.permission === 'public';
|
return this.permission === 'public';
|
||||||
},
|
},
|
||||||
|
|
||||||
hasLegacyLists() {
|
hasSharedListsConverted() {
|
||||||
return this.hasLegacyLists === true;
|
return this.hasSharedListsConverted === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
cards() {
|
cards() {
|
||||||
const ret = ReactiveCache.getCards(
|
const ret = ReactiveCache.getCards(
|
||||||
{ boardId: this._id, archived: false },
|
{ boardId: this._id, archived: false },
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,6 @@ if (errors.length > 0) {
|
||||||
// Import cron job storage for persistent job tracking
|
// Import cron job storage for persistent job tracking
|
||||||
import './cronJobStorage';
|
import './cronJobStorage';
|
||||||
|
|
||||||
// Import board migration detector for automatic board migrations
|
// Note: Automatic migrations are disabled - migrations only run when opening boards
|
||||||
import './boardMigrationDetector';
|
// import './boardMigrationDetector';
|
||||||
|
// import './cronMigrationManager';
|
||||||
// Import cron migration manager for cron-based migrations
|
|
||||||
import './cronMigrationManager';
|
|
||||||
|
|
|
||||||
|
|
@ -337,13 +337,13 @@ class BoardMigrationDetector {
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
export const boardMigrationDetector = new BoardMigrationDetector();
|
export const boardMigrationDetector = new BoardMigrationDetector();
|
||||||
|
|
||||||
// Start the detector on server startup
|
// Note: Automatic migration detector is disabled - migrations only run when opening boards
|
||||||
Meteor.startup(() => {
|
// Meteor.startup(() => {
|
||||||
// Wait a bit for the system to initialize
|
// // Wait a bit for the system to initialize
|
||||||
Meteor.setTimeout(() => {
|
// Meteor.setTimeout(() => {
|
||||||
boardMigrationDetector.start();
|
// boardMigrationDetector.start();
|
||||||
}, 10000); // Start after 10 seconds
|
// }, 10000); // Start after 10 seconds
|
||||||
});
|
// });
|
||||||
|
|
||||||
// Meteor methods for client access
|
// Meteor methods for client access
|
||||||
Meteor.methods({
|
Meteor.methods({
|
||||||
|
|
|
||||||
|
|
@ -233,17 +233,6 @@ class CronMigrationManager {
|
||||||
schedule: 'every 1 minute',
|
schedule: 'every 1 minute',
|
||||||
status: 'stopped'
|
status: 'stopped'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'restore-legacy-lists',
|
|
||||||
name: 'Restore Legacy Lists',
|
|
||||||
description: 'Restore legacy lists to their original shared state across all swimlanes',
|
|
||||||
weight: 3,
|
|
||||||
completed: false,
|
|
||||||
progress: 0,
|
|
||||||
cronName: 'migration_restore_legacy_lists',
|
|
||||||
schedule: 'every 1 minute',
|
|
||||||
status: 'stopped'
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -441,14 +430,6 @@ class CronMigrationManager {
|
||||||
{ name: 'Cleanup old data', duration: 1000 }
|
{ name: 'Cleanup old data', duration: 1000 }
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'restore-legacy-lists':
|
|
||||||
steps.push(
|
|
||||||
{ name: 'Identify legacy lists', duration: 1000 },
|
|
||||||
{ name: 'Restore lists to shared state', duration: 2000 },
|
|
||||||
{ name: 'Update board settings', duration: 500 },
|
|
||||||
{ name: 'Verify restoration', duration: 500 }
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
steps.push(
|
steps.push(
|
||||||
{ name: `Execute ${step.name}`, duration: 2000 },
|
{ name: `Execute ${step.name}`, duration: 2000 },
|
||||||
|
|
@ -465,10 +446,7 @@ class CronMigrationManager {
|
||||||
async executeMigrationStep(jobId, stepIndex, stepData, stepId) {
|
async executeMigrationStep(jobId, stepIndex, stepData, stepId) {
|
||||||
const { name, duration } = stepData;
|
const { name, duration } = stepData;
|
||||||
|
|
||||||
if (stepId === 'restore-legacy-lists') {
|
// Simulate step execution with progress updates for other migrations
|
||||||
await this.executeRestoreLegacyListsMigration(jobId, stepIndex, stepData);
|
|
||||||
} else {
|
|
||||||
// Simulate step execution with progress updates for other migrations
|
|
||||||
const progressSteps = 10;
|
const progressSteps = 10;
|
||||||
for (let i = 0; i <= progressSteps; i++) {
|
for (let i = 0; i <= progressSteps; i++) {
|
||||||
const progress = Math.round((i / progressSteps) * 100);
|
const progress = Math.round((i / progressSteps) * 100);
|
||||||
|
|
@ -485,95 +463,6 @@ class CronMigrationManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the restore legacy lists migration
|
|
||||||
*/
|
|
||||||
async executeRestoreLegacyListsMigration(jobId, stepIndex, stepData) {
|
|
||||||
const { name } = stepData;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Import collections directly for server-side access
|
|
||||||
const { default: Boards } = await import('/models/boards');
|
|
||||||
const { default: Lists } = await import('/models/lists');
|
|
||||||
|
|
||||||
// Step 1: Identify legacy lists
|
|
||||||
cronJobStorage.saveJobStep(jobId, stepIndex, {
|
|
||||||
progress: 25,
|
|
||||||
currentAction: 'Identifying legacy lists...'
|
|
||||||
});
|
|
||||||
|
|
||||||
const boards = Boards.find({}).fetch();
|
|
||||||
const migrationDate = new Date('2025-10-10T21:14:44.000Z'); // Date of commit 719ef87efceacfe91461a8eeca7cf74d11f4cc0a
|
|
||||||
let totalLegacyLists = 0;
|
|
||||||
|
|
||||||
for (const board of boards) {
|
|
||||||
const allLists = Lists.find({ boardId: board._id }).fetch();
|
|
||||||
const legacyLists = allLists.filter(list => {
|
|
||||||
const listDate = list.createdAt || new Date(0);
|
|
||||||
return listDate < migrationDate && list.swimlaneId && list.swimlaneId !== '';
|
|
||||||
});
|
|
||||||
totalLegacyLists += legacyLists.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Restore lists to shared state
|
|
||||||
cronJobStorage.saveJobStep(jobId, stepIndex, {
|
|
||||||
progress: 50,
|
|
||||||
currentAction: 'Restoring lists to shared state...'
|
|
||||||
});
|
|
||||||
|
|
||||||
let restoredCount = 0;
|
|
||||||
|
|
||||||
for (const board of boards) {
|
|
||||||
const allLists = Lists.find({ boardId: board._id }).fetch();
|
|
||||||
const legacyLists = allLists.filter(list => {
|
|
||||||
const listDate = list.createdAt || new Date(0);
|
|
||||||
return listDate < migrationDate && list.swimlaneId && list.swimlaneId !== '';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore legacy lists to shared state (empty swimlaneId)
|
|
||||||
for (const list of legacyLists) {
|
|
||||||
Lists.direct.update(list._id, {
|
|
||||||
$set: {
|
|
||||||
swimlaneId: ''
|
|
||||||
}
|
|
||||||
});
|
|
||||||
restoredCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the board as having legacy lists
|
|
||||||
if (legacyLists.length > 0) {
|
|
||||||
Boards.direct.update(board._id, {
|
|
||||||
$set: {
|
|
||||||
hasLegacyLists: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Update board settings
|
|
||||||
cronJobStorage.saveJobStep(jobId, stepIndex, {
|
|
||||||
progress: 75,
|
|
||||||
currentAction: 'Updating board settings...'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step 4: Verify restoration
|
|
||||||
cronJobStorage.saveJobStep(jobId, stepIndex, {
|
|
||||||
progress: 100,
|
|
||||||
currentAction: `Verification complete. Restored ${restoredCount} legacy lists.`
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Successfully restored ${restoredCount} legacy lists across ${boards.length} boards`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during restore legacy lists migration:', error);
|
|
||||||
cronJobStorage.saveJobStep(jobId, stepIndex, {
|
|
||||||
progress: 0,
|
|
||||||
currentAction: `Error: ${error.message}`,
|
|
||||||
status: 'error'
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a board operation job
|
* Execute a board operation job
|
||||||
|
|
@ -1420,53 +1309,6 @@ class CronMigrationManager {
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Trigger restore legacy lists migration
|
|
||||||
*/
|
|
||||||
async triggerRestoreLegacyListsMigration() {
|
|
||||||
try {
|
|
||||||
// Find the restore legacy lists step
|
|
||||||
const step = this.migrationSteps.find(s => s.id === 'restore-legacy-lists');
|
|
||||||
if (!step) {
|
|
||||||
throw new Error('Restore legacy lists migration step not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a job for this migration
|
|
||||||
const jobId = `restore_legacy_lists_${Date.now()}`;
|
|
||||||
cronJobStorage.addToQueue(jobId, 'migration', step.weight, {
|
|
||||||
stepId: step.id,
|
|
||||||
stepName: step.name,
|
|
||||||
stepDescription: step.description
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save initial job status
|
|
||||||
cronJobStorage.saveJobStatus(jobId, {
|
|
||||||
jobType: 'migration',
|
|
||||||
status: 'pending',
|
|
||||||
progress: 0,
|
|
||||||
stepId: step.id,
|
|
||||||
stepName: step.name
|
|
||||||
});
|
|
||||||
|
|
||||||
// Execute the migration immediately
|
|
||||||
const jobData = {
|
|
||||||
stepId: step.id,
|
|
||||||
stepName: step.name,
|
|
||||||
stepDescription: step.description
|
|
||||||
};
|
|
||||||
await this.executeMigrationJob(jobId, jobData);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
jobId: jobId,
|
|
||||||
message: 'Restore legacy lists migration triggered successfully'
|
|
||||||
};
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error triggering restore legacy lists migration:', error);
|
|
||||||
throw new Meteor.Error('migration-trigger-failed', `Failed to trigger migration: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|
@ -1666,17 +1508,4 @@ Meteor.methods({
|
||||||
return boardMigrationDetector.forceScan();
|
return boardMigrationDetector.forceScan();
|
||||||
},
|
},
|
||||||
|
|
||||||
'cron.triggerRestoreLegacyLists'() {
|
|
||||||
if (!this.userId) {
|
|
||||||
throw new Meteor.Error('not-authorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is admin (optional - you can remove this if you want any user to trigger it)
|
|
||||||
const user = ReactiveCache.getCurrentUser();
|
|
||||||
if (!user || !user.isAdmin) {
|
|
||||||
throw new Meteor.Error('not-authorized', 'Only administrators can trigger this migration');
|
|
||||||
}
|
|
||||||
|
|
||||||
return cronMigrationManager.triggerRestoreLegacyListsMigration();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue