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:
@@ -262,8 +262,20 @@ La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).
|
|||||||
`Bridge.onFaviconResult(reqId, host, dataUri)`
|
`Bridge.onFaviconResult(reqId, host, dataUri)`
|
||||||
- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint
|
- Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint
|
||||||
dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet
|
dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet
|
||||||
(qui re-PUT le password chiffré)
|
(qui re-PUT le password chiffré). Body `{icon_b64, custom:bool}`.
|
||||||
- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache")
|
- **Provenance `icon_custom INTEGER`** (0 = favicon auto-fetchée = cache
|
||||||
|
jetable, 1 = upload utilisateur). **Invariant : une icône custom n'est
|
||||||
|
JAMAIS touchée par une action automatique.** `saveEntryIcon(id, uri,
|
||||||
|
isCustom)` pose le flag (upload→1, fetch/remove→0). `backfillFavicons`
|
||||||
|
skippe `icon_custom` (même sur "Re-fetch all"), `DELETE /entries/icons/all`
|
||||||
|
filtre `COALESCE(icon_custom,0)=0`. Seul l'utilisateur change/retire une
|
||||||
|
custom, par entrée (boutons Upload/Remove du slideover). `GET /entries`
|
||||||
|
echoe `icon_custom`. **Non propagé** par import bulk / sync / export
|
||||||
|
(l'INSERT bulk ne lit pas le flag) → une icône restaurée sur un device
|
||||||
|
neuf compte comme fetchée ; assumé (les customs sont surtout des sites
|
||||||
|
self-hosted, que DDG ne re-fetch pas de toute façon).
|
||||||
|
- Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear
|
||||||
|
cache") — préserve les uploads custom
|
||||||
- Render : `entry-avatar` contient `<img class="entry-avatar-img">` si
|
- Render : `entry-avatar` contient `<img class="entry-avatar-img">` si
|
||||||
`icon_b64`, sinon fallback aux initials. `onerror` repasse aux
|
`icon_b64`, sinon fallback aux initials. `onerror` repasse aux
|
||||||
initials si la data URI est corrompue
|
initials si la data URI est corrompue
|
||||||
|
|||||||
@@ -164,6 +164,10 @@ begin
|
|||||||
LObj.AddPair('icon_b64', TJSONNull.Create)
|
LObj.AddPair('icon_b64', TJSONNull.Create)
|
||||||
else
|
else
|
||||||
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
|
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
|
||||||
|
// Icon provenance so the client knows which icons the automatic
|
||||||
|
// fetch/clear may touch (0) vs a user upload to preserve (1).
|
||||||
|
LObj.AddPair('icon_custom',
|
||||||
|
TJSONBool.Create(LQ.FieldByName('icon_custom').AsInteger = 1));
|
||||||
// Entry kind. Legacy / unset → 'login'.
|
// Entry kind. Legacy / unset → 'login'.
|
||||||
var LKindVal := LQ.FieldByName('kind').AsString;
|
var LKindVal := LQ.FieldByName('kind').AsString;
|
||||||
if LKindVal = '' then LKindVal := 'login';
|
if LKindVal = '' then LKindVal := 'login';
|
||||||
@@ -915,6 +919,7 @@ var
|
|||||||
LUserId, LId: Integer;
|
LUserId, LId: Integer;
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LIcon: string;
|
LIcon: string;
|
||||||
|
LIconCustom: Boolean;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -934,6 +939,8 @@ begin
|
|||||||
LBody := TJSONHelper.ReadBody(ARequest);
|
LBody := TJSONHelper.ReadBody(ARequest);
|
||||||
try
|
try
|
||||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||||
|
// 1 = user upload (preserve from auto fetch/clear), 0 = fetched/cleared.
|
||||||
|
LIconCustom := LBody.GetValue<Boolean>('custom', False);
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
@@ -958,11 +965,15 @@ begin
|
|||||||
// Bump updated_at (UTC) so the icon change wins last-write-wins on
|
// Bump updated_at (UTC) so the icon change wins last-write-wins on
|
||||||
// sync — without it the new icon_b64 rides in the snapshot but other
|
// sync — without it the new icon_b64 rides in the snapshot but other
|
||||||
// devices skip it (timestamp unchanged → "not newer").
|
// devices skip it (timestamp unchanged → "not newer").
|
||||||
'UPDATE vault_entries SET icon_b64 = :ic, updated_at = datetime(''now'') ' +
|
'UPDATE vault_entries SET icon_b64 = :ic, icon_custom = :cust, ' +
|
||||||
|
' updated_at = datetime(''now'') ' +
|
||||||
'WHERE id=:id AND user_id=:uid';
|
'WHERE id=:id AND user_id=:uid';
|
||||||
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
|
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
|
||||||
if LIcon = '' then LQ.ParamByName('ic').Clear
|
if LIcon = '' then LQ.ParamByName('ic').Clear
|
||||||
else LQ.ParamByName('ic').Value := LIcon;
|
else LQ.ParamByName('ic').Value := LIcon;
|
||||||
|
// A cleared icon (LIcon='') is never "custom" regardless of the flag.
|
||||||
|
if (LIcon <> '') and LIconCustom then LQ.ParamByName('cust').AsInteger := 1
|
||||||
|
else LQ.ParamByName('cust').AsInteger := 0;
|
||||||
LQ.ParamByName('id').AsInteger := LId;
|
LQ.ParamByName('id').AsInteger := LId;
|
||||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
@@ -998,7 +1009,10 @@ begin
|
|||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
LQ.SQL.Text :=
|
LQ.SQL.Text :=
|
||||||
'UPDATE vault_entries SET icon_b64 = NULL WHERE user_id = :uid';
|
// Only the auto-fetched cache (icon_custom=0). User uploads (=1) are
|
||||||
|
// deliberate and survive — removed per entry, never by bulk clear.
|
||||||
|
'UPDATE vault_entries SET icon_b64 = NULL ' +
|
||||||
|
'WHERE user_id = :uid AND COALESCE(icon_custom, 0) = 0';
|
||||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -385,6 +385,11 @@ begin
|
|||||||
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
||||||
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
||||||
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
|
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
|
||||||
|
// Icon provenance: 0 = auto-fetched favicon (part of the disposable cache),
|
||||||
|
// 1 = user-uploaded custom icon. Clear-cache and Re-fetch-all only touch
|
||||||
|
// icon_custom=0 so a deliberate upload is never clobbered by an automatic
|
||||||
|
// action — only the user changes/removes it, per entry.
|
||||||
|
AddColumnIfMissing('vault_entries', 'icon_custom', 'INTEGER');
|
||||||
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers
|
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers
|
||||||
// the sidebar "Recent" view. NULL = never accessed since the column
|
// the sidebar "Recent" view. NULL = never accessed since the column
|
||||||
// landed (legacy rows).
|
// landed (legacy rows).
|
||||||
|
|||||||
+20
-4
@@ -38,12 +38,15 @@ function faviconHost(siteRaw) {
|
|||||||
// Save the icon for one entry via the dedicated endpoint (no full PUT,
|
// 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
|
// no re-encryption). Fire-and-forget: failures are silent so a flaky
|
||||||
// network doesn't break the user's flow.
|
// 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 {
|
try {
|
||||||
const r = await fetch(API + '/entries/' + entryId + '/icon', {
|
const r = await fetch(API + '/entries/' + entryId + '/icon', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
body: JSON.stringify({ icon_b64: dataUri || '' }),
|
body: JSON.stringify({ icon_b64: dataUri || '', custom: !!isCustom }),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
// Surface the server's reason so silent persistence failures
|
// Surface the server's reason so silent persistence failures
|
||||||
@@ -54,6 +57,10 @@ async function saveEntryIcon(entryId, dataUri) {
|
|||||||
toast('Icon NOT saved: ' + msg, 'error');
|
toast('Icon NOT saved: ' + msg, 'error');
|
||||||
return;
|
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,
|
// Choke point for every icon write (favicon backfill, upload, remove,
|
||||||
// import overwrite) — refresh the editor if it's open on this entry.
|
// import overwrite) — refresh the editor if it's open on this entry.
|
||||||
editorAbsorbChange(entryId, { icon: dataUri || '' });
|
editorAbsorbChange(entryId, { icon: dataUri || '' });
|
||||||
@@ -89,7 +96,9 @@ async function backfillFavicons(force) {
|
|||||||
const all = state.entries;
|
const all = state.entries;
|
||||||
const eligible = all.filter(e => faviconHost(e.site));
|
const eligible = all.filter(e => faviconHost(e.site));
|
||||||
const skipped = all.length - eligible.length;
|
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 (targets.length === 0) {
|
||||||
if (skipped > 0) {
|
if (skipped > 0) {
|
||||||
toast('No icons to fetch — ' + skipped +
|
toast('No icons to fetch — ' + skipped +
|
||||||
@@ -122,7 +131,14 @@ async function clearAllFavicons() {
|
|||||||
toast('Failed to clear icons', 'error');
|
toast('Failed to clear icons', 'error');
|
||||||
return;
|
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();
|
render();
|
||||||
toast('Cached icons cleared');
|
toast('Cached icons cleared');
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -716,7 +716,7 @@ async function doImport() {
|
|||||||
// PUT ignores icon_b64 (dedicated endpoint owns it), so
|
// PUT ignores icon_b64 (dedicated endpoint owns it), so
|
||||||
// restore the file's icon separately — else overwriting
|
// restore the file's icon separately — else overwriting
|
||||||
// an entry whose icon was cleared never brings it back.
|
// 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++;
|
overwritten++;
|
||||||
} catch (_) { /* skip the single row on failure */ }
|
} catch (_) { /* skip the single row on failure */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4319,8 +4319,9 @@ function soIconField(entry) {
|
|||||||
reader.onload = async ev => {
|
reader.onload = async ev => {
|
||||||
const dataUri = ev.target.result; // 'data:image/...;base64,...'
|
const dataUri = ev.target.result; // 'data:image/...;base64,...'
|
||||||
entry.icon_b64 = dataUri;
|
entry.icon_b64 = dataUri;
|
||||||
|
entry.icon_custom = 1;
|
||||||
paintPreview(dataUri);
|
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.
|
// Refresh entry cards / health view so they show the new icon too.
|
||||||
render();
|
render();
|
||||||
toast('Custom icon set');
|
toast('Custom icon set');
|
||||||
@@ -4332,8 +4333,9 @@ function soIconField(entry) {
|
|||||||
'Remove');
|
'Remove');
|
||||||
resetBtn.addEventListener('click', async () => {
|
resetBtn.addEventListener('click', async () => {
|
||||||
entry.icon_b64 = null;
|
entry.icon_b64 = null;
|
||||||
|
entry.icon_custom = 0;
|
||||||
paintPreview(null);
|
paintPreview(null);
|
||||||
await saveEntryIcon(entry.id, '');
|
await saveEntryIcon(entry.id, '', false);
|
||||||
render();
|
render();
|
||||||
toast('Icon removed');
|
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
|
// Copy attachments. The source's encrypted blobs are keyed to the
|
||||||
// current vault key, so we just download → re-upload (no re-encrypt
|
// current vault key, so we just download → re-upload (no re-encrypt
|
||||||
// needed; same key on both sides).
|
// needed; same key on both sides).
|
||||||
|
|||||||
Reference in New Issue
Block a user