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
+16 -2
View File
@@ -164,6 +164,10 @@ begin
LObj.AddPair('icon_b64', TJSONNull.Create)
else
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'.
var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login';
@@ -915,6 +919,7 @@ var
LUserId, LId: Integer;
LBody: TJSONObject;
LIcon: string;
LIconCustom: Boolean;
LQ: TFDQuery;
begin
try
@@ -934,6 +939,8 @@ begin
LBody := TJSONHelper.ReadBody(ARequest);
try
LIcon := LBody.GetValue<string>('icon_b64', '');
// 1 = user upload (preserve from auto fetch/clear), 0 = fetched/cleared.
LIconCustom := LBody.GetValue<Boolean>('custom', False);
finally
LBody.Free;
end;
@@ -958,11 +965,15 @@ begin
// 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
// 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';
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
if LIcon = '' then LQ.ParamByName('ic').Clear
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('uid').AsInteger := LUserId;
LQ.ExecSQL;
@@ -998,7 +1009,10 @@ begin
try
LQ.Connection := DB.Connection;
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.ExecSQL;
finally