feat(android): an entry can be deleted from the phone
Both, or nothing: the row leaves the snapshot AND a tombstone takes its place. Removing the row alone does not delete - the desktop still holds its own copy, and the next pull would bring it back as an entry we never had. The time matters too: the desktop's arbitration keeps an entry edited AFTER the deletion rather than killing it a second time. No trash on the phone, deliberately. The desktop's is a `deleted` flag that does not travel in sync, so a deletion stopping there would propagate nowhere. Hence a button at the very bottom, in two taps, that disarms itself after four seconds - a button left armed gets touched by accident much later, and this is the one action with no way back. The confirmation is in the page because the engine cannot offer one: a JavaScript prompt needs a WebChromeClient FMX does not provide. Not exercised on the device: it would have destroyed a real entry.
This commit is contained in:
+13
-1
@@ -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.
|
||||
|
||||
@@ -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<TJSONArray>('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<string>('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<TJSONArray>('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
|
||||
|
||||
@@ -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 ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '')
|
||||
+ '</div></div>';
|
||||
}
|
||||
// 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 += '<div class="field"><div class="line">'
|
||||
+ '<button class="btn danger" id="del" style="flex:1">Delete entry'
|
||||
+ '</button></div></div>';
|
||||
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');
|
||||
|
||||
+33
@@ -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')))
|
||||
|
||||
Reference in New Issue
Block a user