diff --git a/CLAUDE.md b/CLAUDE.md index a647dbc..77e68d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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)` - Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet - (qui re-PUT le password chiffré) -- Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache") + (qui re-PUT le password chiffré). Body `{icon_b64, custom:bool}`. +- **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 `` si `icon_b64`, sinon fallback aux initials. `onerror` repasse aux initials si la data URI est corrompue diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 11296c5..f9836bc 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -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('icon_b64', ''); + // 1 = user upload (preserve from auto fetch/clear), 0 = fetched/cleared. + LIconCustom := LBody.GetValue('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 diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 79440b4..8bb0cec 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -385,6 +385,11 @@ begin // 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. 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 // the sidebar "Recent" view. NULL = never accessed since the column // landed (legacy rows). diff --git a/js/app.favicon.js b/js/app.favicon.js index bfdaf5b..f9f382c 100644 --- a/js/app.favicon.js +++ b/js/app.favicon.js @@ -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'); } diff --git a/js/app.import.js b/js/app.import.js index 780f64b..73f61c5 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -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 */ } } diff --git a/js/app.js b/js/app.js index f29ab87..f58f865 100644 --- a/js/app.js +++ b/js/app.js @@ -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).