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
52 changes: 40 additions & 12 deletions nexanote/sync/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -722,15 +722,23 @@ def _push_note(self, note: Note, report: SyncReport) -> None:
if note.notebook_id:
notebook = self.db.get_notebook(note.notebook_id)

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")

note_slug = _note_to_slug(note)
try:
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")
note_slug = _note_to_slug(note)
except Exception as exc:
# Slug computation only fails on truly malformed notes (e.g.
# missing id), but if it ever does we want a useful reason
# instead of a raw traceback bubbling up to the sync report.
msg = f"path generation failed: {type(exc).__name__}: {exc}"
logger.exception("Could not compute push path for note %s", note.id)
report.errors.append(f"Échec partiel push : {note.title} — {msg}")
return

# Créer le dossier carnet sur le serveur si nécessaire
nb_entries = self.client.list_notebooks()
Expand All @@ -745,17 +753,37 @@ def _push_note(self, note: Note, report: SyncReport) -> None:
self.client.create_note_dir(nb_slug, note_slug)

# PUT note.json
try:
meta_payload = _serialize_note_meta(note)
except Exception as exc:
logger.exception("note.json serialization failed for %s", note.id)
reason = f"note.json: serialization failed: {type(exc).__name__}: {exc}"
report.errors.append(f"Échec partiel push : {note.title} — {reason}")
return
meta_ok, meta_reason = self.client.put_note_meta(
nb_slug, note_slug, _serialize_note_meta(note)
nb_slug, note_slug, meta_payload
)

# PUT page_N.ink pour chaque page
pages_ok = True
page_reasons: list[str] = []
for page in note.pages:
try:
ink_payload = _serialize_ink_page(page)
except Exception as exc:
logger.exception(
"page_%d.ink serialization failed for note %s",
page.page_number,
note.id,
)
pages_ok = False
page_reasons.append(
f"page {page.page_number}: serialization failed: "
f"{type(exc).__name__}: {exc}"
)
continue
ok, page_reason = self.client.put_ink_page(
nb_slug, note_slug, page.page_number,
_serialize_ink_page(page)
nb_slug, note_slug, page.page_number, ink_payload,
)
if not ok:
pages_ok = False
Expand Down
75 changes: 51 additions & 24 deletions nexanote/sync/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,12 @@
import logging
import sys
from pathlib import Path
from typing import Optional

from cheroot import wsgi
from wsgidav.wsgidav_app import WsgiDAVApp

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

Expand Down Expand Up @@ -72,12 +73,60 @@ def ensure_default_notebook(db: FileNoteStore) -> Notebook:
id=f"{DEFAULT_NOTEBOOK_ID_PREFIX}-0000-0000-0000-000000000000",
name=DEFAULT_NOTEBOOK_NAME,
color="#6b7280",
sync_status=SyncStatus.SYNCED,
)
db.save_notebook(notebook)
logger.info("Default notebook created: %s", DEFAULT_NOTEBOOK_NAME)
return notebook


def seed_demo_data(db: FileNoteStore) -> Optional[Note]:
"""
EN: First-run-only demo content. Creates a sample notebook and a tutorial
note marked as SYNCED so it lives on this server but never gets
re-pushed by a sync engine sharing this data dir. Pull copies it
to clients normally; once a client edits it, the local copy flips
to MODIFIED and the next push round-trips like any user note.

Skipped if any non-fallback notebook already exists. Returns the
seeded note (or None if nothing was seeded) — useful for tests.

FR: Contenu de démo créé une seule fois. Marqué SYNCED pour qu'il ne
soit jamais re-poussé par un moteur de sync partageant ce data dir.
"""
has_user_notebook = any(
not nb.id.startswith(DEFAULT_NOTEBOOK_ID_PREFIX)
for nb in db.list_notebooks()
)
if has_user_notebook:
return None

notebook = Notebook(
name="Mon premier carnet",
color="#6366f1",
sync_status=SyncStatus.SYNCED,
)
db.save_notebook(notebook)

note = Note(
notebook_id=notebook.id,
title="Bienvenue dans NexaNote",
note_type=NoteType.TYPED,
)
page = note.add_page(template="lined")
page.typed_content = (
"# Bienvenue dans NexaNote\n\n"
"Cette note a été créée automatiquement.\n"
"Connecte ton app Flutter à ce serveur WebDAV pour commencer à synchroniser tes notes.\n"
)
# Mark SYNCED so a sync engine sharing this data dir doesn't pick the
# demo note up for push (it already lives on this server).
note.sync_status = SyncStatus.SYNCED
db.save_note(note)
logger.info("Données de démonstration créées")
return note


def build_app(
db: FileNoteStore,
username: str = "nexanote",
Expand Down Expand Up @@ -160,29 +209,7 @@ def run_server(
# 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")
db.save_notebook(nb)
note = Note(
notebook_id=nb.id,
title="Bienvenue dans NexaNote",
note_type=NoteType.TYPED,
)
page = note.add_page(template="lined")
page.typed_content = (
"# Bienvenue dans NexaNote\n\n"
"Cette note a été créée automatiquement.\n"
"Connecte ton app Flutter à ce serveur WebDAV pour commencer à synchroniser tes notes.\n"
)
db.save_note(note)
logger.info("Données de démonstration créées")
seed_demo_data(db)

app = build_app(db, username=username, password=password, verbose=verbose)

Expand Down
113 changes: 66 additions & 47 deletions nexanote/sync/webdav_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -489,24 +489,33 @@ def __init__(
self.note = note

def _serialize(self) -> bytes:
data = {
"id": self.note.id,
"title": self.note.title,
"type": self.note.note_type.value,
"tags": self.note.tags,
"is_pinned": self.note.is_pinned,
"created_at": self.note.created_at.isoformat(),
"updated_at": self.note.updated_at.isoformat(),
"pages": [
{
"page_number": p.page_number,
"template": p.template,
"typed_content": p.typed_content,
}
for p in self.note.pages
],
}
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
try:
data = {
"id": self.note.id,
"title": self.note.title,
"type": self.note.note_type.value,
"tags": self.note.tags,
"is_pinned": self.note.is_pinned,
"created_at": self.note.created_at.isoformat(),
"updated_at": self.note.updated_at.isoformat(),
"pages": [
{
"page_number": p.page_number,
"template": p.template,
"typed_content": p.typed_content,
}
for p in self.note.pages
],
}
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
except Exception as exc:
# Surface the underlying reason instead of a bare 500. WsgiDAV
# calls `_serialize` from `get_content_length`/`get_content`,
# which run before `begin_write` on PUT — a crash here would
# otherwise become "WebDAV upload failed: 500 Internal Server
# Error" with no clue about the offending field.
logger.exception("note.json serialization failed for %s", self.note.id)
raise _safe_dav_error(exc, "could not serialize note.json") from exc

def get_content_length(self) -> int:
return len(self._serialize())
Expand Down Expand Up @@ -643,35 +652,45 @@ def __init__(
self.note = note

def _serialize(self) -> bytes:
data = {
"page_id": self.page.id,
"note_id": self.page.note_id,
"page_number": self.page.page_number,
"template": self.page.template,
"width_px": self.page.width_px,
"height_px": self.page.height_px,
"updated_at": self.page.updated_at.isoformat(),
"strokes": [
{
"id": s.id,
"color": s.color,
"width": s.width,
"tool": s.tool,
"created_at": s.created_at.isoformat(),
"points": [
{
"x": p.x,
"y": p.y,
"pressure": p.pressure,
"ts": p.timestamp_ms,
}
for p in s.points
],
}
for s in self.page.strokes
],
}
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
try:
data = {
"page_id": self.page.id,
"note_id": self.page.note_id,
"page_number": self.page.page_number,
"template": self.page.template,
"width_px": self.page.width_px,
"height_px": self.page.height_px,
"updated_at": self.page.updated_at.isoformat(),
"strokes": [
{
"id": s.id,
"color": s.color,
"width": s.width,
"tool": s.tool,
"created_at": s.created_at.isoformat(),
"points": [
{
"x": p.x,
"y": p.y,
"pressure": p.pressure,
"ts": p.timestamp_ms,
}
for p in s.points
],
}
for s in self.page.strokes
],
}
return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
except Exception as exc:
logger.exception(
"page_%d.ink serialization failed for note %s",
self.page.page_number,
self.note.id,
)
raise _safe_dav_error(
exc, f"could not serialize page_{self.page.page_number}.ink"
) from exc

def get_content_length(self) -> int:
return len(self._serialize())
Expand Down
Loading
Loading