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:
@@ -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`
|
assumé device-local (purge de cache favicon). **`favorite` et `pinned`
|
||||||
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
|
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
|
||||||
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
|
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 +
|
2026-07-11) — comme l'avatar. En revanche ils **round-trippent** par
|
||||||
export/import (round-trip local), juste pas propagé par le sync. Si un jour on
|
export/import + auto-backup (restaurer une sauvegarde rend la liste telle
|
||||||
veut les synchroniser : ajouter au snapshot + bumper `updated_at` sur le toggle
|
quelle) : container → `parseEntriesFromJSON` → `encryptImportEntry` →
|
||||||
+ appliquer au merge.
|
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`
|
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
|
||||||
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
|
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
|
||||||
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
|
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
|
||||||
|
|||||||
+2
-2
@@ -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)
|
### 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
|
- **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
|
### A10b · icône rafraîchie dans l'éditeur ouvert
|
||||||
- **ACTION** : `A` ouverte → Settings → *Fetch missing* / *Re-fetch all* favicons
|
- **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
|
### 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
|
- **ACTION** : `A` ouverte + dirty → lancer *Sync now* (ou importer un JSON en mode overwrite) qui met `A` à jour → Save
|
||||||
|
|||||||
@@ -333,7 +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;
|
LIconCustom, LFavorite, LPinned: Boolean;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -374,6 +374,9 @@ begin
|
|||||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||||
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
|
// Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0.
|
||||||
LIconCustom := LBody.GetValue<Boolean>('icon_custom', False);
|
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', '');
|
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
|
||||||
@@ -415,10 +418,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, 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, ' +
|
'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, :iconcust, :tplenc, :tpliv, :uuid, :c, :c2, :c)';
|
' :cf, :cfiv, :ic, :iconcust, :fav, :pin, :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);
|
||||||
@@ -457,6 +460,10 @@ begin
|
|||||||
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
|
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
|
||||||
else LQ.ParamByName('iconcust').AsInteger := 0;
|
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, 'tplenc', LTemplateEnc);
|
||||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||||
LQ.ParamByName('uuid').AsString := LUuid;
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
@@ -1208,7 +1215,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;
|
LIconCustom, LFavorite, LPinned: Boolean;
|
||||||
LQ, LTomb: TFDQuery;
|
LQ, LTomb: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -1265,10 +1272,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, 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, ' +
|
'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, :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
|
// 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
|
||||||
@@ -1317,6 +1324,8 @@ begin
|
|||||||
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);
|
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', '');
|
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', ''));
|
||||||
@@ -1362,6 +1371,10 @@ begin
|
|||||||
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
|
if (LIcon <> '') and LIconCustom then LQ.ParamByName('iconcust').AsInteger := 1
|
||||||
else LQ.ParamByName('iconcust').AsInteger := 0;
|
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, 'tplenc', LTemplateEnc);
|
||||||
BindNullable(LQ, 'tpliv', LTemplateIv);
|
BindNullable(LQ, 'tpliv', LTemplateIv);
|
||||||
LQ.ParamByName('uuid').AsString := LUuid;
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
|
|||||||
+1
-1
@@ -207,7 +207,7 @@ async function runAutoBackupNow(silent) {
|
|||||||
uuid: e.uuid || '',
|
uuid: e.uuid || '',
|
||||||
site: e.site, title: e.title || '', username: e.username,
|
site: e.site, title: e.title || '', username: e.username,
|
||||||
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
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',
|
kind: e.kind || 'login',
|
||||||
template: e.template || '',
|
template: e.template || '',
|
||||||
custom_fields: plainCustom,
|
custom_fields: plainCustom,
|
||||||
|
|||||||
@@ -415,6 +415,10 @@ function parseEntriesFromJSON(text) {
|
|||||||
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,
|
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(),
|
created_at: String(e.created_at || '').trim(),
|
||||||
updated_at: String(e.updated_at || '').trim(),
|
updated_at: String(e.updated_at || '').trim(),
|
||||||
});
|
});
|
||||||
@@ -489,6 +493,11 @@ async function encryptImportEntry(plain) {
|
|||||||
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,
|
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 || '',
|
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).
|
||||||
@@ -907,6 +916,7 @@ async function doExport() {
|
|||||||
folder: e.folder,
|
folder: e.folder,
|
||||||
tags: parseTags(e.tags),
|
tags: parseTags(e.tags),
|
||||||
favorite: !!e.favorite,
|
favorite: !!e.favorite,
|
||||||
|
pinned: !!e.pinned,
|
||||||
totp_secret: plainTotp,
|
totp_secret: plainTotp,
|
||||||
kind: e.kind || 'login',
|
kind: e.kind || 'login',
|
||||||
template: e.template || '',
|
template: e.template || '',
|
||||||
|
|||||||
+4
-1
@@ -252,7 +252,10 @@ async function buildSyncSnapshot() {
|
|||||||
password: plain === '[ERROR]' ? '' : plain,
|
password: plain === '[ERROR]' ? '' : plain,
|
||||||
folder: e.folder || 'All',
|
folder: e.folder || 'All',
|
||||||
tags: parseTags(e.tags),
|
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,
|
totp_secret: plainTotp,
|
||||||
kind: e.kind || 'login',
|
kind: e.kind || 'login',
|
||||||
template: e.template || '',
|
template: e.template || '',
|
||||||
|
|||||||
Reference in New Issue
Block a user