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
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,41 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

---

## Unreleased — Sync reliability & diagnostics

### New

- **Sync planning.** Each sync session now builds a `SyncPlan` recording
what it will push, pull, ignore, and which notes are in conflict, plus
any warnings. The plan is the single source the dry-run mode and the
diagnostic log read from.

- **Dry-run mode.** `NexaNoteSyncEngine(db, config, dry_run=True)` — or
`POST /sync/trigger?dry_run=true` — builds the plan without writing any
note files, touching the sync-state registry, uploading to the remote,
or writing a log. Use it to preview what a real sync would do.

- **Sanitized sync log.** Every real sync writes
`<data_dir>/sync_logs/latest.json` (so `/data/sync_logs/latest.json` in
the Docker image), readable via `GET /sync/log`. It records the
timestamp, duration, counts, pushed/pulled note ids and titles, ignored
legacy remote paths, conflicts, and sanitized errors. It never contains
note body content, passwords, tokens, or server URLs — error strings are
scrubbed and the plan/report only ever hold metadata.

### Improved

- **Conflict safety.** When a note changed both locally and remotely, the
conflict is detected and surfaced in the plan/log instead of being
resolved silently. If the chosen strategy would otherwise drop the local
edits, a `(conflit …)` copy is kept so both versions survive on disk.

- **Idempotent sync state.** A failed sync still persists the sync-state
registry atomically, so a crash mid-session can never leave a corrupt or
half-written `.nexanote_sync_state.json`.

---

## v1.0.0 — File-based storage

### New
Expand Down
46 changes: 42 additions & 4 deletions nexanote/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,12 @@ class SyncReportSchema(BaseModel):
errors: list[str]
duration_seconds: float
summary: str
# Diagnostics — additive, default-valued so existing clients are unaffected.
notes_ignored_legacy: int = 0
dry_run: bool = False
conflicts: list[dict] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
plan: dict = Field(default_factory=dict)


class ExportRequestSchema(BaseModel):
Expand Down Expand Up @@ -558,8 +564,15 @@ def configure_sync(config: SyncConfigSchema):
return {"status": "configured", "server_url": config.server_url}

@app.post("/sync/trigger", response_model=SyncReportSchema)
def trigger_sync():
"""Déclenche une synchronisation manuelle."""
def trigger_sync(dry_run: bool = Query(False)):
"""
EN: Trigger a manual sync. With ``?dry_run=true`` the engine builds
the sync plan but writes no files, touches no sync state, and
performs no remote uploads — handy to preview what a real sync
would do.
FR: Déclenche une synchronisation manuelle. Avec ``?dry_run=true``,
le moteur construit le plan sans rien écrire ni envoyer.
"""
if not _sync_config.get("server_url"):
raise HTTPException(400, "Sync non configurée — appeler POST /sync/configure d'abord")

Expand All @@ -573,9 +586,10 @@ def trigger_sync():
),
)

engine = NexaNoteSyncEngine(db, config)
engine = NexaNoteSyncEngine(db, config, dry_run=dry_run)
report = engine.sync()

plan = report.plan
result = SyncReportSchema(
success=report.success(),
notes_pulled=report.notes_pulled,
Expand All @@ -584,14 +598,38 @@ def trigger_sync():
errors=report.errors,
duration_seconds=report.duration_seconds(),
summary=report.summary(),
notes_ignored_legacy=report.notes_ignored_legacy,
dry_run=report.dry_run,
conflicts=[c.to_dict() for c in plan.conflicts] if plan else [],
warnings=list(plan.warnings) if plan else [],
plan=plan.to_dict() if plan else {},
)
_last_sync_report.update(result.model_dump())
# A dry-run is a preview — it must not clobber the last *real* status.
if not dry_run:
_last_sync_report.clear()
_last_sync_report.update(result.model_dump())
return result

@app.get("/sync/status")
def sync_status():
return _last_sync_report or {"status": "never_synced"}

@app.get("/sync/log")
def sync_log():
"""
EN: Return the latest sanitized sync log written to
``<data_dir>/sync_logs/latest.json``. Contains note ids/titles,
counts, ignored remote paths, conflicts and sanitized errors —
never note body content or credentials.
FR: Renvoie le dernier journal de sync assaini.
"""
from nexanote.sync.sync_log import read_sync_log

payload = read_sync_log(db.data_dir)
if payload is None:
return {"status": "no_log"}
return payload

# ------------------------------------------------------------------
# Export Markdown (Obsidian-friendly)
# ------------------------------------------------------------------
Expand Down
Loading
Loading