- Implement PostgreSQL database schema with users and bookmarks tables - Add database connection pooling with retry logic and error handling - Create migration system with automatic schema initialization - Add database CLI tools for management (init, status, validate, etc.) - Include comprehensive error handling and diagnostics - Add development seed data and testing utilities - Implement health monitoring and connection pool statistics - Create detailed documentation and troubleshooting guide Database features: - Users table with authentication fields and email verification - Bookmarks table with user association and metadata - Proper indexes for performance optimization - Automatic timestamp triggers - Transaction support with rollback handling - Connection pooling (20 max connections, 30s idle timeout) - Graceful shutdown handling CLI commands available: - npm run db:init - Initialize database - npm run db:status - Check database status - npm run db:validate - Validate schema - npm run db:test - Run database tests - npm run db:diagnostics - Full diagnostics
9600 lines
355 KiB
JavaScript
9600 lines
355 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
|
|
|
|
// Enhanced link testing configuration
|
|
this.linkTestConfig = {
|
|
timeout: 10000, // Default 10 seconds
|
|
maxRetries: 2, // Maximum retry attempts for transient failures
|
|
retryDelay: 1000, // Delay between retries in milliseconds
|
|
userAgent: 'BookmarkManager/1.0 (Link Checker)'
|
|
};
|
|
|
|
// Error categorization constants
|
|
this.ERROR_CATEGORIES = {
|
|
NETWORK_ERROR: 'network_error',
|
|
TIMEOUT: 'timeout',
|
|
INVALID_URL: 'invalid_url',
|
|
HTTP_ERROR: 'http_error',
|
|
CORS_BLOCKED: 'cors_blocked',
|
|
DNS_ERROR: 'dns_error',
|
|
SSL_ERROR: 'ssl_error',
|
|
CONNECTION_REFUSED: 'connection_refused',
|
|
UNKNOWN: 'unknown'
|
|
};
|
|
|
|
// Mobile touch interaction properties
|
|
this.touchState = {
|
|
startX: 0,
|
|
startY: 0,
|
|
currentX: 0,
|
|
currentY: 0,
|
|
isDragging: false,
|
|
swipeThreshold: 100, // Minimum distance for swipe
|
|
currentBookmark: null,
|
|
swipeDirection: null
|
|
};
|
|
|
|
// Pull-to-refresh properties
|
|
this.pullToRefresh = {
|
|
startY: 0,
|
|
currentY: 0,
|
|
threshold: 80,
|
|
isActive: false,
|
|
isPulling: false,
|
|
element: null
|
|
};
|
|
|
|
// Advanced search properties
|
|
this.searchHistory = [];
|
|
this.savedSearches = [];
|
|
this.searchSuggestions = [];
|
|
this.currentAdvancedSearch = null;
|
|
this.maxSearchHistory = 20;
|
|
this.maxSearchSuggestions = 10;
|
|
|
|
// Security and privacy properties
|
|
this.securitySettings = {
|
|
encryptionEnabled: false,
|
|
encryptionKey: null,
|
|
privacyMode: false,
|
|
accessLogging: true,
|
|
passwordProtection: false,
|
|
sessionTimeout: 30 * 60 * 1000, // 30 minutes
|
|
maxLoginAttempts: 3,
|
|
lockoutDuration: 15 * 60 * 1000 // 15 minutes
|
|
};
|
|
|
|
this.accessLog = [];
|
|
this.encryptedCollections = new Set();
|
|
this.privateBookmarks = new Set();
|
|
this.securitySession = {
|
|
isAuthenticated: false,
|
|
lastActivity: Date.now(),
|
|
loginAttempts: 0,
|
|
lockedUntil: null
|
|
};
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.loadLinkTestConfigFromStorage();
|
|
this.loadSecuritySettings();
|
|
this.loadAccessLog();
|
|
this.loadPrivateBookmarks();
|
|
this.loadEncryptedCollections();
|
|
this.initializeSecurity();
|
|
this.loadBookmarksFromStorage();
|
|
this.loadSearchHistory();
|
|
this.loadSavedSearches();
|
|
this.initializeSharing();
|
|
this.bindEvents();
|
|
this.renderBookmarks();
|
|
this.updateStats();
|
|
}
|
|
|
|
bindEvents() {
|
|
// Import functionality
|
|
document.getElementById('importBtn').addEventListener('click', () => {
|
|
this.showModal('importModal');
|
|
});
|
|
|
|
document.getElementById('importFileBtn').addEventListener('click', () => {
|
|
this.importBookmarks();
|
|
});
|
|
|
|
document.getElementById('previewImportBtn').addEventListener('click', () => {
|
|
this.previewImport();
|
|
});
|
|
|
|
// Export functionality
|
|
document.getElementById('exportBtn').addEventListener('click', () => {
|
|
this.showExportModal();
|
|
});
|
|
|
|
// Add bookmark
|
|
document.getElementById('addBookmarkBtn').addEventListener('click', () => {
|
|
this.showBookmarkModal();
|
|
});
|
|
|
|
// Search with debouncing
|
|
document.getElementById('searchInput').addEventListener('input', (e) => {
|
|
this.debouncedSearch(e.target.value);
|
|
this.updateSearchSuggestions(e.target.value);
|
|
});
|
|
|
|
document.getElementById('searchBtn').addEventListener('click', () => {
|
|
const query = document.getElementById('searchInput').value;
|
|
this.searchBookmarks(query);
|
|
});
|
|
|
|
// Advanced search functionality
|
|
document.getElementById('advancedSearchBtn').addEventListener('click', () => {
|
|
this.showAdvancedSearchModal();
|
|
});
|
|
|
|
// Test all links
|
|
document.getElementById('testAllBtn').addEventListener('click', () => {
|
|
this.testAllLinks();
|
|
});
|
|
|
|
// Test invalid links only
|
|
document.getElementById('testInvalidBtn').addEventListener('click', () => {
|
|
this.testInvalidLinks();
|
|
});
|
|
|
|
// Filter buttons
|
|
document.querySelectorAll('.stats-filter').forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
const filter = e.target.getAttribute('data-filter');
|
|
this.applyFilter(filter);
|
|
|
|
// Update active state and ARIA attributes
|
|
document.querySelectorAll('.stats-filter').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
btn.setAttribute('aria-pressed', 'false');
|
|
});
|
|
e.target.classList.add('active');
|
|
e.target.setAttribute('aria-pressed', 'true');
|
|
});
|
|
|
|
// Add keyboard support for filter buttons
|
|
button.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
button.click();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Find duplicates
|
|
document.getElementById('findDuplicatesBtn').addEventListener('click', () => {
|
|
this.findDuplicates();
|
|
});
|
|
|
|
// Clear all
|
|
document.getElementById('clearAllBtn').addEventListener('click', () => {
|
|
if (confirm('Are you sure you want to delete all bookmarks? This cannot be undone.')) {
|
|
this.clearAllBookmarks();
|
|
}
|
|
});
|
|
|
|
// Settings
|
|
document.getElementById('settingsBtn').addEventListener('click', () => {
|
|
this.showSettingsModal();
|
|
});
|
|
|
|
// Analytics
|
|
document.getElementById('analyticsBtn').addEventListener('click', () => {
|
|
this.showAnalyticsModal();
|
|
});
|
|
|
|
// Add keyboard support for all buttons
|
|
this.addKeyboardSupportToButtons();
|
|
|
|
// Modal events
|
|
document.querySelectorAll('.close').forEach(closeBtn => {
|
|
closeBtn.addEventListener('click', (e) => {
|
|
const modal = e.target.closest('.modal');
|
|
this.hideModal(modal.id);
|
|
});
|
|
});
|
|
|
|
document.getElementById('cancelImportBtn').addEventListener('click', () => {
|
|
this.hideModal('importModal');
|
|
});
|
|
|
|
document.getElementById('cancelBookmarkBtn').addEventListener('click', () => {
|
|
this.hideModal('bookmarkModal');
|
|
});
|
|
|
|
document.getElementById('cancelSettingsBtn').addEventListener('click', () => {
|
|
this.hideModal('settingsModal');
|
|
});
|
|
|
|
// Analytics modal events
|
|
document.getElementById('closeAnalyticsBtn').addEventListener('click', () => {
|
|
this.hideModal('analyticsModal');
|
|
});
|
|
|
|
document.getElementById('exportAnalyticsBtn').addEventListener('click', () => {
|
|
this.exportAnalyticsData();
|
|
});
|
|
|
|
document.getElementById('generateReportBtn').addEventListener('click', () => {
|
|
this.generateAnalyticsReport();
|
|
});
|
|
|
|
// Export modal events
|
|
document.getElementById('cancelExportBtn').addEventListener('click', () => {
|
|
this.hideModal('exportModal');
|
|
});
|
|
|
|
document.getElementById('exportForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.performExport();
|
|
});
|
|
|
|
document.getElementById('exportFilter').addEventListener('change', () => {
|
|
this.updateExportPreview();
|
|
});
|
|
|
|
document.getElementById('exportFolderSelect').addEventListener('change', () => {
|
|
this.updateExportPreview();
|
|
});
|
|
|
|
// Backup reminder modal events
|
|
document.getElementById('backupNowBtn').addEventListener('click', () => {
|
|
this.hideModal('backupReminderModal');
|
|
this.showExportModal();
|
|
});
|
|
|
|
document.getElementById('remindLaterBtn').addEventListener('click', () => {
|
|
this.hideModal('backupReminderModal');
|
|
// Set reminder for next week
|
|
this.backupSettings.lastBackupDate = Date.now() - (23 * 24 * 60 * 60 * 1000); // 23 days ago
|
|
this.saveBackupSettings();
|
|
});
|
|
|
|
document.getElementById('disableRemindersBtn').addEventListener('click', () => {
|
|
this.backupSettings.enabled = false;
|
|
this.saveBackupSettings();
|
|
this.hideModal('backupReminderModal');
|
|
});
|
|
|
|
// Settings form
|
|
document.getElementById('settingsForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveSettings();
|
|
});
|
|
|
|
document.getElementById('resetSettingsBtn').addEventListener('click', () => {
|
|
this.resetSettings();
|
|
});
|
|
|
|
// Bookmark form
|
|
document.getElementById('bookmarkForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveBookmark();
|
|
});
|
|
|
|
// Context menu modal events
|
|
document.getElementById('visitBookmarkBtn').addEventListener('click', () => {
|
|
if (this.currentContextBookmark) {
|
|
this.trackBookmarkVisit(this.currentContextBookmark.id);
|
|
this.logAccess('bookmark_visited', { bookmarkId: this.currentContextBookmark.id, url: this.currentContextBookmark.url });
|
|
window.open(this.currentContextBookmark.url, '_blank');
|
|
this.hideModal('contextModal');
|
|
}
|
|
});
|
|
|
|
document.getElementById('testBookmarkBtn').addEventListener('click', () => {
|
|
if (this.currentContextBookmark) {
|
|
this.testLink(this.currentContextBookmark);
|
|
this.hideModal('contextModal');
|
|
}
|
|
});
|
|
|
|
document.getElementById('editBookmarkBtn').addEventListener('click', () => {
|
|
if (this.currentContextBookmark) {
|
|
this.hideModal('contextModal');
|
|
this.showBookmarkModal(this.currentContextBookmark);
|
|
}
|
|
});
|
|
|
|
document.getElementById('deleteBookmarkBtn').addEventListener('click', () => {
|
|
if (this.currentContextBookmark) {
|
|
this.hideModal('contextModal');
|
|
this.deleteBookmark(this.currentContextBookmark.id);
|
|
}
|
|
});
|
|
|
|
// Close modals when clicking outside
|
|
window.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('modal')) {
|
|
this.hideModal(e.target.id);
|
|
}
|
|
});
|
|
|
|
// Global keyboard event handlers
|
|
document.addEventListener('keydown', (e) => {
|
|
this.handleGlobalKeydown(e);
|
|
});
|
|
|
|
// Advanced search modal events
|
|
this.bindAdvancedSearchEvents();
|
|
|
|
// Organization feature event bindings
|
|
this.bindOrganizationEvents();
|
|
|
|
// Advanced import/export event bindings
|
|
this.bindAdvancedImportEvents();
|
|
|
|
// Mobile touch interaction handlers
|
|
this.initializeMobileTouchHandlers();
|
|
this.initializePullToRefresh();
|
|
|
|
// Drag and drop functionality
|
|
this.initializeDragAndDrop();
|
|
|
|
// Bulk operations
|
|
this.bulkSelection = new Set();
|
|
this.bulkMode = false;
|
|
|
|
// Security event bindings
|
|
this.bindSecurityEvents();
|
|
|
|
// Security settings form events
|
|
document.getElementById('securitySettingsForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
this.saveSecuritySettings();
|
|
});
|
|
|
|
document.getElementById('cancelSecuritySettingsBtn').addEventListener('click', () => {
|
|
this.hideModal('securitySettingsModal');
|
|
});
|
|
|
|
// Security authentication form
|
|
document.getElementById('securityAuthForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
const password = document.getElementById('securityPassword').value;
|
|
this.authenticateUser(password);
|
|
});
|
|
|
|
// Security audit events - with null checks
|
|
const viewAuditLogBtn = document.getElementById('viewAuditLogBtn');
|
|
if (viewAuditLogBtn) {
|
|
viewAuditLogBtn.addEventListener('click', () => {
|
|
this.showSecurityAuditModal();
|
|
});
|
|
}
|
|
|
|
const refreshAuditBtn = document.getElementById('refreshAuditBtn');
|
|
if (refreshAuditBtn) {
|
|
refreshAuditBtn.addEventListener('click', () => {
|
|
this.populateSecurityAuditLog();
|
|
});
|
|
}
|
|
|
|
const exportAuditBtn = document.getElementById('exportAuditBtn');
|
|
if (exportAuditBtn) {
|
|
exportAuditBtn.addEventListener('click', () => {
|
|
this.exportSecurityAuditLog();
|
|
});
|
|
}
|
|
|
|
const clearAuditBtn = document.getElementById('clearAuditBtn');
|
|
if (clearAuditBtn) {
|
|
clearAuditBtn.addEventListener('click', () => {
|
|
this.clearSecurityAuditLog();
|
|
});
|
|
}
|
|
|
|
const closeAuditBtn = document.getElementById('closeAuditBtn');
|
|
if (closeAuditBtn) {
|
|
closeAuditBtn.addEventListener('click', () => {
|
|
this.hideModal('securityAuditModal');
|
|
});
|
|
}
|
|
|
|
// Password protection toggle
|
|
document.getElementById('passwordProtection').addEventListener('change', (e) => {
|
|
const passwordSetupGroup = document.getElementById('passwordSetupGroup');
|
|
if (e.target.checked) {
|
|
passwordSetupGroup.style.display = 'block';
|
|
} else {
|
|
passwordSetupGroup.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Privacy controls in context menu
|
|
document.getElementById('contextPrivacyToggle').addEventListener('change', (e) => {
|
|
if (this.currentContextBookmark) {
|
|
this.toggleBookmarkPrivacy(this.currentContextBookmark.id);
|
|
e.target.checked = this.isBookmarkPrivate(this.currentContextBookmark.id);
|
|
}
|
|
});
|
|
|
|
document.getElementById('contextEncryptionToggle').addEventListener('change', (e) => {
|
|
if (this.currentContextBookmark) {
|
|
this.toggleBookmarkEncryption(this.currentContextBookmark.id);
|
|
e.target.checked = this.isBookmarkEncrypted(this.currentContextBookmark.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Initialize drag and drop functionality
|
|
initializeDragAndDrop() {
|
|
// Enable drag and drop for bookmark items
|
|
document.addEventListener('dragstart', (e) => {
|
|
if (e.target.closest('.bookmark-item')) {
|
|
const bookmarkItem = e.target.closest('.bookmark-item');
|
|
const bookmarkId = bookmarkItem.dataset.bookmarkId;
|
|
e.dataTransfer.setData('text/plain', bookmarkId);
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
bookmarkItem.classList.add('dragging');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('dragend', (e) => {
|
|
if (e.target.closest('.bookmark-item')) {
|
|
const bookmarkItem = e.target.closest('.bookmark-item');
|
|
bookmarkItem.classList.remove('dragging');
|
|
}
|
|
});
|
|
|
|
// Handle drop zones (folder cards)
|
|
document.addEventListener('dragover', (e) => {
|
|
const folderCard = e.target.closest('.folder-card');
|
|
if (folderCard) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
folderCard.classList.add('drag-over');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('dragleave', (e) => {
|
|
const folderCard = e.target.closest('.folder-card');
|
|
if (folderCard && !folderCard.contains(e.relatedTarget)) {
|
|
folderCard.classList.remove('drag-over');
|
|
}
|
|
});
|
|
|
|
document.addEventListener('drop', (e) => {
|
|
const folderCard = e.target.closest('.folder-card');
|
|
if (folderCard) {
|
|
e.preventDefault();
|
|
folderCard.classList.remove('drag-over');
|
|
|
|
const bookmarkId = e.dataTransfer.getData('text/plain');
|
|
const targetFolder = folderCard.dataset.folderName || '';
|
|
|
|
this.moveBookmarkToFolder(bookmarkId, targetFolder);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Security and Privacy Methods
|
|
|
|
// Initialize security system
|
|
initializeSecurity() {
|
|
this.checkSessionTimeout();
|
|
this.startSessionTimeoutTimer();
|
|
|
|
// Check if user needs to authenticate
|
|
if (this.securitySettings.passwordProtection && !this.securitySession.isAuthenticated) {
|
|
this.showSecurityAuthModal();
|
|
}
|
|
}
|
|
|
|
// Load security settings from storage
|
|
loadSecuritySettings() {
|
|
try {
|
|
const settings = localStorage.getItem('bookmarkManager_securitySettings');
|
|
if (settings) {
|
|
this.securitySettings = { ...this.securitySettings, ...JSON.parse(settings) };
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading security settings:', error);
|
|
}
|
|
}
|
|
|
|
// Save security settings to storage
|
|
saveSecuritySettings() {
|
|
try {
|
|
const form = document.getElementById('securitySettingsForm');
|
|
const formData = new FormData(form);
|
|
|
|
this.securitySettings.encryptionEnabled = formData.get('encryptionEnabled') === 'on';
|
|
this.securitySettings.privacyMode = formData.get('privacyMode') === 'on';
|
|
this.securitySettings.accessLogging = formData.get('accessLogging') === 'on';
|
|
this.securitySettings.passwordProtection = formData.get('passwordProtection') === 'on';
|
|
this.securitySettings.sessionTimeout = parseInt(formData.get('sessionTimeout')) * 60 * 1000;
|
|
this.securitySettings.maxLoginAttempts = parseInt(formData.get('maxLoginAttempts'));
|
|
this.securitySettings.lockoutDuration = parseInt(formData.get('lockoutDuration')) * 60 * 1000;
|
|
|
|
// Handle password setup
|
|
const newPassword = formData.get('newPassword');
|
|
const confirmPassword = formData.get('confirmPassword');
|
|
|
|
if (this.securitySettings.passwordProtection && newPassword) {
|
|
if (newPassword !== confirmPassword) {
|
|
alert('Passwords do not match!');
|
|
return;
|
|
}
|
|
|
|
if (newPassword.length < 8) {
|
|
alert('Password must be at least 8 characters long!');
|
|
return;
|
|
}
|
|
|
|
this.securitySettings.encryptionKey = this.hashPassword(newPassword);
|
|
}
|
|
|
|
localStorage.setItem('bookmarkManager_securitySettings', JSON.stringify(this.securitySettings));
|
|
this.hideModal('securitySettingsModal');
|
|
|
|
// Re-initialize security if password protection was enabled
|
|
if (this.securitySettings.passwordProtection) {
|
|
this.securitySession.isAuthenticated = false;
|
|
this.showSecurityAuthModal();
|
|
}
|
|
|
|
this.logAccess('security_settings_changed', {
|
|
encryptionEnabled: this.securitySettings.encryptionEnabled,
|
|
privacyMode: this.securitySettings.privacyMode,
|
|
passwordProtection: this.securitySettings.passwordProtection
|
|
});
|
|
|
|
alert('Security settings saved successfully!');
|
|
} catch (error) {
|
|
console.error('Error saving security settings:', error);
|
|
alert('Error saving security settings. Please try again.');
|
|
}
|
|
}
|
|
|
|
// Load access log from storage
|
|
loadAccessLog() {
|
|
try {
|
|
const log = localStorage.getItem('bookmarkManager_accessLog');
|
|
if (log) {
|
|
this.accessLog = JSON.parse(log);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading access log:', error);
|
|
}
|
|
}
|
|
|
|
// Log access events for security auditing
|
|
logAccess(action, details = {}) {
|
|
if (!this.securitySettings.accessLogging) return;
|
|
|
|
const logEntry = {
|
|
timestamp: Date.now(),
|
|
action: action,
|
|
details: details,
|
|
userAgent: navigator.userAgent,
|
|
ip: 'client-side', // Cannot get real IP in client-side app
|
|
sessionId: this.getSessionId()
|
|
};
|
|
|
|
this.accessLog.push(logEntry);
|
|
|
|
// Keep only last 1000 entries to prevent storage bloat
|
|
if (this.accessLog.length > 1000) {
|
|
this.accessLog = this.accessLog.slice(-1000);
|
|
}
|
|
|
|
try {
|
|
localStorage.setItem('bookmarkManager_accessLog', JSON.stringify(this.accessLog));
|
|
} catch (error) {
|
|
console.error('Error saving access log:', error);
|
|
}
|
|
}
|
|
|
|
// Get or create session ID
|
|
getSessionId() {
|
|
if (!this.sessionId) {
|
|
this.sessionId = 'session_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
return this.sessionId;
|
|
}
|
|
|
|
// Hash password for storage (simple implementation)
|
|
hashPassword(password) {
|
|
// In a real application, use a proper hashing library like bcrypt
|
|
// This is a simple hash for demonstration purposes
|
|
let hash = 0;
|
|
for (let i = 0; i < password.length; i++) {
|
|
const char = password.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash; // Convert to 32-bit integer
|
|
}
|
|
return hash.toString();
|
|
}
|
|
|
|
// Authenticate user with password
|
|
authenticateUser(password) {
|
|
if (this.securitySession.lockedUntil && Date.now() < this.securitySession.lockedUntil) {
|
|
const remainingTime = Math.ceil((this.securitySession.lockedUntil - Date.now()) / 60000);
|
|
alert(`Account is locked. Please try again in ${remainingTime} minutes.`);
|
|
return;
|
|
}
|
|
|
|
const hashedPassword = this.hashPassword(password);
|
|
|
|
if (hashedPassword === this.securitySettings.encryptionKey) {
|
|
this.securitySession.isAuthenticated = true;
|
|
this.securitySession.lastActivity = Date.now();
|
|
this.securitySession.loginAttempts = 0;
|
|
this.securitySession.lockedUntil = null;
|
|
|
|
this.hideModal('securityAuthModal');
|
|
this.logAccess('successful_login', { timestamp: Date.now() });
|
|
|
|
// Clear password field
|
|
document.getElementById('securityPassword').value = '';
|
|
} else {
|
|
this.securitySession.loginAttempts++;
|
|
this.logAccess('failed_login_attempt', {
|
|
attempts: this.securitySession.loginAttempts,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
if (this.securitySession.loginAttempts >= this.securitySettings.maxLoginAttempts) {
|
|
this.securitySession.lockedUntil = Date.now() + this.securitySettings.lockoutDuration;
|
|
this.logAccess('account_locked', {
|
|
lockoutDuration: this.securitySettings.lockoutDuration,
|
|
timestamp: Date.now()
|
|
});
|
|
alert(`Too many failed attempts. Account locked for ${this.securitySettings.lockoutDuration / 60000} minutes.`);
|
|
} else {
|
|
const remainingAttempts = this.securitySettings.maxLoginAttempts - this.securitySession.loginAttempts;
|
|
alert(`Incorrect password. ${remainingAttempts} attempts remaining.`);
|
|
}
|
|
|
|
// Clear password field
|
|
document.getElementById('securityPassword').value = '';
|
|
}
|
|
}
|
|
|
|
// Check session timeout
|
|
checkSessionTimeout() {
|
|
if (this.securitySettings.passwordProtection &&
|
|
this.securitySession.isAuthenticated &&
|
|
Date.now() - this.securitySession.lastActivity > this.securitySettings.sessionTimeout) {
|
|
|
|
this.securitySession.isAuthenticated = false;
|
|
this.logAccess('session_timeout', { timestamp: Date.now() });
|
|
this.showSecurityAuthModal();
|
|
}
|
|
}
|
|
|
|
// Start session timeout timer
|
|
startSessionTimeoutTimer() {
|
|
setInterval(() => {
|
|
this.checkSessionTimeout();
|
|
}, 60000); // Check every minute
|
|
|
|
// Update last activity on user interaction
|
|
document.addEventListener('click', () => {
|
|
if (this.securitySession.isAuthenticated) {
|
|
this.securitySession.lastActivity = Date.now();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', () => {
|
|
if (this.securitySession.isAuthenticated) {
|
|
this.securitySession.lastActivity = Date.now();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Encrypt bookmark data
|
|
encryptBookmark(bookmark) {
|
|
if (!this.securitySettings.encryptionEnabled || !this.securitySettings.encryptionKey) {
|
|
return bookmark;
|
|
}
|
|
|
|
try {
|
|
// Simple encryption - in production, use a proper encryption library
|
|
const key = this.securitySettings.encryptionKey;
|
|
const encryptedTitle = this.simpleEncrypt(bookmark.title, key);
|
|
const encryptedUrl = this.simpleEncrypt(bookmark.url, key);
|
|
const encryptedNotes = bookmark.notes ? this.simpleEncrypt(bookmark.notes, key) : '';
|
|
|
|
return {
|
|
...bookmark,
|
|
title: encryptedTitle,
|
|
url: encryptedUrl,
|
|
notes: encryptedNotes,
|
|
encrypted: true
|
|
};
|
|
} catch (error) {
|
|
console.error('Error encrypting bookmark:', error);
|
|
return bookmark;
|
|
}
|
|
}
|
|
|
|
// Decrypt bookmark data
|
|
decryptBookmark(bookmark) {
|
|
if (!bookmark.encrypted || !this.securitySettings.encryptionKey) {
|
|
return bookmark;
|
|
}
|
|
|
|
try {
|
|
const key = this.securitySettings.encryptionKey;
|
|
const decryptedTitle = this.simpleDecrypt(bookmark.title, key);
|
|
const decryptedUrl = this.simpleDecrypt(bookmark.url, key);
|
|
const decryptedNotes = bookmark.notes ? this.simpleDecrypt(bookmark.notes, key) : '';
|
|
|
|
return {
|
|
...bookmark,
|
|
title: decryptedTitle,
|
|
url: decryptedUrl,
|
|
notes: decryptedNotes
|
|
};
|
|
} catch (error) {
|
|
console.error('Error decrypting bookmark:', error);
|
|
return bookmark;
|
|
}
|
|
}
|
|
|
|
// Simple encryption function (for demonstration - use proper crypto in production)
|
|
simpleEncrypt(text, key) {
|
|
let result = '';
|
|
for (let i = 0; i < text.length; i++) {
|
|
result += String.fromCharCode(text.charCodeAt(i) ^ key.toString().charCodeAt(i % key.toString().length));
|
|
}
|
|
return btoa(result); // Base64 encode
|
|
}
|
|
|
|
// Simple decryption function
|
|
simpleDecrypt(encryptedText, key) {
|
|
try {
|
|
const decoded = atob(encryptedText); // Base64 decode
|
|
let result = '';
|
|
for (let i = 0; i < decoded.length; i++) {
|
|
result += String.fromCharCode(decoded.charCodeAt(i) ^ key.toString().charCodeAt(i % key.toString().length));
|
|
}
|
|
return result;
|
|
} catch (error) {
|
|
console.error('Error decrypting text:', error);
|
|
return encryptedText;
|
|
}
|
|
}
|
|
|
|
// Toggle bookmark privacy status
|
|
toggleBookmarkPrivacy(bookmarkId) {
|
|
if (this.privateBookmarks.has(bookmarkId)) {
|
|
this.privateBookmarks.delete(bookmarkId);
|
|
this.logAccess('bookmark_privacy_disabled', { bookmarkId });
|
|
} else {
|
|
this.privateBookmarks.add(bookmarkId);
|
|
this.logAccess('bookmark_privacy_enabled', { bookmarkId });
|
|
}
|
|
|
|
this.savePrivateBookmarks();
|
|
}
|
|
|
|
// Check if bookmark is private
|
|
isBookmarkPrivate(bookmarkId) {
|
|
return this.privateBookmarks.has(bookmarkId);
|
|
}
|
|
|
|
// Toggle bookmark encryption status
|
|
toggleBookmarkEncryption(bookmarkId) {
|
|
if (this.encryptedCollections.has(bookmarkId)) {
|
|
this.encryptedCollections.delete(bookmarkId);
|
|
this.logAccess('bookmark_encryption_disabled', { bookmarkId });
|
|
} else {
|
|
this.encryptedCollections.add(bookmarkId);
|
|
this.logAccess('bookmark_encryption_enabled', { bookmarkId });
|
|
}
|
|
|
|
this.saveEncryptedCollections();
|
|
this.saveBookmarksToStorage(); // Re-save bookmarks with new encryption status
|
|
}
|
|
|
|
// Check if bookmark is encrypted
|
|
isBookmarkEncrypted(bookmarkId) {
|
|
return this.encryptedCollections.has(bookmarkId);
|
|
}
|
|
|
|
// Save private bookmarks list
|
|
savePrivateBookmarks() {
|
|
try {
|
|
localStorage.setItem('bookmarkManager_privateBookmarks', JSON.stringify([...this.privateBookmarks]));
|
|
} catch (error) {
|
|
console.error('Error saving private bookmarks:', error);
|
|
}
|
|
}
|
|
|
|
// Load private bookmarks list
|
|
loadPrivateBookmarks() {
|
|
try {
|
|
const privateBookmarksData = localStorage.getItem('bookmarkManager_privateBookmarks');
|
|
if (privateBookmarksData) {
|
|
this.privateBookmarks = new Set(JSON.parse(privateBookmarksData));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading private bookmarks:', error);
|
|
}
|
|
}
|
|
|
|
// Save encrypted collections list
|
|
saveEncryptedCollections() {
|
|
try {
|
|
localStorage.setItem('bookmarkManager_encryptedCollections', JSON.stringify([...this.encryptedCollections]));
|
|
} catch (error) {
|
|
console.error('Error saving encrypted collections:', error);
|
|
}
|
|
}
|
|
|
|
// Load encrypted collections list
|
|
loadEncryptedCollections() {
|
|
try {
|
|
const encryptedData = localStorage.getItem('bookmarkManager_encryptedCollections');
|
|
if (encryptedData) {
|
|
this.encryptedCollections = new Set(JSON.parse(encryptedData));
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading encrypted collections:', error);
|
|
}
|
|
}
|
|
|
|
// Filter bookmarks for export (exclude private ones if privacy mode is on)
|
|
getExportableBookmarks(bookmarks) {
|
|
if (!this.securitySettings.privacyMode) {
|
|
return bookmarks;
|
|
}
|
|
|
|
return bookmarks.filter(bookmark => !this.isBookmarkPrivate(bookmark.id));
|
|
}
|
|
|
|
// Generate secure sharing link with password protection
|
|
generateSecureShareLink(bookmarkIds, password) {
|
|
try {
|
|
const bookmarksToShare = this.bookmarks.filter(b => bookmarkIds.includes(b.id));
|
|
const shareData = {
|
|
bookmarks: bookmarksToShare,
|
|
timestamp: Date.now(),
|
|
expiresAt: Date.now() + (24 * 60 * 60 * 1000), // 24 hours
|
|
shareId: 'share_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9)
|
|
};
|
|
|
|
if (password) {
|
|
shareData.passwordHash = this.hashPassword(password);
|
|
shareData.encrypted = true;
|
|
}
|
|
|
|
// In a real application, this would be sent to a server
|
|
// For demo purposes, we'll create a data URL
|
|
const shareDataString = JSON.stringify(shareData);
|
|
const encodedData = btoa(shareDataString);
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}?share=${encodedData}`;
|
|
|
|
this.logAccess('secure_share_created', {
|
|
shareId: shareData.shareId,
|
|
bookmarkCount: bookmarksToShare.length,
|
|
passwordProtected: !!password
|
|
});
|
|
|
|
return shareUrl;
|
|
} catch (error) {
|
|
console.error('Error generating secure share link:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Show security settings modal
|
|
showSecuritySettingsModal() {
|
|
this.showModal('securitySettingsModal');
|
|
this.populateSecuritySettingsForm();
|
|
}
|
|
|
|
// Populate security settings form
|
|
populateSecuritySettingsForm() {
|
|
document.getElementById('encryptionEnabled').checked = this.securitySettings.encryptionEnabled;
|
|
document.getElementById('privacyMode').checked = this.securitySettings.privacyMode;
|
|
document.getElementById('accessLogging').checked = this.securitySettings.accessLogging;
|
|
document.getElementById('passwordProtection').checked = this.securitySettings.passwordProtection;
|
|
document.getElementById('sessionTimeout').value = this.securitySettings.sessionTimeout / 60000;
|
|
document.getElementById('maxLoginAttempts').value = this.securitySettings.maxLoginAttempts;
|
|
document.getElementById('lockoutDuration').value = this.securitySettings.lockoutDuration / 60000;
|
|
|
|
// Show/hide password setup based on current setting
|
|
const passwordSetupGroup = document.getElementById('passwordSetupGroup');
|
|
if (passwordSetupGroup) {
|
|
passwordSetupGroup.style.display = this.securitySettings.passwordProtection ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// Show security authentication modal
|
|
showSecurityAuthModal() {
|
|
this.showModal('securityAuthModal');
|
|
document.getElementById('securityPassword').focus();
|
|
}
|
|
|
|
// Show security audit modal
|
|
showSecurityAuditModal() {
|
|
this.showModal('securityAuditModal');
|
|
this.populateSecurityAuditLog();
|
|
}
|
|
|
|
// Populate security audit log
|
|
populateSecurityAuditLog() {
|
|
const auditLogContainer = document.getElementById('auditLogContainer');
|
|
if (!auditLogContainer) return;
|
|
|
|
auditLogContainer.innerHTML = '';
|
|
|
|
// Sort log entries by timestamp (newest first)
|
|
const sortedLog = [...this.accessLog].sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
sortedLog.forEach(entry => {
|
|
const logItem = document.createElement('div');
|
|
logItem.className = 'audit-log-item';
|
|
|
|
const date = new Date(entry.timestamp).toLocaleString();
|
|
const actionClass = this.getActionClass(entry.action);
|
|
|
|
logItem.innerHTML = `
|
|
<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) {
|
|
this.bookmarks = importedBookmarks;
|
|
} else {
|
|
this.bookmarks = [...this.bookmarks, ...importedBookmarks];
|
|
}
|
|
|
|
this.saveBookmarksToStorage();
|
|
|
|
// Clear loading state
|
|
this.isLoading = false;
|
|
|
|
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
|
|
this.updateStats();
|
|
document.getElementById('importModal').style.display = 'none';
|
|
|
|
alert(`Successfully imported ${importedBookmarks.length} bookmarks!`);
|
|
} catch (error) {
|
|
console.error('Import error:', error);
|
|
|
|
// Clear loading state on error
|
|
this.isLoading = false;
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
|
|
alert('Error importing bookmarks. Please check the file format.');
|
|
}
|
|
}
|
|
|
|
exportBookmarks() {
|
|
if (this.bookmarks.length === 0) {
|
|
alert('No bookmarks to export.');
|
|
return;
|
|
}
|
|
|
|
const html = this.generateNetscapeHTML();
|
|
const blob = new Blob([html], { type: 'text/html' });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `bookmarks_${new Date().toISOString().split('T')[0]}.html`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
generateNetscapeHTML() {
|
|
const folders = {};
|
|
const noFolderBookmarks = [];
|
|
|
|
// Group bookmarks by folder
|
|
this.bookmarks.forEach(bookmark => {
|
|
if (bookmark.folder && bookmark.folder.trim()) {
|
|
if (!folders[bookmark.folder]) {
|
|
folders[bookmark.folder] = [];
|
|
}
|
|
folders[bookmark.folder].push(bookmark);
|
|
} else {
|
|
noFolderBookmarks.push(bookmark);
|
|
}
|
|
});
|
|
|
|
let html = `<!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');
|
|
}
|
|
|
|
saveBookmark() {
|
|
const title = document.getElementById('bookmarkTitle').value.trim();
|
|
const url = document.getElementById('bookmarkUrl').value.trim();
|
|
const folder = document.getElementById('bookmarkFolder').value.trim();
|
|
|
|
// Get new metadata fields
|
|
const tagsInput = document.getElementById('bookmarkTags').value.trim();
|
|
const tags = tagsInput ? tagsInput.split(',').map(tag => tag.trim()).filter(tag => tag) : [];
|
|
const notes = document.getElementById('bookmarkNotes').value.trim();
|
|
const rating = parseInt(document.getElementById('bookmarkRating').value) || 0;
|
|
const favorite = document.getElementById('bookmarkFavorite').checked;
|
|
|
|
if (!title || !url) {
|
|
alert('Please fill in both title and URL.');
|
|
return;
|
|
}
|
|
|
|
if (this.currentEditId) {
|
|
// Edit existing bookmark
|
|
const bookmark = this.bookmarks.find(b => b.id === this.currentEditId);
|
|
if (bookmark) {
|
|
bookmark.title = title;
|
|
bookmark.url = url;
|
|
bookmark.folder = folder;
|
|
bookmark.tags = tags;
|
|
bookmark.notes = notes;
|
|
bookmark.rating = rating;
|
|
bookmark.favorite = favorite;
|
|
bookmark.lastModified = Date.now();
|
|
bookmark.status = 'unknown'; // Reset status when URL changes
|
|
}
|
|
} else {
|
|
// Add new bookmark
|
|
const bookmark = {
|
|
id: Date.now() + Math.random(),
|
|
title,
|
|
url,
|
|
folder,
|
|
tags,
|
|
notes,
|
|
rating,
|
|
favorite,
|
|
addDate: Date.now(),
|
|
lastModified: Date.now(),
|
|
lastVisited: null,
|
|
icon: '',
|
|
status: 'unknown'
|
|
};
|
|
this.bookmarks.push(bookmark);
|
|
}
|
|
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
|
|
this.updateStats();
|
|
document.getElementById('bookmarkModal').style.display = 'none';
|
|
}
|
|
|
|
deleteBookmark(id) {
|
|
if (confirm('Are you sure you want to delete this bookmark?')) {
|
|
this.bookmarks = this.bookmarks.filter(b => b.id !== id);
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks(this.getFilteredBookmarks()); // Maintain current filter
|
|
this.updateStats();
|
|
}
|
|
}
|
|
|
|
async testLink(bookmark) {
|
|
console.log(`🔍 Testing link: ${bookmark.title} - ${bookmark.url}`);
|
|
|
|
// Update UI to show testing status
|
|
bookmark.status = 'testing';
|
|
bookmark.errorCategory = null;
|
|
bookmark.errorDetails = null;
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
|
|
const testResult = await this.performLinkTest(bookmark.url, bookmark.title);
|
|
|
|
// Update bookmark with test results
|
|
bookmark.status = testResult.status;
|
|
bookmark.errorCategory = testResult.errorCategory;
|
|
bookmark.errorDetails = testResult.errorDetails;
|
|
bookmark.lastTested = Date.now();
|
|
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
this.updateStats();
|
|
}
|
|
|
|
/**
|
|
* Performs enhanced link testing with retry logic and detailed error categorization
|
|
* @param {string} url - The URL to test
|
|
* @param {string} title - The bookmark title for logging
|
|
* @returns {Object} Test result with status, error category, and details
|
|
*/
|
|
async performLinkTest(url, title) {
|
|
let lastError = null;
|
|
let attempts = 0;
|
|
const maxAttempts = this.linkTestConfig.maxRetries + 1;
|
|
|
|
while (attempts < maxAttempts) {
|
|
attempts++;
|
|
const isRetry = attempts > 1;
|
|
|
|
if (isRetry) {
|
|
console.log(`🔄 Retry attempt ${attempts - 1}/${this.linkTestConfig.maxRetries} for: ${title}`);
|
|
await this.delay(this.linkTestConfig.retryDelay);
|
|
}
|
|
|
|
try {
|
|
// Validate URL format first
|
|
let parsedUrl;
|
|
try {
|
|
parsedUrl = new URL(url);
|
|
} catch (urlError) {
|
|
const result = {
|
|
status: 'invalid',
|
|
errorCategory: this.ERROR_CATEGORIES.INVALID_URL,
|
|
errorDetails: {
|
|
message: 'Invalid URL format',
|
|
originalError: urlError.message,
|
|
url: url,
|
|
attempts: attempts,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
this.logDetailedError(title, url, result);
|
|
return result;
|
|
}
|
|
|
|
// Create abort controller for timeout
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => {
|
|
controller.abort();
|
|
}, this.linkTestConfig.timeout);
|
|
|
|
// Perform the HTTP request
|
|
const response = await fetch(url, {
|
|
method: 'HEAD',
|
|
mode: 'no-cors',
|
|
signal: controller.signal,
|
|
cache: 'no-cache',
|
|
headers: {
|
|
'User-Agent': this.linkTestConfig.userAgent
|
|
}
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
|
|
// Analyze response
|
|
if (response.ok || response.type === 'opaque') {
|
|
const result = {
|
|
status: 'valid',
|
|
errorCategory: null,
|
|
errorDetails: {
|
|
responseType: response.type,
|
|
status: response.status || 'opaque',
|
|
attempts: attempts,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
console.log(`✅ [Attempt ${attempts}] Valid: ${title} (${response.type === 'opaque' ? 'CORS-protected' : 'HTTP ' + response.status})`);
|
|
|
|
// Try to fetch favicon for this valid URL
|
|
this.fetchFaviconInBackground(url, title);
|
|
|
|
return result;
|
|
} else {
|
|
const result = {
|
|
status: 'invalid',
|
|
errorCategory: this.ERROR_CATEGORIES.HTTP_ERROR,
|
|
errorDetails: {
|
|
message: `HTTP ${response.status} ${response.statusText}`,
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
url: url,
|
|
attempts: attempts,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
|
|
// Don't retry HTTP errors (they're likely permanent)
|
|
this.logDetailedError(title, url, result);
|
|
return result;
|
|
}
|
|
|
|
} catch (error) {
|
|
lastError = error;
|
|
const errorCategory = this.categorizeError(error);
|
|
const isTransientError = this.isTransientError(errorCategory);
|
|
|
|
// If this is not a transient error or we've exhausted retries, return failure
|
|
if (!isTransientError || attempts >= maxAttempts) {
|
|
const result = {
|
|
status: 'invalid',
|
|
errorCategory: errorCategory,
|
|
errorDetails: {
|
|
message: error.message,
|
|
errorType: error.name,
|
|
url: url,
|
|
attempts: attempts,
|
|
isTransient: isTransientError,
|
|
timestamp: new Date().toISOString(),
|
|
stack: error.stack
|
|
}
|
|
};
|
|
this.logDetailedError(title, url, result);
|
|
return result;
|
|
}
|
|
|
|
// Log retry attempt for transient errors
|
|
console.log(`⚠️ [Attempt ${attempts}] Transient error for ${title}: ${error.message} (will retry)`);
|
|
}
|
|
}
|
|
|
|
// This shouldn't be reached, but just in case
|
|
const result = {
|
|
status: 'invalid',
|
|
errorCategory: this.ERROR_CATEGORIES.UNKNOWN,
|
|
errorDetails: {
|
|
message: 'Maximum retry attempts exceeded',
|
|
lastError: lastError?.message,
|
|
url: url,
|
|
attempts: attempts,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
};
|
|
this.logDetailedError(title, url, result);
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Categorizes errors into specific types for better debugging
|
|
* @param {Error} error - The error to categorize
|
|
* @returns {string} Error category constant
|
|
*/
|
|
categorizeError(error) {
|
|
const errorMessage = error.message.toLowerCase();
|
|
const errorName = error.name.toLowerCase();
|
|
|
|
if (errorName === 'aborterror') {
|
|
return this.ERROR_CATEGORIES.TIMEOUT;
|
|
}
|
|
|
|
if (errorName === 'typeerror') {
|
|
if (errorMessage.includes('fetch') || errorMessage.includes('network')) {
|
|
if (errorMessage.includes('cors')) {
|
|
return this.ERROR_CATEGORIES.CORS_BLOCKED;
|
|
}
|
|
return this.ERROR_CATEGORIES.NETWORK_ERROR;
|
|
}
|
|
if (errorMessage.includes('invalid url')) {
|
|
return this.ERROR_CATEGORIES.INVALID_URL;
|
|
}
|
|
}
|
|
|
|
if (errorMessage.includes('dns') || errorMessage.includes('name resolution')) {
|
|
return this.ERROR_CATEGORIES.DNS_ERROR;
|
|
}
|
|
|
|
if (errorMessage.includes('ssl') || errorMessage.includes('tls') || errorMessage.includes('certificate')) {
|
|
return this.ERROR_CATEGORIES.SSL_ERROR;
|
|
}
|
|
|
|
if (errorMessage.includes('connection refused') || errorMessage.includes('econnrefused')) {
|
|
return this.ERROR_CATEGORIES.CONNECTION_REFUSED;
|
|
}
|
|
|
|
if (errorMessage.includes('timeout')) {
|
|
return this.ERROR_CATEGORIES.TIMEOUT;
|
|
}
|
|
|
|
return this.ERROR_CATEGORIES.UNKNOWN;
|
|
}
|
|
|
|
/**
|
|
* Determines if an error is transient and worth retrying
|
|
* @param {string} errorCategory - The error category
|
|
* @returns {boolean} True if the error is transient
|
|
*/
|
|
isTransientError(errorCategory) {
|
|
const transientErrors = [
|
|
this.ERROR_CATEGORIES.NETWORK_ERROR,
|
|
this.ERROR_CATEGORIES.TIMEOUT,
|
|
this.ERROR_CATEGORIES.DNS_ERROR,
|
|
this.ERROR_CATEGORIES.CONNECTION_REFUSED
|
|
];
|
|
return transientErrors.includes(errorCategory);
|
|
}
|
|
|
|
/**
|
|
* Logs detailed error information for debugging
|
|
* @param {string} title - Bookmark title
|
|
* @param {string} url - Bookmark URL
|
|
* @param {Object} result - Test result object
|
|
*/
|
|
logDetailedError(title, url, result) {
|
|
const errorInfo = {
|
|
bookmark: title,
|
|
url: url,
|
|
category: result.errorCategory,
|
|
details: result.errorDetails,
|
|
userAgent: this.linkTestConfig.userAgent,
|
|
timeout: this.linkTestConfig.timeout,
|
|
maxRetries: this.linkTestConfig.maxRetries
|
|
};
|
|
|
|
console.group(`❌ Link Test Failed: ${title}`);
|
|
console.error('Error Category:', result.errorCategory);
|
|
console.error('Error Message:', result.errorDetails.message);
|
|
console.error('URL:', url);
|
|
console.error('Attempts:', result.errorDetails.attempts);
|
|
console.error('Timestamp:', result.errorDetails.timestamp);
|
|
if (result.errorDetails.stack) {
|
|
console.error('Stack Trace:', result.errorDetails.stack);
|
|
}
|
|
console.error('Full Error Details:', errorInfo);
|
|
console.groupEnd();
|
|
}
|
|
|
|
/**
|
|
* Utility function to create a delay
|
|
* @param {number} ms - Milliseconds to delay
|
|
* @returns {Promise} Promise that resolves after the delay
|
|
*/
|
|
delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/**
|
|
* Shows the settings modal with current configuration values
|
|
*/
|
|
showSettingsModal() {
|
|
// Populate form with current settings
|
|
document.getElementById('timeoutSetting').value = this.linkTestConfig.timeout / 1000;
|
|
document.getElementById('maxRetriesSetting').value = this.linkTestConfig.maxRetries;
|
|
document.getElementById('retryDelaySetting').value = this.linkTestConfig.retryDelay;
|
|
document.getElementById('userAgentSetting').value = this.linkTestConfig.userAgent;
|
|
|
|
this.showModal('settingsModal');
|
|
}
|
|
|
|
/**
|
|
* Saves the settings from the form and updates the configuration
|
|
*/
|
|
saveSettings() {
|
|
const timeout = parseInt(document.getElementById('timeoutSetting').value) * 1000;
|
|
const maxRetries = parseInt(document.getElementById('maxRetriesSetting').value);
|
|
const retryDelay = parseInt(document.getElementById('retryDelaySetting').value);
|
|
const userAgent = document.getElementById('userAgentSetting').value.trim();
|
|
|
|
// Validate settings
|
|
if (timeout < 5000 || timeout > 60000) {
|
|
alert('Timeout must be between 5 and 60 seconds.');
|
|
return;
|
|
}
|
|
|
|
if (maxRetries < 0 || maxRetries > 5) {
|
|
alert('Maximum retries must be between 0 and 5.');
|
|
return;
|
|
}
|
|
|
|
if (retryDelay < 500 || retryDelay > 5000) {
|
|
alert('Retry delay must be between 500 and 5000 milliseconds.');
|
|
return;
|
|
}
|
|
|
|
if (!userAgent || userAgent.length === 0) {
|
|
alert('User agent cannot be empty.');
|
|
return;
|
|
}
|
|
|
|
// Update configuration
|
|
this.linkTestConfig.timeout = timeout;
|
|
this.linkTestConfig.maxRetries = maxRetries;
|
|
this.linkTestConfig.retryDelay = retryDelay;
|
|
this.linkTestConfig.userAgent = userAgent;
|
|
|
|
// Save to localStorage
|
|
this.saveLinkTestConfigToStorage();
|
|
|
|
console.log('🔧 Link testing settings updated:', this.linkTestConfig);
|
|
|
|
this.hideModal('settingsModal');
|
|
alert('Settings saved successfully!');
|
|
}
|
|
|
|
/**
|
|
* Resets settings to default values
|
|
*/
|
|
resetSettings() {
|
|
if (confirm('Reset all link testing settings to default values?')) {
|
|
this.linkTestConfig = {
|
|
timeout: 10000,
|
|
maxRetries: 2,
|
|
retryDelay: 1000,
|
|
userAgent: 'BookmarkManager/1.0 (Link Checker)'
|
|
};
|
|
|
|
// Update form with default values
|
|
document.getElementById('timeoutSetting').value = 10;
|
|
document.getElementById('maxRetriesSetting').value = 2;
|
|
document.getElementById('retryDelaySetting').value = 1000;
|
|
document.getElementById('userAgentSetting').value = 'BookmarkManager/1.0 (Link Checker)';
|
|
|
|
// Save to localStorage
|
|
this.saveLinkTestConfigToStorage();
|
|
|
|
console.log('🔧 Link testing settings reset to defaults:', this.linkTestConfig);
|
|
alert('Settings reset to default values!');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Saves link test configuration to localStorage
|
|
*/
|
|
saveLinkTestConfigToStorage() {
|
|
try {
|
|
localStorage.setItem('bookmarkManager_linkTestConfig', JSON.stringify(this.linkTestConfig));
|
|
} catch (error) {
|
|
console.error('Error saving link test config to storage:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads link test configuration from localStorage
|
|
*/
|
|
loadLinkTestConfigFromStorage() {
|
|
try {
|
|
const saved = localStorage.getItem('bookmarkManager_linkTestConfig');
|
|
if (saved) {
|
|
const config = JSON.parse(saved);
|
|
// Merge with defaults to ensure all properties exist
|
|
this.linkTestConfig = {
|
|
...this.linkTestConfig,
|
|
...config
|
|
};
|
|
console.log('🔧 Loaded link testing settings from storage:', this.linkTestConfig);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading link test config from storage:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats error category for display
|
|
* @param {string} errorCategory - The error category constant
|
|
* @returns {string} Human-readable error category
|
|
*/
|
|
formatErrorCategory(errorCategory) {
|
|
const categoryMap = {
|
|
[this.ERROR_CATEGORIES.NETWORK_ERROR]: 'Network Error',
|
|
[this.ERROR_CATEGORIES.TIMEOUT]: 'Timeout',
|
|
[this.ERROR_CATEGORIES.INVALID_URL]: 'Invalid URL',
|
|
[this.ERROR_CATEGORIES.HTTP_ERROR]: 'HTTP Error',
|
|
[this.ERROR_CATEGORIES.CORS_BLOCKED]: 'CORS Blocked',
|
|
[this.ERROR_CATEGORIES.DNS_ERROR]: 'DNS Error',
|
|
[this.ERROR_CATEGORIES.SSL_ERROR]: 'SSL Error',
|
|
[this.ERROR_CATEGORIES.CONNECTION_REFUSED]: 'Connection Refused',
|
|
[this.ERROR_CATEGORIES.UNKNOWN]: 'Unknown Error'
|
|
};
|
|
return categoryMap[errorCategory] || 'Unknown Error';
|
|
}
|
|
|
|
/**
|
|
* Formats a timestamp as relative time (e.g., "2 minutes ago")
|
|
* @param {number} timestamp - The timestamp to format
|
|
* @returns {string} Relative time string
|
|
*/
|
|
formatRelativeTime(timestamp) {
|
|
const now = Date.now();
|
|
const diff = now - timestamp;
|
|
const seconds = Math.floor(diff / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
const days = Math.floor(hours / 24);
|
|
|
|
if (seconds < 60) {
|
|
return 'just now';
|
|
} else if (minutes < 60) {
|
|
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
} else if (hours < 24) {
|
|
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
} else if (days < 7) {
|
|
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
} else {
|
|
return new Date(timestamp).toLocaleDateString();
|
|
}
|
|
}
|
|
|
|
async testAllLinks() {
|
|
if (this.bookmarks.length === 0) {
|
|
alert('No bookmarks to test.');
|
|
return;
|
|
}
|
|
|
|
console.log(`🚀 Starting enhanced link testing for ${this.bookmarks.length} bookmarks`);
|
|
console.log(`Configuration: timeout=${this.linkTestConfig.timeout}ms, maxRetries=${this.linkTestConfig.maxRetries}, retryDelay=${this.linkTestConfig.retryDelay}ms`);
|
|
|
|
// Set loading state for large operations
|
|
this.isLoading = true;
|
|
if (this.bookmarks.length > this.virtualScrollThreshold) {
|
|
this.showLoadingState();
|
|
}
|
|
|
|
const progressContainer = document.getElementById('progressContainer');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressText = document.getElementById('progressText');
|
|
|
|
progressContainer.style.display = 'block';
|
|
progressBar.style.width = '0%';
|
|
|
|
// Update progress bar ARIA attributes
|
|
const progressBarContainer = progressContainer.querySelector('[role="progressbar"]');
|
|
if (progressBarContainer) {
|
|
progressBarContainer.setAttribute('aria-valuenow', '0');
|
|
}
|
|
|
|
let completed = 0;
|
|
const total = this.bookmarks.length;
|
|
const startTime = Date.now();
|
|
const testResults = {
|
|
valid: 0,
|
|
invalid: 0,
|
|
errorCategories: {}
|
|
};
|
|
|
|
for (const bookmark of this.bookmarks) {
|
|
const progressPercent = Math.round((completed / total) * 100);
|
|
progressText.textContent = `Testing ${bookmark.title}... (${completed + 1}/${total})`;
|
|
|
|
console.log(`📊 [${completed + 1}/${total}] Testing: ${bookmark.title.substring(0, 50)}${bookmark.title.length > 50 ? '...' : ''}`);
|
|
|
|
// Update UI to show testing status
|
|
bookmark.status = 'testing';
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
|
|
// Use the enhanced link testing method
|
|
const testResult = await this.performLinkTest(bookmark.url, bookmark.title);
|
|
|
|
// Update bookmark with test results
|
|
bookmark.status = testResult.status;
|
|
bookmark.errorCategory = testResult.errorCategory;
|
|
bookmark.errorDetails = testResult.errorDetails;
|
|
bookmark.lastTested = Date.now();
|
|
|
|
// Track statistics
|
|
if (testResult.status === 'valid') {
|
|
testResults.valid++;
|
|
} else {
|
|
testResults.invalid++;
|
|
if (testResult.errorCategory) {
|
|
testResults.errorCategories[testResult.errorCategory] =
|
|
(testResults.errorCategories[testResult.errorCategory] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
completed++;
|
|
const newProgressPercent = Math.round((completed / total) * 100);
|
|
progressBar.style.width = `${newProgressPercent}%`;
|
|
|
|
// Update progress bar ARIA attributes
|
|
if (progressBarContainer) {
|
|
progressBarContainer.setAttribute('aria-valuenow', newProgressPercent.toString());
|
|
}
|
|
|
|
// Update UI periodically during testing
|
|
if (completed % 10 === 0 || completed === total) {
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
this.updateStats();
|
|
}
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
const duration = Math.round((endTime - startTime) / 1000);
|
|
|
|
// Log comprehensive test results
|
|
console.group(`📈 Link Testing Complete - ${duration}s elapsed`);
|
|
console.log(`✅ Valid links: ${testResults.valid}`);
|
|
console.log(`❌ Invalid links: ${testResults.invalid}`);
|
|
console.log(`📊 Success rate: ${Math.round((testResults.valid / total) * 100)}%`);
|
|
|
|
if (Object.keys(testResults.errorCategories).length > 0) {
|
|
console.log('🔍 Error breakdown:');
|
|
Object.entries(testResults.errorCategories)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.forEach(([category, count]) => {
|
|
console.log(` ${category}: ${count} (${Math.round((count / testResults.invalid) * 100)}%)`);
|
|
});
|
|
}
|
|
console.groupEnd();
|
|
|
|
progressText.textContent = `Testing complete! ${testResults.valid} valid, ${testResults.invalid} invalid (${duration}s)`;
|
|
setTimeout(() => {
|
|
progressContainer.style.display = 'none';
|
|
}, 3000);
|
|
|
|
// Clear loading state
|
|
this.isLoading = false;
|
|
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
this.updateStats();
|
|
}
|
|
|
|
async testInvalidLinks() {
|
|
// Get only bookmarks marked as invalid
|
|
const invalidBookmarks = this.bookmarks.filter(b => b.status === 'invalid');
|
|
|
|
if (invalidBookmarks.length === 0) {
|
|
alert('No invalid bookmarks to retest.');
|
|
return;
|
|
}
|
|
|
|
console.log(`🔄 Starting enhanced retest for ${invalidBookmarks.length} invalid bookmarks`);
|
|
console.log(`Configuration: timeout=${this.linkTestConfig.timeout}ms, maxRetries=${this.linkTestConfig.maxRetries}, retryDelay=${this.linkTestConfig.retryDelay}ms`);
|
|
|
|
// Set loading state for large operations
|
|
this.isLoading = true;
|
|
if (invalidBookmarks.length > this.virtualScrollThreshold) {
|
|
this.showLoadingState();
|
|
}
|
|
|
|
const progressContainer = document.getElementById('progressContainer');
|
|
const progressBar = document.getElementById('progressBar');
|
|
const progressText = document.getElementById('progressText');
|
|
|
|
progressContainer.style.display = 'block';
|
|
progressBar.style.width = '0%';
|
|
|
|
let completed = 0;
|
|
const total = invalidBookmarks.length;
|
|
const startTime = Date.now();
|
|
const retestResults = {
|
|
nowValid: 0,
|
|
stillInvalid: 0,
|
|
errorCategories: {},
|
|
recoveredFromCategories: {}
|
|
};
|
|
|
|
for (const bookmark of invalidBookmarks) {
|
|
progressText.textContent = `Retesting ${bookmark.title}... (${completed + 1}/${total})`;
|
|
|
|
console.log(`🔍 [${completed + 1}/${total}] Retesting: ${bookmark.title.substring(0, 50)}${bookmark.title.length > 50 ? '...' : ''}`);
|
|
|
|
// Store previous error category for recovery tracking
|
|
const previousErrorCategory = bookmark.errorCategory;
|
|
|
|
// Update UI to show testing status
|
|
bookmark.status = 'testing';
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
|
|
// Use the enhanced link testing method
|
|
const testResult = await this.performLinkTest(bookmark.url, bookmark.title);
|
|
|
|
// Update bookmark with test results
|
|
bookmark.status = testResult.status;
|
|
bookmark.errorCategory = testResult.errorCategory;
|
|
bookmark.errorDetails = testResult.errorDetails;
|
|
bookmark.lastTested = Date.now();
|
|
|
|
// Track retest statistics
|
|
if (testResult.status === 'valid') {
|
|
retestResults.nowValid++;
|
|
if (previousErrorCategory) {
|
|
retestResults.recoveredFromCategories[previousErrorCategory] =
|
|
(retestResults.recoveredFromCategories[previousErrorCategory] || 0) + 1;
|
|
}
|
|
console.log(`✅ [${completed + 1}/${total}] Recovered: ${bookmark.title} (was ${previousErrorCategory || 'unknown error'})`);
|
|
} else {
|
|
retestResults.stillInvalid++;
|
|
if (testResult.errorCategory) {
|
|
retestResults.errorCategories[testResult.errorCategory] =
|
|
(retestResults.errorCategories[testResult.errorCategory] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
completed++;
|
|
progressBar.style.width = `${(completed / total) * 100}%`;
|
|
|
|
// Update UI periodically during testing
|
|
if (completed % 5 === 0 || completed === total) {
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
this.updateStats();
|
|
}
|
|
}
|
|
|
|
const endTime = Date.now();
|
|
const duration = Math.round((endTime - startTime) / 1000);
|
|
|
|
// Log comprehensive retest results
|
|
console.group(`🔄 Invalid Link Retest Complete - ${duration}s elapsed`);
|
|
console.log(`✅ Links now valid: ${retestResults.nowValid}`);
|
|
console.log(`❌ Links still invalid: ${retestResults.stillInvalid}`);
|
|
console.log(`📊 Recovery rate: ${Math.round((retestResults.nowValid / total) * 100)}%`);
|
|
|
|
if (Object.keys(retestResults.recoveredFromCategories).length > 0) {
|
|
console.log('🎯 Recovered from error types:');
|
|
Object.entries(retestResults.recoveredFromCategories)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.forEach(([category, count]) => {
|
|
console.log(` ${category}: ${count} recovered`);
|
|
});
|
|
}
|
|
|
|
if (Object.keys(retestResults.errorCategories).length > 0) {
|
|
console.log('🔍 Persistent error breakdown:');
|
|
Object.entries(retestResults.errorCategories)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.forEach(([category, count]) => {
|
|
console.log(` ${category}: ${count} (${Math.round((count / retestResults.stillInvalid) * 100)}%)`);
|
|
});
|
|
}
|
|
console.groupEnd();
|
|
|
|
progressText.textContent = `Retesting complete! ${retestResults.nowValid} recovered, ${retestResults.stillInvalid} still invalid (${duration}s)`;
|
|
setTimeout(() => {
|
|
progressContainer.style.display = 'none';
|
|
}, 3000);
|
|
|
|
// Clear loading state
|
|
this.isLoading = false;
|
|
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks(this.getFilteredBookmarks());
|
|
this.updateStats();
|
|
}
|
|
|
|
/**
|
|
* Enhanced URL normalization to handle more edge cases
|
|
* @param {string} url - The URL to normalize
|
|
* @param {Object} options - Normalization options
|
|
* @returns {string} Normalized URL
|
|
*/
|
|
normalizeUrl(url, options = {}) {
|
|
const {
|
|
removeQueryParams = false,
|
|
removeFragment = false,
|
|
removeWWW = true,
|
|
removeTrailingSlash = true,
|
|
sortQueryParams = true,
|
|
removeDefaultPorts = true,
|
|
removeCommonTracking = false
|
|
} = options;
|
|
|
|
try {
|
|
const urlObj = new URL(url);
|
|
|
|
// Normalize protocol and hostname
|
|
let normalized = urlObj.protocol.toLowerCase() + '//';
|
|
|
|
// Handle www removal
|
|
let hostname = urlObj.hostname.toLowerCase();
|
|
if (removeWWW && hostname.startsWith('www.')) {
|
|
hostname = hostname.substring(4);
|
|
}
|
|
normalized += hostname;
|
|
|
|
// Add port if it's not the default port
|
|
if (removeDefaultPorts) {
|
|
if ((urlObj.protocol === 'http:' && urlObj.port && urlObj.port !== '80') ||
|
|
(urlObj.protocol === 'https:' && urlObj.port && urlObj.port !== '443')) {
|
|
normalized += ':' + urlObj.port;
|
|
}
|
|
} else if (urlObj.port) {
|
|
normalized += ':' + urlObj.port;
|
|
}
|
|
|
|
// Add pathname, handling trailing slash
|
|
let pathname = urlObj.pathname;
|
|
if (removeTrailingSlash && pathname !== '/' && pathname.endsWith('/')) {
|
|
pathname = pathname.slice(0, -1);
|
|
}
|
|
normalized += pathname;
|
|
|
|
// Handle query parameters
|
|
if (!removeQueryParams && urlObj.search) {
|
|
const params = new URLSearchParams(urlObj.search);
|
|
|
|
// Remove common tracking parameters if requested
|
|
if (removeCommonTracking) {
|
|
const trackingParams = [
|
|
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',
|
|
'gclid', 'fbclid', 'msclkid', 'ref', 'source', 'campaign',
|
|
'_ga', '_gid', 'mc_cid', 'mc_eid', 'yclid'
|
|
];
|
|
trackingParams.forEach(param => params.delete(param));
|
|
}
|
|
|
|
if (params.toString()) {
|
|
if (sortQueryParams) {
|
|
const sortedParams = new URLSearchParams();
|
|
[...params.keys()].sort().forEach(key => {
|
|
params.getAll(key).forEach(value => {
|
|
sortedParams.append(key, value);
|
|
});
|
|
});
|
|
normalized += '?' + sortedParams.toString();
|
|
} else {
|
|
normalized += '?' + params.toString();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add hash/fragment if present and not removed
|
|
if (!removeFragment && urlObj.hash) {
|
|
normalized += urlObj.hash;
|
|
}
|
|
|
|
return normalized;
|
|
} catch (error) {
|
|
console.warn('URL normalization failed for:', url, error);
|
|
// If URL parsing fails, return the original URL with basic cleanup
|
|
return url.toLowerCase().trim();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Calculate Levenshtein distance between two strings for fuzzy matching
|
|
* @param {string} str1 - First string
|
|
* @param {string} str2 - Second string
|
|
* @returns {number} Edit distance between strings
|
|
*/
|
|
levenshteinDistance(str1, str2) {
|
|
const matrix = [];
|
|
|
|
// Create matrix
|
|
for (let i = 0; i <= str2.length; i++) {
|
|
matrix[i] = [i];
|
|
}
|
|
|
|
for (let j = 0; j <= str1.length; j++) {
|
|
matrix[0][j] = j;
|
|
}
|
|
|
|
// Fill matrix
|
|
for (let i = 1; i <= str2.length; i++) {
|
|
for (let j = 1; j <= str1.length; j++) {
|
|
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
matrix[i][j] = matrix[i - 1][j - 1];
|
|
} else {
|
|
matrix[i][j] = Math.min(
|
|
matrix[i - 1][j - 1] + 1, // substitution
|
|
matrix[i][j - 1] + 1, // insertion
|
|
matrix[i - 1][j] + 1 // deletion
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return matrix[str2.length][str1.length];
|
|
}
|
|
|
|
/**
|
|
* Calculate similarity ratio between two strings
|
|
* @param {string} str1 - First string
|
|
* @param {string} str2 - Second string
|
|
* @returns {number} Similarity ratio (0-1)
|
|
*/
|
|
calculateSimilarity(str1, str2) {
|
|
if (str1 === str2) return 1;
|
|
if (!str1 || !str2) return 0;
|
|
|
|
const maxLength = Math.max(str1.length, str2.length);
|
|
if (maxLength === 0) return 1;
|
|
|
|
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
|
|
return (maxLength - distance) / maxLength;
|
|
}
|
|
|
|
/**
|
|
* Normalize title for fuzzy matching
|
|
* @param {string} title - Title to normalize
|
|
* @returns {string} Normalized title
|
|
*/
|
|
normalizeTitle(title) {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^\w\s]/g, '') // Remove punctuation
|
|
.replace(/\s+/g, ' ') // Normalize whitespace
|
|
.trim();
|
|
}
|
|
|
|
/**
|
|
* Advanced duplicate detection with enhanced URL normalization and fuzzy title matching
|
|
*/
|
|
async findDuplicates() {
|
|
// Set loading state for large operations
|
|
if (this.bookmarks.length > this.virtualScrollThreshold) {
|
|
this.isLoading = true;
|
|
this.showLoadingState();
|
|
}
|
|
|
|
// First, reset all duplicate statuses
|
|
this.bookmarks.forEach(bookmark => {
|
|
if (bookmark.status === 'duplicate') {
|
|
bookmark.status = 'unknown';
|
|
}
|
|
});
|
|
|
|
// Enhanced duplicate detection with multiple strategies
|
|
const duplicateGroups = await this.detectDuplicates();
|
|
|
|
if (duplicateGroups.length === 0) {
|
|
alert('No duplicate bookmarks found.');
|
|
this.isLoading = false;
|
|
return;
|
|
}
|
|
|
|
// Show duplicate preview and resolution options
|
|
this.showDuplicatePreview(duplicateGroups);
|
|
}
|
|
|
|
/**
|
|
* Detect duplicates using multiple strategies
|
|
* @returns {Array} Array of duplicate groups
|
|
*/
|
|
async detectDuplicates() {
|
|
const duplicateGroups = [];
|
|
const processedBookmarks = new Set();
|
|
|
|
// Strategy 1: Exact URL matches with enhanced normalization
|
|
const urlGroups = this.findUrlDuplicates();
|
|
urlGroups.forEach(group => {
|
|
if (group.length > 1) {
|
|
duplicateGroups.push({
|
|
type: 'exact_url',
|
|
reason: 'Identical URLs',
|
|
bookmarks: group,
|
|
confidence: 1.0
|
|
});
|
|
group.forEach(bookmark => processedBookmarks.add(bookmark.id));
|
|
}
|
|
});
|
|
|
|
// Strategy 2: URL duplicates with different query parameters/fragments
|
|
const urlVariantGroups = this.findUrlVariantDuplicates(processedBookmarks);
|
|
urlVariantGroups.forEach(group => {
|
|
if (group.length > 1) {
|
|
duplicateGroups.push({
|
|
type: 'url_variant',
|
|
reason: 'Same URL with different parameters/fragments',
|
|
bookmarks: group,
|
|
confidence: 0.9
|
|
});
|
|
group.forEach(bookmark => processedBookmarks.add(bookmark.id));
|
|
}
|
|
});
|
|
|
|
// Strategy 3: Fuzzy title matching for near-duplicates
|
|
const titleGroups = this.findTitleDuplicates(processedBookmarks);
|
|
titleGroups.forEach(group => {
|
|
if (group.length > 1) {
|
|
duplicateGroups.push({
|
|
type: 'fuzzy_title',
|
|
reason: 'Similar titles',
|
|
bookmarks: group.bookmarks,
|
|
confidence: group.confidence
|
|
});
|
|
group.bookmarks.forEach(bookmark => processedBookmarks.add(bookmark.id));
|
|
}
|
|
});
|
|
|
|
return duplicateGroups;
|
|
}
|
|
|
|
/**
|
|
* Find exact URL duplicates with enhanced normalization
|
|
* @returns {Array} Array of bookmark groups with identical URLs
|
|
*/
|
|
findUrlDuplicates() {
|
|
const urlMap = new Map();
|
|
|
|
this.bookmarks.forEach(bookmark => {
|
|
// Enhanced URL normalization
|
|
const normalizedUrl = this.normalizeUrl(bookmark.url, {
|
|
removeQueryParams: false,
|
|
removeFragment: false,
|
|
removeWWW: true,
|
|
removeTrailingSlash: true,
|
|
sortQueryParams: true,
|
|
removeCommonTracking: false
|
|
});
|
|
|
|
if (urlMap.has(normalizedUrl)) {
|
|
urlMap.get(normalizedUrl).push(bookmark);
|
|
} else {
|
|
urlMap.set(normalizedUrl, [bookmark]);
|
|
}
|
|
});
|
|
|
|
return Array.from(urlMap.values()).filter(group => group.length > 1);
|
|
}
|
|
|
|
/**
|
|
* Find URL variants (same base URL, different parameters/fragments)
|
|
* @param {Set} processedBookmarks - Already processed bookmark IDs
|
|
* @returns {Array} Array of bookmark groups with URL variants
|
|
*/
|
|
findUrlVariantDuplicates(processedBookmarks) {
|
|
const baseUrlMap = new Map();
|
|
|
|
this.bookmarks
|
|
.filter(bookmark => !processedBookmarks.has(bookmark.id))
|
|
.forEach(bookmark => {
|
|
// Normalize URL without query params and fragments
|
|
const baseUrl = this.normalizeUrl(bookmark.url, {
|
|
removeQueryParams: true,
|
|
removeFragment: true,
|
|
removeWWW: true,
|
|
removeTrailingSlash: true
|
|
});
|
|
|
|
if (baseUrlMap.has(baseUrl)) {
|
|
baseUrlMap.get(baseUrl).push(bookmark);
|
|
} else {
|
|
baseUrlMap.set(baseUrl, [bookmark]);
|
|
}
|
|
});
|
|
|
|
return Array.from(baseUrlMap.values()).filter(group => group.length > 1);
|
|
}
|
|
|
|
/**
|
|
* Find title duplicates using fuzzy matching
|
|
* @param {Set} processedBookmarks - Already processed bookmark IDs
|
|
* @returns {Array} Array of bookmark groups with similar titles
|
|
*/
|
|
findTitleDuplicates(processedBookmarks) {
|
|
const titleGroups = [];
|
|
const remainingBookmarks = this.bookmarks.filter(bookmark => !processedBookmarks.has(bookmark.id));
|
|
const processedTitles = new Set();
|
|
|
|
remainingBookmarks.forEach((bookmark, index) => {
|
|
if (processedTitles.has(bookmark.id)) return;
|
|
|
|
const normalizedTitle = this.normalizeTitle(bookmark.title);
|
|
const similarBookmarks = [bookmark];
|
|
|
|
// Compare with remaining bookmarks
|
|
for (let i = index + 1; i < remainingBookmarks.length; i++) {
|
|
const otherBookmark = remainingBookmarks[i];
|
|
if (processedTitles.has(otherBookmark.id)) continue;
|
|
|
|
const otherNormalizedTitle = this.normalizeTitle(otherBookmark.title);
|
|
const similarity = this.calculateSimilarity(normalizedTitle, otherNormalizedTitle);
|
|
|
|
// Consider titles similar if similarity > 0.8 and length difference is reasonable
|
|
if (similarity > 0.8 && Math.abs(normalizedTitle.length - otherNormalizedTitle.length) < 20) {
|
|
similarBookmarks.push(otherBookmark);
|
|
processedTitles.add(otherBookmark.id);
|
|
}
|
|
}
|
|
|
|
if (similarBookmarks.length > 1) {
|
|
const avgSimilarity = similarBookmarks.reduce((sum, bookmark, idx) => {
|
|
if (idx === 0) return sum;
|
|
return sum + this.calculateSimilarity(normalizedTitle, this.normalizeTitle(bookmark.title));
|
|
}, 0) / (similarBookmarks.length - 1);
|
|
|
|
titleGroups.push({
|
|
bookmarks: similarBookmarks,
|
|
confidence: Math.round(avgSimilarity * 100) / 100
|
|
});
|
|
|
|
similarBookmarks.forEach(bookmark => processedTitles.add(bookmark.id));
|
|
}
|
|
});
|
|
|
|
return titleGroups;
|
|
}
|
|
|
|
/**
|
|
* Show duplicate preview modal with resolution options
|
|
* @param {Array} duplicateGroups - Array of duplicate groups
|
|
*/
|
|
showDuplicatePreview(duplicateGroups) {
|
|
this.currentDuplicateGroups = duplicateGroups;
|
|
|
|
// Calculate totals
|
|
const totalDuplicates = duplicateGroups.reduce((sum, group) => sum + group.bookmarks.length, 0);
|
|
const totalGroups = duplicateGroups.length;
|
|
|
|
// Update modal title
|
|
document.getElementById('duplicateTitle').textContent =
|
|
`${totalDuplicates} Duplicate Bookmarks Found in ${totalGroups} Group${totalGroups > 1 ? 's' : ''}`;
|
|
|
|
// Render duplicate groups
|
|
this.renderDuplicateGroups(duplicateGroups);
|
|
|
|
// Bind events for the duplicate modal
|
|
this.bindDuplicateModalEvents();
|
|
|
|
// Show the modal
|
|
this.showModal('duplicateModal');
|
|
|
|
// Clear loading state
|
|
this.isLoading = false;
|
|
}
|
|
|
|
/**
|
|
* Render duplicate groups in the preview
|
|
* @param {Array} duplicateGroups - Array of duplicate groups
|
|
*/
|
|
renderDuplicateGroups(duplicateGroups) {
|
|
const previewContainer = document.getElementById('duplicatePreview');
|
|
previewContainer.innerHTML = '';
|
|
|
|
duplicateGroups.forEach((group, groupIndex) => {
|
|
const groupElement = document.createElement('div');
|
|
groupElement.className = 'duplicate-group';
|
|
groupElement.dataset.groupIndex = groupIndex;
|
|
|
|
// Group header
|
|
const headerElement = document.createElement('div');
|
|
headerElement.className = 'duplicate-group-header';
|
|
|
|
const titleElement = document.createElement('h4');
|
|
titleElement.className = 'duplicate-group-title';
|
|
titleElement.textContent = `Group ${groupIndex + 1}: ${group.reason}`;
|
|
|
|
const typeElement = document.createElement('span');
|
|
typeElement.className = `duplicate-group-type duplicate-type-${group.type}`;
|
|
typeElement.textContent = group.type.replace('_', ' ');
|
|
|
|
if (group.confidence < 1.0) {
|
|
const confidenceElement = document.createElement('span');
|
|
confidenceElement.className = 'duplicate-confidence';
|
|
confidenceElement.textContent = `(${Math.round(group.confidence * 100)}% match)`;
|
|
headerElement.appendChild(titleElement);
|
|
headerElement.appendChild(typeElement);
|
|
headerElement.appendChild(confidenceElement);
|
|
} else {
|
|
headerElement.appendChild(titleElement);
|
|
headerElement.appendChild(typeElement);
|
|
}
|
|
|
|
// Bookmarks list
|
|
const bookmarksContainer = document.createElement('div');
|
|
bookmarksContainer.className = 'duplicate-bookmarks';
|
|
|
|
group.bookmarks.forEach((bookmark, bookmarkIndex) => {
|
|
const bookmarkElement = document.createElement('div');
|
|
bookmarkElement.className = 'duplicate-bookmark-item';
|
|
bookmarkElement.dataset.bookmarkId = bookmark.id;
|
|
bookmarkElement.dataset.groupIndex = groupIndex;
|
|
bookmarkElement.dataset.bookmarkIndex = bookmarkIndex;
|
|
|
|
// Selection checkbox (for manual resolution)
|
|
const selectElement = document.createElement('input');
|
|
selectElement.type = 'checkbox';
|
|
selectElement.className = 'duplicate-bookmark-select';
|
|
selectElement.style.display = 'none'; // Hidden by default
|
|
|
|
// Bookmark info
|
|
const infoElement = document.createElement('div');
|
|
infoElement.className = 'duplicate-bookmark-info';
|
|
|
|
const titleElement = document.createElement('div');
|
|
titleElement.className = 'duplicate-bookmark-title';
|
|
titleElement.textContent = bookmark.title;
|
|
|
|
const urlElement = document.createElement('div');
|
|
urlElement.className = 'duplicate-bookmark-url';
|
|
urlElement.textContent = bookmark.url;
|
|
|
|
const metaElement = document.createElement('div');
|
|
metaElement.className = 'duplicate-bookmark-meta';
|
|
|
|
const addedDate = new Date(bookmark.addDate).toLocaleDateString();
|
|
metaElement.innerHTML = `
|
|
<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 {
|
|
// Try to get favicon from the domain
|
|
try {
|
|
const url = new URL(bookmark.url);
|
|
contextFavicon.src = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`;
|
|
} catch (error) {
|
|
contextFavicon.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=';
|
|
}
|
|
}
|
|
|
|
// Add error handling for favicon
|
|
contextFavicon.onerror = function () {
|
|
if (this.src !== 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=') {
|
|
try {
|
|
const url = new URL(bookmark.url);
|
|
this.src = `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=16`;
|
|
} catch (error) {
|
|
this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=';
|
|
}
|
|
} else {
|
|
this.src = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PHBhdGggZmlsbD0iIzk5OSIgZD0iTTggMEMzLjYgMCAwIDMuNiAwIDhzMy42IDggOCA4IDgtMy42IDgtOC0zLjYtOC04LTh6bTAgMTRjLTMuMyAwLTYtMi43LTYtNnMyLjctNiA2LTYgNiAyLjcgNiA2LTIuNyA2LTYgNnoiLz48L3N2Zz4=';
|
|
}
|
|
};
|
|
|
|
document.getElementById('contextBookmarkTitle').textContent = bookmark.title;
|
|
|
|
// Set up the clickable link
|
|
const contextLink = document.getElementById('contextBookmarkLink');
|
|
contextLink.href = bookmark.url;
|
|
contextLink.textContent = bookmark.url;
|
|
|
|
// Also keep the non-clickable URL for display consistency
|
|
document.getElementById('contextBookmarkUrl').textContent = bookmark.url;
|
|
|
|
const folderElement = document.getElementById('contextBookmarkFolder');
|
|
if (bookmark.folder) {
|
|
folderElement.textContent = bookmark.folder;
|
|
folderElement.style.display = 'inline-block';
|
|
} else {
|
|
folderElement.style.display = 'none';
|
|
}
|
|
|
|
// Display date information
|
|
const addDateElement = document.getElementById('contextAddDate');
|
|
const lastModifiedElement = document.getElementById('contextLastModified');
|
|
|
|
if (bookmark.addDate) {
|
|
const addDate = new Date(bookmark.addDate);
|
|
addDateElement.textContent = `Added: ${addDate.toLocaleDateString()} ${addDate.toLocaleTimeString()}`;
|
|
addDateElement.style.display = 'block';
|
|
} else {
|
|
addDateElement.style.display = 'none';
|
|
}
|
|
|
|
if (bookmark.lastModified) {
|
|
const lastModified = new Date(bookmark.lastModified);
|
|
lastModifiedElement.textContent = `Modified: ${lastModified.toLocaleDateString()} ${lastModified.toLocaleTimeString()}`;
|
|
lastModifiedElement.style.display = 'block';
|
|
} else {
|
|
lastModifiedElement.style.display = 'none';
|
|
}
|
|
|
|
const statusElement = document.getElementById('contextBookmarkStatus');
|
|
statusElement.className = `bookmark-status status-${bookmark.status}`;
|
|
|
|
// Set status icon in the context modal too
|
|
switch (bookmark.status) {
|
|
case 'valid':
|
|
statusElement.innerHTML = '✓';
|
|
statusElement.title = 'Valid link';
|
|
break;
|
|
case 'invalid':
|
|
statusElement.innerHTML = '✗';
|
|
statusElement.title = 'Invalid link';
|
|
break;
|
|
case 'testing':
|
|
statusElement.innerHTML = '⟳';
|
|
statusElement.title = 'Testing link...';
|
|
break;
|
|
case 'duplicate':
|
|
statusElement.innerHTML = '⚠';
|
|
statusElement.title = 'Duplicate link';
|
|
break;
|
|
default:
|
|
statusElement.innerHTML = '?';
|
|
statusElement.title = 'Unknown status';
|
|
}
|
|
|
|
// Display last visited information
|
|
const lastVisitedElement = document.getElementById('contextLastVisited');
|
|
if (bookmark.lastVisited) {
|
|
const lastVisited = new Date(bookmark.lastVisited);
|
|
lastVisitedElement.textContent = `Last visited: ${lastVisited.toLocaleDateString()} ${lastVisited.toLocaleTimeString()}`;
|
|
lastVisitedElement.style.display = 'block';
|
|
} else {
|
|
lastVisitedElement.style.display = 'none';
|
|
}
|
|
|
|
// Display metadata
|
|
const metadataContainer = document.getElementById('contextBookmarkMetadata');
|
|
let hasMetadata = false;
|
|
|
|
// Display tags
|
|
const tagsContainer = document.getElementById('contextBookmarkTags');
|
|
const tagsDisplay = document.getElementById('contextTagsDisplay');
|
|
if (bookmark.tags && bookmark.tags.length > 0) {
|
|
tagsDisplay.innerHTML = '';
|
|
bookmark.tags.forEach(tag => {
|
|
const tagElement = document.createElement('span');
|
|
tagElement.className = 'bookmark-tag';
|
|
tagElement.textContent = tag;
|
|
tagsDisplay.appendChild(tagElement);
|
|
});
|
|
tagsContainer.style.display = 'block';
|
|
hasMetadata = true;
|
|
} else {
|
|
tagsContainer.style.display = 'none';
|
|
}
|
|
|
|
// Display rating and favorite
|
|
const ratingContainer = document.getElementById('contextBookmarkRating');
|
|
const starsDisplay = document.getElementById('contextStarsDisplay');
|
|
const favoriteDisplay = document.getElementById('contextFavoriteDisplay');
|
|
if (bookmark.rating && bookmark.rating > 0) {
|
|
starsDisplay.textContent = '★'.repeat(bookmark.rating) + '☆'.repeat(5 - bookmark.rating);
|
|
ratingContainer.style.display = 'block';
|
|
hasMetadata = true;
|
|
} else {
|
|
starsDisplay.textContent = '';
|
|
}
|
|
|
|
if (bookmark.favorite) {
|
|
favoriteDisplay.style.display = 'inline';
|
|
ratingContainer.style.display = 'block';
|
|
hasMetadata = true;
|
|
} else {
|
|
favoriteDisplay.style.display = 'none';
|
|
}
|
|
|
|
if (!bookmark.rating && !bookmark.favorite) {
|
|
ratingContainer.style.display = 'none';
|
|
}
|
|
|
|
// Display notes
|
|
const notesContainer = document.getElementById('contextBookmarkNotes');
|
|
const notesDisplay = document.getElementById('contextNotesDisplay');
|
|
if (bookmark.notes && bookmark.notes.trim()) {
|
|
notesDisplay.textContent = bookmark.notes;
|
|
notesContainer.style.display = 'block';
|
|
hasMetadata = true;
|
|
} else {
|
|
notesContainer.style.display = 'none';
|
|
}
|
|
|
|
// Show/hide metadata container
|
|
metadataContainer.style.display = hasMetadata ? 'block' : 'none';
|
|
|
|
// Update privacy controls
|
|
this.updateContextModalPrivacyControls(bookmark);
|
|
|
|
// Show the context modal with custom focus handling
|
|
this.showContextModal();
|
|
}
|
|
|
|
// Custom method to show context modal with proper focus handling
|
|
showContextModal() {
|
|
const modal = document.getElementById('contextModal');
|
|
if (modal) {
|
|
modal.style.display = 'block';
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
|
|
// Remove focus from close button and focus on Visit button instead
|
|
const closeBtn = modal.querySelector('.close');
|
|
if (closeBtn) {
|
|
closeBtn.setAttribute('tabindex', '-1');
|
|
}
|
|
|
|
// Focus on the Visit button after a small delay to ensure modal is rendered
|
|
setTimeout(() => {
|
|
const visitBtn = modal.querySelector('#visitBookmarkBtn');
|
|
if (visitBtn) {
|
|
visitBtn.focus();
|
|
} else {
|
|
// Fallback to first action button if Visit button not found
|
|
const actionButtons = modal.querySelectorAll('.modal-actions button');
|
|
if (actionButtons.length > 0) {
|
|
actionButtons[0].focus();
|
|
}
|
|
}
|
|
}, 50);
|
|
|
|
// Store the previously focused element to restore later
|
|
modal.dataset.previousFocus = document.activeElement.id || '';
|
|
}
|
|
}
|
|
|
|
// Update privacy controls in context modal
|
|
updateContextModalPrivacyControls(bookmark) {
|
|
const privacyToggle = document.getElementById('contextPrivacyToggle');
|
|
const encryptionToggle = document.getElementById('contextEncryptionToggle');
|
|
|
|
if (privacyToggle) {
|
|
privacyToggle.checked = this.isBookmarkPrivate(bookmark.id);
|
|
}
|
|
|
|
if (encryptionToggle) {
|
|
encryptionToggle.checked = this.isBookmarkEncrypted(bookmark.id);
|
|
}
|
|
}
|
|
|
|
updateStats() {
|
|
const total = this.bookmarks.length;
|
|
const valid = this.bookmarks.filter(b => b.status === 'valid').length;
|
|
const invalid = this.bookmarks.filter(b => b.status === 'invalid').length;
|
|
const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length;
|
|
const favorites = this.bookmarks.filter(b => b.favorite).length;
|
|
|
|
document.getElementById('totalCount').textContent = `Total: ${total}`;
|
|
document.getElementById('validCount').textContent = `Valid: ${valid}`;
|
|
document.getElementById('invalidCount').textContent = `Invalid: ${invalid}`;
|
|
document.getElementById('duplicateCount').textContent = `Duplicates: ${duplicates}`;
|
|
document.getElementById('favoriteCount').textContent = `Favorites: ${favorites}`;
|
|
}
|
|
|
|
// Analytics Dashboard Methods
|
|
showAnalyticsModal() {
|
|
this.showModal('analyticsModal');
|
|
this.initializeAnalytics();
|
|
}
|
|
|
|
initializeAnalytics() {
|
|
// Initialize analytics tabs
|
|
this.bindAnalyticsTabEvents();
|
|
|
|
// Load overview tab by default
|
|
this.loadOverviewAnalytics();
|
|
}
|
|
|
|
bindAnalyticsTabEvents() {
|
|
const tabs = document.querySelectorAll('.analytics-tab');
|
|
const tabContents = document.querySelectorAll('.analytics-tab-content');
|
|
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
// Remove active class from all tabs and contents
|
|
tabs.forEach(t => t.classList.remove('active'));
|
|
tabContents.forEach(content => content.classList.remove('active'));
|
|
|
|
// Add active class to clicked tab
|
|
tab.classList.add('active');
|
|
|
|
// Show corresponding content
|
|
const tabName = tab.getAttribute('data-tab');
|
|
const content = document.getElementById(`${tabName}Tab`);
|
|
if (content) {
|
|
content.classList.add('active');
|
|
this.loadTabAnalytics(tabName);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Trends timeframe selector
|
|
const trendsTimeframe = document.getElementById('trendsTimeframe');
|
|
if (trendsTimeframe) {
|
|
trendsTimeframe.addEventListener('change', () => {
|
|
this.loadTrendsAnalytics();
|
|
});
|
|
}
|
|
}
|
|
|
|
loadTabAnalytics(tabName) {
|
|
switch (tabName) {
|
|
case 'overview':
|
|
this.loadOverviewAnalytics();
|
|
break;
|
|
case 'trends':
|
|
this.loadTrendsAnalytics();
|
|
break;
|
|
case 'health':
|
|
this.loadHealthAnalytics();
|
|
break;
|
|
case 'usage':
|
|
this.loadUsageAnalytics();
|
|
break;
|
|
}
|
|
}
|
|
|
|
loadOverviewAnalytics() {
|
|
// Update summary cards
|
|
const total = this.bookmarks.length;
|
|
const valid = this.bookmarks.filter(b => b.status === 'valid').length;
|
|
const invalid = this.bookmarks.filter(b => b.status === 'invalid').length;
|
|
const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length;
|
|
|
|
document.getElementById('totalBookmarksCount').textContent = total;
|
|
document.getElementById('validLinksCount').textContent = valid;
|
|
document.getElementById('invalidLinksCount').textContent = invalid;
|
|
document.getElementById('duplicatesCount').textContent = duplicates;
|
|
|
|
// Create charts
|
|
this.createStatusChart();
|
|
this.createFoldersChart();
|
|
}
|
|
|
|
createStatusChart() {
|
|
const canvas = document.getElementById('statusChart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const total = this.bookmarks.length;
|
|
|
|
if (total === 0) {
|
|
this.drawEmptyChart(ctx, canvas, 'No bookmarks to analyze');
|
|
return;
|
|
}
|
|
|
|
const valid = this.bookmarks.filter(b => b.status === 'valid').length;
|
|
const invalid = this.bookmarks.filter(b => b.status === 'invalid').length;
|
|
const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length;
|
|
const unknown = this.bookmarks.filter(b => b.status === 'unknown').length;
|
|
|
|
const data = [
|
|
{ label: 'Valid', value: valid, color: '#28a745' },
|
|
{ label: 'Invalid', value: invalid, color: '#dc3545' },
|
|
{ label: 'Duplicates', value: duplicates, color: '#17a2b8' },
|
|
{ label: 'Unknown', value: unknown, color: '#6c757d' }
|
|
].filter(item => item.value > 0);
|
|
|
|
this.drawPieChart(ctx, canvas, data, 'Status Distribution');
|
|
}
|
|
|
|
createFoldersChart() {
|
|
const canvas = document.getElementById('foldersChart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
const folderStats = this.getFolderStats();
|
|
|
|
if (Object.keys(folderStats).length === 0) {
|
|
this.drawEmptyChart(ctx, canvas, 'No folders to analyze');
|
|
return;
|
|
}
|
|
|
|
const data = Object.entries(folderStats)
|
|
.map(([folder, stats]) => ({
|
|
label: folder || 'Uncategorized',
|
|
value: stats.total,
|
|
color: this.generateFolderColor(folder)
|
|
}))
|
|
.sort((a, b) => b.value - a.value)
|
|
.slice(0, 10); // Top 10 folders
|
|
|
|
this.drawBarChart(ctx, canvas, data, 'Top Folders by Bookmark Count');
|
|
}
|
|
|
|
loadTrendsAnalytics() {
|
|
const timeframe = parseInt(document.getElementById('trendsTimeframe').value);
|
|
const endDate = new Date();
|
|
const startDate = new Date(endDate.getTime() - (timeframe * 24 * 60 * 60 * 1000));
|
|
|
|
this.createTrendsChart(startDate, endDate);
|
|
this.createTestingTrendsChart(startDate, endDate);
|
|
}
|
|
|
|
createTrendsChart(startDate, endDate) {
|
|
const canvas = document.getElementById('trendsChart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Group bookmarks by date
|
|
const bookmarksByDate = {};
|
|
this.bookmarks.forEach(bookmark => {
|
|
const date = new Date(bookmark.addDate);
|
|
if (date >= startDate && date <= endDate) {
|
|
const dateKey = date.toISOString().split('T')[0];
|
|
bookmarksByDate[dateKey] = (bookmarksByDate[dateKey] || 0) + 1;
|
|
}
|
|
});
|
|
|
|
const data = this.generateDateRange(startDate, endDate).map(date => ({
|
|
label: date,
|
|
value: bookmarksByDate[date] || 0
|
|
}));
|
|
|
|
this.drawLineChart(ctx, canvas, data, 'Bookmarks Added Over Time', '#3498db');
|
|
}
|
|
|
|
createTestingTrendsChart(startDate, endDate) {
|
|
const canvas = document.getElementById('testingTrendsChart');
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Group test results by date
|
|
const testsByDate = {};
|
|
this.bookmarks.forEach(bookmark => {
|
|
if (bookmark.lastTested) {
|
|
const date = new Date(bookmark.lastTested);
|
|
if (date >= startDate && date <= endDate) {
|
|
const dateKey = date.toISOString().split('T')[0];
|
|
if (!testsByDate[dateKey]) {
|
|
testsByDate[dateKey] = { valid: 0, invalid: 0 };
|
|
}
|
|
if (bookmark.status === 'valid') {
|
|
testsByDate[dateKey].valid++;
|
|
} else if (bookmark.status === 'invalid') {
|
|
testsByDate[dateKey].invalid++;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const validData = this.generateDateRange(startDate, endDate).map(date => ({
|
|
label: date,
|
|
value: testsByDate[date]?.valid || 0
|
|
}));
|
|
|
|
const invalidData = this.generateDateRange(startDate, endDate).map(date => ({
|
|
label: date,
|
|
value: testsByDate[date]?.invalid || 0
|
|
}));
|
|
|
|
this.drawMultiLineChart(ctx, canvas, [
|
|
{ data: validData, color: '#28a745', label: 'Valid' },
|
|
{ data: invalidData, color: '#dc3545', label: 'Invalid' }
|
|
], 'Link Testing Results Over Time');
|
|
}
|
|
|
|
loadHealthAnalytics() {
|
|
const healthData = this.calculateHealthMetrics();
|
|
|
|
// Update health score
|
|
document.getElementById('healthScore').textContent = `${healthData.score}%`;
|
|
|
|
// Update last full test
|
|
document.getElementById('lastFullTest').textContent = healthData.lastFullTest;
|
|
|
|
// Update health issues
|
|
this.displayHealthIssues(healthData.issues);
|
|
|
|
// Update recommendations
|
|
this.displayHealthRecommendations(healthData.recommendations);
|
|
}
|
|
|
|
calculateHealthMetrics() {
|
|
const total = this.bookmarks.length;
|
|
if (total === 0) {
|
|
return {
|
|
score: 100,
|
|
lastFullTest: 'Never',
|
|
issues: [],
|
|
recommendations: []
|
|
};
|
|
}
|
|
|
|
const valid = this.bookmarks.filter(b => b.status === 'valid').length;
|
|
const invalid = this.bookmarks.filter(b => b.status === 'invalid').length;
|
|
const duplicates = this.bookmarks.filter(b => b.status === 'duplicate').length;
|
|
const unknown = this.bookmarks.filter(b => b.status === 'unknown').length;
|
|
|
|
// Calculate health score (0-100)
|
|
let score = 100;
|
|
score -= (invalid / total) * 40; // Invalid links reduce score by up to 40%
|
|
score -= (duplicates / total) * 30; // Duplicates reduce score by up to 30%
|
|
score -= (unknown / total) * 20; // Unknown status reduces score by up to 20%
|
|
score = Math.max(0, Math.round(score));
|
|
|
|
// Find last full test
|
|
const testedBookmarksWithDates = this.bookmarks
|
|
.filter(b => b.lastTested)
|
|
.map(b => b.lastTested)
|
|
.sort((a, b) => b - a);
|
|
|
|
const lastFullTest = testedBookmarksWithDates.length > 0
|
|
? new Date(testedBookmarksWithDates[0]).toLocaleDateString()
|
|
: 'Never';
|
|
|
|
// Identify issues
|
|
const issues = [];
|
|
if (invalid > 0) {
|
|
issues.push({
|
|
type: 'error',
|
|
title: 'Broken Links Found',
|
|
description: `${invalid} bookmark${invalid > 1 ? 's have' : ' has'} invalid links that need attention.`,
|
|
count: invalid
|
|
});
|
|
}
|
|
|
|
if (duplicates > 0) {
|
|
issues.push({
|
|
type: 'warning',
|
|
title: 'Duplicate Bookmarks',
|
|
description: `${duplicates} duplicate bookmark${duplicates > 1 ? 's were' : ' was'} found in your collection.`,
|
|
count: duplicates
|
|
});
|
|
}
|
|
|
|
if (unknown > 0) {
|
|
issues.push({
|
|
type: 'info',
|
|
title: 'Untested Links',
|
|
description: `${unknown} bookmark${unknown > 1 ? 's have' : ' has'} not been tested yet.`,
|
|
count: unknown
|
|
});
|
|
}
|
|
|
|
// Old bookmarks (older than 2 years)
|
|
const twoYearsAgo = Date.now() - (2 * 365 * 24 * 60 * 60 * 1000);
|
|
const oldBookmarks = this.bookmarks.filter(b => b.addDate < twoYearsAgo).length;
|
|
if (oldBookmarks > 0) {
|
|
issues.push({
|
|
type: 'info',
|
|
title: 'Old Bookmarks',
|
|
description: `${oldBookmarks} bookmark${oldBookmarks > 1 ? 's are' : ' is'} older than 2 years and might need review.`,
|
|
count: oldBookmarks
|
|
});
|
|
}
|
|
|
|
// Generate recommendations
|
|
const recommendations = [];
|
|
if (invalid > 0) {
|
|
recommendations.push({
|
|
title: 'Fix Broken Links',
|
|
description: 'Review and update or remove bookmarks with invalid links to improve your collection quality.'
|
|
});
|
|
}
|
|
|
|
if (duplicates > 0) {
|
|
recommendations.push({
|
|
title: 'Remove Duplicates',
|
|
description: 'Use the duplicate detection feature to clean up redundant bookmarks and organize your collection better.'
|
|
});
|
|
}
|
|
|
|
if (unknown > total * 0.5) {
|
|
recommendations.push({
|
|
title: 'Test Your Links',
|
|
description: 'Run a full link test to verify the status of your bookmarks and identify any issues.'
|
|
});
|
|
}
|
|
|
|
if (score < 70) {
|
|
recommendations.push({
|
|
title: 'Regular Maintenance',
|
|
description: 'Consider setting up a regular schedule to review and maintain your bookmark collection.'
|
|
});
|
|
}
|
|
|
|
return { score, lastFullTest, issues, recommendations };
|
|
}
|
|
|
|
displayHealthIssues(issues) {
|
|
const container = document.getElementById('healthIssuesList');
|
|
if (!container) return;
|
|
|
|
if (issues.length === 0) {
|
|
container.innerHTML = '<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!');
|
|
}
|
|
|
|
clearAllBookmarks() {
|
|
this.bookmarks = [];
|
|
this.currentFilter = 'all'; // Reset filter when clearing all
|
|
this.saveBookmarksToStorage();
|
|
this.renderBookmarks();
|
|
this.updateStats();
|
|
|
|
// Reset filter button to "Total"
|
|
document.querySelectorAll('.stats-filter').forEach(btn => btn.classList.remove('active'));
|
|
document.getElementById('totalCount').classList.add('active');
|
|
}
|
|
|
|
saveBookmarksToStorage() {
|
|
try {
|
|
// Apply encryption to bookmarks that are marked for encryption
|
|
const bookmarksToSave = this.bookmarks.map(bookmark => {
|
|
if (this.isBookmarkEncrypted(bookmark.id)) {
|
|
return this.encryptBookmark(bookmark);
|
|
}
|
|
return bookmark;
|
|
});
|
|
|
|
localStorage.setItem('bookmarks', JSON.stringify(bookmarksToSave));
|
|
|
|
// Update backup tracking
|
|
this.updateBackupTracking();
|
|
|
|
this.logAccess('bookmarks_saved', { count: bookmarksToSave.length });
|
|
} catch (error) {
|
|
console.error('Error saving bookmarks:', error);
|
|
alert('Error saving bookmarks. Your changes may not be preserved.');
|
|
}
|
|
}
|
|
|
|
loadBookmarksFromStorage() {
|
|
const stored = localStorage.getItem('bookmarks');
|
|
console.log('Loading bookmarks from storage:', stored ? 'Found data' : 'No data found');
|
|
|
|
if (stored) {
|
|
try {
|
|
const loadedBookmarks = JSON.parse(stored);
|
|
console.log('Parsed bookmarks:', loadedBookmarks.length, 'bookmarks found');
|
|
|
|
// Decrypt bookmarks that are encrypted
|
|
this.bookmarks = loadedBookmarks.map(bookmark => {
|
|
if (bookmark.encrypted && this.isBookmarkEncrypted(bookmark.id)) {
|
|
return this.decryptBookmark(bookmark);
|
|
}
|
|
return bookmark;
|
|
});
|
|
|
|
console.log('Final bookmarks loaded:', this.bookmarks.length);
|
|
this.logAccess('bookmarks_loaded', { count: this.bookmarks.length });
|
|
} catch (error) {
|
|
console.error('Error loading bookmarks from storage:', error);
|
|
this.bookmarks = [];
|
|
}
|
|
} else {
|
|
console.log('No bookmarks found in localStorage');
|
|
this.bookmarks = [];
|
|
}
|
|
|
|
// Load backup settings and check if reminder is needed
|
|
this.loadBackupSettings();
|
|
this.checkBackupReminder();
|
|
}
|
|
|
|
// Enhanced Export Functionality
|
|
showExportModal() {
|
|
this.populateExportFolderList();
|
|
this.updateExportPreview();
|
|
this.showModal('exportModal');
|
|
}
|
|
|
|
populateExportFolderList() {
|
|
const uniqueFolders = [...new Set(
|
|
this.bookmarks
|
|
.map(bookmark => bookmark.folder)
|
|
.filter(folder => folder && folder.trim() !== '')
|
|
)].sort();
|
|
|
|
const folderSelect = document.getElementById('exportFolderSelect');
|
|
folderSelect.innerHTML = '<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)}"e=${encodeURIComponent(shareText)}`;
|
|
break;
|
|
case 'linkedin':
|
|
socialUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(collectionName)}&summary=${encodeURIComponent(shareText)}`;
|
|
break;
|
|
case 'reddit':
|
|
socialUrl = `https://reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(collectionName)}`;
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
window.open(socialUrl, '_blank', 'width=600,height=400');
|
|
}
|
|
|
|
// Send email
|
|
sendEmail() {
|
|
const recipients = document.getElementById('emailRecipients').value;
|
|
const subject = document.getElementById('emailSubject').value;
|
|
const message = document.getElementById('emailMessage').value;
|
|
|
|
if (!recipients.trim()) {
|
|
alert('Please enter at least one recipient email address.');
|
|
return;
|
|
}
|
|
|
|
// In a real application, this would send via a backend service
|
|
alert('Email functionality would be implemented with a backend service. For now, opening email client...');
|
|
this.openEmailClient();
|
|
}
|
|
|
|
// Open email client
|
|
openEmailClient() {
|
|
const recipients = document.getElementById('emailRecipients').value;
|
|
const subject = document.getElementById('emailSubject').value;
|
|
const message = document.getElementById('emailMessage').value;
|
|
const shareUrl = document.getElementById('shareUrl').value;
|
|
const includeUrl = document.getElementById('includeShareUrl').checked;
|
|
const includeList = document.getElementById('includeBookmarkList').checked;
|
|
|
|
let emailBody = message + '\n\n';
|
|
|
|
if (includeUrl && shareUrl) {
|
|
emailBody += `View and download the collection here: ${shareUrl}\n\n`;
|
|
}
|
|
|
|
if (includeList) {
|
|
const bookmarks = this.getBookmarksForSharing(document.getElementById('shareFilter').value);
|
|
emailBody += 'Bookmark List:\n';
|
|
bookmarks.forEach((bookmark, index) => {
|
|
emailBody += `${index + 1}. ${bookmark.title}\n ${bookmark.url}\n\n`;
|
|
});
|
|
}
|
|
|
|
const mailtoUrl = `mailto:${recipients}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(emailBody)}`;
|
|
window.location.href = mailtoUrl;
|
|
}
|
|
|
|
// Load recommendations
|
|
loadRecommendations() {
|
|
this.detectCategories();
|
|
this.loadSimilarCollections();
|
|
this.loadRecommendedBookmarks();
|
|
}
|
|
|
|
// Detect categories from user's bookmarks
|
|
detectCategories() {
|
|
const categories = new Map();
|
|
|
|
this.bookmarks.forEach(bookmark => {
|
|
// Simple category detection based on URL and title keywords
|
|
const text = (bookmark.title + ' ' + bookmark.url).toLowerCase();
|
|
|
|
if (text.includes('github') || text.includes('code') || text.includes('dev') || text.includes('programming')) {
|
|
categories.set('development', (categories.get('development') || 0) + 1);
|
|
}
|
|
if (text.includes('design') || text.includes('ui') || text.includes('ux') || text.includes('figma')) {
|
|
categories.set('design', (categories.get('design') || 0) + 1);
|
|
}
|
|
if (text.includes('productivity') || text.includes('tool') || text.includes('app')) {
|
|
categories.set('productivity', (categories.get('productivity') || 0) + 1);
|
|
}
|
|
if (text.includes('learn') || text.includes('tutorial') || text.includes('course') || text.includes('education')) {
|
|
categories.set('learning', (categories.get('learning') || 0) + 1);
|
|
}
|
|
if (text.includes('news') || text.includes('blog') || text.includes('article')) {
|
|
categories.set('news', (categories.get('news') || 0) + 1);
|
|
}
|
|
});
|
|
|
|
// Display detected categories
|
|
const categoriesContainer = document.getElementById('detectedCategories');
|
|
categoriesContainer.innerHTML = '';
|
|
|
|
[...categories.entries()]
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 5)
|
|
.forEach(([category, count]) => {
|
|
const tag = document.createElement('span');
|
|
tag.className = 'category-tag';
|
|
tag.textContent = `${category} (${count})`;
|
|
categoriesContainer.appendChild(tag);
|
|
});
|
|
}
|
|
|
|
// Load similar collections (mock data)
|
|
loadSimilarCollections() {
|
|
const mockCollections = [
|
|
{
|
|
title: 'Web Developer Resources',
|
|
description: 'Essential tools and resources for web developers',
|
|
bookmarks: 45,
|
|
downloads: 1200,
|
|
rating: 4.8
|
|
},
|
|
{
|
|
title: 'Design Inspiration Hub',
|
|
description: 'Curated collection of design inspiration and tools',
|
|
bookmarks: 32,
|
|
downloads: 890,
|
|
rating: 4.6
|
|
},
|
|
{
|
|
title: 'Productivity Power Pack',
|
|
description: 'Apps and tools to boost your productivity',
|
|
bookmarks: 28,
|
|
downloads: 650,
|
|
rating: 4.7
|
|
}
|
|
];
|
|
|
|
const container = document.getElementById('similarCollectionsList');
|
|
container.innerHTML = '';
|
|
|
|
mockCollections.forEach(collection => {
|
|
const item = document.createElement('div');
|
|
item.className = 'collection-item';
|
|
item.innerHTML = `
|
|
<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();
|
|
}
|
|
}
|
|
|
|
// Initialize the bookmark manager when the page loads
|
|
let bookmarkManager;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
bookmarkManager = new BookmarkManager();
|
|
}); |