feat(icons): propagate icon_custom through export / import / sync

Follow-up to 18fe13a: the custom-icon flag now survives round-trips instead
of resetting to fetched on a restore.

- Containers write icon_custom: manual export (doExport), auto-backup, sync
  snapshot; parseEntriesFromJSON carries it back (default 0 when absent, e.g.
  foreign CSV).
- encryptImportEntry ships it in the body (shared by bulk-import + sync
  create/update).
- Server create + bulk-import INSERTs read icon_custom
  (GetValue<Boolean>('icon_custom', False) → 0 default). An empty icon always
  forces icon_custom=0.

Now a custom icon stays custom after export→import and across sync — it won't
be silently reclassified as cache and clobbered by Re-fetch-all on the other
device.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 15:21:04 +01:00
parent 18fe13a861
commit a6ba0100ee
5 changed files with 24 additions and 8 deletions
+6 -4
View File
@@ -270,10 +270,12 @@ La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS).
skippe `icon_custom` (même sur "Re-fetch all"), `DELETE /entries/icons/all` 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 filtre `COALESCE(icon_custom,0)=0`. Seul l'utilisateur change/retire une
custom, par entrée (boutons Upload/Remove du slideover). `GET /entries` custom, par entrée (boutons Upload/Remove du slideover). `GET /entries`
echoe `icon_custom`. **Non propagé** par import bulk / sync / export echoe `icon_custom`. **Propagé** de bout en bout : export manuel,
(l'INSERT bulk ne lit pas le flag) → une icône restaurée sur un device auto-backup, snapshot de sync l'écrivent ; `encryptImportEntry` le porte
neuf compte comme fetchée ; assumé (les customs sont surtout des sites dans le body ; les INSERT **create + bulk-import** le lisent
self-hosted, que DDG ne re-fetch pas de toute façon). (`GetValue<Boolean>('icon_custom', False)` → 0 par défaut si absent, ex.
CSV étranger). Round-trip export→import et sync préservent donc la
provenance. Une icône vide (`icon_b64=''`) force toujours `icon_custom=0`.
- Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear - Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear
cache") — préserve les uploads custom 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
+13 -4
View File
@@ -333,6 +333,7 @@ var
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid, LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt, LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -371,6 +372,8 @@ begin
LCf := LBody.GetValue<string>('custom_fields', ''); LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', ''); LIcon := LBody.GetValue<string>('icon_b64', '');
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
LIconCustom := LBody.GetValue<Boolean>('icon_custom', False);
LTemplateEnc := LBody.GetValue<string>('template_enc', ''); LTemplateEnc := LBody.GetValue<string>('template_enc', '');
LTemplateIv := LBody.GetValue<string>('template_iv', ''); LTemplateIv := LBody.GetValue<string>('template_iv', '');
// Caller may bring its own UUID (sync restore / import preserving // Caller may bring its own UUID (sync restore / import preserving
@@ -412,10 +415,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' + ' icon_b64, icon_custom, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2, :c)'; ' :cf, :cfiv, :ic, :iconcust, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
// site/username/title/tags cleartext columns dropped — only *_enc written. // site/username/title/tags cleartext columns dropped — only *_enc written.
BindNullable(LQ, 'uenc', LUserEnc); BindNullable(LQ, 'uenc', LUserEnc);
@@ -452,6 +455,8 @@ begin
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('ic').DataType := ftMemo; LQ.ParamByName('ic').DataType := ftMemo;
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
else LQ.ParamByName('iconcust').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc); BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv); BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
@@ -1203,6 +1208,7 @@ var
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid, LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
LCreatedAt, LUpdatedAt, LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string; LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom: Boolean;
LQ, LTomb: TFDQuery; LQ, LTomb: TFDQuery;
begin begin
try try
@@ -1259,10 +1265,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' + ' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' + ' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + ' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, template_enc, template_iv, uuid, created_at, updated_at) ' + ' icon_b64, icon_custom, template_enc, template_iv, uuid, created_at, updated_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' + 'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' + ' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :tplenc, :tpliv, :uuid, :c, :c2)'; ' :cf, :cfiv, :ic, :iconcust, :tplenc, :tpliv, :uuid, :c, :c2)';
// Declare optional param types ONCE — the prepared statement is // Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the // reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail // type set before the first .Clear call would otherwise fail
@@ -1310,6 +1316,7 @@ begin
LCf := LEntry.GetValue<string>('custom_fields', ''); LCf := LEntry.GetValue<string>('custom_fields', '');
LCfIv := LEntry.GetValue<string>('custom_fields_iv', ''); LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', ''); LIcon := LEntry.GetValue<string>('icon_b64', '');
LIconCustom := LEntry.GetValue<Boolean>('icon_custom', False);
LTemplateEnc := LEntry.GetValue<string>('template_enc', ''); LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
LTemplateIv := LEntry.GetValue<string>('template_iv', ''); LTemplateIv := LEntry.GetValue<string>('template_iv', '');
LUuid := Trim(LEntry.GetValue<string>('uuid', '')); LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
@@ -1353,6 +1360,8 @@ begin
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf; if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
else LQ.ParamByName('iconcust').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc); BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv); BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('uuid').AsString := LUuid;
+1
View File
@@ -213,6 +213,7 @@ async function runAutoBackupNow(silent) {
custom_fields: plainCustom, custom_fields: plainCustom,
attachments: attachments, attachments: attachments,
icon_b64: e.icon_b64 || '', icon_b64: e.icon_b64 || '',
icon_custom: e.icon_custom ? 1 : 0,
created_at: e.created_at, updated_at: e.updated_at, created_at: e.created_at, updated_at: e.updated_at,
}); });
} }
+3
View File
@@ -414,6 +414,7 @@ function parseEntriesFromJSON(text) {
custom_fields: cf, custom_fields: cf,
attachments: atts, attachments: atts,
icon_b64: String(e.icon_b64 || '').trim(), icon_b64: String(e.icon_b64 || '').trim(),
icon_custom: e.icon_custom ? 1 : 0,
created_at: String(e.created_at || '').trim(), created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').trim(), updated_at: String(e.updated_at || '').trim(),
}); });
@@ -487,6 +488,7 @@ async function encryptImportEntry(plain) {
custom_fields: cfEnc, custom_fields: cfEnc,
custom_fields_iv: cfIv, custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '', icon_b64: plain.icon_b64 || '',
icon_custom: plain.icon_custom ? 1 : 0,
template: plain.template || '', template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back // Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports). // to now only when these are absent (foreign CSV imports).
@@ -911,6 +913,7 @@ async function doExport() {
custom_fields: plainCustom, custom_fields: plainCustom,
attachments: attachments, attachments: attachments,
icon_b64: e.icon_b64 || '', icon_b64: e.icon_b64 || '',
icon_custom: e.icon_custom ? 1 : 0,
created_at: e.created_at, created_at: e.created_at,
updated_at: e.updated_at, updated_at: e.updated_at,
}); });
+1
View File
@@ -259,6 +259,7 @@ async function buildSyncSnapshot() {
custom_fields: plainCustom, custom_fields: plainCustom,
attachments, attachments,
icon_b64: e.icon_b64 || '', icon_b64: e.icon_b64 || '',
icon_custom: e.icon_custom ? 1 : 0,
created_at: e.created_at, created_at: e.created_at,
updated_at: e.updated_at, updated_at: e.updated_at,
}); });