diff --git a/android-client/PLAN.md b/android-client/PLAN.md index bfd320f..61c7946 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -670,7 +670,19 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import. Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis, sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre. - **Reste : supprimer**, qui exige les tombstones. + **Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose + la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la + ligne seule ne supprime pas — le poste tient toujours la sienne, et le + prochain tirage la ramènerait comme une entrée qu'on n'a jamais eue. Et + l'heure compte : l'arbitrage du poste garde une entrée modifiée *après* la + suppression plutôt que de la tuer une seconde fois. + + **Sans retour depuis le téléphone**, et c'est délibéré : la corbeille du + poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation, donc + une suppression qui s'y arrêterait ne se propagerait nulle part. D'où le + bouton en bas d'écran, en deux temps, qui se désarme seul au bout de quatre + secondes — un bouton armé indéfiniment se fait toucher par mégarde bien plus + tard. 4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée jusqu'ici pour de bonnes raisons. diff --git a/android-client/PMA.VaultPatch.pas b/android-client/PMA.VaultPatch.pas index 693a0d1..a3965f0 100644 --- a/android-client/PMA.VaultPatch.pas +++ b/android-client/PMA.VaultPatch.pas @@ -51,6 +51,20 @@ function NewEmptySnapshot(const AUsername: string): string; function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, AKind: string): string; +/// Retire une entree de l'instantane et laisse une PIERRE TOMBALE a sa place. +/// +/// LES DEUX, SINON RIEN. Retirer la ligne sans la tombstone ne supprime pas : +/// le poste tient toujours la sienne, et le prochain tirage la ramenerait comme +/// une entree qu'on n'a jamais eue. C'est la tombstone qui dit "celle-ci a ete +/// supprimee, et a telle heure" - et l'heure compte, parce que l'arbitrage du +/// poste garde une entree modifiee APRES la suppression plutot que de la tuer +/// une seconde fois. +/// +/// SANS RETOUR depuis le telephone : il n'y a pas de corbeille ici. Celle du +/// poste est un drapeau `deleted` qui ne voyage pas dans la synchronisation ; +/// une suppression qui s'y arreterait ne se propagerait donc nulle part. +function DeleteEntry(const APlainJson, AUuid: string): string; + /// Horodatage UTC au format que tout le reste emploie (toISOString cote JS). /// MELANGER heure locale et UTC a deja decale l'arbitrage de la fusion une /// fois : il n'y a qu'une source d'heure ici, et elle est en UTC. @@ -86,6 +100,57 @@ begin end; end; +function DeleteEntry(const APlainJson, AUuid: string): string; +var + LRoot, LEntry, LStone: TJSONObject; + LEntries, LStones: TJSONArray; + I: Integer; + LFound: Boolean; +begin + if AUuid = '' then + raise EPMAPatch.Create('Entree sans uuid : rien a supprimer chez le distant'); + + LRoot := TJSONObject.ParseJSONValue(APlainJson) as TJSONObject; + if LRoot = nil then + raise EPMAPatch.Create('Instantane illisible'); + try + if not LRoot.TryGetValue('entries', LEntries) then + raise EPMAPatch.Create('Instantane sans tableau "entries"'); + + LFound := False; + for I := 0 to LEntries.Count - 1 do + begin + if not (LEntries.Items[I] is TJSONObject) then Continue; + LEntry := LEntries.Items[I] as TJSONObject; + if LEntry.GetValue('uuid', '') <> AUuid then Continue; + // Remove REND la valeur retiree : a l'appelant de la liberer. + LEntries.Remove(I).Free; + LFound := True; + Break; + end; + + if not LFound then + raise EPMAPatch.CreateFmt( + 'Entree %s absente du distant - supprimee ailleurs ?', [AUuid]); + + // Le tableau peut manquer : un conteneur ecrit par un poste qui n'avait + // encore rien supprime n'a aucune raison de le porter. + if not LRoot.TryGetValue('tombstones', LStones) then + begin + LStones := TJSONArray.Create; + LRoot.AddPair('tombstones', LStones); + end; + LStone := TJSONObject.Create; + LStone.AddPair('uuid', AUuid); + LStone.AddPair('deleted_at', NowUtcIso); + LStones.AddElement(LStone); + + Result := LRoot.ToJSON; + finally + LRoot.Free; + end; +end; + function AddEntry(const APlainJson, ATitle, ASite, AUsername, APassword, AKind: string): string; var diff --git a/android-client/web/app.html b/android-client/web/app.html index 86c3fee..eea1719 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -154,6 +154,9 @@ .btn:active { opacity: .62; } .btn.wide { padding: 13px 18px; font-size: 15px; } .btn.main { background: var(--accent); } + /* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez + pour attirer l'oeil sur la seule action irreversible de l'ecran. */ + .btn.danger { background: rgba(220, 80, 80, .30); } .empty { text-align: center; color: var(--muted); padding: 48px 16px; } @@ -593,6 +596,14 @@ function renderDetail(d) { + (r.e ? '' : '') + ''; } + // SUPPRIMER, tout en bas et en deux temps. Sans retour possible depuis le + // telephone - il n'y a pas de corbeille ici - donc ni en tete d'ecran ni a + // portee d'un appui distrait. Le premier appui ne fait qu'armer ; c'est la + // confirmation, faute de pouvoir en demander une au moteur (une invite + // JavaScript exige un WebChromeClient que FMX ne fournit pas). + html += '
' + + '
'; document.getElementById('fields').innerHTML = html; document.body.classList.add('on-detail'); view = 'detail'; @@ -601,6 +612,24 @@ function renderDetail(d) { document.getElementById('fields').addEventListener('click', function (ev) { var t = ev.target, k = t.getAttribute('data-show'); + if (t.id === 'del') { + if (t.getAttribute('data-armed') === null) { + t.setAttribute('data-armed', '1'); + t.textContent = 'Really delete - tap again'; + // Desarme tout seul : un bouton reste arme indefiniment se fait toucher + // par megarde bien plus tard, et la suppression est sans retour. + setTimeout(function () { + if (!t.parentNode) return; + t.removeAttribute('data-armed'); + t.textContent = 'Delete entry'; + }, 4000); + return; + } + t.disabled = true; + t.textContent = 'Deleting...'; + send('delentry'); + return; + } if (k !== null) { var cell = document.querySelector('[data-v="' + k + '"]'); var hidden = cell.classList.contains('mask'); diff --git a/mainApk.pas b/mainApk.pas index b5db585..5035776 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -242,6 +242,8 @@ type /// Ajoute une entree au coffre. Le mot de passe voyage par le pont, /// comme 'setfield' - meme regle, meme journalisation muette. procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); + /// Supprime l'entree ouverte, pierre tombale comprise. Sans retour. + procedure DeleteCurrentEntry; procedure ReopenEntryByUuid(const AUuid: string); procedure OnClipTimer(Sender: TObject); procedure OnDeadline(Sender: TObject); @@ -901,6 +903,34 @@ begin end); end; +/// SUPPRIMER - la seule action sans retour depuis le telephone. +/// +/// Elle ne met pas a la corbeille : celle du poste ne voyage pas dans la +/// synchronisation, donc s'y arreter ne supprimerait nulle part. Elle retire la +/// ligne ET pose la pierre tombale, ce qui fait disparaitre l'entree du poste +/// au prochain tirage. +/// +/// La page demande confirmation avant d'en arriver ici. +procedure TForm8.DeleteCurrentEntry; +var + LUuid, LTitle: string; +begin + if FCurrent.Uuid = '' then + begin + RefuseChange('This entry has no uuid - cannot be deleted'); + Exit; + end; + LUuid := FCurrent.Uuid; + LTitle := FCurrent.Display; + // Pas de rang a rouvrir : l'entree n'existera plus. AdoptVault ramene la + // liste, ce qui est exactement ou l'on veut se retrouver. + CommitChange('Deleted: ' + LTitle, 'entry deleted from phone', '', + function (APlain: string): string + begin + Result := DeleteEntry(APlain, LUuid); + end); +end; + procedure TForm8.CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string); begin // Une entree sans rien pour la reconnaitre serait une ligne muette dans la @@ -1061,6 +1091,9 @@ begin TNetEncoding.URL.Decode(Param('p')), TNetEncoding.URL.Decode(Param('k'))) + else if LAction = 'delentry' then + DeleteCurrentEntry + else if LAction = 'setfield' then SaveEntryField(TNetEncoding.URL.Decode(Param('f')), TNetEncoding.URL.Decode(Param('v')))