diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py index 5ec0d3c..8041ebd 100644 --- a/nexanote/api/routes.py +++ b/nexanote/api/routes.py @@ -1,13 +1,18 @@ """ -NexaNote — API REST (FastAPI) -Interface HTTP que l'app Flutter consomme pour toutes les opérations. - -Routes : - GET /health → statut du serveur - GET /notebooks → liste des carnets - POST /notebooks → créer un carnet - GET /notebooks/{id} → détails d'un carnet - PUT /notebooks/{id} → modifier un carnet +REST API / Interface REST FastAPI + +EN: HTTP interface consumed by the Flutter app for all data operations. + Runs on port 8766 alongside the WebDAV server (port 8765). + +FR: Interface HTTP consommée par l'app Flutter pour toutes les opérations. + Tourne sur le port 8766 en parallèle du serveur WebDAV (port 8765). + +Routes: + GET /health → server status / statut du serveur + GET /notebooks → list notebooks / liste des carnets + POST /notebooks → create notebook / créer un carnet + GET /notebooks/{id} → notebook detail / détails d'un carnet + PUT /notebooks/{id} → update notebook / modifier un carnet DELETE /notebooks/{id} → supprimer un carnet GET /notes → liste des notes (filtrable) @@ -31,7 +36,9 @@ from __future__ import annotations +import json import logging +import os from contextlib import asynccontextmanager from datetime import datetime, timezone from pathlib import Path @@ -255,9 +262,38 @@ async def lifespan(app: FastAPI): allow_headers=["*"], ) - # État sync + # EN: Non-sensitive sync fields that are safe to write to disk. + # Password is intentionally excluded — never persisted in plain text. + # FR: Champs de sync non-sensibles sûrs à écrire sur disque. + # Le mot de passe est exclu intentionnellement — jamais écrit en clair. + _PERSIST_FIELDS = {"server_url", "username", "conflict_strategy"} + _last_sync_report: dict = {} _sync_config: dict = {} + _sync_config_path = db.db_path.parent / "sync_config.json" + + def _save_sync_config_to_disk() -> None: + """ + EN: Persist non-sensitive sync fields to disk. + File permissions are set to 0o600 (owner read/write only). + Password is never written — user must re-enter it after a restart. + FR: Persiste les champs non-sensibles sur disque. + Permissions du fichier : 0o600 (lecture/écriture propriétaire uniquement). + Le mot de passe n'est jamais écrit — l'utilisateur doit le re-saisir après redémarrage. + """ + safe = {k: v for k, v in _sync_config.items() if k in _PERSIST_FIELDS} + try: + _sync_config_path.write_text(json.dumps(safe, indent=2)) + os.chmod(_sync_config_path, 0o600) + except OSError as exc: + logger.warning(f"Could not persist sync config: {exc}") + + if _sync_config_path.exists(): + try: + _sync_config.update(json.loads(_sync_config_path.read_text())) + logger.info(f"Sync config loaded from {_sync_config_path}") + except (OSError, json.JSONDecodeError, ValueError) as exc: + logger.warning(f"Could not load sync config: {exc}") # ------------------------------------------------------------------ # Health @@ -497,8 +533,14 @@ def update_text(note_id: str, page_num: int, data: TextUpdateSchema): @app.post("/sync/configure") def configure_sync(config: SyncConfigSchema): - """Configure les paramètres de connexion WebDAV.""" + """ + EN: Save WebDAV connection settings in memory and persist safe fields to disk. + The password is kept in memory only and is never written to disk. + FR: Sauvegarde les paramètres WebDAV en mémoire et persiste les champs + sûrs sur disque. Le mot de passe reste en mémoire uniquement. + """ _sync_config.update(config.model_dump()) + _save_sync_config_to_disk() return {"status": "configured", "server_url": config.server_url} @app.post("/sync/trigger", response_model=SyncReportSchema) diff --git a/nexanote/sync/client.py b/nexanote/sync/client.py index 5fd0153..5e12901 100644 --- a/nexanote/sync/client.py +++ b/nexanote/sync/client.py @@ -1,14 +1,13 @@ """ -NexaNote — Client de synchronisation WebDAV -Tourne sur l'appareil (Android/Linux/Windows), compare les notes -locales avec le serveur et synchronise intelligemment. - -Flux de sync : - 1. PULL — récupérer les changements du serveur - 2. DIFF — comparer avec la version locale - 3. RESOLVE — résoudre les conflits - 4. PUSH — envoyer les notes locales modifiées - 5. COMMIT — marquer tout comme SYNCED +WebDAV Sync Client / Client de synchronisation WebDAV + +EN: Runs on the device (Linux/Android/Windows). Compares local notes with a + remote WebDAV server and synchronises them intelligently. + Sync flow: PULL → DIFF → RESOLVE CONFLICTS → PUSH → COMMIT (mark SYNCED) + +FR: Tourne sur l'appareil. Compare les notes locales avec un serveur WebDAV + distant et les synchronise intelligemment. + Flux : PULL → DIFF → RÉSOUDRE LES CONFLITS → PUSH → COMMIT (marquer SYNCED) """ from __future__ import annotations @@ -31,6 +30,10 @@ logger = logging.getLogger("nexanote.sync.client") +# EN: Fallback folder name on the remote server for notes not assigned to any notebook. +# FR: Nom du dossier de repli sur le serveur distant pour les notes sans carnet. +DEFAULT_NOTEBOOK_SLUG = "uncategorized" + # --------------------------------------------------------------------------- # Config @@ -123,6 +126,20 @@ def _url(self, *parts: str) -> str: path = "/".join(quote(p, safe="") for p in parts if p) return urljoin(self.base_url, path) + @staticmethod + def _is_mkcol_success(status_code: int) -> bool: + """ + EN: Returns True when a MKCOL response means the collection was created + or already exists. Per the WebDAV spec (RFC 4918): + 201 = Created + 405 = Method Not Allowed → the resource already exists (success for us) + FR: Retourne True si la réponse MKCOL signifie que la collection a été + créée ou existe déjà. Selon la spec WebDAV (RFC 4918) : + 201 = Créé + 405 = Method Not Allowed → la ressource existe déjà (succès pour nous) + """ + return status_code in (200, 201, 405) + def ping(self) -> bool: """Vérifie que le serveur est accessible.""" try: @@ -206,27 +223,23 @@ def put_ink_page( return False def create_notebook_dir(self, notebook_slug: str) -> bool: - """MKCOL /{notebook} — crée un dossier carnet sur le serveur.""" + """MKCOL /{notebook} — creates a notebook folder on the remote server.""" url = self._url(notebook_slug) try: - resp = self.session.request( - "MKCOL", url, timeout=self.config.timeout_seconds - ) - return resp.status_code in (200, 201) + resp = self.session.request("MKCOL", url, timeout=self.config.timeout_seconds) + return self._is_mkcol_success(resp.status_code) except requests.RequestException as e: - logger.error(f"MKCOL échoué ({url}): {e}") + logger.error(f"MKCOL failed ({url}): {e}") return False def create_note_dir(self, notebook_slug: str, note_slug: str) -> bool: - """MKCOL /{notebook}/{note}""" + """MKCOL /{notebook}/{note} — creates a note folder on the remote server.""" url = self._url(notebook_slug, note_slug) try: - resp = self.session.request( - "MKCOL", url, timeout=self.config.timeout_seconds - ) - return resp.status_code in (200, 201) + resp = self.session.request("MKCOL", url, timeout=self.config.timeout_seconds) + return self._is_mkcol_success(resp.status_code) except requests.RequestException as e: - logger.error(f"MKCOL note échoué ({url}): {e}") + logger.error(f"MKCOL note failed ({url}): {e}") return False def _propfind(self, url: str, depth: int = 1) -> list[dict]: @@ -594,11 +607,14 @@ def _push_note(self, note: Note, report: SyncReport) -> None: if note.notebook_id: notebook = self.db.get_notebook(note.notebook_id) - if not notebook: - logger.warning(f"Note {note.title} sans carnet — skip push") - return + if notebook: + nb_slug = _notebook_to_slug(notebook) + else: + # EN: Notes without a notebook go into the default fallback folder. + # FR: Les notes sans carnet sont placées dans le dossier de repli par défaut. + nb_slug = DEFAULT_NOTEBOOK_SLUG + logger.debug(f"Note {note.title!r} has no notebook → using '{nb_slug}' folder") - nb_slug = _notebook_to_slug(notebook) note_slug = _note_to_slug(note) # Créer le dossier carnet sur le serveur si nécessaire