feat(icons): preserve user-uploaded icons from bulk fetch/clear

Auto-fetched favicons and user uploads shared one icon_b64 column, so
"Clear cache" wiped custom uploads and "Re-fetch all" overwrote them — data
loss the user can't undo (a custom icon exists precisely because DDG can't
fetch that site).

New icon_custom flag (0=fetched cache, 1=user upload). Invariant: an
automatic action never touches a custom icon; only the user changes/removes
it per entry.

- Schema: icon_custom INTEGER on vault_entries.
- POST /entries/{id}/icon takes {custom}; GET /entries echoes icon_custom;
  DELETE /entries/icons/all filters COALESCE(icon_custom,0)=0.
- saveEntryIcon(id, uri, isCustom): upload→true, fetch/remove→false.
- backfillFavicons skips icon_custom even on force (Re-fetch all).
- clearAllFavicons clears only non-custom locally + refreshes the open editor
  (the icon preview didn't update on clear before).
- duplicate re-stamps a copied custom icon; import-overwrite carries the flag.

Not propagated across bulk-import / sync (the bulk INSERT doesn't read it) —
a restored icon counts as fetched. Documented; custom icons are typically
self-hosted sites DDG won't re-fetch anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 15:11:25 +01:00
parent f45b96d147
commit 18fe13a861
6 changed files with 67 additions and 11 deletions
+20 -4
View File
@@ -38,12 +38,15 @@ function faviconHost(siteRaw) {
// Save the icon for one entry via the dedicated endpoint (no full PUT,
// no re-encryption). Fire-and-forget: failures are silent so a flaky
// network doesn't break the user's flow.
async function saveEntryIcon(entryId, dataUri) {
// isCustom = true for a user upload (preserved from automatic fetch/clear),
// false for an auto-fetched favicon or a clear. The server stores it in
// icon_custom so bulk fetch/clear can skip deliberate uploads.
async function saveEntryIcon(entryId, dataUri, isCustom) {
try {
const r = await fetch(API + '/entries/' + entryId + '/icon', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ icon_b64: dataUri || '' }),
body: JSON.stringify({ icon_b64: dataUri || '', custom: !!isCustom }),
});
if (!r.ok) {
// Surface the server's reason so silent persistence failures
@@ -54,6 +57,10 @@ async function saveEntryIcon(entryId, dataUri) {
toast('Icon NOT saved: ' + msg, 'error');
return;
}
// Keep the in-memory row's provenance in sync so backfill / clear
// filters see the right value before the next reload.
const ent = state.entries.find(x => x.id === entryId);
if (ent) ent.icon_custom = (dataUri && isCustom) ? 1 : 0;
// Choke point for every icon write (favicon backfill, upload, remove,
// import overwrite) — refresh the editor if it's open on this entry.
editorAbsorbChange(entryId, { icon: dataUri || '' });
@@ -89,7 +96,9 @@ async function backfillFavicons(force) {
const all = state.entries;
const eligible = all.filter(e => faviconHost(e.site));
const skipped = all.length - eligible.length;
const targets = eligible.filter(e => force || !e.icon_b64);
// Never overwrite a user upload — not even on "Re-fetch all". Only the
// user changes a custom icon, per entry. Missing (no icon) still fetches.
const targets = eligible.filter(e => !e.icon_custom && (force || !e.icon_b64));
if (targets.length === 0) {
if (skipped > 0) {
toast('No icons to fetch — ' + skipped +
@@ -122,7 +131,14 @@ async function clearAllFavicons() {
toast('Failed to clear icons', 'error');
return;
}
state.entries.forEach(e => { e.icon_b64 = null; });
// Only the auto-fetched cache is cleared locally — user uploads (custom)
// are preserved, mirroring the server's WHERE icon_custom = 0.
state.entries.forEach(e => { if (!e.icon_custom) e.icon_b64 = null; });
// Refresh the open editor's icon preview if it lost its icon.
if (soState && soState.id != null) {
const ent = state.entries.find(x => x.id === soState.id);
if (ent && !ent.icon_custom) editorAbsorbChange(ent.id, { icon: '' });
}
render();
toast('Cached icons cleared');
}
+1 -1
View File
@@ -716,7 +716,7 @@ async function doImport() {
// PUT ignores icon_b64 (dedicated endpoint owns it), so
// restore the file's icon separately — else overwriting
// an entry whose icon was cleared never brings it back.
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64);
if (src.icon_b64) await saveEntryIcon(local.id, src.icon_b64, !!src.icon_custom);
overwritten++;
} catch (_) { /* skip the single row on failure */ }
}
+11 -2
View File
@@ -4319,8 +4319,9 @@ function soIconField(entry) {
reader.onload = async ev => {
const dataUri = ev.target.result; // 'data:image/...;base64,...'
entry.icon_b64 = dataUri;
entry.icon_custom = 1;
paintPreview(dataUri);
await saveEntryIcon(entry.id, dataUri);
await saveEntryIcon(entry.id, dataUri, true); // user upload → custom
// Refresh entry cards / health view so they show the new icon too.
render();
toast('Custom icon set');
@@ -4332,8 +4333,9 @@ function soIconField(entry) {
'Remove');
resetBtn.addEventListener('click', async () => {
entry.icon_b64 = null;
entry.icon_custom = 0;
paintPreview(null);
await saveEntryIcon(entry.id, '');
await saveEntryIcon(entry.id, '', false);
render();
toast('Icon removed');
});
@@ -5113,6 +5115,13 @@ async function duplicateEntry(entry) {
})),
});
// The create body sets icon_b64 but not provenance (defaults to
// fetched). If the source was a user upload, re-stamp the copy as
// custom so it's preserved from bulk fetch/clear like the original.
if (r && typeof r.id === 'number' && entry.icon_custom && entry.icon_b64) {
await saveEntryIcon(r.id, entry.icon_b64, true);
}
// Copy attachments. The source's encrypted blobs are keyed to the
// current vault key, so we just download → re-upload (no re-encrypt
// needed; same key on both sides).