diff --git a/CLAUDE.md b/CLAUDE.md index 9afe28c..fa4428e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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(…, 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` / diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index afe7e06..1778807 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -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 diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index e153c1b..eb97652 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -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('icon_b64', ''); // Icon provenance (0 = fetched cache, 1 = user upload). Absent → 0. LIconCustom := LBody.GetValue('icon_custom', False); + // Restore flags (import/backup carry them; sync omits them → 0). + LFavorite := LBody.GetValue('favorite', False); + LPinned := LBody.GetValue('pinned', False); LTemplateEnc := LBody.GetValue('template_enc', ''); LTemplateIv := LBody.GetValue('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('custom_fields_iv', ''); LIcon := LEntry.GetValue('icon_b64', ''); LIconCustom := LEntry.GetValue('icon_custom', False); + LFavorite := LEntry.GetValue('favorite', False); + LPinned := LEntry.GetValue('pinned', False); LTemplateEnc := LEntry.GetValue('template_enc', ''); LTemplateIv := LEntry.GetValue('template_iv', ''); LUuid := Trim(LEntry.GetValue('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; diff --git a/js/app.backup.js b/js/app.backup.js index b790b47..366ca8f 100644 --- a/js/app.backup.js +++ b/js/app.backup.js @@ -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, diff --git a/js/app.import.js b/js/app.import.js index 2cc90d5..8603c78 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -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 || '', diff --git a/js/app.sync.js b/js/app.sync.js index 404a762..b0a912c 100644 --- a/js/app.sync.js +++ b/js/app.sync.js @@ -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 || '',