Files
bookmarksite/frontend/script.js
2025-07-20 20:43:06 +02:00

10769 lines
397 KiB
JavaScript

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
// Authentication properties
this.user = null;
this.isAuthenticated = false;
this.apiBaseUrl = '/api'; // Backend API base URL
this.authCheckInterval = null;
// 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();
}
// Authentication Methods
async checkAuthenticationStatus() {
try {
const response = await fetch(`${this.apiBaseUrl}/user/verify-token`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
this.isAuthenticated = true;
return true;
} else {
this.user = null;
this.isAuthenticated = false;
return false;
}
} catch (error) {
console.error('Auth check error:', error);
this.user = null;
this.isAuthenticated = false;
return false;
}
}
redirectToLogin() {
window.location.href = 'login.html';
}
async logout() {
try {
await fetch(`${this.apiBaseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Logout error:', error);
} finally {
// Clear local data and redirect
localStorage.removeItem('user');
this.user = null;
this.isAuthenticated = false;
this.redirectToLogin();
}
}
async refreshToken() {
try {
const response = await fetch(`${this.apiBaseUrl}/auth/refresh`, {
method: 'POST',
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
return true;
} else {
return false;
}
} catch (error) {
console.error('Token refresh error:', error);
return false;
}
}
startAuthenticationMonitoring() {
// Check authentication status every 5 minutes
this.authCheckInterval = setInterval(async () => {
const isValid = await this.checkAuthenticationStatus();
if (!isValid) {
this.handleAuthenticationFailure();
}
}, 5 * 60 * 1000);
// Try to refresh token every 20 minutes
setInterval(async () => {
if (this.isAuthenticated) {
await this.refreshToken();
}
}, 20 * 60 * 1000);
}
handleAuthenticationFailure() {
if (this.authCheckInterval) {
clearInterval(this.authCheckInterval);
}
// Show a message before redirecting
alert('Your session has expired. Please log in again.');
this.redirectToLogin();
}
addUserMenuToHeader() {
const header = document.querySelector('header');
if (!header || !this.user) return;
// Create user menu container
const userMenuContainer = document.createElement('div');
userMenuContainer.className = 'user-menu-container';
userMenuContainer.innerHTML = `
<div class="user-info">
<span class="user-email">${this.user.email}</span>
<button class="user-menu-toggle" id="userMenuToggle" aria-label="User menu">
<span class="user-avatar">${this.user.email.charAt(0).toUpperCase()}</span>
<span class="dropdown-arrow">▼</span>
</button>
</div>
<div class="user-menu-dropdown" id="userMenuDropdown" style="display: none;">
<a href="#" class="menu-item" id="profileMenuItem">
<span class="menu-icon">👤</span>
Profile
</a>
<a href="#" class="menu-item" id="settingsMenuItem">
<span class="menu-icon">⚙️</span>
Settings
</a>
<div class="menu-divider"></div>
<a href="#" class="menu-item" id="logoutMenuItem">
<span class="menu-icon">🚪</span>
Logout
</a>
</div>
`;
// Insert user menu before header actions
const headerActions = header.querySelector('.header-actions');
if (headerActions) {
header.insertBefore(userMenuContainer, headerActions);
} else {
header.appendChild(userMenuContainer);
}
// Bind user menu events
this.bindUserMenuEvents();
}
bindUserMenuEvents() {
const userMenuToggle = document.getElementById('userMenuToggle');
const userMenuDropdown = document.getElementById('userMenuDropdown');
const profileMenuItem = document.getElementById('profileMenuItem');
const settingsMenuItem = document.getElementById('settingsMenuItem');
const logoutMenuItem = document.getElementById('logoutMenuItem');
if (userMenuToggle && userMenuDropdown) {
userMenuToggle.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = userMenuDropdown.style.display !== 'none';
userMenuDropdown.style.display = isVisible ? 'none' : 'block';
});
// Close menu when clicking outside
document.addEventListener('click', () => {
userMenuDropdown.style.display = 'none';
});
userMenuDropdown.addEventListener('click', (e) => {
e.stopPropagation();
});
}
if (profileMenuItem) {
profileMenuItem.addEventListener('click', (e) => {
e.preventDefault();
this.showProfileModal();
userMenuDropdown.style.display = 'none';
});
}
if (settingsMenuItem) {
settingsMenuItem.addEventListener('click', (e) => {
e.preventDefault();
this.showSettingsModal();
userMenuDropdown.style.display = 'none';
});
}
if (logoutMenuItem) {
logoutMenuItem.addEventListener('click', (e) => {
e.preventDefault();
if (confirm('Are you sure you want to logout?')) {
this.logout();
}
userMenuDropdown.style.display = 'none';
});
}
}
async loadBookmarksFromAPI() {
try {
this.isLoading = true;
this.showLoadingState();
const response = await fetch(`${this.apiBaseUrl}/bookmarks`, {
credentials: 'include'
});
if (response.ok) {
const data = await response.json();
this.bookmarks = data.bookmarks || [];
this.logAccess('bookmarks_loaded', { count: this.bookmarks.length });
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return;
} else {
console.error('Failed to load bookmarks:', response.statusText);
this.showError('Failed to load bookmarks. Please try again.');
}
} catch (error) {
console.error('Error loading bookmarks:', error);
this.showError('Network error while loading bookmarks.');
} finally {
this.isLoading = false;
this.hideLoadingState();
}
}
async saveBookmarkToAPI(bookmarkData) {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(bookmarkData)
});
if (response.ok) {
const data = await response.json();
return data.bookmark;
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return null;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to save bookmark');
}
} catch (error) {
console.error('Error saving bookmark:', error);
throw error;
}
}
async updateBookmarkInAPI(bookmarkId, updates) {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks/${bookmarkId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(updates)
});
if (response.ok) {
const data = await response.json();
return data.bookmark;
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return null;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to update bookmark');
}
} catch (error) {
console.error('Error updating bookmark:', error);
throw error;
}
}
async deleteBookmarkFromAPI(bookmarkId) {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks/${bookmarkId}`, {
method: 'DELETE',
credentials: 'include'
});
if (response.ok) {
return true;
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return false;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to delete bookmark');
}
} catch (error) {
console.error('Error deleting bookmark:', error);
throw error;
}
}
showLoadingState() {
const bookmarksList = document.getElementById('bookmarksList');
if (bookmarksList) {
bookmarksList.innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<h3>Loading your bookmarks...</h3>
<p>Please wait while we fetch your bookmark collection.</p>
</div>
`;
}
}
hideLoadingState() {
// Loading state will be replaced by renderBookmarks()
}
showError(message) {
const bookmarksList = document.getElementById('bookmarksList');
if (bookmarksList) {
bookmarksList.innerHTML = `
<div class="error-state">
<h3>Error</h3>
<p>${message}</p>
<button class="btn btn-primary" onclick="location.reload()">Retry</button>
</div>
`;
}
}
showProfileModal() {
// Create profile modal if it doesn't exist
let profileModal = document.getElementById('profileModal');
if (!profileModal) {
profileModal = document.createElement('div');
profileModal.id = 'profileModal';
profileModal.className = 'modal';
profileModal.innerHTML = `
<div class="modal-content">
<button class="close" aria-label="Close profile dialog">&times;</button>
<h2>User Profile</h2>
<form id="profileForm">
<div class="form-group">
<label for="profileEmail">Email Address</label>
<input type="email" id="profileEmail" name="email" required>
</div>
<div class="form-group">
<label for="profileCreatedAt">Account Created</label>
<input type="text" id="profileCreatedAt" readonly>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Update Profile</button>
<button type="button" id="changePasswordBtn" class="btn btn-secondary">Change Password</button>
<button type="button" id="cancelProfileBtn" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
`;
document.body.appendChild(profileModal);
// Bind profile modal events
profileModal.querySelector('.close').addEventListener('click', () => {
this.hideModal('profileModal');
});
profileModal.querySelector('#cancelProfileBtn').addEventListener('click', () => {
this.hideModal('profileModal');
});
profileModal.querySelector('#profileForm').addEventListener('submit', (e) => {
this.handleProfileUpdate(e);
});
profileModal.querySelector('#changePasswordBtn').addEventListener('click', () => {
this.showChangePasswordModal();
});
}
// Populate form with current user data
if (this.user) {
document.getElementById('profileEmail').value = this.user.email;
document.getElementById('profileCreatedAt').value = new Date(this.user.created_at).toLocaleDateString();
}
this.showModal('profileModal');
}
async handleProfileUpdate(e) {
e.preventDefault();
const formData = new FormData(e.target);
const email = formData.get('email');
try {
const response = await fetch(`${this.apiBaseUrl}/user/profile`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email })
});
if (response.ok) {
const data = await response.json();
this.user = data.user;
this.hideModal('profileModal');
alert('Profile updated successfully!');
// Update user menu display
const userEmail = document.querySelector('.user-email');
if (userEmail) {
userEmail.textContent = this.user.email;
}
} else if (response.status === 401) {
this.handleAuthenticationFailure();
} else {
const errorData = await response.json();
alert(errorData.error || 'Failed to update profile');
}
} catch (error) {
console.error('Profile update error:', error);
alert('Network error. Please try again.');
}
}
showChangePasswordModal() {
// Create change password modal if it doesn't exist
let changePasswordModal = document.getElementById('changePasswordModal');
if (!changePasswordModal) {
changePasswordModal = document.createElement('div');
changePasswordModal.id = 'changePasswordModal';
changePasswordModal.className = 'modal';
changePasswordModal.innerHTML = `
<div class="modal-content">
<button class="close" aria-label="Close change password dialog">&times;</button>
<h2>Change Password</h2>
<form id="changePasswordForm">
<div class="form-group">
<label for="currentPassword">Current Password</label>
<input type="password" id="currentPassword" name="currentPassword" required>
</div>
<div class="form-group">
<label for="newPassword">New Password</label>
<input type="password" id="newPassword" name="newPassword" required>
<div class="password-requirements" id="changePasswordRequirements">
<div class="requirement" id="change-req-length">
<span class="req-icon">○</span>
<span class="req-text">At least 8 characters</span>
</div>
<div class="requirement" id="change-req-uppercase">
<span class="req-icon">○</span>
<span class="req-text">One uppercase letter</span>
</div>
<div class="requirement" id="change-req-lowercase">
<span class="req-icon">○</span>
<span class="req-text">One lowercase letter</span>
</div>
<div class="requirement" id="change-req-number">
<span class="req-icon">○</span>
<span class="req-text">One number</span>
</div>
<div class="requirement" id="change-req-special">
<span class="req-icon">○</span>
<span class="req-text">One special character</span>
</div>
</div>
</div>
<div class="form-group">
<label for="confirmNewPassword">Confirm New Password</label>
<input type="password" id="confirmNewPassword" name="confirmNewPassword" required>
</div>
<div class="modal-actions">
<button type="submit" class="btn btn-primary">Change Password</button>
<button type="button" id="cancelChangePasswordBtn" class="btn btn-secondary">Cancel</button>
</div>
</form>
</div>
`;
document.body.appendChild(changePasswordModal);
// Bind change password modal events
changePasswordModal.querySelector('.close').addEventListener('click', () => {
this.hideModal('changePasswordModal');
});
changePasswordModal.querySelector('#cancelChangePasswordBtn').addEventListener('click', () => {
this.hideModal('changePasswordModal');
});
changePasswordModal.querySelector('#changePasswordForm').addEventListener('submit', (e) => {
this.handleChangePassword(e);
});
// Add password validation
changePasswordModal.querySelector('#newPassword').addEventListener('input', (e) => {
this.validateChangePassword(e.target.value);
});
changePasswordModal.querySelector('#confirmNewPassword').addEventListener('input', (e) => {
this.validateChangePasswordMatch(e.target);
});
}
this.hideModal('profileModal');
this.showModal('changePasswordModal');
}
validateChangePassword(password) {
const requirements = {
'change-req-length': password.length >= 8,
'change-req-uppercase': /[A-Z]/.test(password),
'change-req-lowercase': /[a-z]/.test(password),
'change-req-number': /\d/.test(password),
'change-req-special': /[!@#$%^&*(),.?":{}|<>]/.test(password)
};
Object.entries(requirements).forEach(([reqId, isValid]) => {
const reqElement = document.getElementById(reqId);
if (reqElement) {
reqElement.classList.toggle('valid', isValid);
reqElement.classList.toggle('invalid', !isValid);
}
});
return Object.values(requirements).every(req => req);
}
validateChangePasswordMatch(confirmInput) {
const passwordInput = document.getElementById('newPassword');
const isMatch = confirmInput.value === passwordInput.value;
confirmInput.setCustomValidity(isMatch ? '' : 'Passwords do not match');
return isMatch;
}
async handleChangePassword(e) {
e.preventDefault();
const formData = new FormData(e.target);
const currentPassword = formData.get('currentPassword');
const newPassword = formData.get('newPassword');
const confirmNewPassword = formData.get('confirmNewPassword');
// Validate password requirements
if (!this.validateChangePassword(newPassword)) {
alert('Please ensure your new password meets all requirements.');
return;
}
// Validate password confirmation
if (newPassword !== confirmNewPassword) {
alert('New passwords do not match.');
return;
}
try {
const response = await fetch(`${this.apiBaseUrl}/user/change-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
currentPassword,
newPassword
})
});
if (response.ok) {
this.hideModal('changePasswordModal');
alert('Password changed successfully!');
// Clear form
document.getElementById('changePasswordForm').reset();
} else if (response.status === 401) {
const errorData = await response.json();
if (errorData.code === 'INVALID_CURRENT_PASSWORD') {
alert('Current password is incorrect.');
} else {
this.handleAuthenticationFailure();
}
} else {
const errorData = await response.json();
alert(errorData.error || 'Failed to change password');
}
} catch (error) {
console.error('Change password error:', error);
alert('Network error. Please try again.');
}
}
async importBookmarksToAPI(bookmarks) {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks/bulk`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ bookmarks })
});
if (response.ok) {
const data = await response.json();
return { success: true, count: data.count };
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return { success: false, error: 'Authentication failed' };
} else {
const errorData = await response.json();
return { success: false, error: errorData.error || 'Failed to import bookmarks' };
}
} catch (error) {
console.error('Error importing bookmarks:', error);
return { success: false, error: error.message };
}
}
async clearAllBookmarksFromAPI() {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ confirm: 'DELETE_ALL_BOOKMARKS' })
});
if (response.ok) {
return true;
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return false;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to clear bookmarks');
}
} catch (error) {
console.error('Error clearing bookmarks:', error);
throw error;
}
}
async exportBookmarksFromAPI() {
try {
const response = await fetch(`${this.apiBaseUrl}/bookmarks/export`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ format: 'json' })
});
if (response.ok) {
const data = await response.json();
return data.bookmarks;
} else if (response.status === 401) {
this.handleAuthenticationFailure();
return null;
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to export bookmarks');
}
} catch (error) {
console.error('Error exporting bookmarks:', error);
throw error;
}
}
// Update all API calls to include proper error handling for authentication
async makeAuthenticatedRequest(url, options = {}) {
const defaultOptions = {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers
}
};
const finalOptions = { ...defaultOptions, ...options };
try {
const response = await fetch(url, finalOptions);
if (response.status === 401) {
this.handleAuthenticationFailure();
return null;
}
return response;
} catch (error) {
console.error('API request error:', error);
throw error;
}
}
async init() {
// Check authentication status first
await this.checkAuthenticationStatus();
if (!this.isAuthenticated) {
this.redirectToLogin();
return;
}
this.loadLinkTestConfigFromStorage();
this.loadSecuritySettings();
this.loadAccessLog();
this.loadPrivateBookmarks();
this.loadEncryptedCollections();
this.initializeSecurity();
// Load bookmarks from API instead of localStorage
await this.loadBookmarksFromAPI();
this.loadSearchHistory();
this.loadSavedSearches();
this.initializeSharing();
this.bindEvents();
this.renderBookmarks();
this.updateStats();
// Set up periodic auth checks and token refresh
this.startAuthenticationMonitoring();
this.addUserMenuToHeader();
// Initialize migration functionality
this.initializeMigrationModal();
// Check for local bookmarks to migrate
setTimeout(() => this.showMigrationModalIfNeeded(), 1000);
}
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 = `
<div class="audit-log-header">
<span class="audit-action ${actionClass}">${entry.action}</span>
<span class="audit-timestamp">${date}</span>
</div>
<div class="audit-details">
${this.formatAuditDetails(entry.details)}
</div>
<div class="audit-meta">
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
</div>
`;
auditLogContainer.appendChild(logItem);
});
if (sortedLog.length === 0) {
auditLogContainer.innerHTML = '<div class="empty-audit-log">No audit log entries found.</div>';
}
}
// 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 '<em>No additional details</em>';
}
return Object.entries(details)
.map(([key, value]) => `<strong>${key}:</strong> ${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 = `
<div class="audit-log-header">
<span class="audit-action ${actionClass}">${entry.action}</span>
<span class="audit-timestamp">${date}</span>
</div>
<div class="audit-details">
${this.formatAuditDetails(entry.details)}
</div>
<div class="audit-meta">
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
</div>
`;
auditLogContainer.appendChild(logItem);
});
if (sortedLog.length === 0) {
auditLogContainer.innerHTML = '<div class="empty-audit-log">No audit log entries found.</div>';
}
}
// 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 '<em>No additional details</em>';
}
return Object.entries(details)
.map(([key, value]) => `<strong>${key}:</strong> ${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 = `
<div class="audit-log-header">
<span class="audit-action ${actionClass}">${entry.action}</span>
<span class="audit-timestamp">${date}</span>
</div>
<div class="audit-details">
${this.formatAuditDetails(entry.details)}
</div>
<div class="audit-meta">
Session: ${entry.sessionId} | User Agent: ${entry.userAgent.substring(0, 50)}...
</div>
`;
auditLogContainer.appendChild(logItem);
});
if (sortedLog.length === 0) {
auditLogContainer.innerHTML = '<div class="empty-audit-log">No audit log entries found.</div>';
}
}
// 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 '<em>No additional details</em>';
}
return Object.entries(details)
.map(([key, value]) => `<strong>${key}:</strong> ${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 = `
<div class="folder-info">
<div class="folder-name">${this.escapeHtml(folderName || 'Uncategorized')}</div>
<div class="folder-stats">${stats.total} bookmarks (${stats.valid} valid, ${stats.invalid} invalid)</div>
</div>
<div class="folder-actions">
<button class="btn btn-small btn-secondary" onclick="bookmarkManager.renameFolderPrompt('${this.escapeHtml(folderName)}')">Rename</button>
<button class="btn btn-small btn-warning" onclick="bookmarkManager.mergeFolderPrompt('${this.escapeHtml(folderName)}')">Merge</button>
<button class="btn btn-small btn-danger" onclick="bookmarkManager.deleteFolderPrompt('${this.escapeHtml(folderName)}')">Delete</button>
</div>
`;
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 = '<option value="">Move to folder...</option>';
bulkMoveFolder.innerHTML += '<option value="">Uncategorized</option>';
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('<!DOCTYPE NETSCAPE-Bookmark-file-1>') ||
content.includes('<META HTTP-EQUIV="Content-Type"')) {
return 'netscape';
}
}
// Default to Netscape if can't detect
return 'netscape';
}
// Parse Chrome bookmarks JSON
parseChromeBookmarks(content) {
const data = JSON.parse(content);
const bookmarks = [];
const parseFolder = (folder, parentPath = '') => {
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 = `
<div class="preview-bookmark-title">${this.escapeHtml(bookmark.title)}</div>
<div class="preview-bookmark-url">${this.escapeHtml(bookmark.url)}</div>
<div class="preview-bookmark-folder">${this.escapeHtml(bookmark.folder || 'Uncategorized')}</div>
`;
container.appendChild(item);
});
if (newBookmarks.length > 50) {
const moreItem = document.createElement('div');
moreItem.className = 'preview-item';
moreItem.innerHTML = `<div class="preview-bookmark-title">... and ${newBookmarks.length - 50} more bookmarks</div>`;
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 = `
<div class="preview-bookmark-title">
${this.escapeHtml(duplicate.imported.title)}
<span style="color: #6c757d; font-weight: normal;">(${duplicate.reason})</span>
</div>
<div class="preview-bookmark-url">${this.escapeHtml(duplicate.imported.url)}</div>
<div class="preview-bookmark-folder">${this.escapeHtml(duplicate.imported.folder || 'Uncategorized')}</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d;">
<strong>Existing:</strong> ${this.escapeHtml(duplicate.existing.title)}
</div>
`;
container.appendChild(item);
});
if (duplicates.length > 50) {
const moreItem = document.createElement('div');
moreItem.className = 'preview-item';
moreItem.innerHTML = `<div class="preview-bookmark-title">... and ${duplicates.length - 50} more duplicates</div>`;
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 = `
<div style="font-family: monospace; font-size: 8px; line-height: 1;">
<div>█████████████████████████</div>
<div>█ ▄▄▄▄▄ █▀█ █ ▄▄▄▄▄ █</div>
<div>█ █ █ █▀▀ █ █ █ █</div>
<div>█ █▄▄▄█ █▀█ █ █▄▄▄█ █</div>
<div>█▄▄▄▄▄▄▄█▄▀▄█▄▄▄▄▄▄▄█</div>
<div>█▄▄█▄▄▄▄▀██▀▀█▄█▄▀▄▄█</div>
<div>██▄▀█▄▄▄█▀▀▄█▀█▀▄█▄▄█</div>
<div>█▄▄▄▄▄▄▄█▄██▄█▄▄▄█▀██</div>
<div>█ ▄▄▄▄▄ █▄▄▄█ ▄ ▄▄▄▄█</div>
<div>█ █ █ █▄▀▀█▄▄▄▄▀█▄█</div>
<div>█ █▄▄▄█ █▄▄▄█▀█▄▄▄▄▄█</div>
<div>█▄▄▄▄▄▄▄█▄▄▄▄▄▄▄▄▄▄▄█</div>
<div>█████████████████████████</div>
</div>
<div style="margin-top: 10px; font-size: 12px; text-align: center;">
<strong>Sync Code:</strong><br>
<code style="word-break: break-all;">${encodedData.substring(0, 32)}...</code>
</div>
`;
// Store sync data temporarily
this.currentSyncData = encodedData;
// Auto-expire after 5 minutes
setTimeout(() => {
this.currentSyncData = null;
qrDisplay.innerHTML = '<div style="color: #6c757d;">QR Code expired. Generate a new one.</div>';
}, 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 = `
<div style="text-align: center;">
<div style="margin-bottom: 10px;">📷 Camera scanning...</div>
<input type="text" placeholder="Or paste sync code here" id="syncCodeInput" style="width: 100%; padding: 8px;">
<button onclick="bookmarkManager.processSyncCode()" style="margin-top: 10px;" class="btn btn-primary">Process Code</button>
</div>
`;
}
// 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 = `
<div class="sync-status connected">
Local server started on: <strong>192.168.1.100:8080</strong><br>
Share this address with other devices on your network.
</div>
`;
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 = `
<div class="sync-status">
Connecting to ${address}...
</div>
`;
// Simulate connection
setTimeout(() => {
if (Math.random() > 0.3) {
statusDiv.innerHTML = `
<div class="sync-status connected">
Connected to ${address}. Ready to sync bookmarks.
</div>
`;
} else {
statusDiv.innerHTML = `
<div class="sync-status error">
Failed to connect to ${address}. Check address and network.
</div>
`;
}
}, 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) {
// Clear existing bookmarks first
await this.clearAllBookmarksFromAPI();
}
// Import bookmarks via API
const result = await this.importBookmarksToAPI(importedBookmarks);
if (result.success) {
// Reload bookmarks from API
await this.loadBookmarksFromAPI();
// Clear loading state
this.isLoading = false;
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
this.updateStats();
document.getElementById('importModal').style.display = 'none';
alert(`Successfully imported ${result.count} bookmarks!`);
this.logAccess('bookmarks_imported', { count: result.count, replace });
} else {
throw new Error(result.error || 'Failed to import 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.');
}
}
async exportBookmarks() {
if (this.bookmarks.length === 0) {
alert('No bookmarks to export.');
return;
}
try {
const bookmarks = await this.exportBookmarksFromAPI();
if (!bookmarks) return;
const html = this.generateNetscapeHTMLFromBookmarks(bookmarks);
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);
this.logAccess('bookmarks_exported', { count: bookmarks.length });
} catch (error) {
console.error('Export error:', error);
alert('Failed to export bookmarks: ' + error.message);
}
}
generateNetscapeHTMLFromBookmarks(bookmarks) {
return this.generateNetscapeHTML(bookmarks);
}
generateNetscapeHTML(bookmarksToExport = null) {
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 = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
It will be read and overwritten.
DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
`;
// Add bookmarks without folders
noFolderBookmarks.forEach(bookmark => {
html += this.generateBookmarkHTML(bookmark);
});
// Add folders with bookmarks
Object.keys(folders).forEach(folderName => {
html += ` <DT><H3>${this.escapeHtml(folderName)}</H3>\n <DL><p>\n`;
folders[folderName].forEach(bookmark => {
html += this.generateBookmarkHTML(bookmark, ' ');
});
html += ` </DL><p>\n`;
});
html += `</DL><p>`;
return html;
}
generateBookmarkHTML(bookmark, indent = ' ') {
const addDate = Math.floor(new Date(bookmark.addDate).getTime() / 1000);
const iconAttr = bookmark.icon ? ` ICON="${bookmark.icon}"` : '';
return `${indent}<DT><A HREF="${this.escapeHtml(bookmark.url)}" ADD_DATE="${addDate}"${iconAttr}>${this.escapeHtml(bookmark.title)}</A>\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');
}
async 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;
}
try {
const bookmarkData = {
title,
url,
folder,
tags,
notes,
rating,
favorite,
add_date: new Date().toISOString(),
last_modified: new Date().toISOString(),
status: 'unknown'
};
let savedBookmark;
if (this.currentEditId) {
// Edit existing bookmark
savedBookmark = await this.updateBookmarkInAPI(this.currentEditId, bookmarkData);
if (savedBookmark) {
// Update local bookmark
const bookmarkIndex = this.bookmarks.findIndex(b => b.id === this.currentEditId);
if (bookmarkIndex !== -1) {
this.bookmarks[bookmarkIndex] = savedBookmark;
}
}
} else {
// Add new bookmark
savedBookmark = await this.saveBookmarkToAPI(bookmarkData);
if (savedBookmark) {
this.bookmarks.push(savedBookmark);
}
}
if (savedBookmark) {
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
this.updateStats();
document.getElementById('bookmarkModal').style.display = 'none';
this.logAccess('bookmark_saved', { bookmarkId: savedBookmark.id, title: savedBookmark.title });
}
} catch (error) {
console.error('Error saving bookmark:', error);
alert('Failed to save bookmark: ' + error.message);
}
}
async deleteBookmark(id) {
if (confirm('Are you sure you want to delete this bookmark?')) {
try {
const success = await this.deleteBookmarkFromAPI(id);
if (success) {
this.bookmarks = this.bookmarks.filter(b => b.id !== id);
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
this.updateStats();
this.logAccess('bookmark_deleted', { bookmarkId: id });
}
} catch (error) {
console.error('Error deleting bookmark:', error);
alert('Failed to delete bookmark: ' + error.message);
}
}
}
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})`);
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 = `
<span>Added: ${addedDate}</span>
${bookmark.lastModified ? `<span>Modified: ${new Date(bookmark.lastModified).toLocaleDateString()}</span>` : ''}
`;
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 = '<option value="">All Folders</option>';
// 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 = '<div class="empty-history">No recent searches</div>';
return;
}
container.innerHTML = this.searchHistory.map((criteria, index) => {
const description = this.getSearchDescription(criteria);
const timeAgo = this.getTimeAgo(criteria.timestamp);
return `
<div class="search-history-item" data-index="${index}">
<div class="search-history-query">${description}</div>
<div class="search-history-details">${timeAgo}</div>
<div class="search-history-actions">
<button class="btn-use" onclick="bookmarkManager.useSearchFromHistory(${index})">Use</button>
<button class="btn-delete" onclick="bookmarkManager.deleteFromSearchHistory(${index})">Delete</button>
</div>
</div>
`;
}).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 = '<div class="empty-searches">No saved searches</div>';
return;
}
container.innerHTML = this.savedSearches.map((search, index) => {
const description = this.getSearchDescription(search.criteria);
const createdDate = new Date(search.createdAt).toLocaleDateString();
return `
<div class="saved-search-item" data-id="${search.id}">
<div class="saved-search-name">${search.name}</div>
<div class="saved-search-details">${description} • Created ${createdDate}</div>
<div class="saved-search-actions">
<button class="btn-use" onclick="bookmarkManager.useSavedSearch('${search.id}')">Use</button>
<button class="btn-delete" onclick="bookmarkManager.deleteSavedSearch('${search.id}')">Delete</button>
</div>
</div>
`;
}).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 = `
<div class="empty-state">
<h3>No bookmarks found</h3>
<p>Import your bookmarks or add new ones to get started.</p>
</div>
`;
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 = `
<div class="loading-state">
<div class="loading-spinner"></div>
<h3>Loading bookmarks...</h3>
<p>Please wait while we organize your bookmark collection.</p>
</div>
`;
}
// 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 = `
<div class="pagination-info">
Showing page ${this.currentPage} of ${totalPages} (${bookmarks.length} total bookmarks)
</div>
<div class="pagination-buttons">
<button class="btn btn-secondary" id="prevPage" ${this.currentPage <= 1 ? 'disabled' : ''}>
Previous
</button>
<span class="page-numbers">
Page ${this.currentPage} of ${totalPages}
</span>
<button class="btn btn-secondary" id="nextPage" ${this.currentPage >= totalPages ? 'disabled' : ''}>
Next
</button>
</div>
`;
// 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 {
// Use default icon instead of external favicon service
contextFavicon.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=';
}
// Favicon error handling - use default icon only
contextFavicon.onerror = function () {
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 = '<div class="health-issue info"><div class="health-issue-title">No Issues Found</div><div class="health-issue-description">Your bookmark collection is in good health!</div></div>';
return;
}
container.innerHTML = issues.map(issue => `
<div class="health-issue ${issue.type}">
<div class="health-issue-title">
${issue.title}
<span class="health-issue-count">${issue.count}</span>
</div>
<div class="health-issue-description">${issue.description}</div>
</div>
`).join('');
}
displayHealthRecommendations(recommendations) {
const container = document.getElementById('healthRecommendations');
if (!container) return;
if (recommendations.length === 0) {
container.innerHTML = '<div class="recommendation"><div class="recommendation-title">Great Job!</div><div class="recommendation-description">Your bookmark collection is well-maintained.</div></div>';
return;
}
container.innerHTML = recommendations.map(rec => `
<div class="recommendation">
<div class="recommendation-title">${rec.title}</div>
<div class="recommendation-description">${rec.description}</div>
</div>
`).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!');
}
async clearAllBookmarks() {
try {
await this.clearAllBookmarksFromAPI();
this.bookmarks = [];
this.currentFilter = 'all'; // Reset filter when clearing all
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');
this.logAccess('all_bookmarks_cleared');
} catch (error) {
console.error('Error clearing bookmarks:', error);
alert('Failed to clear bookmarks: ' + error.message);
}
}
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 = '<option value="">Select a folder...</option>';
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 = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
<!-- This is an automatically generated file.
It will be read and overwritten.
DO NOT EDIT! -->
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
`;
// Add bookmarks without folders
noFolderBookmarks.forEach(bookmark => {
html += this.generateBookmarkHTML(bookmark);
});
// Add folders with bookmarks
Object.keys(folders).forEach(folderName => {
html += ` <DT><H3>${this.escapeHtml(folderName)}</H3>\n <DL><p>\n`;
folders[folderName].forEach(bookmark => {
html += this.generateBookmarkHTML(bookmark, ' ');
});
html += ` </DL><p>\n`;
});
html += `</DL><p>`;
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 = `
<div class="pull-to-refresh-content">
<div class="pull-to-refresh-icon">↓</div>
<div class="pull-to-refresh-text">Pull to refresh links</div>
</div>
`;
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)}&quote=${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 = `
<div class="collection-title">${collection.title}</div>
<div class="collection-description">${collection.description}</div>
<div class="collection-stats">
<span>${collection.bookmarks} bookmarks</span>
<span>${collection.downloads} downloads</span>
<span>★ ${collection.rating}</span>
</div>
`;
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 = `
<div class="recommendation-title">${rec.title}</div>
<div class="recommendation-description">${rec.description}</div>
<div class="recommendation-stats">
<span>${rec.category}</span>
<span>${Math.round(rec.confidence * 100)}% match</span>
<span>${rec.url}</span>
</div>
<input type="checkbox" class="recommendation-select" data-url="${rec.url}" data-title="${rec.title}">
`;
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 = `
<div class="template-header">
<h3 class="template-title">${template.title}</h3>
<span class="template-category">${template.category}</span>
</div>
<div class="template-description">${template.description}</div>
<div class="template-stats">
<span>${template.bookmarks} bookmarks</span>
<span>${template.downloads} downloads</span>
<span>★ ${template.rating}</span>
</div>
<div class="template-tags">
${template.tags.map(tag => `<span class="template-tag">${tag}</span>`).join('')}
</div>
<div class="template-actions">
<button class="btn btn-primary btn-small" onclick="bookmarkManager.useTemplate('${template.title}')">Use Template</button>
<button class="btn btn-secondary btn-small" onclick="bookmarkManager.previewTemplate('${template.title}')">Preview</button>
</div>
`;
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 = '<p>You haven\'t created any templates yet. Use the "Create Template" tab to get started.</p>';
return;
}
this.myTemplates.forEach(template => {
const item = document.createElement('div');
item.className = 'my-template-item';
item.innerHTML = `
<div class="my-template-info">
<div class="my-template-title">${template.name}</div>
<div class="my-template-meta">
${template.bookmarks.length} bookmarks •
${template.category}
${template.downloads} downloads •
Created ${new Date(template.createdAt).toLocaleDateString()}
</div>
</div>
<div class="my-template-actions">
<button class="btn btn-small btn-secondary" onclick="bookmarkManager.editTemplate('${template.id}')">Edit</button>
<button class="btn btn-small btn-primary" onclick="bookmarkManager.shareTemplate('${template.id}')">Share</button>
<button class="btn btn-small btn-danger" onclick="bookmarkManager.deleteTemplate('${template.id}')">Delete</button>
</div>
`;
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 = `
<div class="audit-log-header">
<span class="audit-action ${actionClass}">${entry.action.replace(/_/g, ' ').toUpperCase()}</span>
<span class="audit-timestamp">${date}</span>
</div>
<div class="audit-details">
<div class="audit-session">Session: ${entry.sessionId}</div>
${entry.details && Object.keys(entry.details).length > 0 ?
`<div class="audit-extra">${JSON.stringify(entry.details)}</div>` : ''}
</div>
`;
logContainer.appendChild(logItem);
});
if (sortedLog.length === 0) {
logContainer.innerHTML = '<div class="empty-state">No audit log entries found.</div>';
}
}
// 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();
}
// Data Migration Methods
/**
* Check if there are local bookmarks to migrate
*/
checkForLocalBookmarks() {
try {
const localBookmarks = localStorage.getItem('bookmarks');
if (localBookmarks) {
const bookmarks = JSON.parse(localBookmarks);
if (Array.isArray(bookmarks) && bookmarks.length > 0) {
return bookmarks;
}
}
} catch (error) {
console.error('Error checking local bookmarks:', error);
}
return null;
}
/**
* Show migration modal if local bookmarks exist
*/
async showMigrationModalIfNeeded() {
// Only show if user is authenticated and has local bookmarks
if (!this.isAuthenticated) return;
const localBookmarks = this.checkForLocalBookmarks();
if (localBookmarks && localBookmarks.length > 0) {
// Check if user has already migrated or dismissed
const migrationStatus = localStorage.getItem('migrationStatus');
if (migrationStatus === 'completed' || migrationStatus === 'dismissed') {
return;
}
// Update the count in the modal
const countElement = document.getElementById('localBookmarkCount');
if (countElement) {
countElement.textContent = localBookmarks.length;
}
this.showModal('migrationModal');
}
}
/**
* Initialize migration modal event listeners
*/
initializeMigrationModal() {
const startMigrationBtn = document.getElementById('startMigrationBtn');
const previewMigrationBtn = document.getElementById('previewMigrationBtn');
const skipMigrationBtn = document.getElementById('skipMigrationBtn');
const closeMigrationBtn = document.getElementById('closeMigrationBtn');
if (startMigrationBtn) {
startMigrationBtn.addEventListener('click', () => this.startMigration());
}
if (previewMigrationBtn) {
previewMigrationBtn.addEventListener('click', () => this.previewMigration());
}
if (skipMigrationBtn) {
skipMigrationBtn.addEventListener('click', () => this.skipMigration());
}
if (closeMigrationBtn) {
closeMigrationBtn.addEventListener('click', () => this.closeMigration());
}
// Strategy change handler
const strategyRadios = document.querySelectorAll('input[name="migrationStrategy"]');
strategyRadios.forEach(radio => {
radio.addEventListener('change', () => this.handleStrategyChange());
});
}
/**
* Handle migration strategy change
*/
handleStrategyChange() {
const selectedStrategy = document.querySelector('input[name="migrationStrategy"]:checked')?.value;
const warningElement = document.getElementById('replaceWarning');
if (warningElement) {
warningElement.style.display = selectedStrategy === 'replace' ? 'block' : 'none';
}
}
/**
* Preview migration without executing
*/
async previewMigration() {
const localBookmarks = this.checkForLocalBookmarks();
if (!localBookmarks) {
alert('No local bookmarks found to migrate.');
return;
}
const selectedStrategy = document.querySelector('input[name="migrationStrategy"]:checked')?.value || 'merge';
try {
// Show preview section
const previewElement = document.getElementById('migrationPreview');
if (previewElement) {
previewElement.style.display = 'block';
}
// Validate local bookmarks
const validationResult = this.validateLocalBookmarks(localBookmarks);
// Update preview stats
document.getElementById('localBookmarksCount').textContent = localBookmarks.length;
document.getElementById('validBookmarksCount').textContent = validationResult.valid.length;
document.getElementById('invalidBookmarksCount').textContent = validationResult.invalid.length;
// If merge strategy, check for duplicates
if (selectedStrategy === 'merge') {
const duplicates = await this.findDuplicatesInMigration(validationResult.valid);
document.getElementById('duplicatesCount').textContent = duplicates.length;
document.getElementById('newBookmarksCount').textContent = validationResult.valid.length - duplicates.length;
} else {
document.getElementById('duplicatesCount').textContent = '0';
document.getElementById('newBookmarksCount').textContent = validationResult.valid.length;
}
// Show validation errors if any
if (validationResult.invalid.length > 0) {
this.showValidationErrors(validationResult.invalid);
}
} catch (error) {
console.error('Preview migration error:', error);
alert('Error previewing migration: ' + error.message);
}
}
/**
* Start the migration process
*/
async startMigration() {
const localBookmarks = this.checkForLocalBookmarks();
if (!localBookmarks) {
alert('No local bookmarks found to migrate.');
return;
}
const selectedStrategy = document.querySelector('input[name="migrationStrategy"]:checked')?.value || 'merge';
// Show confirmation for replace strategy
if (selectedStrategy === 'replace') {
const confirmed = confirm(
'This will permanently delete all your existing bookmarks and replace them with local ones. ' +
'This action cannot be undone. Are you sure you want to continue?'
);
if (!confirmed) return;
}
try {
// Show progress
this.showMigrationProgress();
this.updateMigrationProgress(0, 'Preparing migration...');
// Validate local bookmarks
this.updateMigrationProgress(10, 'Validating bookmarks...');
const validationResult = this.validateLocalBookmarks(localBookmarks);
if (validationResult.valid.length === 0) {
throw new Error('No valid bookmarks found to migrate.');
}
// Call migration API
this.updateMigrationProgress(30, 'Uploading bookmarks...');
const response = await fetch(`${this.apiBaseUrl}/bookmarks/migrate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify({
bookmarks: validationResult.valid,
strategy: selectedStrategy
})
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Migration failed');
}
const result = await response.json();
this.updateMigrationProgress(90, 'Finalizing migration...');
// Show results
this.showMigrationResults(result);
// Clean up localStorage if migration was successful
if (result.summary.successfullyMigrated > 0) {
this.updateMigrationProgress(100, 'Migration completed!');
// Mark migration as completed
localStorage.setItem('migrationStatus', 'completed');
// Optionally clear local bookmarks
const clearLocal = confirm(
'Migration completed successfully! Would you like to clear the local bookmarks from your browser storage?'
);
if (clearLocal) {
localStorage.removeItem('bookmarks');
}
// Reload bookmarks from API
await this.loadBookmarksFromAPI();
}
} catch (error) {
console.error('Migration error:', error);
this.showMigrationError(error.message);
}
}
/**
* Validate local bookmarks format
*/
validateLocalBookmarks(localBookmarks) {
const valid = [];
const invalid = [];
localBookmarks.forEach((bookmark, index) => {
const errors = [];
// Check required fields
if (!bookmark.title || bookmark.title.trim().length === 0) {
errors.push('Missing title');
}
if (!bookmark.url || bookmark.url.trim().length === 0) {
errors.push('Missing URL');
}
// Validate URL format
if (bookmark.url) {
try {
new URL(bookmark.url);
} catch (e) {
errors.push('Invalid URL format');
}
}
// Check field lengths
if (bookmark.title && bookmark.title.length > 500) {
errors.push('Title too long (max 500 characters)');
}
if (bookmark.folder && bookmark.folder.length > 255) {
errors.push('Folder name too long (max 255 characters)');
}
if (errors.length === 0) {
// Transform to API format
valid.push({
title: bookmark.title.trim(),
url: bookmark.url.trim(),
folder: bookmark.folder || '',
add_date: bookmark.addDate || bookmark.add_date || new Date(),
last_modified: bookmark.lastModified || bookmark.last_modified,
icon: bookmark.icon || bookmark.favicon,
status: bookmark.status || 'unknown'
});
} else {
invalid.push({
index,
bookmark,
errors
});
}
});
return { valid, invalid };
}
/**
* Find duplicates in migration bookmarks
*/
async findDuplicatesInMigration(validBookmarks) {
const duplicates = [];
const existingUrls = new Set();
// Get existing bookmarks from current collection
this.bookmarks.forEach(bookmark => {
existingUrls.add(bookmark.url.toLowerCase());
});
// Check for duplicates
validBookmarks.forEach((bookmark, index) => {
if (existingUrls.has(bookmark.url.toLowerCase())) {
duplicates.push({
index,
bookmark,
reason: 'URL already exists'
});
}
});
return duplicates;
}
/**
* Show migration progress
*/
showMigrationProgress() {
const progressElement = document.getElementById('migrationProgress');
if (progressElement) {
progressElement.style.display = 'block';
}
// Hide action buttons
const startBtn = document.getElementById('startMigrationBtn');
const previewBtn = document.getElementById('previewMigrationBtn');
const skipBtn = document.getElementById('skipMigrationBtn');
if (startBtn) startBtn.style.display = 'none';
if (previewBtn) previewBtn.style.display = 'none';
if (skipBtn) skipBtn.style.display = 'none';
}
/**
* Update migration progress
*/
updateMigrationProgress(percentage, message) {
const progressFill = document.getElementById('migrationProgressFill');
const progressText = document.getElementById('migrationProgressText');
if (progressFill) {
progressFill.style.width = `${percentage}%`;
}
if (progressText) {
progressText.textContent = message;
}
}
/**
* Show migration results
*/
showMigrationResults(result) {
const resultsElement = document.getElementById('migrationResults');
if (resultsElement) {
resultsElement.style.display = 'block';
}
// Update result stats
const summary = result.summary;
document.getElementById('migratedCount').textContent = summary.successfullyMigrated || 0;
document.getElementById('skippedCount').textContent = summary.duplicatesSkipped || 0;
document.getElementById('errorCount').textContent = summary.invalidBookmarks || 0;
// Show close button
const closeBtn = document.getElementById('closeMigrationBtn');
if (closeBtn) {
closeBtn.style.display = 'inline-block';
}
// Hide progress
const progressElement = document.getElementById('migrationProgress');
if (progressElement) {
progressElement.style.display = 'none';
}
}
/**
* Show migration error
*/
showMigrationError(errorMessage) {
const progressText = document.getElementById('migrationProgressText');
if (progressText) {
progressText.textContent = `Error: ${errorMessage}`;
progressText.style.color = '#dc3545';
}
// Show close button
const closeBtn = document.getElementById('closeMigrationBtn');
if (closeBtn) {
closeBtn.style.display = 'inline-block';
}
// Show action buttons again
const startBtn = document.getElementById('startMigrationBtn');
const previewBtn = document.getElementById('previewMigrationBtn');
const skipBtn = document.getElementById('skipMigrationBtn');
if (startBtn) startBtn.style.display = 'inline-block';
if (previewBtn) previewBtn.style.display = 'inline-block';
if (skipBtn) skipBtn.style.display = 'inline-block';
}
/**
* Show validation errors
*/
showValidationErrors(invalidBookmarks) {
console.warn('Invalid bookmarks found during migration:', invalidBookmarks);
if (invalidBookmarks.length > 0) {
const errorMessage = `Found ${invalidBookmarks.length} invalid bookmark(s). These will be skipped during migration.`;
alert(errorMessage);
}
}
/**
* Skip migration
*/
skipMigration() {
const confirmed = confirm('Are you sure you want to skip the migration? You can always migrate your bookmarks later.');
if (confirmed) {
localStorage.setItem('migrationStatus', 'dismissed');
this.hideModal('migrationModal');
}
}
/**
* Close migration modal
*/
closeMigration() {
this.hideModal('migrationModal');
}
}
// Initialize the bookmark manager when the page loads
let bookmarkManager;
document.addEventListener('DOMContentLoaded', () => {
bookmarkManager = new BookmarkManager();
});