/** * @fileoverview Reading List (bookmarks) module. * * Manages the reading list view: loading, rendering, adding, editing, * and deleting bookmarks. Also handles migration from localStorage saved items. */ (function() { 'use strict'; const { escapeHtml, escapeAttr, getErrorMessage } = BB.utils; /** Currently selected bookmark tag filter. */ let currentTag = null; /** * Load bookmarks from the backend and render them. * @param {string} [tag] - Optional tag to filter by. */ async function load(tag) { currentTag = tag || null; try { const bookmarks = await BB.api.bookmarks.list(currentTag); renderBookmarks(bookmarks); } catch (err) { BB.ui.showToast('Failed to load reading list: ' + getErrorMessage(err), 'error'); } } /** * Render the bookmark list into the items panel. * @param {Array} bookmarks - Bookmark objects from the backend. */ function renderBookmarks(bookmarks) { const list = document.getElementById('items-list'); list.setAttribute('role', 'listbox'); list.setAttribute('aria-label', 'Reading list'); // Hide the "Load More" button since bookmarks aren't paginated document.getElementById('load-more').style.display = 'none'; if (bookmarks.length === 0) { const msg = currentTag ? 'No bookmarks with tag "' + escapeHtml(currentTag) + '".' : 'Your reading list is empty.
Bookmark articles from the detail panel, or add a URL.'; list.innerHTML = '
  • 🔖
    ' + msg + '
  • '; renderTagBar([]); return; } // Render tag filter bar BB.api.bookmarks.listTags().then(renderTagBar).catch(() => {}); const frag = document.createDocumentFragment(); for (const bm of bookmarks) { const li = document.createElement('li'); li.className = 'item bookmark-item' + (bm.isPinned ? ' pinned' : ''); li.dataset.id = bm.id; li.setAttribute('role', 'option'); li.setAttribute('tabindex', '0'); const tags = bm.tags.length > 0 ? '
    ' + bm.tags.map(t => '' + escapeHtml(t) + '' ).join('') + '
    ' : ''; li.innerHTML = `
    ${bm.author ? '' + escapeHtml(bm.author) + '' : ''} ${escapeHtml(bm.timeAgo)}
    ${bm.sourceName ? '' + escapeHtml(bm.sourceName) + '' : ''}
    ${escapeHtml(bm.title || bm.url)}
    ${tags}
    `; li.onclick = () => openUrl(bm.url); li.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openUrl(bm.url); } }; frag.appendChild(li); } list.innerHTML = ''; list.appendChild(frag); } /** * Render the tag filter bar above the bookmark list. * @param {Array} tags - All distinct bookmark tags. */ function renderTagBar(tags) { let bar = document.getElementById('bookmark-tag-bar'); if (!bar) { bar = document.createElement('div'); bar.id = 'bookmark-tag-bar'; bar.className = 'bookmark-tag-bar'; const itemsPanel = document.querySelector('.items-panel'); const itemsList = document.getElementById('items-list'); itemsPanel.insertBefore(bar, itemsList); } if (tags.length === 0 && !currentTag) { bar.style.display = 'none'; return; } bar.style.display = 'flex'; bar.innerHTML = ''; // "All" button const allBtn = document.createElement('button'); allBtn.className = 'btn btn-small tag-filter-btn' + (!currentTag ? ' active' : ''); allBtn.textContent = 'All'; allBtn.onclick = () => load(null); bar.appendChild(allBtn); for (const tag of tags) { const btn = document.createElement('button'); btn.className = 'btn btn-small tag-filter-btn' + (currentTag === tag ? ' active' : ''); btn.textContent = tag; btn.onclick = () => load(tag); bar.appendChild(btn); } // "Add Bookmark" button const addBtn = document.createElement('button'); addBtn.className = 'btn btn-small btn-primary'; addBtn.textContent = '+ Add'; addBtn.onclick = () => openAddModal(); bar.appendChild(addBtn); } /** * Open the system browser for a URL. * @param {string} url - URL to open. */ function openUrl(url) { if (url) { window.__TAURI__.shell.open(url).catch(() => { BB.ui.showToast('Failed to open URL', 'error'); }); } } /** * Show a context menu with actions for a bookmark. * F5 (2026-06-02): delegated to BB.ui.showContextMenu — the canonical * helper was factored from this file's original pattern. * @param {Event} event - Click event for positioning. * @param {string} id - Bookmark ID. */ function showContextMenu(event, id) { BB.ui.showContextMenu(event, [ { label: 'Edit', fn: () => openEditModal(id) }, { label: 'Set Tags', fn: () => openTagsModal(id) }, { label: 'Export as HTML', fn: () => exportHtml(id) }, { label: 'Delete', fn: () => deleteBookmark(id), danger: true }, ]); } /** * Open a modal to add a new bookmark by URL. * @param {Object} [prefill] - Optional prefill values (url, title). */ function openAddModal(prefill) { prefill = prefill || {}; const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Add Bookmark'; body.innerHTML = ''; const form = document.createElement('div'); form.className = 'settings-content'; form.innerHTML = `
    `; body.appendChild(form); BB.ui.openModal(); document.getElementById('bm-cancel').onclick = BB.ui.closeModal; document.getElementById('bm-save').onclick = async () => { const url = document.getElementById('bm-url').value.trim(); const bmTitle = document.getElementById('bm-title').value.trim(); const tags = document.getElementById('bm-tags').value.split(',').map(t => t.trim()).filter(Boolean); const notes = document.getElementById('bm-notes').value.trim(); if (!url) { BB.ui.showToast('URL is required', 'error'); return; } try { await BB.api.bookmarks.create({ url: url, title: bmTitle || url, tags: tags, notes: notes, }); BB.ui.closeModal(); BB.ui.showToast('Bookmark added'); updateBadge(); if (BB.state.currentSource === '__saved__') load(currentTag); } catch (err) { BB.ui.showToast('Failed to add bookmark: ' + getErrorMessage(err), 'error'); } }; document.getElementById('bm-url').focus(); } /** * Open a modal to edit a bookmark. * @param {string} id - Bookmark ID. */ async function openEditModal(id) { try { const bookmarks = await BB.api.bookmarks.list(); const bm = bookmarks.find(b => b.id === id); if (!bm) { BB.ui.showToast('Bookmark not found', 'error'); return; } const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Edit Bookmark'; body.innerHTML = ''; const form = document.createElement('div'); form.className = 'settings-content'; form.innerHTML = `
    `; body.appendChild(form); BB.ui.openModal(); document.getElementById('bm-edit-cancel').onclick = BB.ui.closeModal; document.getElementById('bm-edit-save').onclick = async () => { try { await BB.api.bookmarks.update(id, { title: document.getElementById('bm-edit-title').value.trim() || null, description: document.getElementById('bm-edit-desc').value.trim() || null, notes: document.getElementById('bm-edit-notes').value.trim() || null, isPinned: document.getElementById('bm-edit-pinned').checked, }); BB.ui.closeModal(); BB.ui.showToast('Bookmark updated'); if (BB.state.currentSource === '__saved__') load(currentTag); } catch (err) { BB.ui.showToast('Failed to update: ' + getErrorMessage(err), 'error'); } }; } catch (err) { BB.ui.showToast('Failed to load bookmark: ' + getErrorMessage(err), 'error'); } } /** * Open a modal to set tags on a bookmark. * @param {string} id - Bookmark ID. */ async function openTagsModal(id) { try { const bookmarks = await BB.api.bookmarks.list(); const bm = bookmarks.find(b => b.id === id); if (!bm) { BB.ui.showToast('Bookmark not found', 'error'); return; } const body = document.getElementById('modal-body'); const title = document.getElementById('modal-title'); title.textContent = 'Set Tags'; body.innerHTML = ''; const form = document.createElement('div'); form.className = 'settings-content'; form.innerHTML = `
    `; body.appendChild(form); BB.ui.openModal(); document.getElementById('bm-tags-cancel').onclick = BB.ui.closeModal; document.getElementById('bm-tags-save').onclick = async () => { const tags = document.getElementById('bm-tags-input').value .split(',').map(t => t.trim()).filter(Boolean); try { await BB.api.bookmarks.setTags(id, tags); BB.ui.closeModal(); BB.ui.showToast('Tags updated'); if (BB.state.currentSource === '__saved__') load(currentTag); } catch (err) { BB.ui.showToast('Failed to update tags: ' + getErrorMessage(err), 'error'); } }; } catch (err) { BB.ui.showToast('Failed to load bookmark: ' + getErrorMessage(err), 'error'); } } /** * Toggle the pinned state of a bookmark. * @param {string} id - Bookmark ID. * @param {boolean} isPinned - Current pinned state. */ async function togglePin(id, isPinned) { try { await BB.api.bookmarks.update(id, { isPinned: !isPinned }); BB.ui.showToast(isPinned ? 'Unpinned' : 'Pinned'); if (BB.state.currentSource === '__saved__') load(currentTag); } catch (err) { BB.ui.showToast('Failed to update pin: ' + getErrorMessage(err), 'error'); } } /** * Delete a bookmark with confirmation. * @param {string} id - Bookmark ID. */ async function deleteBookmark(id) { const ok = await BB.ui.confirmAction('Remove this bookmark?'); if (!ok) return; try { await BB.api.bookmarks.delete(id); BB.ui.showToast('Bookmark removed'); updateBadge(); if (BB.state.currentSource === '__saved__') load(currentTag); } catch (err) { BB.ui.showToast('Failed to delete: ' + getErrorMessage(err), 'error'); } } /** * Export a bookmark as HTML and trigger a download. * @param {string} id - Bookmark ID. */ async function exportHtml(id) { try { const html = await BB.api.bookmarks.exportHtml(id); const blob = new Blob([html], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'bookmark.html'; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); BB.ui.showToast('HTML exported'); } catch (err) { BB.ui.showToast('Export failed: ' + getErrorMessage(err), 'error'); } } /** * Bookmark the currently displayed feed item from the detail panel. */ async function bookmarkCurrentItem() { const item = BB.detail.getCurrentItem(); if (!item) return; try { // Check if already bookmarked if (item.url) { const already = await BB.api.bookmarks.isBookmarked(item.url); if (already) { BB.ui.showToast('Already bookmarked'); return; } } await BB.api.bookmarks.createFromItem(item.id); BB.ui.showToast('Added to Reading List'); updateBadge(); } catch (err) { BB.ui.showToast('Failed to bookmark: ' + getErrorMessage(err), 'error'); } } /** Update the sidebar reading list badge count. */ async function updateBadge() { try { const count = await BB.api.bookmarks.count(); const el = document.getElementById('saved-count'); if (el) el.textContent = count; } catch { // Non-critical } } /** * One-time migration from localStorage saved items to database bookmarks. * Runs on app startup, silently skips items that no longer exist. */ async function migrateFromLocalStorage() { const SAVED_KEY = 'bb-saved-items'; let savedIds; try { savedIds = JSON.parse(localStorage.getItem(SAVED_KEY)); } catch { return; } if (!savedIds || !Array.isArray(savedIds) || savedIds.length === 0) return; let migrated = 0; for (const id of savedIds) { try { await BB.api.bookmarks.createFromItem(id); migrated++; } catch { // Item may no longer exist or already bookmarked — skip } } localStorage.removeItem(SAVED_KEY); if (migrated > 0) { BB.ui.showToast('Migrated ' + migrated + ' saved article' + (migrated === 1 ? '' : 's') + ' to Reading List'); updateBadge(); } } BB.bookmarks = { load, openAddModal, openEditModal, openTagsModal, togglePin, deleteBookmark, exportHtml, bookmarkCurrentItem, updateBadge, migrateFromLocalStorage, openUrl, showContextMenu, }; })();