diff --git a/TEST_SCENARIOS.md b/TEST_SCENARIOS.md index 93dbd74..cf78076 100644 --- a/TEST_SCENARIOS.md +++ b/TEST_SCENARIOS.md @@ -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 diff --git a/js/app.js b/js/app.js index 2baa13e..440cb63 100644 --- a/js/app.js +++ b/js/app.js @@ -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();