Browse Source

Fix 8.16 Lists with no items are deleted every time when board is opened. Moved migrations to right sidebar.

Thanks to xet7 !

Fixes #5994
Lauri Ojansivu 3 days ago
parent
commit
7713e613b4

+ 0 - 1
client/components/boards/boardBody.js

@@ -146,7 +146,6 @@ BlazeComponent.extendComponent({
         { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
         { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 2000 },
         { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
         { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 3000 },
         { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
         { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 1500 },
-        { step: 'cleanup_empty_lists', name: 'Cleanup Empty Lists', duration: 1000 },
         { step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
         { step: 'validate_migration', name: 'Validate Migration', duration: 1000 },
         { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
         { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 1000 },
         { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }
         { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 1000 }

+ 42 - 0
client/components/sidebar/sidebarMigrations.jade

@@ -28,6 +28,36 @@ template(name='migrationsSidebar')
               else
               else
                 span.badge.badge-success {{_ 'migration-complete'}}
                 span.badge.badge-success {{_ 'migration-complete'}}
         
         
+        .migration-item
+          a.js-run-migration(data-migration="deleteDuplicateEmptyLists")
+            .migration-name
+              | {{_ 'delete-duplicate-empty-lists-migration'}}
+            .migration-status
+              if deleteDuplicateEmptyListsNeeded
+                span.badge.badge-warning {{_ 'migration-needed'}}
+              else
+                span.badge.badge-success {{_ 'migration-complete'}}
+        
+        .migration-item
+          a.js-run-migration(data-migration="restoreLostCards")
+            .migration-name
+              | {{_ 'restore-lost-cards-migration'}}
+            .migration-status
+              if restoreLostCardsNeeded
+                span.badge.badge-warning {{_ 'migration-needed'}}
+              else
+                span.badge.badge-success {{_ 'migration-complete'}}
+        
+        .migration-item
+          a.js-run-migration(data-migration="restoreAllArchived")
+            .migration-name
+              | {{_ 'restore-all-archived-migration'}}
+            .migration-status
+              if restoreAllArchivedNeeded
+                span.badge.badge-warning {{_ 'migration-needed'}}
+              else
+                span.badge.badge-success {{_ 'migration-complete'}}
+        
         hr
         hr
         h4 {{_ 'global-migrations'}}
         h4 {{_ 'global-migrations'}}
         .migration-item
         .migration-item
@@ -60,6 +90,18 @@ template(name='runFixMissingListsMigrationPopup')
   p {{_ 'run-fix-missing-lists-migration-confirm'}}
   p {{_ 'run-fix-missing-lists-migration-confirm'}}
   button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
   button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
 
 
+template(name='runDeleteDuplicateEmptyListsMigrationPopup')
+  p {{_ 'run-delete-duplicate-empty-lists-migration-confirm'}}
+  button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runRestoreLostCardsMigrationPopup')
+  p {{_ 'run-restore-lost-cards-migration-confirm'}}
+  button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
+template(name='runRestoreAllArchivedMigrationPopup')
+  p {{_ 'run-restore-all-archived-migration-confirm'}}
+  button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
+
 template(name='runFixAvatarUrlsMigrationPopup')
 template(name='runFixAvatarUrlsMigrationPopup')
   p {{_ 'run-fix-avatar-urls-migration-confirm'}}
   p {{_ 'run-fix-avatar-urls-migration-confirm'}}
   button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}
   button.js-confirm.primary.full(type="submit") {{_ 'run-migration'}}

+ 241 - 28
client/components/sidebar/sidebarMigrations.js

@@ -1,5 +1,6 @@
 import { ReactiveCache } from '/imports/reactiveCache';
 import { ReactiveCache } from '/imports/reactiveCache';
 import { TAPi18n } from '/imports/i18n';
 import { TAPi18n } from '/imports/i18n';
+import { migrationProgressManager } from '/client/components/migrationProgress';
 
 
 BlazeComponent.extendComponent({
 BlazeComponent.extendComponent({
   onCreated() {
   onCreated() {
@@ -29,11 +30,38 @@ BlazeComponent.extendComponent({
       }
       }
     });
     });
 
 
+    // Check delete duplicate empty lists migration
+    Meteor.call('deleteDuplicateEmptyLists.needsMigration', boardId, (err, res) => {
+      if (!err) {
+        const statuses = this.migrationStatuses.get();
+        statuses.deleteDuplicateEmptyLists = res;
+        this.migrationStatuses.set(statuses);
+      }
+    });
+
+    // Check restore lost cards migration
+    Meteor.call('restoreLostCards.needsMigration', boardId, (err, res) => {
+      if (!err) {
+        const statuses = this.migrationStatuses.get();
+        statuses.restoreLostCards = res;
+        this.migrationStatuses.set(statuses);
+      }
+    });
+
+    // Check restore all archived migration
+    Meteor.call('restoreAllArchived.needsMigration', boardId, (err, res) => {
+      if (!err) {
+        const statuses = this.migrationStatuses.get();
+        statuses.restoreAllArchived = res;
+        this.migrationStatuses.set(statuses);
+      }
+    });
+
     // Check fix avatar URLs migration (global)
     // Check fix avatar URLs migration (global)
-    Meteor.call('fixAvatarUrls.needsMigration', (err, res) => {
+      Meteor.call('fixAvatarUrls.needsMigration', (err, res) => {
       if (!err) {
       if (!err) {
         const statuses = this.migrationStatuses.get();
         const statuses = this.migrationStatuses.get();
-        statuses.fixAvatarUrls = res;
+          statuses.fixAvatarUrls = res;
         this.migrationStatuses.set(statuses);
         this.migrationStatuses.set(statuses);
       }
       }
     });
     });
@@ -56,6 +84,22 @@ BlazeComponent.extendComponent({
     return this.migrationStatuses.get().fixMissingLists === true;
     return this.migrationStatuses.get().fixMissingLists === true;
   },
   },
 
 
+  deleteEmptyListsNeeded() {
+    return this.migrationStatuses.get().deleteEmptyLists === true;
+  },
+
+  deleteDuplicateEmptyListsNeeded() {
+    return this.migrationStatuses.get().deleteDuplicateEmptyLists === true;
+  },
+
+  restoreLostCardsNeeded() {
+    return this.migrationStatuses.get().restoreLostCards === true;
+  },
+
+  restoreAllArchivedNeeded() {
+    return this.migrationStatuses.get().restoreAllArchived === true;
+  },
+
   fixAvatarUrlsNeeded() {
   fixAvatarUrlsNeeded() {
     return this.migrationStatuses.get().fixAvatarUrls === true;
     return this.migrationStatuses.get().fixAvatarUrls === true;
   },
   },
@@ -64,6 +108,58 @@ BlazeComponent.extendComponent({
     return this.migrationStatuses.get().fixAllFileUrls === true;
     return this.migrationStatuses.get().fixAllFileUrls === true;
   },
   },
 
 
+  // Simulate migration progress updates using the global progress popup
+  async simulateMigrationProgress(progressSteps) {
+    const totalSteps = progressSteps.length;
+    for (let i = 0; i < progressSteps.length; i++) {
+      const step = progressSteps[i];
+      const overall = Math.round(((i + 1) / totalSteps) * 100);
+
+      // Start step
+      migrationProgressManager.updateProgress({
+        overallProgress: overall,
+        currentStep: i + 1,
+        totalSteps,
+        stepName: step.step,
+        stepProgress: 0,
+        stepStatus: `Starting ${step.name}...`,
+        stepDetails: null,
+        boardId: Session.get('currentBoard'),
+      });
+
+      const stepDuration = step.duration;
+      const updateInterval = 100;
+      const totalUpdates = Math.max(1, Math.floor(stepDuration / updateInterval));
+      for (let j = 0; j < totalUpdates; j++) {
+        const per = Math.round(((j + 1) / totalUpdates) * 100);
+        migrationProgressManager.updateProgress({
+          overallProgress: overall,
+          currentStep: i + 1,
+          totalSteps,
+          stepName: step.step,
+          stepProgress: per,
+          stepStatus: `Processing ${step.name}...`,
+          stepDetails: { progress: `${per}%` },
+          boardId: Session.get('currentBoard'),
+        });
+        // eslint-disable-next-line no-await-in-loop
+        await new Promise((r) => setTimeout(r, updateInterval));
+      }
+
+      // Complete step
+      migrationProgressManager.updateProgress({
+        overallProgress: overall,
+        currentStep: i + 1,
+        totalSteps,
+        stepName: step.step,
+        stepProgress: 100,
+        stepStatus: `${step.name} completed`,
+        stepDetails: { status: 'completed' },
+        boardId: Session.get('currentBoard'),
+      });
+    }
+  },
+
   runMigration(migrationType) {
   runMigration(migrationType) {
     const boardId = Session.get('currentBoard');
     const boardId = Session.get('currentBoard');
     
     
@@ -81,6 +177,26 @@ BlazeComponent.extendComponent({
         methodArgs = [boardId];
         methodArgs = [boardId];
         break;
         break;
       
       
+      case 'deleteEmptyLists':
+        methodName = 'deleteEmptyLists.execute';
+        methodArgs = [boardId];
+        break;
+      
+      case 'deleteDuplicateEmptyLists':
+        methodName = 'deleteDuplicateEmptyLists.execute';
+        methodArgs = [boardId];
+        break;
+      
+      case 'restoreLostCards':
+        methodName = 'restoreLostCards.execute';
+        methodArgs = [boardId];
+        break;
+      
+      case 'restoreAllArchived':
+        methodName = 'restoreAllArchived.execute';
+        methodArgs = [boardId];
+        break;
+      
       case 'fixAvatarUrls':
       case 'fixAvatarUrls':
         methodName = 'fixAvatarUrls.execute';
         methodName = 'fixAvatarUrls.execute';
         break;
         break;
@@ -91,17 +207,104 @@ BlazeComponent.extendComponent({
     }
     }
     
     
     if (methodName) {
     if (methodName) {
-      Meteor.call(methodName, ...methodArgs, (err, result) => {
-        if (err) {
-          console.error('Migration failed:', err);
-          // Show error notification
-          Alert.error(TAPi18n.__('migration-failed') + ': ' + (err.message || err.reason));
+      // Define simulated steps per migration type
+      const stepsByType = {
+        comprehensive: [
+          { step: 'analyze_board_structure', name: 'Analyze Board Structure', duration: 800 },
+          { step: 'fix_orphaned_cards', name: 'Fix Orphaned Cards', duration: 1200 },
+          { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 1000 },
+          { step: 'ensure_per_swimlane_lists', name: 'Ensure Per-Swimlane Lists', duration: 800 },
+          { step: 'validate_migration', name: 'Validate Migration', duration: 800 },
+          { step: 'fix_avatar_urls', name: 'Fix Avatar URLs', duration: 600 },
+          { step: 'fix_attachment_urls', name: 'Fix Attachment URLs', duration: 600 },
+        ],
+        fixMissingLists: [
+          { step: 'analyze_lists', name: 'Analyze Lists', duration: 600 },
+          { step: 'create_missing_lists', name: 'Create Missing Lists', duration: 900 },
+          { step: 'update_cards', name: 'Update Cards', duration: 900 },
+          { step: 'finalize', name: 'Finalize', duration: 400 },
+        ],
+        deleteEmptyLists: [
+          { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 },
+          { step: 'delete_empty_lists', name: 'Delete Empty Lists', duration: 800 },
+        ],
+        deleteDuplicateEmptyLists: [
+          { step: 'convert_shared_lists', name: 'Convert Shared Lists', duration: 700 },
+          { step: 'delete_duplicate_empty_lists', name: 'Delete Duplicate Empty Lists', duration: 800 },
+        ],
+        restoreLostCards: [
+          { step: 'ensure_lost_cards_swimlane', name: 'Ensure Lost Cards Swimlane', duration: 600 },
+          { step: 'restore_lists', name: 'Restore Lists', duration: 800 },
+          { step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
+        ],
+        restoreAllArchived: [
+          { step: 'restore_swimlanes', name: 'Restore Swimlanes', duration: 800 },
+          { step: 'restore_lists', name: 'Restore Lists', duration: 900 },
+          { step: 'restore_cards', name: 'Restore Cards', duration: 1000 },
+          { step: 'fix_missing_ids', name: 'Fix Missing IDs', duration: 600 },
+        ],
+        fixAvatarUrls: [
+          { step: 'scan_users', name: 'Scan Users', duration: 500 },
+          { step: 'fix_urls', name: 'Fix Avatar URLs', duration: 900 },
+        ],
+        fixAllFileUrls: [
+          { step: 'scan_files', name: 'Scan Files', duration: 600 },
+          { step: 'fix_urls', name: 'Fix File URLs', duration: 1000 },
+        ],
+      };
+
+      const steps = stepsByType[migrationType] || [
+        { step: 'running', name: 'Running Migration', duration: 1000 },
+      ];
+
+      // Kick off popup and simulated progress
+      migrationProgressManager.startMigration();
+      const progressPromise = this.simulateMigrationProgress(steps);
+
+      // Start migration call
+      const callPromise = new Promise((resolve, reject) => {
+        Meteor.call(methodName, ...methodArgs, (err, result) => {
+          if (err) return reject(err);
+          return resolve(result);
+        });
+      });
+
+      Promise.allSettled([callPromise, progressPromise]).then(([callRes]) => {
+        if (callRes.status === 'rejected') {
+          migrationProgressManager.failMigration(callRes.reason);
         } else {
         } else {
-          console.log('Migration completed:', result);
-          // Show success notification
-          Alert.success(TAPi18n.__('migration-successful'));
-          
-          // Reload migration statuses
+          const result = callRes.value;
+          // Summarize result details in the popup
+          let summary = {};
+          if (result && result.results) {
+            // Comprehensive returns {success, results}
+            const r = result.results;
+            summary = {
+              totalCardsProcessed: r.totalCardsProcessed,
+              totalListsProcessed: r.totalListsProcessed,
+              totalListsCreated: r.totalListsCreated,
+            };
+          } else if (result && result.changes) {
+            // Many migrations return a changes string array
+            summary = { changes: result.changes.join(' | ') };
+          } else if (result && typeof result === 'object') {
+            summary = result;
+          }
+
+          migrationProgressManager.updateProgress({
+            overallProgress: 100,
+            currentStep: steps.length,
+            totalSteps: steps.length,
+            stepName: 'completed',
+            stepProgress: 100,
+            stepStatus: 'Migration completed',
+            stepDetails: summary,
+            boardId: Session.get('currentBoard'),
+          });
+
+          migrationProgressManager.completeMigration();
+
+          // Refresh status badges slightly after
           Meteor.setTimeout(() => {
           Meteor.setTimeout(() => {
             this.loadMigrationStatuses();
             this.loadMigrationStatuses();
           }, 1000);
           }, 1000);
@@ -111,31 +314,41 @@ BlazeComponent.extendComponent({
   },
   },
 
 
   events() {
   events() {
+    const self = this; // Capture component reference
+    
     return [
     return [
       {
       {
         'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
         'click .js-run-migration[data-migration="comprehensive"]': Popup.afterConfirm('runComprehensiveMigration', function() {
-          const component = BlazeComponent.getComponentForElement(this);
-          if (component) {
-            component.runMigration('comprehensive');
-          }
+          self.runMigration('comprehensive');
+          Popup.back();
         }),
         }),
         'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
         'click .js-run-migration[data-migration="fixMissingLists"]': Popup.afterConfirm('runFixMissingListsMigration', function() {
-          const component = BlazeComponent.getComponentForElement(this);
-          if (component) {
-            component.runMigration('fixMissingLists');
-          }
+          self.runMigration('fixMissingLists');
+          Popup.back();
+        }),
+        'click .js-run-migration[data-migration="deleteEmptyLists"]': Popup.afterConfirm('runDeleteEmptyListsMigration', function() {
+          self.runMigration('deleteEmptyLists');
+          Popup.back();
+        }),
+        'click .js-run-migration[data-migration="deleteDuplicateEmptyLists"]': Popup.afterConfirm('runDeleteDuplicateEmptyListsMigration', function() {
+          self.runMigration('deleteDuplicateEmptyLists');
+          Popup.back();
+        }),
+        'click .js-run-migration[data-migration="restoreLostCards"]': Popup.afterConfirm('runRestoreLostCardsMigration', function() {
+          self.runMigration('restoreLostCards');
+          Popup.back();
+        }),
+        'click .js-run-migration[data-migration="restoreAllArchived"]': Popup.afterConfirm('runRestoreAllArchivedMigration', function() {
+          self.runMigration('restoreAllArchived');
+          Popup.back();
         }),
         }),
         'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() {
         'click .js-run-migration[data-migration="fixAvatarUrls"]': Popup.afterConfirm('runFixAvatarUrlsMigration', function() {
-          const component = BlazeComponent.getComponentForElement(this);
-          if (component) {
-            component.runMigration('fixAvatarUrls');
-          }
+          self.runMigration('fixAvatarUrls');
+          Popup.back();
         }),
         }),
         'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
         'click .js-run-migration[data-migration="fixAllFileUrls"]': Popup.afterConfirm('runFixAllFileUrlsMigration', function() {
-          const component = BlazeComponent.getComponentForElement(this);
-          if (component) {
-            component.runMigration('fixAllFileUrls');
-          }
+          self.runMigration('fixAllFileUrls');
+          Popup.back();
         }),
         }),
       },
       },
     ];
     ];

+ 44 - 0
imports/i18n/data/en.i18n.json

@@ -1408,6 +1408,16 @@
   "card-show-lists-on-minicard": "Show Lists on Minicard",
   "card-show-lists-on-minicard": "Show Lists on Minicard",
   "comprehensive-board-migration": "Comprehensive Board Migration",
   "comprehensive-board-migration": "Comprehensive Board Migration",
   "comprehensive-board-migration-description": "Performs comprehensive checks and fixes for board data integrity, including list ordering, card positions, and swimlane structure.",
   "comprehensive-board-migration-description": "Performs comprehensive checks and fixes for board data integrity, including list ordering, card positions, and swimlane structure.",
+  "delete-empty-lists-migration": "Delete Empty Lists",
+  "delete-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.",
+  "delete-duplicate-empty-lists-migration": "Delete Duplicate Empty Lists",
+  "delete-duplicate-empty-lists-migration-description": "Safely deletes empty duplicate lists. Only removes lists that have no cards AND have another list with the same title that contains cards.",
+  "lost-cards": "Lost Cards",
+  "lost-cards-list": "Restored Items",
+  "restore-lost-cards-migration": "Restore Lost Cards",
+  "restore-lost-cards-migration-description": "Finds and restores cards and lists with missing swimlaneId or listId. Creates a 'Lost Cards' swimlane to make all lost items visible again.",
+  "restore-all-archived-migration": "Restore All Archived",
+  "restore-all-archived-migration-description": "Restores all archived swimlanes, lists, and cards. Automatically fixes any missing swimlaneId or listId to make items visible.",
   "fix-missing-lists-migration": "Fix Missing Lists",
   "fix-missing-lists-migration": "Fix Missing Lists",
   "fix-missing-lists-migration-description": "Detects and repairs missing or corrupted lists in the board structure.",
   "fix-missing-lists-migration-description": "Detects and repairs missing or corrupted lists in the board structure.",
   "fix-avatar-urls-migration": "Fix Avatar URLs",
   "fix-avatar-urls-migration": "Fix Avatar URLs",
@@ -1426,9 +1436,43 @@
   "no-issues-found": "No issues found",
   "no-issues-found": "No issues found",
   "run-migration": "Run Migration",
   "run-migration": "Run Migration",
   "run-comprehensive-migration-confirm": "This will perform a comprehensive migration to check and fix board data integrity. This may take a few moments. Continue?",
   "run-comprehensive-migration-confirm": "This will perform a comprehensive migration to check and fix board data integrity. This may take a few moments. Continue?",
+  "run-delete-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?",
+  "run-delete-duplicate-empty-lists-migration-confirm": "This will first convert any shared lists to per-swimlane lists, then delete empty lists that have a duplicate list with the same title containing cards. Only truly redundant empty lists will be removed. Continue?",
+  "run-restore-lost-cards-migration-confirm": "This will create a 'Lost Cards' swimlane and restore all cards and lists with missing swimlaneId or listId. This only affects non-archived items. Continue?",
+  "run-restore-all-archived-migration-confirm": "This will restore ALL archived swimlanes, lists, and cards, making them visible again. Any items with missing IDs will be automatically fixed. This cannot be easily undone. Continue?",
   "run-fix-missing-lists-migration-confirm": "This will detect and repair missing or corrupted lists in the board structure. Continue?",
   "run-fix-missing-lists-migration-confirm": "This will detect and repair missing or corrupted lists in the board structure. Continue?",
   "run-fix-avatar-urls-migration-confirm": "This will update avatar URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
   "run-fix-avatar-urls-migration-confirm": "This will update avatar URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
   "run-fix-all-file-urls-migration-confirm": "This will update all file attachment URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
   "run-fix-all-file-urls-migration-confirm": "This will update all file attachment URLs across all boards to use the correct storage backend. This is a global operation. Continue?",
+  "restore-lost-cards-nothing-to-restore": "No lost swimlanes, lists, or cards to restore",
+  
+  "migration-progress-title": "Board Migration in Progress",
+  "migration-progress-overall": "Overall Progress",
+  "migration-progress-current-step": "Current Step",
+  "migration-progress-status": "Status",
+  "migration-progress-details": "Details",
+  "migration-progress-note": "Please wait while we migrate your board to the latest structure...",
+  
+  "step-analyze-board-structure": "Analyze Board Structure",
+  "step-fix-orphaned-cards": "Fix Orphaned Cards",
+  "step-convert-shared-lists": "Convert Shared Lists",
+  "step-ensure-per-swimlane-lists": "Ensure Per-Swimlane Lists",
+  "step-validate-migration": "Validate Migration",
+  "step-fix-avatar-urls": "Fix Avatar URLs",
+  "step-fix-attachment-urls": "Fix Attachment URLs",
+  "step-analyze-lists": "Analyze Lists",
+  "step-create-missing-lists": "Create Missing Lists",
+  "step-update-cards": "Update Cards",
+  "step-finalize": "Finalize",
+  "step-delete-empty-lists": "Delete Empty Lists",
+  "step-delete-duplicate-empty-lists": "Delete Duplicate Empty Lists",
+  "step-ensure-lost-cards-swimlane": "Ensure Lost Cards Swimlane",
+  "step-restore-lists": "Restore Lists",
+  "step-restore-cards": "Restore Cards",
+  "step-restore-swimlanes": "Restore Swimlanes",
+  "step-fix-missing-ids": "Fix Missing IDs",
+  "step-scan-users": "Scan Users",
+  "step-scan-files": "Scan Files",
+  "step-fix-file-urls": "Fix File URLs",
   "cleanup": "Cleanup",
   "cleanup": "Cleanup",
   "cleanup-old-jobs": "Cleanup Old Jobs",
   "cleanup-old-jobs": "Cleanup Old Jobs",
   "completed": "Completed",
   "completed": "Completed",

+ 2 - 12
server/migrations/comprehensiveBoardMigration.js

@@ -34,7 +34,6 @@ class ComprehensiveBoardMigration {
       'fix_orphaned_cards',
       'fix_orphaned_cards',
       'convert_shared_lists',
       'convert_shared_lists',
       'ensure_per_swimlane_lists',
       'ensure_per_swimlane_lists',
-      'cleanup_empty_lists',
       'validate_migration'
       'validate_migration'
     ];
     ];
   }
   }
@@ -169,7 +168,6 @@ class ComprehensiveBoardMigration {
         totalCardsProcessed: 0,
         totalCardsProcessed: 0,
         totalListsProcessed: 0,
         totalListsProcessed: 0,
         totalListsCreated: 0,
         totalListsCreated: 0,
-        totalListsRemoved: 0,
         errors: []
         errors: []
       };
       };
 
 
@@ -239,15 +237,7 @@ class ComprehensiveBoardMigration {
         listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
         listsProcessed: results.steps.ensurePerSwimlane.listsProcessed
       });
       });
 
 
-      // Step 5: Cleanup empty lists
-      updateProgress('cleanup_empty_lists', 0, 'Cleaning up empty lists...');
-      results.steps.cleanupEmpty = await this.cleanupEmptyLists(boardId);
-      results.totalListsRemoved += results.steps.cleanupEmpty.listsRemoved || 0;
-      updateProgress('cleanup_empty_lists', 100, 'Empty lists cleaned up', {
-        listsRemoved: results.steps.cleanupEmpty.listsRemoved
-      });
-
-      // Step 6: Validate migration
+      // Step 5: Validate migration
       updateProgress('validate_migration', 0, 'Validating migration...');
       updateProgress('validate_migration', 0, 'Validating migration...');
       results.steps.validate = await this.validateMigration(boardId);
       results.steps.validate = await this.validateMigration(boardId);
       updateProgress('validate_migration', 100, 'Migration validated', {
       updateProgress('validate_migration', 100, 'Migration validated', {
@@ -256,7 +246,7 @@ class ComprehensiveBoardMigration {
         totalLists: results.steps.validate.totalLists
         totalLists: results.steps.validate.totalLists
       });
       });
 
 
-      // Step 7: Fix avatar URLs
+      // Step 6: Fix avatar URLs
       updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
       updateProgress('fix_avatar_urls', 0, 'Fixing avatar URLs...');
       results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
       results.steps.fixAvatarUrls = await this.fixAvatarUrls(boardId);
       updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {
       updateProgress('fix_avatar_urls', 100, 'Avatar URLs fixed', {

+ 423 - 0
server/migrations/deleteDuplicateEmptyLists.js

@@ -0,0 +1,423 @@
+/**
+ * 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 deleteEmptyLists 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 deleteEmptyLists 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({
+  'deleteEmptyLists.needsMigration'(boardId) {
+    check(boardId, String);
+    
+    if (!this.userId) {
+      throw new Meteor.Error('not-authorized', 'You must be logged in');
+    }
+
+    return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
+  },
+
+  'deleteDuplicateEmptyLists.needsMigration'(boardId) {
+    check(boardId, String);
+    
+    if (!this.userId) {
+      throw new Meteor.Error('not-authorized', 'You must be logged in');
+    }
+
+    return deleteDuplicateEmptyListsMigration.needsMigration(boardId);
+  },
+
+  'deleteEmptyLists.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.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);
+  },
+
+  'deleteEmptyLists.getStatus'(boardId) {
+    check(boardId, String);
+    
+    if (!this.userId) {
+      throw new Meteor.Error('not-authorized', 'You must be logged in');
+    }
+
+    return deleteDuplicateEmptyListsMigration.getStatus(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;

+ 266 - 0
server/migrations/restoreAllArchived.js

@@ -0,0 +1,266 @@
+/**
+ * Restore All Archived Migration
+ * 
+ * Restores all archived swimlanes, lists, and cards.
+ * If any restored items are missing swimlaneId, listId, or cardId, 
+ * creates/assigns proper IDs to make them visible.
+ */
+
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { ReactiveCache } from '/imports/reactiveCache';
+import { TAPi18n } from '/imports/i18n';
+import Boards from '/models/boards';
+import Lists from '/models/lists';
+import Cards from '/models/cards';
+import Swimlanes from '/models/swimlanes';
+
+class RestoreAllArchivedMigration {
+  constructor() {
+    this.name = 'restoreAllArchived';
+    this.version = 1;
+  }
+
+  /**
+   * Check if migration is needed for a board
+   */
+  needsMigration(boardId) {
+    try {
+      const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
+      const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
+      const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
+
+      return archivedSwimlanes.length > 0 || archivedLists.length > 0 || archivedCards.length > 0;
+    } catch (error) {
+      console.error('Error checking if restoreAllArchived migration is needed:', error);
+      return false;
+    }
+  }
+
+  /**
+   * Execute the migration
+   */
+  async executeMigration(boardId) {
+    try {
+      const results = {
+        swimlanesRestored: 0,
+        listsRestored: 0,
+        cardsRestored: 0,
+        itemsFixed: 0,
+        errors: []
+      };
+
+      const board = ReactiveCache.getBoard(boardId);
+      if (!board) {
+        throw new Error('Board not found');
+      }
+
+      // Get archived items
+      const archivedSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: true });
+      const archivedLists = ReactiveCache.getLists({ boardId, archived: true });
+      const archivedCards = ReactiveCache.getCards({ boardId, archived: true });
+
+      // Get active items for reference
+      const activeSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
+      const activeLists = ReactiveCache.getLists({ boardId, archived: false });
+
+      // Restore all archived swimlanes
+      for (const swimlane of archivedSwimlanes) {
+        Swimlanes.update(swimlane._id, {
+          $set: {
+            archived: false,
+            updatedAt: new Date()
+          }
+        });
+        results.swimlanesRestored++;
+
+        if (process.env.DEBUG === 'true') {
+          console.log(`Restored swimlane: ${swimlane.title}`);
+        }
+      }
+
+      // Restore all archived lists and fix missing swimlaneId
+      for (const list of archivedLists) {
+        const updateFields = {
+          archived: false,
+          updatedAt: new Date()
+        };
+
+        // Fix missing swimlaneId
+        if (!list.swimlaneId) {
+          // Try to find a suitable swimlane or use default
+          let targetSwimlane = activeSwimlanes.find(s => !s.archived);
+          
+          if (!targetSwimlane) {
+            // No active swimlane found, create default
+            const swimlaneId = Swimlanes.insert({
+              title: TAPi18n.__('default'),
+              boardId: boardId,
+              sort: 0,
+              createdAt: new Date(),
+              updatedAt: new Date(),
+              archived: false
+            });
+            targetSwimlane = ReactiveCache.getSwimlane(swimlaneId);
+          }
+
+          updateFields.swimlaneId = targetSwimlane._id;
+          results.itemsFixed++;
+
+          if (process.env.DEBUG === 'true') {
+            console.log(`Fixed missing swimlaneId for list: ${list.title}`);
+          }
+        }
+
+        Lists.update(list._id, {
+          $set: updateFields
+        });
+        results.listsRestored++;
+
+        if (process.env.DEBUG === 'true') {
+          console.log(`Restored list: ${list.title}`);
+        }
+      }
+
+      // Refresh lists after restoration
+      const allLists = ReactiveCache.getLists({ boardId, archived: false });
+      const allSwimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
+
+      // Restore all archived cards and fix missing IDs
+      for (const card of archivedCards) {
+        const updateFields = {
+          archived: false,
+          updatedAt: new Date()
+        };
+
+        let needsFix = false;
+
+        // Fix missing listId
+        if (!card.listId) {
+          // Find or create a default list
+          let targetList = allLists.find(l => !l.archived);
+          
+          if (!targetList) {
+            // No active list found, create one
+            const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
+            
+            const listId = Lists.insert({
+              title: TAPi18n.__('default'),
+              boardId: boardId,
+              swimlaneId: defaultSwimlane._id,
+              sort: 0,
+              createdAt: new Date(),
+              updatedAt: new Date(),
+              archived: false
+            });
+            targetList = ReactiveCache.getList(listId);
+          }
+
+          updateFields.listId = targetList._id;
+          needsFix = true;
+        }
+
+        // Fix missing swimlaneId
+        if (!card.swimlaneId) {
+          // Try to get swimlaneId from the card's list
+          if (card.listId || updateFields.listId) {
+            const cardList = allLists.find(l => l._id === (updateFields.listId || card.listId));
+            if (cardList && cardList.swimlaneId) {
+              updateFields.swimlaneId = cardList.swimlaneId;
+            } else {
+              // Fall back to first available swimlane
+              const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
+              updateFields.swimlaneId = defaultSwimlane._id;
+            }
+          } else {
+            // Fall back to first available swimlane
+            const defaultSwimlane = allSwimlanes.find(s => !s.archived) || allSwimlanes[0];
+            updateFields.swimlaneId = defaultSwimlane._id;
+          }
+          needsFix = true;
+        }
+
+        if (needsFix) {
+          results.itemsFixed++;
+
+          if (process.env.DEBUG === 'true') {
+            console.log(`Fixed missing IDs for card: ${card.title}`);
+          }
+        }
+
+        Cards.update(card._id, {
+          $set: updateFields
+        });
+        results.cardsRestored++;
+
+        if (process.env.DEBUG === 'true') {
+          console.log(`Restored card: ${card.title}`);
+        }
+      }
+
+      return {
+        success: true,
+        changes: [
+          `Restored ${results.swimlanesRestored} archived swimlanes`,
+          `Restored ${results.listsRestored} archived lists`,
+          `Restored ${results.cardsRestored} archived cards`,
+          `Fixed ${results.itemsFixed} items with missing IDs`
+        ],
+        results
+      };
+    } catch (error) {
+      console.error('Error executing restoreAllArchived migration:', error);
+      return {
+        success: false,
+        error: error.message
+      };
+    }
+  }
+}
+
+const restoreAllArchivedMigration = new RestoreAllArchivedMigration();
+
+// Register Meteor methods
+Meteor.methods({
+  'restoreAllArchived.needsMigration'(boardId) {
+    check(boardId, String);
+    
+    if (!this.userId) {
+      throw new Meteor.Error('not-authorized', 'You must be logged in');
+    }
+
+    return restoreAllArchivedMigration.needsMigration(boardId);
+  },
+
+  'restoreAllArchived.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 restoreAllArchivedMigration.executeMigration(boardId);
+  }
+});
+
+export default restoreAllArchivedMigration;

+ 259 - 0
server/migrations/restoreLostCards.js

@@ -0,0 +1,259 @@
+/**
+ * Restore Lost Cards Migration
+ * 
+ * Finds and restores cards and lists that have missing swimlaneId, listId, or are orphaned.
+ * Creates a "Lost Cards" swimlane and restores visibility of lost items.
+ * Only processes non-archived items.
+ */
+
+import { Meteor } from 'meteor/meteor';
+import { check } from 'meteor/check';
+import { ReactiveCache } from '/imports/reactiveCache';
+import { TAPi18n } from '/imports/i18n';
+import Boards from '/models/boards';
+import Lists from '/models/lists';
+import Cards from '/models/cards';
+import Swimlanes from '/models/swimlanes';
+
+class RestoreLostCardsMigration {
+  constructor() {
+    this.name = 'restoreLostCards';
+    this.version = 1;
+  }
+
+  /**
+   * Check if migration is needed for a board
+   */
+  needsMigration(boardId) {
+    try {
+      const cards = ReactiveCache.getCards({ boardId, archived: false });
+      const lists = ReactiveCache.getLists({ boardId, archived: false });
+
+      // Check for cards missing swimlaneId or listId
+      const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
+      if (lostCards.length > 0) {
+        return true;
+      }
+
+      // Check for lists missing swimlaneId
+      const lostLists = lists.filter(list => !list.swimlaneId);
+      if (lostLists.length > 0) {
+        return true;
+      }
+
+      // Check for orphaned cards (cards whose list doesn't exist)
+      for (const card of cards) {
+        if (card.listId) {
+          const listExists = lists.some(list => list._id === card.listId);
+          if (!listExists) {
+            return true;
+          }
+        }
+      }
+
+      return false;
+    } catch (error) {
+      console.error('Error checking if restoreLostCards migration is needed:', error);
+      return false;
+    }
+  }
+
+  /**
+   * Execute the migration
+   */
+  async executeMigration(boardId) {
+    try {
+      const results = {
+        lostCardsSwimlaneCreated: false,
+        cardsRestored: 0,
+        listsRestored: 0,
+        errors: []
+      };
+
+      const board = ReactiveCache.getBoard(boardId);
+      if (!board) {
+        throw new Error('Board not found');
+      }
+
+      // Get all non-archived items
+      const cards = ReactiveCache.getCards({ boardId, archived: false });
+      const lists = ReactiveCache.getLists({ boardId, archived: false });
+      const swimlanes = ReactiveCache.getSwimlanes({ boardId, archived: false });
+
+      // Detect items to restore BEFORE creating anything
+      const lostLists = lists.filter(list => !list.swimlaneId);
+      const lostCards = cards.filter(card => !card.swimlaneId || !card.listId);
+      const orphanedCards = cards.filter(card => card.listId && !lists.some(list => list._id === card.listId));
+
+      const hasCardsWork = lostCards.length > 0 || orphanedCards.length > 0;
+      const hasListsWork = lostLists.length > 0;
+      const hasAnyWork = hasCardsWork || hasListsWork;
+
+      if (!hasAnyWork) {
+        // Nothing to restore; do not create swimlane or list
+        return {
+          success: true,
+          changes: [
+            'No lost swimlanes, lists, or cards to restore'
+          ],
+          results: {
+            lostCardsSwimlaneCreated: false,
+            cardsRestored: 0,
+            listsRestored: 0
+          }
+        };
+      }
+
+      // Find or create "Lost Cards" swimlane (only if there is actual work)
+      let lostCardsSwimlane = swimlanes.find(s => s.title === TAPi18n.__('lost-cards'));
+      if (!lostCardsSwimlane) {
+        const swimlaneId = Swimlanes.insert({
+          title: TAPi18n.__('lost-cards'),
+          boardId: boardId,
+          sort: 999999, // Put at the end
+          color: 'red',
+          createdAt: new Date(),
+          updatedAt: new Date(),
+          archived: false
+        });
+        lostCardsSwimlane = ReactiveCache.getSwimlane(swimlaneId);
+        results.lostCardsSwimlaneCreated = true;
+        if (process.env.DEBUG === 'true') {
+          console.log(`Created "Lost Cards" swimlane for board ${boardId}`);
+        }
+      }
+
+      // Restore lost lists (lists without swimlaneId)
+      if (hasListsWork) {
+        for (const list of lostLists) {
+          Lists.update(list._id, {
+            $set: {
+              swimlaneId: lostCardsSwimlane._id,
+              updatedAt: new Date()
+            }
+          });
+          results.listsRestored++;
+          if (process.env.DEBUG === 'true') {
+            console.log(`Restored lost list: ${list.title}`);
+          }
+        }
+      }
+
+      // Create default list only if we need to move cards
+      let defaultList = null;
+      if (hasCardsWork) {
+        defaultList = lists.find(l =>
+          l.swimlaneId === lostCardsSwimlane._id &&
+          l.title === TAPi18n.__('lost-cards-list')
+        );
+        if (!defaultList) {
+          const listId = Lists.insert({
+            title: TAPi18n.__('lost-cards-list'),
+            boardId: boardId,
+            swimlaneId: lostCardsSwimlane._id,
+            sort: 0,
+            createdAt: new Date(),
+            updatedAt: new Date(),
+            archived: false
+          });
+          defaultList = ReactiveCache.getList(listId);
+          if (process.env.DEBUG === 'true') {
+            console.log(`Created default list in Lost Cards swimlane`);
+          }
+        }
+      }
+
+      // Restore cards missing swimlaneId or listId
+      if (hasCardsWork) {
+        for (const card of lostCards) {
+          const updateFields = { updatedAt: new Date() };
+          if (!card.swimlaneId) updateFields.swimlaneId = lostCardsSwimlane._id;
+          if (!card.listId) updateFields.listId = defaultList._id;
+          Cards.update(card._id, { $set: updateFields });
+          results.cardsRestored++;
+          if (process.env.DEBUG === 'true') {
+            console.log(`Restored lost card: ${card.title}`);
+          }
+        }
+
+        // Restore orphaned cards (cards whose list doesn't exist)
+        for (const card of orphanedCards) {
+          Cards.update(card._id, {
+            $set: {
+              listId: defaultList._id,
+              swimlaneId: lostCardsSwimlane._id,
+              updatedAt: new Date()
+            }
+          });
+          results.cardsRestored++;
+          if (process.env.DEBUG === 'true') {
+            console.log(`Restored orphaned card: ${card.title}`);
+          }
+        }
+      }
+
+      return {
+        success: true,
+        changes: [
+          results.lostCardsSwimlaneCreated ? 'Created "Lost Cards" swimlane' : 'Using existing "Lost Cards" swimlane',
+          `Restored ${results.listsRestored} lost lists`,
+          `Restored ${results.cardsRestored} lost cards`
+        ],
+        results
+      };
+    } catch (error) {
+      console.error('Error executing restoreLostCards migration:', error);
+      return {
+        success: false,
+        error: error.message
+      };
+    }
+  }
+}
+
+const restoreLostCardsMigration = new RestoreLostCardsMigration();
+
+// Register Meteor methods
+Meteor.methods({
+  'restoreLostCards.needsMigration'(boardId) {
+    check(boardId, String);
+    
+    if (!this.userId) {
+      throw new Meteor.Error('not-authorized', 'You must be logged in');
+    }
+
+    return restoreLostCardsMigration.needsMigration(boardId);
+  },
+
+  'restoreLostCards.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 restoreLostCardsMigration.executeMigration(boardId);
+  }
+});
+
+export default restoreLostCardsMigration;