feat: add tags/labels for saved items
- 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
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
dist/
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
# Privacy Policy for Read it later
|
||||||
|
|
||||||
|
Last updated: 2026-07-14
|
||||||
|
|
||||||
|
Read it later is a browser extension for saving pages and links to read later. This policy explains what data it handles, where that data lives, and what it never does with it.
|
||||||
|
|
||||||
|
## No account, no server
|
||||||
|
|
||||||
|
Read it later has no backend server, no account system, and no analytics or tracking of any kind. The developer never receives, sees, or has access to any data this extension stores. Everything described below either stays on your device or moves through your own browser vendor's sync infrastructure (Google's or Mozilla's), never through infrastructure operated by the developer.
|
||||||
|
|
||||||
|
## What the extension stores
|
||||||
|
|
||||||
|
When you save a page or link, the extension stores:
|
||||||
|
|
||||||
|
- The page's URL, title, and domain
|
||||||
|
- A favicon, or (only when saving the page you're currently viewing) a screenshot of that page, used as a thumbnail
|
||||||
|
- Any tags you choose to add
|
||||||
|
- The date you saved it, whether it's archived, and related display state
|
||||||
|
- Your own settings: grid or list view, sort order, theme, and the reminder, auto-delete, and "Worth a second look" preferences described in the extension's README
|
||||||
|
|
||||||
|
This data is stored using your browser's local extension storage (`chrome.storage.local`), on your device only.
|
||||||
|
|
||||||
|
## Optional sync across your devices
|
||||||
|
|
||||||
|
If your browser is signed into your own account with its built-in sync enabled, the extension also mirrors a lightweight copy of each saved item (URL, title, domain, favicon, dates, archived state, tags, and some internal counters used by the "Worth a second look" feature) through your browser vendor's own sync storage (`chrome.storage.sync`), so the same list appears on your other signed-in devices. Screenshot thumbnails are never included in this sync; devices without a given screenshot show a favicon instead.
|
||||||
|
|
||||||
|
This sync is handled entirely by your browser vendor under their own privacy policy and security practices. The developer does not operate, and has no access to, this sync data at any point.
|
||||||
|
|
||||||
|
## What this extension does not do
|
||||||
|
|
||||||
|
- It does not collect or transmit personally identifiable information, health information, financial or payment information, passwords or other authentication credentials, personal communications, or location data.
|
||||||
|
- It does not monitor your browsing activity, clicks, mouse movement, or keystrokes.
|
||||||
|
- It does not sell, rent, or share any data with third parties.
|
||||||
|
- It does not use your data for advertising, profiling, or any purpose other than showing you the pages and links you chose to save.
|
||||||
|
- It does not download or execute any remote code. All code ships inside the extension package.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The extension requests only the browser permissions it needs to function:
|
||||||
|
|
||||||
|
- **storage**: to save your items and preferences, as described above
|
||||||
|
- **contextMenus**: to add the "Save page for later" and "Save link for later" right-click menu items
|
||||||
|
- **activeTab**: to read the current tab's URL, title, and favicon, and capture a screenshot, only when you click the toolbar icon or a save menu item
|
||||||
|
- **unlimitedStorage**: so screenshot thumbnails don't run into the browser's default storage limit
|
||||||
|
- **alarms**: to run the optional, off-by-default auto-delete check on an hourly schedule
|
||||||
|
|
||||||
|
## Changes to this policy
|
||||||
|
|
||||||
|
If this policy changes, the updated version will be included with the extension and dated above.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
Questions about this policy can be sent to me@stefwill.net.
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
# Read it later
|
# Read it later
|
||||||
|
|
||||||
A new-tab page that shows everything you've saved to read later, as a
|
A new-tab page that shows everything you've saved to read later, as a
|
||||||
sortable grid or list. No account, no server — everything lives in your
|
sortable grid or list. No account, no server: everything lives in your
|
||||||
browser's local extension storage.
|
browser's local extension storage.
|
||||||
|
|
||||||
## How to save something
|
## How to save something
|
||||||
|
|
||||||
- **Click the toolbar icon** — saves the page you're currently on.
|
- **Click the toolbar icon**: saves the page you're currently on.
|
||||||
- **Right-click a page** → "Save page for later"
|
- **Right-click a page** → "Save page for later"
|
||||||
- **Right-click a link** → "Save link for later" (doesn't need to be open)
|
- **Right-click a link** → "Save link for later" (doesn't need to be open)
|
||||||
|
|
||||||
@@ -15,9 +15,9 @@ A ✓ briefly appears on the icon to confirm the save.
|
|||||||
## The shelf (new tab page)
|
## The shelf (new tab page)
|
||||||
|
|
||||||
- Toggle between **grid** (cards with thumbnails) and **list** (compact,
|
- Toggle between **grid** (cards with thumbnails) and **list** (compact,
|
||||||
bookshelf-spine style) — top right.
|
bookshelf-spine style), top right.
|
||||||
- **Sort** by newest, oldest, title, or site.
|
- **Sort** by newest, oldest, title, or site.
|
||||||
- **Search** filters by title, domain, or URL as you type.
|
- **Search** filters by title, domain, URL, or tag as you type.
|
||||||
- **Archive** tucks an item away without deleting it; "Show archived"
|
- **Archive** tucks an item away without deleting it; "Show archived"
|
||||||
reveals archived items again.
|
reveals archived items again.
|
||||||
- **Delete** removes an item, with a 5-second **Undo**.
|
- **Delete** removes an item, with a 5-second **Undo**.
|
||||||
@@ -26,56 +26,68 @@ Thumbnails are captured automatically when you save the *current* page
|
|||||||
(a screenshot of what's on screen). Links saved via right-click show a
|
(a screenshot of what's on screen). Links saved via right-click show a
|
||||||
favicon instead, since the browser hasn't loaded that page yet.
|
favicon instead, since the browser hasn't loaded that page yet.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
In grid view, click **+ tag** on any card to add one or more free-form
|
||||||
|
labels (comma-separated, e.g. `css, research`); click the **×** on a chip to
|
||||||
|
remove it. Tags are grid-only: list view stays the compact bookshelf row
|
||||||
|
it's meant to be.
|
||||||
|
|
||||||
|
Click a tag chip (on a card, or in the 🏷 toolbar button's dropdown, which
|
||||||
|
lists every tag currently in use) to instantly filter the shelf to it. This
|
||||||
|
just fills in the search box, so typing a tag name works too, and it
|
||||||
|
combines with everything search already matches (title, domain, URL).
|
||||||
|
|
||||||
## Reminders and auto-delete
|
## Reminders and auto-delete
|
||||||
|
|
||||||
The gear icon opens a settings panel (also home to the light/dark/system
|
The gear icon opens a settings panel (also home to the light/dark/system
|
||||||
theme picker) with two optional timers, both off by default:
|
theme picker) with two optional timers, both off by default:
|
||||||
|
|
||||||
- **Remind me after** — items older than this get pinned to the top of the
|
- **Remind me after**: items older than this get pinned to the top of the
|
||||||
shelf and highlighted, so they don't get buried.
|
shelf and highlighted, so they don't get buried.
|
||||||
- **Delete unarchived items after** — silently removes items you never
|
- **Delete unarchived items after**: silently removes items you never
|
||||||
came back to. Archiving an item takes it out of consideration entirely.
|
came back to. Archiving an item takes it out of consideration entirely.
|
||||||
|
|
||||||
The delete period must be longer than the reminder period — enforced both
|
The delete period must be longer than the reminder period. This is
|
||||||
in the settings panel (an inline error if you try to set it shorter) and as
|
enforced both in the settings panel (an inline error if you try to set it
|
||||||
a hard rule in the deletion logic itself, so you're always warned before
|
shorter) and as a hard rule in the deletion logic itself, so you're always
|
||||||
anything is removed. Auto-delete runs on an hourly background alarm (so it
|
warned before anything is removed. Auto-delete runs on an hourly background
|
||||||
still works even if you never open the new tab page), and again whenever
|
alarm (so it still works even if you never open the new tab page), and
|
||||||
the shelf page is open; when the shelf page does the deleting, it shows a
|
again whenever the shelf page is open; when the shelf page does the
|
||||||
courtesy toast with an **Undo**.
|
deleting, it shows a courtesy toast with an **Undo**.
|
||||||
|
|
||||||
## Worth a second look
|
## Worth a second look
|
||||||
|
|
||||||
Above the shelf, a small strip proactively surfaces 2–3 old, forgotten,
|
Above the shelf, a small strip proactively surfaces 2–3 old, forgotten,
|
||||||
unarchived items each time you open a new tab — on by default, toggleable
|
unarchived items each time you open a new tab, on by default and toggleable
|
||||||
in the settings panel. Unlike a notification, it doesn't nag: the more
|
in the settings panel. Unlike a notification, it doesn't nag: the more
|
||||||
times an item gets shown without you opening/archiving/deleting it, the
|
times an item gets shown without you opening/archiving/deleting it, the
|
||||||
less often it's picked again (backing off from every few days out to every
|
less often it's picked again (backing off from every few days out to every
|
||||||
couple of months), so nothing gets shown forever, but nothing gets shown
|
couple of months), so nothing gets shown forever, but nothing gets shown
|
||||||
*so* rarely that it's effectively forgotten again either. Archiving or
|
*so* rarely that it's effectively forgotten again either. Archiving or
|
||||||
deleting an item removes it from the strip immediately with no replacement
|
deleting an item removes it from the strip immediately with no replacement
|
||||||
until the next refresh — no whack-a-mole.
|
until the next refresh, avoiding whack-a-mole.
|
||||||
|
|
||||||
"Old enough to matter" uses the same period as the reminder setting above
|
"Old enough to matter" uses the same period as the reminder setting above
|
||||||
when reminders are on, or a 14-day default when they're off. It's fine (and
|
when reminders are on, or a 14-day default when they're off. It's fine (and
|
||||||
expected) for the same item to show up both pinned/highlighted in the main
|
expected) for the same item to show up both pinned/highlighted in the main
|
||||||
shelf *and* in this strip at once — they're not trying to be non-overlapping,
|
shelf *and* in this strip at once. They're not trying to be non-overlapping,
|
||||||
just two different nudges.
|
just two different nudges.
|
||||||
|
|
||||||
## Install — Chrome / Edge (unpacked)
|
## Install on Chrome / Edge (unpacked)
|
||||||
|
|
||||||
1. Go to `chrome://extensions` (or `edge://extensions`).
|
1. Go to `chrome://extensions` (or `edge://extensions`).
|
||||||
2. Turn on **Developer mode** (top right).
|
2. Turn on **Developer mode** (top right).
|
||||||
3. Click **Load unpacked** and select this folder.
|
3. Click **Load unpacked** and select this folder.
|
||||||
4. Open a new tab — you should see the Read it later page.
|
4. Open a new tab. You should see the Read it later page.
|
||||||
|
|
||||||
## Install — Firefox (temporary, for testing)
|
## Install on Firefox (temporary, for testing)
|
||||||
|
|
||||||
1. Go to `about:debugging#/runtime/this-firefox`.
|
1. Go to `about:debugging#/runtime/this-firefox`.
|
||||||
2. Click **Load Temporary Add-on…**
|
2. Click **Load Temporary Add-on…**
|
||||||
3. Select the `manifest.json` file inside this folder.
|
3. Select the `manifest.json` file inside this folder.
|
||||||
4. Open a new tab. Firefox will ask permission to let the extension
|
4. Open a new tab. Firefox will ask permission to let the extension
|
||||||
override the new tab page the first time — allow it.
|
override the new tab page the first time. Allow it.
|
||||||
|
|
||||||
> Temporary add-ons in Firefox are removed when you close the browser.
|
> Temporary add-ons in Firefox are removed when you close the browser.
|
||||||
> For a permanent install, the extension needs to be signed by Mozilla
|
> For a permanent install, the extension needs to be signed by Mozilla
|
||||||
@@ -87,22 +99,22 @@ just two different nudges.
|
|||||||
|
|
||||||
Read it later keeps full items (including screenshot thumbnails) in
|
Read it later keeps full items (including screenshot thumbnails) in
|
||||||
`chrome.storage.local`, and also mirrors a lightweight copy of each item
|
`chrome.storage.local`, and also mirrors a lightweight copy of each item
|
||||||
(url, title, domain, favicon, dates, archived state, spotlight backoff
|
(url, title, domain, favicon, dates, archived state, tags, spotlight
|
||||||
counters — no images) to
|
backoff counters, never images) to
|
||||||
`chrome.storage.sync`, one entry per item so each stays well under the
|
`chrome.storage.sync`, one entry per item so each stays well under the
|
||||||
8KB-per-item sync limit. On another device signed into the same browser
|
8KB-per-item sync limit. On another device signed into the same browser
|
||||||
account, opening the shelf pulls those in automatically; thumbnails just
|
account, opening the shelf pulls those in automatically; thumbnails just
|
||||||
aren't part of what syncs, so items saved elsewhere show a favicon instead
|
aren't part of what syncs, so items saved elsewhere show a favicon instead
|
||||||
until re-saved on that device. Archiving and deleting also sync. This is
|
until re-saved on that device. Archiving and deleting also sync. This is
|
||||||
all best-effort — offline, not signed in, or over the (~100KB / 512-item)
|
all best-effort: offline, not signed in, or over the (~100KB / 512-item)
|
||||||
sync quota just means that device keeps working from local storage alone.
|
sync quota just means that device keeps working from local storage alone.
|
||||||
|
|
||||||
Note: `chrome.storage.sync` needs a stable extension ID to work, which
|
Note: `chrome.storage.sync` needs a stable extension ID to work, which
|
||||||
only applies to a permanently installed, signed add-on — not a Firefox
|
only applies to a permanently installed, signed add-on, not a Firefox
|
||||||
"temporary add-on" loaded via `about:debugging` (see install notes above),
|
"temporary add-on" loaded via `about:debugging` (see install notes above),
|
||||||
which won't persist sync identity across restarts.
|
which won't persist sync identity across restarts.
|
||||||
|
|
||||||
## Notes / things you might want to change
|
## Notes / things you might want to change
|
||||||
|
|
||||||
- Icons in `icons/` are placeholders — swap them for your own artwork
|
- Icons in `icons/` are placeholders. Swap them for your own artwork
|
||||||
any time; sizes 16/32/48/128 are already wired up in `manifest.json`.
|
any time; sizes 16/32/48/128 are already wired up in `manifest.json`.
|
||||||
|
|||||||
+6
-5
@@ -1,4 +1,4 @@
|
|||||||
// Read it later — background service worker
|
// Read it later: background service worker
|
||||||
// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's
|
// Works under both the `chrome` namespace (Chrome/Edge) and Firefox's
|
||||||
// chrome.* alias for WebExtensions.
|
// chrome.* alias for WebExtensions.
|
||||||
|
|
||||||
@@ -36,9 +36,9 @@ async function getPrefs() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Runs on an hourly alarm so overdue items get cleaned up even when the
|
// 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
|
// shelf page is never opened. Silent, since no one's necessarily watching,
|
||||||
// unlike the newtab-page sweep there's no toast/undo here; the newtab page
|
// so unlike the newtab-page sweep there's no toast/undo here; the newtab
|
||||||
// runs its own courtesy-toast sweep whenever it's open.
|
// page runs its own courtesy-toast sweep whenever it's open.
|
||||||
async function sweepAutoDelete() {
|
async function sweepAutoDelete() {
|
||||||
const prefs = await getPrefs();
|
const prefs = await getPrefs();
|
||||||
if (!prefs.autoDeleteEnabled) return;
|
if (!prefs.autoDeleteEnabled) return;
|
||||||
@@ -70,6 +70,7 @@ async function addItem({ url, title, favIconUrl, image }) {
|
|||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
surfaceCount: existingIndex >= 0 ? items[existingIndex].surfaceCount || 0 : 0,
|
surfaceCount: existingIndex >= 0 ? items[existingIndex].surfaceCount || 0 : 0,
|
||||||
lastSurfacedAt: existingIndex >= 0 ? items[existingIndex].lastSurfacedAt || 0 : 0,
|
lastSurfacedAt: existingIndex >= 0 ? items[existingIndex].lastSurfacedAt || 0 : 0,
|
||||||
|
tags: existingIndex >= 0 ? items[existingIndex].tags || [] : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingIndex >= 0) items.splice(existingIndex, 1);
|
if (existingIndex >= 0) items.splice(existingIndex, 1);
|
||||||
@@ -141,7 +142,7 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|||||||
if (info.menuItemId === "save-link-later") {
|
if (info.menuItemId === "save-link-later") {
|
||||||
if (!info.linkUrl) return;
|
if (!info.linkUrl) return;
|
||||||
const linkTitle = info.linkText && info.linkText.trim() ? info.linkText.trim() : info.linkUrl;
|
const linkTitle = info.linkText && info.linkText.trim() ? info.linkText.trim() : info.linkUrl;
|
||||||
// No screenshot for links we haven't navigated to — favicon/title only.
|
// No screenshot for links we haven't navigated to: favicon/title only.
|
||||||
await addItem({ url: info.linkUrl, title: linkTitle, favIconUrl: "", image: "" });
|
await addItem({ url: info.linkUrl, title: linkTitle, favIconUrl: "", image: "" });
|
||||||
if (tab && tab.id) flashBadge(tab.id, "✓");
|
if (tab && tab.id) flashBadge(tab.id, "✓");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Read it later
|
||||||
|
|
||||||
|
## Elevator pitch (1 line)
|
||||||
|
|
||||||
|
A new-tab page that remembers what you saved, actively helps you get back to it, and never turns into a graveyard of forgotten links.
|
||||||
|
|
||||||
|
## Short description
|
||||||
|
|
||||||
|
Read it later is a browser extension for saving pages and links to read later, then actually reading them. Click the toolbar icon or right-click any page or link to save it; every new tab shows your saved items as a sortable, searchable grid or a compact list. Beyond basic saving, it proactively fights the "saved and forgotten" problem: a reminder highlights stale items, a "Worth a second look" strip resurfaces old ones without nagging, and an optional auto-delete clears out things you truly never got back to. Everything lives in the browser's own storage, with no account and no external server, and it optionally syncs across your devices through the browser's built-in sync.
|
||||||
|
|
||||||
|
## Detailed description
|
||||||
|
|
||||||
|
**Saving**
|
||||||
|
|
||||||
|
- Click the toolbar icon to save the page you're currently on. A screenshot of the page is captured automatically as a thumbnail.
|
||||||
|
- Right-click any page and choose "Save page for later," or right-click a link and choose "Save link for later" (this works even for links you haven't opened; they show a favicon instead of a screenshot since the browser never loaded them).
|
||||||
|
- Saving the same URL twice just bumps it back to the top and refreshes its metadata, instead of creating a duplicate.
|
||||||
|
|
||||||
|
**The shelf (new tab page)**
|
||||||
|
|
||||||
|
- Two views: a **grid** of cards with thumbnails, and a compact **list** styled like spines on a bookshelf.
|
||||||
|
- Sort by newest, oldest, title, or site.
|
||||||
|
- Search filters by title, domain, URL, or tag as you type.
|
||||||
|
- Archive tucks an item away without deleting it; "Show archived" brings archived items back into view.
|
||||||
|
- Delete removes an item, with a 5 second Undo.
|
||||||
|
|
||||||
|
**Tags**
|
||||||
|
|
||||||
|
- In grid view, add one or more free-form tags to any card (comma-separated entry supports adding several at once), and remove them individually.
|
||||||
|
- Click any tag, on a card or in the toolbar's tag dropdown (which lists every tag currently in use), to instantly filter the shelf to it. This works by filling in the search box, so typing a tag name filters the same way.
|
||||||
|
|
||||||
|
**Reminders and auto-delete**
|
||||||
|
|
||||||
|
- An optional "Remind me after" timer highlights and pins items older than a chosen period, so they surface when you're browsing the full shelf.
|
||||||
|
- An optional "Delete unarchived items after" timer quietly removes items you never returned to. Archiving an item takes it out of consideration permanently.
|
||||||
|
- The delete period is required to be longer than the reminder period, both as a settings-panel validation and as a hard rule in the deletion logic itself, so nothing is ever deleted without having been flagged first. Auto-delete runs on an hourly background alarm (so it works even if the new tab page is never opened) and again whenever the shelf is open, where it shows a courtesy toast with an Undo.
|
||||||
|
|
||||||
|
**Worth a second look**
|
||||||
|
|
||||||
|
- A small strip above the shelf proactively surfaces 2 to 3 old, unarchived items each time a new tab opens, aiming squarely at the core failure mode of every read-later tool: saving something and never coming back to it.
|
||||||
|
- It's designed to never become a nag. The more times an item is shown without being opened, archived, or deleted, the less often it's picked again, backing off from every few days out to every couple of months. Nothing is shown forever, but nothing goes silent forever either.
|
||||||
|
- Archiving or deleting a spotlighted item removes it immediately, with no replacement sliding in until the next refresh.
|
||||||
|
|
||||||
|
**Appearance**
|
||||||
|
|
||||||
|
- Light, dark, or system theme, chosen in the settings panel.
|
||||||
|
|
||||||
|
**Sync and storage**
|
||||||
|
|
||||||
|
- No account and no external server. Full items, including screenshot thumbnails, live in the browser's local extension storage on each device.
|
||||||
|
- A lightweight copy of each item (URL, title, domain, favicon, dates, archived state, tags, and the "Worth a second look" backoff counters, never the thumbnails) can also sync across devices signed into the same browser account, using the browser's own sync storage. This is best effort: if a device is offline, not signed in, or over the sync quota, it simply keeps working from local storage alone.
|
||||||
|
|
||||||
|
**Platforms**
|
||||||
|
|
||||||
|
- Works in Chrome, Edge, and Firefox.
|
||||||
+8
-2
@@ -27,8 +27,14 @@
|
|||||||
},
|
},
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
"id": "[email protected]",
|
"id": "{b5bcdd65-958d-4b77-a5d6-ba2684c37a94}",
|
||||||
"strict_min_version": "109.0"
|
"strict_min_version": "140.0",
|
||||||
|
"data_collection_permissions": {
|
||||||
|
"required": ["bookmarksInfo"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gecko_android": {
|
||||||
|
"strict_min_version": "142.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -259,6 +259,103 @@ select#sortSelect {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Tags filter popover --- */
|
||||||
|
.tags-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.tags-panel {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
z-index: 20;
|
||||||
|
width: 220px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.tags-panel[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Tag chips (shared by cards and the tags-filter popover) --- */
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--clay-deep);
|
||||||
|
border: none;
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tag-chip-label {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tag-chip:hover .tag-chip-label,
|
||||||
|
.tag-chip-filter:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.tag-remove {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--clay-deep);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.tag-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.tag-add-btn {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px dashed var(--hairline-strong);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.tag-add-btn:hover {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--clay);
|
||||||
|
}
|
||||||
|
.tag-input {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: inherit;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Shelf: grid view (library index cards) --- */
|
/* --- Shelf: grid view (library index cards) --- */
|
||||||
.shelf[data-view="grid"] {
|
.shelf[data-view="grid"] {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
+10
-2
@@ -16,7 +16,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<input id="search" type="text" placeholder="Search your shelf…" autocomplete="off" />
|
<input id="search" type="text" placeholder="Search title, site, or tag…" autocomplete="off" />
|
||||||
|
|
||||||
<div class="control-group" role="group" aria-label="Sort by">
|
<div class="control-group" role="group" aria-label="Sort by">
|
||||||
<label for="sortSelect" class="sr-only">Sort by</label>
|
<label for="sortSelect" class="sr-only">Sort by</label>
|
||||||
@@ -35,6 +35,14 @@
|
|||||||
|
|
||||||
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
|
<button id="showArchivedBtn" class="ghost-btn">Show archived</button>
|
||||||
|
|
||||||
|
<div class="tags-wrap">
|
||||||
|
<button id="tagsBtn" class="toggle-btn" title="Filter by tag" aria-haspopup="true" aria-expanded="false">🏷</button>
|
||||||
|
<div id="tagsPanel" class="tags-panel" hidden>
|
||||||
|
<div id="tagsList" class="tags-list"></div>
|
||||||
|
<p class="settings-hint" id="tagsEmptyHint" hidden>No tags yet. Add one from any card in grid view.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-wrap">
|
<div class="settings-wrap">
|
||||||
<button id="settingsBtn" class="toggle-btn" title="Settings" aria-haspopup="true" aria-expanded="false">⚙</button>
|
<button id="settingsBtn" class="toggle-btn" title="Settings" aria-haspopup="true" aria-expanded="false">⚙</button>
|
||||||
<div id="settingsPanel" class="settings-panel" hidden>
|
<div id="settingsPanel" class="settings-panel" hidden>
|
||||||
@@ -77,7 +85,7 @@
|
|||||||
<option value="months">months</option>
|
<option value="months">months</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<p class="settings-hint">Archive anything you want to keep — this only removes items you never got back to.</p>
|
<p class="settings-hint">Archive anything you want to keep. This only removes items you never got back to.</p>
|
||||||
<p class="settings-error" id="autoDeleteError" hidden>Must be longer than the reminder period above, so you're always warned first.</p>
|
<p class="settings-error" id="autoDeleteError" hidden>Must be longer than the reminder period above, so you're always warned first.</p>
|
||||||
|
|
||||||
<hr class="settings-divider" />
|
<hr class="settings-divider" />
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const autoDeleteErrorEl = document.getElementById("autoDeleteError");
|
|||||||
const spotlightSectionEl = document.getElementById("spotlight");
|
const spotlightSectionEl = document.getElementById("spotlight");
|
||||||
const spotlightStripEl = document.getElementById("spotlightStrip");
|
const spotlightStripEl = document.getElementById("spotlightStrip");
|
||||||
const spotlightEnabledEl = document.getElementById("spotlightEnabled");
|
const spotlightEnabledEl = document.getElementById("spotlightEnabled");
|
||||||
|
const tagsBtn = document.getElementById("tagsBtn");
|
||||||
|
const tagsPanel = document.getElementById("tagsPanel");
|
||||||
|
const tagsListEl = document.getElementById("tagsList");
|
||||||
|
const tagsEmptyHintEl = document.getElementById("tagsEmptyHint");
|
||||||
|
|
||||||
let items = [];
|
let items = [];
|
||||||
let prefs = {
|
let prefs = {
|
||||||
@@ -72,6 +76,51 @@ function timeAgo(ts) {
|
|||||||
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
return new Date(ts).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTag(raw) {
|
||||||
|
return raw.trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supports comma-separated entry ("css, research") so one Enter press can add several.
|
||||||
|
function parseTagInput(raw) {
|
||||||
|
return [...new Set(raw.split(",").map(normalizeTag).filter(Boolean))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function allTags() {
|
||||||
|
const set = new Set();
|
||||||
|
items.forEach((item) => (item.tags || []).forEach((t) => set.add(t)));
|
||||||
|
return [...set].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByTag(tag) {
|
||||||
|
searchEl.value = tag;
|
||||||
|
closeTagsPanel();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTagsToItem(id, raw) {
|
||||||
|
const it = items.find((i) => i.id === id);
|
||||||
|
if (!it) return;
|
||||||
|
const toAdd = parseTagInput(raw);
|
||||||
|
if (!toAdd.length) return;
|
||||||
|
const existing = new Set(it.tags || []);
|
||||||
|
toAdd.forEach((t) => existing.add(t));
|
||||||
|
it.tags = [...existing];
|
||||||
|
it.updatedAt = Date.now();
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
|
await saveItems();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTagFromItem(id, tag) {
|
||||||
|
const it = items.find((i) => i.id === id);
|
||||||
|
if (!it) return;
|
||||||
|
it.tags = (it.tags || []).filter((t) => t !== tag);
|
||||||
|
it.updatedAt = Date.now();
|
||||||
|
items = await reconcileWithSync(items);
|
||||||
|
await saveItems();
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
const data = await chrome.storage.local.get([STORAGE_KEY, PREFS_KEY]);
|
const data = await chrome.storage.local.get([STORAGE_KEY, PREFS_KEY]);
|
||||||
items = data[STORAGE_KEY] || [];
|
items = data[STORAGE_KEY] || [];
|
||||||
@@ -88,7 +137,7 @@ async function saveItems() {
|
|||||||
await chrome.storage.local.set({ [STORAGE_KEY]: items });
|
await chrome.storage.local.set({ [STORAGE_KEY]: items });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Kept out of shelfPrefs deliberately — this is derived runtime state (which
|
// Kept out of shelfPrefs deliberately: this is derived runtime state (which
|
||||||
// items were last shown, and when), not a user-configurable setting.
|
// items were last shown, and when), not a user-configurable setting.
|
||||||
async function loadSpotlightState() {
|
async function loadSpotlightState() {
|
||||||
const data = await chrome.storage.local.get(SPOTLIGHT_STATE_KEY);
|
const data = await chrome.storage.local.get(SPOTLIGHT_STATE_KEY);
|
||||||
@@ -233,6 +282,12 @@ function buildCard(item, view = prefs.view) {
|
|||||||
}
|
}
|
||||||
body.appendChild(meta);
|
body.appendChild(meta);
|
||||||
|
|
||||||
|
// Tag editing needs real vertical room, so it's grid-only. List view stays
|
||||||
|
// the compact bookshelf-spine row it's meant to be.
|
||||||
|
if (view === "grid") {
|
||||||
|
body.appendChild(buildTagsRow(item));
|
||||||
|
}
|
||||||
|
|
||||||
if (view === "grid") {
|
if (view === "grid") {
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "card-actions";
|
actions.className = "card-actions";
|
||||||
@@ -255,6 +310,77 @@ function buildCard(item, view = prefs.view) {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildTagsRow(item) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "card-tags";
|
||||||
|
|
||||||
|
(item.tags || []).forEach((tag) => {
|
||||||
|
const chip = document.createElement("span");
|
||||||
|
chip.className = "tag-chip";
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "tag-chip-label";
|
||||||
|
label.textContent = tag;
|
||||||
|
label.title = `Filter by "${tag}"`;
|
||||||
|
label.addEventListener("click", () => filterByTag(tag));
|
||||||
|
chip.appendChild(label);
|
||||||
|
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "tag-remove";
|
||||||
|
remove.textContent = "×";
|
||||||
|
remove.title = `Remove tag "${tag}"`;
|
||||||
|
remove.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
removeTagFromItem(item.id, tag);
|
||||||
|
});
|
||||||
|
chip.appendChild(remove);
|
||||||
|
|
||||||
|
row.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.className = "tag-add-btn";
|
||||||
|
addBtn.textContent = "+ tag";
|
||||||
|
addBtn.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addBtn.replaceWith(buildTagInput(item));
|
||||||
|
});
|
||||||
|
row.appendChild(addBtn);
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTagInput(item) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "text";
|
||||||
|
input.className = "tag-input";
|
||||||
|
input.placeholder = "tag, another…";
|
||||||
|
|
||||||
|
// Guards against double-submit: committing rebuilds the shelf (via render()),
|
||||||
|
// which removes this input from the DOM, and removing a focused element
|
||||||
|
// fires "blur", which would otherwise re-trigger commit() a second time.
|
||||||
|
let done = false;
|
||||||
|
const commit = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
if (input.value.trim()) addTagsToItem(item.id, input.value);
|
||||||
|
else render();
|
||||||
|
};
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commit();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
done = true;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", commit);
|
||||||
|
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
function makeActionBtn(item) {
|
function makeActionBtn(item) {
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.className = "icon-btn";
|
btn.className = "icon-btn";
|
||||||
@@ -310,7 +436,7 @@ async function deleteItem(id) {
|
|||||||
|
|
||||||
// Also run by background.js on an hourly alarm so items get cleaned up even
|
// Also run by background.js on an hourly alarm so items get cleaned up even
|
||||||
// when this page isn't open; this copy runs whenever the shelf is open (on
|
// when this page isn't open; this copy runs whenever the shelf is open (on
|
||||||
// load, and periodically after) and — unlike the background sweep — surfaces
|
// load, and periodically after) and, unlike the background sweep, surfaces
|
||||||
// a courtesy toast with an Undo, since a human is actually here to see it.
|
// a courtesy toast with an Undo, since a human is actually here to see it.
|
||||||
async function runAutoDeleteSweep() {
|
async function runAutoDeleteSweep() {
|
||||||
if (!prefs.autoDeleteEnabled) return;
|
if (!prefs.autoDeleteEnabled) return;
|
||||||
@@ -361,7 +487,7 @@ async function refreshSpotlightSelection() {
|
|||||||
await saveSpotlightState(spotlightState);
|
await saveSpotlightState(spotlightState);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure projection of the current selection against live item state — no
|
// Pure projection of the current selection against live item state. No
|
||||||
// selection logic here, so archiving/deleting a spotlighted card just makes
|
// selection logic here, so archiving/deleting a spotlighted card just makes
|
||||||
// it disappear (via the normal render() path) with no replacement sliding
|
// it disappear (via the normal render() path) with no replacement sliding
|
||||||
// in until the next cooldown-triggered refresh.
|
// in until the next cooldown-triggered refresh.
|
||||||
@@ -380,6 +506,19 @@ function renderSpotlight() {
|
|||||||
spotlightSectionEl.hidden = visible.length === 0;
|
spotlightSectionEl.hidden = visible.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTagsPanel() {
|
||||||
|
const tags = allTags();
|
||||||
|
tagsListEl.innerHTML = "";
|
||||||
|
tags.forEach((tag) => {
|
||||||
|
const chip = document.createElement("button");
|
||||||
|
chip.className = "tag-chip tag-chip-filter";
|
||||||
|
chip.textContent = tag;
|
||||||
|
chip.addEventListener("click", () => filterByTag(tag));
|
||||||
|
tagsListEl.appendChild(chip);
|
||||||
|
});
|
||||||
|
tagsEmptyHintEl.hidden = tags.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getFilteredSorted() {
|
function getFilteredSorted() {
|
||||||
const query = searchEl.value.trim().toLowerCase();
|
const query = searchEl.value.trim().toLowerCase();
|
||||||
let list = items.filter((i) => (prefs.showArchived ? true : !i.archived));
|
let list = items.filter((i) => (prefs.showArchived ? true : !i.archived));
|
||||||
@@ -389,7 +528,8 @@ function getFilteredSorted() {
|
|||||||
(i) =>
|
(i) =>
|
||||||
(i.title || "").toLowerCase().includes(query) ||
|
(i.title || "").toLowerCase().includes(query) ||
|
||||||
(i.domain || "").toLowerCase().includes(query) ||
|
(i.domain || "").toLowerCase().includes(query) ||
|
||||||
(i.url || "").toLowerCase().includes(query)
|
(i.url || "").toLowerCase().includes(query) ||
|
||||||
|
(i.tags || []).some((t) => t.includes(query))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +566,7 @@ function render() {
|
|||||||
shelfEl.hidden = items.length === 0;
|
shelfEl.hidden = items.length === 0;
|
||||||
|
|
||||||
renderSpotlight();
|
renderSpotlight();
|
||||||
|
renderTagsPanel();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Event wiring ---
|
// --- Event wiring ---
|
||||||
@@ -470,12 +611,31 @@ settingsBtn.addEventListener("click", (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function closeTagsPanel() {
|
||||||
|
tagsPanel.hidden = true;
|
||||||
|
tagsBtn.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
tagsBtn.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const isOpen = !tagsPanel.hidden;
|
||||||
|
if (isOpen) {
|
||||||
|
closeTagsPanel();
|
||||||
|
} else {
|
||||||
|
tagsPanel.hidden = false;
|
||||||
|
tagsBtn.setAttribute("aria-expanded", "true");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener("click", (e) => {
|
document.addEventListener("click", (e) => {
|
||||||
if (!settingsPanel.hidden && !e.target.closest(".settings-wrap")) closeSettingsPanel();
|
if (!settingsPanel.hidden && !e.target.closest(".settings-wrap")) closeSettingsPanel();
|
||||||
|
if (!tagsPanel.hidden && !e.target.closest(".tags-wrap")) closeTagsPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Escape" && !settingsPanel.hidden) closeSettingsPanel();
|
if (e.key !== "Escape") return;
|
||||||
|
if (!settingsPanel.hidden) closeSettingsPanel();
|
||||||
|
if (!tagsPanel.hidden) closeTagsPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
reminderEnabledEl.addEventListener("change", async () => {
|
reminderEnabledEl.addEventListener("change", async () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// and newtab.html (via <script>, Firefox event page + the shelf UI).
|
// and newtab.html (via <script>, Firefox event page + the shelf UI).
|
||||||
//
|
//
|
||||||
// Sync storage only ever holds a lightweight metadata projection of each
|
// Sync storage only ever holds a lightweight metadata projection of each
|
||||||
// item (no screenshot thumbnails — chrome.storage.sync caps a single key at
|
// 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;
|
// 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
|
// 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,
|
// best-effort: if the user isn't signed in, is offline, or is over quota,
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
const SYNC_KEY_PREFIX = "m_";
|
const SYNC_KEY_PREFIX = "m_";
|
||||||
const SYNCED_IDS_KEY = "shelfSyncedIds";
|
const SYNCED_IDS_KEY = "shelfSyncedIds";
|
||||||
const FAVICON_SYNC_MAX_LEN = 1000;
|
const FAVICON_SYNC_MAX_LEN = 1000;
|
||||||
|
const TAGS_SYNC_MAX_COUNT = 20;
|
||||||
|
|
||||||
function syncKeyFor(id) {
|
function syncKeyFor(id) {
|
||||||
return SYNC_KEY_PREFIX + id;
|
return SYNC_KEY_PREFIX + id;
|
||||||
@@ -20,7 +21,7 @@ function syncKeyFor(id) {
|
|||||||
|
|
||||||
// Shared by the reminder highlight and the auto-delete sweep, in both
|
// 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.
|
// 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.
|
// Months are approximated as 30 days; no need for calendar precision here.
|
||||||
const UNIT_MS = { days: 86400000, weeks: 604800000, months: 2592000000 };
|
const UNIT_MS = { days: 86400000, weeks: 604800000, months: 2592000000 };
|
||||||
|
|
||||||
function periodMs(amount, unit) {
|
function periodMs(amount, unit) {
|
||||||
@@ -32,7 +33,7 @@ function periodMs(amount, unit) {
|
|||||||
// invariant as the settings-panel validation, as a hard safety net: even if
|
// 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
|
// prefs somehow end up misconfigured (e.g. edited directly in storage), this
|
||||||
// refuses to delete anything until the auto-delete period genuinely exceeds
|
// refuses to delete anything until the auto-delete period genuinely exceeds
|
||||||
// the reminder period — so a user is always reminded before something is
|
// the reminder period, so a user is always reminded before something is
|
||||||
// removed, never surprised by it.
|
// removed, never surprised by it.
|
||||||
function isOverdueForDeletion(item, prefs) {
|
function isOverdueForDeletion(item, prefs) {
|
||||||
if (!prefs || !prefs.autoDeleteEnabled || item.archived) return false;
|
if (!prefs || !prefs.autoDeleteEnabled || item.archived) return false;
|
||||||
@@ -54,16 +55,17 @@ function metaFromItem(item) {
|
|||||||
updatedAt: item.updatedAt || item.dateAdded,
|
updatedAt: item.updatedAt || item.dateAdded,
|
||||||
surfaceCount: item.surfaceCount || 0,
|
surfaceCount: item.surfaceCount || 0,
|
||||||
lastSurfacedAt: item.lastSurfacedAt || 0,
|
lastSurfacedAt: item.lastSurfacedAt || 0,
|
||||||
|
tags: (item.tags || []).slice(0, TAGS_SYNC_MAX_COUNT),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// "Worth a second look" spotlight: proactively resurfaces a few old,
|
// "Worth a second look" spotlight: proactively resurfaces a few old,
|
||||||
// forgotten, unarchived items on the new tab page. The more times an item
|
// 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
|
// 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
|
// again, so it never nags forever on the same links, but never fully goes
|
||||||
// silent either (it always comes back eventually).
|
// silent either (it always comes back eventually).
|
||||||
const SPOTLIGHT_BACKOFF_DAYS = [3, 7, 14, 30, 60];
|
const SPOTLIGHT_BACKOFF_DAYS = [3, 7, 14, 30, 60];
|
||||||
// Used when the reminder feature is off (its default state) — otherwise
|
// Used when the reminder feature is off (its default state). Otherwise
|
||||||
// "old enough to matter" would have no threshold at all and the spotlight
|
// "old enough to matter" would have no threshold at all and the spotlight
|
||||||
// would silently do nothing for most users.
|
// would silently do nothing for most users.
|
||||||
const SPOTLIGHT_FALLBACK_MIN_AGE_MS = periodMs(14, "days");
|
const SPOTLIGHT_FALLBACK_MIN_AGE_MS = periodMs(14, "days");
|
||||||
@@ -122,7 +124,7 @@ async function writeSyncedIds(idSet) {
|
|||||||
|
|
||||||
// Pulls remote adds/edits into `items` and pushes local adds/edits/deletes
|
// Pulls remote adds/edits into `items` and pushes local adds/edits/deletes
|
||||||
// out to chrome.storage.sync. Returns the merged items array; does not
|
// out to chrome.storage.sync. Returns the merged items array; does not
|
||||||
// persist it — callers are responsible for saving the result locally.
|
// persist it. Callers are responsible for saving the result locally.
|
||||||
async function reconcileWithSync(items) {
|
async function reconcileWithSync(items) {
|
||||||
let syncItems, previouslySynced;
|
let syncItems, previouslySynced;
|
||||||
try {
|
try {
|
||||||
@@ -135,7 +137,7 @@ async function reconcileWithSync(items) {
|
|||||||
const localById = new Map(items.map((i) => [i.id, i]));
|
const localById = new Map(items.map((i) => [i.id, i]));
|
||||||
|
|
||||||
// Items that were synced before but are no longer in sync storage were
|
// Items that were synced before but are no longer in sync storage were
|
||||||
// deleted on another device — drop them here too.
|
// deleted on another device, so drop them here too.
|
||||||
let merged = items.filter((item) => !(previouslySynced.has(item.id) && !syncById.has(item.id)));
|
let merged = items.filter((item) => !(previouslySynced.has(item.id) && !syncById.has(item.id)));
|
||||||
|
|
||||||
// Pull in remote adds/edits.
|
// Pull in remote adds/edits.
|
||||||
@@ -143,8 +145,8 @@ async function reconcileWithSync(items) {
|
|||||||
const local = localById.get(meta.id);
|
const local = localById.get(meta.id);
|
||||||
if (!local) {
|
if (!local) {
|
||||||
// Absent locally but previously synced means *this* device deleted it;
|
// Absent locally but previously synced means *this* device deleted it;
|
||||||
// don't resurrect it from a remote copy that hasn't caught up yet —
|
// don't resurrect it from a remote copy that hasn't caught up yet.
|
||||||
// let the deletion below propagate out instead.
|
// Let the deletion below propagate out instead.
|
||||||
if (previouslySynced.has(meta.id)) continue;
|
if (previouslySynced.has(meta.id)) continue;
|
||||||
merged.push({
|
merged.push({
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
@@ -158,6 +160,7 @@ async function reconcileWithSync(items) {
|
|||||||
updatedAt: meta.updatedAt,
|
updatedAt: meta.updatedAt,
|
||||||
surfaceCount: meta.surfaceCount || 0,
|
surfaceCount: meta.surfaceCount || 0,
|
||||||
lastSurfacedAt: meta.lastSurfacedAt || 0,
|
lastSurfacedAt: meta.lastSurfacedAt || 0,
|
||||||
|
tags: meta.tags || [],
|
||||||
});
|
});
|
||||||
} else if ((meta.updatedAt || 0) > (local.updatedAt || local.dateAdded || 0)) {
|
} else if ((meta.updatedAt || 0) > (local.updatedAt || local.dateAdded || 0)) {
|
||||||
local.title = meta.title;
|
local.title = meta.title;
|
||||||
@@ -168,6 +171,7 @@ async function reconcileWithSync(items) {
|
|||||||
local.updatedAt = meta.updatedAt;
|
local.updatedAt = meta.updatedAt;
|
||||||
local.surfaceCount = meta.surfaceCount || 0;
|
local.surfaceCount = meta.surfaceCount || 0;
|
||||||
local.lastSurfacedAt = meta.lastSurfacedAt || 0;
|
local.lastSurfacedAt = meta.lastSurfacedAt || 0;
|
||||||
|
local.tags = meta.tags || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +202,7 @@ async function reconcileWithSync(items) {
|
|||||||
if (removals.length) await chrome.storage.sync.remove(removals);
|
if (removals.length) await chrome.storage.sync.remove(removals);
|
||||||
await writeSyncedIds(nextSyncedIds);
|
await writeSyncedIds(nextSyncedIds);
|
||||||
} catch {
|
} catch {
|
||||||
// Best effort — local storage remains the source of truth for this device.
|
// Best effort: local storage remains the source of truth for this device.
|
||||||
}
|
}
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
|
|||||||
Reference in New Issue
Block a user