- 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
206 lines
7.8 KiB
JavaScript
206 lines
7.8 KiB
JavaScript
// Shared local <-> chrome.storage.sync reconciliation for shelf items.
|
|
// Loaded by both background.js (via importScripts, Chrome service worker)
|
|
// and newtab.html (via <script>, Firefox event page + the shelf UI).
|
|
//
|
|
// Sync storage only ever holds a lightweight metadata projection of each
|
|
// item (no screenshot thumbnails — chrome.storage.sync caps a single key at
|
|
// 8KB), one key per item ("m_<id>") so the per-key cap is never a problem;
|
|
// the 512-key / ~100KB total quota is the real ceiling. All sync writes are
|
|
// best-effort: if the user isn't signed in, is offline, or is over quota,
|
|
// errors are swallowed and the extension keeps working from local storage
|
|
// alone.
|
|
|
|
const SYNC_KEY_PREFIX = "m_";
|
|
const SYNCED_IDS_KEY = "shelfSyncedIds";
|
|
const FAVICON_SYNC_MAX_LEN = 1000;
|
|
|
|
function syncKeyFor(id) {
|
|
return SYNC_KEY_PREFIX + id;
|
|
}
|
|
|
|
// Shared by the reminder highlight and the auto-delete sweep, in both
|
|
// background.js and newtab.js, so the two periods can never drift apart.
|
|
// Months are approximated as 30 days — no need for calendar precision here.
|
|
const UNIT_MS = { days: 86400000, weeks: 604800000, months: 2592000000 };
|
|
|
|
function periodMs(amount, unit) {
|
|
return (amount || 0) * (UNIT_MS[unit] || UNIT_MS.days);
|
|
}
|
|
|
|
// Whether an item is old enough (and eligible) to be auto-deleted.
|
|
// Enforces the same "auto-delete period must outlast the reminder period"
|
|
// invariant as the settings-panel validation, as a hard safety net: even if
|
|
// prefs somehow end up misconfigured (e.g. edited directly in storage), this
|
|
// refuses to delete anything until the auto-delete period genuinely exceeds
|
|
// the reminder period — so a user is always reminded before something is
|
|
// removed, never surprised by it.
|
|
function isOverdueForDeletion(item, prefs) {
|
|
if (!prefs || !prefs.autoDeleteEnabled || item.archived) return false;
|
|
const deleteMs = periodMs(prefs.autoDeleteAmount, prefs.autoDeleteUnit);
|
|
const reminderMs = periodMs(prefs.reminderAmount, prefs.reminderUnit);
|
|
if (!deleteMs || deleteMs <= reminderMs) return false;
|
|
return Date.now() - item.dateAdded >= deleteMs;
|
|
}
|
|
|
|
function metaFromItem(item) {
|
|
return {
|
|
id: item.id,
|
|
url: item.url,
|
|
title: item.title,
|
|
domain: item.domain,
|
|
favicon: item.favicon && item.favicon.length <= FAVICON_SYNC_MAX_LEN ? item.favicon : "",
|
|
dateAdded: item.dateAdded,
|
|
archived: !!item.archived,
|
|
updatedAt: item.updatedAt || item.dateAdded,
|
|
surfaceCount: item.surfaceCount || 0,
|
|
lastSurfacedAt: item.lastSurfacedAt || 0,
|
|
};
|
|
}
|
|
|
|
// "Worth a second look" spotlight: proactively resurfaces a few old,
|
|
// forgotten, unarchived items on the new tab page. The more times an item
|
|
// has been surfaced without being acted on, the longer before it's shown
|
|
// again — so it never nags forever on the same links, but never fully goes
|
|
// silent either (it always comes back eventually).
|
|
const SPOTLIGHT_BACKOFF_DAYS = [3, 7, 14, 30, 60];
|
|
// Used when the reminder feature is off (its default state) — otherwise
|
|
// "old enough to matter" would have no threshold at all and the spotlight
|
|
// would silently do nothing for most users.
|
|
const SPOTLIGHT_FALLBACK_MIN_AGE_MS = periodMs(14, "days");
|
|
|
|
function spotlightMinAgeMs(prefs) {
|
|
return prefs.reminderEnabled
|
|
? periodMs(prefs.reminderAmount, prefs.reminderUnit)
|
|
: SPOTLIGHT_FALLBACK_MIN_AGE_MS;
|
|
}
|
|
|
|
function passesSpotlightBackoff(item) {
|
|
if (!item.lastSurfacedAt) return true;
|
|
const tier = Math.min(item.surfaceCount || 0, SPOTLIGHT_BACKOFF_DAYS.length - 1);
|
|
return Date.now() - item.lastSurfacedAt >= SPOTLIGHT_BACKOFF_DAYS[tier] * UNIT_MS.days;
|
|
}
|
|
|
|
function isSpotlightCandidate(item, prefs) {
|
|
if (item.archived) return false;
|
|
if (Date.now() - item.dateAdded < spotlightMinAgeMs(prefs)) return false;
|
|
return passesSpotlightBackoff(item);
|
|
}
|
|
|
|
function shuffleInPlace(arr) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
// Prefers items that have never been (or were least recently) surfaced,
|
|
// with a light shuffle among the leading candidates so it isn't the exact
|
|
// same set every single time.
|
|
function pickSpotlightItems(items, prefs, count) {
|
|
const candidates = items.filter((item) => isSpotlightCandidate(item, prefs));
|
|
candidates.sort((a, b) => (a.lastSurfacedAt || 0) - (b.lastSurfacedAt || 0));
|
|
const pool = shuffleInPlace(candidates.slice(0, count * 2));
|
|
return pool.slice(0, count);
|
|
}
|
|
|
|
async function readSyncMeta() {
|
|
const all = await chrome.storage.sync.get(null);
|
|
return Object.keys(all)
|
|
.filter((k) => k.startsWith(SYNC_KEY_PREFIX))
|
|
.map((k) => all[k]);
|
|
}
|
|
|
|
async function readSyncedIds() {
|
|
const data = await chrome.storage.local.get(SYNCED_IDS_KEY);
|
|
return new Set(data[SYNCED_IDS_KEY] || []);
|
|
}
|
|
|
|
async function writeSyncedIds(idSet) {
|
|
await chrome.storage.local.set({ [SYNCED_IDS_KEY]: [...idSet] });
|
|
}
|
|
|
|
// Pulls remote adds/edits into `items` and pushes local adds/edits/deletes
|
|
// out to chrome.storage.sync. Returns the merged items array; does not
|
|
// persist it — callers are responsible for saving the result locally.
|
|
async function reconcileWithSync(items) {
|
|
let syncItems, previouslySynced;
|
|
try {
|
|
[syncItems, previouslySynced] = await Promise.all([readSyncMeta(), readSyncedIds()]);
|
|
} catch {
|
|
return items;
|
|
}
|
|
|
|
const syncById = new Map(syncItems.map((m) => [m.id, m]));
|
|
const localById = new Map(items.map((i) => [i.id, i]));
|
|
|
|
// Items that were synced before but are no longer in sync storage were
|
|
// deleted on another device — drop them here too.
|
|
let merged = items.filter((item) => !(previouslySynced.has(item.id) && !syncById.has(item.id)));
|
|
|
|
// Pull in remote adds/edits.
|
|
for (const meta of syncItems) {
|
|
const local = localById.get(meta.id);
|
|
if (!local) {
|
|
// Absent locally but previously synced means *this* device deleted it;
|
|
// don't resurrect it from a remote copy that hasn't caught up yet —
|
|
// let the deletion below propagate out instead.
|
|
if (previouslySynced.has(meta.id)) continue;
|
|
merged.push({
|
|
id: meta.id,
|
|
url: meta.url,
|
|
title: meta.title,
|
|
domain: meta.domain,
|
|
favicon: meta.favicon || "",
|
|
image: "",
|
|
dateAdded: meta.dateAdded,
|
|
archived: meta.archived,
|
|
updatedAt: meta.updatedAt,
|
|
surfaceCount: meta.surfaceCount || 0,
|
|
lastSurfacedAt: meta.lastSurfacedAt || 0,
|
|
});
|
|
} else if ((meta.updatedAt || 0) > (local.updatedAt || local.dateAdded || 0)) {
|
|
local.title = meta.title;
|
|
local.domain = meta.domain;
|
|
local.archived = meta.archived;
|
|
local.dateAdded = meta.dateAdded;
|
|
local.favicon = meta.favicon || local.favicon;
|
|
local.updatedAt = meta.updatedAt;
|
|
local.surfaceCount = meta.surfaceCount || 0;
|
|
local.lastSurfacedAt = meta.lastSurfacedAt || 0;
|
|
}
|
|
}
|
|
|
|
// Push local adds/edits that are newer than (or missing from) sync.
|
|
const nextSyncedIds = new Set(syncById.keys());
|
|
const writes = {};
|
|
for (const item of merged) {
|
|
const remote = syncById.get(item.id);
|
|
const localUpdatedAt = item.updatedAt || item.dateAdded;
|
|
if (!remote || localUpdatedAt > (remote.updatedAt || 0)) {
|
|
writes[syncKeyFor(item.id)] = metaFromItem(item);
|
|
nextSyncedIds.add(item.id);
|
|
}
|
|
}
|
|
|
|
// Propagate local deletions: ids we used to sync that are gone locally now.
|
|
const mergedIds = new Set(merged.map((i) => i.id));
|
|
const removals = [];
|
|
for (const id of previouslySynced) {
|
|
if (!mergedIds.has(id) && syncById.has(id)) {
|
|
removals.push(syncKeyFor(id));
|
|
nextSyncedIds.delete(id);
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (Object.keys(writes).length) await chrome.storage.sync.set(writes);
|
|
if (removals.length) await chrome.storage.sync.remove(removals);
|
|
await writeSyncedIds(nextSyncedIds);
|
|
} catch {
|
|
// Best effort — local storage remains the source of truth for this device.
|
|
}
|
|
|
|
return merged;
|
|
}
|