Philo stores daily notes as markdown files on disk, but edits them in memory as TipTap JSON. The storage boundary is intentionally opinionated: the app normalizes markdown on the way in, normalizes it again on the way out, and treats the markdown file as the long-term source of truth.
- In memory:
DailyNote.contentis a JSON string containing a TipTap document. - On disk: each note is a
.mdfile, optionally prefixed with frontmatter. apps/desktop/src/services/storage.tsowns file I/O.apps/desktop/src/lib/markdown.tsowns markdown parsing and serialization.
That contract is already called out in apps/desktop/src/types/note.ts:
content: string; // TipTap JSON string (in-memory), markdown on diskapps/desktop/src/services/paths.ts decides the note path:
- If
journalDiris configured, Philo writes directly there. - Otherwise, if
vaultDiris configured, Philo writes into the vault's daily-notes folder. - Otherwise it falls back to the app data directory under
journal/. - The filename comes from the configured pattern, defaulting to
YYYY-MM-DD.md.
getNotePath(date) is the final source of truth for note file paths.
The write flow is:
EditableNotelistens to TipTaponUpdate.- Changes are debounced for 500 ms.
- The updated TipTap document is serialized with
editor.getJSON(). saveDailyNote()converts that JSON into markdown and writes the file through a Tauri command.
The important steps in apps/desktop/src/services/storage.ts are:
- Parse the JSON string with
parseJsonContent(). - Convert TipTap JSON to markdown with
json2md(). - Rewrite asset URLs back to relative markdown paths with
unresolveMarkdownImages(). - Rewrite
@mentionsinto Obsidian-style wiki links withconvertAtMentionsToWikiLinks(). - Rewrite canonical date-mention links back into note links with
rewriteDateMentionLinksToNoteLinks(). - Re-attach frontmatter with
buildFrontmatter(). - Call the Tauri command
write_markdown_file.
The Rust side in apps/desktop/src-tauri/src/lib.rs is intentionally thin:
write_markdown_file(path, content)creates parent directories if needed.- Then it writes the raw string to disk.
There is no separate database for note bodies. The markdown file is the source of truth.
apps/desktop/src/lib/markdown.ts is not a straight MarkdownManager.serialize() wrapper. The current write-side logic does a few important normalizations so the saved markdown stays stable and reparses correctly:
- Top-level blocks are serialized one at a time and stitched together with explicit newline counts. This is how Philo preserves empty paragraphs without inheriting TipTap's default double block spacing around lists.
- Consecutive top-level paragraphs are still merged into one markdown paragraph with embedded newline text.
This preserves blank lines between paragraphs without writing placeholder text like
. - Empty bullet items are normalized from
-to-, because TipTap's default serializer emits-but its parser reparses that as plain paragraph text instead of an empty bullet item. - When Philo is pointed at a vault, serialization uses tab indentation (
{ style: "tab", size: 1 }) so the file on disk matches Obsidian's layout more closely.
The practical consequence is that the file on disk is not just "whatever TipTap emitted". Philo post-normalizes the markdown so the next load can reconstruct the same structure.
The read flow is the inverse:
AppLayoutloads today's note withgetOrCreateDailyNote().- Past notes lazy-load when they scroll near the viewport.
loadDailyNote()reads the markdown file from disk.- The markdown is normalized into editor-friendly markup.
md2json()converts that markdown into TipTap JSON.EditableNotereceives that JSON string and callssetContent().
The normalization inside loadDailyNote() is important:
parseFrontmatter()strips frontmatter and extractscity.rewriteNoteLinksToDateMentionLinks()converts daily-note wiki links into canonical date mentions.resolveExcalidrawEmbeds()turns![[drawing.excalidraw]]into a placeholder HTML node.replaceMentionWikiLinksWithChips()turns[[...]]links into mention-chip HTML nodes.resolveMarkdownImages()converts relative markdown image paths into Tauri asset-protocol URLs the editor can display.md2json()parses the final markdown/HTML mix into a TipTap document.
The Tauri command read_markdown_file(path) just returns the raw file contents or null if the file does not exist.
The current load path does several markdown-specific repairs before the content ever reaches the editor:
- Line endings are normalized to
\n. - If the note is in a vault, leading tabs are expanded to parser-friendly spaces for parsing only. This does not rewrite the file on disk.
- Obsidian-style nested bare task lines like
\t [ ] childare rewritten to a parser-safe form like\t- [ ] childbefore lexing. MarkdownManager.instance.lexer()is used directly so Philo can preserve blank lines thatmarkedsometimes reports as:- explicit
spacetokens - leading
\non the next token - trailing
\n\non the previous token
- explicit
- Mixed top-level list tokens are split manually when
markedmerges bullet items and task items into onelisttoken. This is what preserves a blank line between a bullet list and a task list across reloads. - Blank lines are reintroduced as explicit empty TipTap paragraph nodes.
That means the md -> TipTap path is intentionally more opinionated than a raw markdown parse. Philo has to compensate for parser edge cases around blank lines, mixed lists, empty bullet items, and Obsidian-style indentation.
apps/desktop/src/components/journal/EditableNote.tsx configures the live TipTap editor. apps/desktop/src/lib/markdown.ts configures a MarkdownManager with the matching markdown extensions for load/save.
That pairing is what makes the round-trip work:
- Standard markdown nodes come from
StarterKit,TaskList,Image,Link,Table,UnderlineExtension, andHighlight. CustomParagraphpreserves intentionally blank paragraphs.MentionChipExtensionrenders chips in the editor, but serializes them as wiki links like[[2026-03-08]]or[[tag_work|work]].ExcalidrawExtensionrenders an embedded preview in the editor, but serializes back to![[file.excalidraw]].WidgetExtensionrenders an interactive React node view in the editor, but persists as a raw HTML sentinel:
<div data-widget="" data-id="..." data-prompt="..." data-spec="..." data-saved="true"></div>The editor is therefore not reading the markdown file directly on every keystroke. It reads a TipTap document that was derived from markdown when the note was loaded.
The live editor now uses normal block splitting for Enter at the top level. That matters because list creation depends on real paragraph boundaries:
- typing plain text, pressing Enter, and then typing
-should create a real bullet list item - pressing
cmd+lon a blank block should turn that block into a task item - blank lines in the editor are represented as actual empty paragraph nodes, not newline characters embedded in the previous paragraph
The markdown serializer assumes the TipTap document is already structurally correct before it writes to disk. If the editor shape is wrong, the markdown file will be wrong too.
Philo already does a limited form of filesystem sync with the raw markdown file:
- On startup it loads from disk.
- While the app is open, today's note is re-read when the window regains focus.
- The journal directory is also watched for
.mdfile changes; if one changes, Philo refreshes today's note from disk.
This means external edits to today's markdown file can show up in the editor without restarting the app.
Current limitations:
- The watcher only re-syncs today's note, not every already-mounted past note.
- Sync is file-level reload, not operational merge. The latest disk read replaces the in-memory note state.
- Autosave is still editor-driven, so local editor changes are usually written back within 500 ms.
- Local writes suppress the watcher briefly so Philo does not immediately reload the file it just saved and reset the editor selection.
When Philo is pointed at an Obsidian vault, it tries to honor the vault layout instead of inventing its own:
detectObsidianFolders()reads.obsidianconfig to detect the daily-notes folder, attachments folder, excalidraw folder, and filename format.bootstrap_obsidian_vault()can create a minimal.obsidiansetup for a fresh vault.- Mention links and Excalidraw embeds intentionally use Obsidian-friendly syntax so the markdown stays portable.
- Vault-backed notes load with tab indentation assumptions and save back with tab indentation as well.
A note might look like this on disk:
---
city: Seoul
---
- [ ] Review draft [[2026_03_09]]
![[weekly-plan.excalidraw]]
<div data-widget="" data-id="w1" data-prompt="habit tracker" data-spec="{...}" data-saved="true"></div>That same note appears in the editor as:
- a task item with a rendered mention chip
- an embedded Excalidraw block
- an interactive widget node view
The markdown file stays plain and portable, while the editor gets richer UI from the custom TipTap extensions.