feat(entries): offer to save the editor content as a new entry when the row is gone

A 404 on save meant the work had to be copied out by hand. The POST mints a fresh uuid, so this is a new entry rather than a resurrection and cannot fight the tombstone the delete left behind. Gated on status 404 AND the server message: the HTTP layer answers 404 to any request it refuses, which the status alone would misread.
This commit is contained in:
r-zakarya
2026-08-01 16:41:11 +01:00
parent 5eb279ff31
commit bd86899c2b
2 changed files with 49 additions and 5 deletions
+6
View File
@@ -91,6 +91,12 @@ Aucun de ces enchaînements ne doit afficher « Entry changed elsewhere » :
- **Bug trouvé par ce test** : le serveur répondait **200 « Updated »** pour un `UPDATE` n'ayant touché **aucune** ligne — le client affichait « Saved » alors que rien n'était écrit. Perte de données silencieuse. `HandleUpdateEntry` teste maintenant `RowsAffected` et renvoie 404.
- **Même classe, non corrigé** : delete / restore / favorite / pin / set-icon répondent aussi OK sur un id inexistant. Assumé — aucun ne prétend avoir enregistré un travail de l'utilisateur, et le client recharge la liste juste après.
### A13b · récupérer la saisie quand l'entrée a disparu
- **ACTION** : même mise en place qu'A13 → au Save, répondre **« Save as new entry »**.
- **ATTENDU** : une nouvelle entrée est créée avec le contenu de l'éditeur, le panneau se rouvre dessus et la carte clignote. *Cancel* → rien n'est écrit, l'éditeur reste ouvert avec la saisie.
- **Nouvel uuid, pas une résurrection** : le POST en génère un neuf, donc l'entrée recréée n'entre pas en conflit avec le tombstone laissé par la suppression — aucun risque de la voir re-supprimée au sync suivant, ni de ressusciter en boucle ce qu'un autre appareil a effacé volontairement.
- **Piège du déclenchement** : le serveur HTTP répond aussi **404** à toute requête qu'il refuse (verrou par token). La bascule teste donc `status === 404` **et** le message « no longer exists » — le statut seul confondrait une requête rejetée avec une entrée supprimée.
---
## B — Fermeture de l'éditeur & message de discard
+43 -5
View File
@@ -5072,11 +5072,49 @@ async function soSaveInner() {
toast('Saved');
}
} else {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
try {
await api('/entries/' + soState.id, {
method: 'PUT',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
} catch (err) {
// The row is gone — deleted by a sync, another device, or a
// purge, after the editor opened it. Offer to keep the work as
// a NEW entry rather than making the user copy it out by hand.
// A fresh uuid is minted, so this is a new entry and not a
// resurrection: it can't fight the tombstone the delete left.
// Both checks matter: the HTTP layer also answers 404 to any
// request it refuses (token lockdown), so the status alone
// would misread a rejected request as a deleted entry.
if (err.status !== 404 || !/no longer exists/i.test(err.message || ''))
throw err;
const again = await confirmDialog({
title: 'Entry no longer exists',
message: 'It was deleted after you opened it — by a sync, ' +
'an import, or another device. Your changes are ' +
'still here and can be saved as a new entry.',
okText: 'Save as new entry',
});
if (!again) return;
const created = await api('/entries', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body,
});
const newId = created && created.id;
toast('Saved as a new entry');
await loadEntries();
if (typeof healthCache !== 'undefined') healthCache = null;
// Cleared before reopening for the same reason as the normal
// save path: a stale soState would trigger the discard prompt.
soState = null;
if (newId) openSlideOver(newId, { noFocus: true });
else closeSlideOver();
render();
if (newId) flashEntry(newId);
return;
}
toast('Saved');
}
await loadEntries();