Files
read-it-later/background.js
T
stefwill c5bd7e38bf feat: add reminders, auto-delete, cross-device sync, dark mode, and rediscovery spotlight
- Rename extension from Shelf to "Read it later"
- Add reminder settings: highlight and pin overdue unarchived items
- Add auto-delete with a hard safety rule requiring its period to exceed the reminder period
- Add cross-device sync via chrome.storage.sync (lightweight per-item metadata, no thumbnails)
- Add light/dark/system theme support
- Fix list-view Archive/Delete button alignment (card-perf wasn't filling the row)
- Add "Worth a second look" spotlight: proactively resurfaces old, forgotten items with automatic backoff so repeatedly-ignored items are shown less often, never permanently
2026-07-13 20:49:05 +10:00

160 lines
5.4 KiB
JavaScript

// Read it later — background service worker
// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's
// chrome.* alias for WebExtensions.
// Chrome loads only "service_worker" from the manifest (a Worker context, so
// importScripts works); Firefox loads the "scripts" array instead, which
// already includes sync.js ahead of this file in the same global scope.
if (typeof importScripts === "function") {
importScripts("sync.js");
}
const STORAGE_KEY = "shelfItems";
const PREFS_KEY = "shelfPrefs";
const AUTO_DELETE_ALARM = "autoDeleteSweep";
function domainOf(url) {
try {
return new URL(url).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
async function getItems() {
const data = await chrome.storage.local.get(STORAGE_KEY);
return data[STORAGE_KEY] || [];
}
async function setItems(items) {
await chrome.storage.local.set({ [STORAGE_KEY]: items });
}
async function getPrefs() {
const data = await chrome.storage.local.get(PREFS_KEY);
return data[PREFS_KEY] || {};
}
// Runs on an hourly alarm so overdue items get cleaned up even when the
// shelf page is never opened. Silent — no one's necessarily watching, so
// unlike the newtab-page sweep there's no toast/undo here; the newtab page
// runs its own courtesy-toast sweep whenever it's open.
async function sweepAutoDelete() {
const prefs = await getPrefs();
if (!prefs.autoDeleteEnabled) return;
const items = await getItems();
const remaining = items.filter((item) => !isOverdueForDeletion(item, prefs));
if (remaining.length === items.length) return;
const merged = typeof reconcileWithSync === "function" ? await reconcileWithSync(remaining) : remaining;
await setItems(merged);
}
async function addItem({ url, title, favIconUrl, image }) {
if (!url) return;
let items = await getItems();
// De-dupe: if already saved, just bump it to the top and refresh metadata.
const existingIndex = items.findIndex((i) => i.url === url);
const now = Date.now();
const entry = {
id: existingIndex >= 0 ? items[existingIndex].id : `${now}-${Math.random().toString(36).slice(2, 8)}`,
url,
title: title || url,
domain: domainOf(url),
favicon: favIconUrl || "",
image: image || (existingIndex >= 0 ? items[existingIndex].image : ""),
dateAdded: now,
archived: false,
updatedAt: now,
surfaceCount: existingIndex >= 0 ? items[existingIndex].surfaceCount || 0 : 0,
lastSurfacedAt: existingIndex >= 0 ? items[existingIndex].lastSurfacedAt || 0 : 0,
};
if (existingIndex >= 0) items.splice(existingIndex, 1);
items.unshift(entry);
items = typeof reconcileWithSync === "function" ? await reconcileWithSync(items) : items;
await setItems(items);
return entry;
}
async function flashBadge(tabId, text) {
try {
await chrome.action.setBadgeText({ text, tabId });
await chrome.action.setBadgeBackgroundColor({ color: "#C47A4A", tabId });
setTimeout(() => {
chrome.action.setBadgeText({ text: "", tabId }).catch(() => {});
}, 1400);
} catch {
// tabId may be gone; ignore.
}
}
// Try to grab a screenshot of the *currently active* tab. This only works
// for the page the user is actually looking at (browser security), so it's
// used when saving the current page, not for arbitrary right-clicked links.
async function captureScreenshot(windowId) {
try {
const dataUrl = await chrome.tabs.captureVisibleTab(windowId, {
format: "jpeg",
quality: 55,
});
return dataUrl;
} catch {
return "";
}
}
// --- Toolbar icon click: save the current page ---
chrome.action.onClicked.addListener(async (tab) => {
if (!tab || !tab.url || tab.url.startsWith("chrome://") || tab.url.startsWith("about:")) {
return;
}
const image = await captureScreenshot(tab.windowId);
await addItem({ url: tab.url, title: tab.title, favIconUrl: tab.favIconUrl, image });
flashBadge(tab.id, "✓");
});
// --- Context menus: right-click a page or a link ---
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: "save-page-later",
title: "Save page for later",
contexts: ["page"],
});
chrome.contextMenus.create({
id: "save-link-later",
title: "Save link for later",
contexts: ["link"],
});
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === "save-page-later") {
if (!tab || !tab.url) return;
const image = await captureScreenshot(tab.windowId);
await addItem({ url: tab.url, title: tab.title, favIconUrl: tab.favIconUrl, image });
flashBadge(tab.id, "✓");
}
if (info.menuItemId === "save-link-later") {
if (!info.linkUrl) return;
const linkTitle = info.linkText && info.linkText.trim() ? info.linkText.trim() : info.linkUrl;
// No screenshot for links we haven't navigated to — favicon/title only.
await addItem({ url: info.linkUrl, title: linkTitle, favIconUrl: "", image: "" });
if (tab && tab.id) flashBadge(tab.id, "✓");
}
});
// --- Auto-delete: hourly sweep, independent of whether the shelf is open ---
// chrome.alarms.create is idempotent (re-arming an existing alarm just
// resets it), so it's safe to call unconditionally every time this script
// runs rather than only from onInstalled.
chrome.alarms.create(AUTO_DELETE_ALARM, { periodInMinutes: 60 });
sweepAutoDelete();
chrome.alarms.onAlarm.addListener((alarm) => {
if (alarm.name === AUTO_DELETE_ALARM) sweepAutoDelete();
});