diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 1d341b3..3f56536 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -19,7 +19,7 @@ from datetime import datetime, timezone from enum import Enum from typing import Optional -from urllib.parse import urljoin, quote +from urllib.parse import urljoin, quote, unquote import requests from requests.auth import HTTPBasicAuth @@ -402,9 +402,20 @@ def _parse_propfind(self, xml_text: str, base_url: str) -> list[dict]: if not href or href.rstrip("/") == base_url.rstrip("/"): continue # On ignore la ressource elle-même - display_name = response.findtext( - ".//D:displayname", "", ns - ) or href.rstrip("/").split("/")[-1] + # EN: Hrefs come back URL-encoded (WsgiDAV percent-encodes + # non-ASCII like `é` → `%C3%A9`). The slugs we compare + # against (`note_slug`, `nb_slug`) are kept decoded, so + # we must decode here too — otherwise notes with accents + # in their title look "missing" and the engine kicks + # off a redundant MKCOL on every push, which masks real + # 409 failures and inflates server load. + # FR: Les hrefs sont URL-encodés. Les slugs comparés sont + # décodés — on décode ici, sinon les notes accentuées + # déclenchent un MKCOL inutile à chaque push. + raw_name = href.rstrip("/").split("/")[-1] + name = unquote(raw_name) + + display_name = response.findtext(".//D:displayname", "", ns) or name last_mod_text = response.findtext(".//D:getlastmodified", "", ns) last_modified = None @@ -418,7 +429,7 @@ def _parse_propfind(self, xml_text: str, base_url: str) -> list[dict]: is_col = response.find(".//D:collection", ns) is not None resources.append({ - "name": href.rstrip("/").split("/")[-1], + "name": name, "href": href, "display_name": display_name, "is_collection": is_col, diff --git a/nexanote/sync/webdav_provider.py b/nexanote/sync/webdav_provider.py index c170b1f..e06d9fd 100644 --- a/nexanote/sync/webdav_provider.py +++ b/nexanote/sync/webdav_provider.py @@ -813,9 +813,23 @@ def get_resource_inst( """ Résout un chemin DAV vers la ressource correspondante. Ex: /mon-carnet__a1b2c3d4/ma-note__e5f6g7h8/note.json + + EN: For PUT requests, transparently materialise missing parent + collections (notebook + note) when their slug carries a valid + id-prefix. This makes PUT idempotent and avoids spurious 409 + "PUT parent must be a collection" failures when a client skips + (or partially fails) the MKCOL chain. Read methods stay strict + and return None for unknown paths. + FR: Pour les PUT, on matérialise à la volée les collections parentes + manquantes (carnet/note) si leur slug porte un préfixe d'ID + valide. Le PUT devient idempotent et n'aboutit plus à un 409 + « parent doit être une collection » quand le client a sauté + (ou raté) la chaîne MKCOL. Les méthodes de lecture restent + strictes. """ path = path.rstrip("/") or "/" parts = [p for p in path.split("/") if p] + auto_materialize = environ.get("REQUEST_METHOD", "").upper() == "PUT" # Racine if not parts: @@ -824,7 +838,10 @@ def get_resource_inst( # Niveau carnet target_nb = _find_notebook_by_slug(self.db, parts[0]) if target_nb is None: - return None + if auto_materialize and len(parts) >= 2: + target_nb = _materialize_notebook(self.db, parts[0]) + if target_nb is None: + return None nb_path = "/" + parts[0] @@ -834,7 +851,10 @@ def get_resource_inst( # Niveau note target_note = _find_note_by_slug(self.db, target_nb.id, parts[1]) if target_note is None: - return None + if auto_materialize and len(parts) >= 3: + target_note = _materialize_note(self.db, target_nb, parts[1]) + if target_note is None: + return None full_note = self.db.get_note(target_note.id, load_pages=True) note_path = nb_path + "/" + parts[1] @@ -846,3 +866,53 @@ def get_resource_inst( file_name = parts[2] note_col = NoteCollection(note_path, environ, self.db, full_note) return note_col.get_member(file_name) + + +def _materialize_notebook(db: FileNoteStore, slug: str) -> Optional[Notebook]: + """ + EN: Create a fresh notebook from a path slug. Returns None if the slug + doesn't carry a usable id-prefix — we never invent ids for arbitrary + names because that would mask typos as silent creations. + FR: Crée un carnet à partir d'un slug de chemin. None si le slug n'a + pas de préfixe d'ID exploitable. + """ + title, id_prefix = _parse_slug(slug) + if id_prefix is None and slug != _DEFAULT_NB_SLUG: + return None + try: + notebook = Notebook(id=_id_with_prefix(id_prefix), name=title) + db.save_notebook(notebook) + except Exception: + logger.exception("auto-materialize notebook failed: %s", slug) + return None + logger.info("Carnet matérialisé pour PUT : %s", title) + return notebook + + +def _materialize_note( + db: FileNoteStore, notebook: Notebook, slug: str +) -> Optional[Note]: + """ + EN: Create a fresh note (with one empty page) from a path slug. Returns + None unless the slug carries a usable id-prefix. The follow-up PUT + of note.json will swap the placeholder id for the client's real id. + FR: Crée une note (1 page vide) à partir d'un slug. None sans préfixe + d'ID exploitable. Le PUT note.json ensuite remplace l'ID placeholder. + """ + _, id_prefix = _parse_slug(slug) + if id_prefix is None: + return None + title, _ = _parse_slug(slug) + try: + note = Note( + id=_id_with_prefix(id_prefix), + notebook_id=notebook.id, + title=title, + ) + note.add_page() + db.save_note(note) + except Exception: + logger.exception("auto-materialize note failed: %s", slug) + return None + logger.info("Note matérialisée pour PUT : %s", title) + return note diff --git a/tests/test_webdav_sync_push.py b/tests/test_webdav_sync_push.py index 070f0ea..6ea1880 100644 --- a/tests/test_webdav_sync_push.py +++ b/tests/test_webdav_sync_push.py @@ -343,23 +343,68 @@ def test_put_note_json_after_mkcol_chain(self, live_server): assert roundtripped["title"] == "Ma note synchronisée" assert roundtripped["pages"][0]["typed_content"] == "Hello" - def test_put_into_missing_parent_returns_409_not_500(self, live_server): + def test_put_into_missing_parent_auto_materialises(self, live_server): """ - EN: Direct PUT (without MKCOL) to a nested path with no parent must - return 409, not 500 — and the body must hint that the parent - collection is missing rather than leaking a stack trace. + EN: PUT into a path whose ancestors don't yet exist must NOT fail + with 409. The provider materialises the missing notebook and + note from their slugs (id-prefix carried in the slug) so the + client's PUT lands on a real resource — sync push becomes + idempotent and survives a partial MKCOL chain. + FR: Un PUT sur un chemin sans parent ne doit pas échouer en 409 ; + le provider crée carnet/note à la volée à partir des slugs. """ url = live_server["url"] auth = HTTPBasicAuth(live_server["username"], live_server["password"]) resp = requests.put( f"{url}does-not-exist__deadbeef/note__cafebabe/note.json", + json={ + "id": "cafebabe-1111-2222-3333-444455556666", + "title": "Auto-materialised note", + "type": "typed", + "tags": [], + "is_pinned": False, + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:00+00:00", + "pages": [ + {"page_number": 1, "template": "blank", "typed_content": "hi"}, + ], + }, + auth=auth, + timeout=5, + ) + assert resp.status_code in (200, 201, 204), ( + f"PUT must succeed via auto-materialise: {resp.status_code} {resp.text}" + ) + + # The note is now persisted on the server with the client's id. + server_note = live_server["db"].get_note( + "cafebabe-1111-2222-3333-444455556666", load_pages=True + ) + assert server_note is not None + assert server_note.title == "Auto-materialised note" + + def test_put_with_no_id_prefix_still_fails(self, live_server): + """ + EN: Auto-materialisation only kicks in for slugs that carry a valid + id-prefix. A slug with no `__` suffix means the client + doesn't have a stable id yet, so we keep returning 409 to + surface the bug instead of silently inventing a note. + FR: Sans préfixe d'ID dans le slug, on garde le 409 — éviter de + créer une note silencieuse pour un client mal-câblé. + """ + url = live_server["url"] + auth = HTTPBasicAuth(live_server["username"], live_server["password"]) + + resp = requests.put( + f"{url}also-missing/no-id-prefix/note.json", json={"id": "x", "pages": []}, auth=auth, timeout=5, ) - # 409 (parent missing) is the WebDAV-correct answer — not 500. - assert resp.status_code == 409, f"got {resp.status_code}: {resp.text}" + assert resp.status_code == 409, ( + f"slug without id-prefix must still 409: {resp.status_code}" + ) class TestSyncClientPushHappyPath: @@ -444,6 +489,195 @@ def test_push_uncategorized_notebook_works(self, live_server, tmp_path): assert DEFAULT_NOTEBOOK_SLUG in names +# --------------------------------------------------------------------------- +# Idempotent overwrite — pushing the same note twice or pushing modified +# versions must never trip the 409 path. Regression coverage for the bug +# where notes with non-ASCII titles (e.g. "Chaudré de saucisses") or +# apostrophes ("Commande d'imprimante…") failed mid-sync with a 409. +# --------------------------------------------------------------------------- + + +class TestIdempotentPush: + def _engine(self, live_server, client_dir): + local_db = FileNoteStore(client_dir) + engine = NexaNoteSyncEngine( + local_db, + SyncConfig( + server_url=live_server["url"], + username=live_server["username"], + password=live_server["password"], + timeout_seconds=5, + ), + ) + return local_db, engine + + def _push_only(self, engine): + from nexanote.sync.client import SyncReport + + report = SyncReport() + engine._push(report) + return report + + def test_push_same_note_twice_does_not_fail(self, live_server, tmp_path): + """Re-uploading an unchanged note must succeed without 409.""" + local_db, engine = self._engine(live_server, tmp_path / "client_twice") + nb = Notebook(name="Cuisine", color="#3b82f6") + local_db.save_notebook(nb) + note = Note( + notebook_id=nb.id, + title="Chaudré de saucisses", + note_type=NoteType.TYPED, + ) + note.add_page(template="lined").typed_content = "Une vieille recette." + local_db.save_note(note) + + first = engine.sync() + assert first.success(), f"first sync: {first.errors}" + assert first.notes_pushed == 1 + + # Re-mark MODIFIED to force a re-push, then push only (skip pull + # so the conflict resolver doesn't intercept the second upload). + local = local_db.get_note(note.id, load_pages=True) + local.sync_status = SyncStatus.MODIFIED + local_db.save_note(local, save_pages=False) + second = self._push_only(engine) + assert not second.errors, f"re-push must not 409: {second.errors}" + assert second.notes_pushed == 1 + + def test_push_modified_note_overwrites(self, live_server, tmp_path): + """Editing a note locally and pushing must replace the server copy.""" + local_db, engine = self._engine(live_server, tmp_path / "client_mod") + nb = Notebook(name="Notes") + local_db.save_notebook(nb) + note = Note( + notebook_id=nb.id, + title="Commande d'imprimante qui ne veut pas se connecter", + note_type=NoteType.TYPED, + ) + note.add_page().typed_content = "v1" + local_db.save_note(note) + + first = engine.sync() + assert first.success(), first.errors + assert first.notes_pushed == 1 + + local = local_db.get_note(note.id, load_pages=True) + local.pages[0].typed_content = "v2 — modifié" + local.touch() + local.sync_status = SyncStatus.MODIFIED + local_db.save_note(local) + + second = self._push_only(engine) + assert not second.errors, f"overwrite push must not 409: {second.errors}" + + server_note = live_server["db"].get_note(note.id, load_pages=True) + assert server_note is not None + assert server_note.pages[0].typed_content.strip().endswith("v2 — modifié") + + def test_push_existing_file_no_409(self, live_server, tmp_path): + """Direct PUT on an already-existing note.json must return 2xx, never 409.""" + local_db = FileNoteStore(tmp_path / "client_direct") + nb = Notebook(name="Direct") + local_db.save_notebook(nb) + note = Note(notebook_id=nb.id, title="Déjà là", note_type=NoteType.TYPED) + note.add_page().typed_content = "first" + local_db.save_note(note) + + client = _make_client(live_server) + nb_slug = _slugify(nb.name) + "__" + nb.id[:8] + note_slug = _slugify(note.title) + "__" + note.id[:8] + + # Seed the server. + meta = { + "id": note.id, + "title": note.title, + "type": "typed", + "tags": [], + "is_pinned": False, + "created_at": note.created_at.isoformat(), + "updated_at": note.updated_at.isoformat(), + "pages": [ + {"page_number": 1, "template": "blank", "typed_content": "first"}, + ], + } + ok, reason = client.put_note_meta(nb_slug, note_slug, meta) + assert ok, f"initial PUT failed: {reason}" + + # Overwrite the same path with new content. + meta["pages"][0]["typed_content"] = "second" + meta["updated_at"] = "2026-05-04T10:00:00+00:00" + ok, reason = client.put_note_meta(nb_slug, note_slug, meta) + assert ok, f"overwrite PUT must not 409: {reason}" + + # The server now reflects the second write. + server_note = live_server["db"].get_note(note.id, load_pages=True) + assert server_note.pages[0].typed_content.strip().endswith("second") + + def test_repeated_pushes_with_accented_title_are_idempotent( + self, live_server, tmp_path + ): + """ + EN: Notes whose URL-encoded slug differs from their decoded slug + (e.g. é → %C3%A9) used to trigger a redundant MKCOL on every + push. The encoding mismatch in PROPFIND name comparison is now + fixed by URL-decoding the href client-side. Three back-to-back + pushes must never produce errors. + FR: Les notes accentuées déclenchaient un MKCOL redondant à chaque + push. Trois pushs consécutifs ne doivent jamais produire + d'erreur. + """ + local_db, engine = self._engine(live_server, tmp_path / "client_accent") + nb = Notebook(name="Recettes") + local_db.save_notebook(nb) + note = Note( + notebook_id=nb.id, + title="Chaudré de saucisses", + note_type=NoteType.TYPED, + ) + note.add_page().typed_content = "v1" + local_db.save_note(note) + + for i in range(3): + local = local_db.get_note(note.id, load_pages=True) + local.pages[0].typed_content = f"v{i + 1}" + local.touch() + local.sync_status = SyncStatus.MODIFIED + local_db.save_note(local) + + report = self._push_only(engine) + assert not report.errors, f"push {i + 1}: {report.errors}" + assert report.notes_pushed == 1 + + def test_propfind_decodes_url_encoded_names(self, live_server, tmp_path): + """ + EN: The client compares slugs against PROPFIND names — those names + must be URL-decoded so notes/notebooks with non-ASCII chars in + their slug are correctly recognised as already existing. + FR: Le client compare les slugs aux noms de PROPFIND — ces noms + doivent être URL-décodés pour reconnaître correctement les + ressources existantes. + """ + # Seed a notebook + note with accented chars on the server. + nb = Notebook(name="Recettes") + live_server["db"].save_notebook(nb) + note = Note( + notebook_id=nb.id, + title="Crème brûlée", + note_type=NoteType.TYPED, + ) + note.add_page().typed_content = "..." + live_server["db"].save_note(note) + + client = _make_client(live_server) + nb_slug = _slugify(nb.name) + "__" + nb.id[:8] + expected_note_slug = _slugify(note.title) + "__" + note.id[:8] + + names = {entry["name"] for entry in client.list_notes(nb_slug)} + assert expected_note_slug in names, ( + f"PROPFIND name must round-trip the decoded slug; got {names!r}" + ) + + # --------------------------------------------------------------------------- # Server bootstrap # ---------------------------------------------------------------------------