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
64 changes: 53 additions & 11 deletions nexanote/api/routes.py
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 42 additions & 26 deletions nexanote/sync/client.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
Loading