Skip to content

Instantly share code, notes, and snippets.

@bangundwir
Last active December 12, 2025 19:53
Show Gist options
  • Select an option

  • Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.

Select an option

Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
fixing
<!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, "&lt;").replace(/>/g, "&gt;"); }
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 (&lt; 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>
@ivanluelmo
Copy link

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.

@bangundwir
Copy link
Author

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.
image

@DantesNoWar
Copy link

What website? I only see rows of code, where's the instructions?

@bangundwir
Copy link
Author

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.

@molitar
Copy link

molitar commented Oct 12, 2025

it still seems buggy how are these all the same?

image

They are different domains

@DantesNoWar
Copy link

Thanks @bangundwir for the explanation, I'll try it. I hope to not get the same duplicate issue of @molitar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment