diff --git a/manifest.json b/manifest.json index 68018dd..66adc84 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "listr", - "version": "1.0.3", + "version": "1.1.0", "description": "Displays a popup with bookmarks and more.", "icons": { "16": "assets/icons/icon-16.png", diff --git a/package.json b/package.json index bc571b2..4595cae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "listr", - "version": "1.0.3", + "version": "1.1.0", "description": "Get bookmarks and favorites from your favorite social media platforms.", "scripts": { "build": "webpack --mode production", diff --git a/src/content.ts b/src/content.ts index 0f3f0d9..90fbf3e 100644 --- a/src/content.ts +++ b/src/content.ts @@ -37,8 +37,11 @@ const collectedInstaLinks = new Set(); const collectedInstaItems: InstagramItem[] = []; const collectedTiktokFavLinks = new Set(); -/** NEW: Pinterest incremental set */ -const collectedPinterestLinks = new Set(); +/** Pinterest incremental sets and state */ +type PinterestMode = 'inactive' | 'board' | 'moreIdeas'; +let pinterestMode: PinterestMode = 'inactive'; +const collectedPinterestBoardLinks = new Set(); +const collectedPinterestMoreIdeasLinks = new Set(); function toAbsoluteInstagramUrl(pathOrUrl: string): string | null { try { @@ -120,17 +123,75 @@ function scanAndCollectYouTubeVideos(logEach: boolean = false): string[] { return newly; } -/** NEW: Pinterest pin scanning */ -function scanAndCollectPinterestPins(logEach: boolean = false): string[] { - if (!/pinterest\./.test(location.hostname)) return []; +/** Helpers to distinguish board pins vs "Find more ideas" suggestions */ +function findMoreIdeasBoundary(): number | null { + const boundarySelectors = [ + '[data-test-id="board-more-ideas"]', + '[data-test-id="board-more-ideas-feed"]', + '[data-test-id="board-more-ideas-section"]', + '[data-test-id="more-ideas-board-section"]', + '[data-test-id="more-ideas-feed"]', + '[data-test-id="moreIdeas"]', + '[data-test-id="BoardPageMoreIdeasFeed"]', + ]; + + for (const sel of boundarySelectors) { + const el = document.querySelector(sel); + if (el) { + const rect = el.getBoundingClientRect(); + return rect.top + window.scrollY; + } + } + + const textMatch = Array.from(document.querySelectorAll('h1,h2,h3,div,span')) + .find(el => (el.textContent || '').trim().toLowerCase() === 'find more ideas'); + if (textMatch) { + const rect = textMatch.getBoundingClientRect(); + return rect.bottom + window.scrollY; + } + + return null; +} + +function determinePinterestAnchorCategory(anchor: HTMLElement, boundary: number | null): 'board' | 'moreIdeas' { + let current: HTMLElement | null = anchor; + while (current) { + const dataTestId = current.getAttribute && current.getAttribute('data-test-id'); + if (dataTestId && /more[-_\s]?ideas/i.test(dataTestId)) { + return 'moreIdeas'; + } + const className = typeof current.className === 'string' ? current.className : ''; + if (className && /more[-_\s]?ideas/i.test(className)) { + return 'moreIdeas'; + } + current = current.parentElement; + } + + if (boundary != null) { + const rect = anchor.getBoundingClientRect(); + const centerY = rect.top + rect.height / 2 + window.scrollY; + if (centerY >= boundary) { + return 'moreIdeas'; + } + } + + return 'board'; +} + +/** Pinterest pin scanning with board vs "more ideas" separation */ +function scanAndCollectPinterestPins(logEach: boolean = false): { links: string[]; boardExhausted: boolean } { + if (!/pinterest\./.test(location.hostname)) return { links: [], boardExhausted: false }; + if (pinterestMode === 'inactive') return { links: [], boardExhausted: false }; + + const boundary = findMoreIdeasBoundary(); const newly: string[] = []; + let foundBoardPin = false; + let sawMoreIdeas = false; - const anchors = document.querySelectorAll('a[href]'); - anchors.forEach(a => { + document.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href') || ''; if (!href) return; - // Accept absolute or site-relative links, normalize to absolute let url: string; try { url = new URL(href, location.origin).toString(); @@ -138,7 +199,6 @@ function scanAndCollectPinterestPins(logEach: boolean = false): string[] { return; } - // Filter strictly to /pin/{id}/ style URLs and strip query/hash const m = url.match(/^https:\/\/(?:[^/]+\.)?pinterest\.com\/pin\/(\d+)\/?/i); if (!m) return; @@ -146,26 +206,41 @@ function scanAndCollectPinterestPins(logEach: boolean = false): string[] { const u = new URL(url); u.hash = ''; u.search = ''; - // Ensure a trailing slash for consistency - const path = u.pathname.endsWith('/') ? u.pathname : u.pathname + '/'; + const path = u.pathname.endsWith('/') ? u.pathname : `${u.pathname}/`; u.pathname = path; const finalUrl = u.toString(); - // Only count visible anchors to avoid hidden templates const isVisible = !!(a.offsetParent || (a as any).offsetWidth || (a as any).offsetHeight); if (!isVisible) return; - if (!collectedPinterestLinks.has(finalUrl)) { - collectedPinterestLinks.add(finalUrl); + const category = determinePinterestAnchorCategory(a, boundary); + let targetSet: Set; + if (category === 'moreIdeas') { + sawMoreIdeas = true; + if (pinterestMode === 'board') { + return; + } + targetSet = collectedPinterestMoreIdeasLinks; + } else { + foundBoardPin = true; + if (pinterestMode === 'moreIdeas') { + return; + } + targetSet = collectedPinterestBoardLinks; + } + + if (!targetSet.has(finalUrl)) { + targetSet.add(finalUrl); newly.push(finalUrl); - if (logEach) console.log('Added Pinterest pin:', finalUrl); + if (logEach) console.log(`Added Pinterest pin (${category}):`, finalUrl); } } catch { /* ignore */ } }); - return newly; + const boardExhausted = pinterestMode === 'board' && !foundBoardPin && sawMoreIdeas; + return { links: newly, boardExhausted }; } /** @@ -378,12 +453,23 @@ function doScrollStep() { } // NEW: Incremental Pinterest pin discovery - const newlyPins = scanAndCollectPinterestPins(true); + const pinterestScan = scanAndCollectPinterestPins(true); + const newlyPins = pinterestScan.links; if (newlyPins.length > 0) { + const modeSnapshot = pinterestMode; try { - browser.runtime.sendMessage({ type: 'pinterestNewLinks', links: newlyPins }) + browser.runtime.sendMessage({ type: 'pinterestNewLinks', links: newlyPins, mode: modeSnapshot }) .catch(() => {}); } catch {} + } else if (pinterestMode === 'board' && pinterestScan.boardExhausted) { + console.log('Pinterest board pins exhausted; stopping scroll.'); + stopScrolling(); + pinterestMode = 'inactive'; + try { + browser.runtime.sendMessage({ type: 'scrollComplete', reason: 'pinterestBoardComplete' }) + .catch(() => {}); + } catch {} + return; } if (currentHeight > lastHeight) { @@ -394,6 +480,9 @@ function doScrollStep() { if (Date.now() - lastChangeTime > 2000) { // Reduced from 20000 to 2000 (2 seconds) console.log("Scrolling stopped. No new content loaded in 2 seconds."); stopScrolling(); + if (pinterestMode !== 'inactive') { + pinterestMode = 'inactive'; + } browser.runtime.sendMessage({ type: 'scrollComplete' }) .catch(() => {}); return; @@ -544,19 +633,47 @@ browser.runtime.onMessage.addListener(async (message: any, _sender: any) => { break; } - /** NEW: Pinterest helpers */ + /** Pinterest helpers */ case 'collectPinterestLinks': { - const links = Array.from(collectedPinterestLinks); - resolve({ links }); + const scope = message?.scope; + if (scope === 'board') { + resolve({ links: Array.from(collectedPinterestBoardLinks) }); + break; + } + if (scope === 'moreIdeas') { + resolve({ links: Array.from(collectedPinterestMoreIdeasLinks) }); + break; + } + const combined = new Set(); + collectedPinterestBoardLinks.forEach(link => combined.add(link)); + collectedPinterestMoreIdeasLinks.forEach(link => combined.add(link)); + resolve({ links: Array.from(combined) }); break; } - case 'resetPinterestState': - collectedPinterestLinks.clear(); + case 'resetPinterestState': { + const scope = message?.scope; + if (!scope || scope === 'board' || scope === 'all') { + collectedPinterestBoardLinks.clear(); + } + if (!scope || scope === 'moreIdeas' || scope === 'all') { + collectedPinterestMoreIdeasLinks.clear(); + } resolve({ status: 'cleared' }); break; + } + case 'setPinterestMode': { + const nextMode = message?.mode; + if (nextMode === 'board' || nextMode === 'moreIdeas') { + pinterestMode = nextMode; + } else { + pinterestMode = 'inactive'; + } + resolve({ status: 'ok', mode: pinterestMode }); + break; + } case 'scanPinterestOnce': { - const links = scanAndCollectPinterestPins(true); - resolve({ links }); + const { links, boardExhausted } = scanAndCollectPinterestPins(true); + resolve({ links, mode: pinterestMode, boardExhausted }); break; } diff --git a/src/popup.tsx b/src/popup.tsx index 77d1673..cec86df 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { createRoot } from 'react-dom/client'; -import { Sun, Moon, Download, Ban, ListTodo, Play, Pause, Trash2, Plus, Settings } from 'lucide-react'; +import { Sun, Moon, Download, Ban, ListTodo, Play, Pause, Trash2, Plus, Settings, Lightbulb } from 'lucide-react'; import browser from 'webextension-polyfill'; import { useActiveTab } from './hooks/useActiveTab'; import { useScrolling } from './hooks/useScrolling'; @@ -93,7 +93,7 @@ const Popup: React.FC = () => { const tiktokPollIntervalRef = React.useRef(null); /** NEW: Pinterest active collection ref */ - const pinterestActiveRef = React.useRef<{ name: string; type: 'bookmarks' | 'profile'; handle: string } | null>(null); + const pinterestActiveRef = React.useRef<{ name: string; type: 'bookmarks' | 'profile'; handle: string; mode: 'board' | 'moreIdeas' } | null>(null); const isTikTokDomain = activeUrl.startsWith("https://www.tiktok.com"); const isInstagramDomain = activeUrl.startsWith("https://www.instagram.com"); @@ -110,6 +110,29 @@ const Popup: React.FC = () => { } }, [activeUrl, isPinterestDomain]); + const pinterestBoardInfo = React.useMemo(() => { + if (!isPinterestDomain) return null; + try { + const u = new URL(activeUrl); + const segments = u.pathname.split('/').filter(Boolean); + if (segments.length >= 2) { + const [first, second] = segments; + if (['pin', 'search', 'ideas', 'explore', 'topics'].includes(first)) { + return null; + } + return { + user: decodeURIComponent(first), + board: decodeURIComponent(second), + }; + } + return null; + } catch { + return null; + } + }, [activeUrl, isPinterestDomain]); + + const isPinterestBoardPage = pinterestBoardInfo !== null; + const isYouTubeVideoPage = isYouTubeDomain && activeUrl.includes('/watch?v='); const isYouTubePlaylistPage = isYouTubeDomain && activeUrl.includes('/playlist?list='); const isYouTubeChannelPage = @@ -372,18 +395,30 @@ const Popup: React.FC = () => { if (links.length === 0) return; const active = pinterestActiveRef.current; - if (active) { + const mode: 'board' | 'moreIdeas' = message.mode === 'moreIdeas' ? 'moreIdeas' : 'board'; + if (active && active.mode === mode) { ensureCollection('pinterest', active.name, { type: active.type, handle: active.handle }); addBookmarksToCollection('pinterest', active.name, links); return; } + if (mode === 'moreIdeas') { + const fallbackName = pinterestBoardInfo + ? `${pinterestBoardInfo.user}_${pinterestBoardInfo.board}_more_ideas` + : 'pinterest_more_ideas'; + const fallbackHandle = pinterestBoardInfo + ? `${pinterestBoardInfo.user} - ${pinterestBoardInfo.board} (more ideas)` + : 'Pinterest (more ideas)'; + ensureCollection('pinterest', fallbackName, { type: 'profile', handle: fallbackHandle }); + addBookmarksToCollection('pinterest', fallbackName, links); + return; + } ensureCollection('pinterest', 'pinterest_page', { type: 'profile', handle: 'Pinterest' }); addBookmarksToCollection('pinterest', 'pinterest_page', links); } }; browser.runtime.onMessage.addListener(handler); return () => browser.runtime.onMessage.removeListener(handler); - }, [activeUrl, isPinterestDomain, isTikTokDomain, isInstagramDomain, isYouTubeDomain, isYouTubePlaylistPage]); + }, [activeUrl, isPinterestDomain, isTikTokDomain, isInstagramDomain, isYouTubeDomain, isYouTubePlaylistPage, pinterestBoardInfo]); const handleBookmarkAll = () => { browser.tabs.query({ active: true, currentWindow: true }) @@ -415,18 +450,23 @@ const Popup: React.FC = () => { }); }, [activeUrl, ensureCollection, addBookmarksToCollection, pingContentScript, scrollStatus, startScrolling, scrollWaitTime]); - /** Pinterest list button handler */ - // FIXED: ensure /search/pins is handled BEFORE the generic 2-segment matcher - // Pinterest: list pins on current page (search-first routing + strict TS-safe) - const handleCollectPinterestPins = React.useCallback(async () => { + /** Pinterest list button handlers */ + const triggerPinterestCollection = React.useCallback(async (mode: 'board' | 'moreIdeas') => { try { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const tabId = tabs[0]?.id ?? null; const u = new URL(activeUrl); let collectionName = 'pinterest_page'; let handle = 'Pinterest'; let type: 'bookmarks' | 'profile' = 'profile'; + let isBoardContext = pinterestBoardInfo != null; // 1) /search/pins?q=... (handle this BEFORE the generic 2-segment matcher) if (u.pathname.startsWith('/search/pins')) { + if (mode === 'moreIdeas') { + console.warn('Pinterest "Find more ideas" collection is only available on board pages.'); + return; + } const q = (u.searchParams.get('q') || '').trim(); if (q) { collectionName = `search_${q}`; @@ -439,32 +479,29 @@ const Popup: React.FC = () => { // 2) Single pin } else if (/^\/pin\/(\d+)/.test(u.pathname)) { + if (mode === 'moreIdeas') { + console.warn('Pinterest "Find more ideas" collection is only available on board pages.'); + return; + } const id = (u.pathname.match(/^\/pin\/(\d+)/) || [, ''])[1]; collectionName = `pin_${id}`; handle = `Pin ${id}`; type = 'bookmarks'; // 3) Board: /{user}/{board} (runs after search so it won't swallow it) - } else if (/^\/([^/]+)\/([^/]+)\/?/.test(u.pathname)) { - const m = u.pathname.match(/^\/([^/]+)\/([^/]+)\/?/)!; - const user = decodeURIComponent(m[1]); - const board = decodeURIComponent(m[2]); + } else if (pinterestBoardInfo) { + const { user, board } = pinterestBoardInfo; collectionName = `${user}_${board}`; handle = `${user} - ${board}`; type = 'profile'; + isBoardContext = true; // 4) Home: include selected section if available } else if (u.pathname === '/') { try { - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); - const tabId = tabs[0]?.id; - - // TS-safe: force `any` so strict mode won't complain about `.section` const res: any = tabId - ? await (browser.tabs.sendMessage(tabId, { action: 'pinterestGetSection' }) as any) - .catch(() => null) + ? await browser.tabs.sendMessage(tabId, { action: 'pinterestGetSection' }).catch(() => null) : null; - const section = (res && typeof res.section === 'string') ? res.section.trim() : ''; if (section) { collectionName = `home_${section}`; @@ -477,27 +514,57 @@ const Popup: React.FC = () => { collectionName = 'home'; handle = 'Home'; } + } else { + if (mode === 'moreIdeas') { + console.warn('Pinterest "Find more ideas" collection is only available on board pages.'); + return; + } + } + + if (mode === 'moreIdeas') { + if (!isBoardContext || !pinterestBoardInfo) { + console.warn('Pinterest "Find more ideas" collection requires a board context.'); + return; + } + collectionName = `${collectionName}_more_ideas`; + handle = `${handle} (more ideas)`; } // Ensure meta row and start incremental collection - pinterestActiveRef.current = { name: collectionName, type, handle }; + pinterestActiveRef.current = { name: collectionName, type, handle, mode }; ensureCollection('pinterest', collectionName, { type, handle }); - const tabs = await browser.tabs.query({ active: true, currentWindow: true }); - const tabId = tabs[0]?.id; if (tabId) { - await browser.tabs.sendMessage(tabId, { action: 'resetPinterestState' }).catch(() => null); + await browser.tabs.sendMessage(tabId, { action: 'setPinterestMode', mode }).catch(() => null); + await browser.tabs.sendMessage(tabId, { action: 'resetPinterestState', scope: 'all' }).catch(() => null); } startScrolling(scrollWaitTime); } catch (e) { console.error('Error starting Pinterest collection:', e); } - }, [activeUrl, ensureCollection, startScrolling, scrollWaitTime]); + }, [activeUrl, ensureCollection, pinterestBoardInfo, startScrolling, scrollWaitTime]); + + const handleCollectPinterestPins = React.useCallback(() => { + void triggerPinterestCollection('board'); + }, [triggerPinterestCollection]); + + const handleCollectPinterestMoreIdeas = React.useCallback(() => { + void triggerPinterestCollection('moreIdeas'); + }, [triggerPinterestCollection]); const handleCancelListing = () => { cancelScrolling(); tiktokActiveCollectionRef.current = null; pinterestActiveRef.current = null; + if (isPinterestDomain) { + browser.tabs.query({ active: true, currentWindow: true }) + .then(tabs => { + const tabId = tabs[0]?.id; + if (tabId == null) return; + return browser.tabs.sendMessage(tabId, { action: 'setPinterestMode', mode: 'inactive' }).catch(() => null); + }) + .catch(() => {}); + } if (tiktokPollIntervalRef.current) { window.clearInterval(tiktokPollIntervalRef.current); tiktokPollIntervalRef.current = null; @@ -819,6 +886,16 @@ const Popup: React.FC = () => { )} + {scrollStatus === 'idle' && isPinterestBoardPage && ( + + )} {scrollStatus === 'idle' && isYouTubeDomain && !isYouTubeVideoPage && !isYouTubeChannelPage && !isYouTubePlaylistPage && (