- Add free-form tags per item: chip display with remove, inline add (comma-separated, grid view only), and a toolbar tag-filter popover listing every tag in use - Search now also matches tags; tags sync across devices like every other item field - Fix Firefox manifest.json compliance for AMO submission: replace the placeholder gecko id with a real UUID, add the now-required data_collection_permissions (bookmarksInfo, since synced items are personal browsing/bookmark data), and bump strict_min_version to 140.0 (142.0 for gecko_android) to match - Add description.md (elevator pitch / short / detailed descriptions for store listings), PRIVACY.md, and .gitignore for the dist/ packaging output - Reword all em-dash punctuation across docs and code comments per user preference
161 lines
5.4 KiB
JavaScript
161 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, since 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,
|
|
tags: existingIndex >= 0 ? items[existingIndex].tags || [] : [],
|
|
};
|
|
|
|
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();
|
|
});
|