From 63fedfb52510e4f3d0ac68c37753929d68324495 Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Mon, 16 Feb 2026 01:06:55 -0500 Subject: [PATCH] Add grapho CLI with improved UX New CLI features: - One-liner health check as default (grapho) - Component status dashboard (grapho status) - Verbose mode with details (grapho status -v) - System diagnostics with fix commands (grapho doctor) - Machine-readable output (grapho --json) - Actionable fix suggestions for all warnings/errors Also adds documentation: - docs/MARKDOWN-EDITORS.md - Editor recommendations for mobile/desktop - docs/LUX-LIMITATIONS.md - Tracking Lux language issues Co-Authored-By: Claude Opus 4.5 --- cli/grapho.lux | 452 +++++++++++++++++++++++++++++++++++++++ docs/LUX-LIMITATIONS.md | 137 ++++++++++++ docs/MARKDOWN-EDITORS.md | 238 +++++++++++++++++++++ flake.lock | 89 +++++++- grapho | Bin 0 -> 49288 bytes 5 files changed, 915 insertions(+), 1 deletion(-) create mode 100644 cli/grapho.lux create mode 100644 docs/LUX-LIMITATIONS.md create mode 100644 docs/MARKDOWN-EDITORS.md create mode 100755 grapho diff --git a/cli/grapho.lux b/cli/grapho.lux new file mode 100644 index 0000000..16d4428 --- /dev/null +++ b/cli/grapho.lux @@ -0,0 +1,452 @@ +// grapho - Unified CLI for the Ultimate Notetaking, Sync & Backup System +// +// Usage: +// grapho One-line health check (default) +// grapho status Full status dashboard +// grapho status -v Verbose status with all details +// grapho doctor Diagnose issues and suggest fixes +// grapho sync Sync all (nb + syncthing) +// grapho backup Run backup now +// grapho help Show help +// grapho --json Machine-readable JSON output + +// ============================================================================= +// Types +// ============================================================================= + +type SyncStatus = + | StatusOk + | StatusWarn + | StatusErr + | StatusNone + +// ============================================================================= +// Status icons +// ============================================================================= + +fn statusIcon(s: SyncStatus): String = + match s { + StatusOk => "[ok]", + StatusWarn => "[!!]", + StatusErr => "[ERR]", + StatusNone => "[--]" + } + +fn statusEmoji(s: SyncStatus): String = + match s { + StatusOk => "✓", + StatusWarn => "⚠", + StatusErr => "✗", + StatusNone => "-" + } + +// ============================================================================= +// Shell helpers +// ============================================================================= + +fn hasCommand(cmd: String): Bool with {Process} = { + let result = Process.exec("command -v " + cmd + " 2>/dev/null || true") + String.length(String.trim(result)) > 0 +} + +fn execQuiet(cmd: String): String with {Process} = + String.trim(Process.exec(cmd + " 2>/dev/null || true")) + +fn homeDir(): String with {Process} = + match Process.env("HOME") { + Some(h) => h, + None => "/tmp" + } + +// ============================================================================= +// Syncthing helpers +// ============================================================================= + +fn syncthingRunning(): Bool with {Process} = { + let result = Process.exec("syncthing cli show system 2>/dev/null | jq -r '.myID' 2>/dev/null || true") + String.length(String.trim(result)) > 10 +} + +fn getSyncthingUptime(): String with {Process} = + Process.exec("syncthing cli show system 2>/dev/null | jq -r '.uptime // 0' 2>/dev/null || echo 0") + +fn getSyncthingFolderCount(): String with {Process} = + String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0")) + +fn getSyncthingDeviceCount(): String with {Process} = + String.trim(Process.exec("syncthing cli show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")) + +// ============================================================================= +// One-liner health check (new default) +// ============================================================================= + +fn showHealthCheck(): Unit with {Console, Process} = { + let hasIssues = false + let hasWarnings = false + let summary = "" + + // Check nb + let nbOk = hasCommand("nb") + let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0" + + // Check syncthing + let stOk = hasCommand("syncthing") + let stRunning = if stOk then syncthingRunning() else false + let stFolders = if stRunning then getSyncthingFolderCount() else "0" + let stDevices = if stRunning then getSyncthingDeviceCount() else "0" + + // Check backup + let resticOk = hasCommand("restic") + let timerActive = execQuiet("systemctl is-active restic-backup.timer") + let backupOk = if timerActive == "active" then true else false + + // Determine overall status and print appropriate message + if nbOk == false || stOk == false || resticOk == false then { + Console.print(statusEmoji(StatusErr) + " Issues detected") + if nbOk == false then + Console.print(" " + statusIcon(StatusErr) + " nb: not installed -> nix develop") + else () + if stOk == false then + Console.print(" " + statusIcon(StatusErr) + " syncthing: not installed -> nix develop") + else () + if resticOk == false then + Console.print(" " + statusIcon(StatusErr) + " restic: not installed -> nix develop") + else () + } else if stRunning == false || backupOk == false then { + Console.print(statusEmoji(StatusWarn) + " Warnings") + if stRunning == false then + Console.print(" " + statusIcon(StatusWarn) + " syncthing: not running -> systemctl --user start syncthing") + else () + if backupOk == false then + Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> sudo systemctl enable --now restic-backup.timer") + else () + } else { + Console.print(statusEmoji(StatusOk) + " All systems healthy (" + nbCount + " notebooks, " + stFolders + " folders, " + stDevices + " devices, backup active)") + } +} + +// ============================================================================= +// Status dashboard +// ============================================================================= + +fn showStatus(verbose: Bool): Unit with {Console, Process} = { + Console.print("grapho status") + Console.print("") + + // nb + if hasCommand("nb") then { + let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l") + Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks") + if verbose then { + let notebooks = execQuiet("nb notebooks --names") + if String.length(notebooks) > 0 then { + let lines = String.lines(notebooks) + printNotebooks(lines) + } else () + } else () + } else { + Console.print(statusIcon(StatusErr) + " nb: not installed") + Console.print(" Fix: nix develop") + } + + // Syncthing + if hasCommand("syncthing") then { + if syncthingRunning() then { + let folders = getSyncthingFolderCount() + let devices = getSyncthingDeviceCount() + Console.print(statusIcon(StatusOk) + " syncthing: " + folders + " folders, " + devices + " devices") + if verbose then { + let folderLabels = execQuiet("syncthing cli show config | jq -r '.folders[].label'") + if String.length(folderLabels) > 0 then { + Console.print(" Folders:") + let flines = String.lines(folderLabels) + printLinesIndented(flines) + } else () + let deviceNames = execQuiet("syncthing cli show config | jq -r '.devices[].name'") + if String.length(deviceNames) > 0 then { + Console.print(" Devices:") + let dlines = String.lines(deviceNames) + printLinesIndented(dlines) + } else () + } else () + } else { + Console.print(statusIcon(StatusWarn) + " syncthing: not running") + Console.print(" Fix: systemctl --user start syncthing") + } + } else { + Console.print(statusIcon(StatusErr) + " syncthing: not installed") + Console.print(" Fix: nix develop") + } + + // Backup + if hasCommand("restic") then { + let timerActive = execQuiet("systemctl is-active restic-backup.timer") + if timerActive == "active" then { + Console.print(statusIcon(StatusOk) + " backup: timer active") + } else { + Console.print(statusIcon(StatusWarn) + " backup: timer inactive") + Console.print(" Fix: sudo systemctl enable --now restic-backup.timer") + } + } else { + Console.print(statusIcon(StatusErr) + " backup: restic not installed") + Console.print(" Fix: nix develop") + } +} + +fn printNotebooks(lines: List): Unit with {Console, Process} = + match List.head(lines) { + Some(name) => { + if String.length(name) > 0 then { + let count = execQuiet("find ~/.nb/" + name + " -name '*.md' 2>/dev/null | wc -l") + Console.print(" " + name + ": " + count + " notes") + } else () + match List.tail(lines) { + Some(rest) => printNotebooks(rest), + None => () + } + }, + None => () + } + +fn printLinesIndented(lines: List): Unit with {Console} = + match List.head(lines) { + Some(line) => { + if String.length(line) > 0 then + Console.print(" - " + line) + else () + match List.tail(lines) { + Some(rest) => printLinesIndented(rest), + None => () + } + }, + None => () + } + +// ============================================================================= +// Doctor command +// ============================================================================= + +fn showDoctor(): Unit with {Console, Process} = { + Console.print("grapho doctor") + Console.print("") + Console.print("Checking system health...") + Console.print("") + + // Check nix + if hasCommand("nix") then { + let nixVer = execQuiet("nix --version") + Console.print(statusIcon(StatusOk) + " nix: " + nixVer) + } else { + Console.print(statusIcon(StatusErr) + " nix: not found") + Console.print(" Install: https://nixos.org/download") + } + + // Check git + if hasCommand("git") then { + let gitVer = execQuiet("git --version | cut -d' ' -f3") + Console.print(statusIcon(StatusOk) + " git: " + gitVer) + } else { + Console.print(statusIcon(StatusErr) + " git: not found") + } + + // Check nb + if hasCommand("nb") then { + let nbCount = execQuiet("nb notebooks --names 2>/dev/null | wc -l") + Console.print(statusIcon(StatusOk) + " nb: " + nbCount + " notebooks") + } else { + Console.print(statusIcon(StatusErr) + " nb: not installed") + Console.print(" Fix: nix develop") + } + + // Check syncthing + if hasCommand("syncthing") then { + if syncthingRunning() then { + let folders = getSyncthingFolderCount() + Console.print(statusIcon(StatusOk) + " syncthing: running (" + folders + " folders)") + } else { + Console.print(statusIcon(StatusWarn) + " syncthing: not running") + Console.print(" Fix: systemctl --user start syncthing") + } + } else { + Console.print(statusIcon(StatusErr) + " syncthing: not installed") + Console.print(" Fix: nix develop") + } + + // Check backup + if hasCommand("restic") then { + let timerActive = execQuiet("systemctl is-active restic-backup.timer") + if timerActive == "active" then { + Console.print(statusIcon(StatusOk) + " backup: timer active") + } else { + Console.print(statusIcon(StatusWarn) + " backup: timer inactive") + Console.print(" Fix: sudo systemctl enable --now restic-backup.timer") + } + } else { + Console.print(statusIcon(StatusErr) + " restic: not installed") + Console.print(" Fix: nix develop") + } + + // Additional checks + Console.print("") + Console.print("Additional checks:") + + // Check age key + let ageKey = execQuiet("test -f ~/.config/sops/age/keys.txt && echo yes") + if ageKey == "yes" then + Console.print(statusIcon(StatusOk) + " Age encryption key exists") + else { + Console.print(statusIcon(StatusWarn) + " No age key") + Console.print(" Fix: age-keygen -o ~/.config/sops/age/keys.txt") + } + + Console.print("") + Console.print("Run 'grapho status -v' for detailed component info") +} + +// ============================================================================= +// JSON output +// ============================================================================= + +fn showJson(): Unit with {Console, Process} = { + // Note: JSON output with quotes is difficult in Lux due to escape sequence limitations + // Using a simple key=value format instead + let nbOk = hasCommand("nb") + let nbCount = if nbOk then execQuiet("nb notebooks --names 2>/dev/null | wc -l") else "0" + let nbStatus = if nbOk then "healthy" else "error" + let nbMsg = if nbOk then nbCount + " notebooks" else "not installed" + + let stOk = hasCommand("syncthing") + let stRunning = if stOk then syncthingRunning() else false + let stStatus = if stOk == false then "error" else if stRunning then "healthy" else "warning" + let stMsg = if stOk == false then "not installed" else if stRunning then "running" else "not running" + + let resticOk = hasCommand("restic") + let timerResult = execQuiet("systemctl is-active restic-backup.timer") + let timerActive = if timerResult == "active" then true else false + let backupStatus = if resticOk == false then "error" else if timerActive then "healthy" else "warning" + let backupMsg = if resticOk == false then "not installed" else if timerActive then "timer active" else "timer inactive" + + // Output in a simple parseable format + Console.print("nb.status=" + nbStatus) + Console.print("nb.message=" + nbMsg) + Console.print("syncthing.status=" + stStatus) + Console.print("syncthing.message=" + stMsg) + Console.print("backup.status=" + backupStatus) + Console.print("backup.message=" + backupMsg) +} + +// ============================================================================= +// Sync command +// ============================================================================= + +fn doSync(): Unit with {Console, Process} = { + Console.print("Syncing...") + + // Sync nb notebooks + if hasCommand("nb") then { + Console.print("-> nb sync --all") + let result = Process.exec("nb sync --all 2>&1 || true") + if String.contains(result, "error") then + Console.print(statusIcon(StatusWarn) + " nb sync had issues") + else + Console.print(statusIcon(StatusOk) + " nb synced") + } else () + + // Trigger Syncthing scan + if hasCommand("syncthing") then { + if syncthingRunning() then { + Console.print("-> syncthing cli scan") + let scanResult = Process.exec("syncthing cli scan 2>/dev/null || true") + Console.print(statusIcon(StatusOk) + " Syncthing scan triggered") + } else () + } else () + + Console.print("Done!") +} + +// ============================================================================= +// Backup command +// ============================================================================= + +fn doBackup(): Unit with {Console, Process} = { + Console.print("Running backup...") + + if hasCommand("restic") then { + let hasService = execQuiet("systemctl cat restic-backup.service") + if String.length(hasService) > 0 then { + Console.print("-> sudo systemctl start restic-backup.service") + let startResult = Process.exec("sudo systemctl start restic-backup.service 2>&1 || true") + Console.print(statusIcon(StatusOk) + " Backup service triggered") + } else { + Console.print(statusIcon(StatusWarn) + " No systemd backup service configured") + Console.print(" Configure in modules/backup.nix") + } + } else { + Console.print(statusIcon(StatusErr) + " restic not installed") + } +} + +// ============================================================================= +// Help +// ============================================================================= + +fn showHelp(): Unit with {Console} = { + Console.print("grapho - Personal Data Infrastructure") + Console.print("") + Console.print("Usage:") + Console.print(" grapho Health check (one-liner)") + Console.print(" grapho status Component status") + Console.print(" grapho status -v Verbose status with details") + Console.print(" grapho doctor Diagnose issues and fixes") + Console.print(" grapho sync Sync all (nb + syncthing)") + Console.print(" grapho backup Run backup now") + Console.print(" grapho --json Machine-readable output (key=value format)") + Console.print(" grapho help Show this help") + Console.print("") + Console.print("Quick start:") + Console.print(" nb add Create a new note") + Console.print(" nb search Search notes") + Console.print(" grapho sync Sync to all devices") + Console.print("") + Console.print("More info: https://git.qrty.ink/blu/grapho") +} + +// ============================================================================= +// Main +// ============================================================================= + +fn main(): Unit with {Console, Process} = { + let args = Process.args() + let cmd = match List.get(args, 1) { + Some(c) => c, + None => "" + } + + match cmd { + "" => showHealthCheck(), + "status" => { + let subcmd = match List.get(args, 2) { + Some(s) => s, + None => "" + } + match subcmd { + "-v" => showStatus(true), + "--verbose" => showStatus(true), + "verbose" => showStatus(true), + _ => showStatus(false) + } + }, + "doctor" => showDoctor(), + "sync" => doSync(), + "backup" => doBackup(), + "help" => showHelp(), + "-h" => showHelp(), + "--help" => showHelp(), + "--json" => showJson(), + "-j" => showJson(), + "json" => showJson(), + _ => showHelp() + } +} + +let result = run main() with {} diff --git a/docs/LUX-LIMITATIONS.md b/docs/LUX-LIMITATIONS.md new file mode 100644 index 0000000..e6be597 --- /dev/null +++ b/docs/LUX-LIMITATIONS.md @@ -0,0 +1,137 @@ +# Lux Language Limitations (grapho CLI) + +This document tracks limitations encountered while developing the grapho CLI in Lux, to help improve the language. + +## Fixed Issues + +### 1. Double Execution Bug (FIXED) +**Severity:** Critical +**Status:** Fixed in Lux c_backend.rs + +When using `let result = run main() with {}` to invoke the main function, the entire program was executing twice. + +**Root Cause:** In `c_backend.rs:3878-3907`, the generated C code was: +1. Executing all `run` expressions (including `run main() with {}`) +2. Then ALSO calling `main_lux()` separately because `has_main` was true + +**Fix:** Added tracking of whether main was already called via a `run` expression, and skip the separate `main_lux()` call if so. + +--- + +## String Handling Issues + +### 2. No Escape Sequences in String Literals +**Severity:** High +**Status:** Confirmed + +Lux does not support backslash escape sequences like `\"`, `\n`, `\t` in string literals. + +```lux +// This FAILS - backslash causes parse error +Console.print("Hello \"World\"") // ERROR: Unexpected character: '\' + +// This FAILS +Console.print("Line1\nLine2") // ERROR: Unexpected character: '\' +``` + +**Impact:** Cannot include quotes in strings, cannot create multi-line strings, cannot output JSON with proper formatting. + +**Workaround:** +- Use shell commands via `Process.exec` to generate quoted output +- Use `String.fromChar('"')` for quotes (but this had issues too) +- For JSON output, use key=value format instead + +### 3. Dollar Sign in Strings Causes Parse Error +**Severity:** Medium +**Status:** Confirmed + +The `$` character in strings triggers the string interpolation lexer, even inside shell command strings. + +```lux +// This FAILS +execQuiet("jq -n --arg x '$foo' ...") // ERROR: Unexpected character: '$' +``` + +**Impact:** Cannot use shell variable syntax or jq arguments in command strings. + +**Workaround:** Avoid `$` in strings, or construct commands differently. + +### 4. String.fromChar Returns Int, Not String +**Severity:** Medium +**Status:** Bug + +`String.fromChar('"')` appears to return an Int instead of a String, causing C compilation errors. + +```lux +let q = String.fromChar('"') // Compiles but C code is wrong +Console.print(q + "hello") // C error: int + string +``` + +**Impact:** Cannot use character literals to build strings. + +**Workaround:** Use `execQuiet("printf '%s' '\"'")` to get a quote character. + +--- + +## Type System Issues + +### 5. Record Type Definitions Don't Work as Expected +**Severity:** Medium +**Status:** Needs Investigation + +Defining a record type and then creating values of that type doesn't work: + +```lux +type ComponentStatus = { + name: String, + status: HealthStatus, + message: String, + fix: String +} + +fn checkNb(): ComponentStatus with {Process} = { + // ... + { name: "nb", status: Healthy, message: "ok", fix: "" } + // ERROR: Cannot unify { name: String, ... } with ComponentStatus +} +``` + +**Impact:** Cannot use structured types for cleaner code organization. + +**Workaround:** Avoid record types, use multiple return values via tuples or restructure code. + +### 6. Int.parse Doesn't Exist or Has Wrong Signature +**Severity:** Low +**Status:** Confirmed + +There's no obvious way to parse a string to an integer. + +```lux +let count = Int.parse(someString) // ERROR: Unknown effect operation +``` + +**Impact:** Cannot convert string output from shell commands to numbers. + +**Workaround:** Keep numbers as strings, use shell for numeric comparisons. + +--- + +## Suggestions for Lux + +1. **Add escape sequence support** - At minimum `\"`, `\\`, `\n`, `\t` +2. **Fix String.fromChar** to return String, not Int +3. **Add raw string literals** - Something like `r"..."` or `'''...'''` for shell commands +4. **Fix the double execution bug** in the runtime +5. **Support record type literals** matching their declared type +6. **Add Int.parse and Float.parse** for string-to-number conversion +7. **Consider a heredoc syntax** for multi-line strings with special characters + +--- + +## Current Workarounds in grapho CLI + +1. **Double output:** FIXED in Lux c_backend.rs +2. **JSON output:** Using key=value format instead of proper JSON +3. **Quotes in output:** Avoided entirely or generated via shell +4. **Structured types:** Using individual variables instead of records +5. **Numeric parsing:** Keeping counts as strings throughout diff --git a/docs/MARKDOWN-EDITORS.md b/docs/MARKDOWN-EDITORS.md new file mode 100644 index 0000000..48bac7c --- /dev/null +++ b/docs/MARKDOWN-EDITORS.md @@ -0,0 +1,238 @@ +# Markdown Editors for grapho + +This document covers recommended markdown editors for use with grapho across desktop and mobile platforms. + +## Recommended: md (PWA) + +**URL:** https://md-ashy.vercel.app + +A lightweight, browser-based markdown editor that works on both desktop and mobile. + +### Features +- WYSIWYG editing with inline markdown transformation +- Source mode toggle for raw editing +- Offline support via PWA (installable as app) +- Dark theme +- File drag-and-drop support +- Share documents via compressed URL links +- GitHub Flavored Markdown (GFM) support including tables and task lists +- Syntax highlighting for code blocks +- Keyboard shortcuts (Ctrl+S to download, Ctrl+B/I for formatting) + +### Why It's Good for grapho +- Works on any device with a browser +- Can be installed as a PWA on mobile home screen +- No account required +- Files stay local (privacy-first) +- Can edit files from Syncthing-synced folders + +### Setup +1. Visit https://md-ashy.vercel.app +2. Click the install prompt (or use browser menu > "Add to Home Screen") +3. Open markdown files from your synced folders + +--- + +## Desktop Editors + +### MarkText (Recommended for Desktop) +**Open Source** | **Cross-platform** | [GitHub](https://github.com/marktext/marktext) + +A simple, elegant markdown editor with real-time preview. + +**Pros:** +- Clean, distraction-free interface +- WYSIWYG preview (like Typora, but free) +- Multiple editing modes: Source, Typewriter, Focus +- Six themes (light/dark variants) +- Supports CommonMark, GFM, and Pandoc markdown +- Diagrams (flowcharts, sequence, Gantt via Mermaid) +- Math expressions via KaTeX +- Auto-save and file recovery + +**Cons:** +- Last release was March 2022 (minimally maintained) +- No mobile version + +**Best for:** Writers who want a polished, free Typora alternative. + +--- + +### Visual Studio Code +**Open Source** | **Cross-platform** | [Website](https://code.visualstudio.com) + +The developer's Swiss Army knife with excellent markdown support. + +**Pros:** +- Built-in markdown preview +- Extensive extension ecosystem (markdownlint, Markdown All in One, etc.) +- Git integration built-in +- Works with any programming workflow +- Highly customizable + +**Cons:** +- Resource-heavy for just markdown editing +- Can feel like overkill for simple notes + +**Best for:** Developers who want one editor for code and notes. + +--- + +### Obsidian +**Freemium** | **Cross-platform** | [Website](https://obsidian.md) + +A powerful knowledge base that works on local markdown files. + +**Pros:** +- Bidirectional linking between notes +- Graph view of note connections +- Extensive plugin ecosystem (900+ plugins) +- Local-first, privacy-focused +- Mobile apps (iOS/Android) +- Sync available (paid) or use Syncthing + +**Cons:** +- Not fully open source (free for personal use) +- Learning curve for advanced features +- Can become complex with too many plugins + +**Best for:** Building a personal knowledge base / "second brain". + +--- + +### Zettlr +**Open Source** | **Cross-platform** | [Website](https://www.zettlr.com) + +Built for academics and researchers. + +**Pros:** +- Built-in citation management (Zotero integration) +- Footnotes and LaTeX support +- Zettelkasten method support +- Export to PDF, Word, LaTeX via Pandoc +- Focus on long-form writing + +**Cons:** +- No mobile app +- Steeper learning curve +- Requires Pandoc for some exports + +**Best for:** Academic writing, research papers, thesis work. + +--- + +### Joplin +**Open Source** | **Cross-platform** | [Website](https://joplinapp.org) + +Note-taking with sync and mobile apps. + +**Pros:** +- End-to-end encryption +- Mobile apps (iOS/Android) +- Sync with Nextcloud, Dropbox, OneDrive, WebDAV +- Import from Evernote +- Notebooks and tagging +- Web clipper extension + +**Cons:** +- Notes stored in SQLite database, not plain files +- Can be resource-intensive +- Less suited for power users who want plain markdown + +**Best for:** Evernote replacement with cross-platform sync. + +--- + +## Mobile Editors + +### Markor (Android) +**Open Source** | [GitHub](https://github.com/gsantner/markor) + +The best open-source markdown editor for Android. + +**Pros:** +- Works with any folder (including Syncthing) +- No account required +- Supports markdown, todo.txt, and more +- Offline-first + +**Best for:** grapho users on Android. + +### iA Writer (iOS/Android) +**Paid** | [Website](https://ia.net/writer) + +Premium minimalist writing experience. + +**Pros:** +- Beautiful, distraction-free interface +- Works with iCloud/Dropbox folders +- Focus mode highlights current sentence + +**Cons:** +- Paid app +- File management less flexible than Markor + +**Best for:** iOS users who value polish. + +### Obsidian Mobile (iOS/Android) +**Free** | [Website](https://obsidian.md) + +Mobile companion to Obsidian desktop. + +**Pros:** +- Full Obsidian features on mobile +- Sync via iCloud, Obsidian Sync, or Syncthing + +**Best for:** Existing Obsidian users. + +--- + +## Recommendation for grapho Users + +### Simple Setup (Recommended) +1. **Desktop:** MarkText or VS Code +2. **Mobile:** md PWA (https://md-ashy.vercel.app) or Markor (Android) +3. **Sync:** Syncthing (already part of grapho) + +### Power User Setup +1. **Desktop:** Obsidian with Syncthing sync +2. **Mobile:** Obsidian Mobile +3. **Notes in:** `~/.nb/` or a dedicated Syncthing folder + +### Academic Setup +1. **Desktop:** Zettlr with Zotero +2. **Mobile:** md PWA for quick edits +3. **Export:** Pandoc for final documents + +--- + +## Integration with grapho + +All recommended editors work with plain markdown files, which means: + +1. Store notes in an `nb` notebook or Syncthing folder +2. Edit with any editor on any device +3. Changes sync automatically via Syncthing +4. Backup happens via restic + +Example workflow: +```bash +# Create a note with nb +nb add "Meeting notes" + +# Edit in your preferred editor +marktext ~/.nb/home/meeting-notes.md + +# Or on mobile, open the same file via Syncthing folder +# Sync happens automatically +grapho sync +``` + +## Sources + +- [MarkText GitHub](https://github.com/marktext/marktext) +- [Obsidian](https://obsidian.md) +- [Zettlr](https://www.zettlr.com) +- [Joplin](https://joplinapp.org) +- [awesome-markdown-editors](https://github.com/mundimark/awesome-markdown-editors) +- [Markdown Guide Tools](https://www.markdownguide.org/tools/) diff --git a/flake.lock b/flake.lock index 2dae45c..0e9f001 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -20,7 +38,42 @@ "type": "github" } }, + "lux": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ], + "rust-overlay": "rust-overlay" + }, + "locked": { + "lastModified": 1771221263, + "narHash": "sha256-Av4s4pelV+ueIMSY61aHuT8KjKZ6ekXtJsnjVc89gtQ=", + "path": "/home/blu/src/lux", + "type": "path" + }, + "original": { + "path": "/home/blu/src/lux", + "type": "path" + } + }, "nixpkgs": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { "locked": { "lastModified": 1770841267, "narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=", @@ -39,10 +92,29 @@ "root": { "inputs": { "home-manager": "home-manager", - "nixpkgs": "nixpkgs", + "lux": "lux", + "nixpkgs": "nixpkgs_2", "sops-nix": "sops-nix" } }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1770952264, + "narHash": "sha256-CjymNrJZWBtpavyuTkfPVPaZkwzIzGaf0E/3WgcwM14=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "ec6a3d5cdf14bb5a1dd03652bd3f6351004d2188", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, "sops-nix": { "inputs": { "nixpkgs": [ @@ -62,6 +134,21 @@ "repo": "sops-nix", "type": "github" } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } } }, "root": "root", diff --git a/grapho b/grapho new file mode 100755 index 0000000000000000000000000000000000000000..eba82dbdfa1c08c228a34936fc346d4b0d512263 GIT binary patch literal 49288 zcmb<-^>JfjWMqH=W(GS35KllEBH{p{7&vx984L^z4h$9yybKNuatyKzYzzzxEMPH+ zJWM@|zQF_$htV7mE(0@Ep9F}(z`%e`2Se4tXpoygLLeGsABc?&TZlkJVKjpPgb&ik z3SugN2nGfQG};hq5sXIG2ez*Pst=9+Ar29T(a8EhVG}V2A|J5^l~w>dhJnEXO2hPl zf(xWC1FA0rst-mBfD|w=Fu-V7c!Jyr!ZW5r>_Mkt@d2aJ_1%E#L#NS#2^`lT8-kyf zq>y7@08C#m$P@;K8EEtsP((2>Fu-V#9Uzr~PfJoj;R0e4gQ3yR5CpXkS3InMrau@B zwVpvgFSA0wxFo+QRlhvRtSFuCo`|KLbt-gOxMgruQ*@N2;yp01_lOw1_lOD_L5{^V322E zU=U+qU{HieGeDCsgDjM<%fP@O%D})N4CQMwFfi~jFff3U9Via97#P6$6)MfZ%fP^( z1mi$yZ3YGg83qOheg*~x9R>z)-UCSqGB7X*F)%PlBV`AWI58L$FQBAIOq_6{8OYAS zzyM12JWz2iD2+@>F)%R5K?PJm>79XrL7jntft!JWL5+cd0ThmUpmKwOfkA_TfdS+e z5FZqO5H~e~b%Vq}jEkw~azQjMEXD{43v_uS9OB;4utQgW8;3Y3zCdA(uKq0!^~pHw zEnq?vUC3rF!C{Un4s!xSHB(a1cEx#Z&kD)j}IXkrkWMEEe9z%S5ZfY*fyyB9g z;*uhU;?%qpuxVf`LFO@}r

o7ndYe<|XE)GUR5bWEL^R$7dGjCmR`qTvU>u!w?@| zl$u-yR$i2vn3I#A%#aJA3X*g3i$M+q)pzm5C5c5P@wthac??CVx%p)viR6mJ__WNt z#GK5kRFKn>Gm01rz|Ku8NzKUt*`JzM#*hXvD6OEhq?jQ&KQAvexdi0Wyn>?4yplAq z-HFM`sl~+%5O0ArCYOW!UzD6%z>rp)nwkwVKM%wMxdv3jnlZThcse=98|fKaz?p_7 zaHg>_oQV)LG=~csBSek$5NsnoQwIHv{M=Oiq?}Uy;-X~z^rFOqjC}q4(h`sdKs2b* zx1y-YfUG;qC_6nTCq1Ruq})6&(W0ub%rLh&(;&B?tRSl*CDB4RJvmv|&{WSz&p;R4 z+Q>{|i1!TfjZaA}N=?r!E=es4@paD0&r1zSOv(YpOL}g89yG$@Axg0-1lOa?3``6x z3`}6i2qr;nRxpbVOoI5x7^H)dft`VafsKI^tO6tg!Vpz3Q$YEGk%5CDn~?$5+-2A? zaT*%~11Cc%RBQ^E&%w|DhG|g#bg(=t!vZLO3z*Nsuo}uQ zk<8=-wRpBc`K3}IJ`=+sP(jYXzyNEPgW6Ic_k930%orFL5}@sFnD_>$IL8K%Dh39a zIE=4=CZ4bnqRs+M+yN@?fF`a0756|Be*onNKxvr03!wZ6C=C-&fbwB9TpZf|%z( zahShh<{yB%^97pv1E7W=0|UbcH1Ptc_zyJk0H`K~Xw(&2^m z5E|4r2DRNGJO+jvNaAp_7#J8HAc@1;=^#BXki=nq6p;7_BymuG10)8*Kaj+sQ3w)d zn6M5^F@V}@(5wg+n1Lh?YSV)SLFo-i9NJ|AORYc>2lXkyf(#4{8<507eujzfKoSSp z4HG|rB#zu)Ie{dO+)ufHBo6A6z|6UUBo69}!NeaRiG%uYF!2{i;-LN+O#B0qIIJ%V zlKX)qj?zwthBL@d$n7Z(Bym_D5@e15k~pl-2@;n;5(o7=L1G}RfFv#k5`bb2Bymt5 z2P({9fFv#f6J%guus{+A^?zYf4oKplz8_5714$gzPlbsGAc=$ewlMJsBym{(5hRy@ zBo1nGg2X^L14$e<)&UYPKoW=b%|YT7NaD!->;@!pXjK7H&d`A*4$Zn?@d-%c(4q${ zJ_AV{T4aF57a)m4vpQIO1(GHf{+mYiF);jBjp<`x;Fot`_^%4$gJyRaUOxE$|Nnp0pgsnM3{V&T z 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 aB5}cE>@dG0i*xOO@cuz4I0