Files
read-it-later/sync.js
T

206 lines
7.8 KiB
JavaScript
Raw Normal View History

// 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;
}