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:
@@ -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.
|
- **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.
|
- **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
|
## B — Fermeture de l'éditeur & message de discard
|
||||||
|
|||||||
@@ -5072,11 +5072,49 @@ async function soSaveInner() {
|
|||||||
toast('Saved');
|
toast('Saved');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await api('/entries/' + soState.id, {
|
try {
|
||||||
method: 'PUT',
|
await api('/entries/' + soState.id, {
|
||||||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
method: 'PUT',
|
||||||
body,
|
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');
|
toast('Saved');
|
||||||
}
|
}
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
|
|||||||
Reference in New Issue
Block a user