Architecture
Principle
Disk is source of truth. One note = one .md file. SQLite (drift)
is a rebuildable per-library index (FTS5 search, tree rows, frontmatter
fields) — it stores nothing that cannot be reconstructed from disk.
Layout (lib/src/<module>/)
| Module | Contents |
|---|---|
core/ |
Settings (settings/library_config.dart), logging, the in-flight isolate gauge (isolate_gauge.dart), storage access, themes and their .json transfer, language, shortcuts/launch args, share-in (share_in.dart), the first-run state (welcome.dart), single instance, the tray, the changelog parser |
update/ |
The GitHub-Releases update check, its scheduler and the download of the next build |
library/ |
Library open/session state, note file ops, the note write path (NoteWriter), watcher, image/audio import, Markdown import |
import/ |
Bringing a Notion export into the library (#25): the zip walk, page ids off the names, links rewritten, assets kept |
export/ |
What leaves the app: a note’s one-page HTML (note_html.dart, html_*), the PDF through the system browser and its drawn fallback (export_pdf.dart, pdf_webview.dart), the EPUB books (epub_book.dart, epub_note.dart) and the folder/library zips (export_tree*.dart) |
workspace/ |
The open notes of one library on one device: panes, tabs and their mementos — see workspace.md |
journal/ |
The journal’s day pattern, settings and calendar summaries (see journal) |
reading/ |
Where each PDF and each book was left, kept in .niman/reading.json |
annotations/ |
A file’s companion notes, the annotation model and its marks — see annotations.md |
epub/ |
Reading an EPUB into Markdown, its table of contents, its look and its marks — see epub-reader.md |
history/ |
.history/ versions: manifest, snapshot policy, disk store (off-isolate), NoteHistory service — see sync.md |
diff/ |
Myers line diff with its hunk summary, and the three-way merge over them, shared by history rollback and sync conflicts |
sync/webdav/ |
WebDAV client on dart:io (streamed GET/PUT, PROPFIND parsing, typed failures) and the capability probe — see sync.md |
sync/ |
Sync state store (sync_destinations, sync_items, sync_ops), secure password store, pure reconcile, the engine (full and quick runs), the trigger scheduler and network monitor, and LibrarySyncService for the UI |
ui/sync/ |
WebDAV settings screen (with the trigger options), status icon and panel (with the queue), first-sync and mass-deletion dialogs, conflict screen (merge by region, or whole copies) |
db/ |
AppDatabase (app settings, migration chain) + IndexDatabase (one per library, schema 1, no migrations — delete to rebuild); the indexer facade, the full scan that reconciles one directory at a time with bounded memory (#302), the tree materialization and the content store |
markdown/ |
The one Markdown surface (unified-surface.md): SourceBuffer, the block scanner and parser (over the markdown AST), the styler, the surface controller, and under render/ the source view (modes source and live, the WYSIWYG) and the read view (the preview); edit/ holds selection, caret motion, input, history and find |
editor/ |
What the surface is driven by: the incremental tokenizer (highlighting.dart), the Markdown commands (md_editing.dart), toolbar and its layout, context menu, find bar, outline, word count, list tally, typewriter and note column |
preview/ |
KaTeX math (typesetting, cache, rasterizing), code highlight, image aspect — what the surface draws with |
links/ |
Wikilink/Markdown-link parse + resolve (single parse rule shared by editor, preview, indexer) |
search/ |
FTS query builder (user text is never raw FTS), field/tag queries, excerpts and the contains scan read from the notes, replace |
frontmatter/ |
YAML parse, known fields (title tags date pinned aliases), field repo |
templates/ |
Substitution engine (engine.dart), directives, includes, ask/choice prompts, counters |
todo/ |
todo.txt line model, file store, filters, reminder scheduling backends |
spellcheck/ |
hunspell (desktop) / system IME (Android) providers, per-library personal dictionary layered in front (right-click Add to dictionary) |
transcription/ |
On-device speech-to-text for audio notes (whisper_ggml): model catalog, downloads, the model directory, transcription settings — see transcription.md |
ui/ |
Shell, tree, settings screens, shared widgets; ui/welcome/ is the first-run deck (#266) and ui/tour/ the guided tour with its target registry |
widget/ |
Android home-screen widgets: placement, payload, refresh, theming, background row ops (native Kotlin providers in android/app/src/main/kotlin/dev/niman/niman/) |
State: Riverpod. No god classes — one class per file, split at ~300 lines or when responsibilities mix.
Key flows
- Open library: read
.niman/settings.jsonand the device’s share (library_device_settings,LibraryConfig.deviceKeys) once intoLibraryConfigRepo(cached per session) → scan files off the UI isolate (Isolate.run— every stat is a FUSE round trip on Android) → upsert intoIndexDatabaseon main. Behind the ready bump, a sweep off the UI isolate removes the.niman-tmp-*files a killed write left (core/files.dart, #379), taking only files older thanstaleTempMinimumAgeso a write in flight is never touched. - Edit:
NoteOps.saveNote→ history snapshot and atomic write (temp + rename) in one isolate pass → the note is rescanned into the index. Every mode of the surface shares one tokenizer for links. - Sync download: a note lands the same way (snapshot + rename on an
isolate). An attachment has no snapshot, so it lands through
swapFileInon the calling isolate instead — a stat, a mkdir and a rename are async I/O that never block the loop, and an isolate around async-only work buys nothing. On Windows that rename hangs on the third attachment of every run, so it is given three seconds and then the bytes are copied instead: a workaround for #103. See sync.md for what has been ruled out. - Isolate jobs: the scan, probe and write passes above go through
IsolateGauge.run(core/isolate_gauge.dart) instead ofIsolate.rundirectly, which logs each job’s start and finish with how many are in flight, and warns when one outlives 10 s. A job that hangs otherwise logs nothing at all — an exported log then shows a gap and no reason, which is what made #103 unreadable until the gauge went in. - Search:
search/query.dartbuilds a safe FTS5 MATCH (tokens quoted, prefix*on last token only);key = valueand#tagtake the field and tag paths instead. The FTS table is contentless (content=''): it holds the word index and no copy of the text, which made the index file as large as the notes. A word result’s excerpt and the contains scan read the note on disk, on an isolate and only up to the first match (search/search_excerpt.dart). - Reminders: first valid
rem:YYYY-MM-DDTHH:MMper task schedules an exact alarm (Android plugin backend, desktop backend); health/warnings surface permission problems. - Home-screen widgets (Android): placed instances are pushed a
payload whenever the todo snapshot or pinned note moves; rows render
natively (RemoteViews) and row taps come back as
niman://intents handled off the UI isolate. Placement runs a native config activity (library/note pick).
Planned, not built
- Nothing large at the moment. The one Markdown surface that was planned here is built (#247): unified-surface.md is its design and record, and editor-alternatives.md the measured record of the packages it replaced.