fix(import): restore favorite + pinned on import/backup restore

A pinned entry came back unpinned after export→import. Wider than pinned:
`favorite` was WRITTEN into the export/backup/sync containers but nothing
ever read it back — not parseEntriesFromJSON, not encryptImportEntry, not
the server INSERTs. So it was a write-only field, and a restore silently
dropped both flags. `pinned` wasn't in any container at all.

Wired the full round-trip:
- containers: export + auto-backup now carry `pinned` (favorite was there).
- parseEntriesFromJSON reads both back (absent → 0, e.g. foreign CSV).
- encryptImportEntry passes them through to the body.
- server create + bulk-import INSERTs write favorite/pinned
  (GetValue<Boolean>(..., False) → 0 default). PUT still ignores them, so a
  sync update can't clobber local flags.

Sync stays device-local as decided 2026-07-11: buildSyncSnapshot now OMITS
favorite explicitly (it was dead weight before; with the passthrough live,
leaving it in would have started propagating it). Commented so the intent
isn't re-broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 16:06:05 +01:00
parent a6ba0100ee
commit 30e37b3fbd
6 changed files with 47 additions and 14 deletions
+11 -4
View File
@@ -965,10 +965,17 @@ le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
assumé device-local (purge de cache favicon). **`favorite` et `pinned`
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
2026-07-11) — comme l'avatar. `favorite` est quand même dans l'auto-backup +
export/import (round-trip local), juste pas propagé par le sync. Si un jour on
veut les synchroniser : ajouter au snapshot + bumper `updated_at` sur le toggle
+ appliquer au merge.
2026-07-11) — comme l'avatar. En revanche ils **round-trippent** par
export/import + auto-backup (restaurer une sauvegarde rend la liste telle
quelle) : container → `parseEntriesFromJSON` → `encryptImportEntry` →
INSERT create + bulk-import (`GetValue<Boolean>(…, False)` → 0 si absent,
ex. CSV étranger). **C'est l'omission dans le snapshot de sync qui les garde
device-local** — `encryptImportEntry` fait un passthrough générique, donc
les remettre dans `buildSyncSnapshot` suffirait à les propager (+ bumper
`updated_at` sur le toggle + appliquer au merge). Piège historique : le
snapshot écrivait `favorite` mais **rien ne le relisait** (ni parser, ni
body, ni INSERT) → l'export semblait le porter alors que l'import le
perdait, et `pinned` n'était nulle part.
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
+2 -2
View File
@@ -66,11 +66,11 @@ Préparer : 3 entrées `A`, `B`, `C`, dont `A` dans le dossier `Work` avec le ta
### A10 · icône / pièce jointe pendant l'édition (non-régression)
- **ACTION** : `A` ouverte + dirty → Settings → *Fetch missing* favicons (ou upload d'icône) → Save
- **ATTENDU** : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅ icon not refreshed in edit
- **ATTENDU** : l'icône survit au Save (endpoint dédié, hors payload éditeur).✅
### A10b · icône rafraîchie dans l'éditeur ouvert
- **ACTION** : `A` ouverte → Settings → *Fetch missing* / *Re-fetch all* favicons
- **ATTENDU** : l'aperçu d'icône **dans l'éditeur** se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.
- **ATTENDU** : l'aperçu d'icône **dans l'éditeur** se met à jour tout seul (pas seulement la carte). Idem après un import en mode overwrite qui apporte une icône.✅
### A11 · sync / import pendant que l'éditeur est ouvert
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
+19 -6
View File
@@ -333,7 +333,7 @@ var
LTotpSec, LTotpIv, LKind, LCf, LCfIv, LIcon, LUuid,
LTemplateEnc, LTemplateIv, LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom: Boolean;
LIconCustom, LFavorite, LPinned: Boolean;
LQ: TFDQuery;
begin
try
@@ -374,6 +374,9 @@ begin
LIcon := LBody.GetValue<string>('icon_b64', '');
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
LIconCustom := LBody.GetValue<Boolean>('icon_custom', False);
// Restore flags (import/backup carry them; sync omits them → 0).
LFavorite := LBody.GetValue<Boolean>('favorite', False);
LPinned := LBody.GetValue<Boolean>('pinned', False);
LTemplateEnc := LBody.GetValue<string>('template_enc', '');
LTemplateIv := LBody.GetValue<string>('template_iv', '');
// Caller may bring its own UUID (sync restore / import preserving
@@ -415,10 +418,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, icon_custom, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at, password_changed_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :iconcust, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
LQ.ParamByName('uid').AsInteger := LUserId;
// site/username/title/tags cleartext columns dropped — only *_enc written.
BindNullable(LQ, 'uenc', LUserEnc);
@@ -457,6 +460,10 @@ begin
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;
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
else LQ.ParamByName('fav').AsInteger := 0;
if LPinned then LQ.ParamByName('pin').AsInteger := 1
else LQ.ParamByName('pin').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid;
@@ -1208,7 +1215,7 @@ var
LTotpIv, LNow, LKind, LCf, LCfIv, LIcon, LTemplateEnc, LTemplateIv, LUuid,
LCreatedAt, LUpdatedAt,
LSiteEnc, LSiteIv, LTitleEnc, LTitleIv, LTagsEnc, LTagsIv: string;
LIconCustom: Boolean;
LIconCustom, LFavorite, LPinned: Boolean;
LQ, LTomb: TFDQuery;
begin
try
@@ -1265,10 +1272,10 @@ begin
' site_enc, site_iv, title_enc, title_iv, tags_enc, tags_iv, ' +
' encrypted_password, iv, encryption_method, ' +
' folder, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
' icon_b64, icon_custom, template_enc, template_iv, uuid, created_at, updated_at) ' +
' icon_b64, icon_custom, favorite, pinned, template_enc, template_iv, uuid, created_at, updated_at) ' +
'VALUES (:uid, :uenc, :uiv, :senc, :siv, :tenc, :tiv2, :genc, :giv, ' +
' :e, :i, ''client'', :f, :ts, :tiv, :k, ' +
' :cf, :cfiv, :ic, :iconcust, :tplenc, :tpliv, :uuid, :c, :c2)';
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :tplenc, :tpliv, :uuid, :c, :c2)';
// Declare optional param types ONCE — the prepared statement is
// reused across every imported entry, and FireDAC needs the
// type set before the first .Clear call would otherwise fail
@@ -1317,6 +1324,8 @@ begin
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', '');
LIconCustom := LEntry.GetValue<Boolean>('icon_custom', False);
LFavorite := LEntry.GetValue<Boolean>('favorite', False);
LPinned := LEntry.GetValue<Boolean>('pinned', False);
LTemplateEnc := LEntry.GetValue<string>('template_enc', '');
LTemplateIv := LEntry.GetValue<string>('template_iv', '');
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
@@ -1362,6 +1371,10 @@ begin
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;
if LFavorite then LQ.ParamByName('fav').AsInteger := 1
else LQ.ParamByName('fav').AsInteger := 0;
if LPinned then LQ.ParamByName('pin').AsInteger := 1
else LQ.ParamByName('pin').AsInteger := 0;
BindNullable(LQ, 'tplenc', LTemplateEnc);
BindNullable(LQ, 'tpliv', LTemplateIv);
LQ.ParamByName('uuid').AsString := LUuid;
+1 -1
View File
@@ -207,7 +207,7 @@ async function runAutoBackupNow(silent) {
uuid: e.uuid || '',
site: e.site, title: e.title || '', username: e.username,
password: plain, folder: e.folder, tags: parseTags(e.tags),
favorite: !!e.favorite, totp_secret: plainTotp,
favorite: !!e.favorite, pinned: !!e.pinned, totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
custom_fields: plainCustom,
+10
View File
@@ -415,6 +415,10 @@ function parseEntriesFromJSON(text) {
attachments: atts,
icon_b64: String(e.icon_b64 || '').trim(),
icon_custom: e.icon_custom ? 1 : 0,
// Flags are part of the vault's state: a restore should give the
// list back the way it was. Absent (foreign CSV) → 0.
favorite: e.favorite ? 1 : 0,
pinned: e.pinned ? 1 : 0,
created_at: String(e.created_at || '').trim(),
updated_at: String(e.updated_at || '').trim(),
});
@@ -489,6 +493,11 @@ async function encryptImportEntry(plain) {
custom_fields_iv: cfIv,
icon_b64: plain.icon_b64 || '',
icon_custom: plain.icon_custom ? 1 : 0,
// Passthrough: present in the export/backup container (restore keeps
// the flags), absent from the sync snapshot (favorite/pinned stay
// device-local by design) → undefined there, so the server writes 0.
favorite: plain.favorite ? 1 : 0,
pinned: plain.pinned ? 1 : 0,
template: plain.template || '',
// Preserve original timestamps on restore — bulk-import falls back
// to now only when these are absent (foreign CSV imports).
@@ -907,6 +916,7 @@ async function doExport() {
folder: e.folder,
tags: parseTags(e.tags),
favorite: !!e.favorite,
pinned: !!e.pinned,
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',
+4 -1
View File
@@ -252,7 +252,10 @@ async function buildSyncSnapshot() {
password: plain === '[ERROR]' ? '' : plain,
folder: e.folder || 'All',
tags: parseTags(e.tags),
favorite: !!e.favorite,
// favorite / pinned deliberately OMITTED — device-local by design
// (decided 2026-07-11, like the avatar). encryptImportEntry passes
// through whatever the container holds, so leaving them out here
// is what keeps them from propagating. Adding them back = they sync.
totp_secret: plainTotp,
kind: e.kind || 'login',
template: e.template || '',