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
145 changes: 117 additions & 28 deletions nexanote/sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,50 +204,139 @@ def get_ink_page(self, notebook_slug: str, note_slug: str, page_num: int) -> Opt
def put_note_meta(
self, notebook_slug: str, note_slug: str, data: dict
) -> tuple[bool, Optional[str]]:
"""PUT /{notebook}/{note}/note.json — returns (ok, reason_if_failed)."""
"""
EN: PUT /{notebook}/{note}/note.json. On 409 (parent missing) we
transparently MKCOL the parents and retry once — keeps push
reliable when the server hasn't seen this notebook/note yet.
FR: PUT note.json. Si le serveur renvoie 409 (parent absent), on
crée les dossiers parents en MKCOL et on retente une fois.
Returns (ok, reason_if_failed).
"""
url = self._url(notebook_slug, note_slug, "note.json")
try:
resp = self.session.put(
url,
json=data,
headers={"Content-Type": "application/json"},
timeout=self.config.timeout_seconds,
)
if resp.status_code in (200, 201, 204):
return True, None
reason = f"WebDAV upload failed: {resp.status_code} {resp.reason or ''}".strip()
logger.error(f"PUT note.json échoué ({url}): {reason}")
return False, reason
except requests.RequestException as e:
reason = _sanitize_request_error(e)
logger.error(f"PUT note.json échoué ({url}): {reason}")
return False, reason
return self._put_json(
url,
data,
label="note.json",
mkcol_paths=(notebook_slug, f"{notebook_slug}/{note_slug}"),
)

def put_ink_page(
self, notebook_slug: str, note_slug: str, page_num: int, data: dict
) -> tuple[bool, Optional[str]]:
"""PUT /{notebook}/{note}/page_N.ink — returns (ok, reason_if_failed)."""
"""
EN: PUT /{notebook}/{note}/page_N.ink with the same MKCOL-on-409
recovery as ``put_note_meta``.
FR: PUT page_N.ink avec la même récupération MKCOL sur 409.
"""
url = self._url(notebook_slug, note_slug, f"page_{page_num}.ink")
try:
resp = self.session.put(
return self._put_json(
url,
data,
label=f"page_{page_num}.ink",
mkcol_paths=(notebook_slug, f"{notebook_slug}/{note_slug}"),
page_num=page_num,
)

def _put_json(
self,
url: str,
data: dict,
label: str,
mkcol_paths: tuple[str, ...],
page_num: Optional[int] = None,
) -> tuple[bool, Optional[str]]:
"""
EN: Internal helper that performs PUT with a single MKCOL-and-retry
cycle when the server returns 409 (parent collection missing).
Treats every non-2xx response uniformly so callers get a stable
(ok, reason) shape.
FR: Helper interne pour PUT avec récupération MKCOL+retry sur 409.
"""
prefix = f"{label}: " if page_num is None else f"page {page_num}: "

def _do_put() -> requests.Response:
return self.session.put(
url,
json=data,
headers={"Content-Type": "application/json"},
timeout=self.config.timeout_seconds,
)

try:
resp = _do_put()
if resp.status_code in (200, 201, 204):
return True, None
reason = (
f"WebDAV upload failed (page {page_num}): "
f"{resp.status_code} {resp.reason or ''}"
).strip()
logger.error(f"PUT page.ink échoué ({url}): {reason}")

if resp.status_code == 409 and self._mkcol_chain(mkcol_paths):
logger.info(
"PUT %s returned 409 — MKCOL'd parents, retrying once",
label,
)
resp = _do_put()
if resp.status_code in (200, 201, 204):
return True, None

reason = self._format_http_failure(resp, prefix)
logger.error(f"PUT {label} échoué ({url}): {reason}")
return False, reason
except requests.RequestException as e:
reason = f"page {page_num}: {_sanitize_request_error(e)}"
logger.error(f"PUT page.ink échoué ({url}): {reason}")
except requests.RequestException as exc:
reason = f"{prefix}{_sanitize_request_error(exc)}"
logger.error(f"PUT {label} échoué ({url}): {reason}")
return False, reason

@staticmethod
def _format_http_failure(resp: requests.Response, prefix: str) -> str:
"""
EN: Turn a non-2xx response into a short user-safe message. We
include the body for 5xx so backend errors aren't reduced to
an opaque "500" — but cap the length so leaked stack traces
don't bloat the UI.
FR: Formate une réponse non-2xx en message court. Pour les 5xx,
on inclut le corps tronqué pour exposer le motif réel.
"""
base = f"{prefix}WebDAV upload failed: {resp.status_code} {resp.reason or ''}".strip()
if 500 <= resp.status_code < 600:
try:
body = resp.text.strip()
except Exception:
body = ""
if body:
snippet = body[:200].replace("\n", " ")
if len(body) > 200:
snippet += "…"
return f"{base} — {snippet}"
return base

def _mkcol_chain(self, paths: tuple[str, ...]) -> bool:
"""
EN: MKCOL each path in order. Returns True if every step yielded a
success-equivalent status (created or already-exists). Used to
heal a 409 caused by a missing parent collection.
FR: MKCOL chaque chemin dans l'ordre. Retourne True si chaque étape
a réussi (créé ou déjà présent). Sert à corriger un 409 dû à
un parent manquant.
"""
for path in paths:
url = urljoin(self.base_url, "/".join(quote(p, safe="") for p in path.split("/") if p))
try:
resp = self.session.request(
"MKCOL",
url,
timeout=self.config.timeout_seconds,
)
except requests.RequestException as exc:
logger.warning(f"MKCOL chain failed at {path}: {exc}")
return False
if not self._is_mkcol_success(resp.status_code):
logger.warning(
"MKCOL chain rejected at %s: %s %s",
path,
resp.status_code,
resp.reason,
)
return False
return True

def create_notebook_dir(self, notebook_slug: str) -> bool:
"""MKCOL /{notebook} — creates a notebook folder on the remote server."""
url = self._url(notebook_slug)
Expand Down
54 changes: 52 additions & 2 deletions nexanote/sync/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,18 @@
from cheroot import wsgi
from wsgidav.wsgidav_app import WsgiDAVApp

from nexanote.models.note import Notebook
from nexanote.storage import FileNoteStore, run_migration
from nexanote.sync.webdav_provider import NexaNoteDAVProvider

# EN: Slug for the fallback notebook used to host notes that aren't assigned
# to any user-created notebook. Mirrors the constant in sync.client so
# push targets always have a valid parent collection on the server.
# FR: Slug du carnet de repli pour les notes sans carnet attribué. Identique
# au constant côté client : garantit un parent valide pour les PUT.
DEFAULT_NOTEBOOK_NAME = "Uncategorized"
DEFAULT_NOTEBOOK_ID_PREFIX = "00000000"

logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
Expand All @@ -36,6 +45,39 @@ def _hash_password(password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()


def ensure_storage_layout(db: FileNoteStore) -> None:
"""
EN: Make sure the on-disk directories WebDAV expects (notes/, drawings/,
notebooks/) exist. ``FileNoteStore.__init__`` already creates them,
but call this defensively before serving so a wiped data dir during
runtime is recreated rather than throwing on the first request.
FR: S'assure que les dossiers attendus par WebDAV existent. Idempotent.
"""
for d in (db.notes_dir, db.drawings_dir, db.notebooks_dir):
d.mkdir(parents=True, exist_ok=True)


def ensure_default_notebook(db: FileNoteStore) -> Notebook:
"""
EN: Ensure the fallback "Uncategorized" notebook exists. Notes pushed
without a notebook id land here, so its parent collection must be
present on the server before the client's first PUT.
FR: Garantit que le carnet "Uncategorized" existe. Les notes sans
carnet y sont rangées : son dossier doit déjà être créé côté serveur.
"""
for nb in db.list_notebooks():
if nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX):
return nb
notebook = Notebook(
id=f"{DEFAULT_NOTEBOOK_ID_PREFIX}-0000-0000-0000-000000000000",
name=DEFAULT_NOTEBOOK_NAME,
color="#6b7280",
)
db.save_notebook(notebook)
logger.info("Default notebook created: %s", DEFAULT_NOTEBOOK_NAME)
return notebook


def build_app(
db: FileNoteStore,
username: str = "nexanote",
Expand Down Expand Up @@ -114,8 +156,16 @@ def run_server(
logger.info(migration_report.summary())
db = FileNoteStore(data_dir)

# Créer un carnet de démonstration si le store est vide
notebooks = db.list_notebooks()
# Ensure required WebDAV directories exist + the fallback notebook is
# present, so the very first sync push has valid parent collections.
ensure_storage_layout(db)
ensure_default_notebook(db)

# Créer un carnet de démonstration si seul le carnet de repli existe.
notebooks = [
nb for nb in db.list_notebooks()
if not nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX)
]
if not notebooks:
from nexanote.models.note import Note, Notebook, NoteType
nb = Notebook(name="Mon premier carnet", color="#6366f1")
Expand Down
Loading
Loading