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 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 01:06:55 -05:00
parent 9c7f47e727
commit 63fedfb525
5 changed files with 915 additions and 1 deletions

452
cli/grapho.lux Normal file
View File

@@ -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<String>): 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<String>): 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 <term> 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 {}

137
docs/LUX-LIMITATIONS.md Normal file
View File

@@ -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

238
docs/MARKDOWN-EDITORS.md Normal file
View File

@@ -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/)

89
flake.lock generated
View File

@@ -1,5 +1,23 @@
{ {
"nodes": { "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": { "home-manager": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -20,7 +38,42 @@
"type": "github" "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": { "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": { "locked": {
"lastModified": 1770841267, "lastModified": 1770841267,
"narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=", "narHash": "sha256-9xejG0KoqsoKEGp2kVbXRlEYtFFcDTHjidiuX8hGO44=",
@@ -39,10 +92,29 @@
"root": { "root": {
"inputs": { "inputs": {
"home-manager": "home-manager", "home-manager": "home-manager",
"nixpkgs": "nixpkgs", "lux": "lux",
"nixpkgs": "nixpkgs_2",
"sops-nix": "sops-nix" "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": { "sops-nix": {
"inputs": { "inputs": {
"nixpkgs": [ "nixpkgs": [
@@ -62,6 +134,21 @@
"repo": "sops-nix", "repo": "sops-nix",
"type": "github" "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", "root": "root",

BIN
grapho Executable file

Binary file not shown.