diff --git a/CHANGELOG.md b/CHANGELOG.md index f62ee01..50eebbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [0.3.0] - 2026-01-14 + +### Changed + +- Use Feedly API for fetching and unsaving entries (DOM as fallback) +- Always reload page after successful operation +- Save settings immediately on change (including real-time input) +- Remove reload checkbox option (now automatic) +- Use pagination for "all" mode to fetch entries beyond 100-item limit +- Parallel batch DELETE requests for improved performance + +### Removed + +- Optional reload setting (now always enabled) +- Unused `ct`/`cv` API parameters + +### Fixed + +- Token cache invalidation on authentication errors +- Consistent use of `normalizeCount()` for count validation +- Settings saved on input (not just on blur) + +## [0.2.0] - 2026-01-06 + +### Changed + +- Updated manifest version + ## [0.1.0] - 2025-01-02 ### Added diff --git a/README.md b/README.md index 66db4c5..6f76d43 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ Browser extension to open saved items from Feedly Read Later in background tabs - Runs only on the Feedly Read Later page - Open all saved items or open a specified number -- Optional reload after opening +- Auto-reload after opening to reflect changes - Settings stored in browser storage +- API-first approach with DOM fallback - No external libraries ## Requirements @@ -33,8 +34,8 @@ Browser extension to open saved items from Feedly Read Later in background tabs 1. Open the Feedly Read Later page 2. Click the extension icon 3. Choose "Open all saved items" or "Open only this many" -4. Optionally enable reload -5. Click "Open and Unsave" +4. Click "Open and unsave" +5. Tabs open in background, page reloads automatically ## Notes diff --git a/content.js b/content.js index b90556e..66c38c0 100644 --- a/content.js +++ b/content.js @@ -1,5 +1,10 @@ const api = typeof browser !== "undefined" ? browser : chrome; -const usesPromises = typeof browser !== "undefined"; + +// ============================================================================= +// API Constants +// ============================================================================= + +const FEEDLY_API_BASE = "https://api.feedly.com"; // ============================================================================= // Constants @@ -37,6 +42,242 @@ function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Normalize count value to a positive integer. + * @param {number|undefined} count - Count value from settings + * @returns {number} Normalized count (minimum 1) + */ +function normalizeCount(count) { + return Math.max(count || 1, 1); +} + +// ============================================================================= +// Feedly API Functions +// ============================================================================= + +// Token storage for communication between page and content script +let cachedAccessToken = null; + +/** + * Clear the cached access token. + * Called when authentication fails to allow re-fetching from localStorage. + */ +function clearAccessTokenCache() { + cachedAccessToken = null; +} + +/** + * Get access token from Feedly's localStorage. + * + * IMPLEMENTATION NOTE: + * Token is stored by Feedly web app in localStorage under "feedly.session" key. + * This is an undocumented implementation detail and may change in future Feedly updates. + * If token retrieval fails, the extension falls back to DOM-based operations. + * + * @returns {Promise} Access token or null if not available + */ +async function getAccessToken() { + // Return cached token if available + if (cachedAccessToken) { + return cachedAccessToken; + } + + try { + const sessionData = localStorage.getItem("feedly.session"); + if (sessionData) { + const session = JSON.parse(sessionData); + if (session.feedlyToken) { + cachedAccessToken = session.feedlyToken; + return cachedAccessToken; + } + } + } catch (e) { + console.warn("[Feedly Opener] Failed to get token from localStorage:", e); + } + + return null; +} + +/** + * Make authenticated API request to Feedly. + * @param {string} endpoint - API endpoint path + * @param {Object} options - Fetch options + * @returns {Promise} Response JSON + */ +async function feedlyApiRequest(endpoint, options = {}) { + const token = await getAccessToken(); + if (!token) { + throw new Error("No Feedly access token available"); + } + + const url = `${FEEDLY_API_BASE}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + "Authorization": `Bearer ${token}`, + "Content-Type": "application/json", + ...options.headers + } + }); + + if (!response.ok) { + // Clear token cache on authentication errors to allow retry with fresh token + if (response.status === 401 || response.status === 403) { + clearAccessTokenCache(); + } + const errorText = await response.text().catch(() => "Unknown error"); + throw new Error(`Feedly API error ${response.status}: ${errorText}`); + } + + // DELETE requests may return empty body + if (response.status === 204 || response.headers.get("content-length") === "0") { + return { success: true }; + } + + return response.json(); +} + +/** + * Get current user's profile to extract userId. + * @returns {Promise} User ID + */ +async function getUserId() { + const profile = await feedlyApiRequest("/v3/profile"); + if (!profile.id) { + throw new Error("User ID not found in profile response"); + } + return profile.id; +} + +/** + * Parse entry items from API response into normalized format. + * @param {Array} items - Raw items from API response + * @returns {Array} Array of entry objects with id and url + */ +function parseEntryItems(items) { + if (!items || !Array.isArray(items)) { + return []; + } + return items.map((item) => ({ + id: item.id, + url: item.alternate?.[0]?.href || item.canonicalUrl || item.originId || null, + title: item.title || "Untitled" + })).filter((item) => item.url); +} + +/** + * Fetch saved entries via Feedly API. + * @param {string} userId - User ID + * @param {number} count - Maximum number of entries to fetch + * @returns {Promise} Array of entry objects with id and url + */ +async function fetchSavedEntriesViaAPI(userId, count = 100) { + const streamId = encodeURIComponent(`user/${userId}/tag/global.saved`); + const response = await feedlyApiRequest( + `/v3/streams/contents?streamId=${streamId}&count=${count}&ranked=newest` + ); + return parseEntryItems(response.items); +} + +/** + * Fetch all saved entries via Feedly API using pagination. + * Uses continuation token to fetch entries beyond the 100-item limit. + * @param {string} userId - User ID + * @returns {Promise} Array of all entry objects with id and url + */ +async function fetchAllSavedEntriesViaAPI(userId) { + const allEntries = []; + let continuation = null; + const PAGE_SIZE = 100; + + do { + const streamId = encodeURIComponent(`user/${userId}/tag/global.saved`); + let url = `/v3/streams/contents?streamId=${streamId}&count=${PAGE_SIZE}&ranked=newest`; + if (continuation) { + url += `&continuation=${encodeURIComponent(continuation)}`; + } + + const response = await feedlyApiRequest(url); + const entries = parseEntryItems(response.items); + allEntries.push(...entries); + + continuation = response.continuation || null; + } while (continuation); + + return allEntries; +} + +/** + * Unsave entries via API (delete from Read Later). + * Uses parallel batch processing to improve performance while respecting rate limits. + * @param {string} userId - User ID + * @param {Array} entryIds - Array of entry IDs to unsave + * @returns {Promise} Success status + */ +async function unsaveEntriesViaAPI(userId, entryIds) { + if (!entryIds || entryIds.length === 0) { + return true; + } + + const tagId = encodeURIComponent(`user/${userId}/tag/global.saved`); + const BATCH_SIZE = 5; + + // Process DELETE requests in parallel batches to balance speed and rate limiting + for (let i = 0; i < entryIds.length; i += BATCH_SIZE) { + const batch = entryIds.slice(i, i + BATCH_SIZE); + await Promise.all( + batch.map((entryId) => + feedlyApiRequest(`/v3/tags/${tagId}/${encodeURIComponent(entryId)}`, { + method: "DELETE" + }) + ) + ); + } + return true; +} + +/** + * Main API-based handler for fetching and unsaving entries. + * @param {Object} settings - Settings object with mode and count + * @returns {Promise} Result object with ok, urls, and method + */ +async function handleOpenViaAPI(settings) { + const token = await getAccessToken(); + if (!token) { + throw new Error("No access token available. Please ensure you are logged into Feedly."); + } + + const userId = await getUserId(); + + // Fetch entries: use pagination for "all" mode, single request for "count" mode + const entries = settings.mode === "all" + ? await fetchAllSavedEntriesViaAPI(userId) + : await fetchSavedEntriesViaAPI(userId, normalizeCount(settings.count)); + + if (entries.length === 0) { + return { + ok: true, + urls: [], + method: "api", + message: "No saved entries found" + }; + } + + // Limit entries based on settings (only needed for "count" mode as safeguard) + const entriesToProcess = settings.mode === "count" + ? entries.slice(0, normalizeCount(settings.count)) + : entries; + + const entryIds = entriesToProcess.map((e) => e.id); + await unsaveEntriesViaAPI(userId, entryIds); + + return { + ok: true, + urls: entriesToProcess.map((e) => e.url), + method: "api" + }; +} + function isReadLaterPage(url) { const candidateUrl = url || location.href; if (READ_LATER_PATTERNS.some((pattern) => pattern.test(candidateUrl))) { @@ -238,7 +479,7 @@ async function getSavedEntriesWithUrls(settings) { const seen = new Set(); const results = []; const limit = - settings.mode === "count" ? Math.max(settings.count || 1, 1) : Infinity; + settings.mode === "count" ? normalizeCount(settings.count) : Infinity; for (const entry of entries) { if (results.length >= limit) { @@ -276,25 +517,6 @@ async function unsaveEntry(entry, knownButton) { return true; } -async function revealToolbar(entry) { - const hoverTargets = [ - entry, - entry.querySelector(".EntryMetadataWrapper"), - entry.querySelector(".EntryMetadataReadLater"), - entry.querySelector("div"), - entry.firstElementChild - ].filter(Boolean); - - for (const target of hoverTargets) { - const mouseInit = { bubbles: true, cancelable: true, view: window }; - target.dispatchEvent(new MouseEvent("mouseenter", mouseInit)); - target.dispatchEvent(new MouseEvent("mouseover", mouseInit)); - target.dispatchEvent(new MouseEvent("mousemove", mouseInit)); - } - - await delay(120); -} - // ============================================================================= // Event Dispatching // ============================================================================= @@ -319,33 +541,14 @@ function clickElement(element) { } } -function activateAsButton(element) { - element.focus({ preventScroll: true }); - - const keyEvents = ["keydown", "keyup"]; - for (const key of ["Enter", " "]) { - for (const type of keyEvents) { - element.dispatchEvent( - new KeyboardEvent(type, { - bubbles: true, - cancelable: true, - key - }) - ); - } - } - - element.click(); -} - // ============================================================================= // Infinite Scroll Loading // ============================================================================= async function loadAllEntries({ maxRounds, idleThreshold }) { let idleRounds = 0; - let lastCount = getEntryElements().length; const entries = getEntryElements(); + let lastCount = entries.length; const scrollElement = entries.length ? findScrollContainer(entries[0]) : document.scrollingElement || document.documentElement || document.body; @@ -394,7 +597,13 @@ function findScrollContainer(startNode) { // Main Handler // ============================================================================= -async function handleOpen(settings) { +/** + * DOM-based handler for fetching and unsaving entries. + * Used as fallback when API is unavailable. + * @param {Object} settings - Settings object with mode and count + * @returns {Promise} Result object + */ +async function handleOpenViaDOM(settings) { if (!(await waitForReadLaterPage(2000))) { return { ok: false, error: "This tab is not a Feedly Read Later page." }; } @@ -409,17 +618,55 @@ async function handleOpen(settings) { await unsaveEntry(item.entry, item.button); } - if (settings.reload) { + return { + ok: true, + urls: selected.map((item) => item.url), + method: "dom" + }; +} + +/** + * Main handler: tries API first, falls back to DOM on failure. + * @param {Object} settings - Settings object with mode and count + * @returns {Promise} Result object + */ +async function handleOpen(settings) { + let result; + let apiError = null; + + // Try API-based approach first + try { + result = await handleOpenViaAPI(settings); + } catch (e) { + apiError = e; + console.warn("[Feedly Opener] API operation failed, falling back to DOM:", e.message); + } + + // Fallback to DOM-based approach if API failed + if (!result || !result.ok) { + try { + result = await handleOpenViaDOM(settings); + if (apiError) { + result.apiError = apiError.message; + } + } catch (domError) { + console.error("[Feedly Opener] DOM operation also failed:", domError); + return { + ok: false, + error: `API error: ${apiError?.message || "unknown"}. DOM error: ${domError.message}`, + method: "failed" + }; + } + } + + // Always reload after successful operation to reflect UI changes + if (result.ok) { setTimeout(() => { location.reload(); }, 1000); } - return { - ok: true, - urls: selected.map((item) => item.url), - reloadScheduled: Boolean(settings.reload) - }; + return result; } async function waitForReadLaterPage(timeoutMs) { diff --git a/manifest.json b/manifest.json index 3269a22..cead598 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Feedly Read Later Opener", - "version": "0.2.0", + "version": "0.3.0", "description": "Open Feedly Read Later items in background tabs and unsave them.", "icons": { "16": "icons/icon-16.png", @@ -25,17 +25,8 @@ "scripting" ], "host_permissions": [ - "https://feedly.com/*" - ], - "web_accessible_resources": [ - { - "resources": [ - "content.js" - ], - "matches": [ - "https://feedly.com/*" - ] - } + "https://feedly.com/*", + "https://api.feedly.com/*" ], "content_scripts": [ { diff --git a/popup.css b/popup.css index 2c923bc..b53f8e3 100644 --- a/popup.css +++ b/popup.css @@ -94,15 +94,7 @@ body { color: #596273; } -.toggle { - display: flex; - align-items: center; - gap: 8px; - font-size: 13px; -} - -input[type="radio"], -input[type="checkbox"] { +input[type="radio"] { appearance: none; width: 16px; height: 16px; @@ -116,12 +108,7 @@ input[type="checkbox"] { background 0.15s ease; } -input[type="checkbox"] { - border-radius: 4px; -} - -input[type="radio"]::before, -input[type="checkbox"]::before { +input[type="radio"]::before { content: ""; width: 8px; height: 8px; @@ -131,26 +118,17 @@ input[type="checkbox"]::before { border-radius: 50%; } -input[type="checkbox"]::before { - width: 10px; - height: 10px; - border-radius: 2px; -} - -input[type="radio"]:checked::before, -input[type="checkbox"]:checked::before { +input[type="radio"]:checked::before { transform: scale(1); } -input[type="radio"]:focus-visible, -input[type="checkbox"]:focus-visible { +input[type="radio"]:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.25); border-color: #1a73e8; } -label:hover input[type="radio"], -label:hover input[type="checkbox"] { +label:hover input[type="radio"] { border-color: #8fa3bf; } diff --git a/popup.html b/popup.html index ccaa976..fc861e9 100644 --- a/popup.html +++ b/popup.html @@ -55,13 +55,6 @@

-
- -
-