Browse Source

Fix duplicated lists.

Thanks to xet7 !

Fixes #5952
Lauri Ojansivu 1 day ago
parent
commit
b6e7b258e0

+ 42 - 24
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()
-            };
-
-            // 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}`);
+              title: sharedList.title
+            });
+
+            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()
+              };
+
+              // 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') {

+ 93 - 0
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;
+}

+ 286 - 0
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;

+ 14 - 7
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;
   },

+ 234 - 0
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
+    };
+  }
+});