Skip to content

Add plain Markdown storage backend with auto-export support#57

Merged
TheZupZup merged 2 commits into
mainfrom
claude/add-markdown-export-0bjpQ
May 3, 2026
Merged

Add plain Markdown storage backend with auto-export support#57
TheZupZup merged 2 commits into
mainfrom
claude/add-markdown-export-0bjpQ

Conversation

@TheZupZup
Copy link
Copy Markdown
Owner

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 .md files with metadata in .json sidecars, 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>.json sidecar pairs
    • Pure Markdown body with no frontmatter
    • Metadata (id, tags, dates, notebook_id) in JSON sidecars
    • Supports external .md files dropped in by users (Obsidian imports) with deterministic id derivation
    • Full API parity with FileNoteStore for REST routes, WebDAV, and sync engine compatibility
    • Handles title changes with file renaming while keeping note ids stable
    • Conflict resolution and soft delete/archive support

Backend Factory & Mode Selection

  • nexanote/storage/backend.py (new): Factory module for backend selection
    • detect_mode(): Resolves backend from on-disk marker → env var → default (YAML)
    • create_store(): Factory function that instantiates the appropriate backend
    • Mode marker file (.nexanote_storage_mode) prevents accidental backend swaps
    • Env var NEXANOTE_STORAGE_MODE allows explicit backend selection

Automatic Export

  • AutoExporter class in nexanote/storage/export.py: Maintains an Obsidian-friendly mirror
    • Triggered on note create, update, title change, and sync pull
    • Keeps an index (.nexanote_export_index.json) to track exported filenames
    • Handles collisions with (N) suffixes
    • Cleans up after soft delete/archive/hard delete
    • Failures never propagate—export is optional, not a guarantee
    • Disabled by default; opt-in via NEXANOTE_AUTO_EXPORT_MARKDOWN env var
    • Configurable target directory via NEXANOTE_MARKDOWN_EXPORT_DIR

Migration Support

  • migrate_yaml_to_plain() in nexanote/storage/migration.py: Converts existing YAML stores to plain Markdown
    • Backs up original YAML files to notes/_yaml_backup/
    • Idempotent (marker prevents double runs)
    • Writes mode marker so subsequent opens use the plain backend

Comprehensive Test Coverage

  • tests/test_plain_store.py (774 lines): Full test suite for the plain backend

    • Note creation, rename, sync, conflict handling
    • External .md import (Obsidian drop-in)
    • Notebook CRUD parity
    • Backend factory and mode detection
    • YAML → plain migration
  • tests/test_auto_export.py (582 lines): Auto-export feature tests

    • Config parsing from env vars
    • Triggers: create, update, title change, sync pull
    • Safety guarantees: disabled by default, archived/deleted notes excluded, collisions handled
    • Internal storage isolation

Notable Implementation Details

  • Stable IDs: Notes in the plain backend use UUID-based ids stored in sidecars. External .md files without sidecars get deterministic ids derived from filename so they're addressable until first save.
  • Atomic writes: File operations use the same _atomic_write() helper as the YAML backend to prevent corruption on crash.
  • Metadata merging: When save_pages=False, existing page content is preserved (mirrors YAML backend behavior).
  • Collision handling: Filename collisions are resolved with (N) suffixes; the backend tracks which note owns which file via sidecars.
  • Backward compatibility: Existing YAML stores continue to work unchanged; the plain backend is

https://claude.ai/code/session_01F3tzvA8HSUVpVKp2h4py69

claude added 2 commits May 3, 2026 15:42
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
@TheZupZup TheZupZup merged commit 61457d6 into main May 3, 2026
1 check passed
@TheZupZup TheZupZup deleted the claude/add-markdown-export-0bjpQ branch May 3, 2026 16:02
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +135 to +136
if mode == MODE_PLAIN:
return PlainMarkdownNoteStore(data_dir, auto_export=auto_export)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +311 to +313
write_mode_marker(data_dir, MODE_PLAIN)
marker.write_text(
_marker_payload(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants