Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 16 additions & 5 deletions nexanote/sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
74 changes: 72 additions & 2 deletions nexanote/sync/webdav_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]

Expand All @@ -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])
Comment on lines +854 to +855
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Defer parent auto-creation until PUT target is validated

get_resource_inst() now creates missing notebook/note collections for every PUT path with at least three segments, before confirming the child resource is actually writable (e.g., note.json or page_N.ink) and before payload validation runs. With WsgiDAV’s do_PUT flow, a request like PUT /nb__deadbeef/note__cafebabe/unsupported.bin (or malformed note.json) will still persist a new notebook/note, then fail with 403/400, leaving orphan data from a failed write. This is a regression in data integrity because failed requests now mutate storage.

Useful? React with 👍 / 👎.

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]
Expand All @@ -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
Loading
Loading