-
-
Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
| <!DOCTYPE html> | |
| <html lang="en" class=""> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Bitwarden Vault Analyzer - Clean & Optimize Your Password Vault</title> | |
| <!-- Bitwarden Favicon --> | |
| <link | |
| rel="shortcut icon" | |
| href="https://raw.githubusercontent.com/bitwarden/clients/main/apps/web/src/images/icons/favicon-32x32.png" | |
| type="image/x-icon" | |
| /> | |
| <!-- Load Tailwind CSS --> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| } | |
| </script> | |
| <style> | |
| /* Spinner animation for loading indicator */ | |
| .spinner { | |
| border: 4px solid rgba(255, 255, 255, 0.3); | |
| border-radius: 50%; | |
| border-top: 4px solid white; | |
| width: 20px; | |
| height: 20px; | |
| animation: spin 1s linear infinite; | |
| } | |
| @keyframes spin { | |
| 0% { transform: rotate(0deg); } | |
| 100% { transform: rotate(360deg); } | |
| } | |
| /* Smooth transitions for elements */ | |
| .transition-all { | |
| transition: all 0.3s ease-in-out; | |
| } | |
| /* Hide original file input */ | |
| .hidden-input { | |
| display: none; | |
| } | |
| /* Styling for active filter buttons */ | |
| .filter-btn-active { | |
| background-color: #3B82F6; /* bg-blue-600 */ | |
| color: white; | |
| } | |
| .dark .filter-btn-active { | |
| background-color: #60A5FA; /* bg-blue-400 */ | |
| color: #1F2937; /* dark:text-gray-900 */ | |
| } | |
| /* Tooltip styling */ | |
| .tooltip { | |
| position: relative; | |
| display: inline-block; | |
| } | |
| .tooltip .tooltiptext { | |
| visibility: hidden; | |
| width: 220px; | |
| background-color: #555; | |
| color: #fff; | |
| text-align: center; | |
| border-radius: 6px; | |
| padding: 5px 10px; | |
| position: absolute; | |
| z-index: 50; | |
| bottom: 125%; | |
| left: 50%; | |
| margin-left: -110px; | |
| opacity: 0; | |
| transition: opacity 0.3s; | |
| font-size: 12px; | |
| } | |
| .tooltip:hover .tooltiptext { | |
| visibility: visible; | |
| opacity: 1; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 dark:bg-gray-900 dark:text-gray-200 font-sans text-gray-800 transition-all"> | |
| <div class="container mx-auto max-w-6xl p-4 sm:p-6 md:p-8"> | |
| <!-- Header and Title --> | |
| <header class="text-center mb-8 relative"> | |
| <div class="absolute top-0 right-0 flex gap-2"> | |
| <button id="help-toggle" class="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| </button> | |
| <button id="theme-toggle" class="p-2 rounded-full text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700 transition-all"> | |
| <svg id="theme-toggle-dark-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20"><path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path></svg> | |
| <svg id="theme-toggle-light-icon" class="w-6 h-6 hidden" fill="currentColor" viewBox="0 0 20 20"><path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm-.707 10.607a1 1 0 010-1.414l.707-.707a1 1 0 111.414 1.414l-.707.707a1 1 0 01-1.414 0zM11 17a1 1 0 10-2 0v1a1 1 0 102 0v-1z"></path></svg> | |
| </button> | |
| </div> | |
| <h1 class="text-3xl sm:text-4xl font-bold text-blue-700 dark:text-blue-400">🔐 Bitwarden Vault Analyzer</h1> | |
| <p class="text-gray-600 dark:text-gray-400 mt-2">Analyze, filter, and clean duplicate entries & folders automatically</p> | |
| </header> | |
| <!-- Help/Instructions Panel (Hidden by default) --> | |
| <div id="help-panel" class="mb-6 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 rounded-lg shadow-lg p-6 hidden"> | |
| <div class="flex justify-between items-start mb-4"> | |
| <h2 class="text-xl font-bold text-blue-800 dark:text-blue-300">📖 How to Use This Tool</h2> | |
| <button id="close-help" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> | |
| <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> | |
| </button> | |
| </div> | |
| <div class="grid md:grid-cols-2 gap-4 text-sm"> | |
| <div> | |
| <h3 class="font-semibold text-blue-700 dark:text-blue-300 mb-2">🚀 Quick Start:</h3> | |
| <ol class="list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300"> | |
| <li><strong>Export your Bitwarden vault:</strong> Go to Bitwarden → Tools → Export Vault → Select JSON format</li> | |
| <li><strong>Upload the file:</strong> Click "Select Bitwarden Export File (.json)" button</li> | |
| <li><strong>Review analysis:</strong> Check the dashboard showing duplicates, weak passwords, etc.</li> | |
| <li><strong>Mark items:</strong> Use "Mark All Duplicates" or manually select items to remove</li> | |
| <li><strong>Download clean vault:</strong> Click "Download Clean Vault" to get the cleaned JSON file</li> | |
| <li><strong>Import back:</strong> Import the cleaned file back into Bitwarden</li> | |
| </ol> | |
| </div> | |
| <div> | |
| <h3 class="font-semibold text-blue-700 dark:text-blue-300 mb-2">✨ Features:</h3> | |
| <ul class="list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300"> | |
| <li><strong>Duplicate Detection:</strong> Finds entries with identical username & password</li> | |
| <li><strong>Folder Merging:</strong> Combines folders with the same name</li> | |
| <li><strong>Password Reuse Analysis:</strong> Identifies passwords used across multiple services</li> | |
| <li><strong>Weak Password Detection:</strong> Finds passwords shorter than 12 characters</li> | |
| <li><strong>Bulk Actions:</strong> Mark all duplicates or reset selections with one click</li> | |
| <li><strong>100% Client-Side:</strong> All processing happens in your browser - no data is uploaded</li> | |
| </ul> | |
| </div> | |
| </div> | |
| <div class="mt-4 p-3 bg-yellow-100 dark:bg-yellow-900/30 border-l-4 border-yellow-500 rounded"> | |
| <p class="text-sm text-yellow-800 dark:text-yellow-300"><strong>⚠️ Important:</strong> This tool processes your vault locally in your browser. Always backup your vault before importing the cleaned file. Keep your original export safe!</p> | |
| </div> | |
| </div> | |
| <!-- Main Control Area --> | |
| <div class="sticky top-0 bg-gray-100/95 dark:bg-gray-900/95 backdrop-blur-md z-40 py-4 mb-6 rounded-lg shadow-sm"> | |
| <div class="flex flex-col sm:flex-row gap-4 items-center"> | |
| <label for="fileInput" class="w-full sm:w-auto flex-grow cursor-pointer bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 dark:from-blue-600 dark:to-blue-700 dark:hover:from-blue-700 dark:hover:to-blue-800 text-white border-2 border-blue-600 dark:border-blue-500 rounded-lg px-6 py-3 text-center transition-all shadow-md hover:shadow-lg"> | |
| <span id="fileLabel" class="font-semibold">📁 1. Select Bitwarden Export File (.json)</span> | |
| <input type="file" id="fileInput" accept=".json" class="hidden-input" /> | |
| </label> | |
| <button id="downloadBtn" class="w-full sm:w-auto bg-gradient-to-r from-green-600 to-green-700 hover:from-green-700 hover:to-green-800 text-white font-bold py-3 px-6 rounded-lg flex items-center justify-center gap-2 transition-all shadow-md hover:shadow-lg" style="display: none;"> | |
| <div id="downloadSpinner" class="spinner" style="display: none;"></div> | |
| <span id="downloadText">💾 3. Download Clean Vault</span> | |
| </button> | |
| </div> | |
| <div id="notification" class="mt-4 text-center text-red-600 dark:text-red-400 font-medium h-6"></div> | |
| </div> | |
| <!-- Analysis Dashboard and Filter Controls --> | |
| <div id="analysis-dashboard" class="mb-8" style="display: none;"> | |
| <h2 class="text-2xl font-bold text-gray-700 dark:text-gray-200 mb-4">📊 Vault Analysis Dashboard</h2> | |
| <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-4 text-center"> | |
| <div class="bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-green-200 dark:border-green-700"> | |
| <p class="text-3xl font-bold text-green-600 dark:text-green-400" id="health-score">0%</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Health Score</p> | |
| <span class="tooltiptext">Percentage of items with unique credentials. Higher is better!</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-blue-200 dark:border-blue-700"> | |
| <p class="text-2xl font-bold text-blue-600 dark:text-blue-400" id="total-items">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Total Items</p> | |
| <span class="tooltiptext">Total number of login items in your vault.</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-red-50 to-red-100 dark:from-red-900/30 dark:to-red-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-red-200 dark:border-red-700"> | |
| <p class="text-2xl font-bold text-red-600 dark:text-red-400" id="reused-passwords">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Reused Passwords</p> | |
| <span class="tooltiptext">Passwords used across multiple services. This is a security risk!</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-900/30 dark:to-purple-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-purple-200 dark:border-purple-700"> | |
| <p class="text-2xl font-bold text-purple-600 dark:text-purple-400" id="weak-passwords">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Weak Passwords</p> | |
| <span class="tooltiptext">Passwords shorter than 12 characters.</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/30 dark:to-orange-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-orange-200 dark:border-orange-700"> | |
| <p class="text-2xl font-bold text-orange-600 dark:text-orange-400" id="duplicate-sets">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Duplicate Sets</p> | |
| <span class="tooltiptext">Number of duplicate item groups with identical username & password.</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-cyan-50 to-cyan-100 dark:from-cyan-900/30 dark:to-cyan-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-cyan-200 dark:border-cyan-700"> | |
| <p class="text-2xl font-bold text-cyan-600 dark:text-cyan-400" id="duplicate-folders">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Duplicate Folders</p> | |
| <span class="tooltiptext">Number of folder groups with the same name.</span> | |
| </div> | |
| <div class="bg-gradient-to-br from-yellow-50 to-yellow-100 dark:from-yellow-900/30 dark:to-yellow-800/30 p-4 rounded-lg shadow-md hover:shadow-lg transition-all tooltip border border-yellow-200 dark:border-yellow-700"> | |
| <p class="text-2xl font-bold text-yellow-600 dark:text-yellow-400" id="pending-actions">0</p> | |
| <p class="text-sm text-gray-700 dark:text-gray-300 font-medium">Pending Actions</p> | |
| <span class="tooltiptext">Items marked for deletion and folders to be merged.</span> | |
| </div> | |
| </div> | |
| <div class="mt-6 bg-white dark:bg-gray-800 p-5 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700"> | |
| <h3 class="text-lg font-semibold mb-4 text-gray-700 dark:text-gray-300">⚡ 2. Quick Actions & Filter Reports</h3> | |
| <div class="flex flex-col md:flex-row gap-4 justify-between items-center"> | |
| <!-- Quick Action Controls --> | |
| <div class="flex flex-wrap gap-2"> | |
| <button id="bulk-delete-btn" class="bg-gradient-to-r from-red-500 to-red-600 hover:from-red-600 hover:to-red-700 text-white text-sm font-bold py-2.5 px-5 rounded-md shadow-md hover:shadow-lg transition-all flex items-center gap-2"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg> | |
| Mark All Duplicates | |
| </button> | |
| <button id="merge-all-folders-btn" class="bg-gradient-to-r from-cyan-500 to-cyan-600 hover:from-cyan-600 hover:to-cyan-700 text-white text-sm font-bold py-2.5 px-5 rounded-md shadow-md hover:shadow-lg transition-all flex items-center gap-2" style="display: none;"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path></svg> | |
| Merge All Folders | |
| </button> | |
| <button id="reset-selection-btn" class="bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white text-sm font-bold py-2.5 px-5 rounded-md shadow-md hover:shadow-lg transition-all flex items-center gap-2"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg> | |
| Reset All | |
| </button> | |
| </div> | |
| <!-- Filter Controls --> | |
| <div id="filter-controls" class="flex flex-wrap gap-2 justify-center"> | |
| <button class="filter-btn filter-btn-active px-4 py-2 text-sm font-semibold rounded-md transition-all shadow-sm hover:shadow-md" data-filter="duplicates">📋 Duplicates</button> | |
| <button class="filter-btn px-4 py-2 text-sm font-semibold rounded-md transition-all shadow-sm hover:shadow-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600" data-filter="folders">📁 Folders</button> | |
| <button class="filter-btn px-4 py-2 text-sm font-semibold rounded-md transition-all shadow-sm hover:shadow-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600" data-filter="reused">🔁 Reused</button> | |
| <button class="filter-btn px-4 py-2 text-sm font-semibold rounded-md transition-all shadow-sm hover:shadow-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600" data-filter="weak">⚠️ Weak</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Results Area --> | |
| <main id="results" class="space-y-6"> | |
| <div id="initialMessage" class="text-center py-16 px-6 bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700"> | |
| <div class="text-6xl mb-4">🔐</div> | |
| <h2 class="text-3xl font-bold mb-3 text-gray-800 dark:text-gray-100">Welcome to Bitwarden Vault Analyzer!</h2> | |
| <p class="text-gray-600 dark:text-gray-400 mb-6 max-w-2xl mx-auto">Get started by selecting your Bitwarden JSON export file. This tool will help you identify and clean up duplicate entries, merge duplicate folders, and improve your vault's security.</p> | |
| <div class="flex flex-col sm:flex-row gap-3 justify-center items-center"> | |
| <button id="help-btn-initial" class="bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 px-6 py-2 rounded-lg font-medium hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-all flex items-center gap-2"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| View Instructions | |
| </button> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| // --- State Management --- | |
| let originalData = null; | |
| let originalFileName = ""; | |
| let deletedItems = new Set(); | |
| let currentFilter = 'duplicates'; // 'duplicates', 'folders', 'reused', 'weak' | |
| let domainGroupsCache = {}; | |
| let reusedPasswordMap = new Map(); | |
| let duplicateFolderMap = new Map(); | |
| let folderMergeSelections = new Map(); // Maps folderName to the ID of the folder to keep | |
| let folderItemsMap = new Map(); | |
| const analysisCache = { | |
| duplicatesByDomain: [], | |
| totalDuplicateSets: 0, | |
| totalDuplicateItems: 0, | |
| totalLoginItems: 0, | |
| reusedPasswordEntries: [], | |
| weakPasswordItems: [], | |
| duplicateFolderEntries: [], | |
| duplicateFolderLookup: new Map() | |
| }; | |
| // --- DOM Element References --- | |
| const fileInput = document.getElementById("fileInput"); | |
| const fileLabel = document.getElementById("fileLabel"); | |
| const downloadBtn = document.getElementById("downloadBtn"); | |
| const downloadSpinner = document.getElementById("downloadSpinner"); | |
| const downloadText = document.getElementById("downloadText"); | |
| const resultsDiv = document.getElementById("results"); | |
| const initialMessage = document.getElementById("initialMessage"); | |
| const notificationDiv = document.getElementById("notification"); | |
| const analysisDashboard = document.getElementById('analysis-dashboard'); | |
| const filterControls = document.getElementById('filter-controls'); | |
| // --- Utility Functions --- | |
| function sanitizeHTML(str) { if (typeof str !== 'string') return ''; return str.replace(/</g, "<").replace(/>/g, ">"); } | |
| function showNotification(message, isError = false, duration = 4000) { | |
| notificationDiv.textContent = message; | |
| notificationDiv.className = isError ? 'mt-4 text-center text-red-600 dark:text-red-400 font-medium h-6' : 'mt-4 text-center text-blue-600 dark:text-blue-400 font-medium h-6'; | |
| if (message) { | |
| setTimeout(() => { notificationDiv.textContent = ''; }, duration); | |
| } | |
| } | |
| function extractDomain(url) { | |
| try { | |
| if(!url) return 'No URI'; | |
| const { hostname } = new URL(url); | |
| if (hostname.match(/^(\d{1,3}\.){3}\d{1,3}$/)) return hostname; | |
| const parts = hostname.split("."); | |
| if (parts.length > 2) { | |
| const sld = parts.slice(-2).join("."); | |
| if (sld.match(/(com|co|org|gov|edu|net|ac)\.\w{2}/)) return parts.slice(-3).join("."); | |
| return sld; | |
| } | |
| return hostname; | |
| } catch (error) { return url || 'No URI'; } | |
| } | |
| function getItemsInFolder(folderId) { | |
| return folderItemsMap.get(folderId) || []; | |
| } | |
| // --- Core Application Logic --- | |
| function analyzeData(data) { | |
| analysisCache.duplicatesByDomain = []; | |
| analysisCache.totalDuplicateSets = 0; | |
| analysisCache.totalDuplicateItems = 0; | |
| analysisCache.totalLoginItems = Array.isArray(data?.items) ? data.items.filter(item => item.type === 1).length : 0; | |
| analysisCache.reusedPasswordEntries = []; | |
| analysisCache.weakPasswordItems = []; | |
| analysisCache.duplicateFolderEntries = []; | |
| analysisCache.duplicateFolderLookup = new Map(); | |
| folderMergeSelections.clear(); | |
| reusedPasswordMap.clear(); | |
| domainGroupsCache = {}; | |
| buildFolderItemsMap(data); | |
| analyzeFolders(data); | |
| groupPasswords(data); | |
| analyzePasswordReuse(data); | |
| analyzeWeakPasswords(data); | |
| } | |
| function buildFolderItemsMap(data) { | |
| folderItemsMap = new Map(); | |
| if (!data || !Array.isArray(data.items)) return; | |
| data.items.forEach(item => { | |
| const folderId = item.folderId; | |
| if (folderId === null || folderId === undefined) return; | |
| if (!folderItemsMap.has(folderId)) { | |
| folderItemsMap.set(folderId, []); | |
| } | |
| folderItemsMap.get(folderId).push(item); | |
| }); | |
| } | |
| function analyzeFolders(data) { | |
| duplicateFolderMap.clear(); | |
| if (!data || !Array.isArray(data.folders)) return; | |
| const folderMap = new Map(); | |
| data.folders.forEach(folder => { | |
| const folderName = (folder.name || '').trim(); | |
| if (!folderMap.has(folderName)) { | |
| folderMap.set(folderName, []); | |
| } | |
| folderMap.get(folderName).push(folder); | |
| }); | |
| const duplicateEntries = []; | |
| for (const [name, folders] of folderMap.entries()) { | |
| if (folders.length > 1) { | |
| duplicateFolderMap.set(name, folders); | |
| const foldersWithCounts = folders | |
| .map(folder => { | |
| const itemsInFolder = getItemsInFolder(folder.id); | |
| return { | |
| id: folder.id, | |
| name: folder.name, | |
| itemCount: itemsInFolder.length | |
| }; | |
| }) | |
| .sort((a, b) => b.itemCount - a.itemCount); | |
| const totalItemCount = foldersWithCounts.reduce((sum, folder) => sum + folder.itemCount, 0); | |
| duplicateEntries.push({ name, folders: foldersWithCounts, totalItemCount }); | |
| } | |
| } | |
| analysisCache.duplicateFolderEntries = duplicateEntries.sort((a, b) => b.folders.length - a.folders.length || b.totalItemCount - a.totalItemCount); | |
| analysisCache.duplicateFolderLookup = new Map(analysisCache.duplicateFolderEntries.map(entry => [entry.name, entry])); | |
| } | |
| function groupPasswords(data) { | |
| domainGroupsCache = {}; | |
| if (!data || !Array.isArray(data.items)) return; | |
| data.items.forEach(item => { | |
| if (item.type !== 1 || !item.login) return; | |
| if (!item.login.uris || item.login.uris.length === 0) return; | |
| const credentialKey = `${item.login.username || ''}::${item.login.password || ''}`; | |
| const domains = new Set(item.login.uris.map(uri => extractDomain(uri.uri))); | |
| domains.forEach(domain => { | |
| if (!domainGroupsCache[domain]) { | |
| domainGroupsCache[domain] = { credentialGroups: Object.create(null) }; | |
| } | |
| const credentialGroups = domainGroupsCache[domain].credentialGroups; | |
| if (!credentialGroups[credentialKey]) { | |
| credentialGroups[credentialKey] = []; | |
| } | |
| credentialGroups[credentialKey].push(item); | |
| }); | |
| }); | |
| const duplicatesByDomain = []; | |
| Object.entries(domainGroupsCache).forEach(([domain, group]) => { | |
| const duplicateSets = []; | |
| Object.values(group.credentialGroups).forEach(set => { | |
| if (set.length > 1) { | |
| const sortedSet = [...set].sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
| duplicateSets.push(sortedSet); | |
| analysisCache.totalDuplicateSets += 1; | |
| analysisCache.totalDuplicateItems += Math.max(0, sortedSet.length - 1); | |
| } | |
| }); | |
| if (duplicateSets.length) { | |
| duplicateSets.sort((a, b) => b.length - a.length); | |
| duplicatesByDomain.push({ domain, duplicateSets }); | |
| } | |
| }); | |
| analysisCache.duplicatesByDomain = duplicatesByDomain.sort((a, b) => b.duplicateSets.length - a.duplicateSets.length); | |
| } | |
| function analyzePasswordReuse(data) { | |
| reusedPasswordMap.clear(); | |
| analysisCache.reusedPasswordEntries = []; | |
| if (!data || !Array.isArray(data.items)) return; | |
| const passwordMap = new Map(); | |
| data.items.forEach(item => { | |
| if (item.type === 1 && item.login?.password) { | |
| const password = item.login.password; | |
| if (!passwordMap.has(password)) passwordMap.set(password, []); | |
| passwordMap.get(password).push(item); | |
| } | |
| }); | |
| passwordMap.forEach((items, password) => { | |
| if (items.length <= 1) return; | |
| const uniqueServices = new Set(items.map(item => item.name)); | |
| if (uniqueServices.size <= 1) return; | |
| const sortedItems = [...items].sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
| reusedPasswordMap.set(password, sortedItems); | |
| analysisCache.reusedPasswordEntries.push({ | |
| password, | |
| items: sortedItems, | |
| occurrences: sortedItems.length | |
| }); | |
| }); | |
| analysisCache.reusedPasswordEntries.sort((a, b) => b.occurrences - a.occurrences); | |
| } | |
| function analyzeWeakPasswords(data) { | |
| analysisCache.weakPasswordItems = []; | |
| if (!data || !Array.isArray(data.items)) return; | |
| analysisCache.weakPasswordItems = data.items | |
| .filter(item => item.type === 1 && item.login?.password && item.login.password.length < 12) | |
| .sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
| } | |
| function updateDashboard() { | |
| const totalItems = analysisCache.totalLoginItems; | |
| const duplicateSetsCount = analysisCache.totalDuplicateSets; | |
| const totalDuplicateItems = analysisCache.totalDuplicateItems; | |
| const healthScoreRaw = totalItems > 0 ? Math.round(((totalItems - totalDuplicateItems) / totalItems) * 100) : 100; | |
| const healthScore = Math.min(100, Math.max(0, healthScoreRaw)); | |
| document.getElementById('health-score').textContent = `${healthScore}%`; | |
| document.getElementById('total-items').textContent = totalItems; | |
| document.getElementById('reused-passwords').textContent = analysisCache.reusedPasswordEntries.length; | |
| document.getElementById('weak-passwords').textContent = analysisCache.weakPasswordItems.length; | |
| document.getElementById('duplicate-sets').textContent = duplicateSetsCount; | |
| document.getElementById('duplicate-folders').textContent = analysisCache.duplicateFolderEntries.length; | |
| document.getElementById('pending-actions').textContent = deletedItems.size + folderMergeSelections.size; | |
| } | |
| function displayResults() { | |
| resultsDiv.replaceChildren(); | |
| updateDashboard(); | |
| const filterAction = { | |
| 'duplicates': displayDuplicatesReport, | |
| 'folders': displayFoldersReport, | |
| 'reused': displayReusedReport, | |
| 'weak': displayWeakReport | |
| }[currentFilter]; | |
| if(filterAction) filterAction(); | |
| updateDownloadButtonState(); | |
| } | |
| function displayDuplicatesReport() { | |
| const domainsToDisplay = analysisCache.duplicatesByDomain; | |
| if (domainsToDisplay.length === 0) { | |
| resultsDiv.innerHTML = `<div class="text-center text-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 p-12 rounded-xl shadow-lg border border-green-200 dark:border-green-700"> | |
| <div class="text-6xl mb-4">🎉</div> | |
| <h3 class="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">Excellent!</h3> | |
| <p class="text-gray-700 dark:text-gray-300">No duplicate items (same username & password) found in your vault.</p> | |
| </div>`; | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| domainsToDisplay.forEach(({ domain, duplicateSets }) => { | |
| const groupDiv = document.createElement("div"); | |
| groupDiv.className = "bg-white dark:bg-gray-800 shadow-lg rounded-xl p-6 border border-gray-200 dark:border-gray-700 hover:shadow-xl transition-all"; | |
| let contentHtml = `<h2 class="text-xl font-bold mb-4 text-gray-800 dark:text-gray-200 border-b-2 border-blue-500 dark:border-blue-400 pb-3 flex items-center gap-2"> | |
| <svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg> | |
| ${sanitizeHTML(domain) || "No Domain"} | |
| </h2>`; | |
| duplicateSets.forEach((set) => { | |
| const username = set[0].login.username; | |
| const itemIds = set.map(item => item.id).join(','); | |
| contentHtml += `<div class="border-2 border-orange-300 dark:border-orange-700 bg-gradient-to-r from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 rounded-lg p-5 mb-4 shadow-sm hover:shadow-md transition-all"> | |
| <div class="flex flex-col sm:flex-row justify-between sm:items-center mb-3"> | |
| <div> | |
| <h3 class="font-bold text-orange-800 dark:text-orange-300 text-lg flex items-center gap-2"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h4M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z"></path></svg> | |
| Duplicate Items (${set.length} items) | |
| </h3> | |
| <p class="text-sm text-orange-700 dark:text-orange-400 mt-1">Username: ${sanitizeHTML(username) || '<i>Empty</i>'}</p> | |
| </div> | |
| <button class="keep-one-btn mt-3 sm:mt-0 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white text-sm font-bold py-2.5 px-4 rounded-md shadow-md hover:shadow-lg transition-all flex items-center gap-2" data-ids="${itemIds}"> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg> | |
| Keep Latest Only | |
| </button> | |
| </div><ul class="space-y-2">${renderItems(set, { alreadySorted: true })}</ul></div>`; | |
| }); | |
| groupDiv.innerHTML = contentHtml; | |
| fragment.appendChild(groupDiv); | |
| }); | |
| resultsDiv.appendChild(fragment); | |
| } | |
| function displayFoldersReport() { | |
| if (analysisCache.duplicateFolderEntries.length === 0) { | |
| resultsDiv.innerHTML = `<div class="text-center text-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 p-12 rounded-xl shadow-lg border border-green-200 dark:border-green-700"> | |
| <div class="text-6xl mb-4">🎉</div> | |
| <h3 class="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">Excellent!</h3> | |
| <p class="text-gray-700 dark:text-gray-300">No duplicate folders found in your vault.</p> | |
| </div>`; | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| analysisCache.duplicateFolderEntries.forEach(({ name, folders }) => { | |
| const groupDiv = document.createElement("div"); | |
| groupDiv.className = "bg-white dark:bg-gray-800 shadow-md rounded-lg p-6"; | |
| const isMerged = folderMergeSelections.has(name); | |
| const keptFolderId = folderMergeSelections.get(name); | |
| const folderListHtml = folders.map(folderInfo => { | |
| const items = getItemsInFolder(folderInfo.id); | |
| let borderClass = 'border-gray-200 dark:border-gray-700'; | |
| let bgClass = 'bg-gray-50 dark:bg-gray-800'; | |
| let statusHtml = ''; | |
| if (isMerged) { | |
| if (folderInfo.id === keptFolderId) { | |
| borderClass = 'border-green-500'; | |
| bgClass = 'bg-green-50 dark:bg-green-900/30'; | |
| statusHtml = '<p class="font-bold text-sm text-green-600 dark:text-green-400">This folder will be kept</p>'; | |
| } else { | |
| borderClass = 'border-red-500'; | |
| bgClass = 'bg-red-50 dark:bg-red-900/30 opacity-60'; | |
| statusHtml = '<p class="font-bold text-sm text-red-600 dark:text-red-400">Will be merged & deleted</p>'; | |
| } | |
| } | |
| const itemsHtml = items.length > 0 | |
| ? `<ul class="list-disc list-inside mt-2 text-sm text-gray-600 dark:text-gray-400 space-y-1">${items.map(i => `<li>${sanitizeHTML(i.name)}</li>`).join('')}</ul>` | |
| : '<p class="text-sm text-gray-500 dark:text-gray-400 mt-2"><i>This folder is empty.</i></p>'; | |
| return ` | |
| <div class="p-4 border rounded-lg ${borderClass} ${bgClass} transition-all"> | |
| <div class="flex justify-between items-start"> | |
| <div> | |
| <p class="font-semibold">${sanitizeHTML(folderInfo.name)} (${items.length} item)</p> | |
| <p class="text-xs text-gray-500 dark:text-gray-400">ID: ${folderInfo.id}</p> | |
| ${statusHtml} | |
| </div> | |
| </div> | |
| ${itemsHtml} | |
| </div> | |
| `; | |
| }).join(''); | |
| groupDiv.innerHTML = ` | |
| <div class="border border-cyan-300 dark:border-cyan-700 bg-cyan-50 dark:bg-cyan-900/20 rounded-lg p-4 mb-4"> | |
| <div class="flex flex-col sm:flex-row justify-between sm:items-center mb-4"> | |
| <h3 class="font-semibold text-cyan-800 dark:text-cyan-300 text-lg">Duplicate Folder: "${sanitizeHTML(name)}"</h3> | |
| <button class="merge-folders-btn mt-2 sm:mt-0 bg-blue-600 text-white font-bold py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-all" data-folder-name="${sanitizeHTML(name)}" ${isMerged ? 'disabled' : ''}> | |
| ${isMerged ? 'Already Marked' : 'Merge & Keep 1'} | |
| </button> | |
| </div> | |
| <p class="text-sm text-cyan-700 dark:text-cyan-500 mt-1 mb-3">Click the button to automatically merge all items into the folder with the most content, and delete the rest.</p> | |
| <div class="space-y-3">${folderListHtml}</div> | |
| </div> | |
| `; | |
| fragment.appendChild(groupDiv); | |
| }); | |
| resultsDiv.appendChild(fragment); | |
| } | |
| function displayReusedReport() { | |
| if (analysisCache.reusedPasswordEntries.length === 0) { | |
| resultsDiv.innerHTML = `<div class="text-center text-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 p-12 rounded-xl shadow-lg border border-green-200 dark:border-green-700"> | |
| <div class="text-6xl mb-4">🎉</div> | |
| <h3 class="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">Excellent!</h3> | |
| <p class="text-gray-700 dark:text-gray-300">No passwords are reused across multiple services. Great job!</p> | |
| </div>`; | |
| return; | |
| } | |
| const fragment = document.createDocumentFragment(); | |
| analysisCache.reusedPasswordEntries.forEach(({ password, items, occurrences }) => { | |
| const groupDiv = document.createElement("div"); | |
| groupDiv.className = "bg-white dark:bg-gray-800 shadow-md rounded-lg p-6"; | |
| let contentHtml = `<div class="border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 rounded-lg p-4 mb-4"> | |
| <h3 class="font-semibold text-red-800 dark:text-red-300">Used ${occurrences} times</h3> | |
| <div class="flex items-center gap-2 mt-1 mb-3"> | |
| <p class="text-sm">Password:</p> | |
| <span class="font-mono bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-sm" data-password="${sanitizeHTML(password)}">********</span> | |
| <button class="toggle-password-btn text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg> | |
| </button> | |
| </div> | |
| <p class="text-sm font-semibold mb-2">Used on the following services (recommended to change):</p> | |
| <ul class="space-y-2">${renderItems(items, { showReuseWarning: false, alreadySorted: true })}</ul></div>`; | |
| groupDiv.innerHTML = contentHtml; | |
| fragment.appendChild(groupDiv); | |
| }); | |
| resultsDiv.appendChild(fragment); | |
| } | |
| function displayWeakReport() { | |
| if (analysisCache.weakPasswordItems.length === 0) { | |
| resultsDiv.innerHTML = `<div class="text-center text-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/30 dark:to-green-800/30 p-12 rounded-xl shadow-lg border border-green-200 dark:border-green-700"> | |
| <div class="text-6xl mb-4">🎉</div> | |
| <h3 class="text-3xl font-bold text-green-600 dark:text-green-400 mb-2">Excellent!</h3> | |
| <p class="text-gray-700 dark:text-gray-300">No weak passwords found. All passwords meet the minimum length requirement!</p> | |
| </div>`; | |
| return; | |
| } | |
| const groupDiv = document.createElement("div"); | |
| groupDiv.className = "bg-white dark:bg-gray-800 shadow-md rounded-lg p-6"; | |
| let contentHtml = `<div class="border border-purple-300 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4 mb-4"> | |
| <h3 class="font-semibold text-purple-800 dark:text-purple-300">Found ${analysisCache.weakPasswordItems.length} Weak Passwords (< 12 characters)</h3> | |
| <ul class="space-y-2 mt-2">${renderItems(analysisCache.weakPasswordItems, { showReuseWarning: false, alreadySorted: true })}</ul></div>`; | |
| groupDiv.innerHTML = contentHtml; | |
| resultsDiv.appendChild(groupDiv); | |
| } | |
| function renderItems(items, options = {}) { | |
| if (!Array.isArray(items) || items.length === 0) return ''; | |
| const { showReuseWarning = true, alreadySorted = false } = options; | |
| const itemsToRender = alreadySorted ? items : [...items].sort((a, b) => new Date(b.revisionDate) - new Date(a.revisionDate)); | |
| return itemsToRender.map(item => { | |
| const isDeleted = deletedItems.has(item.id); | |
| const password = item.login.password || ''; | |
| const isReused = showReuseWarning && reusedPasswordMap.has(password); | |
| let reuseWarningHtml = ''; | |
| if (isReused) { | |
| reuseWarningHtml = `<div class="tooltip inline-block ml-2"> | |
| <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-exclamation-triangle-fill text-red-500" viewBox="0 0 16 16"><path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/></svg> | |
| <span class="tooltiptext">This password is used in ${reusedPasswordMap.get(password).length} different services. This is a security risk!</span> | |
| </div>`; | |
| } | |
| return `<li class="flex flex-col sm:flex-row sm:items-start sm:justify-between py-3 border-b border-gray-200 dark:border-gray-700 last:border-b-0 ${isDeleted ? "opacity-40" : ""}"> | |
| <div class="flex-grow mb-3 sm:mb-0 pr-4"> | |
| <p class="font-semibold">${sanitizeHTML(item.name)}</p> | |
| <p class="text-sm text-gray-600 dark:text-gray-400">${sanitizeHTML(item.login.username) || '<i>Empty username</i>'}</p> | |
| <div class="flex items-center gap-2 mt-1"> | |
| <p class="text-sm">Password:</p> | |
| <span class="font-mono bg-gray-200 dark:bg-gray-700 px-2 py-1 rounded text-sm" data-password="${sanitizeHTML(password)}">********</span> | |
| <button class="toggle-password-btn text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0zM2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg> | |
| </button> | |
| ${reuseWarningHtml} | |
| </div> | |
| </div> | |
| <button class="delete-btn w-full sm:w-auto text-sm font-bold py-2 px-4 rounded-md transition-all self-center ${isDeleted ? 'bg-yellow-400 hover:bg-yellow-500 text-gray-800 dark:text-gray-900' : 'bg-red-500 hover:bg-red-600 text-white'}" data-id="${sanitizeHTML(item.id)}">${isDeleted ? "Undo" : "Mark Delete"}</button> | |
| </li>`; | |
| }).join(""); | |
| } | |
| function updateDownloadButtonState() { | |
| const hasPendingActions = deletedItems.size > 0 || folderMergeSelections.size > 0; | |
| downloadBtn.disabled = !hasPendingActions; | |
| downloadBtn.classList.toggle('opacity-50', !hasPendingActions); | |
| downloadBtn.classList.toggle('cursor-not-allowed', !hasPendingActions); | |
| } | |
| // --- Event Handlers --- | |
| function handleFileSelect(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| originalData = null; deletedItems.clear(); folderMergeSelections.clear(); showNotification(""); | |
| originalFileName = file.name; | |
| fileLabel.innerHTML = `<span class="font-semibold">⏳ Loading: ${file.name}...</span>`; | |
| if(initialMessage) initialMessage.style.display = 'none'; | |
| analysisDashboard.style.display = 'block'; | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| originalData = JSON.parse(e.target.result); | |
| if (originalData.encrypted) throw new Error("Encrypted files are not supported. Please export as plain JSON."); | |
| analyzeData(originalData); | |
| displayResults(); | |
| downloadBtn.style.display = "flex"; | |
| fileLabel.innerHTML = `<span class="font-semibold">✓ Loaded: ${file.name}</span>`; | |
| showNotification(`✅ File loaded successfully! Found ${originalData.items?.length || 0} items.`, false, 3000); | |
| } catch (error) { | |
| showNotification(`❌ Error: ${error.message}`, true, 5000); | |
| fileLabel.innerHTML = `<span class="font-semibold">📁 1. Select Bitwarden Export File (.json)</span>`; | |
| downloadBtn.style.display = "none"; | |
| analysisDashboard.style.display = 'none'; | |
| } | |
| }; | |
| reader.readAsText(file); | |
| } | |
| function handleResultsClick(event) { | |
| const target = event.target; | |
| const deleteBtn = target.closest('.delete-btn'); | |
| const toggleBtn = target.closest('.toggle-password-btn'); | |
| const keepOneBtn = target.closest('.keep-one-btn'); | |
| const mergeFoldersBtn = target.closest('.merge-folders-btn'); | |
| if (deleteBtn) { | |
| const id = deleteBtn.dataset.id; | |
| if (deletedItems.has(id)) deletedItems.delete(id); else deletedItems.add(id); | |
| } else if (toggleBtn) { | |
| const passwordSpan = toggleBtn.closest('div').querySelector('[data-password]'); | |
| const isHidden = passwordSpan.textContent === '********'; | |
| passwordSpan.textContent = isHidden ? passwordSpan.dataset.password : '********'; | |
| } else if (keepOneBtn) { | |
| const ids = keepOneBtn.dataset.ids.split(',').filter(Boolean); | |
| ids.slice(1).forEach(id => deletedItems.add(id)); | |
| } else if (mergeFoldersBtn) { | |
| const folderName = mergeFoldersBtn.dataset.folderName; | |
| const duplicateFolders = duplicateFolderMap.get(folderName); | |
| if (!duplicateFolders || duplicateFolders.length < 2) return; | |
| const folderEntry = analysisCache.duplicateFolderLookup.get(folderName); | |
| let folderToKeepId = folderEntry?.folders?.[0]?.id; | |
| if (!folderToKeepId) { | |
| const fallback = duplicateFolders | |
| .map(folder => ({ | |
| id: folder.id, | |
| itemCount: getItemsInFolder(folder.id).length | |
| })) | |
| .sort((a, b) => b.itemCount - a.itemCount); | |
| folderToKeepId = fallback[0]?.id; | |
| } | |
| if (!folderToKeepId) return; | |
| folderMergeSelections.set(folderName, folderToKeepId); | |
| showNotification(`✅ Folder "${folderName}" will be merged into the folder with most items.`, false); | |
| } | |
| if (deleteBtn || keepOneBtn || mergeFoldersBtn) { | |
| displayResults(); | |
| } | |
| } | |
| function handleFilterClick(event) { | |
| const target = event.target.closest('.filter-btn'); | |
| if (!target || target.classList.contains('filter-btn-active')) return; | |
| document.querySelectorAll('#filter-controls .filter-btn').forEach(btn => { | |
| btn.classList.remove('filter-btn-active'); | |
| btn.classList.add('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300'); | |
| btn.classList.remove('bg-blue-600', 'dark:bg-blue-400', 'text-white', 'dark:text-gray-900'); | |
| }); | |
| target.classList.add('filter-btn-active'); | |
| target.classList.remove('bg-gray-100', 'dark:bg-gray-700', 'text-gray-700', 'dark:text-gray-300'); | |
| target.classList.add('bg-blue-600', 'dark:bg-blue-400', 'text-white', 'dark:text-gray-900'); | |
| currentFilter = target.dataset.filter; | |
| // Show/hide action buttons based on filter | |
| document.getElementById('bulk-delete-btn').style.display = currentFilter === 'duplicates' ? 'inline-flex' : 'none'; | |
| document.getElementById('merge-all-folders-btn').style.display = currentFilter === 'folders' ? 'inline-flex' : 'none'; | |
| displayResults(); | |
| } | |
| async function downloadUpdatedJSON() { | |
| if (!originalData || (deletedItems.size === 0 && folderMergeSelections.size === 0)) return; | |
| downloadBtn.disabled = true; downloadSpinner.style.display = "inline-block"; downloadText.textContent = "Processing..."; | |
| await new Promise(resolve => setTimeout(resolve, 50)); | |
| const processedData = JSON.parse(JSON.stringify(originalData)); | |
| const foldersToDelete = new Set(); | |
| let itemsMovedCount = 0; | |
| // 1. Process folder merges | |
| folderMergeSelections.forEach((keptFolderId, folderName) => { | |
| const duplicateFolders = duplicateFolderMap.get(folderName) || []; | |
| const foldersToMerge = duplicateFolders.filter(f => f.id !== keptFolderId); | |
| foldersToMerge.forEach(folderToRemove => { | |
| foldersToDelete.add(folderToRemove.id); | |
| processedData.items.forEach(item => { | |
| if (item.folderId === folderToRemove.id) { | |
| item.folderId = keptFolderId; | |
| itemsMovedCount++; | |
| } | |
| }); | |
| }); | |
| }); | |
| // 2. Filter out deleted folders and items | |
| processedData.folders = processedData.folders.filter(folder => !foldersToDelete.has(folder.id)); | |
| const itemsDeletedCount = deletedItems.size; | |
| processedData.items = processedData.items.filter(item => !deletedItems.has(item.id)); | |
| // 3. Create and trigger download | |
| const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(processedData, null, 2)); | |
| const downloadAnchorNode = document.createElement("a"); | |
| downloadAnchorNode.setAttribute("href", dataStr); | |
| downloadAnchorNode.setAttribute("download", `${originalFileName.replace(/\.json$/, "")}_cleaned.json`); | |
| document.body.appendChild(downloadAnchorNode); downloadAnchorNode.click(); downloadAnchorNode.remove(); | |
| showNotification(`✅ Success! ${itemsDeletedCount} items & ${foldersToDelete.size} duplicate folders cleaned.`, false, 5000); | |
| // 5. Reset state with the new clean data and re-analyze | |
| originalData = processedData; | |
| deletedItems.clear(); | |
| analyzeData(originalData); | |
| displayResults(); | |
| downloadSpinner.style.display = "none"; downloadText.textContent = "3. Download Clean Vault"; | |
| } | |
| function handleBulkDelete() { | |
| if (!originalData || currentFilter !== 'duplicates') return; | |
| let itemsMarked = 0; | |
| analysisCache.duplicatesByDomain.forEach(({ duplicateSets }) => { | |
| duplicateSets.forEach(set => { | |
| set.slice(1).forEach(item => { | |
| if (!deletedItems.has(item.id)) { | |
| deletedItems.add(item.id); | |
| itemsMarked++; | |
| } | |
| }); | |
| }); | |
| }); | |
| if (itemsMarked > 0) showNotification(`✅ ${itemsMarked} duplicate items marked for deletion.`, false); | |
| displayResults(); | |
| } | |
| function handleResetSelection() { | |
| const itemResetCount = deletedItems.size; | |
| const folderResetCount = folderMergeSelections.size; | |
| if (itemResetCount > 0 || folderResetCount > 0) { | |
| deletedItems.clear(); | |
| folderMergeSelections.clear(); | |
| showNotification(`✅ Reset complete: ${itemResetCount} items & ${folderResetCount} folder groups cleared.`, false); | |
| displayResults(); | |
| } | |
| } | |
| function handleMergeAllFolders() { | |
| if (!originalData || currentFilter !== 'folders') return; | |
| let foldersMarked = 0; | |
| analysisCache.duplicateFolderEntries.forEach(({ name, folders }) => { | |
| if (folderMergeSelections.has(name)) return; | |
| const folderToKeepId = folders[0]?.id; | |
| if (!folderToKeepId) return; | |
| folderMergeSelections.set(name, folderToKeepId); | |
| foldersMarked++; | |
| }); | |
| if (foldersMarked > 0) { | |
| showNotification(`✅ ${foldersMarked} folder groups marked for merging.`, false); | |
| displayResults(); | |
| } | |
| } | |
| function toggleHelpPanel() { | |
| const helpPanel = document.getElementById('help-panel'); | |
| helpPanel.classList.toggle('hidden'); | |
| if (!helpPanel.classList.contains('hidden')) { | |
| helpPanel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); | |
| } | |
| } | |
| // --- Theme Toggle --- | |
| const themeToggleBtn = document.getElementById('theme-toggle'); | |
| const darkIcon = document.getElementById('theme-toggle-dark-icon'); | |
| const lightIcon = document.getElementById('theme-toggle-light-icon'); | |
| if (localStorage.getItem('color-theme') === 'dark' || (!('color-theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { | |
| lightIcon.classList.remove('hidden'); | |
| document.documentElement.classList.add('dark'); | |
| } else { | |
| darkIcon.classList.remove('hidden'); | |
| document.documentElement.classList.remove('dark'); | |
| } | |
| themeToggleBtn.addEventListener('click', function() { | |
| darkIcon.classList.toggle('hidden'); | |
| lightIcon.classList.toggle('hidden'); | |
| document.documentElement.classList.toggle('dark'); | |
| const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; | |
| localStorage.setItem('color-theme', theme); | |
| }); | |
| // --- Event Listeners Initialization --- | |
| fileInput.addEventListener("change", handleFileSelect); | |
| resultsDiv.addEventListener("click", handleResultsClick); | |
| filterControls.addEventListener("click", handleFilterClick); | |
| downloadBtn.addEventListener("click", downloadUpdatedJSON); | |
| document.getElementById('bulk-delete-btn').addEventListener('click', handleBulkDelete); | |
| document.getElementById('merge-all-folders-btn').addEventListener('click', handleMergeAllFolders); | |
| document.getElementById('reset-selection-btn').addEventListener('click', handleResetSelection); | |
| document.getElementById('help-toggle').addEventListener('click', toggleHelpPanel); | |
| document.getElementById('close-help').addEventListener('click', toggleHelpPanel); | |
| document.getElementById('help-btn-initial').addEventListener('click', toggleHelpPanel); | |
| window.addEventListener("beforeunload", (e) => { | |
| if (deletedItems.size > 0 || folderMergeSelections.size > 0) { | |
| e.preventDefault(); e.returnValue = ""; return ""; | |
| } | |
| }); | |
| // Initial setup | |
| document.getElementById('bulk-delete-btn').style.display = 'inline-flex'; // Show by default | |
| </script> | |
| </body> | |
| </html> |
Nice work ! I get a "Error: Cannot set properties of null (setting 'textContent')" error, though ... Being quite useless at JS, I cannot figure out which var out (of the 16) is null at runtime (passwordSpan or notificationdiv ?). Any help would be greatly appreciated. Thanks.
I have already fixed this, and I have also provided usage instructions directly on the website. Everything runs locally.

What website? I only see rows of code, where's the instructions?
What website? I only see rows of code, where's the instructions?
You export a backup from Bitwarden as a JSON file, then upload it to that website. After that, download the source, open it in your browser, and then upload your file.
Thanks @bangundwir for the explanation, I'll try it. I hope to not get the same duplicate issue of @molitar

Nice work !
I get a "Error: Cannot set properties of null (setting 'textContent')" error, though ...
Being quite useless at JS, I cannot figure out which var out (of the 16) is null at runtime (passwordSpan or notificationdiv ?).
Any help would be greatly appreciated. Thanks.