Add plain Markdown storage backend with auto-export support#57
Conversation
Mirror managed notes as Obsidian-friendly `<title>.md` files in a target directory whenever a note is created, saved, renamed, or pulled from a sync server. The mirror is opt-in via NEXANOTE_AUTO_EXPORT_MARKDOWN and its location is configurable via NEXANOTE_MARKDOWN_EXPORT_DIR (defaults to <data_dir>/export). The exporter is owned by FileNoteStore and runs after every save_note, so REST, WebDAV, and sync pull writes all stay reflected without touching the internal storage format. A small JSON sidecar tracks the note→file mapping so renames move the file, soft-delete/archive removes it, and same-title collisions get the existing `(N)` suffix. https://claude.ai/code/session_01F3tzvA8HSUVpVKp2h4py69
Add PlainMarkdownNoteStore as a second storage backend alongside the existing FileNoteStore (YAML frontmatter). Notes are stored as <Sanitized Title>.md (pure Markdown body) plus a <Sanitized Title>.json sidecar holding id, tags, dates, notebook reference and per-page metadata. Notebooks and drawings keep their existing layout so REST, WebDAV and sync engine code paths work against either backend without modification. Backend selection: - .nexanote_storage_mode marker file pins the choice for a data dir - NEXANOTE_STORAGE_MODE env var (`yaml` | `plain`) is the fallback - default stays `yaml` to preserve existing data `create_store(data_dir)` reads the marker / env and returns the right backend; main.py and the WebDAV server now use it. A new `migrate_yaml_to_plain` helper converts an existing YAML-backed data directory in place, backing up the original `.md` files to `notes/_yaml_backup/`. Stable ids come from the sidecar; .md files dropped in by the user (e.g. an Obsidian vault) get a deterministic id derived from the filename so the API can address them until they are saved through NexaNote. https://claude.ai/code/session_01F3tzvA8HSUVpVKp2h4py69
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cad669028d
ℹ️ 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".
| if mode == MODE_PLAIN: | ||
| return PlainMarkdownNoteStore(data_dir, auto_export=auto_export) |
There was a problem hiding this comment.
Block plain mode on non-migrated YAML stores
create_store switches to PlainMarkdownNoteStore as soon as mode == plain, but it never verifies that the directory has already been converted from YAML-frontmatter files. In a data dir that still contains YAML notes, setting NEXANOTE_STORAGE_MODE=plain makes those files get interpreted as external plain Markdown, which changes note IDs (to synthetic md.* IDs) and makes previous IDs unreachable for API/sync clients. Add a migration precondition (or explicit refusal with guidance) before allowing the plain backend.
Useful? React with 👍 / 👎.
| write_mode_marker(data_dir, MODE_PLAIN) | ||
| marker.write_text( | ||
| _marker_payload( |
There was a problem hiding this comment.
Avoid marking failed YAML→plain migrations as complete
The migration always writes the plain mode marker and completion marker even when some notes fail to convert and report.errors is non-empty. That leaves the store pinned to plain mode while missing notes from the active dataset, and subsequent retries are skipped because the marker is already present. Gate marker writes on a fully successful conversion (or fail fast) so partial migrations can be retried safely.
Useful? React with 👍 / 👎.
Summary
This PR introduces a new plain Markdown storage backend (
PlainMarkdownNoteStore) as an alternative to the existing YAML-frontmatter backend. Notes are stored as clean.mdfiles with metadata in.jsonsidecars, making them directly compatible with Obsidian and other plain-Markdown tools. It also adds automatic export functionality to keep an Obsidian-friendly mirror in sync with the primary storage.Key Changes
New Storage Backend
PlainMarkdownNoteStore(nexanote/storage/plain_store.py): Alternative backend that stores notes as<Title>.md+<Title>.jsonsidecar pairs.mdfiles dropped in by users (Obsidian imports) with deterministic id derivationFileNoteStorefor REST routes, WebDAV, and sync engine compatibilityBackend Factory & Mode Selection
nexanote/storage/backend.py(new): Factory module for backend selectiondetect_mode(): Resolves backend from on-disk marker → env var → default (YAML)create_store(): Factory function that instantiates the appropriate backend.nexanote_storage_mode) prevents accidental backend swapsNEXANOTE_STORAGE_MODEallows explicit backend selectionAutomatic Export
AutoExporterclass innexanote/storage/export.py: Maintains an Obsidian-friendly mirror.nexanote_export_index.json) to track exported filenames(N)suffixesNEXANOTE_AUTO_EXPORT_MARKDOWNenv varNEXANOTE_MARKDOWN_EXPORT_DIRMigration Support
migrate_yaml_to_plain()innexanote/storage/migration.py: Converts existing YAML stores to plain Markdownnotes/_yaml_backup/Comprehensive Test Coverage
tests/test_plain_store.py(774 lines): Full test suite for the plain backend.mdimport (Obsidian drop-in)tests/test_auto_export.py(582 lines): Auto-export feature testsNotable Implementation Details
.mdfiles without sidecars get deterministic ids derived from filename so they're addressable until first save._atomic_write()helper as the YAML backend to prevent corruption on crash.save_pages=False, existing page content is preserved (mirrors YAML backend behavior).(N)suffixes; the backend tracks which note owns which file via sidecars.https://claude.ai/code/session_01F3tzvA8HSUVpVKp2h4py69