Skip to content

Instantly share code, notes, and snippets.

@bangundwir
Last active October 12, 2025 22:34
Show Gist options
  • Save bangundwir/0b009c9cdc3b03d9913626aa790fdd88 to your computer and use it in GitHub Desktop.
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

@DavideRedivaD
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

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