Add plain Markdown import and clean export functionality#56
Conversation
- file_store: detect plain `.md` files (no NexaNote frontmatter), surface them as Notes with stable synthetic ids derived from the filename, and leave the source file untouched until the user explicitly saves an edit. - storage/export: new module that writes notes to `<title>.md` files containing only the markdown body (no frontmatter). Filenames are sanitized (forbidden chars stripped, length capped, Windows reserved names handled) and collisions get `(N)` suffixes. Internal storage is never touched. - api/routes: add `POST /export/markdown` to trigger a clean export to a caller-supplied dir (or `<data_dir>/export` by default). - tests: 44 new tests covering plain MD import (legacy + Obsidian style coexist, ids stable, files not rewritten on read, save converts in-place), filename sanitization (invalid chars, blanks, reserved names, length cap), and clean export (body-only output, dedup on duplicates, case-insensitive collision check, internal storage unchanged, plain MDs round-trip cleanly).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d11d84f00d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 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" |
There was a problem hiding this comment.
Sanitize decoded plain-md stems before filesystem access
The new plain-markdown ID path bypasses _safe_id() and directly uses the base64-decoded stem as a path component, so a crafted note_id like md.<base64('../../outside')> resolves outside notes_dir. This affects all operations that call _note_path() (get_note, save_note, delete_note_permanent, etc.), allowing reads/writes/deletes of unintended files whenever an untrusted or malformed ID reaches storage.
Useful? React with 👍 / 👎.
| if cleaned.upper() in _RESERVED_NAMES: | ||
| return f"_{cleaned}" |
There was a problem hiding this comment.
Reject Windows reserved basenames even with extensions
The reserved-name guard only checks exact matches (e.g., CON) and misses reserved device names followed by extensions (e.g., CON.txt, NUL.tar.gz), which Windows still treats as invalid. As a result, exporting notes with those titles can fail with OSError on Windows despite the function claiming cross-platform-safe filenames.
Useful? React with 👍 / 👎.
Summary
This PR adds support for importing plain Markdown files (without NexaNote frontmatter) and exporting notes as clean Markdown files suitable for use in Obsidian and other plain-text tools. Plain
.mdfiles dropped into thenotes/directory are now automatically discovered and surfaced as notes without being rewritten, while exports produce frontmatter-free output with sanitized filenames and collision handling.Key Changes
Plain Markdown Import
.mdfiles are assigned stable synthetic IDs using themd.prefix followed by URL-safe base64-encoded filenames, allowing the original filename to be recovered without an external indexClean Markdown Export
export.pymodule: Providesexport_note()andexport_all()functions that write notes as body-only Markdown files without YAML frontmatter or internal metadatasanitize_filename()removes filesystem-unsafe characters (control chars,<>:"/\|?*), collapses whitespace, handles Windows reserved names, and provides a fallback for blank names(2),(3)suffixes using case-insensitive matching to prevent overwrites on case-insensitive filesystemsAPI Endpoint
/export/markdown: New endpoint accepts optionaltarget_dirandinclude_archivedparameters, defaults to<data_dir>/export, and returns a report with the count and paths of exported filesStorage Layer Updates
file_store.py: Added helper functionsplain_md_id_from_stem(),stem_from_plain_md_id(), andsynthesize_plain_md_note()to handle plain Markdown discovery and synthesis_read_note()andlist_notes(): Updated to synthesize plain Markdown notes when deserialization fails (no frontmatter found)get_stats(): Fixed to correctly count plain Markdown notes and their pagesComprehensive Test Coverage
.mdfiles with stable IDs and non-invasive readsNotable Implementation Details
st_ctime/st_mtime) so external edits are reflected on the next listinglist_notes()filteringhttps://claude.ai/code/session_017Mf5G58RPUBKqKQL7zET2m