/* ============================================ IVY'S RSS HUB — Main Application Orchestrates the RSS feed aggregator ============================================ */ /** * Shared Utilities — Used by both App and Sidebar * Avoids code duplication across modules */ window.IvyUtils = { /** * Escape HTML to prevent XSS */ escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; }, /** * Escape text for use in HTML attributes */ escapeAttr(text) { if (!text) return ""; return text .replace(/&/g, "&") .replace(/"/g, """) .replace(/'/g, "'") .replace(//g, ">"); }, /** * Truncate text with ellipsis */ truncate(text, maxLength) { if (!text || text.length <= maxLength) return text; return text.substring(0, maxLength) + "..."; } }; /** * RSSHub Application */ class RSSHubApp { constructor() { // Initialize parser this.parser = new RSSParser(); // State this.feeds = []; this.feedResults = []; this.currentCategory = "all"; this.currentLang = "all"; // Language filter: "all", "en", "fr" this.settings = this.loadSettings(); this.isRefreshing = false; // Prevent double-refresh race conditions this.autoRefreshTimer = null; // Auto-refresh interval timer this.nextRefreshTime = null; // Timestamp of next auto-refresh this.countdownTimer = null; // Countdown display timer // DOM Elements this.elements = { feedsContainer: document.getElementById("feeds-container"), statusText: document.getElementById("status-text"), statusTime: document.getElementById("status-time"), btnRefresh: document.getElementById("btn-refresh"), btnTheme: document.getElementById("btn-theme"), btnSettings: document.getElementById("btn-settings"), btnAbout: document.getElementById("btn-about"), modalAbout: document.getElementById("modal-about"), modalSettings: document.getElementById("modal-settings"), modalCloseAbout: document.getElementById("modal-close-about"), modalCloseSettings: document.getElementById("modal-close-settings"), sourcesList: document.getElementById("sources-list"), navButtons: document.querySelectorAll(".nav-btn"), langButtons: document.querySelectorAll(".lang-btn"), sidebar: document.getElementById("sidebar") }; // Initialize theme this.initTheme(); // Initialize this.init(); } /** * Initialize theme from localStorage or system preference */ initTheme() { const savedTheme = localStorage.getItem("ivy-rss-hub-theme"); if (savedTheme) { // User has a saved preference document.documentElement.setAttribute("data-theme", savedTheme); this.updateThemeIcon(savedTheme); } else { // Use system preference (CSS handles it, but we update the icon) const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; this.updateThemeIcon(prefersDark ? "dark" : "light"); } // Listen for system theme changes window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => { if (!localStorage.getItem("ivy-rss-hub-theme")) { this.updateThemeIcon(e.matches ? "dark" : "light"); } }); } /** * Toggle between light and dark theme */ toggleTheme() { const currentTheme = document.documentElement.getAttribute("data-theme"); const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; // Determine current effective theme let effectiveTheme; if (currentTheme) { effectiveTheme = currentTheme; } else { effectiveTheme = prefersDark ? "dark" : "light"; } // Toggle const newTheme = effectiveTheme === "dark" ? "light" : "dark"; document.documentElement.setAttribute("data-theme", newTheme); localStorage.setItem("ivy-rss-hub-theme", newTheme); this.updateThemeIcon(newTheme); this.showToast(`${newTheme === "dark" ? "🌙 Dark" : "☀️ Light"} theme activated`, "info"); } /** * Update theme toggle button icon */ updateThemeIcon(theme) { if (this.elements.btnTheme) { this.elements.btnTheme.textContent = theme === "dark" ? "🌙" : "☀️"; this.elements.btnTheme.title = `Switch to ${theme === "dark" ? "light" : "dark"} theme`; } } /** * Initialize the application */ init() { // Load feeds from settings or defaults this.feeds = this.settings.feeds || [...window.FeedsConfig.DEFAULT_FEEDS]; // Save if migration occurred if (this._needsMigrationSave) { console.log("[Migration] Saving migrated feed configuration"); this.saveSettings(); this._needsMigrationSave = false; } // Setup event listeners this.setupEventListeners(); // Setup scroll propagation for nested scrollable containers this.setupScrollPropagation(); // Render settings this.renderSourcesList(); // Initialize sidebar this.sidebar = new SidebarManager(this); window.sidebar = this.sidebar; // Expose for event handlers // Initialize settings UI from saved values this.initSettingsUI(); // Initial fetch this.refreshFeeds(); // Start auto-refresh if enabled if (this.settings.autoRefresh) { this.startAutoRefresh(); } } /** * Initialize settings UI elements from saved settings */ initSettingsUI() { const groupBySourceCheckbox = document.getElementById("opt-group-by-source"); const showDescriptionsCheckbox = document.getElementById("opt-show-descriptions"); const maxItemsInput = document.getElementById("opt-max-items"); const autoRefreshCheckbox = document.getElementById("opt-auto-refresh"); const refreshIntervalSelect = document.getElementById("opt-refresh-interval"); if (groupBySourceCheckbox) { groupBySourceCheckbox.checked = this.settings.groupBySource !== false; } if (showDescriptionsCheckbox) { showDescriptionsCheckbox.checked = this.settings.showDescriptions !== false; } if (maxItemsInput) { maxItemsInput.value = this.settings.maxItems || 10; } if (autoRefreshCheckbox) { autoRefreshCheckbox.checked = this.settings.autoRefresh === true; } if (refreshIntervalSelect) { refreshIntervalSelect.value = this.settings.refreshInterval || 10; } this.updateAutoRefreshStatus(); } /** * Setup event listeners */ setupEventListeners() { // Refresh button - normal click uses cache, shift+click forces refresh this.elements.btnRefresh.addEventListener("click", async e => { // Prevent double-click while already loading if (this.isRefreshing) { return; } if (e.shiftKey) { // Force refresh - clear all cache including IndexedDB await this.parser.clearAllCache(); this.showToast("Cache cleared! Refreshing...", "info"); console.log("🔄 Force refresh - cache cleared"); } // Normal refresh uses cache if available this.refreshFeeds(); }); // Add tooltip hint this.elements.btnRefresh.title = "Refresh feeds (Shift+Click to force refresh)"; // Theme toggle this.elements.btnTheme?.addEventListener("click", () => { this.toggleTheme(); }); // About modal this.elements.btnAbout.addEventListener("click", () => { this.elements.modalAbout.classList.add("active"); // Focus the close button for accessibility setTimeout(() => this.elements.modalCloseAbout.focus(), 100); }); this.elements.modalCloseAbout.addEventListener("click", () => { this.elements.modalAbout.classList.remove("active"); }); // Settings modal this.elements.btnSettings.addEventListener("click", () => { this.renderSourcesList(); this.elements.modalSettings.classList.add("active"); // Focus the close button for accessibility setTimeout(() => this.elements.modalCloseSettings.focus(), 100); }); this.elements.modalCloseSettings.addEventListener("click", () => { this.elements.modalSettings.classList.remove("active"); }); // Close modals on overlay click [this.elements.modalAbout, this.elements.modalSettings].forEach(modal => { modal.addEventListener("click", e => { if (e.target === modal) { modal.classList.remove("active"); } }); }); // Focus trap for modals (accessibility) document.addEventListener("keydown", e => { if (e.key !== "Tab") return; // Check if a modal is open const activeModal = document.querySelector(".modal-overlay.active .modal"); if (!activeModal) return; // Get all focusable elements in the modal const focusableElements = activeModal.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1]; // Trap focus within modal if (e.shiftKey && document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } else if (!e.shiftKey && document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } }); // Keyboard shortcuts document.addEventListener("keydown", e => { // Don't trigger shortcuts when typing in inputs if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") { // Escape to blur inputs if (e.key === "Escape") e.target.blur(); return; } // Escape to close modals and sidebar if (e.key === "Escape") { this.elements.modalAbout.classList.remove("active"); this.elements.modalSettings.classList.remove("active"); // Also close mobile sidebar this.elements.sidebar?.classList.remove("open"); } // R to refresh (Shift+R for force refresh) if (e.key === "r" || e.key === "R") { e.preventDefault(); if (e.shiftKey) { this.parser.clearAllCache().then(() => { this.showToast("Cache cleared! Refreshing...", "info"); this.refreshFeeds(); }); } else { this.refreshFeeds(); } } // / to focus search if (e.key === "/") { e.preventDefault(); const searchInput = document.getElementById("search-input"); if (searchInput) { searchInput.focus(); // Open sidebar on mobile if (window.innerWidth <= 1100) { this.elements.sidebar?.classList.add("open"); } } } // S to toggle sidebar on desktop if (e.key === "s" && !e.ctrlKey && !e.metaKey) { const sidebar = document.getElementById("sidebar"); if (sidebar) sidebar.classList.toggle("hidden-desktop"); } // T to toggle theme if (e.key === "t" && !e.ctrlKey && !e.metaKey) { this.toggleTheme(); } // Home or ArrowUp (when not in input) to scroll to top if (e.key === "Home" || (e.key === "ArrowUp" && e.ctrlKey)) { e.preventDefault(); this.scrollToTop(); } }); // Back to top button this.setupBackToTop(); // Category navigation this.elements.navButtons.forEach(btn => { btn.addEventListener("click", () => { this.setCategory(btn.dataset.category); }); }); // Add custom feed (use AbortController pattern to prevent duplicate listeners) const addFeedBtn = document.getElementById("btn-add-feed"); if (addFeedBtn && !addFeedBtn.dataset.listenerAttached) { addFeedBtn.addEventListener("click", () => this.addCustomFeed()); addFeedBtn.dataset.listenerAttached = "true"; } // Settings options document.getElementById("opt-group-by-source")?.addEventListener("change", e => { this.settings.groupBySource = e.target.checked; this.saveSettings(); this.renderFeeds(); }); document.getElementById("opt-show-descriptions")?.addEventListener("change", e => { this.settings.showDescriptions = e.target.checked; this.saveSettings(); this.renderFeeds(); }); document.getElementById("opt-max-items")?.addEventListener("change", e => { this.settings.maxItems = parseInt(e.target.value) || 20; this.saveSettings(); this.renderFeeds(); }); // Auto-refresh settings document.getElementById("opt-auto-refresh")?.addEventListener("change", e => { this.settings.autoRefresh = e.target.checked; this.saveSettings(); if (e.target.checked) { this.startAutoRefresh(); this.showToast("Auto-refresh enabled! ⏰", "success"); } else { this.stopAutoRefresh(); this.showToast("Auto-refresh disabled", "info"); } }); document.getElementById("opt-refresh-interval")?.addEventListener("change", e => { this.settings.refreshInterval = parseInt(e.target.value) || 10; this.saveSettings(); // Restart timer with new interval if auto-refresh is enabled if (this.settings.autoRefresh) { this.startAutoRefresh(); this.showToast(`Refresh interval set to ${e.target.value} minutes`, "success"); } }); // Enable/Disable all feeds buttons document.getElementById("btn-enable-all")?.addEventListener("click", () => { this.feeds.forEach(f => (f.enabled = true)); this.saveSettings(); this.renderSourcesList(); this.showToast("All feeds enabled!", "success"); }); document.getElementById("btn-disable-all")?.addEventListener("click", () => { this.feeds.forEach(f => (f.enabled = false)); this.saveSettings(); this.renderSourcesList(); this.showToast("All feeds disabled", "info"); }); // Language filter buttons this.elements.langButtons.forEach(btn => { btn.addEventListener("click", () => { this.setLanguage(btn.dataset.lang); }); }); // Reset all data button document.getElementById("btn-reset-all")?.addEventListener("click", () => { this.resetAllData(); }); } /** * Set active category filter */ setCategory(category) { this.currentCategory = category; // Update nav buttons this.elements.navButtons.forEach(btn => { btn.classList.toggle("active", btn.dataset.category === category); }); // Re-render feeds this.renderFeeds(); } /** * Setup scroll propagation for nested scrollable containers * Fixes the issue where scroll gets "trapped" inside .source-articles * when reaching the top or bottom of the container */ setupScrollPropagation() { // Use event delegation on the feeds container this.elements.feedsContainer.addEventListener( "wheel", e => { const target = e.target.closest(".source-articles"); if (!target) return; const { scrollTop, scrollHeight, clientHeight } = target; const isAtTop = scrollTop <= 0; const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1; // If scrolling up and at top, or scrolling down and at bottom, // let the event propagate to the parent if ((e.deltaY < 0 && isAtTop) || (e.deltaY > 0 && isAtBottom)) { // Don't prevent default - let parent scroll return; } // Otherwise, prevent the event from reaching the parent // This keeps scroll inside the container when not at bounds if (scrollHeight > clientHeight) { e.stopPropagation(); } }, { passive: true } ); } /** * Setup Back to Top button behavior */ setupBackToTop() { const backToTopBtn = document.getElementById("back-to-top"); if (!backToTopBtn) return; // Show/hide button based on scroll position let ticking = false; window.addEventListener( "scroll", () => { if (!ticking) { requestAnimationFrame(() => { const scrollY = window.scrollY || document.documentElement.scrollTop; // Show button after scrolling 400px if (scrollY > 400) { backToTopBtn.classList.add("visible"); } else { backToTopBtn.classList.remove("visible"); } ticking = false; }); ticking = true; } }, { passive: true } ); // Click handler backToTopBtn.addEventListener("click", () => { this.scrollToTop(); }); } /** * Smooth scroll to top of page */ scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); } /* ============================================ AUTO-REFRESH SYSTEM ============================================ */ /** * Start auto-refresh timer */ startAutoRefresh() { // Clear any existing timer this.stopAutoRefresh(); const intervalMinutes = this.settings.refreshInterval || 10; const intervalMs = intervalMinutes * 60 * 1000; // Set next refresh time this.nextRefreshTime = Date.now() + intervalMs; // Start main refresh timer this.autoRefreshTimer = setInterval(() => { console.log("[RSS] Auto-refresh triggered"); this.refreshFeeds(); // Reset next refresh time this.nextRefreshTime = Date.now() + intervalMs; }, intervalMs); // Start countdown display timer (update every 10 seconds) this.countdownTimer = setInterval(() => { this.updateAutoRefreshStatus(); }, 10000); // Update status immediately this.updateAutoRefreshStatus(); console.log(`[RSS] Auto-refresh started: every ${intervalMinutes} minutes`); } /** * Stop auto-refresh timer */ stopAutoRefresh() { if (this.autoRefreshTimer) { clearInterval(this.autoRefreshTimer); this.autoRefreshTimer = null; } if (this.countdownTimer) { clearInterval(this.countdownTimer); this.countdownTimer = null; } this.nextRefreshTime = null; this.updateAutoRefreshStatus(); } /** * Update auto-refresh status display */ updateAutoRefreshStatus() { const statusEl = document.getElementById("auto-refresh-status"); if (!statusEl) return; if (!this.settings.autoRefresh || !this.nextRefreshTime) { statusEl.textContent = "Auto-refresh is disabled"; statusEl.style.color = ""; return; } const remaining = this.nextRefreshTime - Date.now(); if (remaining <= 0) { statusEl.textContent = "Refreshing now..."; statusEl.style.color = "var(--ivy-green)"; return; } const minutes = Math.floor(remaining / 60000); const seconds = Math.floor((remaining % 60000) / 1000); if (minutes > 0) { statusEl.textContent = `⏰ Next refresh in ${minutes}m ${seconds}s`; } else { statusEl.textContent = `⏰ Next refresh in ${seconds}s`; } statusEl.style.color = "var(--ivy-green)"; } /** * Set active language filter */ setLanguage(lang) { this.currentLang = lang; // Update lang buttons this.elements.langButtons.forEach(btn => { btn.classList.toggle("active", btn.dataset.lang === lang); }); // Re-render feeds this.renderFeeds(); } /** * Refresh all feeds */ async refreshFeeds() { // Prevent concurrent refresh calls if (this.isRefreshing) { console.debug("[RSS] Refresh already in progress, skipping"); return; } this.isRefreshing = true; try { // Show loading state this.elements.btnRefresh.classList.add("spinning"); this.elements.btnRefresh.setAttribute("aria-busy", "true"); this.setStatus("Loading feeds...", ""); this.renderLoading(); // Get enabled feeds const enabledFeeds = this.feeds.filter(f => f.enabled); if (enabledFeeds.length === 0) { this.setStatus("No feeds enabled", ""); this.renderEmpty("No feeds enabled. Go to Settings to enable some feeds."); return; } // Use progressive loading with callback this.feedResults = []; await this.parser.fetchAllFeedsProgressive(enabledFeeds, (results, loaded, total) => { // Update results this.feedResults = results; // Calculate stats const successful = results.filter(r => r.status === "success").length; const fromCache = results.filter(r => r.fromCache).length; const maxItems = this.settings.maxItems || 10; // Count DISPLAYED articles (limited by maxItems per source) const displayedArticles = results .filter(r => r.feed) .reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0); // Update status with progress const cacheInfo = fromCache > 0 ? ` (${fromCache} cached)` : ""; this.setStatus( `${successful}/${loaded} sources loaded${cacheInfo} • ~${displayedArticles} articles`, loaded < total ? `Loading ${loaded}/${total}...` : `Last updated: ${this.formatTime(new Date())}` ); // Render feeds progressively this.renderFeeds(); }); // Final update const successful = this.feedResults.filter(r => r.status === "success").length; const total = this.feedResults.length; const fromCache = this.feedResults.filter(r => r.fromCache).length; const maxItems = this.settings.maxItems || 10; const displayedArticles = this.feedResults .filter(r => r.feed) .reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0); const cacheInfo = fromCache > 0 ? ` (${fromCache} ⚡)` : ""; this.setStatus( `${successful}/${total} sources${cacheInfo} • ${displayedArticles} articles`, `Last updated: ${this.formatTime(new Date())}` ); // Render feeds this.renderFeeds(); // Update sidebar with article data if (this.sidebar) { this.sidebar.updateWithArticles(this.feedResults); } } catch (error) { console.error("[RSS] Error during refresh:", error); this.setStatus("Error loading feeds", ""); this.showToast("Failed to load some feeds. Please try again.", "error"); } finally { // Always cleanup - stop spinner and reset flag this.elements.btnRefresh.classList.remove("spinning"); this.elements.btnRefresh.setAttribute("aria-busy", "false"); this.isRefreshing = false; } } /** * Render loading state */ renderLoading() { const enabledCount = this.feeds.filter(f => f.enabled).length; this.elements.feedsContainer.innerHTML = `

Fetching ${enabledCount} feeds...

First load may take a moment. Shift+R to force refresh.

`; } /** * Render empty state */ renderEmpty(message) { this.elements.feedsContainer.innerHTML = `
📭

${message}

Try changing filters or enabling more sources in Settings.

`; } /** * Render feeds to DOM */ renderFeeds() { // Filter by category let filteredResults = this.feedResults; if (this.currentCategory !== "all") { filteredResults = this.feedResults.filter(r => r.category === this.currentCategory); } // Filter by language if (this.currentLang !== "all") { filteredResults = filteredResults.filter(r => r.lang === this.currentLang); } // Filter to only successful feeds const successfulFeeds = filteredResults.filter(r => r.status === "success" && r.feed); if (successfulFeeds.length === 0) { const langLabel = this.currentLang === "en" ? "English" : this.currentLang === "fr" ? "French" : ""; this.renderEmpty(`No articles found for this filter.${langLabel ? ` (${langLabel} only)` : ""}`); return; } // Group by source (default: true) if (this.settings.groupBySource !== false) { this.renderGroupedBySource(successfulFeeds); } else { this.renderMergedList(successfulFeeds); } // Show failed feeds if any (collapsed by default) const failedFeeds = filteredResults.filter(r => r.status === "error"); if (failedFeeds.length > 0) { this.renderFailedFeeds(failedFeeds); } } /** * Render a section showing failed feeds */ renderFailedFeeds(failedFeeds) { const html = ` `; this.elements.feedsContainer.insertAdjacentHTML("beforeend", html); } /** * Render feeds grouped by source (optimized with innerHTML batching) */ renderGroupedBySource(feedResults) { const maxItems = this.settings.maxItems || 10; // Build all HTML at once for better performance (single DOM reflow) const htmlParts = feedResults.map((result, index) => { const items = result.feed.items.slice(0, maxItems); const categoryInfo = window.FeedsConfig.CATEGORIES[result.category] || {}; const isFavorite = this.sidebar?.isSourceFavorite(result.id); const favoriteClass = isFavorite ? "saved" : ""; const colorIndex = this.getSourceColorIndex(result.id); // Determine status class based on result const statusClass = result.fromCache ? "cached" : "fresh"; const statusIcon = result.fromCache ? "⚡" : ""; return `
${result.name}
${items.length} ${statusIcon} ${this.formatTimeAgo(result.lastFetched)}
`; }); // Single DOM update for better performance this.elements.feedsContainer.innerHTML = htmlParts.join(""); } /** * Toggle favorite state for a source */ toggleSourceFavorite(sourceId, sourceName, sourceIcon) { if (!this.sidebar) return; const wasAdded = this.sidebar.toggleFavoriteSource(sourceId, sourceName, sourceIcon); this.showToast( wasAdded ? `${sourceName} added to favorites! ⭐` : `${sourceName} removed from favorites`, wasAdded ? "success" : "info" ); this.renderFeeds(); } /** * Render all articles in a merged list sorted by date */ renderMergedList(feedResults) { const maxItems = this.settings.maxItems || 10; // Collect all articles with source info const allArticles = feedResults.flatMap(result => result.feed.items.map(item => ({ ...item, sourceName: result.name, sourceIcon: result.icon, sourceId: result.id })) ); // Sort by date (newest first) allArticles.sort((a, b) => { const dateA = a.pubDate || new Date(0); const dateB = b.pubDate || new Date(0); return dateB - dateA; }); // Limit total const limitedArticles = allArticles.slice(0, maxItems * feedResults.length); const html = `
📰 All Articles
${limitedArticles.length}
`; this.elements.feedsContainer.innerHTML = html; } /** * Render a single article item */ renderArticleItem(item, showSource = false) { const dateStr = item.pubDate ? this.formatTimeAgo(item.pubDate) : ""; const fullDate = item.pubDate ? this.formatFullDate(item.pubDate) : ""; const sourceStr = showSource && item.sourceName ? `${item.sourceIcon} ${item.sourceName}` : ""; // Check if article is recent (less than 2 hours old) const isRecent = item.pubDate && Date.now() - item.pubDate.getTime() < 2 * 60 * 60 * 1000; const recentClass = isRecent ? "recent" : ""; // Show description preview if available and enabled in settings const showDescriptions = this.settings.showDescriptions !== false; const descriptionStr = showDescriptions && item.description ? `
${this.escapeHtml(item.description.substring(0, 120))}${item.description.length > 120 ? "..." : ""}
` : ""; const isBookmarked = this.sidebar?.isBookmarked(item.link); const bookmarkClass = isBookmarked ? "saved" : ""; // Use data attributes to avoid XSS and JSON parsing issues in onclick const safeTitle = this.escapeAttr(item.title); const safeLink = this.escapeAttr(item.link); const safeSource = this.escapeAttr(item.sourceName || "Unknown"); return `
${isRecent ? 'NEW' : ""}${this.escapeHtml(item.title)}
${descriptionStr}
`; } /** * Toggle bookmark for an article (from data attributes) */ toggleBookmarkFromElement(element) { if (!this.sidebar) return; const articleData = { title: element.dataset.title, link: element.dataset.link, sourceName: element.dataset.source }; if (this.sidebar.isBookmarked(articleData.link)) { this.sidebar.removeBookmark(articleData.link); this.showToast("Bookmark removed", "info"); } else { this.sidebar.addBookmark(articleData); this.showToast("Bookmark added! ⭐", "success"); } // Re-render to update bookmark states this.renderFeeds(); } /** * Show toast notification */ showToast(message, type = "info") { // Remove existing toast if any const existing = document.querySelector(".toast-notification"); if (existing) existing.remove(); const toast = document.createElement("div"); toast.className = `toast-notification toast-${type}`; toast.setAttribute("role", "status"); toast.setAttribute("aria-live", "polite"); toast.innerHTML = `${message}`; document.body.appendChild(toast); // Trigger animation requestAnimationFrame(() => toast.classList.add("show")); // Auto-dismiss setTimeout(() => { toast.classList.remove("show"); setTimeout(() => toast.remove(), 300); }, 2500); } /** * Toggle source section collapse */ toggleSource(sourceId) { const section = document.querySelector(`.source-section[data-source="${sourceId}"]`); if (section) { section.classList.toggle("collapsed"); // Update aria-expanded const header = section.querySelector(".source-header"); if (header) { const isCollapsed = section.classList.contains("collapsed"); header.setAttribute("aria-expanded", !isCollapsed); } } } /** * Render sources list in settings */ renderSourcesList() { const html = this.feeds .map(feed => { const categoryInfo = window.FeedsConfig.CATEGORIES[feed.category] || {}; const langFlag = feed.lang === "fr" ? "🇫🇷" : feed.lang === "en" ? "🇬🇧" : "🌍"; const deleteBtn = feed.custom ? `` : ""; return `
${feed.icon} ${this.escapeHtml(feed.name)} ${feed.custom ? "Custom" : ""}
${this.escapeHtml(feed.url)}
${langFlag} ${categoryInfo.icon || ""} ${categoryInfo.name || feed.category} ${deleteBtn}
`; }) .join(""); this.elements.sourcesList.innerHTML = html; } /** * Delete a custom feed */ deleteCustomFeed(feedId) { const feed = this.feeds.find(f => f.id === feedId); if (!feed || !feed.custom) { this.showToast("Cannot delete built-in feeds", "error"); return; } if (!confirm(`Delete "${feed.name}"?`)) return; this.feeds = this.feeds.filter(f => f.id !== feedId); this.saveSettings(); this.renderSourcesList(); this.showToast(`"${feed.name}" deleted`, "info"); this.refreshFeeds(); } /** * Toggle feed enabled state */ toggleFeedEnabled(feedId, enabled) { const feed = this.feeds.find(f => f.id === feedId); if (feed) { feed.enabled = enabled; this.saveSettings(); this.showToast(`${feed.name} ${enabled ? "enabled" : "disabled"}`, enabled ? "success" : "info"); } } /** * Add custom feed with validation */ async addCustomFeed() { const nameInput = document.getElementById("custom-feed-name"); const urlInput = document.getElementById("custom-feed-url"); const categorySelect = document.getElementById("custom-feed-category"); const addBtn = document.getElementById("btn-add-feed"); const name = nameInput.value.trim(); const url = urlInput.value.trim(); const category = categorySelect.value; if (!name || !url) { this.showToast("Please enter both name and URL", "error"); return; } // Validate URL format try { new URL(url); } catch { this.showToast("Please enter a valid URL", "error"); return; } // Show loading state const originalText = addBtn.textContent; addBtn.textContent = "Testing..."; addBtn.disabled = true; // Test if the URL is actually a valid RSS feed try { const result = await this.parser.fetchFeed({ url, name, id: "test" }); if (result.status !== "success") { throw new Error(result.error || "Invalid RSS feed"); } } catch (e) { addBtn.textContent = originalText; addBtn.disabled = false; this.showToast(`Invalid feed: ${e.message}`, "error"); return; } // Restore button addBtn.textContent = originalText; addBtn.disabled = false; // Generate unique ID const id = "custom_" + Date.now(); // Detect language from category or default to 'en' const langSelect = document.getElementById("custom-feed-lang"); const lang = langSelect ? langSelect.value : "en"; // Add to feeds this.feeds.push({ id, name, url, icon: "📡", category, lang, enabled: true, custom: true }); // Save and refresh this.saveSettings(); this.renderSourcesList(); this.showToast(`Feed "${name}" added! 🎉`, "success"); // Clear inputs nameInput.value = ""; urlInput.value = ""; // Refresh feeds this.refreshFeeds(); } /** * Load settings from localStorage */ loadSettings() { try { const saved = localStorage.getItem("ivy-rss-hub-settings"); if (saved) { const parsed = JSON.parse(saved); // Ensure groupBySource defaults to true if not set if (parsed.groupBySource === undefined) { parsed.groupBySource = true; } // Ensure showDescriptions defaults to true if not set if (parsed.showDescriptions === undefined) { parsed.showDescriptions = true; } // Run migrations for existing users if (parsed.feeds) { parsed.feeds = this.migrateFeeds(parsed.feeds); } return parsed; } } catch (e) { console.warn("Failed to load settings:", e); } // Defaults return { groupBySource: true, showDescriptions: true, maxItems: 10, feeds: null // Will use defaults }; } /** * Migrate old feed configurations to new ones * This handles breaking changes like the arXiv RSS -> API migration */ migrateFeeds(feeds) { // Migration v1: Replace old arXiv RSS feeds with new API feed // Old feeds: arxiv-ai, arxiv-ml, arxiv-cl, arxiv-cv, arxiv-ne, arxiv-combined // New feed: arxiv-ai-api const oldArxivIds = ["arxiv-ai", "arxiv-ml", "arxiv-cl", "arxiv-cv", "arxiv-ne", "arxiv-combined"]; const newArxivId = "arxiv-ai-api"; const hasOldArxiv = feeds.some(f => oldArxivIds.includes(f.id)); const hasNewArxiv = feeds.some(f => f.id === newArxivId); if (hasOldArxiv && !hasNewArxiv) { console.log("[Migration] Replacing old arXiv RSS feeds with new API feed"); // Check if any old arXiv feed was enabled const wasEnabled = feeds.some(f => oldArxivIds.includes(f.id) && f.enabled); // Remove old arXiv feeds feeds = feeds.filter(f => !oldArxivIds.includes(f.id)); // Add new combined API feed at the beginning of AI category const newArxivFeed = { id: "arxiv-ai-api", name: "arXiv — AI & ML (Recent)", url: "https://export.arxiv.org/api/query?search_query=cat:cs.AI+OR+cat:cs.LG+OR+cat:cs.CL+OR+cat:cs.CV+OR+cat:cs.NE&max_results=100&sortBy=submittedDate&sortOrder=descending", icon: "🎓", category: "ai", lang: "en", enabled: wasEnabled, isAtom: true }; // Insert at the beginning feeds.unshift(newArxivFeed); // Mark that we need to save after init this._needsMigrationSave = true; } return feeds; } /** * Reset all data (localStorage + IndexedDB) */ async resetAllData() { if ( !confirm( "Are you sure you want to reset ALL data?\n\nThis will delete:\n• All settings\n• Custom feeds\n• Bookmarks\n• Cached data\n\nThis action cannot be undone." ) ) { return; } try { // Clear localStorage entries for this app localStorage.removeItem("ivy-rss-hub-settings"); localStorage.removeItem("ivy-rss-hub-theme"); localStorage.removeItem("ivy-rss-hub-bookmarks"); localStorage.removeItem("ivy-rss-hub-favorites"); localStorage.removeItem("ivy-rss-hub-sidebar"); // Clear IndexedDB (Dexie cache) if (this.parser && this.parser.db) { await this.parser.db.delete(); console.log("🗑️ IndexedDB cleared"); } this.showToast("All data cleared! Reloading...", "success"); // Reload page after a short delay setTimeout(() => { window.location.reload(); }, 1000); } catch (e) { console.error("Reset failed:", e); this.showToast("Reset failed: " + e.message, "error"); } } /** * Save settings to localStorage with quota handling */ saveSettings() { try { this.settings.feeds = this.feeds; const data = JSON.stringify(this.settings); // Check approximate size (rough estimate) const sizeKB = new Blob([data]).size / 1024; if (sizeKB > 4500) { // Approaching 5MB limit - warn user console.warn(`Settings size: ${sizeKB.toFixed(1)}KB - approaching limit`); this.showToast("Storage nearly full. Consider removing some feeds.", "error"); } localStorage.setItem("ivy-rss-hub-settings", data); } catch (e) { if (e.name === "QuotaExceededError") { this.showToast("Storage full! Please delete some feeds.", "error"); } console.warn("Failed to save settings:", e); } } /** * Set status bar text */ setStatus(text, time) { this.elements.statusText.textContent = text; this.elements.statusTime.textContent = time; } /** * Format time to readable string */ formatTime(date) { return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } /** * Format full date and time for tooltips */ formatFullDate(date) { if (!date) return ""; return date.toLocaleString("fr-FR", { weekday: "short", day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }); } /** * Format time ago string */ formatTimeAgo(date) { if (!date) return ""; const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); if (diffMins < 1) return "just now"; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } /** * Escape HTML to prevent XSS (delegates to shared utility) */ escapeHtml(text) { return window.IvyUtils.escapeHtml(text); } /** * Escape text for use in HTML attributes (delegates to shared utility) */ escapeAttr(text) { return window.IvyUtils.escapeAttr(text); } /** * Get a consistent color index (0-11) based on source ID * Uses simple hash for consistent colors per source */ getSourceColorIndex(sourceId) { let hash = 0; for (let i = 0; i < sourceId.length; i++) { hash = (hash << 5) - hash + sourceId.charCodeAt(i); hash = hash & hash; // Convert to 32-bit integer } return Math.abs(hash) % 12; // 12 color variations } } // Initialize app when DOM is ready let app; document.addEventListener("DOMContentLoaded", () => { app = new RSSHubApp(); window.app = app; // Expose for event handlers });