class BookmarkManager { constructor() { this.bookmarks = []; this.currentEditId = null; this.currentFilter = 'all'; // Track the current filter this.searchTimeout = null; // For debounced search this.virtualScrollThreshold = 100; // Threshold for virtual scrolling this.itemsPerPage = 50; // Items per page for pagination this.currentPage = 1; this.isLoading = false; // Loading state tracker // Enhanced link testing configuration this.linkTestConfig = { timeout: 10000, // Default 10 seconds maxRetries: 2, // Maximum retry attempts for transient failures retryDelay: 1000, // Delay between retries in milliseconds userAgent: 'BookmarkManager/1.0 (Link Checker)' }; // Error categorization constants this.ERROR_CATEGORIES = { NETWORK_ERROR: 'network_error', TIMEOUT: 'timeout', INVALID_URL: 'invalid_url', HTTP_ERROR: 'http_error', CORS_BLOCKED: 'cors_blocked', DNS_ERROR: 'dns_error', SSL_ERROR: 'ssl_error', CONNECTION_REFUSED: 'connection_refused', UNKNOWN: 'unknown' }; // Mobile touch interaction properties this.touchState = { startX: 0, startY: 0, currentX: 0, currentY: 0, isDragging: false, swipeThreshold: 100, // Minimum distance for swipe currentBookmark: null, swipeDirection: null }; // Pull-to-refresh properties this.pullToRefresh = { startY: 0, currentY: 0, threshold: 80, isActive: false, isPulling: false, element: null }; // Advanced search properties this.searchHistory = []; this.savedSearches = []; this.searchSuggestions = []; this.currentAdvancedSearch = null; this.maxSearchHistory = 20; this.maxSearchSuggestions = 10; // Security and privacy properties this.securitySettings = { encryptionEnabled: false, encryptionKey: null, privacyMode: false, accessLogging: true, passwordProtection: false, sessionTimeout: 30 * 60 * 1000, // 30 minutes maxLoginAttempts: 3, lockoutDuration: 15 * 60 * 1000 // 15 minutes }; this.accessLog = []; this.encryptedCollections = new Set(); this.privateBookmarks = new Set(); this.securitySession = { isAuthenticated: false, lastActivity: Date.now(), loginAttempts: 0, lockedUntil: null }; this.init(); } init() { this.loadLinkTestConfigFromStorage(); this.loadSecuritySettings(); this.loadAccessLog(); this.loadPrivateBookmarks(); this.loadEncryptedCollections(); this.initializeSecurity(); this.loadBookmarksFromStorage(); this.loadSearchHistory(); this.loadSavedSearches(); this.initializeSharing(); this.bindEvents(); this.renderBookmarks(); this.updateStats(); } bindEvents() { // Import functionality document.getElementById('importBtn').addEventListener('click', () => { this.showModal('importModal'); }); document.getElementById('importFileBtn').addEventListener('click', () => { this.importBookmarks(); }); document.getElementById('previewImportBtn').addEventListener('click', () => { this.previewImport(); }); // Export functionality document.getElementById('exportBtn').addEventListener('click', () => { this.showExportModal(); }); // Add bookmark document.getElementById('addBookmarkBtn').addEventListener('click', () => { this.showBookmarkModal(); }); // Search with debouncing document.getElementById('searchInput').addEventListener('input', (e) => { this.debouncedSearch(e.target.value); this.updateSearchSuggestions(e.target.value); }); document.getElementById('searchBtn').addEventListener('click', () => { const query = document.getElementById('searchInput').value; this.searchBookmarks(query); }); // Advanced search functionality document.getElementById('advancedSearchBtn').addEventListener('click', () => { this.showAdvancedSearchModal(); }); // Test all links document.getElementById('testAllBtn').addEventListener('click', () => { this.testAllLinks(); }); // Test invalid links only document.getElementById('testInvalidBtn').addEventListener('click', () => { this.testInvalidLinks(); }); // Filter buttons document.querySelectorAll('.stats-filter').forEach(button => { button.addEventListener('click', (e) => { const filter = e.target.getAttribute('data-filter'); this.applyFilter(filter); // Update active state and ARIA attributes document.querySelectorAll('.stats-filter').forEach(btn => { btn.classList.remove('active'); btn.setAttribute('aria-pressed', 'false'); }); e.target.classList.add('active'); e.target.setAttribute('aria-pressed', 'true'); }); // Add keyboard support for filter buttons button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); button.click(); } }); }); // Find duplicates document.getElementById('findDuplicatesBtn').addEventListener('click', () => { this.findDuplicates(); }); // Clear all document.getElementById('clearAllBtn').addEventListener('click', () => { if (confirm('Are you sure you want to delete all bookmarks? This cannot be undone.')) { this.clearAllBookmarks(); } }); // Settings document.getElementById('settingsBtn').addEventListener('click', () => { this.showSettingsModal(); }); // Analytics document.getElementById('analyticsBtn').addEventListener('click', () => { this.showAnalyticsModal(); }); // Add keyboard support for all buttons this.addKeyboardSupportToButtons(); // Modal events document.querySelectorAll('.close').forEach(closeBtn => { closeBtn.addEventListener('click', (e) => { const modal = e.target.closest('.modal'); this.hideModal(modal.id); }); }); document.getElementById('cancelImportBtn').addEventListener('click', () => { this.hideModal('importModal'); }); document.getElementById('cancelBookmarkBtn').addEventListener('click', () => { this.hideModal('bookmarkModal'); }); document.getElementById('cancelSettingsBtn').addEventListener('click', () => { this.hideModal('settingsModal'); }); // Analytics modal events document.getElementById('closeAnalyticsBtn').addEventListener('click', () => { this.hideModal('analyticsModal'); }); document.getElementById('exportAnalyticsBtn').addEventListener('click', () => { this.exportAnalyticsData(); }); document.getElementById('generateReportBtn').addEventListener('click', () => { this.generateAnalyticsReport(); }); // Export modal events document.getElementById('cancelExportBtn').addEventListener('click', () => { this.hideModal('exportModal'); }); document.getElementById('exportForm').addEventListener('submit', (e) => { e.preventDefault(); this.performExport(); }); document.getElementById('exportFilter').addEventListener('change', () => { this.updateExportPreview(); }); document.getElementById('exportFolderSelect').addEventListener('change', () => { this.updateExportPreview(); }); // Backup reminder modal events document.getElementById('backupNowBtn').addEventListener('click', () => { this.hideModal('backupReminderModal'); this.showExportModal(); }); document.getElementById('remindLaterBtn').addEventListener('click', () => { this.hideModal('backupReminderModal'); // Set reminder for next week this.backupSettings.lastBackupDate = Date.now() - (23 * 24 * 60 * 60 * 1000); // 23 days ago this.saveBackupSettings(); }); document.getElementById('disableRemindersBtn').addEventListener('click', () => { this.backupSettings.enabled = false; this.saveBackupSettings(); this.hideModal('backupReminderModal'); }); // Settings form document.getElementById('settingsForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveSettings(); }); document.getElementById('resetSettingsBtn').addEventListener('click', () => { this.resetSettings(); }); // Bookmark form document.getElementById('bookmarkForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveBookmark(); }); // Context menu modal events document.getElementById('visitBookmarkBtn').addEventListener('click', () => { if (this.currentContextBookmark) { this.trackBookmarkVisit(this.currentContextBookmark.id); this.logAccess('bookmark_visited', { bookmarkId: this.currentContextBookmark.id, url: this.currentContextBookmark.url }); window.open(this.currentContextBookmark.url, '_blank'); this.hideModal('contextModal'); } }); document.getElementById('testBookmarkBtn').addEventListener('click', () => { if (this.currentContextBookmark) { this.testLink(this.currentContextBookmark); this.hideModal('contextModal'); } }); document.getElementById('editBookmarkBtn').addEventListener('click', () => { if (this.currentContextBookmark) { this.hideModal('contextModal'); this.showBookmarkModal(this.currentContextBookmark); } }); document.getElementById('deleteBookmarkBtn').addEventListener('click', () => { if (this.currentContextBookmark) { this.hideModal('contextModal'); this.deleteBookmark(this.currentContextBookmark.id); } }); // Close modals when clicking outside window.addEventListener('click', (e) => { if (e.target.classList.contains('modal')) { this.hideModal(e.target.id); } }); // Global keyboard event handlers document.addEventListener('keydown', (e) => { this.handleGlobalKeydown(e); }); // Advanced search modal events this.bindAdvancedSearchEvents(); // Organization feature event bindings this.bindOrganizationEvents(); // Advanced import/export event bindings this.bindAdvancedImportEvents(); // Mobile touch interaction handlers this.initializeMobileTouchHandlers(); this.initializePullToRefresh(); // Drag and drop functionality this.initializeDragAndDrop(); // Bulk operations this.bulkSelection = new Set(); this.bulkMode = false; // Security event bindings this.bindSecurityEvents(); // Security settings form events document.getElementById('securitySettingsForm').addEventListener('submit', (e) => { e.preventDefault(); this.saveSecuritySettings(); }); document.getElementById('cancelSecuritySettingsBtn').addEventListener('click', () => { this.hideModal('securitySettingsModal'); }); // Security authentication form document.getElementById('securityAuthForm').addEventListener('submit', (e) => { e.preventDefault(); const password = document.getElementById('securityPassword').value; this.authenticateUser(password); }); // Security audit events - with null checks const viewAuditLogBtn = document.getElementById('viewAuditLogBtn'); if (viewAuditLogBtn) { viewAuditLogBtn.addEventListener('click', () => { this.showSecurityAuditModal(); }); } const refreshAuditBtn = document.getElementById('refreshAuditBtn'); if (refreshAuditBtn) { refreshAuditBtn.addEventListener('click', () => { this.populateSecurityAuditLog(); }); } const exportAuditBtn = document.getElementById('exportAuditBtn'); if (exportAuditBtn) { exportAuditBtn.addEventListener('click', () => { this.exportSecurityAuditLog(); }); } const clearAuditBtn = document.getElementById('clearAuditBtn'); if (clearAuditBtn) { clearAuditBtn.addEventListener('click', () => { this.clearSecurityAuditLog(); }); } const closeAuditBtn = document.getElementById('closeAuditBtn'); if (closeAuditBtn) { closeAuditBtn.addEventListener('click', () => { this.hideModal('securityAuditModal'); }); } // Password protection toggle document.getElementById('passwordProtection').addEventListener('change', (e) => { const passwordSetupGroup = document.getElementById('passwordSetupGroup'); if (e.target.checked) { passwordSetupGroup.style.display = 'block'; } else { passwordSetupGroup.style.display = 'none'; } }); // Privacy controls in context menu document.getElementById('contextPrivacyToggle').addEventListener('change', (e) => { if (this.currentContextBookmark) { this.toggleBookmarkPrivacy(this.currentContextBookmark.id); e.target.checked = this.isBookmarkPrivate(this.currentContextBookmark.id); } }); document.getElementById('contextEncryptionToggle').addEventListener('change', (e) => { if (this.currentContextBookmark) { this.toggleBookmarkEncryption(this.currentContextBookmark.id); e.target.checked = this.isBookmarkEncrypted(this.currentContextBookmark.id); } }); } // Initialize drag and drop functionality initializeDragAndDrop() { // Enable drag and drop for bookmark items document.addEventListener('dragstart', (e) => { if (e.target.closest('.bookmark-item')) { const bookmarkItem = e.target.closest('.bookmark-item'); const bookmarkId = bookmarkItem.dataset.bookmarkId; e.dataTransfer.setData('text/plain', bookmarkId); e.dataTransfer.effectAllowed = 'move'; bookmarkItem.classList.add('dragging'); } }); document.addEventListener('dragend', (e) => { if (e.target.closest('.bookmark-item')) { const bookmarkItem = e.target.closest('.bookmark-item'); bookmarkItem.classList.remove('dragging'); } }); // Handle drop zones (folder cards) document.addEventListener('dragover', (e) => { const folderCard = e.target.closest('.folder-card'); if (folderCard) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; folderCard.classList.add('drag-over'); } }); document.addEventListener('dragleave', (e) => { const folderCard = e.target.closest('.folder-card'); if (folderCard && !folderCard.contains(e.relatedTarget)) { folderCard.classList.remove('drag-over'); } }); document.addEventListener('drop', (e) => { const folderCard = e.target.closest('.folder-card'); if (folderCard) { e.preventDefault(); folderCard.classList.remove('drag-over'); const bookmarkId = e.dataTransfer.getData('text/plain'); const targetFolder = folderCard.dataset.folderName || ''; this.moveBookmarkToFolder(bookmarkId, targetFolder); } }); } // Security and Privacy Methods // Initialize security system initializeSecurity() { this.checkSessionTimeout(); this.startSessionTimeoutTimer(); // Check if user needs to authenticate if (this.securitySettings.passwordProtection && !this.securitySession.isAuthenticated) { this.showSecurityAuthModal(); } } // Load security settings from storage loadSecuritySettings() { try { const settings = localStorage.getItem('bookmarkManager_securitySettings'); if (settings) { this.securitySettings = { ...this.securitySettings, ...JSON.parse(settings) }; } } catch (error) { console.error('Error loading security settings:', error); } } // Save security settings to storage saveSecuritySettings() { try { const form = document.getElementById('securitySettingsForm'); const formData = new FormData(form); this.securitySettings.encryptionEnabled = formData.get('encryptionEnabled') === 'on'; this.securitySettings.privacyMode = formData.get('privacyMode') === 'on'; this.securitySettings.accessLogging = formData.get('accessLogging') === 'on'; this.securitySettings.passwordProtection = formData.get('passwordProtection') === 'on'; this.securitySettings.sessionTimeout = parseInt(formData.get('sessionTimeout')) * 60 * 1000; this.securitySettings.maxLoginAttempts = parseInt(formData.get('maxLoginAttempts')); this.securitySettings.lockoutDuration = parseInt(formData.get('lockoutDuration')) * 60 * 1000; // Handle password setup const newPassword = formData.get('newPassword'); const confirmPassword = formData.get('confirmPassword'); if (this.securitySettings.passwordProtection && newPassword) { if (newPassword !== confirmPassword) { alert('Passwords do not match!'); return; } if (newPassword.length < 8) { alert('Password must be at least 8 characters long!'); return; } this.securitySettings.encryptionKey = this.hashPassword(newPassword); } localStorage.setItem('bookmarkManager_securitySettings', JSON.stringify(this.securitySettings)); this.hideModal('securitySettingsModal'); // Re-initialize security if password protection was enabled if (this.securitySettings.passwordProtection) { this.securitySession.isAuthenticated = false; this.showSecurityAuthModal(); } this.logAccess('security_settings_changed', { encryptionEnabled: this.securitySettings.encryptionEnabled, privacyMode: this.securitySettings.privacyMode, passwordProtection: this.securitySettings.passwordProtection }); alert('Security settings saved successfully!'); } catch (error) { console.error('Error saving security settings:', error); alert('Error saving security settings. Please try again.'); } } // Load access log from storage loadAccessLog() { try { const log = localStorage.getItem('bookmarkManager_accessLog'); if (log) { this.accessLog = JSON.parse(log); } } catch (error) { console.error('Error loading access log:', error); } } // Log access events for security auditing logAccess(action, details = {}) { if (!this.securitySettings.accessLogging) return; const logEntry = { timestamp: Date.now(), action: action, details: details, userAgent: navigator.userAgent, ip: 'client-side', // Cannot get real IP in client-side app sessionId: this.getSessionId() }; this.accessLog.push(logEntry); // Keep only last 1000 entries to prevent storage bloat if (this.accessLog.length > 1000) { this.accessLog = this.accessLog.slice(-1000); } try { localStorage.setItem('bookmarkManager_accessLog', JSON.stringify(this.accessLog)); } catch (error) { console.error('Error saving access log:', error); } } // Get or create session ID getSessionId() { if (!this.sessionId) { this.sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } return this.sessionId; } // Hash password for storage (simple implementation) hashPassword(password) { // In a real application, use a proper hashing library like bcrypt // This is a simple hash for demonstration purposes let hash = 0; for (let i = 0; i < password.length; i++) { const char = password.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(); } // Authenticate user with password authenticateUser(password) { if (this.securitySession.lockedUntil && Date.now() < this.securitySession.lockedUntil) { const remainingTime = Math.ceil((this.securitySession.lockedUntil - Date.now()) / 60000); alert(`Account is locked. Please try again in ${remainingTime} minutes.`); return; } const hashedPassword = this.hashPassword(password); if (hashedPassword === this.securitySettings.encryptionKey) { this.securitySession.isAuthenticated = true; this.securitySession.lastActivity = Date.now(); this.securitySession.loginAttempts = 0; this.securitySession.lockedUntil = null; this.hideModal('securityAuthModal'); this.logAccess('successful_login', { timestamp: Date.now() }); // Clear password field document.getElementById('securityPassword').value = ''; } else { this.securitySession.loginAttempts++; this.logAccess('failed_login_attempt', { attempts: this.securitySession.loginAttempts, timestamp: Date.now() }); if (this.securitySession.loginAttempts >= this.securitySettings.maxLoginAttempts) { this.securitySession.lockedUntil = Date.now() + this.securitySettings.lockoutDuration; this.logAccess('account_locked', { lockoutDuration: this.securitySettings.lockoutDuration, timestamp: Date.now() }); alert(`Too many failed attempts. Account locked for ${this.securitySettings.lockoutDuration / 60000} minutes.`); } else { const remainingAttempts = this.securitySettings.maxLoginAttempts - this.securitySession.loginAttempts; alert(`Incorrect password. ${remainingAttempts} attempts remaining.`); } // Clear password field document.getElementById('securityPassword').value = ''; } } // Check session timeout checkSessionTimeout() { if (this.securitySettings.passwordProtection && this.securitySession.isAuthenticated && Date.now() - this.securitySession.lastActivity > this.securitySettings.sessionTimeout) { this.securitySession.isAuthenticated = false; this.logAccess('session_timeout', { timestamp: Date.now() }); this.showSecurityAuthModal(); } } // Start session timeout timer startSessionTimeoutTimer() { setInterval(() => { this.checkSessionTimeout(); }, 60000); // Check every minute // Update last activity on user interaction document.addEventListener('click', () => { if (this.securitySession.isAuthenticated) { this.securitySession.lastActivity = Date.now(); } }); document.addEventListener('keydown', () => { if (this.securitySession.isAuthenticated) { this.securitySession.lastActivity = Date.now(); } }); } // Encrypt bookmark data encryptBookmark(bookmark) { if (!this.securitySettings.encryptionEnabled || !this.securitySettings.encryptionKey) { return bookmark; } try { // Simple encryption - in production, use a proper encryption library const key = this.securitySettings.encryptionKey; const encryptedTitle = this.simpleEncrypt(bookmark.title, key); const encryptedUrl = this.simpleEncrypt(bookmark.url, key); const encryptedNotes = bookmark.notes ? this.simpleEncrypt(bookmark.notes, key) : ''; return { ...bookmark, title: encryptedTitle, url: encryptedUrl, notes: encryptedNotes, encrypted: true }; } catch (error) { console.error('Error encrypting bookmark:', error); return bookmark; } } // Decrypt bookmark data decryptBookmark(bookmark) { if (!bookmark.encrypted || !this.securitySettings.encryptionKey) { return bookmark; } try { const key = this.securitySettings.encryptionKey; const decryptedTitle = this.simpleDecrypt(bookmark.title, key); const decryptedUrl = this.simpleDecrypt(bookmark.url, key); const decryptedNotes = bookmark.notes ? this.simpleDecrypt(bookmark.notes, key) : ''; return { ...bookmark, title: decryptedTitle, url: decryptedUrl, notes: decryptedNotes }; } catch (error) { console.error('Error decrypting bookmark:', error); return bookmark; } } // Simple encryption function (for demonstration - use proper crypto in production) simpleEncrypt(text, key) { let result = ''; for (let i = 0; i < text.length; i++) { result += String.fromCharCode(text.charCodeAt(i) ^ key.toString().charCodeAt(i % key.toString().length)); } return btoa(result); // Base64 encode } // Simple decryption function simpleDecrypt(encryptedText, key) { try { const decoded = atob(encryptedText); // Base64 decode let result = ''; for (let i = 0; i < decoded.length; i++) { result += String.fromCharCode(decoded.charCodeAt(i) ^ key.toString().charCodeAt(i % key.toString().length)); } return result; } catch (error) { console.error('Error decrypting text:', error); return encryptedText; } } // Toggle bookmark privacy status toggleBookmarkPrivacy(bookmarkId) { if (this.privateBookmarks.has(bookmarkId)) { this.privateBookmarks.delete(bookmarkId); this.logAccess('bookmark_privacy_disabled', { bookmarkId }); } else { this.privateBookmarks.add(bookmarkId); this.logAccess('bookmark_privacy_enabled', { bookmarkId }); } this.savePrivateBookmarks(); } // Check if bookmark is private isBookmarkPrivate(bookmarkId) { return this.privateBookmarks.has(bookmarkId); } // Toggle bookmark encryption status toggleBookmarkEncryption(bookmarkId) { if (this.encryptedCollections.has(bookmarkId)) { this.encryptedCollections.delete(bookmarkId); this.logAccess('bookmark_encryption_disabled', { bookmarkId }); } else { this.encryptedCollections.add(bookmarkId); this.logAccess('bookmark_encryption_enabled', { bookmarkId }); } this.saveEncryptedCollections(); this.saveBookmarksToStorage(); // Re-save bookmarks with new encryption status } // Check if bookmark is encrypted isBookmarkEncrypted(bookmarkId) { return this.encryptedCollections.has(bookmarkId); } // Save private bookmarks list savePrivateBookmarks() { try { localStorage.setItem('bookmarkManager_privateBookmarks', JSON.stringify([...this.privateBookmarks])); } catch (error) { console.error('Error saving private bookmarks:', error); } } // Load private bookmarks list loadPrivateBookmarks() { try { const privateBookmarksData = localStorage.getItem('bookmarkManager_privateBookmarks'); if (privateBookmarksData) { this.privateBookmarks = new Set(JSON.parse(privateBookmarksData)); } } catch (error) { console.error('Error loading private bookmarks:', error); } } // Save encrypted collections list saveEncryptedCollections() { try { localStorage.setItem('bookmarkManager_encryptedCollections', JSON.stringify([...this.encryptedCollections])); } catch (error) { console.error('Error saving encrypted collections:', error); } } // Load encrypted collections list loadEncryptedCollections() { try { const encryptedData = localStorage.getItem('bookmarkManager_encryptedCollections'); if (encryptedData) { this.encryptedCollections = new Set(JSON.parse(encryptedData)); } } catch (error) { console.error('Error loading encrypted collections:', error); } } // Filter bookmarks for export (exclude private ones if privacy mode is on) getExportableBookmarks(bookmarks) { if (!this.securitySettings.privacyMode) { return bookmarks; } return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id)); } // Generate secure sharing link with password protection generateSecureShareLink(bookmarkIds, password) { try { const bookmarksToShare = this.bookmarks.filter(b => bookmarkIds.includes(b.id)); const shareData = { bookmarks: bookmarksToShare, timestamp: Date.now(), expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours shareId: 'share_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9) }; if (password) { shareData.passwordHash = this.hashPassword(password); shareData.encrypted = true; } // In a real application, this would be sent to a server // For demo purposes, we'll create a data URL const shareDataString = JSON.stringify(shareData); const encodedData = btoa(shareDataString); const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodedData}`; this.logAccess('secure_share_created', { shareId: shareData.shareId, bookmarkCount: bookmarksToShare.length, passwordProtected: !!password }); return shareUrl; } catch (error) { console.error('Error generating secure share link:', error); return null; } } // Show security settings modal showSecuritySettingsModal() { this.showModal('securitySettingsModal'); this.populateSecuritySettingsForm(); } // Populate security settings form populateSecuritySettingsForm() { document.getElementById('encryptionEnabled').checked = this.securitySettings.encryptionEnabled; document.getElementById('privacyMode').checked = this.securitySettings.privacyMode; document.getElementById('accessLogging').checked = this.securitySettings.accessLogging; document.getElementById('passwordProtection').checked = this.securitySettings.passwordProtection; document.getElementById('sessionTimeout').value = this.securitySettings.sessionTimeout / 60000; document.getElementById('maxLoginAttempts').value = this.securitySettings.maxLoginAttempts; document.getElementById('lockoutDuration').value = this.securitySettings.lockoutDuration / 60000; // Show/hide password setup based on current setting const passwordSetupGroup = document.getElementById('passwordSetupGroup'); if (passwordSetupGroup) { passwordSetupGroup.style.display = this.securitySettings.passwordProtection ? 'block' : 'none'; } } // Show security authentication modal showSecurityAuthModal() { this.showModal('securityAuthModal'); document.getElementById('securityPassword').focus(); } // Show security audit modal showSecurityAuditModal() { this.showModal('securityAuditModal'); this.populateSecurityAuditLog(); } // Populate security audit log populateSecurityAuditLog() { const auditLogContainer = document.getElementById('auditLogContainer'); if (!auditLogContainer) return; auditLogContainer.innerHTML = ''; // Sort log entries by timestamp (newest first) const sortedLog = [...this.accessLog].sort((a, b) => b.timestamp - a.timestamp); sortedLog.forEach(entry => { const logItem = document.createElement('div'); logItem.className = 'audit-log-item'; const date = new Date(entry.timestamp).toLocaleString(); const actionClass = this.getActionClass(entry.action); logItem.innerHTML = `
${entry.action} ${date}
${this.formatAuditDetails(entry.details)}
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
`; auditLogContainer.appendChild(logItem); }); if (sortedLog.length === 0) { auditLogContainer.innerHTML = '
No audit log entries found.
'; } } // Get CSS class for audit action getActionClass(action) { const actionClasses = { 'successful_login': 'success', 'failed_login_attempt': 'warning', 'account_locked': 'danger', 'session_timeout': 'warning', 'bookmark_visited': 'info', 'bookmark_privacy_enabled': 'info', 'bookmark_privacy_disabled': 'info', 'bookmark_encryption_enabled': 'success', 'bookmark_encryption_disabled': 'warning', 'security_settings_changed': 'warning', 'secure_share_created': 'info' }; return actionClasses[action] || 'default'; } // Format audit details for display formatAuditDetails(details) { if (!details || Object.keys(details).length === 0) { return 'No additional details'; } return Object.entries(details) .map(([key, value]) => `${key}: ${value}`) .join(' | '); } // Export security audit log exportSecurityAuditLog() { try { const auditData = { exportDate: new Date().toISOString(), totalEntries: this.accessLog.length, entries: this.accessLog }; const dataStr = JSON.stringify(auditData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `bookmark_manager_audit_log_${new Date().toISOString().split('T')[0]}.json`; link.click(); this.logAccess('audit_log_exported', { entryCount: this.accessLog.length }); } catch (error) { console.error('Error exporting audit log:', error); alert('Error exporting audit log. Please try again.'); } } // Clear security audit log clearSecurityAuditLog() { if (confirm('Are you sure you want to clear the security audit log? This action cannot be undone.')) { this.accessLog = []; localStorage.removeItem('bookmarkManager_accessLog'); this.populateSecurityAuditLog(); this.logAccess('audit_log_cleared', { timestamp: Date.now() }); alert('Security audit log cleared successfully.'); } } // Bind security-related events bindSecurityEvents() { // Security settings button const securityBtn = document.getElementById('securityBtn'); if (securityBtn) { securityBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); console.log('Security button clicked!'); alert('Security button clicked! Opening modal...'); try { // Simple modal opening - bypass potential method conflicts const modal = document.getElementById('securitySettingsModal'); if (modal) { modal.style.display = 'block'; modal.setAttribute('aria-hidden', 'false'); console.log('Security modal opened successfully'); } else { console.error('Security modal not found'); alert('Security settings modal not found in the page.'); } } catch (error) { console.error('Error opening security modal:', error); alert('Error opening security settings. Please check the console for details.'); } }); } else { console.error('Security button not found in DOM'); } } // Fetch favicon in background during link testing async fetchFaviconInBackground(url, title) { try { const parsedUrl = new URL(url); const domain = parsedUrl.hostname; // Find the bookmark to update const bookmark = this.bookmarks.find(b => b.url === url); if (!bookmark || (bookmark.icon && bookmark.icon.trim() !== '')) { // Skip if bookmark not found or already has favicon return; } console.log(`🔍 Fetching favicon for: ${title}`); // Try multiple favicon locations in order of preference const faviconUrls = [ `${parsedUrl.protocol}//${domain}/favicon.ico`, `${parsedUrl.protocol}//${domain}/favicon.png`, `${parsedUrl.protocol}//${domain}/apple-touch-icon.png`, `${parsedUrl.protocol}//${domain}/apple-touch-icon-precomposed.png`, `${parsedUrl.protocol}//${domain}/images/favicon.ico`, `${parsedUrl.protocol}//${domain}/assets/favicon.ico` ]; for (const faviconUrl of faviconUrls) { try { // Use a timeout for favicon requests to avoid hanging const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout // First try to fetch the actual image data const response = await fetch(faviconUrl, { method: 'GET', cache: 'no-cache', signal: controller.signal }); clearTimeout(timeoutId); if (response.ok && response.type !== 'opaque') { // We can access the response body - convert to data URL try { const blob = await response.blob(); const dataUrl = await this.blobToDataUrl(blob); console.log(`✅ Found and converted favicon for ${title}: ${faviconUrl}`); // Update the bookmark with the favicon data URL bookmark.icon = dataUrl; bookmark.lastModified = Date.now(); this.saveBookmarksToStorage(); this.updateBookmarkFaviconInUI(bookmark.id, dataUrl); return; // Success, stop trying other URLs } catch (blobError) { console.log(`❌ Could not convert favicon to data URL: ${faviconUrl}`); // Fall through to use URL directly } } // If we can't get the image data (CORS blocked or opaque response), // fall back to using the favicon URL directly if (response.ok || response.type === 'opaque') { console.log(`✅ Found favicon (using URL) for ${title}: ${faviconUrl}`); // Update the bookmark with the favicon URL bookmark.icon = faviconUrl; bookmark.lastModified = Date.now(); this.saveBookmarksToStorage(); this.updateBookmarkFaviconInUI(bookmark.id, faviconUrl); return; // Success, stop trying other URLs } } catch (error) { // This favicon URL didn't work, try the next one console.log(`❌ Favicon not found at: ${faviconUrl}`); continue; } } console.log(`🚫 No favicon found for: ${title}`); } catch (error) { console.error(`Error fetching favicon for ${title}:`, error); } } // Helper function to convert blob to data URL blobToDataUrl(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } // Update a specific bookmark's favicon in the UI without full re-render updateBookmarkFaviconInUI(bookmarkId, faviconUrl) { try { // Find all favicon elements for this bookmark const bookmarkElements = document.querySelectorAll(`[data-bookmark-id="${bookmarkId}"]`); bookmarkElements.forEach(element => { const faviconImg = element.querySelector('.bookmark-favicon'); if (faviconImg) { faviconImg.src = faviconUrl; console.log(`🔄 Updated favicon in UI for bookmark: ${bookmarkId}`); } }); } catch (error) { console.error('Error updating favicon in UI:', error); } } // Save private bookmarks list savePrivateBookmarks() { try { localStorage.setItem('bookmarkManager_privateBookmarks', JSON.stringify([...this.privateBookmarks])); } catch (error) { console.error('Error saving private bookmarks:', error); } } // Load private bookmarks list loadPrivateBookmarks() { try { const privateBookmarksData = localStorage.getItem('bookmarkManager_privateBookmarks'); if (privateBookmarksData) { this.privateBookmarks = new Set(JSON.parse(privateBookmarksData)); } } catch (error) { console.error('Error loading private bookmarks:', error); } } // Save encrypted collections list saveEncryptedCollections() { try { localStorage.setItem('bookmarkManager_encryptedCollections', JSON.stringify([...this.encryptedCollections])); } catch (error) { console.error('Error saving encrypted collections:', error); } } // Load encrypted collections list loadEncryptedCollections() { try { const encryptedData = localStorage.getItem('bookmarkManager_encryptedCollections'); if (encryptedData) { this.encryptedCollections = new Set(JSON.parse(encryptedData)); } } catch (error) { console.error('Error loading encrypted collections:', error); } } // Filter bookmarks for export (exclude private ones if privacy mode is on) getExportableBookmarks(bookmarks) { if (!this.securitySettings.privacyMode) { return bookmarks; } return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id)); } // Generate secure sharing link with password protection generateSecureShareLink(bookmarkIds, password) { try { const bookmarksToShare = this.bookmarks.filter(b => bookmarkIds.includes(b.id)); const shareData = { bookmarks: bookmarksToShare, timestamp: Date.now(), expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours shareId: 'share_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9) }; if (password) { shareData.passwordHash = this.hashPassword(password); shareData.encrypted = true; } // In a real application, this would be sent to a server // For demo purposes, we'll create a data URL const shareDataString = JSON.stringify(shareData); const encodedData = btoa(shareDataString); const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodedData}`; this.logAccess('secure_share_created', { shareId: shareData.shareId, bookmarkCount: bookmarksToShare.length, passwordProtected: !!password }); return shareUrl; } catch (error) { console.error('Error generating secure share link:', error); return null; } } // Show security settings modal showSecuritySettingsModal() { this.showModal('securitySettingsModal'); this.populateSecuritySettingsForm(); } // Populate security settings form populateSecuritySettingsForm() { document.getElementById('encryptionEnabled').checked = this.securitySettings.encryptionEnabled; document.getElementById('privacyMode').checked = this.securitySettings.privacyMode; document.getElementById('accessLogging').checked = this.securitySettings.accessLogging; document.getElementById('passwordProtection').checked = this.securitySettings.passwordProtection; document.getElementById('sessionTimeout').value = this.securitySettings.sessionTimeout / 60000; document.getElementById('maxLoginAttempts').value = this.securitySettings.maxLoginAttempts; document.getElementById('lockoutDuration').value = this.securitySettings.lockoutDuration / 60000; // Show/hide password setup based on current setting const passwordSetupGroup = document.getElementById('passwordSetupGroup'); if (passwordSetupGroup) { passwordSetupGroup.style.display = this.securitySettings.passwordProtection ? 'block' : 'none'; } } // Show security authentication modal showSecurityAuthModal() { this.showModal('securityAuthModal'); document.getElementById('securityPassword').focus(); } // Show security audit modal showSecurityAuditModal() { this.showModal('securityAuditModal'); this.populateSecurityAuditLog(); } // Populate security audit log populateSecurityAuditLog() { const auditLogContainer = document.getElementById('auditLogContainer'); if (!auditLogContainer) return; auditLogContainer.innerHTML = ''; // Sort log entries by timestamp (newest first) const sortedLog = [...this.accessLog].sort((a, b) => b.timestamp - a.timestamp); sortedLog.forEach(entry => { const logItem = document.createElement('div'); logItem.className = 'audit-log-item'; const date = new Date(entry.timestamp).toLocaleString(); const actionClass = this.getActionClass(entry.action); logItem.innerHTML = `
${entry.action} ${date}
${this.formatAuditDetails(entry.details)}
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
`; auditLogContainer.appendChild(logItem); }); if (sortedLog.length === 0) { auditLogContainer.innerHTML = '
No audit log entries found.
'; } } // Get CSS class for audit action getActionClass(action) { const actionClasses = { 'successful_login': 'success', 'failed_login_attempt': 'warning', 'account_locked': 'danger', 'session_timeout': 'warning', 'bookmark_visited': 'info', 'bookmark_privacy_enabled': 'info', 'bookmark_privacy_disabled': 'info', 'bookmark_encryption_enabled': 'success', 'bookmark_encryption_disabled': 'warning', 'security_settings_changed': 'warning', 'secure_share_created': 'info' }; return actionClasses[action] || 'default'; } // Format audit details for display formatAuditDetails(details) { if (!details || Object.keys(details).length === 0) { return 'No additional details'; } return Object.entries(details) .map(([key, value]) => `${key}: ${value}`) .join(' | '); } // Export security audit log exportSecurityAuditLog() { try { const auditData = { exportDate: new Date().toISOString(), totalEntries: this.accessLog.length, entries: this.accessLog }; const dataStr = JSON.stringify(auditData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `bookmark_manager_audit_log_${new Date().toISOString().split('T')[0]}.json`; link.click(); this.logAccess('audit_log_exported', { entryCount: this.accessLog.length }); } catch (error) { console.error('Error exporting audit log:', error); alert('Error exporting audit log. Please try again.'); } } // Clear security audit log clearSecurityAuditLog() { if (confirm('Are you sure you want to clear the security audit log? This action cannot be undone.')) { this.accessLog = []; localStorage.removeItem('bookmarkManager_accessLog'); this.populateSecurityAuditLog(); this.logAccess('audit_log_cleared', { timestamp: Date.now() }); alert('Security audit log cleared successfully.'); } } // Save private bookmarks list savePrivateBookmarks() { try { localStorage.setItem('bookmarkManager_privateBookmarks', JSON.stringify([...this.privateBookmarks])); } catch (error) { console.error('Error saving private bookmarks:', error); } } // Load private bookmarks list loadPrivateBookmarks() { try { const privateBookmarksData = localStorage.getItem('bookmarkManager_privateBookmarks'); if (privateBookmarksData) { this.privateBookmarks = new Set(JSON.parse(privateBookmarksData)); } } catch (error) { console.error('Error loading private bookmarks:', error); } } // Save encrypted collections list saveEncryptedCollections() { try { localStorage.setItem('bookmarkManager_encryptedCollections', JSON.stringify([...this.encryptedCollections])); } catch (error) { console.error('Error saving encrypted collections:', error); } } // Load encrypted collections list loadEncryptedCollections() { try { const encryptedData = localStorage.getItem('bookmarkManager_encryptedCollections'); if (encryptedData) { this.encryptedCollections = new Set(JSON.parse(encryptedData)); } } catch (error) { console.error('Error loading encrypted collections:', error); } } // Filter bookmarks for export (exclude private ones if privacy mode is on) getExportableBookmarks(bookmarks) { if (!this.securitySettings.privacyMode) { return bookmarks; } return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id)); } // Generate secure sharing link with password protection generateSecureShareLink(bookmarkIds, password) { try { const bookmarksToShare = this.bookmarks.filter(b => bookmarkIds.includes(b.id)); const shareData = { bookmarks: bookmarksToShare, timestamp: Date.now(), expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours shareId: 'share_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9) }; if (password) { shareData.passwordHash = this.hashPassword(password); shareData.encrypted = true; } // In a real application, this would be sent to a server // For demo purposes, we'll create a data URL const shareDataString = JSON.stringify(shareData); const encodedData = btoa(shareDataString); const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodedData}`; this.logAccess('secure_share_created', { shareId: shareData.shareId, bookmarkCount: bookmarksToShare.length, passwordProtected: !!password }); return shareUrl; } catch (error) { console.error('Error generating secure share link:', error); return null; } } // Show security settings modal showSecuritySettingsModal() { this.showModal('securitySettingsModal'); this.populateSecuritySettingsForm(); } // Populate security settings form populateSecuritySettingsForm() { document.getElementById('encryptionEnabled').checked = this.securitySettings.encryptionEnabled; document.getElementById('privacyMode').checked = this.securitySettings.privacyMode; document.getElementById('accessLogging').checked = this.securitySettings.accessLogging; document.getElementById('passwordProtection').checked = this.securitySettings.passwordProtection; document.getElementById('sessionTimeout').value = this.securitySettings.sessionTimeout / 60000; document.getElementById('maxLoginAttempts').value = this.securitySettings.maxLoginAttempts; document.getElementById('lockoutDuration').value = this.securitySettings.lockoutDuration / 60000; // Show/hide password setup based on current setting const passwordSetupGroup = document.getElementById('passwordSetupGroup'); if (passwordSetupGroup) { passwordSetupGroup.style.display = this.securitySettings.passwordProtection ? 'block' : 'none'; } } // Show security authentication modal showSecurityAuthModal() { this.showModal('securityAuthModal'); document.getElementById('securityPassword').focus(); } // Show security audit modal showSecurityAuditModal() { this.showModal('securityAuditModal'); this.populateSecurityAuditLog(); } // Populate security audit log populateSecurityAuditLog() { const auditLogContainer = document.getElementById('auditLogContainer'); if (!auditLogContainer) return; auditLogContainer.innerHTML = ''; // Sort log entries by timestamp (newest first) const sortedLog = [...this.accessLog].sort((a, b) => b.timestamp - a.timestamp); sortedLog.forEach(entry => { const logItem = document.createElement('div'); logItem.className = 'audit-log-item'; const date = new Date(entry.timestamp).toLocaleString(); const actionClass = this.getActionClass(entry.action); logItem.innerHTML = `
${entry.action} ${date}
${this.formatAuditDetails(entry.details)}
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
`; auditLogContainer.appendChild(logItem); }); if (sortedLog.length === 0) { auditLogContainer.innerHTML = '
No audit log entries found.
'; } } // Get CSS class for audit action getActionClass(action) { const actionClasses = { 'successful_login': 'success', 'failed_login_attempt': 'warning', 'account_locked': 'danger', 'session_timeout': 'warning', 'bookmark_visited': 'info', 'bookmark_privacy_enabled': 'info', 'bookmark_privacy_disabled': 'info', 'bookmark_encryption_enabled': 'success', 'bookmark_encryption_disabled': 'warning', 'security_settings_changed': 'warning', 'secure_share_created': 'info' }; return actionClasses[action] || 'default'; } // Format audit details for display formatAuditDetails(details) { if (!details || Object.keys(details).length === 0) { return 'No additional details'; } return Object.entries(details) .map(([key, value]) => `${key}: ${value}`) .join(' | '); } // Export security audit log exportSecurityAuditLog() { try { const auditData = { exportDate: new Date().toISOString(), totalEntries: this.accessLog.length, entries: this.accessLog }; const dataStr = JSON.stringify(auditData, null, 2); const dataBlob = new Blob([dataStr], { type: 'application/json' }); const link = document.createElement('a'); link.href = URL.createObjectURL(dataBlob); link.download = `bookmark_manager_audit_log_${new Date().toISOString().split('T')[0]}.json`; link.click(); this.logAccess('audit_log_exported', { entryCount: this.accessLog.length }); } catch (error) { console.error('Error exporting audit log:', error); alert('Error exporting audit log. Please try again.'); } } // Clear security audit log clearSecurityAuditLog() { if (confirm('Are you sure you want to clear the security audit log? This action cannot be undone.')) { this.accessLog = []; localStorage.removeItem('bookmarkManager_accessLog'); this.populateSecurityAuditLog(); this.logAccess('audit_log_cleared', { timestamp: Date.now() }); alert('Security audit log cleared successfully.'); } } // Move bookmark to a different folder moveBookmarkToFolder(bookmarkId, targetFolder) { const bookmark = this.bookmarks.find(b => b.id == bookmarkId); if (bookmark && bookmark.folder !== targetFolder) { const oldFolder = bookmark.folder || 'Uncategorized'; bookmark.folder = targetFolder; this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); // Show confirmation const targetFolderName = targetFolder || 'Uncategorized'; console.log(`Moved "${bookmark.title}" from "${oldFolder}" to "${targetFolderName}"`); } } // Toggle bulk selection mode toggleBulkMode() { this.bulkMode = !this.bulkMode; this.bulkSelection.clear(); const bulkModeBtn = document.getElementById('bulkModeBtn'); if (bulkModeBtn) { bulkModeBtn.textContent = this.bulkMode ? 'Exit Bulk Mode' : 'Bulk Select'; bulkModeBtn.classList.toggle('active', this.bulkMode); } this.renderBookmarks(this.getFilteredBookmarks()); } // Toggle bookmark selection in bulk mode toggleBookmarkSelection(bookmarkId) { if (this.bulkSelection.has(bookmarkId)) { this.bulkSelection.delete(bookmarkId); } else { this.bulkSelection.add(bookmarkId); } this.updateBulkSelectionUI(); } // Update bulk selection UI updateBulkSelectionUI() { const selectedCount = this.bulkSelection.size; const bulkActionsDiv = document.getElementById('bulkActions'); if (bulkActionsDiv) { const countSpan = bulkActionsDiv.querySelector('.selection-count'); if (countSpan) { countSpan.textContent = `${selectedCount} selected`; } // Enable/disable bulk action buttons const bulkButtons = bulkActionsDiv.querySelectorAll('button:not(.selection-count)'); bulkButtons.forEach(btn => { btn.disabled = selectedCount === 0; }); } } // Bulk delete selected bookmarks bulkDeleteBookmarks() { if (this.bulkSelection.size === 0) return; const count = this.bulkSelection.size; if (confirm(`Are you sure you want to delete ${count} selected bookmark${count > 1 ? 's' : ''}?`)) { this.bookmarks = this.bookmarks.filter(b => !this.bulkSelection.has(b.id)); this.bulkSelection.clear(); this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.updateBulkSelectionUI(); } } // Bulk move selected bookmarks to folder bulkMoveToFolder(targetFolder) { if (this.bulkSelection.size === 0) return; let movedCount = 0; this.bookmarks.forEach(bookmark => { if (this.bulkSelection.has(bookmark.id)) { bookmark.folder = targetFolder; movedCount++; } }); this.bulkSelection.clear(); this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.updateBulkSelectionUI(); const folderName = targetFolder || 'Uncategorized'; alert(`Moved ${movedCount} bookmark${movedCount > 1 ? 's' : ''} to "${folderName}"`); } // Sort bookmarks by different criteria sortBookmarks(criteria, order = 'asc') { const sortedBookmarks = [...this.bookmarks]; sortedBookmarks.sort((a, b) => { let valueA, valueB; switch (criteria) { case 'title': valueA = a.title.toLowerCase(); valueB = b.title.toLowerCase(); break; case 'url': valueA = a.url.toLowerCase(); valueB = b.url.toLowerCase(); break; case 'folder': valueA = (a.folder || '').toLowerCase(); valueB = (b.folder || '').toLowerCase(); break; case 'date': valueA = a.addDate; valueB = b.addDate; break; case 'status': const statusOrder = { 'valid': 1, 'unknown': 2, 'invalid': 3, 'duplicate': 4, 'testing': 5 }; valueA = statusOrder[a.status] || 6; valueB = statusOrder[b.status] || 6; break; default: return 0; } if (valueA < valueB) return order === 'asc' ? -1 : 1; if (valueA > valueB) return order === 'asc' ? 1 : -1; return 0; }); this.bookmarks = sortedBookmarks; this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); } // Show folder management modal showFolderManagementModal() { this.showModal('folderManagementModal'); this.populateFolderManagementList(); } // Populate folder management list populateFolderManagementList() { const folderList = document.getElementById('folderManagementList'); if (!folderList) return; const folders = this.getFolderStats(); folderList.innerHTML = ''; Object.entries(folders).forEach(([folderName, stats]) => { const folderItem = document.createElement('div'); folderItem.className = 'folder-management-item'; folderItem.innerHTML = `
${this.escapeHtml(folderName || 'Uncategorized')}
${stats.total} bookmarks (${stats.valid} valid, ${stats.invalid} invalid)
`; folderList.appendChild(folderItem); }); } // Get folder statistics getFolderStats() { const stats = {}; this.bookmarks.forEach(bookmark => { const folder = bookmark.folder || ''; if (!stats[folder]) { stats[folder] = { total: 0, valid: 0, invalid: 0, duplicate: 0, unknown: 0 }; } stats[folder].total++; stats[folder][bookmark.status]++; }); return stats; } // Rename folder renameFolderPrompt(oldFolderName) { const newFolderName = prompt(`Rename folder "${oldFolderName}" to:`, oldFolderName); if (newFolderName !== null && newFolderName.trim() !== oldFolderName) { this.renameFolder(oldFolderName, newFolderName.trim()); } } renameFolder(oldName, newName) { let renamedCount = 0; this.bookmarks.forEach(bookmark => { if (bookmark.folder === oldName) { bookmark.folder = newName; renamedCount++; } }); if (renamedCount > 0) { this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.populateFolderManagementList(); alert(`Renamed folder "${oldName}" to "${newName}" (${renamedCount} bookmarks affected)`); } } // Merge folder mergeFolderPrompt(sourceFolderName) { const folders = Object.keys(this.getFolderStats()).filter(f => f !== sourceFolderName); if (folders.length === 0) { alert('No other folders available to merge with.'); return; } const targetFolder = prompt( `Merge folder "${sourceFolderName}" into which folder?\n\nAvailable folders:\n${folders.join('\n')}\n\nEnter folder name:` ); if (targetFolder !== null && folders.includes(targetFolder)) { this.mergeFolder(sourceFolderName, targetFolder); } } mergeFolder(sourceFolder, targetFolder) { let mergedCount = 0; this.bookmarks.forEach(bookmark => { if (bookmark.folder === sourceFolder) { bookmark.folder = targetFolder; mergedCount++; } }); if (mergedCount > 0) { this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.populateFolderManagementList(); alert(`Merged ${mergedCount} bookmarks from "${sourceFolder}" into "${targetFolder}"`); } } // Delete folder (moves bookmarks to uncategorized) deleteFolderPrompt(folderName) { const stats = this.getFolderStats()[folderName]; if (confirm(`Delete folder "${folderName}"?\n\n${stats.total} bookmarks will be moved to "Uncategorized".`)) { this.deleteFolder(folderName); } } deleteFolder(folderName) { let movedCount = 0; this.bookmarks.forEach(bookmark => { if (bookmark.folder === folderName) { bookmark.folder = ''; movedCount++; } }); if (movedCount > 0) { this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.populateFolderManagementList(); alert(`Deleted folder "${folderName}" and moved ${movedCount} bookmarks to "Uncategorized"`); } } // Bind organization feature events bindOrganizationEvents() { // Bulk mode toggle document.getElementById('bulkModeBtn').addEventListener('click', () => { this.toggleBulkMode(); }); // Sort button document.getElementById('sortBtn').addEventListener('click', () => { this.showModal('sortModal'); }); // Folder management button document.getElementById('folderManagementBtn').addEventListener('click', () => { this.showFolderManagementModal(); }); // Sort form document.getElementById('sortForm').addEventListener('submit', (e) => { e.preventDefault(); const criteria = document.getElementById('sortCriteria').value; const order = document.getElementById('sortOrder').value; this.sortBookmarks(criteria, order); this.hideModal('sortModal'); }); document.getElementById('cancelSortBtn').addEventListener('click', () => { this.hideModal('sortModal'); }); // Folder management form document.getElementById('createFolderForm').addEventListener('submit', (e) => { e.preventDefault(); const folderName = document.getElementById('newFolderName').value.trim(); if (folderName) { this.createFolder(folderName); document.getElementById('newFolderName').value = ''; } }); document.getElementById('cancelFolderManagementBtn').addEventListener('click', () => { this.hideModal('folderManagementModal'); }); // Bulk actions document.getElementById('bulkMoveBtn').addEventListener('click', () => { const targetFolder = document.getElementById('bulkMoveFolder').value; this.bulkMoveToFolder(targetFolder); }); document.getElementById('bulkDeleteBtn').addEventListener('click', () => { this.bulkDeleteBookmarks(); }); document.getElementById('bulkSelectAllBtn').addEventListener('click', () => { this.selectAllVisibleBookmarks(); }); document.getElementById('bulkClearSelectionBtn').addEventListener('click', () => { this.clearBulkSelection(); }); } // Create a new folder createFolder(folderName) { // Check if folder already exists const existingFolders = Object.keys(this.getFolderStats()); if (existingFolders.includes(folderName)) { alert(`Folder "${folderName}" already exists.`); return; } // Create a placeholder bookmark in the new folder (will be removed when user adds real bookmarks) const placeholderBookmark = { id: Date.now() + Math.random(), title: `New folder: ${folderName}`, url: 'about:blank', folder: folderName, addDate: Date.now(), icon: '', status: 'unknown', isPlaceholder: true }; this.bookmarks.push(placeholderBookmark); this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); this.populateFolderManagementList(); alert(`Created folder "${folderName}"`); } // Select all visible bookmarks in bulk mode selectAllVisibleBookmarks() { if (!this.bulkMode) return; const filteredBookmarks = this.getFilteredBookmarks(); filteredBookmarks.forEach(bookmark => { this.bulkSelection.add(bookmark.id); }); this.updateBulkSelectionUI(); this.renderBookmarks(filteredBookmarks); } // Clear bulk selection clearBulkSelection() { this.bulkSelection.clear(); this.updateBulkSelectionUI(); this.renderBookmarks(this.getFilteredBookmarks()); } // Update bulk folder select dropdown updateBulkFolderSelect() { const bulkMoveFolder = document.getElementById('bulkMoveFolder'); if (!bulkMoveFolder) return; const folders = Object.keys(this.getFolderStats()).sort(); bulkMoveFolder.innerHTML = ''; bulkMoveFolder.innerHTML += ''; folders.forEach(folder => { if (folder) { const option = document.createElement('option'); option.value = folder; option.textContent = folder; bulkMoveFolder.appendChild(option); } }); } // Add keyboard support for all buttons addKeyboardSupportToButtons() { const buttons = document.querySelectorAll('button:not(.stats-filter)'); buttons.forEach(button => { button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); button.click(); } }); }); } // Handle global keyboard events handleGlobalKeydown(e) { // Escape key to close modals if (e.key === 'Escape') { const openModal = document.querySelector('.modal[style*="block"]'); if (openModal) { this.hideModal(openModal.id); e.preventDefault(); } } // Ctrl/Cmd + I for import if ((e.ctrlKey || e.metaKey) && e.key === 'i') { e.preventDefault(); this.showModal('importModal'); } // Ctrl/Cmd + E for export if ((e.ctrlKey || e.metaKey) && e.key === 'e') { e.preventDefault(); this.showExportModal(); } // Ctrl/Cmd + N for new bookmark if ((e.ctrlKey || e.metaKey) && e.key === 'n') { e.preventDefault(); this.showBookmarkModal(); } // Focus search with Ctrl/Cmd + F if ((e.ctrlKey || e.metaKey) && e.key === 'f') { e.preventDefault(); document.getElementById('searchInput').focus(); } } // Show modal with proper accessibility showModal(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.style.display = 'block'; modal.setAttribute('aria-hidden', 'false'); // Handle focus management for different modal types if (modalId === 'contextModal') { // For context modal, make close button non-focusable and focus Visit button const closeBtn = modal.querySelector('.close'); if (closeBtn) { closeBtn.setAttribute('tabindex', '-1'); } // Focus on the "Visit" button instead setTimeout(() => { const visitBtn = modal.querySelector('#visitBookmarkBtn'); if (visitBtn) { visitBtn.focus(); } else { // Fallback to first action button const actionButtons = modal.querySelectorAll('.modal-actions button'); if (actionButtons.length > 0) { actionButtons[0].focus(); } } }, 10); } else { // For other modals, focus the first useful element (not close button) const focusableElements = modal.querySelectorAll( 'input, select, textarea, button:not(.close), [href], [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length > 0) { focusableElements[0].focus(); } } // Store the previously focused element to restore later modal.dataset.previousFocus = document.activeElement.id || ''; } } // Hide modal with proper accessibility hideModal(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); // Restore focus to the previously focused element const previousFocusId = modal.dataset.previousFocus; if (previousFocusId) { const previousElement = document.getElementById(previousFocusId); if (previousElement) { previousElement.focus(); } } } } // Bind star rating events bindStarRatingEvents() { const stars = document.querySelectorAll('.star'); const ratingInput = document.getElementById('bookmarkRating'); stars.forEach((star, index) => { // Mouse events star.addEventListener('mouseenter', () => { this.highlightStars(index + 1); }); star.addEventListener('mouseleave', () => { this.highlightStars(parseInt(ratingInput.value)); }); star.addEventListener('click', () => { const rating = index + 1; ratingInput.value = rating; this.updateStarRating(rating); }); // Keyboard events star.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const rating = index + 1; ratingInput.value = rating; this.updateStarRating(rating); } }); }); } // Highlight stars up to the given rating highlightStars(rating) { const stars = document.querySelectorAll('.star'); stars.forEach((star, index) => { if (index < rating) { star.classList.add('active'); } else { star.classList.remove('active'); } }); } // Update star rating display updateStarRating(rating) { this.highlightStars(rating); document.getElementById('bookmarkRating').value = rating; } // Track bookmark visit trackBookmarkVisit(bookmarkId) { const bookmark = this.bookmarks.find(b => b.id == bookmarkId); if (bookmark) { bookmark.lastVisited = Date.now(); this.saveBookmarksToStorage(); } } parseNetscapeBookmarks(htmlContent) { const parser = new DOMParser(); const doc = parser.parseFromString(htmlContent, 'text/html'); const bookmarks = []; console.log('Starting bookmark parsing...'); console.log('HTML content length:', htmlContent.length); // First, build a map of all folders and their paths const folderMap = new Map(); // Find all H3 elements (folder headers) const allH3s = Array.from(doc.querySelectorAll('H3')); console.log(`Found ${allH3s.length} folder headers (H3 elements)`); // For each H3, determine its folder path by looking at parent H3s allH3s.forEach(h3 => { const folderName = h3.textContent.trim(); if (!folderName) return; // Find the parent folders by traversing up the DOM const parentFolders = []; let current = h3.parentElement; while (current) { // Look for H3 elements in parent DTs if (current.tagName === 'DT') { const parentH3 = current.querySelector('H3'); if (parentH3 && parentH3 !== h3) { parentFolders.unshift(parentH3.textContent.trim()); } } current = current.parentElement; } // Store the full path for this folder, but skip "Bookmarks Toolbar" let fullPath = [...parentFolders, folderName]; // Remove "Bookmarks Toolbar" from the path if it exists fullPath = fullPath.filter(folder => folder.toLowerCase() !== 'bookmarks toolbar' && folder.toLowerCase() !== 'bookmarks bar' ); folderMap.set(h3, fullPath); console.log(`Folder: "${folderName}" → Full path: "${fullPath.join(' / ') || '(root)'}"`); }); // Now find all A elements (bookmarks) const allLinks = Array.from(doc.querySelectorAll('A[HREF], A[href]')); console.log(`Found ${allLinks.length} bookmark links`); // Process each bookmark allLinks.forEach((link, index) => { const url = link.getAttribute('HREF') || link.getAttribute('href') || link.href; const title = link.textContent.trim() || link.innerText?.trim() || url || 'Untitled'; if (!url || url.trim() === '' || url === 'undefined') { return; } // Find the folder for this bookmark let folderPath = []; // Start from the link and go up the DOM tree let current = link.parentElement; let closestH3 = null; // First, try to find the direct parent folder while (current && !closestH3) { if (current.tagName === 'DT') { // Check if this DT or any of its ancestors contains an H3 let dt = current; while (dt && !closestH3) { const h3 = dt.querySelector('H3'); if (h3) { closestH3 = h3; break; } dt = dt.parentElement; } } // If we found an H3, get its folder path from our map if (closestH3 && folderMap.has(closestH3)) { folderPath = folderMap.get(closestH3); break; } current = current.parentElement; } // If we still don't have a folder, try to find the closest H3 in document order if (folderPath.length === 0) { // Get all elements in document order const allElements = Array.from(doc.querySelectorAll('*')); const linkIndex = allElements.indexOf(link); // Find the closest H3 that comes before this link let closestDistance = Infinity; allH3s.forEach(h3 => { const h3Index = allElements.indexOf(h3); if (h3Index < linkIndex) { const distance = linkIndex - h3Index; if (distance < closestDistance) { closestDistance = distance; closestH3 = h3; } } }); if (closestH3 && folderMap.has(closestH3)) { folderPath = folderMap.get(closestH3); } } const fullFolderPath = folderPath.join(' / '); const bookmark = { id: Date.now() + Math.random() + bookmarks.length + index, title: title, url: url, folder: fullFolderPath, addDate: link.getAttribute('ADD_DATE') || link.getAttribute('add_date') ? parseInt(link.getAttribute('ADD_DATE') || link.getAttribute('add_date')) * 1000 : Date.now(), lastModified: link.getAttribute('LAST_MODIFIED') || link.getAttribute('last_modified') ? parseInt(link.getAttribute('LAST_MODIFIED') || link.getAttribute('last_modified')) * 1000 : null, icon: link.getAttribute('ICON') || link.getAttribute('icon') || '', status: 'unknown' }; bookmarks.push(bookmark); if (index < 10 || index % 1000 === 0) { console.log(`Bookmark ${index + 1}: "${title.substring(0, 30)}" → folder: "${fullFolderPath || 'Uncategorized'}"`); } }); console.log(`Successfully parsed ${bookmarks.length} bookmarks`); // Log detailed folder statistics const folderStats = {}; bookmarks.forEach(b => { const folder = b.folder || 'Uncategorized'; folderStats[folder] = (folderStats[folder] || 0) + 1; }); console.log('Folder distribution:'); Object.entries(folderStats) .sort((a, b) => b[1] - a[1]) .slice(0, 20) .forEach(([folder, count]) => { console.log(` "${folder}": ${count} bookmarks`); }); return bookmarks; } // Bind advanced import/export events bindAdvancedImportEvents() { // Import tab switching document.querySelectorAll('.import-tab').forEach(tab => { tab.addEventListener('click', (e) => { const tabName = e.target.getAttribute('data-tab'); this.switchImportTab(tabName); }); }); // Import mode change handler document.getElementById('importMode').addEventListener('change', (e) => { const mode = e.target.value; const duplicateHandling = document.getElementById('duplicateHandling'); if (mode === 'merge' || mode === 'incremental') { duplicateHandling.style.display = 'block'; } else { duplicateHandling.style.display = 'none'; } }); // Sync method change handler document.getElementById('syncMethod').addEventListener('change', (e) => { this.switchSyncMethod(e.target.value); }); // Preview tab switching document.querySelectorAll('.preview-tab').forEach(tab => { tab.addEventListener('click', (e) => { const tabName = e.target.getAttribute('data-tab'); this.switchPreviewTab(tabName); }); }); // Import preview modal events document.getElementById('confirmImportBtn').addEventListener('click', () => { this.confirmImport(); }); document.getElementById('modifyImportBtn').addEventListener('click', () => { this.hideModal('importPreviewModal'); this.showModal('importModal'); }); document.getElementById('cancelPreviewBtn').addEventListener('click', () => { this.hideModal('importPreviewModal'); }); // Cloud sync events document.getElementById('connectCloudBtn').addEventListener('click', () => { this.connectToCloud(); }); // QR sync events document.getElementById('generateQRBtn').addEventListener('click', () => { this.generateQRCode(); }); document.getElementById('scanQRBtn').addEventListener('click', () => { this.scanQRCode(); }); // Local sync events document.getElementById('startLocalServerBtn').addEventListener('click', () => { this.startLocalServer(); }); document.getElementById('connectToDeviceBtn').addEventListener('click', () => { this.connectToDevice(); }); } // Switch import tab switchImportTab(tabName) { // Update tab buttons document.querySelectorAll('.import-tab').forEach(tab => { tab.classList.remove('active'); }); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); // Update tab content document.querySelectorAll('.import-tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabName}ImportTab`).classList.add('active'); } // Switch sync method switchSyncMethod(method) { // Hide all sync options document.querySelectorAll('.sync-options').forEach(option => { option.style.display = 'none'; }); // Show selected sync option const optionId = method + 'SyncOptions'; const optionElement = document.getElementById(optionId); if (optionElement) { optionElement.style.display = 'block'; } } // Switch preview tab switchPreviewTab(tabName) { // Update tab buttons document.querySelectorAll('.preview-tab').forEach(tab => { tab.classList.remove('active'); }); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); // Update tab content document.querySelectorAll('.preview-tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabName}Preview`).classList.add('active'); } // Preview import functionality async previewImport() { const fileInput = document.getElementById('fileInput'); const file = fileInput.files[0]; if (!file) { alert('Please select a file to import.'); return; } try { const importData = await this.parseImportFile(file); if (!importData || importData.bookmarks.length === 0) { alert('No bookmarks found in the selected file.'); return; } this.currentImportData = importData; this.showImportPreview(importData); } catch (error) { console.error('Import preview error:', error); alert('Error reading import file: ' + error.message); } } // Parse import file based on format async parseImportFile(file) { const format = document.getElementById('importFormat').value; const content = await this.readFileContent(file); let bookmarks = []; let detectedFormat = format; try { if (format === 'auto') { detectedFormat = this.detectFileFormat(file, content); } switch (detectedFormat) { case 'netscape': bookmarks = this.parseNetscapeBookmarks(content); break; case 'chrome': bookmarks = this.parseChromeBookmarks(content); break; case 'firefox': bookmarks = this.parseFirefoxBookmarks(content); break; case 'safari': bookmarks = this.parseSafariBookmarks(content); break; case 'json': bookmarks = this.parseJSONBookmarks(content); break; default: throw new Error('Unsupported file format'); } return { bookmarks, format: detectedFormat, originalCount: bookmarks.length }; } catch (error) { throw new Error(`Failed to parse ${detectedFormat} format: ${error.message}`); } } // Read file content as text readFileContent(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => resolve(e.target.result); reader.onerror = () => reject(new Error('Failed to read file')); reader.readAsText(file); }); } // Detect file format automatically detectFileFormat(file, content) { const fileName = file.name.toLowerCase(); // Check file extension first if (fileName.endsWith('.json')) { // Try to parse as JSON and detect structure try { const data = JSON.parse(content); if (data.roots && data.roots.bookmark_bar) { return 'chrome'; } else if (Array.isArray(data) && data[0] && data[0].type) { return 'firefox'; } else if (data.bookmarks && Array.isArray(data.bookmarks)) { // Our own JSON export format return 'json'; } else if (Array.isArray(data) && data[0] && (data[0].title || data[0].url)) { // Simple JSON array of bookmarks return 'json'; } } catch (e) { // Not valid JSON } } else if (fileName.endsWith('.plist')) { return 'safari'; } else if (fileName.endsWith('.html') || fileName.endsWith('.htm')) { // Check for Netscape format markers if (content.includes('') || content.includes(' { if (!folder.children) return; folder.children.forEach(item => { if (item.type === 'url') { bookmarks.push({ id: Date.now() + Math.random() + bookmarks.length, title: item.name || 'Untitled', url: item.url, folder: parentPath, addDate: item.date_added ? parseInt(item.date_added) / 1000 : Date.now(), lastModified: item.date_modified ? parseInt(item.date_modified) / 1000 : null, icon: '', status: 'unknown' }); } else if (item.type === 'folder') { const folderPath = parentPath ? `${parentPath} / ${item.name}` : item.name; parseFolder(item, folderPath); } }); }; // Parse bookmark bar and other folders if (data.roots) { if (data.roots.bookmark_bar) { parseFolder(data.roots.bookmark_bar, ''); } if (data.roots.other) { parseFolder(data.roots.other, 'Other Bookmarks'); } if (data.roots.synced) { parseFolder(data.roots.synced, 'Mobile Bookmarks'); } } return bookmarks; } // Parse JSON bookmarks (our own export format) parseJSONBookmarks(content) { try { const data = JSON.parse(content); // Check if it's our export format if (data.bookmarks && Array.isArray(data.bookmarks)) { return data.bookmarks.map(bookmark => ({ id: bookmark.id || Date.now() + Math.random(), title: bookmark.title || 'Untitled', url: bookmark.url || '', folder: bookmark.folder || '', tags: bookmark.tags || [], notes: bookmark.notes || '', rating: bookmark.rating || 0, favorite: bookmark.favorite || false, addDate: bookmark.addDate || Date.now(), lastModified: bookmark.lastModified || null, lastVisited: bookmark.lastVisited || null, icon: bookmark.icon || '', status: 'unknown', // Reset status on import errorCategory: null, lastTested: null })); } // If it's just an array of bookmarks if (Array.isArray(data)) { return data.map(bookmark => ({ id: bookmark.id || Date.now() + Math.random(), title: bookmark.title || 'Untitled', url: bookmark.url || '', folder: bookmark.folder || '', tags: bookmark.tags || [], notes: bookmark.notes || '', rating: bookmark.rating || 0, favorite: bookmark.favorite || false, addDate: bookmark.addDate || Date.now(), lastModified: bookmark.lastModified || null, lastVisited: bookmark.lastVisited || null, icon: bookmark.icon || '', status: 'unknown', errorCategory: null, lastTested: null })); } throw new Error('Invalid JSON bookmark format'); } catch (error) { throw new Error(`Failed to parse JSON bookmarks: ${error.message}`); } } // Parse Firefox bookmarks JSON parseFirefoxBookmarks(content) { const data = JSON.parse(content); const bookmarks = []; const parseItem = (item, parentPath = '') => { if (item.type === 'text/x-moz-place') { if (item.uri) { bookmarks.push({ id: Date.now() + Math.random() + bookmarks.length, title: item.title || 'Untitled', url: item.uri, folder: parentPath, addDate: item.dateAdded ? item.dateAdded / 1000 : Date.now(), lastModified: item.lastModified ? item.lastModified / 1000 : null, icon: '', status: 'unknown' }); } } else if (item.type === 'text/x-moz-place-container' && item.children) { const folderPath = parentPath ? `${parentPath} / ${item.title}` : item.title; item.children.forEach(child => parseItem(child, folderPath)); } }; if (Array.isArray(data)) { data.forEach(item => parseItem(item)); } else { parseItem(data); } return bookmarks; } // Parse Safari bookmarks plist (simplified - would need proper plist parser) parseSafariBookmarks(content) { // This is a simplified implementation // In a real application, you'd want to use a proper plist parser const bookmarks = []; // For now, just show an error message throw new Error('Safari plist format parsing not fully implemented. Please export Safari bookmarks as HTML format.'); } // Show import preview modal showImportPreview(importData) { const mode = document.getElementById('importMode').value; const analysis = this.analyzeImportData(importData, mode); // Update preview statistics document.getElementById('previewTotalCount').textContent = importData.bookmarks.length; document.getElementById('previewNewCount').textContent = analysis.newBookmarks.length; document.getElementById('previewDuplicateCount').textContent = analysis.duplicates.length; document.getElementById('previewFolderCount').textContent = analysis.folders.length; // Populate preview tabs this.populateNewBookmarksPreview(analysis.newBookmarks); this.populateDuplicatesPreview(analysis.duplicates); this.populateFoldersPreview(analysis.folders); this.hideModal('importModal'); this.showModal('importPreviewModal'); } // Analyze import data for duplicates and new bookmarks analyzeImportData(importData, mode) { const newBookmarks = []; const duplicates = []; const folders = new Set(); const normalizeUrls = document.getElementById('normalizeUrls').checked; const fuzzyTitleMatch = document.getElementById('fuzzyTitleMatch').checked; importData.bookmarks.forEach(bookmark => { folders.add(bookmark.folder || 'Uncategorized'); const isDuplicate = this.findDuplicateBookmark(bookmark, normalizeUrls, fuzzyTitleMatch); if (isDuplicate) { duplicates.push({ imported: bookmark, existing: isDuplicate, reason: this.getDuplicateReason(bookmark, isDuplicate, normalizeUrls, fuzzyTitleMatch) }); } else { newBookmarks.push(bookmark); } }); return { newBookmarks, duplicates, folders: Array.from(folders) }; } // Find duplicate bookmark in existing collection findDuplicateBookmark(bookmark, normalizeUrls, fuzzyTitleMatch) { const bookmarkUrl = normalizeUrls ? this.normalizeUrl(bookmark.url) : bookmark.url; for (const existing of this.bookmarks) { const existingUrl = normalizeUrls ? this.normalizeUrl(existing.url) : existing.url; // URL match if (bookmarkUrl === existingUrl) { return existing; } // Fuzzy title match (if URLs are similar) if (fuzzyTitleMatch && this.isSimilarUrl(bookmarkUrl, existingUrl)) { const titleSimilarity = this.calculateStringSimilarity(bookmark.title, existing.title); if (titleSimilarity > 0.8) { return existing; } } } return null; } // Get reason for duplicate detection getDuplicateReason(imported, existing, normalizeUrls, fuzzyTitleMatch) { const importedUrl = normalizeUrls ? this.normalizeUrl(imported.url) : imported.url; const existingUrl = normalizeUrls ? this.normalizeUrl(existing.url) : existing.url; if (importedUrl === existingUrl) { return 'Identical URL'; } if (fuzzyTitleMatch) { const titleSimilarity = this.calculateStringSimilarity(imported.title, existing.title); if (titleSimilarity > 0.8) { return `Similar title (${Math.round(titleSimilarity * 100)}% match)`; } } return 'Similar URL'; } // Check if URLs are similar isSimilarUrl(url1, url2) { const similarity = this.calculateStringSimilarity(url1, url2); return similarity > 0.7; } // Calculate string similarity using Levenshtein distance calculateStringSimilarity(str1, str2) { const len1 = str1.length; const len2 = str2.length; const matrix = Array(len2 + 1).fill().map(() => Array(len1 + 1).fill(0)); for (let i = 0; i <= len1; i++) matrix[0][i] = i; for (let j = 0; j <= len2; j++) matrix[j][0] = j; for (let j = 1; j <= len2; j++) { for (let i = 1; i <= len1; i++) { const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; matrix[j][i] = Math.min( matrix[j - 1][i] + 1, matrix[j][i - 1] + 1, matrix[j - 1][i - 1] + cost ); } } const maxLen = Math.max(len1, len2); return maxLen === 0 ? 1 : (maxLen - matrix[len2][len1]) / maxLen; } // Populate new bookmarks preview populateNewBookmarksPreview(newBookmarks) { const container = document.getElementById('newBookmarksList'); container.innerHTML = ''; newBookmarks.slice(0, 50).forEach(bookmark => { // Show first 50 const item = document.createElement('div'); item.className = 'preview-item new'; item.innerHTML = `
${this.escapeHtml(bookmark.title)}
${this.escapeHtml(bookmark.url)}
${this.escapeHtml(bookmark.folder || 'Uncategorized')}
`; container.appendChild(item); }); if (newBookmarks.length > 50) { const moreItem = document.createElement('div'); moreItem.className = 'preview-item'; moreItem.innerHTML = `
... and ${newBookmarks.length - 50} more bookmarks
`; container.appendChild(moreItem); } } // Populate duplicates preview populateDuplicatesPreview(duplicates) { const container = document.getElementById('duplicatesList'); container.innerHTML = ''; duplicates.slice(0, 50).forEach(duplicate => { const item = document.createElement('div'); item.className = 'preview-item duplicate'; item.innerHTML = `
${this.escapeHtml(duplicate.imported.title)} (${duplicate.reason})
${this.escapeHtml(duplicate.imported.url)}
${this.escapeHtml(duplicate.imported.folder || 'Uncategorized')}
Existing: ${this.escapeHtml(duplicate.existing.title)}
`; container.appendChild(item); }); if (duplicates.length > 50) { const moreItem = document.createElement('div'); moreItem.className = 'preview-item'; moreItem.innerHTML = `
... and ${duplicates.length - 50} more duplicates
`; container.appendChild(moreItem); } } // Populate folders preview populateFoldersPreview(folders) { const container = document.getElementById('foldersList'); container.innerHTML = ''; const folderTree = document.createElement('div'); folderTree.className = 'folder-tree'; folders.sort().forEach(folder => { const item = document.createElement('div'); item.className = 'folder-tree-item folder'; item.textContent = folder || 'Uncategorized'; folderTree.appendChild(item); }); container.appendChild(folderTree); } // Confirm import after preview async confirmImport() { if (!this.currentImportData) { alert('No import data available.'); return; } const mode = document.getElementById('importMode').value; const duplicateStrategy = document.getElementById('duplicateStrategy').value; try { await this.performAdvancedImport(this.currentImportData, mode, duplicateStrategy); this.hideModal('importPreviewModal'); alert(`Successfully imported ${this.currentImportData.bookmarks.length} bookmarks!`); } catch (error) { console.error('Import error:', error); alert('Import failed: ' + error.message); } } // Perform advanced import with different modes async performAdvancedImport(importData, mode, duplicateStrategy) { const analysis = this.analyzeImportData(importData, mode); switch (mode) { case 'replace': this.bookmarks = importData.bookmarks; break; case 'merge': this.handleMergeImport(analysis, duplicateStrategy); break; case 'incremental': this.handleIncrementalImport(analysis, duplicateStrategy); break; default: // Preview mode - should not reach here throw new Error('Invalid import mode'); } this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } // Handle merge import handleMergeImport(analysis, duplicateStrategy) { // Add new bookmarks this.bookmarks.push(...analysis.newBookmarks); // Handle duplicates based on strategy analysis.duplicates.forEach(duplicate => { switch (duplicateStrategy) { case 'skip': // Do nothing - keep existing break; case 'update': this.updateBookmarkFromImport(duplicate.existing, duplicate.imported); break; case 'keep_newer': if (duplicate.imported.addDate > duplicate.existing.addDate) { this.updateBookmarkFromImport(duplicate.existing, duplicate.imported); } break; case 'keep_older': if (duplicate.imported.addDate < duplicate.existing.addDate) { this.updateBookmarkFromImport(duplicate.existing, duplicate.imported); } break; } }); } // Handle incremental import (smart merge with conflict resolution) handleIncrementalImport(analysis, duplicateStrategy) { // Add new bookmarks this.bookmarks.push(...analysis.newBookmarks); // For incremental import, we're more intelligent about duplicates analysis.duplicates.forEach(duplicate => { const existing = duplicate.existing; const imported = duplicate.imported; // Check if imported bookmark has more/better data const shouldUpdate = this.shouldUpdateBookmarkInIncremental(existing, imported); if (shouldUpdate) { this.updateBookmarkFromImport(existing, imported); } }); } // Determine if bookmark should be updated in incremental import shouldUpdateBookmarkInIncremental(existing, imported) { // Update if imported has more recent modification date if (imported.lastModified && existing.lastModified && imported.lastModified > existing.lastModified) { return true; } // Update if imported has better metadata (icon, description, etc.) if (imported.icon && !existing.icon) { return true; } // Update if imported has better folder organization if (imported.folder && !existing.folder) { return true; } return false; } // Update existing bookmark with imported data updateBookmarkFromImport(existing, imported) { existing.title = imported.title || existing.title; existing.url = imported.url || existing.url; existing.folder = imported.folder !== undefined ? imported.folder : existing.folder; existing.icon = imported.icon || existing.icon; existing.lastModified = Math.max(imported.lastModified || 0, existing.lastModified || 0); // Preserve existing status and other local data // existing.status remains unchanged // existing.addDate remains unchanged (keep original) } // Cloud synchronization functionality async connectToCloud() { const provider = document.getElementById('cloudProvider').value; const statusDiv = document.getElementById('cloudStatus'); try { statusDiv.textContent = 'Connecting to ' + provider + '...'; statusDiv.className = 'cloud-status'; // Simulate cloud connection (in real implementation, use OAuth) await this.simulateCloudConnection(provider); statusDiv.textContent = 'Connected to ' + provider + ' successfully!'; statusDiv.className = 'cloud-status connected'; // Enable sync functionality this.cloudSyncEnabled = true; this.cloudProvider = provider; } catch (error) { statusDiv.textContent = 'Failed to connect: ' + error.message; statusDiv.className = 'cloud-status error'; } } // Simulate cloud connection (replace with real OAuth implementation) async simulateCloudConnection(provider) { return new Promise((resolve, reject) => { setTimeout(() => { // Simulate success/failure if (Math.random() > 0.2) { // 80% success rate resolve(); } else { reject(new Error('Connection timeout')); } }, 2000); }); } // Generate QR code for device sync generateQRCode() { const qrDisplay = document.getElementById('qrCodeDisplay'); // Create sync data const syncData = { bookmarks: this.bookmarks, timestamp: Date.now(), deviceId: this.getDeviceId(), version: '1.0' }; // Compress and encode data const encodedData = this.compressAndEncodeData(syncData); // Generate QR code (using a simple text representation for demo) qrDisplay.innerHTML = `
█████████████████████████
█ ▄▄▄▄▄ █▀█ █ ▄▄▄▄▄ █
█ █ █ █▀▀ █ █ █ █
█ █▄▄▄█ █▀█ █ █▄▄▄█ █
█▄▄▄▄▄▄▄█▄▀▄█▄▄▄▄▄▄▄█
█▄▄█▄▄▄▄▀██▀▀█▄█▄▀▄▄█
██▄▀█▄▄▄█▀▀▄█▀█▀▄█▄▄█
█▄▄▄▄▄▄▄█▄██▄█▄▄▄█▀██
█ ▄▄▄▄▄ █▄▄▄█ ▄ ▄▄▄▄█
█ █ █ █▄▀▀█▄▄▄▄▀█▄█
█ █▄▄▄█ █▄▄▄█▀█▄▄▄▄▄█
█▄▄▄▄▄▄▄█▄▄▄▄▄▄▄▄▄▄▄█
█████████████████████████
Sync Code:
${encodedData.substring(0, 32)}...
`; // Store sync data temporarily this.currentSyncData = encodedData; // Auto-expire after 5 minutes setTimeout(() => { this.currentSyncData = null; qrDisplay.innerHTML = '
QR Code expired. Generate a new one.
'; }, 5 * 60 * 1000); } // Scan QR code for device sync scanQRCode() { const scanArea = document.getElementById('qrScanArea'); // Simulate QR scanning (in real implementation, use camera API) scanArea.innerHTML = `
📷 Camera scanning...
`; } // Process sync code from QR or manual input async processSyncCode() { const syncCodeInput = document.getElementById('syncCodeInput'); const syncCode = syncCodeInput.value.trim(); if (!syncCode) { alert('Please enter a sync code.'); return; } try { const syncData = this.decodeAndDecompressData(syncCode); await this.processSyncData(syncData); alert('Sync completed successfully!'); } catch (error) { alert('Invalid sync code or sync failed: ' + error.message); } } // Start local server for device sync startLocalServer() { const statusDiv = document.getElementById('localSyncStatus'); // Simulate local server start (in real implementation, use WebRTC or local network) statusDiv.innerHTML = `
Local server started on: 192.168.1.100:8080
Share this address with other devices on your network.
`; this.localSyncServer = { active: true, address: '192.168.1.100:8080', startTime: Date.now() }; } // Connect to another device connectToDevice() { const address = prompt('Enter device address (IP:PORT):'); if (!address) return; const statusDiv = document.getElementById('localSyncStatus'); statusDiv.innerHTML = `
Connecting to ${address}...
`; // Simulate connection setTimeout(() => { if (Math.random() > 0.3) { statusDiv.innerHTML = `
Connected to ${address}. Ready to sync bookmarks.
`; } else { statusDiv.innerHTML = `
Failed to connect to ${address}. Check address and network.
`; } }, 2000); } // Get unique device ID getDeviceId() { let deviceId = localStorage.getItem('deviceId'); if (!deviceId) { deviceId = 'device_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); localStorage.setItem('deviceId', deviceId); } return deviceId; } // Compress and encode sync data compressAndEncodeData(data) { const jsonString = JSON.stringify(data); // Simple base64 encoding (in real implementation, use compression) return btoa(jsonString); } // Decode and decompress sync data decodeAndDecompressData(encodedData) { try { const jsonString = atob(encodedData); return JSON.parse(jsonString); } catch (error) { throw new Error('Invalid sync data format'); } } // Process received sync data async processSyncData(syncData) { if (!syncData.bookmarks || !Array.isArray(syncData.bookmarks)) { throw new Error('Invalid sync data structure'); } // Create import data structure const importData = { bookmarks: syncData.bookmarks, format: 'sync', originalCount: syncData.bookmarks.length }; // Use incremental import mode for sync await this.performAdvancedImport(importData, 'incremental', 'keep_newer'); } // Enhanced export functionality for sync exportForSync() { return { bookmarks: this.bookmarks, timestamp: Date.now(), deviceId: this.getDeviceId(), version: '1.0', metadata: { totalBookmarks: this.bookmarks.length, folders: Object.keys(this.getFolderStats()).length, lastModified: Math.max(...this.bookmarks.map(b => b.lastModified || b.addDate)) } }; } // Auto-sync functionality enableAutoSync(interval = 30000) { // 30 seconds default if (this.autoSyncInterval) { clearInterval(this.autoSyncInterval); } this.autoSyncInterval = setInterval(() => { if (this.cloudSyncEnabled) { this.performAutoSync(); } }, interval); } // Perform automatic sync async performAutoSync() { try { // Check if local data has changed const currentHash = this.calculateDataHash(); const lastSyncHash = localStorage.getItem('lastSyncHash'); if (currentHash !== lastSyncHash) { // Data has changed, sync to cloud await this.syncToCloud(); localStorage.setItem('lastSyncHash', currentHash); } // Check for remote changes await this.syncFromCloud(); } catch (error) { console.error('Auto-sync failed:', error); } } // Calculate hash of bookmark data for change detection calculateDataHash() { const dataString = JSON.stringify(this.bookmarks.map(b => ({ id: b.id, title: b.title, url: b.url, folder: b.folder, lastModified: b.lastModified }))); // Simple hash function (in real implementation, use crypto.subtle) let hash = 0; for (let i = 0; i < dataString.length; i++) { const char = dataString.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(); } // Sync to cloud storage async syncToCloud() { if (!this.cloudSyncEnabled) return; const syncData = this.exportForSync(); // In real implementation, upload to cloud storage console.log('Syncing to cloud:', this.cloudProvider, syncData.metadata); } // Sync from cloud storage async syncFromCloud() { if (!this.cloudSyncEnabled) return; // In real implementation, download from cloud storage and merge console.log('Checking cloud for updates:', this.cloudProvider); } async importBookmarks() { const fileInput = document.getElementById('fileInput'); const file = fileInput.files[0]; if (!file) { alert('Please select a file to import.'); return; } // Set loading state for large import operations this.isLoading = true; this.showLoadingState(); try { const parseResult = await this.parseImportFile(file); const importedBookmarks = parseResult.bookmarks; if (importedBookmarks.length === 0) { alert('No bookmarks found in the selected file.'); this.isLoading = false; this.renderBookmarks(this.getFilteredBookmarks()); return; } // Validate imported bookmarks const validation = this.validateImportData(importedBookmarks); if (!validation.isValid) { const errorMessage = 'Import validation failed:\n\n' + validation.errors.join('\n') + (validation.warnings.length > 0 ? '\n\nWarnings:\n' + validation.warnings.join('\n') : ''); alert(errorMessage); return; } // Show warnings if any if (validation.warnings.length > 0) { const warningMessage = 'Import warnings (will proceed):\n\n' + validation.warnings.join('\n') + '\n\nDo you want to continue?'; if (!confirm(warningMessage)) { return; } } // Ask user if they want to replace or merge const replace = confirm( `Found ${importedBookmarks.length} bookmarks. ` + 'Click OK to replace existing bookmarks, or Cancel to merge with existing ones.' ); if (replace) { this.bookmarks = importedBookmarks; } else { this.bookmarks = [...this.bookmarks, ...importedBookmarks]; } this.saveBookmarksToStorage(); // Clear loading state this.isLoading = false; this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter this.updateStats(); document.getElementById('importModal').style.display = 'none'; alert(`Successfully imported ${importedBookmarks.length} bookmarks!`); } catch (error) { console.error('Import error:', error); // Clear loading state on error this.isLoading = false; this.renderBookmarks(this.getFilteredBookmarks()); alert('Error importing bookmarks. Please check the file format.'); } } exportBookmarks() { if (this.bookmarks.length === 0) { alert('No bookmarks to export.'); return; } const html = this.generateNetscapeHTML(); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmarks_${new Date().toISOString().split('T')[0]}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } generateNetscapeHTML() { const folders = {}; const noFolderBookmarks = []; // Group bookmarks by folder this.bookmarks.forEach(bookmark => { if (bookmark.folder && bookmark.folder.trim()) { if (!folders[bookmark.folder]) { folders[bookmark.folder] = []; } folders[bookmark.folder].push(bookmark); } else { noFolderBookmarks.push(bookmark); } }); let html = ` Bookmarks

Bookmarks

`; // Add bookmarks without folders noFolderBookmarks.forEach(bookmark => { html += this.generateBookmarkHTML(bookmark); }); // Add folders with bookmarks Object.keys(folders).forEach(folderName => { html += `

${this.escapeHtml(folderName)}

\n

\n`; folders[folderName].forEach(bookmark => { html += this.generateBookmarkHTML(bookmark, ' '); }); html += `

\n`; }); html += `

`; return html; } generateBookmarkHTML(bookmark, indent = ' ') { const addDate = Math.floor(new Date(bookmark.addDate).getTime() / 1000); const iconAttr = bookmark.icon ? ` ICON="${bookmark.icon}"` : ''; return `${indent}

${this.escapeHtml(bookmark.title)}\n`; } escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } populateFolderList() { // Get unique folder names from existing bookmarks const uniqueFolders = [...new Set( this.bookmarks .map(bookmark => bookmark.folder) .filter(folder => folder && folder.trim() !== '') )].sort(); // Get the datalist element const folderList = document.getElementById('folderList'); // Clear existing options folderList.innerHTML = ''; // Add options for each unique folder uniqueFolders.forEach(folder => { const option = document.createElement('option'); option.value = folder; folderList.appendChild(option); }); } showBookmarkModal(bookmark = null) { this.currentEditId = bookmark ? bookmark.id : null; document.getElementById('modalTitle').textContent = bookmark ? 'Edit Bookmark' : 'Add Bookmark'; document.getElementById('bookmarkTitle').value = bookmark ? bookmark.title : ''; document.getElementById('bookmarkUrl').value = bookmark ? bookmark.url : ''; document.getElementById('bookmarkFolder').value = bookmark ? bookmark.folder : ''; // Handle new metadata fields document.getElementById('bookmarkTags').value = bookmark && bookmark.tags ? bookmark.tags.join(', ') : ''; document.getElementById('bookmarkNotes').value = bookmark ? (bookmark.notes || '') : ''; document.getElementById('bookmarkRating').value = bookmark ? (bookmark.rating || 0) : 0; document.getElementById('bookmarkFavorite').checked = bookmark ? (bookmark.favorite || false) : false; // Update star rating display this.updateStarRating(bookmark ? (bookmark.rating || 0) : 0); // Populate the folder datalist with existing folders this.populateFolderList(); // Bind star rating events this.bindStarRatingEvents(); this.showModal('bookmarkModal'); } saveBookmark() { const title = document.getElementById('bookmarkTitle').value.trim(); const url = document.getElementById('bookmarkUrl').value.trim(); const folder = document.getElementById('bookmarkFolder').value.trim(); // Get new metadata fields const tagsInput = document.getElementById('bookmarkTags').value.trim(); const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : []; const notes = document.getElementById('bookmarkNotes').value.trim(); const rating = parseInt(document.getElementById('bookmarkRating').value) || 0; const favorite = document.getElementById('bookmarkFavorite').checked; if (!title || !url) { alert('Please fill in both title and URL.'); return; } if (this.currentEditId) { // Edit existing bookmark const bookmark = this.bookmarks.find(b => b.id === this.currentEditId); if (bookmark) { bookmark.title = title; bookmark.url = url; bookmark.folder = folder; bookmark.tags = tags; bookmark.notes = notes; bookmark.rating = rating; bookmark.favorite = favorite; bookmark.lastModified = Date.now(); bookmark.status = 'unknown'; // Reset status when URL changes } } else { // Add new bookmark const bookmark = { id: Date.now() + Math.random(), title, url, folder, tags, notes, rating, favorite, addDate: Date.now(), lastModified: Date.now(), lastVisited: null, icon: '', status: 'unknown' }; this.bookmarks.push(bookmark); } this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter this.updateStats(); document.getElementById('bookmarkModal').style.display = 'none'; } deleteBookmark(id) { if (confirm('Are you sure you want to delete this bookmark?')) { this.bookmarks = this.bookmarks.filter(b => b.id !== id); this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter this.updateStats(); } } async testLink(bookmark) { console.log(`🔍 Testing link: ${bookmark.title} - ${bookmark.url}`); // Update UI to show testing status bookmark.status = 'testing'; bookmark.errorCategory = null; bookmark.errorDetails = null; this.renderBookmarks(this.getFilteredBookmarks()); const testResult = await this.performLinkTest(bookmark.url, bookmark.title); // Update bookmark with test results bookmark.status = testResult.status; bookmark.errorCategory = testResult.errorCategory; bookmark.errorDetails = testResult.errorDetails; bookmark.lastTested = Date.now(); this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } /** * Performs enhanced link testing with retry logic and detailed error categorization * @param {string} url - The URL to test * @param {string} title - The bookmark title for logging * @returns {Object} Test result with status, error category, and details */ async performLinkTest(url, title) { let lastError = null; let attempts = 0; const maxAttempts = this.linkTestConfig.maxRetries + 1; while (attempts < maxAttempts) { attempts++; const isRetry = attempts > 1; if (isRetry) { console.log(`🔄 Retry attempt ${attempts - 1}/${this.linkTestConfig.maxRetries} for: ${title}`); await this.delay(this.linkTestConfig.retryDelay); } try { // Validate URL format first let parsedUrl; try { parsedUrl = new URL(url); } catch (urlError) { const result = { status: 'invalid', errorCategory: this.ERROR_CATEGORIES.INVALID_URL, errorDetails: { message: 'Invalid URL format', originalError: urlError.message, url: url, attempts: attempts, timestamp: new Date().toISOString() } }; this.logDetailedError(title, url, result); return result; } // Create abort controller for timeout const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, this.linkTestConfig.timeout); // Perform the HTTP request const response = await fetch(url, { method: 'HEAD', mode: 'no-cors', signal: controller.signal, cache: 'no-cache', headers: { 'User-Agent': this.linkTestConfig.userAgent } }); clearTimeout(timeoutId); // Analyze response if (response.ok || response.type === 'opaque') { const result = { status: 'valid', errorCategory: null, errorDetails: { responseType: response.type, status: response.status || 'opaque', attempts: attempts, timestamp: new Date().toISOString() } }; console.log(`✅ [Attempt ${attempts}] Valid: ${title} (${response.type === 'opaque' ? 'CORS-protected' : 'HTTP ' + response.status})`); // Try to fetch favicon for this valid URL this.fetchFaviconInBackground(url, title); return result; } else { const result = { status: 'invalid', errorCategory: this.ERROR_CATEGORIES.HTTP_ERROR, errorDetails: { message: `HTTP ${response.status} ${response.statusText}`, status: response.status, statusText: response.statusText, url: url, attempts: attempts, timestamp: new Date().toISOString() } }; // Don't retry HTTP errors (they're likely permanent) this.logDetailedError(title, url, result); return result; } } catch (error) { lastError = error; const errorCategory = this.categorizeError(error); const isTransientError = this.isTransientError(errorCategory); // If this is not a transient error or we've exhausted retries, return failure if (!isTransientError || attempts >= maxAttempts) { const result = { status: 'invalid', errorCategory: errorCategory, errorDetails: { message: error.message, errorType: error.name, url: url, attempts: attempts, isTransient: isTransientError, timestamp: new Date().toISOString(), stack: error.stack } }; this.logDetailedError(title, url, result); return result; } // Log retry attempt for transient errors console.log(`⚠️ [Attempt ${attempts}] Transient error for ${title}: ${error.message} (will retry)`); } } // This shouldn't be reached, but just in case const result = { status: 'invalid', errorCategory: this.ERROR_CATEGORIES.UNKNOWN, errorDetails: { message: 'Maximum retry attempts exceeded', lastError: lastError?.message, url: url, attempts: attempts, timestamp: new Date().toISOString() } }; this.logDetailedError(title, url, result); return result; } /** * Categorizes errors into specific types for better debugging * @param {Error} error - The error to categorize * @returns {string} Error category constant */ categorizeError(error) { const errorMessage = error.message.toLowerCase(); const errorName = error.name.toLowerCase(); if (errorName === 'aborterror') { return this.ERROR_CATEGORIES.TIMEOUT; } if (errorName === 'typeerror') { if (errorMessage.includes('fetch') || errorMessage.includes('network')) { if (errorMessage.includes('cors')) { return this.ERROR_CATEGORIES.CORS_BLOCKED; } return this.ERROR_CATEGORIES.NETWORK_ERROR; } if (errorMessage.includes('invalid url')) { return this.ERROR_CATEGORIES.INVALID_URL; } } if (errorMessage.includes('dns') || errorMessage.includes('name resolution')) { return this.ERROR_CATEGORIES.DNS_ERROR; } if (errorMessage.includes('ssl') || errorMessage.includes('tls') || errorMessage.includes('certificate')) { return this.ERROR_CATEGORIES.SSL_ERROR; } if (errorMessage.includes('connection refused') || errorMessage.includes('econnrefused')) { return this.ERROR_CATEGORIES.CONNECTION_REFUSED; } if (errorMessage.includes('timeout')) { return this.ERROR_CATEGORIES.TIMEOUT; } return this.ERROR_CATEGORIES.UNKNOWN; } /** * Determines if an error is transient and worth retrying * @param {string} errorCategory - The error category * @returns {boolean} True if the error is transient */ isTransientError(errorCategory) { const transientErrors = [ this.ERROR_CATEGORIES.NETWORK_ERROR, this.ERROR_CATEGORIES.TIMEOUT, this.ERROR_CATEGORIES.DNS_ERROR, this.ERROR_CATEGORIES.CONNECTION_REFUSED ]; return transientErrors.includes(errorCategory); } /** * Logs detailed error information for debugging * @param {string} title - Bookmark title * @param {string} url - Bookmark URL * @param {Object} result - Test result object */ logDetailedError(title, url, result) { const errorInfo = { bookmark: title, url: url, category: result.errorCategory, details: result.errorDetails, userAgent: this.linkTestConfig.userAgent, timeout: this.linkTestConfig.timeout, maxRetries: this.linkTestConfig.maxRetries }; console.group(`❌ Link Test Failed: ${title}`); console.error('Error Category:', result.errorCategory); console.error('Error Message:', result.errorDetails.message); console.error('URL:', url); console.error('Attempts:', result.errorDetails.attempts); console.error('Timestamp:', result.errorDetails.timestamp); if (result.errorDetails.stack) { console.error('Stack Trace:', result.errorDetails.stack); } console.error('Full Error Details:', errorInfo); console.groupEnd(); } /** * Utility function to create a delay * @param {number} ms - Milliseconds to delay * @returns {Promise} Promise that resolves after the delay */ delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * Shows the settings modal with current configuration values */ showSettingsModal() { // Populate form with current settings document.getElementById('timeoutSetting').value = this.linkTestConfig.timeout / 1000; document.getElementById('maxRetriesSetting').value = this.linkTestConfig.maxRetries; document.getElementById('retryDelaySetting').value = this.linkTestConfig.retryDelay; document.getElementById('userAgentSetting').value = this.linkTestConfig.userAgent; this.showModal('settingsModal'); } /** * Saves the settings from the form and updates the configuration */ saveSettings() { const timeout = parseInt(document.getElementById('timeoutSetting').value) * 1000; const maxRetries = parseInt(document.getElementById('maxRetriesSetting').value); const retryDelay = parseInt(document.getElementById('retryDelaySetting').value); const userAgent = document.getElementById('userAgentSetting').value.trim(); // Validate settings if (timeout < 5000 || timeout > 60000) { alert('Timeout must be between 5 and 60 seconds.'); return; } if (maxRetries < 0 || maxRetries > 5) { alert('Maximum retries must be between 0 and 5.'); return; } if (retryDelay < 500 || retryDelay > 5000) { alert('Retry delay must be between 500 and 5000 milliseconds.'); return; } if (!userAgent || userAgent.length === 0) { alert('User agent cannot be empty.'); return; } // Update configuration this.linkTestConfig.timeout = timeout; this.linkTestConfig.maxRetries = maxRetries; this.linkTestConfig.retryDelay = retryDelay; this.linkTestConfig.userAgent = userAgent; // Save to localStorage this.saveLinkTestConfigToStorage(); console.log('🔧 Link testing settings updated:', this.linkTestConfig); this.hideModal('settingsModal'); alert('Settings saved successfully!'); } /** * Resets settings to default values */ resetSettings() { if (confirm('Reset all link testing settings to default values?')) { this.linkTestConfig = { timeout: 10000, maxRetries: 2, retryDelay: 1000, userAgent: 'BookmarkManager/1.0 (Link Checker)' }; // Update form with default values document.getElementById('timeoutSetting').value = 10; document.getElementById('maxRetriesSetting').value = 2; document.getElementById('retryDelaySetting').value = 1000; document.getElementById('userAgentSetting').value = 'BookmarkManager/1.0 (Link Checker)'; // Save to localStorage this.saveLinkTestConfigToStorage(); console.log('🔧 Link testing settings reset to defaults:', this.linkTestConfig); alert('Settings reset to default values!'); } } /** * Saves link test configuration to localStorage */ saveLinkTestConfigToStorage() { try { localStorage.setItem('bookmarkManager_linkTestConfig', JSON.stringify(this.linkTestConfig)); } catch (error) { console.error('Error saving link test config to storage:', error); } } /** * Loads link test configuration from localStorage */ loadLinkTestConfigFromStorage() { try { const saved = localStorage.getItem('bookmarkManager_linkTestConfig'); if (saved) { const config = JSON.parse(saved); // Merge with defaults to ensure all properties exist this.linkTestConfig = { ...this.linkTestConfig, ...config }; console.log('🔧 Loaded link testing settings from storage:', this.linkTestConfig); } } catch (error) { console.error('Error loading link test config from storage:', error); } } /** * Formats error category for display * @param {string} errorCategory - The error category constant * @returns {string} Human-readable error category */ formatErrorCategory(errorCategory) { const categoryMap = { [this.ERROR_CATEGORIES.NETWORK_ERROR]: 'Network Error', [this.ERROR_CATEGORIES.TIMEOUT]: 'Timeout', [this.ERROR_CATEGORIES.INVALID_URL]: 'Invalid URL', [this.ERROR_CATEGORIES.HTTP_ERROR]: 'HTTP Error', [this.ERROR_CATEGORIES.CORS_BLOCKED]: 'CORS Blocked', [this.ERROR_CATEGORIES.DNS_ERROR]: 'DNS Error', [this.ERROR_CATEGORIES.SSL_ERROR]: 'SSL Error', [this.ERROR_CATEGORIES.CONNECTION_REFUSED]: 'Connection Refused', [this.ERROR_CATEGORIES.UNKNOWN]: 'Unknown Error' }; return categoryMap[errorCategory] || 'Unknown Error'; } /** * Formats a timestamp as relative time (e.g., "2 minutes ago") * @param {number} timestamp - The timestamp to format * @returns {string} Relative time string */ formatRelativeTime(timestamp) { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (seconds < 60) { return 'just now'; } else if (minutes < 60) { return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`; } else if (hours < 24) { return `${hours} hour${hours !== 1 ? 's' : ''} ago`; } else if (days < 7) { return `${days} day${days !== 1 ? 's' : ''} ago`; } else { return new Date(timestamp).toLocaleDateString(); } } async testAllLinks() { if (this.bookmarks.length === 0) { alert('No bookmarks to test.'); return; } console.log(`🚀 Starting enhanced link testing for ${this.bookmarks.length} bookmarks`); console.log(`Configuration: timeout=${this.linkTestConfig.timeout}ms, maxRetries=${this.linkTestConfig.maxRetries}, retryDelay=${this.linkTestConfig.retryDelay}ms`); // Set loading state for large operations this.isLoading = true; if (this.bookmarks.length > this.virtualScrollThreshold) { this.showLoadingState(); } const progressContainer = document.getElementById('progressContainer'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); progressContainer.style.display = 'block'; progressBar.style.width = '0%'; // Update progress bar ARIA attributes const progressBarContainer = progressContainer.querySelector('[role="progressbar"]'); if (progressBarContainer) { progressBarContainer.setAttribute('aria-valuenow', '0'); } let completed = 0; const total = this.bookmarks.length; const startTime = Date.now(); const testResults = { valid: 0, invalid: 0, errorCategories: {} }; for (const bookmark of this.bookmarks) { const progressPercent = Math.round((completed / total) * 100); progressText.textContent = `Testing ${bookmark.title}... (${completed + 1}/${total})`; console.log(`📊 [${completed + 1}/${total}] Testing: ${bookmark.title.substring(0, 50)}${bookmark.title.length > 50 ? '...' : ''}`); // Update UI to show testing status bookmark.status = 'testing'; this.renderBookmarks(this.getFilteredBookmarks()); // Use the enhanced link testing method const testResult = await this.performLinkTest(bookmark.url, bookmark.title); // Update bookmark with test results bookmark.status = testResult.status; bookmark.errorCategory = testResult.errorCategory; bookmark.errorDetails = testResult.errorDetails; bookmark.lastTested = Date.now(); // Track statistics if (testResult.status === 'valid') { testResults.valid++; } else { testResults.invalid++; if (testResult.errorCategory) { testResults.errorCategories[testResult.errorCategory] = (testResults.errorCategories[testResult.errorCategory] || 0) + 1; } } completed++; const newProgressPercent = Math.round((completed / total) * 100); progressBar.style.width = `${newProgressPercent}%`; // Update progress bar ARIA attributes if (progressBarContainer) { progressBarContainer.setAttribute('aria-valuenow', newProgressPercent.toString()); } // Update UI periodically during testing if (completed % 10 === 0 || completed === total) { this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } } const endTime = Date.now(); const duration = Math.round((endTime - startTime) / 1000); // Log comprehensive test results console.group(`📈 Link Testing Complete - ${duration}s elapsed`); console.log(`✅ Valid links: ${testResults.valid}`); console.log(`❌ Invalid links: ${testResults.invalid}`); console.log(`📊 Success rate: ${Math.round((testResults.valid / total) * 100)}%`); if (Object.keys(testResults.errorCategories).length > 0) { console.log('🔍 Error breakdown:'); Object.entries(testResults.errorCategories) .sort((a, b) => b[1] - a[1]) .forEach(([category, count]) => { console.log(` ${category}: ${count} (${Math.round((count / testResults.invalid) * 100)}%)`); }); } console.groupEnd(); progressText.textContent = `Testing complete! ${testResults.valid} valid, ${testResults.invalid} invalid (${duration}s)`; setTimeout(() => { progressContainer.style.display = 'none'; }, 3000); // Clear loading state this.isLoading = false; this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } async testInvalidLinks() { // Get only bookmarks marked as invalid const invalidBookmarks = this.bookmarks.filter(b => b.status === 'invalid'); if (invalidBookmarks.length === 0) { alert('No invalid bookmarks to retest.'); return; } console.log(`🔄 Starting enhanced retest for ${invalidBookmarks.length} invalid bookmarks`); console.log(`Configuration: timeout=${this.linkTestConfig.timeout}ms, maxRetries=${this.linkTestConfig.maxRetries}, retryDelay=${this.linkTestConfig.retryDelay}ms`); // Set loading state for large operations this.isLoading = true; if (invalidBookmarks.length > this.virtualScrollThreshold) { this.showLoadingState(); } const progressContainer = document.getElementById('progressContainer'); const progressBar = document.getElementById('progressBar'); const progressText = document.getElementById('progressText'); progressContainer.style.display = 'block'; progressBar.style.width = '0%'; let completed = 0; const total = invalidBookmarks.length; const startTime = Date.now(); const retestResults = { nowValid: 0, stillInvalid: 0, errorCategories: {}, recoveredFromCategories: {} }; for (const bookmark of invalidBookmarks) { progressText.textContent = `Retesting ${bookmark.title}... (${completed + 1}/${total})`; console.log(`🔍 [${completed + 1}/${total}] Retesting: ${bookmark.title.substring(0, 50)}${bookmark.title.length > 50 ? '...' : ''}`); // Store previous error category for recovery tracking const previousErrorCategory = bookmark.errorCategory; // Update UI to show testing status bookmark.status = 'testing'; this.renderBookmarks(this.getFilteredBookmarks()); // Use the enhanced link testing method const testResult = await this.performLinkTest(bookmark.url, bookmark.title); // Update bookmark with test results bookmark.status = testResult.status; bookmark.errorCategory = testResult.errorCategory; bookmark.errorDetails = testResult.errorDetails; bookmark.lastTested = Date.now(); // Track retest statistics if (testResult.status === 'valid') { retestResults.nowValid++; if (previousErrorCategory) { retestResults.recoveredFromCategories[previousErrorCategory] = (retestResults.recoveredFromCategories[previousErrorCategory] || 0) + 1; } console.log(`✅ [${completed + 1}/${total}] Recovered: ${bookmark.title} (was ${previousErrorCategory || 'unknown error'})`); } else { retestResults.stillInvalid++; if (testResult.errorCategory) { retestResults.errorCategories[testResult.errorCategory] = (retestResults.errorCategories[testResult.errorCategory] || 0) + 1; } } completed++; progressBar.style.width = `${(completed / total) * 100}%`; // Update UI periodically during testing if (completed % 5 === 0 || completed === total) { this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } } const endTime = Date.now(); const duration = Math.round((endTime - startTime) / 1000); // Log comprehensive retest results console.group(`🔄 Invalid Link Retest Complete - ${duration}s elapsed`); console.log(`✅ Links now valid: ${retestResults.nowValid}`); console.log(`❌ Links still invalid: ${retestResults.stillInvalid}`); console.log(`📊 Recovery rate: ${Math.round((retestResults.nowValid / total) * 100)}%`); if (Object.keys(retestResults.recoveredFromCategories).length > 0) { console.log('🎯 Recovered from error types:'); Object.entries(retestResults.recoveredFromCategories) .sort((a, b) => b[1] - a[1]) .forEach(([category, count]) => { console.log(` ${category}: ${count} recovered`); }); } if (Object.keys(retestResults.errorCategories).length > 0) { console.log('🔍 Persistent error breakdown:'); Object.entries(retestResults.errorCategories) .sort((a, b) => b[1] - a[1]) .forEach(([category, count]) => { console.log(` ${category}: ${count} (${Math.round((count / retestResults.stillInvalid) * 100)}%)`); }); } console.groupEnd(); progressText.textContent = `Retesting complete! ${retestResults.nowValid} recovered, ${retestResults.stillInvalid} still invalid (${duration}s)`; setTimeout(() => { progressContainer.style.display = 'none'; }, 3000); // Clear loading state this.isLoading = false; this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } /** * Enhanced URL normalization to handle more edge cases * @param {string} url - The URL to normalize * @param {Object} options - Normalization options * @returns {string} Normalized URL */ normalizeUrl(url, options = {}) { const { removeQueryParams = false, removeFragment = false, removeWWW = true, removeTrailingSlash = true, sortQueryParams = true, removeDefaultPorts = true, removeCommonTracking = false } = options; try { const urlObj = new URL(url); // Normalize protocol and hostname let normalized = urlObj.protocol.toLowerCase() + '//'; // Handle www removal let hostname = urlObj.hostname.toLowerCase(); if (removeWWW && hostname.startsWith('www.')) { hostname = hostname.substring(4); } normalized += hostname; // Add port if it's not the default port if (removeDefaultPorts) { if ((urlObj.protocol === 'http:' && urlObj.port && urlObj.port !== '80') || (urlObj.protocol === 'https:' && urlObj.port && urlObj.port !== '443')) { normalized += ':' + urlObj.port; } } else if (urlObj.port) { normalized += ':' + urlObj.port; } // Add pathname, handling trailing slash let pathname = urlObj.pathname; if (removeTrailingSlash && pathname !== '/' && pathname.endsWith('/')) { pathname = pathname.slice(0, -1); } normalized += pathname; // Handle query parameters if (!removeQueryParams && urlObj.search) { const params = new URLSearchParams(urlObj.search); // Remove common tracking parameters if requested if (removeCommonTracking) { const trackingParams = [ 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'gclid', 'fbclid', 'msclkid', 'ref', 'source', 'campaign', '_ga', '_gid', 'mc_cid', 'mc_eid', 'yclid' ]; trackingParams.forEach(param => params.delete(param)); } if (params.toString()) { if (sortQueryParams) { const sortedParams = new URLSearchParams(); [...params.keys()].sort().forEach(key => { params.getAll(key).forEach(value => { sortedParams.append(key, value); }); }); normalized += '?' + sortedParams.toString(); } else { normalized += '?' + params.toString(); } } } // Add hash/fragment if present and not removed if (!removeFragment && urlObj.hash) { normalized += urlObj.hash; } return normalized; } catch (error) { console.warn('URL normalization failed for:', url, error); // If URL parsing fails, return the original URL with basic cleanup return url.toLowerCase().trim(); } } /** * Calculate Levenshtein distance between two strings for fuzzy matching * @param {string} str1 - First string * @param {string} str2 - Second string * @returns {number} Edit distance between strings */ levenshteinDistance(str1, str2) { const matrix = []; // Create matrix for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } // Fill matrix for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min( matrix[i - 1][j - 1] + 1, // substitution matrix[i][j - 1] + 1, // insertion matrix[i - 1][j] + 1 // deletion ); } } } return matrix[str2.length][str1.length]; } /** * Calculate similarity ratio between two strings * @param {string} str1 - First string * @param {string} str2 - Second string * @returns {number} Similarity ratio (0-1) */ calculateSimilarity(str1, str2) { if (str1 === str2) return 1; if (!str1 || !str2) return 0; const maxLength = Math.max(str1.length, str2.length); if (maxLength === 0) return 1; const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase()); return (maxLength - distance) / maxLength; } /** * Normalize title for fuzzy matching * @param {string} title - Title to normalize * @returns {string} Normalized title */ normalizeTitle(title) { return title .toLowerCase() .replace(/[^\w\s]/g, '') // Remove punctuation .replace(/\s+/g, ' ') // Normalize whitespace .trim(); } /** * Advanced duplicate detection with enhanced URL normalization and fuzzy title matching */ async findDuplicates() { // Set loading state for large operations if (this.bookmarks.length > this.virtualScrollThreshold) { this.isLoading = true; this.showLoadingState(); } // First, reset all duplicate statuses this.bookmarks.forEach(bookmark => { if (bookmark.status === 'duplicate') { bookmark.status = 'unknown'; } }); // Enhanced duplicate detection with multiple strategies const duplicateGroups = await this.detectDuplicates(); if (duplicateGroups.length === 0) { alert('No duplicate bookmarks found.'); this.isLoading = false; return; } // Show duplicate preview and resolution options this.showDuplicatePreview(duplicateGroups); } /** * Detect duplicates using multiple strategies * @returns {Array} Array of duplicate groups */ async detectDuplicates() { const duplicateGroups = []; const processedBookmarks = new Set(); // Strategy 1: Exact URL matches with enhanced normalization const urlGroups = this.findUrlDuplicates(); urlGroups.forEach(group => { if (group.length > 1) { duplicateGroups.push({ type: 'exact_url', reason: 'Identical URLs', bookmarks: group, confidence: 1.0 }); group.forEach(bookmark => processedBookmarks.add(bookmark.id)); } }); // Strategy 2: URL duplicates with different query parameters/fragments const urlVariantGroups = this.findUrlVariantDuplicates(processedBookmarks); urlVariantGroups.forEach(group => { if (group.length > 1) { duplicateGroups.push({ type: 'url_variant', reason: 'Same URL with different parameters/fragments', bookmarks: group, confidence: 0.9 }); group.forEach(bookmark => processedBookmarks.add(bookmark.id)); } }); // Strategy 3: Fuzzy title matching for near-duplicates const titleGroups = this.findTitleDuplicates(processedBookmarks); titleGroups.forEach(group => { if (group.length > 1) { duplicateGroups.push({ type: 'fuzzy_title', reason: 'Similar titles', bookmarks: group.bookmarks, confidence: group.confidence }); group.bookmarks.forEach(bookmark => processedBookmarks.add(bookmark.id)); } }); return duplicateGroups; } /** * Find exact URL duplicates with enhanced normalization * @returns {Array} Array of bookmark groups with identical URLs */ findUrlDuplicates() { const urlMap = new Map(); this.bookmarks.forEach(bookmark => { // Enhanced URL normalization const normalizedUrl = this.normalizeUrl(bookmark.url, { removeQueryParams: false, removeFragment: false, removeWWW: true, removeTrailingSlash: true, sortQueryParams: true, removeCommonTracking: false }); if (urlMap.has(normalizedUrl)) { urlMap.get(normalizedUrl).push(bookmark); } else { urlMap.set(normalizedUrl, [bookmark]); } }); return Array.from(urlMap.values()).filter(group => group.length > 1); } /** * Find URL variants (same base URL, different parameters/fragments) * @param {Set} processedBookmarks - Already processed bookmark IDs * @returns {Array} Array of bookmark groups with URL variants */ findUrlVariantDuplicates(processedBookmarks) { const baseUrlMap = new Map(); this.bookmarks .filter(bookmark => !processedBookmarks.has(bookmark.id)) .forEach(bookmark => { // Normalize URL without query params and fragments const baseUrl = this.normalizeUrl(bookmark.url, { removeQueryParams: true, removeFragment: true, removeWWW: true, removeTrailingSlash: true }); if (baseUrlMap.has(baseUrl)) { baseUrlMap.get(baseUrl).push(bookmark); } else { baseUrlMap.set(baseUrl, [bookmark]); } }); return Array.from(baseUrlMap.values()).filter(group => group.length > 1); } /** * Find title duplicates using fuzzy matching * @param {Set} processedBookmarks - Already processed bookmark IDs * @returns {Array} Array of bookmark groups with similar titles */ findTitleDuplicates(processedBookmarks) { const titleGroups = []; const remainingBookmarks = this.bookmarks.filter(bookmark => !processedBookmarks.has(bookmark.id)); const processedTitles = new Set(); remainingBookmarks.forEach((bookmark, index) => { if (processedTitles.has(bookmark.id)) return; const normalizedTitle = this.normalizeTitle(bookmark.title); const similarBookmarks = [bookmark]; // Compare with remaining bookmarks for (let i = index + 1; i < remainingBookmarks.length; i++) { const otherBookmark = remainingBookmarks[i]; if (processedTitles.has(otherBookmark.id)) continue; const otherNormalizedTitle = this.normalizeTitle(otherBookmark.title); const similarity = this.calculateSimilarity(normalizedTitle, otherNormalizedTitle); // Consider titles similar if similarity > 0.8 and length difference is reasonable if (similarity > 0.8 && Math.abs(normalizedTitle.length - otherNormalizedTitle.length) < 20) { similarBookmarks.push(otherBookmark); processedTitles.add(otherBookmark.id); } } if (similarBookmarks.length > 1) { const avgSimilarity = similarBookmarks.reduce((sum, bookmark, idx) => { if (idx === 0) return sum; return sum + this.calculateSimilarity(normalizedTitle, this.normalizeTitle(bookmark.title)); }, 0) / (similarBookmarks.length - 1); titleGroups.push({ bookmarks: similarBookmarks, confidence: Math.round(avgSimilarity * 100) / 100 }); similarBookmarks.forEach(bookmark => processedTitles.add(bookmark.id)); } }); return titleGroups; } /** * Show duplicate preview modal with resolution options * @param {Array} duplicateGroups - Array of duplicate groups */ showDuplicatePreview(duplicateGroups) { this.currentDuplicateGroups = duplicateGroups; // Calculate totals const totalDuplicates = duplicateGroups.reduce((sum, group) => sum + group.bookmarks.length, 0); const totalGroups = duplicateGroups.length; // Update modal title document.getElementById('duplicateTitle').textContent = `${totalDuplicates} Duplicate Bookmarks Found in ${totalGroups} Group${totalGroups > 1 ? 's' : ''}`; // Render duplicate groups this.renderDuplicateGroups(duplicateGroups); // Bind events for the duplicate modal this.bindDuplicateModalEvents(); // Show the modal this.showModal('duplicateModal'); // Clear loading state this.isLoading = false; } /** * Render duplicate groups in the preview * @param {Array} duplicateGroups - Array of duplicate groups */ renderDuplicateGroups(duplicateGroups) { const previewContainer = document.getElementById('duplicatePreview'); previewContainer.innerHTML = ''; duplicateGroups.forEach((group, groupIndex) => { const groupElement = document.createElement('div'); groupElement.className = 'duplicate-group'; groupElement.dataset.groupIndex = groupIndex; // Group header const headerElement = document.createElement('div'); headerElement.className = 'duplicate-group-header'; const titleElement = document.createElement('h4'); titleElement.className = 'duplicate-group-title'; titleElement.textContent = `Group ${groupIndex + 1}: ${group.reason}`; const typeElement = document.createElement('span'); typeElement.className = `duplicate-group-type duplicate-type-${group.type}`; typeElement.textContent = group.type.replace('_', ' '); if (group.confidence < 1.0) { const confidenceElement = document.createElement('span'); confidenceElement.className = 'duplicate-confidence'; confidenceElement.textContent = `(${Math.round(group.confidence * 100)}% match)`; headerElement.appendChild(titleElement); headerElement.appendChild(typeElement); headerElement.appendChild(confidenceElement); } else { headerElement.appendChild(titleElement); headerElement.appendChild(typeElement); } // Bookmarks list const bookmarksContainer = document.createElement('div'); bookmarksContainer.className = 'duplicate-bookmarks'; group.bookmarks.forEach((bookmark, bookmarkIndex) => { const bookmarkElement = document.createElement('div'); bookmarkElement.className = 'duplicate-bookmark-item'; bookmarkElement.dataset.bookmarkId = bookmark.id; bookmarkElement.dataset.groupIndex = groupIndex; bookmarkElement.dataset.bookmarkIndex = bookmarkIndex; // Selection checkbox (for manual resolution) const selectElement = document.createElement('input'); selectElement.type = 'checkbox'; selectElement.className = 'duplicate-bookmark-select'; selectElement.style.display = 'none'; // Hidden by default // Bookmark info const infoElement = document.createElement('div'); infoElement.className = 'duplicate-bookmark-info'; const titleElement = document.createElement('div'); titleElement.className = 'duplicate-bookmark-title'; titleElement.textContent = bookmark.title; const urlElement = document.createElement('div'); urlElement.className = 'duplicate-bookmark-url'; urlElement.textContent = bookmark.url; const metaElement = document.createElement('div'); metaElement.className = 'duplicate-bookmark-meta'; const addedDate = new Date(bookmark.addDate).toLocaleDateString(); metaElement.innerHTML = ` Added: ${addedDate} ${bookmark.lastModified ? `Modified: ${new Date(bookmark.lastModified).toLocaleDateString()}` : ''} `; if (bookmark.folder) { const folderElement = document.createElement('div'); folderElement.className = 'duplicate-bookmark-folder'; folderElement.textContent = bookmark.folder; infoElement.appendChild(folderElement); } infoElement.appendChild(titleElement); infoElement.appendChild(urlElement); infoElement.appendChild(metaElement); bookmarkElement.appendChild(selectElement); bookmarkElement.appendChild(infoElement); bookmarksContainer.appendChild(bookmarkElement); }); groupElement.appendChild(headerElement); groupElement.appendChild(bookmarksContainer); previewContainer.appendChild(groupElement); }); } /** * Bind events for duplicate modal */ bindDuplicateModalEvents() { // Remove existing listeners to prevent duplicates const existingApplyBtn = document.getElementById('applyDuplicateResolution'); const existingCancelBtn = document.getElementById('cancelDuplicateBtn'); if (existingApplyBtn) { existingApplyBtn.replaceWith(existingApplyBtn.cloneNode(true)); } if (existingCancelBtn) { existingCancelBtn.replaceWith(existingCancelBtn.cloneNode(true)); } // Apply resolution button document.getElementById('applyDuplicateResolution').addEventListener('click', () => { this.applyDuplicateResolution(); }); // Cancel button document.getElementById('cancelDuplicateBtn').addEventListener('click', () => { this.hideModal('duplicateModal'); }); // Resolution strategy change document.querySelectorAll('input[name="resolutionStrategy"]').forEach(radio => { radio.addEventListener('change', (e) => { this.handleResolutionStrategyChange(e.target.value); }); }); // Close modal when clicking outside document.getElementById('duplicateModal').addEventListener('click', (e) => { if (e.target.id === 'duplicateModal') { this.hideModal('duplicateModal'); } }); } /** * Handle resolution strategy change * @param {string} strategy - Selected resolution strategy */ handleResolutionStrategyChange(strategy) { const manualControls = document.querySelector('.manual-resolution-controls'); const checkboxes = document.querySelectorAll('.duplicate-bookmark-select'); const bookmarkItems = document.querySelectorAll('.duplicate-bookmark-item'); // Reset all visual states bookmarkItems.forEach(item => { item.classList.remove('selected', 'to-delete'); }); if (strategy === 'manual') { // Show manual controls and checkboxes if (manualControls) { manualControls.classList.add('active'); } checkboxes.forEach(checkbox => { checkbox.style.display = 'block'; checkbox.addEventListener('change', this.handleManualSelection.bind(this)); }); } else { // Hide manual controls and checkboxes if (manualControls) { manualControls.classList.remove('active'); } checkboxes.forEach(checkbox => { checkbox.style.display = 'none'; checkbox.removeEventListener('change', this.handleManualSelection.bind(this)); }); // Preview automatic resolution this.previewAutomaticResolution(strategy); } } /** * Handle manual bookmark selection * @param {Event} event - Change event from checkbox */ handleManualSelection(event) { const checkbox = event.target; const bookmarkItem = checkbox.closest('.duplicate-bookmark-item'); const groupIndex = parseInt(bookmarkItem.dataset.groupIndex); if (checkbox.checked) { bookmarkItem.classList.add('selected'); bookmarkItem.classList.remove('to-delete'); } else { bookmarkItem.classList.remove('selected'); bookmarkItem.classList.add('to-delete'); } // Update other bookmarks in the same group const groupBookmarks = document.querySelectorAll(`[data-group-index="${groupIndex}"]`); groupBookmarks.forEach(item => { if (item !== bookmarkItem) { const otherCheckbox = item.querySelector('.duplicate-bookmark-select'); if (checkbox.checked && !otherCheckbox.checked) { item.classList.add('to-delete'); } else if (!checkbox.checked) { item.classList.remove('to-delete'); } } }); } /** * Preview automatic resolution strategy * @param {string} strategy - Resolution strategy */ previewAutomaticResolution(strategy) { this.currentDuplicateGroups.forEach((group, groupIndex) => { let keepBookmark = null; switch (strategy) { case 'keep_newest': keepBookmark = group.bookmarks.reduce((newest, bookmark) => (bookmark.addDate > newest.addDate) ? bookmark : newest ); break; case 'keep_oldest': keepBookmark = group.bookmarks.reduce((oldest, bookmark) => (bookmark.addDate < oldest.addDate) ? bookmark : oldest ); break; case 'mark_only': // Don't select any for deletion, just mark break; } // Update visual indicators const groupBookmarksElements = document.querySelectorAll(`[data-group-index="${groupIndex}"]`); groupBookmarksElements.forEach(element => { const bookmarkId = element.dataset.bookmarkId; if (strategy === 'mark_only') { element.classList.remove('selected', 'to-delete'); } else if (keepBookmark && bookmarkId === keepBookmark.id.toString()) { element.classList.add('selected'); element.classList.remove('to-delete'); } else { element.classList.remove('selected'); element.classList.add('to-delete'); } }); }); } /** * Apply the selected duplicate resolution strategy */ applyDuplicateResolution() { const selectedStrategy = document.querySelector('input[name="resolutionStrategy"]:checked').value; if (!selectedStrategy) { alert('Please select a resolution strategy.'); return; } let bookmarksToDelete = []; let bookmarksToMark = []; if (selectedStrategy === 'mark_only') { // Just mark all duplicates this.currentDuplicateGroups.forEach(group => { group.bookmarks.forEach(bookmark => { bookmarksToMark.push(bookmark.id); }); }); } else if (selectedStrategy === 'manual') { // Get manually selected bookmarks const selectedCheckboxes = document.querySelectorAll('.duplicate-bookmark-select:checked'); const selectedIds = Array.from(selectedCheckboxes).map(cb => cb.closest('.duplicate-bookmark-item').dataset.bookmarkId ); this.currentDuplicateGroups.forEach(group => { const groupSelectedIds = group.bookmarks .filter(bookmark => selectedIds.includes(bookmark.id.toString())) .map(bookmark => bookmark.id); if (groupSelectedIds.length === 0) { alert(`Please select at least one bookmark to keep in each group.`); return; } // Mark all as duplicates, delete unselected ones group.bookmarks.forEach(bookmark => { bookmarksToMark.push(bookmark.id); if (!groupSelectedIds.includes(bookmark.id)) { bookmarksToDelete.push(bookmark.id); } }); }); } else { // Automatic resolution (keep_newest or keep_oldest) this.currentDuplicateGroups.forEach(group => { let keepBookmark = null; switch (selectedStrategy) { case 'keep_newest': keepBookmark = group.bookmarks.reduce((newest, bookmark) => (bookmark.addDate > newest.addDate) ? bookmark : newest ); break; case 'keep_oldest': keepBookmark = group.bookmarks.reduce((oldest, bookmark) => (bookmark.addDate < oldest.addDate) ? bookmark : oldest ); break; } group.bookmarks.forEach(bookmark => { bookmarksToMark.push(bookmark.id); if (bookmark.id !== keepBookmark.id) { bookmarksToDelete.push(bookmark.id); } }); }); } // Apply the resolution this.executeDuplicateResolution(bookmarksToMark, bookmarksToDelete, selectedStrategy); } /** * Execute the duplicate resolution * @param {Array} bookmarksToMark - Bookmark IDs to mark as duplicates * @param {Array} bookmarksToDelete - Bookmark IDs to delete * @param {string} strategy - Applied strategy */ executeDuplicateResolution(bookmarksToMark, bookmarksToDelete, strategy) { // Mark bookmarks as duplicates this.bookmarks.forEach(bookmark => { if (bookmarksToMark.includes(bookmark.id)) { bookmark.status = 'duplicate'; } }); // Delete bookmarks if not mark-only if (strategy !== 'mark_only' && bookmarksToDelete.length > 0) { const confirmMessage = `This will permanently delete ${bookmarksToDelete.length} duplicate bookmark${bookmarksToDelete.length > 1 ? 's' : ''}. Continue?`; if (confirm(confirmMessage)) { this.bookmarks = this.bookmarks.filter(bookmark => !bookmarksToDelete.includes(bookmark.id)); } else { return; // User cancelled } } // Save and update UI this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); // Hide modal and show success message this.hideModal('duplicateModal'); const totalProcessed = bookmarksToMark.length; const totalDeleted = bookmarksToDelete.length; if (strategy === 'mark_only') { alert(`Marked ${totalProcessed} bookmarks as duplicates.`); } else { alert(`Processed ${totalProcessed} duplicate bookmarks. ${totalDeleted} were removed.`); } } // Debounced search to reduce excessive filtering debouncedSearch(query) { // Clear existing timeout if (this.searchTimeout) { clearTimeout(this.searchTimeout); } // Set new timeout for 300ms delay this.searchTimeout = setTimeout(() => { this.searchBookmarks(query); }, 300); } searchBookmarks(query) { if (query.trim() === '') { // If search is empty, show bookmarks based on current filter this.renderBookmarks(this.getFilteredBookmarks()); } else { // Apply search to the currently filtered bookmarks, not all bookmarks const currentFilteredBookmarks = this.getFilteredBookmarks(); // Optimize search for large collections const lowerQuery = query.toLowerCase(); const searchResults = currentFilteredBookmarks.filter(bookmark => { // Use early return for better performance if (bookmark.title.toLowerCase().includes(lowerQuery)) return true; if (bookmark.url.toLowerCase().includes(lowerQuery)) return true; if (bookmark.folder && bookmark.folder.toLowerCase().includes(lowerQuery)) return true; // Search in tags if (bookmark.tags && bookmark.tags.some(tag => tag.toLowerCase().includes(lowerQuery))) return true; // Search in notes if (bookmark.notes && bookmark.notes.toLowerCase().includes(lowerQuery)) return true; return false; }); this.renderBookmarks(searchResults); } } applyFilter(filter) { this.currentFilter = filter; // Track the current filter let filteredBookmarks; switch (filter) { case 'all': filteredBookmarks = this.bookmarks; break; case 'valid': filteredBookmarks = this.bookmarks.filter(b => b.status === 'valid'); break; case 'invalid': filteredBookmarks = this.bookmarks.filter(b => b.status === 'invalid'); break; case 'duplicate': filteredBookmarks = this.bookmarks.filter(b => b.status === 'duplicate'); break; case 'favorite': filteredBookmarks = this.bookmarks.filter(b => b.favorite); break; default: filteredBookmarks = this.bookmarks; } this.renderBookmarks(filteredBookmarks); // Clear search input when applying filter document.getElementById('searchInput').value = ''; } // Helper method to get filtered bookmarks based on current filter getFilteredBookmarks() { switch (this.currentFilter) { case 'all': return this.bookmarks; case 'valid': return this.bookmarks.filter(b => b.status === 'valid'); case 'invalid': return this.bookmarks.filter(b => b.status === 'invalid'); case 'duplicate': return this.bookmarks.filter(b => b.status === 'duplicate'); default: return this.bookmarks; } } // Advanced Search Methods bindAdvancedSearchEvents() { // Advanced search form submission document.getElementById('advancedSearchForm').addEventListener('submit', (e) => { e.preventDefault(); this.performAdvancedSearch(); }); // Cancel advanced search document.getElementById('cancelAdvancedSearchBtn').addEventListener('click', () => { this.hideModal('advancedSearchModal'); }); // Clear advanced search form document.getElementById('clearAdvancedSearchBtn').addEventListener('click', () => { this.clearAdvancedSearchForm(); }); // Save search functionality document.getElementById('saveSearchBtn').addEventListener('click', () => { this.saveCurrentSearch(); }); // Date filter change handler document.getElementById('dateFilter').addEventListener('change', (e) => { const customDateRange = document.getElementById('customDateRange'); if (e.target.value === 'custom') { customDateRange.classList.add('active'); customDateRange.style.display = 'block'; } else { customDateRange.classList.remove('active'); customDateRange.style.display = 'none'; } }); } showAdvancedSearchModal() { // Populate folder dropdown this.populateAdvancedSearchFolders(); // Load search history and saved searches this.loadSearchHistory(); this.loadSavedSearches(); this.renderSearchHistory(); this.renderSavedSearches(); this.showModal('advancedSearchModal'); } populateAdvancedSearchFolders() { const folderSelect = document.getElementById('searchInFolder'); const uniqueFolders = [...new Set( this.bookmarks .map(bookmark => bookmark.folder) .filter(folder => folder && folder.trim() !== '') )].sort(); // Clear existing options except "All Folders" folderSelect.innerHTML = ''; // Add folder options uniqueFolders.forEach(folder => { const option = document.createElement('option'); option.value = folder; option.textContent = folder; folderSelect.appendChild(option); }); } performAdvancedSearch() { const searchCriteria = this.getAdvancedSearchCriteria(); // Add to search history this.addToSearchHistory(searchCriteria); // Perform the search const results = this.executeAdvancedSearch(searchCriteria); // Store current search for potential saving this.currentAdvancedSearch = searchCriteria; // Update UI with results this.renderBookmarks(results); this.hideModal('advancedSearchModal'); // Update main search input to show the query if (searchCriteria.query) { document.getElementById('searchInput').value = searchCriteria.query; } } getAdvancedSearchCriteria() { return { query: document.getElementById('advancedSearchQuery').value.trim(), folder: document.getElementById('searchInFolder').value, dateFilter: document.getElementById('dateFilter').value, dateFrom: document.getElementById('dateFrom').value, dateTo: document.getElementById('dateTo').value, status: document.getElementById('statusFilter').value, timestamp: Date.now() }; } executeAdvancedSearch(criteria) { let results = [...this.bookmarks]; // Apply text search if (criteria.query) { const lowerQuery = criteria.query.toLowerCase(); results = results.filter(bookmark => { return bookmark.title.toLowerCase().includes(lowerQuery) || bookmark.url.toLowerCase().includes(lowerQuery) || (bookmark.folder && bookmark.folder.toLowerCase().includes(lowerQuery)); }); } // Apply folder filter if (criteria.folder) { results = results.filter(bookmark => bookmark.folder === criteria.folder); } // Apply date filter if (criteria.dateFilter) { results = this.applyDateFilter(results, criteria); } // Apply status filter if (criteria.status) { results = results.filter(bookmark => bookmark.status === criteria.status); } return results; } applyDateFilter(bookmarks, criteria) { const now = new Date(); let startDate, endDate; switch (criteria.dateFilter) { case 'today': startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); endDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1); break; case 'week': const weekStart = new Date(now); weekStart.setDate(now.getDate() - now.getDay()); weekStart.setHours(0, 0, 0, 0); startDate = weekStart; endDate = new Date(weekStart); endDate.setDate(weekStart.getDate() + 7); break; case 'month': startDate = new Date(now.getFullYear(), now.getMonth(), 1); endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); break; case 'year': startDate = new Date(now.getFullYear(), 0, 1); endDate = new Date(now.getFullYear() + 1, 0, 1); break; case 'custom': if (criteria.dateFrom) { startDate = new Date(criteria.dateFrom); } if (criteria.dateTo) { endDate = new Date(criteria.dateTo); endDate.setDate(endDate.getDate() + 1); // Include the end date } break; default: return bookmarks; } return bookmarks.filter(bookmark => { const bookmarkDate = new Date(bookmark.addDate); const afterStart = !startDate || bookmarkDate >= startDate; const beforeEnd = !endDate || bookmarkDate < endDate; return afterStart && beforeEnd; }); } clearAdvancedSearchForm() { document.getElementById('advancedSearchQuery').value = ''; document.getElementById('searchInFolder').value = ''; document.getElementById('dateFilter').value = ''; document.getElementById('dateFrom').value = ''; document.getElementById('dateTo').value = ''; document.getElementById('statusFilter').value = ''; // Hide custom date range const customDateRange = document.getElementById('customDateRange'); customDateRange.classList.remove('active'); customDateRange.style.display = 'none'; } // Search History Management addToSearchHistory(criteria) { // Don't add empty searches if (!criteria.query && !criteria.folder && !criteria.dateFilter && !criteria.status) { return; } // Remove duplicate if exists this.searchHistory = this.searchHistory.filter(item => !this.areSearchCriteriaEqual(item, criteria) ); // Add to beginning of array this.searchHistory.unshift(criteria); // Limit history size if (this.searchHistory.length > this.maxSearchHistory) { this.searchHistory = this.searchHistory.slice(0, this.maxSearchHistory); } this.saveSearchHistory(); } areSearchCriteriaEqual(criteria1, criteria2) { return criteria1.query === criteria2.query && criteria1.folder === criteria2.folder && criteria1.dateFilter === criteria2.dateFilter && criteria1.dateFrom === criteria2.dateFrom && criteria1.dateTo === criteria2.dateTo && criteria1.status === criteria2.status; } loadSearchHistory() { try { const stored = localStorage.getItem('bookmarkSearchHistory'); this.searchHistory = stored ? JSON.parse(stored) : []; } catch (error) { console.error('Error loading search history:', error); this.searchHistory = []; } } saveSearchHistory() { try { localStorage.setItem('bookmarkSearchHistory', JSON.stringify(this.searchHistory)); } catch (error) { console.error('Error saving search history:', error); } } renderSearchHistory() { const container = document.getElementById('searchHistoryList'); if (this.searchHistory.length === 0) { container.innerHTML = '
No recent searches
'; return; } container.innerHTML = this.searchHistory.map((criteria, index) => { const description = this.getSearchDescription(criteria); const timeAgo = this.getTimeAgo(criteria.timestamp); return `
${description}
${timeAgo}
`; }).join(''); } getSearchDescription(criteria) { const parts = []; if (criteria.query) parts.push(`"${criteria.query}"`); if (criteria.folder) parts.push(`in ${criteria.folder}`); if (criteria.dateFilter) parts.push(`from ${criteria.dateFilter}`); if (criteria.status) parts.push(`status: ${criteria.status}`); return parts.length > 0 ? parts.join(', ') : 'Advanced search'; } getTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; return 'Just now'; } useSearchFromHistory(index) { const criteria = this.searchHistory[index]; this.populateAdvancedSearchForm(criteria); } deleteFromSearchHistory(index) { this.searchHistory.splice(index, 1); this.saveSearchHistory(); this.renderSearchHistory(); } populateAdvancedSearchForm(criteria) { document.getElementById('advancedSearchQuery').value = criteria.query || ''; document.getElementById('searchInFolder').value = criteria.folder || ''; document.getElementById('dateFilter').value = criteria.dateFilter || ''; document.getElementById('dateFrom').value = criteria.dateFrom || ''; document.getElementById('dateTo').value = criteria.dateTo || ''; document.getElementById('statusFilter').value = criteria.status || ''; // Show custom date range if needed const customDateRange = document.getElementById('customDateRange'); if (criteria.dateFilter === 'custom') { customDateRange.classList.add('active'); customDateRange.style.display = 'block'; } } // Saved Searches Management saveCurrentSearch() { const criteria = this.getAdvancedSearchCriteria(); // Don't save empty searches if (!criteria.query && !criteria.folder && !criteria.dateFilter && !criteria.status) { alert('Please enter some search criteria before saving.'); return; } const name = prompt('Enter a name for this saved search:'); if (!name || !name.trim()) { return; } const savedSearch = { id: Date.now() + Math.random(), name: name.trim(), criteria: criteria, createdAt: Date.now() }; this.savedSearches.push(savedSearch); this.saveSavedSearches(); this.renderSavedSearches(); } loadSavedSearches() { try { const stored = localStorage.getItem('bookmarkSavedSearches'); this.savedSearches = stored ? JSON.parse(stored) : []; } catch (error) { console.error('Error loading saved searches:', error); this.savedSearches = []; } } saveSavedSearches() { try { localStorage.setItem('bookmarkSavedSearches', JSON.stringify(this.savedSearches)); } catch (error) { console.error('Error saving saved searches:', error); } } renderSavedSearches() { const container = document.getElementById('savedSearchesList'); if (this.savedSearches.length === 0) { container.innerHTML = '
No saved searches
'; return; } container.innerHTML = this.savedSearches.map((search, index) => { const description = this.getSearchDescription(search.criteria); const createdDate = new Date(search.createdAt).toLocaleDateString(); return `
${search.name}
${description} • Created ${createdDate}
`; }).join(''); } useSavedSearch(searchId) { const savedSearch = this.savedSearches.find(s => s.id == searchId); if (savedSearch) { this.populateAdvancedSearchForm(savedSearch.criteria); } } deleteSavedSearch(searchId) { if (confirm('Are you sure you want to delete this saved search?')) { this.savedSearches = this.savedSearches.filter(s => s.id != searchId); this.saveSavedSearches(); this.renderSavedSearches(); } } // Search Suggestions updateSearchSuggestions(query) { if (!query || query.length < 2) { this.clearSearchSuggestions(); return; } const suggestions = this.generateSearchSuggestions(query); this.populateSearchSuggestions(suggestions); } generateSearchSuggestions(query) { const lowerQuery = query.toLowerCase(); const suggestions = new Set(); // Add matching titles this.bookmarks.forEach(bookmark => { if (bookmark.title.toLowerCase().includes(lowerQuery)) { suggestions.add(bookmark.title); } }); // Add matching folders this.bookmarks.forEach(bookmark => { if (bookmark.folder && bookmark.folder.toLowerCase().includes(lowerQuery)) { suggestions.add(bookmark.folder); } }); // Add from search history this.searchHistory.forEach(criteria => { if (criteria.query && criteria.query.toLowerCase().includes(lowerQuery)) { suggestions.add(criteria.query); } }); // Convert to array and limit return Array.from(suggestions).slice(0, this.maxSearchSuggestions); } populateSearchSuggestions(suggestions) { const datalist = document.getElementById('searchSuggestions'); datalist.innerHTML = ''; suggestions.forEach(suggestion => { const option = document.createElement('option'); option.value = suggestion; datalist.appendChild(option); }); } clearSearchSuggestions() { const datalist = document.getElementById('searchSuggestions'); datalist.innerHTML = ''; } // Method to move a bookmark to a different folder moveBookmarkToFolder(bookmarkId, targetFolder) { const bookmark = this.bookmarks.find(b => b.id === bookmarkId); if (!bookmark) { console.error('Bookmark not found:', bookmarkId); return; } const oldFolder = bookmark.folder || 'Uncategorized'; const newFolder = targetFolder || 'Uncategorized'; if (oldFolder === newFolder) { console.log('Bookmark is already in the target folder'); return; } // Update the bookmark's folder bookmark.folder = targetFolder; console.log(`Moved bookmark "${bookmark.title}" from "${oldFolder}" to "${newFolder}"`); // Save changes and refresh display this.saveBookmarksToStorage(); this.renderBookmarks(this.getFilteredBookmarks()); this.updateStats(); } renderBookmarks(bookmarksToRender = null) { // Show loading state for large operations if (this.isLoading) { this.showLoadingState(); return; } const bookmarksList = document.getElementById('bookmarksList'); const bookmarks = bookmarksToRender || this.bookmarks; // Show/hide bulk actions bar based on bulk mode const bulkActions = document.getElementById('bulkActions'); if (bulkActions) { bulkActions.style.display = this.bulkMode ? 'flex' : 'none'; if (this.bulkMode) { this.updateBulkFolderSelect(); this.updateBulkSelectionUI(); } } if (bookmarks.length === 0) { bookmarksList.innerHTML = `

No bookmarks found

Import your bookmarks or add new ones to get started.

`; return; } // For large collections, use requestAnimationFrame to prevent blocking if (bookmarks.length > this.virtualScrollThreshold) { this.renderLargeCollection(bookmarks); } else { this.renderNormalCollection(bookmarks); } } // Optimized rendering for large bookmark collections renderLargeCollection(bookmarks) { const bookmarksList = document.getElementById('bookmarksList'); // Show loading indicator this.showLoadingState(); // Use requestAnimationFrame to prevent UI blocking requestAnimationFrame(() => { // Group bookmarks by folder efficiently const { folders, noFolderBookmarks } = this.groupBookmarksByFolder(bookmarks); // Clear container with minimal reflow this.clearContainer(bookmarksList); // Skip pagination for folder-based view - folders provide natural organization // Pagination would be more appropriate for a flat list view // Render folders in batches to prevent blocking this.renderFoldersInBatches(bookmarksList, folders, noFolderBookmarks); }); } // Standard rendering for smaller collections renderNormalCollection(bookmarks) { const bookmarksList = document.getElementById('bookmarksList'); // Group bookmarks by folder const { folders, noFolderBookmarks } = this.groupBookmarksByFolder(bookmarks); // Clear the container efficiently this.clearContainer(bookmarksList); // Create folder cards const folderNames = Object.keys(folders).sort(); // Add "No Folder" card if there are bookmarks without folders if (noFolderBookmarks.length > 0) { this.createFolderCard(bookmarksList, 'Uncategorized', noFolderBookmarks, true); } // Add folder cards folderNames.forEach(folderName => { this.createFolderCard(bookmarksList, folderName, folders[folderName], false); }); } // Efficiently group bookmarks by folder groupBookmarksByFolder(bookmarks) { const folders = {}; const noFolderBookmarks = []; bookmarks.forEach(bookmark => { if (bookmark.folder && bookmark.folder.trim()) { if (!folders[bookmark.folder]) { folders[bookmark.folder] = []; } folders[bookmark.folder].push(bookmark); } else { noFolderBookmarks.push(bookmark); } }); return { folders, noFolderBookmarks }; } // Clear container with minimal reflow clearContainer(container) { // Use more efficient innerHTML clearing for better performance container.innerHTML = ''; // Alternative approach using DocumentFragment for very large collections // This prevents multiple reflows during clearing if (container.children.length > this.virtualScrollThreshold) { const fragment = document.createDocumentFragment(); while (container.firstChild) { fragment.appendChild(container.firstChild); } // Fragment will be garbage collected automatically } } // Show loading state for operations showLoadingState() { const bookmarksList = document.getElementById('bookmarksList'); bookmarksList.innerHTML = `

Loading bookmarks...

Please wait while we organize your bookmark collection.

`; } // Add pagination controls for very large collections addPaginationControls(container, bookmarks) { const totalPages = Math.ceil(bookmarks.length / this.itemsPerPage); if (totalPages <= 1) return; const paginationDiv = document.createElement('div'); paginationDiv.className = 'pagination-controls'; paginationDiv.innerHTML = `
Showing page ${this.currentPage} of ${totalPages} (${bookmarks.length} total bookmarks)
Page ${this.currentPage} of ${totalPages}
`; // Add event listeners for pagination const prevBtn = paginationDiv.querySelector('#prevPage'); const nextBtn = paginationDiv.querySelector('#nextPage'); prevBtn?.addEventListener('click', () => { if (this.currentPage > 1) { this.currentPage--; this.renderBookmarks(bookmarks); } }); nextBtn?.addEventListener('click', () => { if (this.currentPage < totalPages) { this.currentPage++; this.renderBookmarks(bookmarks); } }); container.appendChild(paginationDiv); } // Render folders in batches to prevent UI blocking renderFoldersInBatches(container, folders, noFolderBookmarks) { const folderNames = Object.keys(folders).sort(); let currentIndex = 0; const batchSize = 5; // Process 5 folders at a time const renderBatch = () => { const endIndex = Math.min(currentIndex + batchSize, folderNames.length + (noFolderBookmarks.length > 0 ? 1 : 0)); // Add "No Folder" card first if needed if (currentIndex === 0 && noFolderBookmarks.length > 0) { this.createFolderCard(container, 'Uncategorized', noFolderBookmarks, true); currentIndex++; } // Add folder cards in current batch while (currentIndex <= endIndex && currentIndex - (noFolderBookmarks.length > 0 ? 1 : 0) < folderNames.length) { const folderIndex = currentIndex - (noFolderBookmarks.length > 0 ? 1 : 0); if (folderIndex >= 0 && folderIndex < folderNames.length) { const folderName = folderNames[folderIndex]; this.createFolderCard(container, folderName, folders[folderName], false); } currentIndex++; } // Continue with next batch if there are more folders if (currentIndex < folderNames.length + (noFolderBookmarks.length > 0 ? 1 : 0)) { requestAnimationFrame(renderBatch); } else { // Hide loading state when done this.hideLoadingState(); } }; renderBatch(); } // Hide loading state hideLoadingState() { // Loading state is automatically hidden when content is rendered // This method can be used for cleanup if needed } createFolderCard(container, folderName, bookmarks, isNoFolder = false) { // Use DocumentFragment for efficient DOM construction const fragment = document.createDocumentFragment(); const folderCard = document.createElement('div'); folderCard.className = 'folder-card'; folderCard.setAttribute('data-folder-name', folderName); // Make folder card a drop zone folderCard.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; folderCard.classList.add('drag-over'); }); folderCard.addEventListener('dragleave', (e) => { // Only remove highlight if we're actually leaving the card if (!folderCard.contains(e.relatedTarget)) { folderCard.classList.remove('drag-over'); } }); folderCard.addEventListener('drop', (e) => { e.preventDefault(); folderCard.classList.remove('drag-over'); const bookmarkId = parseFloat(e.dataTransfer.getData('text/plain')); const targetFolder = isNoFolder ? '' : folderName; this.moveBookmarkToFolder(bookmarkId, targetFolder); }); // Folder header const folderHeader = document.createElement('div'); folderHeader.className = `folder-header ${isNoFolder ? 'no-folder' : ''}`; const folderTitle = document.createElement('h3'); folderTitle.className = 'folder-title'; folderTitle.textContent = folderName; const folderCount = document.createElement('span'); folderCount.className = 'folder-count'; folderCount.textContent = bookmarks.length; folderTitle.appendChild(folderCount); // Folder stats const folderStats = document.createElement('div'); folderStats.className = 'folder-stats'; const validCount = bookmarks.filter(b => b.status === 'valid').length; const invalidCount = bookmarks.filter(b => b.status === 'invalid').length; const duplicateCount = bookmarks.filter(b => b.status === 'duplicate').length; if (validCount > 0) { const validSpan = document.createElement('span'); validSpan.textContent = `✓ ${validCount} valid`; folderStats.appendChild(validSpan); } if (invalidCount > 0) { const invalidSpan = document.createElement('span'); invalidSpan.textContent = `✗ ${invalidCount} invalid`; folderStats.appendChild(invalidSpan); } if (duplicateCount > 0) { const duplicateSpan = document.createElement('span'); duplicateSpan.textContent = `⚠ ${duplicateCount} duplicates`; folderStats.appendChild(duplicateSpan); } folderHeader.appendChild(folderTitle); folderHeader.appendChild(folderStats); // Bookmark list const bookmarkList = document.createElement('div'); bookmarkList.className = 'bookmark-list'; bookmarks.forEach(bookmark => { const bookmarkItem = this.createBookmarkItem(bookmark); bookmarkList.appendChild(bookmarkItem); }); folderCard.appendChild(folderHeader); folderCard.appendChild(bookmarkList); // Use DocumentFragment to minimize reflows when adding to container fragment.appendChild(folderCard); container.appendChild(fragment); // Add touch listeners for mobile devices after rendering if (this.isMobileDevice()) { this.addTouchListenersToBookmarks(); } } createBookmarkItem(bookmark) { const bookmarkItem = document.createElement('div'); bookmarkItem.className = 'bookmark-item'; bookmarkItem.setAttribute('role', 'button'); bookmarkItem.setAttribute('tabindex', '0'); bookmarkItem.setAttribute('data-bookmark-id', bookmark.id); bookmarkItem.setAttribute('aria-label', `Bookmark: ${bookmark.title}. URL: ${bookmark.url}. Status: ${bookmark.status}. Press Enter or Space to open actions menu.`); // Create a clickable link that contains the bookmark info const bookmarkLink = document.createElement('div'); bookmarkLink.className = 'bookmark-link'; // Add click handler to show context menu bookmarkLink.addEventListener('click', (e) => { e.preventDefault(); this.showContextMenu(bookmark); }); // Add keyboard navigation support bookmarkItem.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); this.showContextMenu(bookmark); } }); // Favicon const favicon = document.createElement('img'); favicon.className = 'bookmark-favicon'; // Use the imported favicon if available, otherwise use default icon if (bookmark.icon && bookmark.icon.trim() !== '') { favicon.src = bookmark.icon; } else { // Skip Google favicon service to avoid 404s - use default icon directly favicon.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4='; } favicon.alt = 'Favicon'; favicon.onerror = function () { // Silently fall back to default icon without console errors this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4='; // Remove the onerror handler to prevent further attempts this.onerror = null; }; // Bookmark info container const bookmarkInfo = document.createElement('div'); bookmarkInfo.className = 'bookmark-info'; const title = document.createElement('div'); title.className = 'bookmark-title'; title.textContent = bookmark.title; const url = document.createElement('div'); url.className = 'bookmark-url'; url.textContent = bookmark.url; bookmarkInfo.appendChild(title); bookmarkInfo.appendChild(url); // Add tags display if (bookmark.tags && bookmark.tags.length > 0) { const tagsContainer = document.createElement('div'); tagsContainer.className = 'bookmark-tags'; bookmark.tags.forEach(tag => { const tagElement = document.createElement('span'); tagElement.className = 'bookmark-tag'; tagElement.textContent = tag; tagsContainer.appendChild(tagElement); }); bookmarkInfo.appendChild(tagsContainer); } // Add rating and favorite display if ((bookmark.rating && bookmark.rating > 0) || bookmark.favorite) { const ratingContainer = document.createElement('div'); ratingContainer.className = 'bookmark-rating'; if (bookmark.rating && bookmark.rating > 0) { const starsElement = document.createElement('span'); starsElement.className = 'bookmark-stars'; starsElement.textContent = '★'.repeat(bookmark.rating); starsElement.title = `Rating: ${bookmark.rating}/5 stars`; ratingContainer.appendChild(starsElement); } if (bookmark.favorite) { const favoriteElement = document.createElement('span'); favoriteElement.className = 'bookmark-favorite'; favoriteElement.textContent = '❤️'; favoriteElement.title = 'Favorite bookmark'; ratingContainer.appendChild(favoriteElement); } bookmarkInfo.appendChild(ratingContainer); } // Add notes display if (bookmark.notes && bookmark.notes.trim()) { const notesElement = document.createElement('div'); notesElement.className = 'bookmark-notes'; notesElement.textContent = bookmark.notes; notesElement.title = bookmark.notes; bookmarkInfo.appendChild(notesElement); } // Add last visited information if (bookmark.lastVisited) { const lastVisited = document.createElement('div'); lastVisited.className = 'bookmark-last-visited'; lastVisited.textContent = `Last visited: ${this.formatRelativeTime(bookmark.lastVisited)}`; bookmarkInfo.appendChild(lastVisited); } // Add error category information for invalid bookmarks if (bookmark.status === 'invalid' && bookmark.errorCategory) { const errorCategory = document.createElement('div'); errorCategory.className = `bookmark-error-category error-${bookmark.errorCategory}`; errorCategory.textContent = this.formatErrorCategory(bookmark.errorCategory); bookmarkInfo.appendChild(errorCategory); } // Add last tested information if available if (bookmark.lastTested) { const lastTested = document.createElement('div'); lastTested.className = 'bookmark-last-tested'; lastTested.textContent = `Last tested: ${this.formatRelativeTime(bookmark.lastTested)}`; bookmarkInfo.appendChild(lastTested); } // Status indicator with icon const status = document.createElement('div'); status.className = `bookmark-status status-${bookmark.status}`; // Set icon and title based on status let statusTitle = ''; switch (bookmark.status) { case 'valid': status.innerHTML = '✓'; statusTitle = 'Valid link'; break; case 'invalid': status.innerHTML = '✗'; statusTitle = bookmark.errorCategory ? `Invalid link (${this.formatErrorCategory(bookmark.errorCategory)})` : 'Invalid link'; break; case 'testing': status.innerHTML = '⟳'; statusTitle = 'Testing link...'; break; case 'duplicate': status.innerHTML = '⚠'; statusTitle = 'Duplicate link'; break; default: status.innerHTML = '?'; statusTitle = 'Unknown status'; } status.title = statusTitle; status.setAttribute('aria-label', statusTitle); // Assemble the bookmark link bookmarkLink.appendChild(favicon); bookmarkLink.appendChild(bookmarkInfo); // Make bookmark item draggable bookmarkItem.draggable = true; bookmarkItem.setAttribute('data-bookmark-id', bookmark.id); // Add drag event listeners bookmarkItem.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/plain', bookmark.id); e.dataTransfer.effectAllowed = 'move'; bookmarkItem.classList.add('dragging'); }); bookmarkItem.addEventListener('dragend', (e) => { bookmarkItem.classList.remove('dragging'); }); // Drag handle removed - users can drag from anywhere on the bookmark item // Add bulk selection checkbox if in bulk mode if (this.bulkMode) { const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'bookmark-checkbox'; checkbox.checked = this.bulkSelection.has(bookmark.id); checkbox.setAttribute('aria-label', `Select bookmark: ${bookmark.title}`); checkbox.addEventListener('change', (e) => { e.stopPropagation(); this.toggleBookmarkSelection(bookmark.id); }); bookmarkItem.appendChild(checkbox); bookmarkItem.classList.toggle('bulk-selected', this.bulkSelection.has(bookmark.id)); } // Add privacy and security indicators const privacyIndicators = document.createElement('div'); privacyIndicators.className = 'bookmark-privacy-indicators'; if (this.isBookmarkPrivate(bookmark.id)) { const privateIndicator = document.createElement('div'); privateIndicator.className = 'bookmark-privacy-indicator private'; privateIndicator.innerHTML = '🔒'; privateIndicator.title = 'Private bookmark (excluded from exports/sharing)'; privacyIndicators.appendChild(privateIndicator); bookmarkItem.classList.add('private'); } if (this.isBookmarkEncrypted(bookmark.id)) { const encryptedIndicator = document.createElement('div'); encryptedIndicator.className = 'bookmark-privacy-indicator encrypted'; encryptedIndicator.innerHTML = '🔐'; encryptedIndicator.title = 'Encrypted bookmark'; privacyIndicators.appendChild(encryptedIndicator); bookmarkItem.classList.add('encrypted'); } // Assemble the bookmark item bookmarkItem.appendChild(bookmarkLink); bookmarkItem.appendChild(status); if (privacyIndicators.children.length > 0) { bookmarkItem.appendChild(privacyIndicators); } return bookmarkItem; } showContextMenu(bookmark) { this.currentContextBookmark = bookmark; // Update context modal content const contextFavicon = document.getElementById('contextFavicon'); // Use the same favicon logic as in createBookmarkItem if (bookmark.icon && bookmark.icon.trim() !== '') { contextFavicon.src = bookmark.icon; } else { // Try to get favicon from the domain try { const url = new URL(bookmark.url); contextFavicon.src = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`; } catch (error) { contextFavicon.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4='; } } // Add error handling for favicon contextFavicon.onerror = function () { if (this.src !== 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=') { try { const url = new URL(bookmark.url); this.src = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`; } catch (error) { this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4='; } } else { this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4='; } }; document.getElementById('contextBookmarkTitle').textContent = bookmark.title; // Set up the clickable link const contextLink = document.getElementById('contextBookmarkLink'); contextLink.href = bookmark.url; contextLink.textContent = bookmark.url; // Also keep the non-clickable URL for display consistency document.getElementById('contextBookmarkUrl').textContent = bookmark.url; const folderElement = document.getElementById('contextBookmarkFolder'); if (bookmark.folder) { folderElement.textContent = bookmark.folder; folderElement.style.display = 'inline-block'; } else { folderElement.style.display = 'none'; } // Display date information const addDateElement = document.getElementById('contextAddDate'); const lastModifiedElement = document.getElementById('contextLastModified'); if (bookmark.addDate) { const addDate = new Date(bookmark.addDate); addDateElement.textContent = `Added: ${addDate.toLocaleDateString()} ${addDate.toLocaleTimeString()}`; addDateElement.style.display = 'block'; } else { addDateElement.style.display = 'none'; } if (bookmark.lastModified) { const lastModified = new Date(bookmark.lastModified); lastModifiedElement.textContent = `Modified: ${lastModified.toLocaleDateString()} ${lastModified.toLocaleTimeString()}`; lastModifiedElement.style.display = 'block'; } else { lastModifiedElement.style.display = 'none'; } const statusElement = document.getElementById('contextBookmarkStatus'); statusElement.className = `bookmark-status status-${bookmark.status}`; // Set status icon in the context modal too switch (bookmark.status) { case 'valid': statusElement.innerHTML = '✓'; statusElement.title = 'Valid link'; break; case 'invalid': statusElement.innerHTML = '✗'; statusElement.title = 'Invalid link'; break; case 'testing': statusElement.innerHTML = '⟳'; statusElement.title = 'Testing link...'; break; case 'duplicate': statusElement.innerHTML = '⚠'; statusElement.title = 'Duplicate link'; break; default: statusElement.innerHTML = '?'; statusElement.title = 'Unknown status'; } // Display last visited information const lastVisitedElement = document.getElementById('contextLastVisited'); if (bookmark.lastVisited) { const lastVisited = new Date(bookmark.lastVisited); lastVisitedElement.textContent = `Last visited: ${lastVisited.toLocaleDateString()} ${lastVisited.toLocaleTimeString()}`; lastVisitedElement.style.display = 'block'; } else { lastVisitedElement.style.display = 'none'; } // Display metadata const metadataContainer = document.getElementById('contextBookmarkMetadata'); let hasMetadata = false; // Display tags const tagsContainer = document.getElementById('contextBookmarkTags'); const tagsDisplay = document.getElementById('contextTagsDisplay'); if (bookmark.tags && bookmark.tags.length > 0) { tagsDisplay.innerHTML = ''; bookmark.tags.forEach(tag => { const tagElement = document.createElement('span'); tagElement.className = 'bookmark-tag'; tagElement.textContent = tag; tagsDisplay.appendChild(tagElement); }); tagsContainer.style.display = 'block'; hasMetadata = true; } else { tagsContainer.style.display = 'none'; } // Display rating and favorite const ratingContainer = document.getElementById('contextBookmarkRating'); const starsDisplay = document.getElementById('contextStarsDisplay'); const favoriteDisplay = document.getElementById('contextFavoriteDisplay'); if (bookmark.rating && bookmark.rating > 0) { starsDisplay.textContent = '★'.repeat(bookmark.rating) + '☆'.repeat(5 - bookmark.rating); ratingContainer.style.display = 'block'; hasMetadata = true; } else { starsDisplay.textContent = ''; } if (bookmark.favorite) { favoriteDisplay.style.display = 'inline'; ratingContainer.style.display = 'block'; hasMetadata = true; } else { favoriteDisplay.style.display = 'none'; } if (!bookmark.rating && !bookmark.favorite) { ratingContainer.style.display = 'none'; } // Display notes const notesContainer = document.getElementById('contextBookmarkNotes'); const notesDisplay = document.getElementById('contextNotesDisplay'); if (bookmark.notes && bookmark.notes.trim()) { notesDisplay.textContent = bookmark.notes; notesContainer.style.display = 'block'; hasMetadata = true; } else { notesContainer.style.display = 'none'; } // Show/hide metadata container metadataContainer.style.display = hasMetadata ? 'block' : 'none'; // Update privacy controls this.updateContextModalPrivacyControls(bookmark); // Show the context modal with custom focus handling this.showContextModal(); } // Custom method to show context modal with proper focus handling showContextModal() { const modal = document.getElementById('contextModal'); if (modal) { modal.style.display = 'block'; modal.setAttribute('aria-hidden', 'false'); // Remove focus from close button and focus on Visit button instead const closeBtn = modal.querySelector('.close'); if (closeBtn) { closeBtn.setAttribute('tabindex', '-1'); } // Focus on the Visit button after a small delay to ensure modal is rendered setTimeout(() => { const visitBtn = modal.querySelector('#visitBookmarkBtn'); if (visitBtn) { visitBtn.focus(); } else { // Fallback to first action button if Visit button not found const actionButtons = modal.querySelectorAll('.modal-actions button'); if (actionButtons.length > 0) { actionButtons[0].focus(); } } }, 50); // Store the previously focused element to restore later modal.dataset.previousFocus = document.activeElement.id || ''; } } // Update privacy controls in context modal updateContextModalPrivacyControls(bookmark) { const privacyToggle = document.getElementById('contextPrivacyToggle'); const encryptionToggle = document.getElementById('contextEncryptionToggle'); if (privacyToggle) { privacyToggle.checked = this.isBookmarkPrivate(bookmark.id); } if (encryptionToggle) { encryptionToggle.checked = this.isBookmarkEncrypted(bookmark.id); } } updateStats() { const total = this.bookmarks.length; const valid = this.bookmarks.filter(b => b.status === 'valid').length; const invalid = this.bookmarks.filter(b => b.status === 'invalid').length; const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length; const favorites = this.bookmarks.filter(b => b.favorite).length; document.getElementById('totalCount').textContent = `Total: ${total}`; document.getElementById('validCount').textContent = `Valid: ${valid}`; document.getElementById('invalidCount').textContent = `Invalid: ${invalid}`; document.getElementById('duplicateCount').textContent = `Duplicates: ${duplicates}`; document.getElementById('favoriteCount').textContent = `Favorites: ${favorites}`; } // Analytics Dashboard Methods showAnalyticsModal() { this.showModal('analyticsModal'); this.initializeAnalytics(); } initializeAnalytics() { // Initialize analytics tabs this.bindAnalyticsTabEvents(); // Load overview tab by default this.loadOverviewAnalytics(); } bindAnalyticsTabEvents() { const tabs = document.querySelectorAll('.analytics-tab'); const tabContents = document.querySelectorAll('.analytics-tab-content'); tabs.forEach(tab => { tab.addEventListener('click', () => { // Remove active class from all tabs and contents tabs.forEach(t => t.classList.remove('active')); tabContents.forEach(content => content.classList.remove('active')); // Add active class to clicked tab tab.classList.add('active'); // Show corresponding content const tabName = tab.getAttribute('data-tab'); const content = document.getElementById(`${tabName}Tab`); if (content) { content.classList.add('active'); this.loadTabAnalytics(tabName); } }); }); // Trends timeframe selector const trendsTimeframe = document.getElementById('trendsTimeframe'); if (trendsTimeframe) { trendsTimeframe.addEventListener('change', () => { this.loadTrendsAnalytics(); }); } } loadTabAnalytics(tabName) { switch (tabName) { case 'overview': this.loadOverviewAnalytics(); break; case 'trends': this.loadTrendsAnalytics(); break; case 'health': this.loadHealthAnalytics(); break; case 'usage': this.loadUsageAnalytics(); break; } } loadOverviewAnalytics() { // Update summary cards const total = this.bookmarks.length; const valid = this.bookmarks.filter(b => b.status === 'valid').length; const invalid = this.bookmarks.filter(b => b.status === 'invalid').length; const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length; document.getElementById('totalBookmarksCount').textContent = total; document.getElementById('validLinksCount').textContent = valid; document.getElementById('invalidLinksCount').textContent = invalid; document.getElementById('duplicatesCount').textContent = duplicates; // Create charts this.createStatusChart(); this.createFoldersChart(); } createStatusChart() { const canvas = document.getElementById('statusChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const total = this.bookmarks.length; if (total === 0) { this.drawEmptyChart(ctx, canvas, 'No bookmarks to analyze'); return; } const valid = this.bookmarks.filter(b => b.status === 'valid').length; const invalid = this.bookmarks.filter(b => b.status === 'invalid').length; const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length; const unknown = this.bookmarks.filter(b => b.status === 'unknown').length; const data = [ { label: 'Valid', value: valid, color: '#28a745' }, { label: 'Invalid', value: invalid, color: '#dc3545' }, { label: 'Duplicates', value: duplicates, color: '#17a2b8' }, { label: 'Unknown', value: unknown, color: '#6c757d' } ].filter(item => item.value > 0); this.drawPieChart(ctx, canvas, data, 'Status Distribution'); } createFoldersChart() { const canvas = document.getElementById('foldersChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const folderStats = this.getFolderStats(); if (Object.keys(folderStats).length === 0) { this.drawEmptyChart(ctx, canvas, 'No folders to analyze'); return; } const data = Object.entries(folderStats) .map(([folder, stats]) => ({ label: folder || 'Uncategorized', value: stats.total, color: this.generateFolderColor(folder) })) .sort((a, b) => b.value - a.value) .slice(0, 10); // Top 10 folders this.drawBarChart(ctx, canvas, data, 'Top Folders by Bookmark Count'); } loadTrendsAnalytics() { const timeframe = parseInt(document.getElementById('trendsTimeframe').value); const endDate = new Date(); const startDate = new Date(endDate.getTime() - (timeframe * 24 * 60 * 60 * 1000)); this.createTrendsChart(startDate, endDate); this.createTestingTrendsChart(startDate, endDate); } createTrendsChart(startDate, endDate) { const canvas = document.getElementById('trendsChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Group bookmarks by date const bookmarksByDate = {}; this.bookmarks.forEach(bookmark => { const date = new Date(bookmark.addDate); if (date >= startDate && date <= endDate) { const dateKey = date.toISOString().split('T')[0]; bookmarksByDate[dateKey] = (bookmarksByDate[dateKey] || 0) + 1; } }); const data = this.generateDateRange(startDate, endDate).map(date => ({ label: date, value: bookmarksByDate[date] || 0 })); this.drawLineChart(ctx, canvas, data, 'Bookmarks Added Over Time', '#3498db'); } createTestingTrendsChart(startDate, endDate) { const canvas = document.getElementById('testingTrendsChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Group test results by date const testsByDate = {}; this.bookmarks.forEach(bookmark => { if (bookmark.lastTested) { const date = new Date(bookmark.lastTested); if (date >= startDate && date <= endDate) { const dateKey = date.toISOString().split('T')[0]; if (!testsByDate[dateKey]) { testsByDate[dateKey] = { valid: 0, invalid: 0 }; } if (bookmark.status === 'valid') { testsByDate[dateKey].valid++; } else if (bookmark.status === 'invalid') { testsByDate[dateKey].invalid++; } } } }); const validData = this.generateDateRange(startDate, endDate).map(date => ({ label: date, value: testsByDate[date]?.valid || 0 })); const invalidData = this.generateDateRange(startDate, endDate).map(date => ({ label: date, value: testsByDate[date]?.invalid || 0 })); this.drawMultiLineChart(ctx, canvas, [ { data: validData, color: '#28a745', label: 'Valid' }, { data: invalidData, color: '#dc3545', label: 'Invalid' } ], 'Link Testing Results Over Time'); } loadHealthAnalytics() { const healthData = this.calculateHealthMetrics(); // Update health score document.getElementById('healthScore').textContent = `${healthData.score}%`; // Update last full test document.getElementById('lastFullTest').textContent = healthData.lastFullTest; // Update health issues this.displayHealthIssues(healthData.issues); // Update recommendations this.displayHealthRecommendations(healthData.recommendations); } calculateHealthMetrics() { const total = this.bookmarks.length; if (total === 0) { return { score: 100, lastFullTest: 'Never', issues: [], recommendations: [] }; } const valid = this.bookmarks.filter(b => b.status === 'valid').length; const invalid = this.bookmarks.filter(b => b.status === 'invalid').length; const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length; const unknown = this.bookmarks.filter(b => b.status === 'unknown').length; // Calculate health score (0-100) let score = 100; score -= (invalid / total) * 40; // Invalid links reduce score by up to 40% score -= (duplicates / total) * 30; // Duplicates reduce score by up to 30% score -= (unknown / total) * 20; // Unknown status reduces score by up to 20% score = Math.max(0, Math.round(score)); // Find last full test const testedBookmarksWithDates = this.bookmarks .filter(b => b.lastTested) .map(b => b.lastTested) .sort((a, b) => b - a); const lastFullTest = testedBookmarksWithDates.length > 0 ? new Date(testedBookmarksWithDates[0]).toLocaleDateString() : 'Never'; // Identify issues const issues = []; if (invalid > 0) { issues.push({ type: 'error', title: 'Broken Links Found', description: `${invalid} bookmark${invalid > 1 ? 's have' : ' has'} invalid links that need attention.`, count: invalid }); } if (duplicates > 0) { issues.push({ type: 'warning', title: 'Duplicate Bookmarks', description: `${duplicates} duplicate bookmark${duplicates > 1 ? 's were' : ' was'} found in your collection.`, count: duplicates }); } if (unknown > 0) { issues.push({ type: 'info', title: 'Untested Links', description: `${unknown} bookmark${unknown > 1 ? 's have' : ' has'} not been tested yet.`, count: unknown }); } // Old bookmarks (older than 2 years) const twoYearsAgo = Date.now() - (2 * 365 * 24 * 60 * 60 * 1000); const oldBookmarks = this.bookmarks.filter(b => b.addDate < twoYearsAgo).length; if (oldBookmarks > 0) { issues.push({ type: 'info', title: 'Old Bookmarks', description: `${oldBookmarks} bookmark${oldBookmarks > 1 ? 's are' : ' is'} older than 2 years and might need review.`, count: oldBookmarks }); } // Generate recommendations const recommendations = []; if (invalid > 0) { recommendations.push({ title: 'Fix Broken Links', description: 'Review and update or remove bookmarks with invalid links to improve your collection quality.' }); } if (duplicates > 0) { recommendations.push({ title: 'Remove Duplicates', description: 'Use the duplicate detection feature to clean up redundant bookmarks and organize your collection better.' }); } if (unknown > total * 0.5) { recommendations.push({ title: 'Test Your Links', description: 'Run a full link test to verify the status of your bookmarks and identify any issues.' }); } if (score < 70) { recommendations.push({ title: 'Regular Maintenance', description: 'Consider setting up a regular schedule to review and maintain your bookmark collection.' }); } return { score, lastFullTest, issues, recommendations }; } displayHealthIssues(issues) { const container = document.getElementById('healthIssuesList'); if (!container) return; if (issues.length === 0) { container.innerHTML = '
No Issues Found
Your bookmark collection is in good health!
'; return; } container.innerHTML = issues.map(issue => `
${issue.title} ${issue.count}
${issue.description}
`).join(''); } displayHealthRecommendations(recommendations) { const container = document.getElementById('healthRecommendations'); if (!container) return; if (recommendations.length === 0) { container.innerHTML = '
Great Job!
Your bookmark collection is well-maintained.
'; return; } container.innerHTML = recommendations.map(rec => `
${rec.title}
${rec.description}
`).join(''); } loadUsageAnalytics() { const usageData = this.calculateUsageMetrics(); // Update usage stats document.getElementById('mostActiveFolder').textContent = usageData.mostActiveFolder; document.getElementById('averageRating').textContent = usageData.averageRating; document.getElementById('mostVisited').textContent = usageData.mostVisited; // Create charts this.createTopFoldersChart(); this.createRatingsChart(); } calculateUsageMetrics() { const folderStats = this.getFolderStats(); // Find most active folder let mostActiveFolder = 'None'; let maxCount = 0; Object.entries(folderStats).forEach(([folder, stats]) => { if (stats.total > maxCount) { maxCount = stats.total; mostActiveFolder = folder || 'Uncategorized'; } }); // Calculate average rating const ratedBookmarks = this.bookmarks.filter(b => b.rating && b.rating > 0); const averageRating = ratedBookmarks.length > 0 ? (ratedBookmarks.reduce((sum, b) => sum + b.rating, 0) / ratedBookmarks.length).toFixed(1) + '/5' : 'No ratings'; // Find most visited bookmark const visitedBookmarks = this.bookmarks.filter(b => b.visitCount && b.visitCount > 0); let mostVisited = 'None'; if (visitedBookmarks.length > 0) { const mostVisitedBookmark = visitedBookmarks.reduce((max, b) => b.visitCount > (max.visitCount || 0) ? b : max ); mostVisited = mostVisitedBookmark.title; } return { mostActiveFolder, averageRating, mostVisited }; } createTopFoldersChart() { const canvas = document.getElementById('topFoldersChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); const folderStats = this.getFolderStats(); const data = Object.entries(folderStats) .map(([folder, stats]) => ({ label: folder || 'Uncategorized', value: stats.total, color: this.generateFolderColor(folder) })) .sort((a, b) => b.value - a.value) .slice(0, 10); this.drawBarChart(ctx, canvas, data, 'Top Folders by Bookmark Count'); } createRatingsChart() { const canvas = document.getElementById('ratingsChart'); if (!canvas) return; const ctx = canvas.getContext('2d'); // Count ratings const ratingCounts = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 }; this.bookmarks.forEach(bookmark => { if (bookmark.rating && bookmark.rating >= 1 && bookmark.rating <= 5) { ratingCounts[bookmark.rating]++; } }); const data = Object.entries(ratingCounts).map(([rating, count]) => ({ label: `${rating} Star${rating > 1 ? 's' : ''}`, value: count, color: this.generateRatingColor(parseInt(rating)) })); this.drawBarChart(ctx, canvas, data, 'Rating Distribution'); } // Chart drawing utilities drawPieChart(ctx, canvas, data, title) { const centerX = canvas.width / 2; const centerY = canvas.height / 2; const radius = Math.min(centerX, centerY) - 40; ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw title ctx.fillStyle = '#2c3e50'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.fillText(title, centerX, 25); if (data.length === 0) { this.drawEmptyChart(ctx, canvas, 'No data available'); return; } const total = data.reduce((sum, item) => sum + item.value, 0); let currentAngle = -Math.PI / 2; // Draw pie slices data.forEach(item => { const sliceAngle = (item.value / total) * 2 * Math.PI; ctx.beginPath(); ctx.moveTo(centerX, centerY); ctx.arc(centerX, centerY, radius, currentAngle, currentAngle + sliceAngle); ctx.closePath(); ctx.fillStyle = item.color; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke(); currentAngle += sliceAngle; }); // Draw legend this.drawLegend(ctx, canvas, data, centerX + radius + 20, centerY - (data.length * 15) / 2); } drawBarChart(ctx, canvas, data, title) { ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw title ctx.fillStyle = '#2c3e50'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.fillText(title, canvas.width / 2, 25); if (data.length === 0) { this.drawEmptyChart(ctx, canvas, 'No data available'); return; } const margin = 60; const chartWidth = canvas.width - 2 * margin; const chartHeight = canvas.height - 80; const barWidth = chartWidth / data.length - 10; const maxValue = Math.max(...data.map(d => d.value)); data.forEach((item, index) => { const barHeight = (item.value / maxValue) * chartHeight; const x = margin + index * (barWidth + 10); const y = canvas.height - margin - barHeight; // Draw bar ctx.fillStyle = item.color; ctx.fillRect(x, y, barWidth, barHeight); // Draw value on top of bar ctx.fillStyle = '#2c3e50'; ctx.font = '12px Arial'; ctx.textAlign = 'center'; ctx.fillText(item.value.toString(), x + barWidth / 2, y - 5); // Draw label ctx.save(); ctx.translate(x + barWidth / 2, canvas.height - margin + 15); ctx.rotate(-Math.PI / 4); ctx.textAlign = 'right'; ctx.fillText(item.label, 0, 0); ctx.restore(); }); } drawLineChart(ctx, canvas, data, title, color) { ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw title ctx.fillStyle = '#2c3e50'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.fillText(title, canvas.width / 2, 25); if (data.length === 0) { this.drawEmptyChart(ctx, canvas, 'No data available'); return; } const margin = 60; const chartWidth = canvas.width - 2 * margin; const chartHeight = canvas.height - 80; const maxValue = Math.max(...data.map(d => d.value), 1); // Draw axes ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(margin, margin); ctx.lineTo(margin, canvas.height - margin); ctx.lineTo(canvas.width - margin, canvas.height - margin); ctx.stroke(); // Draw line ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); data.forEach((point, index) => { const x = margin + (index / (data.length - 1)) * chartWidth; const y = canvas.height - margin - (point.value / maxValue) * chartHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // Draw points ctx.fillStyle = color; data.forEach((point, index) => { const x = margin + (index / (data.length - 1)) * chartWidth; const y = canvas.height - margin - (point.value / maxValue) * chartHeight; ctx.beginPath(); ctx.arc(x, y, 3, 0, 2 * Math.PI); ctx.fill(); }); } drawMultiLineChart(ctx, canvas, datasets, title) { ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw title ctx.fillStyle = '#2c3e50'; ctx.font = 'bold 16px Arial'; ctx.textAlign = 'center'; ctx.fillText(title, canvas.width / 2, 25); if (datasets.length === 0 || datasets[0].data.length === 0) { this.drawEmptyChart(ctx, canvas, 'No data available'); return; } const margin = 60; const chartWidth = canvas.width - 2 * margin; const chartHeight = canvas.height - 100; const maxValue = Math.max(...datasets.flatMap(d => d.data.map(p => p.value)), 1); // Draw axes ctx.strokeStyle = '#ddd'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(margin, margin); ctx.lineTo(margin, canvas.height - margin - 20); ctx.lineTo(canvas.width - margin, canvas.height - margin - 20); ctx.stroke(); // Draw lines for each dataset datasets.forEach(dataset => { ctx.strokeStyle = dataset.color; ctx.lineWidth = 2; ctx.beginPath(); dataset.data.forEach((point, index) => { const x = margin + (index / (dataset.data.length - 1)) * chartWidth; const y = canvas.height - margin - 20 - (point.value / maxValue) * chartHeight; if (index === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); } }); ctx.stroke(); // Draw points ctx.fillStyle = dataset.color; dataset.data.forEach((point, index) => { const x = margin + (index / (dataset.data.length - 1)) * chartWidth; const y = canvas.height - margin - 20 - (point.value / maxValue) * chartHeight; ctx.beginPath(); ctx.arc(x, y, 3, 0, 2 * Math.PI); ctx.fill(); }); }); // Draw legend const legendY = canvas.height - 15; let legendX = margin; datasets.forEach(dataset => { ctx.fillStyle = dataset.color; ctx.fillRect(legendX, legendY - 8, 12, 12); ctx.fillStyle = '#2c3e50'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.fillText(dataset.label, legendX + 18, legendY); legendX += ctx.measureText(dataset.label).width + 40; }); } drawLegend(ctx, canvas, data, x, y) { data.forEach((item, index) => { const legendY = y + index * 20; // Draw color box ctx.fillStyle = item.color; ctx.fillRect(x, legendY, 12, 12); // Draw text ctx.fillStyle = '#2c3e50'; ctx.font = '12px Arial'; ctx.textAlign = 'left'; ctx.fillText(`${item.label} (${item.value})`, x + 18, legendY + 9); }); } drawEmptyChart(ctx, canvas, message) { ctx.fillStyle = '#6c757d'; ctx.font = '14px Arial'; ctx.textAlign = 'center'; ctx.fillText(message, canvas.width / 2, canvas.height / 2); } // Utility methods generateFolderColor(folder) { const colors = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#34495e', '#e67e22']; const hash = this.hashString(folder || 'uncategorized'); return colors[hash % colors.length]; } generateRatingColor(rating) { const colors = ['#dc3545', '#fd7e14', '#ffc107', '#28a745', '#17a2b8']; return colors[rating - 1] || '#6c757d'; } hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash); } generateDateRange(startDate, endDate) { const dates = []; const currentDate = new Date(startDate); while (currentDate <= endDate) { dates.push(currentDate.toISOString().split('T')[0]); currentDate.setDate(currentDate.getDate() + 1); } return dates; } // Export analytics data exportAnalyticsData() { const analyticsData = { generatedAt: new Date().toISOString(), summary: { totalBookmarks: this.bookmarks.length, validLinks: this.bookmarks.filter(b => b.status === 'valid').length, invalidLinks: this.bookmarks.filter(b => b.status === 'invalid').length, duplicates: this.bookmarks.filter(b => b.status === 'duplicate').length, unknownStatus: this.bookmarks.filter(b => b.status === 'unknown').length }, folderStats: this.getFolderStats(), healthMetrics: this.calculateHealthMetrics(), usageMetrics: this.calculateUsageMetrics(), bookmarkDetails: this.bookmarks.map(bookmark => ({ title: bookmark.title, url: bookmark.url, folder: bookmark.folder || 'Uncategorized', status: bookmark.status, addDate: new Date(bookmark.addDate).toISOString(), lastTested: bookmark.lastTested ? new Date(bookmark.lastTested).toISOString() : null, rating: bookmark.rating || 0, favorite: bookmark.favorite || false, visitCount: bookmark.visitCount || 0 })) }; const blob = new Blob([JSON.stringify(analyticsData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmark-analytics-${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert('Analytics data exported successfully!'); } // Generate analytics report generateAnalyticsReport() { const healthData = this.calculateHealthMetrics(); const usageData = this.calculateUsageMetrics(); const folderStats = this.getFolderStats(); const report = ` # Bookmark Manager Analytics Report Generated on: ${new Date().toLocaleDateString()} ## Summary - **Total Bookmarks:** ${this.bookmarks.length} - **Valid Links:** ${this.bookmarks.filter(b => b.status === 'valid').length} - **Invalid Links:** ${this.bookmarks.filter(b => b.status === 'invalid').length} - **Duplicates:** ${this.bookmarks.filter(b => b.status === 'duplicate').length} - **Health Score:** ${healthData.score}% ## Folder Analysis ${Object.entries(folderStats).map(([folder, stats]) => `- **${folder || 'Uncategorized'}:** ${stats.total} bookmarks (${stats.valid} valid, ${stats.invalid} invalid)` ).join('\n')} ## Health Issues ${healthData.issues.length > 0 ? healthData.issues.map(issue => `- **${issue.title}:** ${issue.description}`).join('\n') : '- No issues found - your collection is healthy!' } ## Recommendations ${healthData.recommendations.length > 0 ? healthData.recommendations.map(rec => `- **${rec.title}:** ${rec.description}`).join('\n') : '- Your bookmark collection is well-maintained!' } ## Usage Statistics - **Most Active Folder:** ${usageData.mostActiveFolder} - **Average Rating:** ${usageData.averageRating} - **Most Visited:** ${usageData.mostVisited} --- Report generated by Bookmark Manager Analytics `.trim(); const blob = new Blob([report], { type: 'text/markdown' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmark-report-${new Date().toISOString().split('T')[0]}.md`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); alert('Analytics report generated successfully!'); } clearAllBookmarks() { this.bookmarks = []; this.currentFilter = 'all'; // Reset filter when clearing all this.saveBookmarksToStorage(); this.renderBookmarks(); this.updateStats(); // Reset filter button to "Total" document.querySelectorAll('.stats-filter').forEach(btn => btn.classList.remove('active')); document.getElementById('totalCount').classList.add('active'); } saveBookmarksToStorage() { try { // Apply encryption to bookmarks that are marked for encryption const bookmarksToSave = this.bookmarks.map(bookmark => { if (this.isBookmarkEncrypted(bookmark.id)) { return this.encryptBookmark(bookmark); } return bookmark; }); localStorage.setItem('bookmarks', JSON.stringify(bookmarksToSave)); // Update backup tracking this.updateBackupTracking(); this.logAccess('bookmarks_saved', { count: bookmarksToSave.length }); } catch (error) { console.error('Error saving bookmarks:', error); alert('Error saving bookmarks. Your changes may not be preserved.'); } } loadBookmarksFromStorage() { const stored = localStorage.getItem('bookmarks'); console.log('Loading bookmarks from storage:', stored ? 'Found data' : 'No data found'); if (stored) { try { const loadedBookmarks = JSON.parse(stored); console.log('Parsed bookmarks:', loadedBookmarks.length, 'bookmarks found'); // Decrypt bookmarks that are encrypted this.bookmarks = loadedBookmarks.map(bookmark => { if (bookmark.encrypted && this.isBookmarkEncrypted(bookmark.id)) { return this.decryptBookmark(bookmark); } return bookmark; }); console.log('Final bookmarks loaded:', this.bookmarks.length); this.logAccess('bookmarks_loaded', { count: this.bookmarks.length }); } catch (error) { console.error('Error loading bookmarks from storage:', error); this.bookmarks = []; } } else { console.log('No bookmarks found in localStorage'); this.bookmarks = []; } // Load backup settings and check if reminder is needed this.loadBackupSettings(); this.checkBackupReminder(); } // Enhanced Export Functionality showExportModal() { this.populateExportFolderList(); this.updateExportPreview(); this.showModal('exportModal'); } populateExportFolderList() { const uniqueFolders = [...new Set( this.bookmarks .map(bookmark => bookmark.folder) .filter(folder => folder && folder.trim() !== '') )].sort(); const folderSelect = document.getElementById('exportFolderSelect'); folderSelect.innerHTML = ''; uniqueFolders.forEach(folder => { const option = document.createElement('option'); option.value = folder; option.textContent = folder; folderSelect.appendChild(option); }); } updateExportPreview() { const format = document.getElementById('exportFormat').value; const filter = document.getElementById('exportFilter').value; const folderSelect = document.getElementById('exportFolderSelect').value; // Show/hide folder selection based on filter const folderGroup = document.getElementById('folderSelectionGroup'); folderGroup.style.display = filter === 'folder' ? 'block' : 'none'; // Get bookmarks to export const bookmarksToExport = this.getBookmarksForExport(filter, folderSelect); // Update preview document.getElementById('exportCount').textContent = `${bookmarksToExport.length} bookmarks`; } getBookmarksForExport(filter, selectedFolder = '') { let bookmarksToExport = []; switch (filter) { case 'all': bookmarksToExport = [...this.bookmarks]; break; case 'current': bookmarksToExport = this.getFilteredBookmarks(); break; case 'valid': bookmarksToExport = this.bookmarks.filter(b => b.status === 'valid'); break; case 'invalid': bookmarksToExport = this.bookmarks.filter(b => b.status === 'invalid'); break; case 'duplicates': bookmarksToExport = this.bookmarks.filter(b => b.status === 'duplicate'); break; case 'folder': if (selectedFolder) { bookmarksToExport = this.bookmarks.filter(b => b.folder === selectedFolder); } break; default: bookmarksToExport = [...this.bookmarks]; } return bookmarksToExport; } exportBookmarks() { if (this.bookmarks.length === 0) { alert('No bookmarks to export.'); return; } const html = this.generateNetscapeHTML(); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmarks_${new Date().toISOString().split('T')[0]}.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } performExport() { const format = document.getElementById('exportFormat').value; const filter = document.getElementById('exportFilter').value; const selectedFolder = document.getElementById('exportFolderSelect').value; let bookmarksToExport = this.getBookmarksForExport(filter, selectedFolder); // Apply privacy filtering bookmarksToExport = this.getExportableBookmarks(bookmarksToExport); if (bookmarksToExport.length === 0) { alert('No bookmarks match the selected criteria.'); return; } let content, mimeType, extension; switch (format) { case 'html': content = this.generateNetscapeHTML(bookmarksToExport); mimeType = 'text/html'; extension = 'html'; break; case 'json': content = this.generateJSONExport(bookmarksToExport); mimeType = 'application/json'; extension = 'json'; break; case 'csv': content = this.generateCSVExport(bookmarksToExport); mimeType = 'text/csv'; extension = 'csv'; break; case 'txt': content = this.generateTextExport(bookmarksToExport); mimeType = 'text/plain'; extension = 'txt'; break; default: alert('Invalid export format selected.'); return; } // Create and download file const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmarks_${filter}_${new Date().toISOString().split('T')[0]}.${extension}`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); // Update backup tracking this.recordBackup(); // Close modal this.hideModal('exportModal'); alert(`Successfully exported ${bookmarksToExport.length} bookmarks as ${format.toUpperCase()}!`); } generateNetscapeHTML(bookmarksToExport = null) { const bookmarks = bookmarksToExport || this.bookmarks; const folders = {}; const noFolderBookmarks = []; // Group bookmarks by folder bookmarks.forEach(bookmark => { if (bookmark.folder && bookmark.folder.trim()) { if (!folders[bookmark.folder]) { folders[bookmark.folder] = []; } folders[bookmark.folder].push(bookmark); } else { noFolderBookmarks.push(bookmark); } }); let html = ` Bookmarks

Bookmarks

`; // Add bookmarks without folders noFolderBookmarks.forEach(bookmark => { html += this.generateBookmarkHTML(bookmark); }); // Add folders with bookmarks Object.keys(folders).forEach(folderName => { html += `

${this.escapeHtml(folderName)}

\n

\n`; folders[folderName].forEach(bookmark => { html += this.generateBookmarkHTML(bookmark, ' '); }); html += `

\n`; }); html += `

`; return html; } generateJSONExport(bookmarksToExport) { const exportData = { exportDate: new Date().toISOString(), version: '1.1', totalBookmarks: bookmarksToExport.length, bookmarks: bookmarksToExport.map(bookmark => ({ id: bookmark.id, title: bookmark.title, url: bookmark.url, folder: bookmark.folder || '', tags: bookmark.tags || [], notes: bookmark.notes || '', rating: bookmark.rating || 0, favorite: bookmark.favorite || false, addDate: bookmark.addDate, lastModified: bookmark.lastModified, lastVisited: bookmark.lastVisited, icon: bookmark.icon || '', status: bookmark.status, errorCategory: bookmark.errorCategory, lastTested: bookmark.lastTested })) }; return JSON.stringify(exportData, null, 2); } generateCSVExport(bookmarksToExport) { const headers = ['Title', 'URL', 'Folder', 'Tags', 'Notes', 'Rating', 'Favorite', 'Status', 'Add Date', 'Last Modified', 'Last Visited', 'Last Tested', 'Error Category']; let csv = headers.join(',') + '\n'; bookmarksToExport.forEach(bookmark => { const row = [ this.escapeCSV(bookmark.title), this.escapeCSV(bookmark.url), this.escapeCSV(bookmark.folder || ''), this.escapeCSV((bookmark.tags || []).join('; ')), this.escapeCSV(bookmark.notes || ''), bookmark.rating || 0, bookmark.favorite ? 'Yes' : 'No', this.escapeCSV(bookmark.status), bookmark.addDate ? new Date(bookmark.addDate).toISOString() : '', bookmark.lastModified ? new Date(bookmark.lastModified).toISOString() : '', bookmark.lastVisited ? new Date(bookmark.lastVisited).toISOString() : '', bookmark.lastTested ? new Date(bookmark.lastTested).toISOString() : '', this.escapeCSV(bookmark.errorCategory || '') ]; csv += row.join(',') + '\n'; }); return csv; } generateTextExport(bookmarksToExport) { let text = `Bookmark Export - ${new Date().toLocaleDateString()}\n`; text += `Total Bookmarks: ${bookmarksToExport.length}\n\n`; // Group by folder const folders = {}; const noFolderBookmarks = []; bookmarksToExport.forEach(bookmark => { if (bookmark.folder && bookmark.folder.trim()) { if (!folders[bookmark.folder]) { folders[bookmark.folder] = []; } folders[bookmark.folder].push(bookmark); } else { noFolderBookmarks.push(bookmark); } }); // Add bookmarks without folders if (noFolderBookmarks.length > 0) { text += 'UNCATEGORIZED BOOKMARKS:\n'; text += '='.repeat(50) + '\n'; noFolderBookmarks.forEach(bookmark => { text += `• ${bookmark.title}\n ${bookmark.url}\n Status: ${bookmark.status}\n\n`; }); } // Add folders with bookmarks Object.keys(folders).sort().forEach(folderName => { text += `${folderName.toUpperCase()}:\n`; text += '='.repeat(folderName.length + 1) + '\n'; folders[folderName].forEach(bookmark => { text += `• ${bookmark.title}\n ${bookmark.url}\n Status: ${bookmark.status}\n\n`; }); }); return text; } escapeCSV(field) { if (field === null || field === undefined) return ''; const str = String(field); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return '"' + str.replace(/"/g, '""') + '"'; } return str; } // Backup Reminder Functionality loadBackupSettings() { const settings = localStorage.getItem('backupSettings'); this.backupSettings = settings ? JSON.parse(settings) : { enabled: true, lastBackupDate: null, bookmarkCountAtLastBackup: 0, reminderThreshold: { days: 30, bookmarkCount: 50 } }; } saveBackupSettings() { localStorage.setItem('backupSettings', JSON.stringify(this.backupSettings)); } updateBackupTracking() { if (!this.backupSettings) { this.loadBackupSettings(); } // This is called when bookmarks are saved, so we track changes // but don't update the backup date (that's only done on actual export) } recordBackup() { if (!this.backupSettings) { this.loadBackupSettings(); } this.backupSettings.lastBackupDate = Date.now(); this.backupSettings.bookmarkCountAtLastBackup = this.bookmarks.length; this.saveBackupSettings(); } checkBackupReminder() { if (!this.backupSettings || !this.backupSettings.enabled) { return; } const now = Date.now(); const lastBackup = this.backupSettings.lastBackupDate; const currentCount = this.bookmarks.length; const lastBackupCount = this.backupSettings.bookmarkCountAtLastBackup; let shouldRemind = false; let daysSinceBackup = 0; let bookmarksSinceBackup = currentCount - lastBackupCount; if (!lastBackup) { // Never backed up shouldRemind = currentCount > 10; // Only remind if they have some bookmarks } else { daysSinceBackup = Math.floor((now - lastBackup) / (1000 * 60 * 60 * 24)); // Check if we should remind based on time or bookmark count shouldRemind = daysSinceBackup >= this.backupSettings.reminderThreshold.days || bookmarksSinceBackup >= this.backupSettings.reminderThreshold.bookmarkCount; } if (shouldRemind) { // Show reminder after a short delay to not interfere with app initialization setTimeout(() => { this.showBackupReminder(daysSinceBackup, bookmarksSinceBackup); }, 2000); } } showBackupReminder(daysSinceBackup, bookmarksSinceBackup) { document.getElementById('bookmarkCountSinceBackup').textContent = bookmarksSinceBackup; document.getElementById('daysSinceBackup').textContent = daysSinceBackup; this.showModal('backupReminderModal'); } // Import Validation validateImportData(bookmarks) { const errors = []; const warnings = []; if (!Array.isArray(bookmarks)) { errors.push('Import data must be an array of bookmarks'); return { isValid: false, errors, warnings }; } bookmarks.forEach((bookmark, index) => { if (!bookmark.title || typeof bookmark.title !== 'string') { errors.push(`Bookmark ${index + 1}: Missing or invalid title`); } if (!bookmark.url || typeof bookmark.url !== 'string') { errors.push(`Bookmark ${index + 1}: Missing or invalid URL`); } else { try { new URL(bookmark.url); } catch (e) { warnings.push(`Bookmark ${index + 1}: Invalid URL format - ${bookmark.url}`); } } if (bookmark.folder && typeof bookmark.folder !== 'string') { warnings.push(`Bookmark ${index + 1}: Invalid folder type`); } if (bookmark.addDate && (typeof bookmark.addDate !== 'number' || bookmark.addDate < 0)) { warnings.push(`Bookmark ${index + 1}: Invalid add date`); } }); return { isValid: errors.length === 0, errors, warnings }; } // Mobile Touch Interaction Methods initializeMobileTouchHandlers() { // Only initialize on mobile devices if (!this.isMobileDevice()) { return; } console.log('Initializing mobile touch handlers...'); // Add touch event listeners to bookmark items when they're rendered this.addTouchListenersToBookmarks(); } isMobileDevice() { return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); } addTouchListenersToBookmarks() { // This will be called after rendering bookmarks const bookmarkItems = document.querySelectorAll('.bookmark-item'); bookmarkItems.forEach(item => { // Remove existing listeners to prevent duplicates item.removeEventListener('touchstart', this.handleTouchStart.bind(this)); item.removeEventListener('touchmove', this.handleTouchMove.bind(this)); item.removeEventListener('touchend', this.handleTouchEnd.bind(this)); // Add touch event listeners item.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: false }); item.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false }); item.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: false }); }); } handleTouchStart(e) { const touch = e.touches[0]; const bookmarkItem = e.currentTarget; this.touchState.startX = touch.clientX; this.touchState.startY = touch.clientY; this.touchState.currentX = touch.clientX; this.touchState.currentY = touch.clientY; this.touchState.isDragging = false; this.touchState.currentBookmark = this.getBookmarkFromElement(bookmarkItem); this.touchState.swipeDirection = null; // Add swiping class for visual feedback bookmarkItem.classList.add('swiping'); } handleTouchMove(e) { if (!this.touchState.currentBookmark) return; const touch = e.touches[0]; const bookmarkItem = e.currentTarget; this.touchState.currentX = touch.clientX; this.touchState.currentY = touch.clientY; const deltaX = this.touchState.currentX - this.touchState.startX; const deltaY = this.touchState.currentY - this.touchState.startY; // Check if this is a horizontal swipe (not vertical scroll) if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) { e.preventDefault(); // Prevent scrolling this.touchState.isDragging = true; // Update visual feedback bookmarkItem.style.setProperty('--swipe-offset', `${deltaX}px`); // Determine swipe direction and add visual feedback if (deltaX > 30) { // Swipe right - mark as valid/test bookmarkItem.classList.add('swipe-right'); bookmarkItem.classList.remove('swipe-left'); this.touchState.swipeDirection = 'right'; } else if (deltaX < -30) { // Swipe left - delete bookmarkItem.classList.add('swipe-left'); bookmarkItem.classList.remove('swipe-right'); this.touchState.swipeDirection = 'left'; } else { bookmarkItem.classList.remove('swipe-left', 'swipe-right'); this.touchState.swipeDirection = null; } } } handleTouchEnd(e) { const bookmarkItem = e.currentTarget; const deltaX = this.touchState.currentX - this.touchState.startX; // Clean up visual states bookmarkItem.classList.remove('swiping', 'swipe-left', 'swipe-right'); bookmarkItem.style.removeProperty('--swipe-offset'); // Execute action if swipe threshold was met if (this.touchState.isDragging && Math.abs(deltaX) > this.touchState.swipeThreshold) { if (this.touchState.swipeDirection === 'right') { // Swipe right - test link or mark as valid this.handleSwipeRight(this.touchState.currentBookmark); } else if (this.touchState.swipeDirection === 'left') { // Swipe left - delete bookmark this.handleSwipeLeft(this.touchState.currentBookmark); } } else if (!this.touchState.isDragging) { // Regular tap - show context menu this.showBookmarkContextMenu(this.touchState.currentBookmark); } // Reset touch state this.resetTouchState(); } handleSwipeRight(bookmark) { // Test the link this.testLink(bookmark); // Show feedback this.showSwipeFeedback('Testing link...', 'success'); } handleSwipeLeft(bookmark) { // Delete bookmark with confirmation if (confirm(`Delete "${bookmark.title}"?`)) { this.deleteBookmark(bookmark.id); this.showSwipeFeedback('Bookmark deleted', 'danger'); } } showSwipeFeedback(message, type = 'info') { // Create or get feedback element let feedback = document.getElementById('swipe-feedback'); if (!feedback) { feedback = document.createElement('div'); feedback.id = 'swipe-feedback'; feedback.style.cssText = ` position: fixed; top: 20px; left: 50%; transform: translateX(-50%); padding: 12px 24px; border-radius: 8px; color: white; font-weight: 500; z-index: 1002; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; `; document.body.appendChild(feedback); } // Set message and color feedback.textContent = message; feedback.className = `swipe-feedback-${type}`; const colors = { success: '#28a745', danger: '#dc3545', info: '#17a2b8', warning: '#ffc107' }; feedback.style.backgroundColor = colors[type] || colors.info; feedback.style.opacity = '1'; // Hide after 2 seconds setTimeout(() => { feedback.style.opacity = '0'; }, 2000); } getBookmarkFromElement(element) { const bookmarkId = element.dataset.bookmarkId; return this.bookmarks.find(b => b.id == bookmarkId); } resetTouchState() { this.touchState = { startX: 0, startY: 0, currentX: 0, currentY: 0, isDragging: false, swipeThreshold: 100, currentBookmark: null, swipeDirection: null }; } // Pull-to-Refresh Functionality initializePullToRefresh() { if (!this.isMobileDevice()) { return; } console.log('Initializing pull-to-refresh...'); // Create pull-to-refresh indicator this.createPullToRefreshIndicator(); // Add touch listeners to the main container const container = document.querySelector('.container'); if (container) { container.addEventListener('touchstart', this.handlePullStart.bind(this), { passive: false }); container.addEventListener('touchmove', this.handlePullMove.bind(this), { passive: false }); container.addEventListener('touchend', this.handlePullEnd.bind(this), { passive: false }); } } createPullToRefreshIndicator() { const indicator = document.createElement('div'); indicator.id = 'pull-to-refresh-indicator'; indicator.className = 'pull-to-refresh'; indicator.innerHTML = `

Pull to refresh links
`; document.body.appendChild(indicator); this.pullToRefresh.element = indicator; } handlePullStart(e) { // Only activate if we're at the top of the page if (window.scrollY > 0) { return; } const touch = e.touches[0]; this.pullToRefresh.startY = touch.clientY; this.pullToRefresh.currentY = touch.clientY; this.pullToRefresh.isPulling = false; } handlePullMove(e) { if (window.scrollY > 0 || !this.pullToRefresh.startY) { return; } const touch = e.touches[0]; this.pullToRefresh.currentY = touch.clientY; const deltaY = this.pullToRefresh.currentY - this.pullToRefresh.startY; // Only handle downward pulls if (deltaY > 0) { e.preventDefault(); this.pullToRefresh.isPulling = true; // Update indicator position and state const progress = Math.min(deltaY / this.pullToRefresh.threshold, 1); const indicator = this.pullToRefresh.element; if (indicator) { indicator.style.transform = `translateX(-50%) translateY(${Math.min(deltaY * 0.5, 50)}px)`; indicator.classList.toggle('visible', deltaY > 20); indicator.classList.toggle('active', deltaY > this.pullToRefresh.threshold); // Update text based on progress const textElement = indicator.querySelector('.pull-to-refresh-text'); const iconElement = indicator.querySelector('.pull-to-refresh-icon'); if (deltaY > this.pullToRefresh.threshold) { textElement.textContent = 'Release to refresh'; iconElement.style.transform = 'rotate(180deg)'; } else { textElement.textContent = 'Pull to refresh links'; iconElement.style.transform = 'rotate(0deg)'; } } } } handlePullEnd(e) { if (!this.pullToRefresh.isPulling) { return; } const deltaY = this.pullToRefresh.currentY - this.pullToRefresh.startY; const indicator = this.pullToRefresh.element; // Reset indicator if (indicator) { indicator.style.transform = 'translateX(-50%) translateY(-100%)'; indicator.classList.remove('visible', 'active'); } // Trigger refresh if threshold was met if (deltaY > this.pullToRefresh.threshold) { this.triggerPullToRefresh(); } // Reset state this.pullToRefresh.startY = 0; this.pullToRefresh.currentY = 0; this.pullToRefresh.isPulling = false; } triggerPullToRefresh() { console.log('Pull-to-refresh triggered'); // Show feedback this.showSwipeFeedback('Refreshing links...', 'info'); // Test all invalid links (or all links if none are invalid) const invalidBookmarks = this.bookmarks.filter(b => b.status === 'invalid'); if (invalidBookmarks.length > 0) { this.testInvalidLinks(); } else { // If no invalid links, test all links this.testAllLinks(); } } showBookmarkContextMenu(bookmark) { // Use existing context menu functionality this.showContextMenu(bookmark); } // ===== SHARING AND COLLABORATION FEATURES ===== // Initialize sharing functionality initializeSharing() { // Sharing data storage this.sharedCollections = this.loadSharedCollections(); this.bookmarkTemplates = this.loadBookmarkTemplates(); this.myTemplates = this.loadMyTemplates(); // Bind sharing events this.bindSharingEvents(); this.bindTemplateEvents(); } // Bind sharing-related events bindSharingEvents() { // Share button document.getElementById('shareBtn').addEventListener('click', () => { this.showShareModal(); }); // Share modal tabs document.querySelectorAll('.share-tab').forEach(tab => { tab.addEventListener('click', (e) => { this.switchShareTab(e.target.dataset.tab); }); }); // Share form events document.getElementById('shareFilter').addEventListener('change', () => { this.updateSharePreview(); }); document.getElementById('requirePassword').addEventListener('change', (e) => { document.getElementById('passwordGroup').style.display = e.target.checked ? 'block' : 'none'; }); document.getElementById('generateShareUrlBtn').addEventListener('click', () => { this.generateShareUrl(); }); document.getElementById('copyUrlBtn').addEventListener('click', () => { this.copyShareUrl(); }); // Social media sharing document.querySelectorAll('.social-btn').forEach(btn => { btn.addEventListener('click', (e) => { this.shareToSocialMedia(e.target.dataset.platform); }); }); // Email sharing document.getElementById('sendEmailBtn').addEventListener('click', () => { this.sendEmail(); }); document.getElementById('openEmailClientBtn').addEventListener('click', () => { this.openEmailClient(); }); // Recommendations document.getElementById('refreshRecommendationsBtn').addEventListener('click', () => { this.refreshRecommendations(); }); document.getElementById('saveRecommendationsBtn').addEventListener('click', () => { this.saveSelectedRecommendations(); }); // Close share modal document.getElementById('cancelShareBtn').addEventListener('click', () => { this.hideModal('shareModal'); }); } // Bind template-related events bindTemplateEvents() { // Templates button document.getElementById('templatesBtn').addEventListener('click', () => { this.showTemplatesModal(); }); // Template modal tabs document.querySelectorAll('.templates-tab').forEach(tab => { tab.addEventListener('click', (e) => { this.switchTemplateTab(e.target.dataset.tab); }); }); // Template category filters document.querySelectorAll('.category-filter').forEach(filter => { filter.addEventListener('click', (e) => { this.filterTemplatesByCategory(e.target.dataset.category); }); }); // Create template document.getElementById('createTemplateBtn').addEventListener('click', () => { this.createTemplate(); }); document.getElementById('previewTemplateBtn').addEventListener('click', () => { this.previewTemplate(); }); // Close templates modal document.getElementById('cancelTemplatesBtn').addEventListener('click', () => { this.hideModal('templatesModal'); }); } // Show share modal showShareModal() { this.showModal('shareModal'); this.updateSharePreview(); this.populateShareFolders(); this.loadRecommendations(); } // Switch share tabs switchShareTab(tabName) { // Update tab buttons document.querySelectorAll('.share-tab').forEach(tab => { tab.classList.remove('active'); }); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); // Update tab content document.querySelectorAll('.share-tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabName}ShareTab`).classList.add('active'); // Load tab-specific content if (tabName === 'recommendations') { this.loadRecommendations(); } } // Update share preview updateSharePreview() { const filter = document.getElementById('shareFilter').value; const bookmarksToShare = this.getBookmarksForSharing(filter); // Update social media preview const collectionName = document.getElementById('collectionName').value || 'My Bookmark Collection'; const previewText = `Check out my curated bookmark collection: "${collectionName}" - ${bookmarksToShare.length} carefully selected links.`; document.getElementById('socialPostText').textContent = previewText; // Show/hide folder selection const folderGroup = document.getElementById('shareFolderGroup'); folderGroup.style.display = filter === 'folder' ? 'block' : 'none'; } // Get bookmarks for sharing based on filter getBookmarksForSharing(filter) { switch (filter) { case 'all': return this.bookmarks; case 'current': return this.getFilteredBookmarks(); case 'valid': return this.bookmarks.filter(b => b.status === 'valid'); case 'folder': const selectedFolder = document.getElementById('shareFolderSelect').value; return this.bookmarks.filter(b => b.folder === selectedFolder); case 'favorites': return this.bookmarks.filter(b => b.favorite); default: return this.bookmarks; } } // Populate share folder dropdown populateShareFolders() { const folderSelect = document.getElementById('shareFolderSelect'); const folders = [...new Set(this.bookmarks.map(b => b.folder || ''))].sort(); folderSelect.innerHTML = ''; folders.forEach(folder => { const option = document.createElement('option'); option.value = folder; option.textContent = folder || 'Uncategorized'; folderSelect.appendChild(option); }); } // Generate shareable URL generateShareUrl() { const collectionName = document.getElementById('collectionName').value; const description = document.getElementById('collectionDescription').value; const filter = document.getElementById('shareFilter').value; const allowComments = document.getElementById('allowComments').checked; const allowDownload = document.getElementById('allowDownload').checked; const requirePassword = document.getElementById('requirePassword').checked; const password = document.getElementById('sharePassword').value; if (!collectionName.trim()) { alert('Please enter a collection name.'); return; } const bookmarksToShare = this.getBookmarksForSharing(filter); if (bookmarksToShare.length === 0) { alert('No bookmarks to share with the selected filter.'); return; } // Create share data const shareData = { id: this.generateShareId(), name: collectionName, description: description, bookmarks: bookmarksToShare, settings: { allowComments, allowDownload, requirePassword, password: requirePassword ? password : null }, createdAt: Date.now(), views: 0, downloads: 0 }; // Store share data (in real app, this would be sent to server) this.storeSharedCollection(shareData); // Generate URL (in real app, this would be a server URL) const shareUrl = `${window.location.origin}/shared/${shareData.id}`; // Display result document.getElementById('shareUrl').value = shareUrl; document.getElementById('shareUrlResult').style.display = 'block'; document.getElementById('copyShareUrlBtn').style.display = 'inline-block'; // Update social media preview document.getElementById('socialPostLink').textContent = shareUrl; alert('Share URL generated successfully!'); } // Generate unique share ID generateShareId() { return Math.random().toString(36).substr(2, 9); } // Copy share URL to clipboard copyShareUrl() { const shareUrl = document.getElementById('shareUrl'); shareUrl.select(); document.execCommand('copy'); const copyBtn = document.getElementById('copyUrlBtn'); const originalText = copyBtn.textContent; copyBtn.textContent = 'Copied!'; setTimeout(() => { copyBtn.textContent = originalText; }, 2000); } // Share to social media shareToSocialMedia(platform) { const collectionName = document.getElementById('collectionName').value || 'My Bookmark Collection'; const shareUrl = document.getElementById('shareUrl').value; const customMessage = document.getElementById('socialMessage').value; if (!shareUrl) { alert('Please generate a share URL first.'); return; } const bookmarksCount = this.getBookmarksForSharing(document.getElementById('shareFilter').value).length; const defaultText = `Check out my curated bookmark collection: "${collectionName}" - ${bookmarksCount} carefully selected links.`; const shareText = customMessage || defaultText; let socialUrl; switch (platform) { case 'twitter': socialUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(shareUrl)}`; break; case 'facebook': socialUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}"e=${encodeURIComponent(shareText)}`; break; case 'linkedin': socialUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(collectionName)}&summary=${encodeURIComponent(shareText)}`; break; case 'reddit': socialUrl = `https://reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(collectionName)}`; break; default: return; } window.open(socialUrl, '_blank', 'width=600,height=400'); } // Send email sendEmail() { const recipients = document.getElementById('emailRecipients').value; const subject = document.getElementById('emailSubject').value; const message = document.getElementById('emailMessage').value; if (!recipients.trim()) { alert('Please enter at least one recipient email address.'); return; } // In a real application, this would send via a backend service alert('Email functionality would be implemented with a backend service. For now, opening email client...'); this.openEmailClient(); } // Open email client openEmailClient() { const recipients = document.getElementById('emailRecipients').value; const subject = document.getElementById('emailSubject').value; const message = document.getElementById('emailMessage').value; const shareUrl = document.getElementById('shareUrl').value; const includeUrl = document.getElementById('includeShareUrl').checked; const includeList = document.getElementById('includeBookmarkList').checked; let emailBody = message + '\n\n'; if (includeUrl && shareUrl) { emailBody += `View and download the collection here: ${shareUrl}\n\n`; } if (includeList) { const bookmarks = this.getBookmarksForSharing(document.getElementById('shareFilter').value); emailBody += 'Bookmark List:\n'; bookmarks.forEach((bookmark, index) => { emailBody += `${index + 1}. ${bookmark.title}\n ${bookmark.url}\n\n`; }); } const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`; window.location.href = mailtoUrl; } // Load recommendations loadRecommendations() { this.detectCategories(); this.loadSimilarCollections(); this.loadRecommendedBookmarks(); } // Detect categories from user's bookmarks detectCategories() { const categories = new Map(); this.bookmarks.forEach(bookmark => { // Simple category detection based on URL and title keywords const text = (bookmark.title + ' ' + bookmark.url).toLowerCase(); if (text.includes('github') || text.includes('code') || text.includes('dev') || text.includes('programming')) { categories.set('development', (categories.get('development') || 0) + 1); } if (text.includes('design') || text.includes('ui') || text.includes('ux') || text.includes('figma')) { categories.set('design', (categories.get('design') || 0) + 1); } if (text.includes('productivity') || text.includes('tool') || text.includes('app')) { categories.set('productivity', (categories.get('productivity') || 0) + 1); } if (text.includes('learn') || text.includes('tutorial') || text.includes('course') || text.includes('education')) { categories.set('learning', (categories.get('learning') || 0) + 1); } if (text.includes('news') || text.includes('blog') || text.includes('article')) { categories.set('news', (categories.get('news') || 0) + 1); } }); // Display detected categories const categoriesContainer = document.getElementById('detectedCategories'); categoriesContainer.innerHTML = ''; [...categories.entries()] .sort((a, b) => b[1] - a[1]) .slice(0, 5) .forEach(([category, count]) => { const tag = document.createElement('span'); tag.className = 'category-tag'; tag.textContent = `${category} (${count})`; categoriesContainer.appendChild(tag); }); } // Load similar collections (mock data) loadSimilarCollections() { const mockCollections = [ { title: 'Web Developer Resources', description: 'Essential tools and resources for web developers', bookmarks: 45, downloads: 1200, rating: 4.8 }, { title: 'Design Inspiration Hub', description: 'Curated collection of design inspiration and tools', bookmarks: 32, downloads: 890, rating: 4.6 }, { title: 'Productivity Power Pack', description: 'Apps and tools to boost your productivity', bookmarks: 28, downloads: 650, rating: 4.7 } ]; const container = document.getElementById('similarCollectionsList'); container.innerHTML = ''; mockCollections.forEach(collection => { const item = document.createElement('div'); item.className = 'collection-item'; item.innerHTML = `
${collection.title}
${collection.description}
${collection.bookmarks} bookmarks ${collection.downloads} downloads ★ ${collection.rating}
`; item.addEventListener('click', () => { alert(`Would open collection: ${collection.title}`); }); container.appendChild(item); }); } // Load recommended bookmarks (mock data) loadRecommendedBookmarks() { const mockRecommendations = [ { title: 'VS Code Extensions for Productivity', url: 'https://example.com/vscode-extensions', description: 'Essential VS Code extensions every developer should have', category: 'development', confidence: 0.95 }, { title: 'Figma Design System Templates', url: 'https://example.com/figma-templates', description: 'Ready-to-use design system templates for Figma', category: 'design', confidence: 0.88 }, { title: 'Notion Productivity Templates', url: 'https://example.com/notion-templates', description: 'Boost your productivity with these Notion templates', category: 'productivity', confidence: 0.82 } ]; const container = document.getElementById('recommendedBookmarksList'); container.innerHTML = ''; mockRecommendations.forEach(rec => { const item = document.createElement('div'); item.className = 'recommendation-item'; item.innerHTML = `
${rec.title}
${rec.description}
${rec.category} ${Math.round(rec.confidence * 100)}% match ${rec.url}
`; container.appendChild(item); }); } // Refresh recommendations refreshRecommendations() { this.loadRecommendations(); alert('Recommendations refreshed!'); } // Save selected recommendations saveSelectedRecommendations() { const selectedCheckboxes = document.querySelectorAll('.recommendation-select:checked'); let savedCount = 0; selectedCheckboxes.forEach(checkbox => { const title = checkbox.dataset.title; const url = checkbox.dataset.url; // Add as new bookmark const newBookmark = { id: Date.now() + Math.random(), title: title, url: url, folder: 'Recommendations', addDate: Date.now(), icon: '', status: 'unknown' }; this.bookmarks.push(newBookmark); savedCount++; }); if (savedCount > 0) { this.saveBookmarksToStorage(); this.renderBookmarks(); this.updateStats(); alert(`Saved ${savedCount} recommended bookmark${savedCount > 1 ? 's' : ''} to your collection!`); } else { alert('Please select at least one recommendation to save.'); } } // Show templates modal showTemplatesModal() { this.showModal('templatesModal'); this.loadTemplates(); this.loadMyTemplates(); } // Switch template tabs switchTemplateTab(tabName) { // Update tab buttons document.querySelectorAll('.templates-tab').forEach(tab => { tab.classList.remove('active'); }); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); // Update tab content document.querySelectorAll('.templates-tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabName}TemplatesTab`).classList.add('active'); // Load tab-specific content if (tabName === 'browse') { this.loadTemplates(); } else if (tabName === 'my-templates') { this.loadMyTemplates(); } } // Filter templates by category filterTemplatesByCategory(category) { // Update active filter document.querySelectorAll('.category-filter').forEach(filter => { filter.classList.remove('active'); }); document.querySelector(`[data-category="${category}"]`).classList.add('active'); // Filter templates const templates = document.querySelectorAll('.template-card'); templates.forEach(template => { const templateCategory = template.dataset.category; if (category === 'all' || templateCategory === category) { template.style.display = 'block'; } else { template.style.display = 'none'; } }); } // Load templates (mock data) loadTemplates() { const mockTemplates = [ { title: 'Web Developer Starter Kit', description: 'Essential bookmarks for new web developers including documentation, tools, and learning resources.', category: 'development', tags: ['javascript', 'html', 'css', 'tools'], bookmarks: 25, downloads: 1500, rating: 4.9 }, { title: 'UI/UX Designer Resources', description: 'Curated collection of design inspiration, tools, and resources for UI/UX designers.', category: 'design', tags: ['ui', 'ux', 'inspiration', 'tools'], bookmarks: 30, downloads: 1200, rating: 4.7 }, { title: 'Productivity Power Pack', description: 'Apps, tools, and resources to boost your daily productivity and workflow.', category: 'productivity', tags: ['apps', 'tools', 'workflow', 'efficiency'], bookmarks: 20, downloads: 800, rating: 4.6 }, { title: 'Learning Resources Hub', description: 'Online courses, tutorials, and educational platforms for continuous learning.', category: 'learning', tags: ['courses', 'tutorials', 'education', 'skills'], bookmarks: 35, downloads: 950, rating: 4.8 } ]; const container = document.getElementById('templatesGrid'); container.innerHTML = ''; mockTemplates.forEach(template => { const card = document.createElement('div'); card.className = 'template-card'; card.dataset.category = template.category; card.innerHTML = `

${template.title}

${template.category}
${template.description}
${template.bookmarks} bookmarks ${template.downloads} downloads ★ ${template.rating}
${template.tags.map(tag => `${tag}`).join('')}
`; container.appendChild(card); }); } // Use template useTemplate(templateTitle) { if (confirm(`Import bookmarks from "${templateTitle}" template? This will add new bookmarks to your collection.`)) { // Mock template bookmarks const templateBookmarks = this.generateMockTemplateBookmarks(templateTitle); templateBookmarks.forEach(bookmark => { this.bookmarks.push({ ...bookmark, id: Date.now() + Math.random(), addDate: Date.now(), status: 'unknown' }); }); this.saveBookmarksToStorage(); this.renderBookmarks(); this.updateStats(); this.hideModal('templatesModal'); alert(`Successfully imported ${templateBookmarks.length} bookmarks from "${templateTitle}" template!`); } } // Generate mock template bookmarks generateMockTemplateBookmarks(templateTitle) { const templates = { 'Web Developer Starter Kit': [ { title: 'MDN Web Docs', url: 'https://developer.mozilla.org', folder: 'Documentation' }, { title: 'Stack Overflow', url: 'https://stackoverflow.com', folder: 'Help & Community' }, { title: 'GitHub', url: 'https://github.com', folder: 'Tools' }, { title: 'VS Code', url: 'https://code.visualstudio.com', folder: 'Tools' }, { title: 'Can I Use', url: 'https://caniuse.com', folder: 'Reference' } ], 'UI/UX Designer Resources': [ { title: 'Figma', url: 'https://figma.com', folder: 'Design Tools' }, { title: 'Dribbble', url: 'https://dribbble.com', folder: 'Inspiration' }, { title: 'Behance', url: 'https://behance.net', folder: 'Inspiration' }, { title: 'Adobe Color', url: 'https://color.adobe.com', folder: 'Tools' }, { title: 'Unsplash', url: 'https://unsplash.com', folder: 'Resources' } ], 'Productivity Power Pack': [ { title: 'Notion', url: 'https://notion.so', folder: 'Productivity' }, { title: 'Todoist', url: 'https://todoist.com', folder: 'Task Management' }, { title: 'Calendly', url: 'https://calendly.com', folder: 'Scheduling' }, { title: 'Grammarly', url: 'https://grammarly.com', folder: 'Writing' }, { title: 'RescueTime', url: 'https://rescuetime.com', folder: 'Time Tracking' } ], 'Learning Resources Hub': [ { title: 'Coursera', url: 'https://coursera.org', folder: 'Online Courses' }, { title: 'Khan Academy', url: 'https://khanacademy.org', folder: 'Education' }, { title: 'freeCodeCamp', url: 'https://freecodecamp.org', folder: 'Programming' }, { title: 'TED Talks', url: 'https://ted.com/talks', folder: 'Inspiration' }, { title: 'Udemy', url: 'https://udemy.com', folder: 'Online Courses' } ] }; return templates[templateTitle] || []; } // Preview template previewTemplate(templateTitle) { const bookmarks = this.generateMockTemplateBookmarks(templateTitle); let previewText = `Template: ${templateTitle}\n\nBookmarks (${bookmarks.length}):\n\n`; bookmarks.forEach((bookmark, index) => { previewText += `${index + 1}. ${bookmark.title}\n ${bookmark.url}\n Folder: ${bookmark.folder}\n\n`; }); alert(previewText); } // Create template createTemplate() { const name = document.getElementById('templateName').value; const description = document.getElementById('templateDescription').value; const category = document.getElementById('templateCategory').value; const tags = document.getElementById('templateTags').value; const source = document.getElementById('templateSource').value; const isPublic = document.getElementById('templatePublic').checked; const allowAttribution = document.getElementById('templateAttribution').checked; if (!name.trim()) { alert('Please enter a template name.'); return; } const sourceBookmarks = this.getBookmarksForSharing(source); if (sourceBookmarks.length === 0) { alert('No bookmarks to include in template with the selected source.'); return; } const template = { id: Date.now() + Math.random(), name: name, description: description, category: category, tags: tags.split(',').map(t => t.trim()).filter(t => t), bookmarks: sourceBookmarks, isPublic: isPublic, allowAttribution: allowAttribution, createdAt: Date.now(), downloads: 0 }; // Save template this.myTemplates.push(template); this.saveMyTemplates(); // Clear form document.getElementById('templateName').value = ''; document.getElementById('templateDescription').value = ''; document.getElementById('templateTags').value = ''; // Refresh my templates view this.loadMyTemplates(); alert(`Template "${name}" created successfully with ${sourceBookmarks.length} bookmarks!`); } // Load my templates loadMyTemplates() { const container = document.getElementById('myTemplatesList'); container.innerHTML = ''; if (this.myTemplates.length === 0) { container.innerHTML = '

You haven\'t created any templates yet. Use the "Create Template" tab to get started.

'; return; } this.myTemplates.forEach(template => { const item = document.createElement('div'); item.className = 'my-template-item'; item.innerHTML = `
${template.name}
${template.bookmarks.length} bookmarks • ${template.category} • ${template.downloads} downloads • Created ${new Date(template.createdAt).toLocaleDateString()}
`; container.appendChild(item); }); } // Edit template editTemplate(templateId) { const template = this.myTemplates.find(t => t.id == templateId); if (!template) return; // Switch to create tab and populate form this.switchTemplateTab('create'); document.getElementById('templateName').value = template.name; document.getElementById('templateDescription').value = template.description; document.getElementById('templateCategory').value = template.category; document.getElementById('templateTags').value = template.tags.join(', '); document.getElementById('templatePublic').checked = template.isPublic; document.getElementById('templateAttribution').checked = template.allowAttribution; } // Share template shareTemplate(templateId) { const template = this.myTemplates.find(t => t.id == templateId); if (!template) return; const shareUrl = `${window.location.origin}/template/${templateId}`; const shareText = `Check out my bookmark template: "${template.name}" - ${template.bookmarks.length} curated bookmarks for ${template.category}.`; if (navigator.share) { navigator.share({ title: template.name, text: shareText, url: shareUrl }); } else { // Fallback to copy to clipboard navigator.clipboard.writeText(`${shareText}\n${shareUrl}`).then(() => { alert('Template share link copied to clipboard!'); }); } } // Delete template deleteTemplate(templateId) { const template = this.myTemplates.find(t => t.id == templateId); if (!template) return; if (confirm(`Are you sure you want to delete the template "${template.name}"?`)) { this.myTemplates = this.myTemplates.filter(t => t.id != templateId); this.saveMyTemplates(); this.loadMyTemplates(); alert('Template deleted successfully.'); } } // Storage methods for sharing data loadSharedCollections() { try { return JSON.parse(localStorage.getItem('sharedCollections') || '[]'); } catch (e) { return []; } } storeSharedCollection(shareData) { const collections = this.loadSharedCollections(); collections.push(shareData); localStorage.setItem('sharedCollections', JSON.stringify(collections)); } loadBookmarkTemplates() { try { return JSON.parse(localStorage.getItem('bookmarkTemplates') || '[]'); } catch (e) { return []; } } loadMyTemplates() { try { return JSON.parse(localStorage.getItem('myTemplates') || '[]'); } catch (e) { return []; } } saveMyTemplates() { localStorage.setItem('myTemplates', JSON.stringify(this.myTemplates)); } // ===== SECURITY AND PRIVACY METHODS ===== // Load security settings from storage loadSecuritySettings() { try { const saved = localStorage.getItem('bookmarkManager_securitySettings'); if (saved) { this.securitySettings = { ...this.securitySettings, ...JSON.parse(saved) }; } } catch (error) { console.warn('Failed to load security settings:', error); } } // Save security settings to storage saveSecuritySettings() { try { localStorage.setItem('bookmarkManager_securitySettings', JSON.stringify(this.securitySettings)); } catch (error) { console.error('Failed to save security settings:', error); } } // Load access log from storage loadAccessLog() { try { const saved = localStorage.getItem('bookmarkManager_accessLog'); if (saved) { this.accessLog = JSON.parse(saved); // Keep only last 1000 entries to prevent storage bloat if (this.accessLog.length > 1000) { this.accessLog = this.accessLog.slice(-1000); this.saveAccessLog(); } } } catch (error) { console.warn('Failed to load access log:', error); this.accessLog = []; } } // Save access log to storage saveAccessLog() { try { localStorage.setItem('bookmarkManager_accessLog', JSON.stringify(this.accessLog)); } catch (error) { console.error('Failed to save access log:', error); } } // Initialize security features initializeSecurity() { // Check for session timeout this.checkSessionTimeout(); // Set up periodic session checks setInterval(() => { this.checkSessionTimeout(); }, 60000); // Check every minute // Track user activity this.trackUserActivity(); // Load private bookmarks and encrypted collections this.loadPrivacySettings(); } // Load privacy settings loadPrivacySettings() { try { const privateBookmarks = localStorage.getItem('bookmarkManager_privateBookmarks'); if (privateBookmarks) { this.privateBookmarks = new Set(JSON.parse(privateBookmarks)); } const encryptedCollections = localStorage.getItem('bookmarkManager_encryptedCollections'); if (encryptedCollections) { this.encryptedCollections = new Set(JSON.parse(encryptedCollections)); } } catch (error) { console.warn('Failed to load privacy settings:', error); } } // Save privacy settings savePrivacySettings() { try { localStorage.setItem('bookmarkManager_privateBookmarks', JSON.stringify([...this.privateBookmarks])); localStorage.setItem('bookmarkManager_encryptedCollections', JSON.stringify([...this.encryptedCollections])); } catch (error) { console.error('Failed to save privacy settings:', error); } } // Log access events logAccess(action, details = {}) { if (!this.securitySettings.accessLogging) return; const logEntry = { timestamp: Date.now(), action: action, details: details, userAgent: navigator.userAgent, ip: 'client-side', // Note: Real IP would need server-side logging sessionId: this.getSessionId() }; this.accessLog.push(logEntry); // Keep only last 1000 entries if (this.accessLog.length > 1000) { this.accessLog = this.accessLog.slice(-1000); } this.saveAccessLog(); } // Get or create session ID getSessionId() { let sessionId = sessionStorage.getItem('bookmarkManager_sessionId'); if (!sessionId) { sessionId = Date.now() + '_' + Math.random().toString(36).substr(2, 9); sessionStorage.setItem('bookmarkManager_sessionId', sessionId); } return sessionId; } // Track user activity for session management trackUserActivity() { const updateActivity = () => { this.securitySession.lastActivity = Date.now(); }; // Track various user interactions document.addEventListener('click', updateActivity); document.addEventListener('keypress', updateActivity); document.addEventListener('scroll', updateActivity); document.addEventListener('mousemove', updateActivity); } // Check session timeout checkSessionTimeout() { if (!this.securitySettings.passwordProtection) return; const now = Date.now(); const timeSinceActivity = now - this.securitySession.lastActivity; if (this.securitySession.isAuthenticated && timeSinceActivity > this.securitySettings.sessionTimeout) { this.logAccess('session_timeout'); this.lockApplication(); } // Check if lockout period has expired if (this.securitySession.lockedUntil && now > this.securitySession.lockedUntil) { this.securitySession.lockedUntil = null; this.securitySession.loginAttempts = 0; } } // Lock the application lockApplication() { this.securitySession.isAuthenticated = false; this.showSecurityModal(); this.logAccess('application_locked'); } // Show security/authentication modal showSecurityModal() { this.showModal('securityModal'); } // Authenticate user async authenticateUser(password) { if (this.securitySession.lockedUntil && Date.now() < this.securitySession.lockedUntil) { const remainingTime = Math.ceil((this.securitySession.lockedUntil - Date.now()) / 60000); alert(`Account locked. Try again in ${remainingTime} minute(s).`); return false; } // Simple password check (in production, use proper hashing) const storedPasswordHash = localStorage.getItem('bookmarkManager_passwordHash'); const passwordHash = await this.hashPassword(password); if (storedPasswordHash === passwordHash) { this.securitySession.isAuthenticated = true; this.securitySession.loginAttempts = 0; this.securitySession.lastActivity = Date.now(); this.hideModal('securityModal'); this.logAccess('successful_login'); return true; } else { this.securitySession.loginAttempts++; this.logAccess('failed_login_attempt', { attempts: this.securitySession.loginAttempts }); if (this.securitySession.loginAttempts >= this.securitySettings.maxLoginAttempts) { this.securitySession.lockedUntil = Date.now() + this.securitySettings.lockoutDuration; this.logAccess('account_locked', { duration: this.securitySettings.lockoutDuration }); alert('Too many failed attempts. Account locked for 15 minutes.'); } else { const remaining = this.securitySettings.maxLoginAttempts - this.securitySession.loginAttempts; alert(`Incorrect password. ${remaining} attempt(s) remaining.`); } return false; } } // Hash password (simple implementation - use bcrypt in production) async hashPassword(password) { const encoder = new TextEncoder(); const data = encoder.encode(password + 'bookmark_salt_2024'); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // Set application password async setApplicationPassword(password) { if (password.length < 8) { alert('Password must be at least 8 characters long.'); return false; } const passwordHash = await this.hashPassword(password); localStorage.setItem('bookmarkManager_passwordHash', passwordHash); this.securitySettings.passwordProtection = true; this.saveSecuritySettings(); this.logAccess('password_set'); return true; } // Encrypt bookmark data async encryptBookmark(bookmark, password) { try { const key = await this.deriveKey(password); const data = JSON.stringify(bookmark); const encrypted = await this.encryptData(data, key); return encrypted; } catch (error) { console.error('Encryption failed:', error); throw error; } } // Decrypt bookmark data async decryptBookmark(encryptedData, password) { try { const key = await this.deriveKey(password); const decrypted = await this.decryptData(encryptedData, key); return JSON.parse(decrypted); } catch (error) { console.error('Decryption failed:', error); throw error; } } // Derive encryption key from password async deriveKey(password) { const encoder = new TextEncoder(); const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits', 'deriveKey'] ); return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: encoder.encode('bookmark_encryption_salt_2024'), iterations: 100000, hash: 'SHA-256' }, keyMaterial, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); } // Encrypt data using AES-GCM async encryptData(data, key) { const encoder = new TextEncoder(); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, encoder.encode(data) ); // Combine IV and encrypted data const combined = new Uint8Array(iv.length + encrypted.byteLength); combined.set(iv); combined.set(new Uint8Array(encrypted), iv.length); return btoa(String.fromCharCode(...combined)); } // Decrypt data using AES-GCM async decryptData(encryptedData, key) { const combined = new Uint8Array(atob(encryptedData).split('').map(c => c.charCodeAt(0))); const iv = combined.slice(0, 12); const data = combined.slice(12); const decrypted = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, key, data ); return new TextDecoder().decode(decrypted); } // Toggle bookmark privacy status toggleBookmarkPrivacy(bookmarkId) { if (this.privateBookmarks.has(bookmarkId)) { this.privateBookmarks.delete(bookmarkId); this.logAccess('bookmark_made_public', { bookmarkId }); } else { this.privateBookmarks.add(bookmarkId); this.logAccess('bookmark_made_private', { bookmarkId }); } this.savePrivacySettings(); this.renderBookmarks(this.getFilteredBookmarks()); } // Check if bookmark is private isBookmarkPrivate(bookmarkId) { return this.privateBookmarks.has(bookmarkId); } // Get filtered bookmarks for export (excluding private ones if privacy mode is on) getExportableBookmarks(bookmarks = this.bookmarks) { if (!this.securitySettings.privacyMode) { return bookmarks; } return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id)); } // Generate secure sharing URL with password protection generateSecureShareUrl(bookmarks, options = {}) { const shareData = { bookmarks: this.getExportableBookmarks(bookmarks), metadata: { title: options.title || 'Shared Bookmarks', description: options.description || '', createdAt: Date.now(), expiresAt: options.expiresAt || null, passwordProtected: !!options.password, allowDownload: options.allowDownload !== false, allowComments: options.allowComments !== false } }; // Generate unique share ID const shareId = Date.now() + '_' + Math.random().toString(36).substr(2, 9); // Store share data (in production, this would be server-side) const shareKey = `bookmarkManager_share_${shareId}`; if (options.password) { // Encrypt share data with password this.encryptBookmark(shareData, options.password).then(encrypted => { localStorage.setItem(shareKey, JSON.stringify({ encrypted: true, data: encrypted, metadata: shareData.metadata })); }); } else { localStorage.setItem(shareKey, JSON.stringify({ encrypted: false, data: shareData, metadata: shareData.metadata })); } this.logAccess('share_created', { shareId, passwordProtected: !!options.password }); // Return shareable URL (in production, this would be a proper URL) return `${window.location.origin}${window.location.pathname}?share=${shareId}`; } // View security audit log showSecurityAuditModal() { this.showModal('securityAuditModal'); this.populateSecurityAuditLog(); } // Populate security audit log populateSecurityAuditLog() { const logContainer = document.getElementById('securityAuditLog'); if (!logContainer) return; logContainer.innerHTML = ''; // Sort log entries by timestamp (newest first) const sortedLog = [...this.accessLog].sort((a, b) => b.timestamp - a.timestamp); sortedLog.slice(0, 100).forEach(entry => { // Show last 100 entries const logItem = document.createElement('div'); logItem.className = 'audit-log-item'; const date = new Date(entry.timestamp).toLocaleString(); const actionClass = this.getActionClass(entry.action); logItem.innerHTML = `
${entry.action.replace(/_/g, ' ').toUpperCase()} ${date}
Session: ${entry.sessionId}
${entry.details && Object.keys(entry.details).length > 0 ? `
${JSON.stringify(entry.details)}
` : ''}
`; logContainer.appendChild(logItem); }); if (sortedLog.length === 0) { logContainer.innerHTML = '
No audit log entries found.
'; } } // Get CSS class for audit log action getActionClass(action) { const actionClasses = { 'successful_login': 'success', 'failed_login_attempt': 'warning', 'account_locked': 'danger', 'session_timeout': 'warning', 'application_locked': 'warning', 'bookmark_made_private': 'info', 'bookmark_made_public': 'info', 'share_created': 'success', 'password_set': 'success' }; return actionClasses[action] || 'default'; } // Export security audit log exportSecurityAuditLog() { const logData = { exportDate: new Date().toISOString(), totalEntries: this.accessLog.length, entries: this.accessLog }; const blob = new Blob([JSON.stringify(logData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `bookmark_security_audit_${new Date().toISOString().split('T')[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); this.logAccess('audit_log_exported'); } // Clear security audit log clearSecurityAuditLog() { if (confirm('Are you sure you want to clear the security audit log? This action cannot be undone.')) { this.accessLog = []; this.saveAccessLog(); this.populateSecurityAuditLog(); this.logAccess('audit_log_cleared'); } } // Bind security-related events bindSecurityEvents() { // Security button in toolbar const securityBtn = document.createElement('button'); securityBtn.id = 'securityBtn'; securityBtn.className = 'btn btn-secondary'; securityBtn.innerHTML = '🔒 Security'; securityBtn.setAttribute('aria-label', 'Open security settings'); securityBtn.addEventListener('click', () => { this.showSecuritySettingsModal(); }); // Add security button to toolbar const actionsDiv = document.querySelector('.actions'); if (actionsDiv) { actionsDiv.appendChild(securityBtn); } } // Show security settings modal showSecuritySettingsModal() { this.showModal('securitySettingsModal'); this.populateSecuritySettings(); } // Populate security settings form populateSecuritySettings() { const form = document.getElementById('securitySettingsForm'); if (!form) return; // Update form fields with current settings const encryptionEnabled = form.querySelector('#encryptionEnabled'); const privacyMode = form.querySelector('#privacyMode'); const accessLogging = form.querySelector('#accessLogging'); const passwordProtection = form.querySelector('#passwordProtection'); const sessionTimeout = form.querySelector('#sessionTimeout'); if (encryptionEnabled) encryptionEnabled.checked = this.securitySettings.encryptionEnabled; if (privacyMode) privacyMode.checked = this.securitySettings.privacyMode; if (accessLogging) accessLogging.checked = this.securitySettings.accessLogging; if (passwordProtection) passwordProtection.checked = this.securitySettings.passwordProtection; if (sessionTimeout) sessionTimeout.value = this.securitySettings.sessionTimeout / 60000; // Convert to minutes } // Save security settings from form async saveSecuritySettings() { const form = document.getElementById('securitySettingsForm'); if (!form) return; const formData = new FormData(form); // Update security settings this.securitySettings.encryptionEnabled = formData.has('encryptionEnabled'); this.securitySettings.privacyMode = formData.has('privacyMode'); this.securitySettings.accessLogging = formData.has('accessLogging'); this.securitySettings.passwordProtection = formData.has('passwordProtection'); this.securitySettings.sessionTimeout = parseInt(formData.get('sessionTimeout')) * 60000; // Convert to milliseconds // Handle password setup const newPassword = formData.get('newPassword'); if (this.securitySettings.passwordProtection && newPassword) { const success = await this.setApplicationPassword(newPassword); if (!success) { return; // Don't save settings if password setup failed } } this.saveSecuritySettings(); this.hideModal('securitySettingsModal'); this.logAccess('security_settings_updated'); alert('Security settings saved successfully.'); } // Toggle bookmark encryption status async toggleBookmarkEncryption(bookmarkId) { const bookmark = this.bookmarks.find(b => b.id == bookmarkId); if (!bookmark) return; if (this.encryptedCollections.has(bookmarkId)) { // Decrypt bookmark const password = prompt('Enter password to decrypt this bookmark:'); if (password) { try { // In a real implementation, you would decrypt the bookmark data here this.encryptedCollections.delete(bookmarkId); this.logAccess('bookmark_decrypted', { bookmarkId }); alert('Bookmark decrypted successfully.'); } catch (error) { alert('Failed to decrypt bookmark. Incorrect password?'); } } } else { // Encrypt bookmark const password = prompt('Enter password to encrypt this bookmark:'); if (password && password.length >= 8) { try { // In a real implementation, you would encrypt the bookmark data here this.encryptedCollections.add(bookmarkId); this.logAccess('bookmark_encrypted', { bookmarkId }); alert('Bookmark encrypted successfully.'); } catch (error) { alert('Failed to encrypt bookmark.'); } } else if (password) { alert('Password must be at least 8 characters long.'); } } this.savePrivacySettings(); this.renderBookmarks(this.getFilteredBookmarks()); } // Check if bookmark is encrypted isBookmarkEncrypted(bookmarkId) { return this.encryptedCollections.has(bookmarkId); } // Update context modal with privacy settings updateContextModalPrivacyControls(bookmark) { const privacyToggle = document.getElementById('contextPrivacyToggle'); const encryptionToggle = document.getElementById('contextEncryptionToggle'); if (privacyToggle) { privacyToggle.checked = this.isBookmarkPrivate(bookmark.id); } if (encryptionToggle) { encryptionToggle.checked = this.isBookmarkEncrypted(bookmark.id); } } // Override the existing export methods to respect privacy mode getExportableBookmarks(bookmarks = this.bookmarks) { if (!this.securitySettings.privacyMode) { return bookmarks; } return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id)); } // Update the existing showBookmarkModal method to include privacy controls showBookmarkModal(bookmark = null) { this.currentEditId = bookmark ? bookmark.id : null; const modal = document.getElementById('bookmarkModal'); const title = document.getElementById('modalTitle'); const titleInput = document.getElementById('bookmarkTitle'); const urlInput = document.getElementById('bookmarkUrl'); const folderInput = document.getElementById('bookmarkFolder'); const tagsInput = document.getElementById('bookmarkTags'); const notesInput = document.getElementById('bookmarkNotes'); const ratingInput = document.getElementById('bookmarkRating'); const favoriteInput = document.getElementById('bookmarkFavorite'); if (bookmark) { title.textContent = 'Edit Bookmark'; titleInput.value = bookmark.title || ''; urlInput.value = bookmark.url || ''; folderInput.value = bookmark.folder || ''; tagsInput.value = bookmark.tags ? bookmark.tags.join(', ') : ''; notesInput.value = bookmark.notes || ''; ratingInput.value = bookmark.rating || 0; favoriteInput.checked = bookmark.favorite || false; // Update star rating display this.updateStarRating(bookmark.rating || 0); } else { title.textContent = 'Add Bookmark'; titleInput.value = ''; urlInput.value = ''; folderInput.value = ''; tagsInput.value = ''; notesInput.value = ''; ratingInput.value = 0; favoriteInput.checked = false; // Reset star rating display this.updateStarRating(0); } // Populate folder datalist this.populateFolderDatalist(); this.showModal('bookmarkModal'); titleInput.focus(); } } // Initialize the bookmark manager when the page loads let bookmarkManager; document.addEventListener('DOMContentLoaded', () => { bookmarkManager = new BookmarkManager(); });