diff --git a/nexanote/api/routes.py b/nexanote/api/routes.py
index 58d5612..69637ca 100644
--- a/nexanote/api/routes.py
+++ b/nexanote/api/routes.py
@@ -30,6 +30,8 @@
POST /sync/trigger → déclencher une sync WebDAV
GET /sync/status → état de la dernière sync
+ POST /export/markdown → export propre vers un dossier (Obsidian)
+
GET /stats → statistiques globales
GET /search?q=... → recherche par titre
"""
@@ -170,6 +172,17 @@ class SyncReportSchema(BaseModel):
summary: str
+class ExportRequestSchema(BaseModel):
+ target_dir: Optional[str] = None
+ include_archived: bool = False
+
+
+class ExportReportSchema(BaseModel):
+ target_dir: str
+ exported: int
+ files: list[str]
+
+
# ---------------------------------------------------------------------------
# Sérialiseurs
# ---------------------------------------------------------------------------
@@ -579,6 +592,37 @@ def trigger_sync():
def sync_status():
return _last_sync_report or {"status": "never_synced"}
+ # ------------------------------------------------------------------
+ # Export Markdown (Obsidian-friendly)
+ # ------------------------------------------------------------------
+
+ @app.post("/export/markdown", response_model=ExportReportSchema)
+ def export_markdown(data: ExportRequestSchema):
+ """
+ EN: Write each note as a clean `
.md` file (body only, no
+ frontmatter) into `target_dir`. Defaults to `/export`
+ when no target is given. Internal NexaNote storage is untouched.
+ FR: Écrit chaque note en `.md` propre (corps seul) dans
+ `target_dir`. Par défaut `/export`. N'altère pas
+ le stockage interne.
+ """
+ from nexanote.storage.export import export_all
+
+ target = (
+ Path(data.target_dir).expanduser()
+ if data.target_dir
+ else db.data_dir / "export"
+ )
+ try:
+ paths = export_all(db, target, include_archived=data.include_archived)
+ except OSError as exc:
+ raise HTTPException(500, f"export failed: {exc}")
+ return ExportReportSchema(
+ target_dir=str(target),
+ exported=len(paths),
+ files=[str(p) for p in paths],
+ )
+
# ------------------------------------------------------------------
# Stats et recherche
# ------------------------------------------------------------------
diff --git a/nexanote/storage/__init__.py b/nexanote/storage/__init__.py
index 2b2aa63..3276bc9 100644
--- a/nexanote/storage/__init__.py
+++ b/nexanote/storage/__init__.py
@@ -7,6 +7,7 @@
- NexaNoteDB legacy SQLite store, kept for migration
"""
+from nexanote.storage.export import export_all, export_note, sanitize_filename
from nexanote.storage.file_store import FileNoteStore
from nexanote.storage.legacy_db import NexaNoteDB
from nexanote.storage.migration import (
@@ -21,4 +22,7 @@
"MigrationReport",
"needs_migration",
"run_migration",
+ "export_all",
+ "export_note",
+ "sanitize_filename",
]
diff --git a/nexanote/storage/export.py b/nexanote/storage/export.py
new file mode 100644
index 0000000..5421d0d
--- /dev/null
+++ b/nexanote/storage/export.py
@@ -0,0 +1,162 @@
+"""
+NexaNote — Clean Markdown export / Export Markdown propre.
+
+EN: Writes each note as a `.md` file containing only the markdown
+ body — no YAML frontmatter, no NexaNote internals — so the output is
+ directly usable in Obsidian and other plain-markdown tools. The internal
+ NexaNote storage (notes/.md with frontmatter, drawings/.json,
+ notebooks/.yaml) is never touched.
+
+FR: Écrit chaque note dans un fichier `.md` contenant uniquement le
+ corps markdown — sans frontmatter ni métadonnées NexaNote — pour rester
+ compatible Obsidian. Le stockage interne n'est jamais modifié.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from pathlib import Path
+from typing import Optional
+
+from nexanote.models.note import Note
+from nexanote.storage.file_store import FileNoteStore, _atomic_write
+
+logger = logging.getLogger("nexanote.storage.export")
+
+
+# ---------------------------------------------------------------------------
+# Filename sanitization
+# ---------------------------------------------------------------------------
+
+# Reserved across Windows + POSIX path separators + ASCII control chars.
+_INVALID_RE = re.compile(r'[\x00-\x1f<>:"/\\|?*]')
+_WS_RE = re.compile(r"\s+")
+_MAX_LEN = 200
+_RESERVED_NAMES = {
+ "CON", "PRN", "AUX", "NUL",
+ *(f"COM{i}" for i in range(1, 10)),
+ *(f"LPT{i}" for i in range(1, 10)),
+}
+_FALLBACK = "untitled"
+
+
+def sanitize_filename(name: str) -> str:
+ """
+ EN: Strip filesystem-unsafe characters, collapse whitespace, and trim
+ trailing dots/spaces so the result is safe on Windows, macOS and
+ Linux. Returns a fallback string when nothing usable remains.
+ FR: Supprime les caractères interdits, fusionne les espaces, retire les
+ points/espaces finaux. Renvoie une valeur de repli si vide.
+ """
+ cleaned = _INVALID_RE.sub("", name or "")
+ cleaned = _WS_RE.sub(" ", cleaned).strip()
+ cleaned = cleaned.rstrip(". ")
+ if len(cleaned) > _MAX_LEN:
+ cleaned = cleaned[:_MAX_LEN].rstrip(". ")
+ if not cleaned:
+ return _FALLBACK
+ if cleaned.upper() in _RESERVED_NAMES:
+ return f"_{cleaned}"
+ return cleaned
+
+
+def _unique_name(base: str, used: set[str]) -> str:
+ """
+ EN: Pick the first `.md`, ` (2).md`, … that doesn't collide
+ with any name in `used` (case-insensitive — Windows/macOS are not
+ case-sensitive on the default filesystems).
+ FR: Renvoie le premier nom libre dans la liste `used` (insensible à
+ la casse, pour rester sûr sous Windows/macOS).
+ """
+ candidate = f"{base}.md"
+ if candidate.lower() not in used:
+ return candidate
+ n = 2
+ while True:
+ candidate = f"{base} ({n}).md"
+ if candidate.lower() not in used:
+ return candidate
+ n += 1
+
+
+def _note_body(note: Note) -> str:
+ """Concatenate every page's typed content, separated by a blank line."""
+ parts = [
+ page.typed_content.strip("\n")
+ for page in note.pages
+ if page.typed_content and page.typed_content.strip()
+ ]
+ if not parts:
+ return ""
+ return "\n\n".join(parts).rstrip("\n") + "\n"
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+def export_note(
+ note: Note,
+ target_dir: Path,
+ used_names: Optional[set[str]] = None,
+) -> Path:
+ """
+ EN: Write `note` to `/.md`. The file
+ contains only the markdown body — no YAML frontmatter. Returns
+ the path that was written.
+
+ `used_names` is a case-insensitive set of `.md` filenames that
+ should not be overwritten; on collision, a `(N)` suffix is added.
+ Pass an empty set to start from scratch, or None to seed it from
+ the directory's existing contents.
+ FR: Écrit `note` en `/.md`. Contenu = corps
+ markdown uniquement, sans frontmatter. Renvoie le chemin écrit.
+ """
+ target_dir = Path(target_dir)
+ target_dir.mkdir(parents=True, exist_ok=True)
+ if used_names is None:
+ used_names = {p.name.lower() for p in target_dir.glob("*.md")}
+
+ base = sanitize_filename(note.title)
+ name = _unique_name(base, used_names)
+ used_names.add(name.lower())
+
+ path = target_dir / name
+ _atomic_write(path, _note_body(note).encode("utf-8"))
+ return path
+
+
+def export_all(
+ store: FileNoteStore,
+ target_dir: Path,
+ include_archived: bool = False,
+) -> list[Path]:
+ """
+ EN: Export every visible note in `store` as a clean Markdown file in
+ `target_dir`. Soft-deleted notes are skipped (they are excluded by
+ `list_notes` already). Returns the list of written file paths.
+ FR: Exporte toutes les notes visibles vers `target_dir` en Markdown
+ propre. Les notes en corbeille sont ignorées.
+ """
+ target_dir = Path(target_dir)
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ used: set[str] = {p.name.lower() for p in target_dir.glob("*.md")}
+ written: list[Path] = []
+ for meta in store.list_notes(include_archived=include_archived):
+ note = store.get_note(meta.id, load_pages=True)
+ if note is None:
+ continue
+ try:
+ written.append(export_note(note, target_dir, used))
+ except OSError as exc:
+ logger.warning(f"export failed for {note.title!r}: {exc}")
+ return written
+
+
+__all__ = [
+ "export_all",
+ "export_note",
+ "sanitize_filename",
+]
diff --git a/nexanote/storage/file_store.py b/nexanote/storage/file_store.py
index 014d3b1..c7bdc9e 100644
--- a/nexanote/storage/file_store.py
+++ b/nexanote/storage/file_store.py
@@ -25,6 +25,7 @@
from __future__ import annotations
+import base64
import json
import logging
import os
@@ -62,6 +63,16 @@
PAGE_MARKER_RE = re.compile(r"^\s*$", re.MULTILINE)
DRAWING_SCHEMA_VERSION = 1
+# EN: Synthetic id prefix used for plain Markdown files (no frontmatter).
+# The remainder is URL-safe base64 of the file stem so the id is stable
+# across reads and the original filename can be recovered without an
+# extra index. The chars used by the prefix and base64 alphabet all
+# pass through `_safe_id()` unchanged.
+# FR: Préfixe d'id pour les fichiers Markdown bruts (sans frontmatter). Le
+# reste est le stem en base64 url-safe : id stable et nom de fichier
+# récupérable sans index annexe.
+PLAIN_MD_ID_PREFIX = "md."
+
# ---------------------------------------------------------------------------
# Helpers
@@ -487,6 +498,9 @@ def close(self) -> None:
# ------------------------------------------------------------------
def _note_path(self, note_id: str) -> Path:
+ stem = stem_from_plain_md_id(note_id)
+ if stem is not None:
+ return self.notes_dir / f"{stem}.md"
return self.notes_dir / f"{_safe_id(note_id)}.md"
def _drawing_path(self, note_id: str) -> Path:
@@ -603,7 +617,10 @@ def _read_note(self, note_id: str, load_pages: bool) -> Optional[Note]:
note = deserialize_note(md_text, drawings)
if note is None:
- return None
+ # Plain Markdown (no NexaNote frontmatter) — surface it as a note
+ # without rewriting the file. Internal storage stays untouched
+ # until the user explicitly saves an edit through NexaNote.
+ note = synthesize_plain_md_note(note_path, md_text)
if not load_pages:
note.pages = []
return note
@@ -625,7 +642,7 @@ def list_notes(
continue
note = deserialize_note(md_text, None)
if note is None:
- continue
+ note = synthesize_plain_md_note(path, md_text)
note.pages = [] # listings are metadata-only
if not include_deleted and note.is_deleted:
@@ -734,17 +751,24 @@ def get_stats(self) -> dict:
notebooks += 1
for path in self.notes_dir.glob("*.md"):
- note = self._read_note(path.stem, load_pages=True)
- if note is None:
+ try:
+ md_text = path.read_text(encoding="utf-8")
+ except OSError:
continue
+ note = deserialize_note(md_text, None)
+ if note is None:
+ note = synthesize_plain_md_note(path, md_text)
if note.is_deleted:
notes_deleted += 1
continue
if note.is_archived:
continue
+ # Reload with pages/strokes for the body counters. Plain MDs don't
+ # have a drawings sidecar, so this is a no-op for them.
+ full = self._read_note(note.id, load_pages=True) or note
notes += 1
- pages += len(note.pages)
- for page in note.pages:
+ pages += len(full.pages)
+ for page in full.pages:
strokes += len(page.strokes)
return {
@@ -761,6 +785,7 @@ def get_stats(self) -> dict:
# ---------------------------------------------------------------------------
_SAFE_ID_RE = re.compile(r"[^A-Za-z0-9._-]")
+_BASE64_URLSAFE_RE = re.compile(r"[A-Za-z0-9_\-]+")
def _safe_id(value: str) -> str:
@@ -771,6 +796,64 @@ def _safe_id(value: str) -> str:
return sanitized[:160] # generous cap
+def plain_md_id_from_stem(stem: str) -> str:
+ """Build the synthetic note id used to address a plain `.md` file."""
+ encoded = base64.urlsafe_b64encode(stem.encode("utf-8")).decode("ascii").rstrip("=")
+ return f"{PLAIN_MD_ID_PREFIX}{encoded}"
+
+
+def stem_from_plain_md_id(note_id: str) -> Optional[str]:
+ """Inverse of `plain_md_id_from_stem`. Returns None for non-plain ids."""
+ if not note_id or not note_id.startswith(PLAIN_MD_ID_PREFIX):
+ return None
+ encoded = note_id[len(PLAIN_MD_ID_PREFIX):]
+ if not encoded or not _BASE64_URLSAFE_RE.fullmatch(encoded):
+ return None
+ pad = (-len(encoded)) % 4
+ try:
+ return base64.urlsafe_b64decode(encoded + "=" * pad).decode("utf-8")
+ except (ValueError, UnicodeDecodeError):
+ return None
+
+
+def synthesize_plain_md_note(path: Path, md_text: str) -> Note:
+ """
+ EN: Build a Note from a plain Markdown file (no NexaNote frontmatter).
+ Title comes from the filename, body is the raw file content, and
+ timestamps are derived from the filesystem so external edits show
+ up on the next listing.
+ FR: Construit une Note à partir d'un fichier Markdown brut. Le titre
+ vient du nom de fichier, le corps est le contenu, et les dates
+ viennent du système de fichiers.
+ """
+ try:
+ stat = path.stat()
+ created = datetime.fromtimestamp(stat.st_ctime, tz=timezone.utc)
+ updated = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
+ except OSError:
+ created = updated = _now()
+
+ note_id = plain_md_id_from_stem(path.stem)
+ page = Page(
+ id=f"{note_id}::p1",
+ note_id=note_id,
+ page_number=1,
+ template="blank",
+ typed_content=md_text.rstrip("\n"),
+ created_at=created,
+ updated_at=updated,
+ )
+ return Note(
+ id=note_id,
+ title=path.stem,
+ note_type=NoteType.TYPED,
+ sync_status=SyncStatus.LOCAL_ONLY,
+ created_at=created,
+ updated_at=updated,
+ pages=[page],
+ )
+
+
def _merge_metadata(meta_source: Note, with_pages: Note) -> Note:
"""
EN: Used when `save_note(save_pages=False)` is called — overlay the
diff --git a/tests/test_plain_md_and_export.py b/tests/test_plain_md_and_export.py
new file mode 100644
index 0000000..5ff234c
--- /dev/null
+++ b/tests/test_plain_md_and_export.py
@@ -0,0 +1,378 @@
+"""
+Tests NexaNote — import de .md bruts + export Markdown propre.
+
+EN: Covers the plain-Markdown import path (Obsidian-style files dropped
+ into notes/) and the clean export pipeline (no YAML frontmatter,
+ sanitized filenames, collision-free).
+FR: Couvre l'import des .md bruts (style Obsidian) et l'export Markdown
+ propre (sans frontmatter, avec noms de fichiers nettoyés).
+"""
+
+import sys
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+
+import pytest
+from fastapi.testclient import TestClient
+
+from nexanote.api.routes import create_app
+from nexanote.models.note import Note, NoteType
+from nexanote.storage import FileNoteStore
+from nexanote.storage.export import (
+ export_all,
+ export_note,
+ sanitize_filename,
+)
+from nexanote.storage.file_store import (
+ plain_md_id_from_stem,
+ stem_from_plain_md_id,
+)
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def store(tmp_path):
+ s = FileNoteStore(tmp_path / "store")
+ yield s
+ s.close()
+
+
+def _seed_plain(store, name: str, body: str) -> Path:
+ """Drop a plain Markdown file (no frontmatter) into the store's notes dir."""
+ path = store.notes_dir / f"{name}.md"
+ path.write_text(body, encoding="utf-8")
+ return path
+
+
+def _typed_note(title: str, body: str = "body") -> Note:
+ note = Note(title=title, note_type=NoteType.TYPED)
+ note.add_page().typed_content = body
+ return note
+
+
+# ---------------------------------------------------------------------------
+# Plain MD id <-> stem round-trip
+# ---------------------------------------------------------------------------
+
+class TestPlainMdId:
+ def test_roundtrip_ascii(self):
+ assert stem_from_plain_md_id(plain_md_id_from_stem("Hello")) == "Hello"
+
+ def test_roundtrip_unicode(self):
+ assert stem_from_plain_md_id(plain_md_id_from_stem("Café ☕")) == "Café ☕"
+
+ def test_roundtrip_with_spaces(self):
+ assert stem_from_plain_md_id(plain_md_id_from_stem("My Notes 2025")) == "My Notes 2025"
+
+ def test_non_plain_id_returns_none(self):
+ assert stem_from_plain_md_id("not-a-plain-id") is None
+ assert stem_from_plain_md_id("") is None
+ assert stem_from_plain_md_id("abc-123-uuid") is None
+
+ def test_id_survives_safe_id_filter(self):
+ # The synthesized id must only contain chars that the storage layer
+ # leaves untouched, otherwise _note_path would mangle the lookup.
+ from nexanote.storage.file_store import _safe_id
+ encoded = plain_md_id_from_stem("hello world")
+ assert _safe_id(encoded) == encoded
+
+
+# ---------------------------------------------------------------------------
+# Plain MD import
+# ---------------------------------------------------------------------------
+
+class TestPlainMdImport:
+ def test_plain_md_listed_as_note(self, store):
+ _seed_plain(store, "Hello World", "# Hello\n\nThis is plain MD.")
+ notes = store.list_notes()
+ assert len(notes) == 1
+ assert notes[0].title == "Hello World"
+ assert notes[0].note_type == NoteType.TYPED
+
+ def test_plain_md_get_returns_body(self, store):
+ _seed_plain(store, "Doc", "Plain body content.")
+ note_id = store.list_notes()[0].id
+ full = store.get_note(note_id, load_pages=True)
+ assert full is not None
+ assert full.title == "Doc"
+ assert len(full.pages) == 1
+ assert full.pages[0].typed_content == "Plain body content."
+
+ def test_plain_md_id_is_stable(self, store):
+ _seed_plain(store, "stable", "x")
+ first = store.list_notes()[0].id
+ second = store.list_notes()[0].id
+ assert first == second
+ assert store.get_note(first) is not None
+
+ def test_plain_md_unicode_filename(self, store):
+ _seed_plain(store, "Café", "espresso")
+ notes = store.list_notes()
+ assert any(n.title == "Café" for n in notes)
+ # Round-trip through the API id must still resolve.
+ cafe = next(n for n in notes if n.title == "Café")
+ assert store.get_note(cafe.id) is not None
+
+ def test_legacy_frontmatter_md_still_loads(self, store):
+ note = _typed_note("Legacy", "old style content")
+ store.save_note(note)
+ loaded = store.get_note(note.id, load_pages=True)
+ assert loaded is not None
+ assert loaded.title == "Legacy"
+ assert "old style content" in loaded.pages[0].typed_content
+
+ def test_plain_and_managed_coexist(self, store):
+ managed = _typed_note("Managed", "with frontmatter")
+ store.save_note(managed)
+ _seed_plain(store, "Plain", "no frontmatter")
+
+ titles = sorted(n.title for n in store.list_notes())
+ assert titles == ["Managed", "Plain"]
+
+ def test_plain_md_not_rewritten_on_read(self, store):
+ path = _seed_plain(store, "untouched", "leave me alone\n")
+ original = path.read_bytes()
+ # Listing + fetching by id must not rewrite the file.
+ store.list_notes()
+ note_id = store.list_notes()[0].id
+ store.get_note(note_id, load_pages=True)
+ assert path.read_bytes() == original
+
+ def test_save_converts_plain_md_in_place(self, store):
+ path = _seed_plain(store, "Editme", "original body")
+ note_id = store.list_notes()[0].id
+ note = store.get_note(note_id, load_pages=True)
+ note.pages[0].typed_content = "edited body"
+ store.save_note(note)
+
+ # Same file, now NexaNote-managed (frontmatter present).
+ assert path.exists()
+ text = path.read_text(encoding="utf-8")
+ assert text.startswith("---\n")
+ assert "edited body" in text
+ # Re-read via the same id still resolves.
+ again = store.get_note(note_id, load_pages=True)
+ assert again is not None
+ assert "edited body" in again.pages[0].typed_content
+
+ def test_stats_count_plain_md(self, store):
+ _seed_plain(store, "A", "x")
+ _seed_plain(store, "B", "y")
+ stats = store.get_stats()
+ assert stats["notes"] >= 2
+
+
+# ---------------------------------------------------------------------------
+# Filename sanitization
+# ---------------------------------------------------------------------------
+
+class TestSanitize:
+ @pytest.mark.parametrize("ch", list('<>:"/\\|?*'))
+ def test_strips_each_invalid_char(self, ch):
+ out = sanitize_filename(f"a{ch}b")
+ assert ch not in out
+
+ def test_strips_control_chars(self):
+ out = sanitize_filename("a\x00b\x01c")
+ assert "\x00" not in out
+ assert "\x01" not in out
+
+ def test_blank_returns_fallback(self):
+ assert sanitize_filename("") == "untitled"
+ assert sanitize_filename(" ") == "untitled"
+ assert sanitize_filename("///") == "untitled"
+
+ def test_collapses_whitespace(self):
+ assert sanitize_filename("hello world") == "hello world"
+
+ def test_truncates_long_names(self):
+ out = sanitize_filename("x" * 1000)
+ assert 1 <= len(out) <= 200
+
+ def test_strips_trailing_dot_and_space(self):
+ assert not sanitize_filename("name. ").endswith(".")
+ assert not sanitize_filename("name. ").endswith(" ")
+
+ def test_reserved_windows_name_prefixed(self):
+ # CON/PRN/AUX/NUL are reserved on Windows — must not collide.
+ out = sanitize_filename("CON")
+ assert out.upper() != "CON"
+
+
+# ---------------------------------------------------------------------------
+# Clean export
+# ---------------------------------------------------------------------------
+
+class TestCleanExport:
+ def test_export_writes_body_only(self, store, tmp_path):
+ note = _typed_note("Markdown export", "# Body\n\nClean output.")
+ store.save_note(note)
+
+ out = tmp_path / "obsidian"
+ paths = export_all(store, out)
+ assert len(paths) == 1
+
+ text = paths[0].read_text(encoding="utf-8")
+ assert not text.startswith("---")
+ assert "nexanote:page" not in text
+ assert "# Body" in text
+ assert "Clean output." in text
+
+ def test_filename_from_title(self, store, tmp_path):
+ store.save_note(_typed_note("Réunion équipe", "notes"))
+ out = tmp_path / "out"
+ path = export_all(store, out)[0]
+ assert path.name == "Réunion équipe.md"
+
+ def test_invalid_chars_sanitized(self, store, tmp_path):
+ store.save_note(_typed_note("bad/title:with*chars?", "x"))
+ out = tmp_path / "out"
+ path = export_all(store, out)[0]
+ for forbidden in '/:*?<>"\\|':
+ assert forbidden not in path.name
+ assert path.name.endswith(".md")
+ assert path.exists()
+
+ def test_blank_title_uses_fallback(self, store, tmp_path):
+ store.save_note(_typed_note(" ", "body"))
+ out = tmp_path / "out"
+ path = export_all(store, out)[0]
+ assert path.name == "untitled.md"
+
+ def test_duplicate_titles_handled(self, store, tmp_path):
+ for i in range(3):
+ store.save_note(_typed_note("Same", f"body {i}"))
+ out = tmp_path / "out"
+ names = sorted(p.name for p in export_all(store, out))
+ assert names == ["Same (2).md", "Same (3).md", "Same.md"]
+
+ def test_does_not_overwrite_existing_files(self, store, tmp_path):
+ out = tmp_path / "out"
+ out.mkdir()
+ kept = out / "Notes.md"
+ kept.write_text("preexisting content\n", encoding="utf-8")
+
+ store.save_note(_typed_note("Notes", "exported content"))
+ export_all(store, out)
+
+ assert kept.read_text(encoding="utf-8") == "preexisting content\n"
+ assert (out / "Notes (2).md").exists()
+ assert "exported content" in (out / "Notes (2).md").read_text()
+
+ def test_collisions_case_insensitive(self, store, tmp_path):
+ # "notes.md" and "Notes.md" must not collide on case-insensitive FS.
+ out = tmp_path / "out"
+ out.mkdir()
+ (out / "notes.md").write_text("x", encoding="utf-8")
+ store.save_note(_typed_note("Notes", "exported"))
+ names = {p.name for p in export_all(store, out)}
+ assert "Notes.md" not in names
+
+ def test_internal_storage_untouched(self, store, tmp_path):
+ note = _typed_note("Internal", "body")
+ store.save_note(note)
+ internal = store.notes_dir / f"{note.id}.md"
+ before = internal.read_bytes()
+
+ export_all(store, tmp_path / "out")
+
+ after = internal.read_bytes()
+ assert before == after
+ assert after.startswith(b"---\n"), "frontmatter must remain in internal file"
+
+ def test_skips_soft_deleted(self, store, tmp_path):
+ note = _typed_note("Trash", "x")
+ note.soft_delete()
+ store.save_note(note)
+ assert export_all(store, tmp_path / "out") == []
+
+ def test_export_plain_md_passthrough(self, store, tmp_path):
+ # Plain MD already in notes_dir → exported as a clean copy too.
+ _seed_plain(store, "PlainExisting", "# Plain\n\nbody\n")
+ out = tmp_path / "out"
+ paths = export_all(store, out)
+ assert len(paths) == 1
+ assert paths[0].name == "PlainExisting.md"
+ text = paths[0].read_text(encoding="utf-8")
+ assert "# Plain" in text
+ assert not text.startswith("---")
+
+ def test_multi_page_joined_with_blank_line(self, store, tmp_path):
+ note = Note(title="Multi", note_type=NoteType.TYPED)
+ note.add_page().typed_content = "page one"
+ note.add_page().typed_content = "page two"
+ store.save_note(note)
+
+ path = export_all(store, tmp_path / "out")[0]
+ text = path.read_text(encoding="utf-8")
+ assert "page one" in text
+ assert "page two" in text
+ assert "nexanote:page" not in text # internal markers stripped
+
+ def test_export_note_returns_path(self, store, tmp_path):
+ note = _typed_note("Single", "hello")
+ store.save_note(note)
+ loaded = store.get_note(note.id, load_pages=True)
+ out = tmp_path / "single"
+ path = export_note(loaded, out)
+ assert path == out / "Single.md"
+ assert path.read_text(encoding="utf-8") == "hello\n"
+
+
+# ---------------------------------------------------------------------------
+# API endpoint
+# ---------------------------------------------------------------------------
+
+class TestExportEndpoint:
+ @pytest.fixture
+ def client(self, tmp_path):
+ db = FileNoteStore(tmp_path / "api_export_store")
+ # Seed one managed + one plain note.
+ managed = Note(title="Managed", note_type=NoteType.TYPED)
+ managed.add_page().typed_content = "managed body"
+ db.save_note(managed)
+ (db.notes_dir / "Imported.md").write_text("plain body\n", encoding="utf-8")
+
+ app = create_app(db)
+ with TestClient(app) as c:
+ yield c, db
+ db.close()
+
+ def test_endpoint_writes_clean_md(self, client, tmp_path):
+ c, db = client
+ target = tmp_path / "obsidian-vault"
+ resp = c.post("/export/markdown", json={"target_dir": str(target)})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["exported"] == 2
+ assert data["target_dir"] == str(target)
+
+ # Every produced file must be plain markdown (no frontmatter).
+ for fpath in data["files"]:
+ text = Path(fpath).read_text(encoding="utf-8")
+ assert not text.startswith("---")
+
+ def test_endpoint_default_target(self, client):
+ c, db = client
+ resp = c.post("/export/markdown", json={})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["target_dir"].endswith("export")
+ assert data["exported"] == 2
+
+ def test_endpoint_does_not_touch_internal_storage(self, client, tmp_path):
+ c, db = client
+ before = {
+ p.name: p.read_bytes()
+ for p in db.notes_dir.glob("*.md")
+ }
+ c.post("/export/markdown", json={"target_dir": str(tmp_path / "out")})
+ after = {
+ p.name: p.read_bytes()
+ for p in db.notes_dir.glob("*.md")
+ }
+ assert before == after