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 `<data_dir>/export` + when no target is given. Internal NexaNote storage is untouched. + FR: Écrit chaque note en `<titre>.md` propre (corps seul) dans + `target_dir`. Par défaut `<data_dir>/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 `<title>.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/<id>.md with frontmatter, drawings/<id>.json, + notebooks/<id>.yaml) is never touched. + +FR: Écrit chaque note dans un fichier `<titre>.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 `<base>.md`, `<base> (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 `<target_dir>/<sanitized title>.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 `<target_dir>/<titre nettoyé>.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*nexanote:page\s+(\d+)\s*-->\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