Skip to content

Add markdown exporter#5998

Open
clnhlzmn wants to merge 9 commits into
usememos:mainfrom
clnhlzmn:markdown-export
Open

Add markdown exporter#5998
clnhlzmn wants to merge 9 commits into
usememos:mainfrom
clnhlzmn:markdown-export

Conversation

@clnhlzmn

@clnhlzmn clnhlzmn commented May 30, 2026

Copy link
Copy Markdown
Contributor

This adds a simple markdown export background service. See #5988

Adds an optional background runner that mirrors all memos to a directory of markdown files on disk. One-way sync (store → filesystem): new memos become files, edits rewrite in place, deleted/archived memos are pruned.

  • Set MEMOS_MARKDOWN_EXPORT_DIR to enable. Runs once at startup, then every 24h.
  • Layout: <exportDir>/<username>/<YYYY-MM-DD>/<uid>.md
  • Each file is YAML frontmatter (uid, created, updated, visibility, pinned, tags, attachments) followed by the memo's raw markdown. Attachments list each file's filename and storage path (relative to the data dir for local storage); attachment bytes themselves aren't copied.

See server/runner/markdownexport/README.md for details.

@clnhlzmn clnhlzmn requested a review from a team as a code owner May 30, 2026 18:00
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR adds a new background service that periodically exports memos from the store to markdown files on disk. The implementation includes a configurable runner with daily scheduling, per-user directory organization, conditional file writing to preserve mtimes, orphan cleanup, comprehensive tests, documentation, and server integration.

Changes

Markdown Export Runner

Layer / File(s) Summary
Runner Type and Scheduling Loop
server/runner/markdownexport/runner.go
Exports Runner struct with Store and Profile fields; NewRunner constructor and Run scheduling loop trigger RunOnce every 24 hours until context cancellation.
Export Implementation and Helpers
server/runner/markdownexport/runner.go
Core export workflow: RunOnce resolves paths; main export pass lists users, paginates memos, builds user ID→username map, computes deterministic per-memo file paths, and renders/writes markdown with YAML frontmatter. Helpers include memo date calculation, stable tag ordering, conditional file writing to preserve mtimes, path sanitization, attachment path handling/relativization, YAML quoting, and bottom-up directory pruning of unreferenced exports.
Runner Test Suite
server/runner/markdownexport/runner_test.go
Unit tests validate safeSegment, memoDate, and renderMemo behavior; filesystem tests verify conditional rewriting and orphan pruning; end-to-end integration test creates users/memos, exports them, validates file structure/content, deletes a memo, and confirms cleanup.
Feature Specification
server/runner/markdownexport/README.md, AGENTS.md
README documents environment variable configuration, 24-hour schedule with startup execution, per-user/per-UTC-date layout, YAML frontmatter fields, tag sorting, trailing newlines, prune behavior, example file tree, and non-exported data. AGENTS.md notes markdown export among background runner responsibilities and simplifies command examples.
Server Integration
server/server.go
Imports markdownexport package; wires runner into startBackgroundRunners with dedicated cancellable context, initial RunOnce execution, and tracked goroutine for Run with shutdown coordination and stop logging.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Add markdown exporter' directly and clearly summarizes the main change: introducing a new markdown export feature as a background service.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description clearly explains the purpose of adding a markdown export background service, including how to enable it, the directory layout, file format with YAML frontmatter and raw markdown, and references the related documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@server/runner/markdownexport/README.md`:
- Line 66: Update the inaccurate sentence in README.md to reflect that
pruneOrphans removes any .md file not produced by the exporter (so a manually
added README.md in the export tree will be deleted on the next run); reference
the actual behavior implemented in the pruneOrphans function in runner.go and
change the wording to warn that only exporter-written .md files are retained
rather than implying non-.md files are the only ones left alone.
- Line 23: The fenced code block consisting of a bare triple-backtick (```)
should declare a language to satisfy markdownlint MD040; update that fence to
include a language identifier (for example change ``` to ```text or another
appropriate language) so the README.md fenced code block is properly annotated.

In `@server/runner/markdownexport/runner.go`:
- Around line 53-63: Add proper Go doc comments for the exported symbols: the
Runner struct, NewRunner function, Run method, and RunOnce method — place a
concise godoc-style comment immediately above each declaration (starting with
the symbol name) describing its purpose, salient behavior, parameters/returns
and any important side effects; ensure comments for Runner explain the role of
Store and Profile fields, NewRunner describes construction and returned *Runner,
and Run/RunOnce summarize what they execute and how they differ.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 50ee9d1b-1cd2-4a2b-bc74-386b80dd1b73

📥 Commits

Reviewing files that changed from the base of the PR and between 7c3bff4 and 60bdb27.

📒 Files selected for processing (5)
  • AGENTS.md
  • server/runner/markdownexport/README.md
  • server/runner/markdownexport/runner.go
  • server/runner/markdownexport/runner_test.go
  • server/server.go

Comment thread server/runner/markdownexport/README.md Outdated
Comment thread server/runner/markdownexport/README.md Outdated
Comment thread server/runner/markdownexport/runner.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
server/runner/markdownexport/runner.go (2)

150-152: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Track expected paths before writing, not only successful writes.

A memo path is added to written only after writeIfChanged succeeds. If rewriting an already-exported memo fails once, its old file is omitted from written and the prune step deletes the last good export for a memo that still exists. Pruning should be driven by the set of live/expected memo paths, independent of whether this run managed to rewrite them.

Suggested fix
-	// Track every file we write this run. Anything in the tree that we did not
-	// write is an orphan (its memo was deleted or archived) and gets pruned.
-	written := make(map[string]struct{})
+	// Track every live memo path we expect to exist after this run. Pruning
+	// must be based on liveness, not on whether a rewrite happened to succeed.
+	livePaths := make(map[string]struct{})
@@
 			relPath := filepath.Join(safeSegment(username), memoDate(memo), safeSegment(memo.UID)+".md")
 			absPath := filepath.Join(exportDir, relPath)
+			livePaths[absPath] = struct{}{}
 
 			if err := writeIfChanged(absPath, renderMemo(memo, attachmentsByMemo[memo.ID], dataDir)); err != nil {
 				slog.Error("failed to write memo file",
 					slog.String("path", absPath),
 					slog.String("error", err.Error()))
 				continue
 			}
-			written[absPath] = struct{}{}
 		}
 	}
 
-	pruneOrphans(exportDir, written)
+	pruneOrphans(exportDir, livePaths)
 	slog.Info("markdown export complete",
 		slog.String("dir", exportDir),
-		slog.Int("files", len(written)))
+		slog.Int("files", len(livePaths)))

Also applies to: 189-195, 201-204


154-165: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Offset pagination makes orphan pruning unsafe on a live store.

This pass pages with Offset/Limit, but the runner is wired into the server and runs while memos can still be created, deleted, or archived. If rows shift between pages, a live memo can be skipped and never enter written, then pruneOrphans deletes its existing .md as if it were gone. Use a snapshot-stable traversal here (keyset pagination on an immutable cursor, or export from a captured ID set) before pruning based on the result set.
Based on the live server wiring in server/server.go:171-184, this export runs concurrently with normal store mutations.

Also applies to: 198-205

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@server/runner/markdownexport/runner.go` around lines 154 - 165, The loop
currently pages memos using offset/limit (variables offset, limit,
listBatchSize) and r.Store.ListMemos which is unsafe on a live store because
rows can shift and cause memos to be skipped before pruneOrphans/written runs
(also affects the second loop at the other location). Replace offset pagination
with a snapshot-stable traversal: either (A) first capture a stable ID set by
repeatedly calling a keyset-style ListMemos variant (e.g., order by created_at
or ID and use a lastSeenID/createdTs cursor in store.FindMemo) to collect all
memo IDs, or (B) if the store supports a snapshot/export API, call that to get
the immutable set of IDs; then iterate that fixed ID set to build the written
map and run pruneOrphans. Update calls that use store.FindMemo/ListMemos (and
remove offset usage) so pagination is driven by a cursor (e.g., lastID or
createdTs) and ensure ordering is deterministic to avoid live-store skews.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@server/runner/markdownexport/runner.go`:
- Around line 154-165: The loop currently pages memos using offset/limit
(variables offset, limit, listBatchSize) and r.Store.ListMemos which is unsafe
on a live store because rows can shift and cause memos to be skipped before
pruneOrphans/written runs (also affects the second loop at the other location).
Replace offset pagination with a snapshot-stable traversal: either (A) first
capture a stable ID set by repeatedly calling a keyset-style ListMemos variant
(e.g., order by created_at or ID and use a lastSeenID/createdTs cursor in
store.FindMemo) to collect all memo IDs, or (B) if the store supports a
snapshot/export API, call that to get the immutable set of IDs; then iterate
that fixed ID set to build the written map and run pruneOrphans. Update calls
that use store.FindMemo/ListMemos (and remove offset usage) so pagination is
driven by a cursor (e.g., lastID or createdTs) and ensure ordering is
deterministic to avoid live-store skews.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 09b0f2a5-2274-4e19-ba0d-0ead3607f8f3

📥 Commits

Reviewing files that changed from the base of the PR and between 449ae66 and 618b020.

📒 Files selected for processing (3)
  • server/runner/markdownexport/README.md
  • server/runner/markdownexport/runner.go
  • server/runner/markdownexport/runner_test.go
✅ Files skipped from review due to trivial changes (1)
  • server/runner/markdownexport/README.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/runner/markdownexport/runner_test.go

clnhlzmn and others added 4 commits June 6, 2026 19:01
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.

1 participant