Compare commits
2 Commits
9c7f47e727
...
117e6af528
| Author | SHA1 | Date | |
|---|---|---|---|
| 117e6af528 | |||
| 63fedfb525 |
679
cli/grapho.lux
Normal file
679
cli/grapho.lux
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
// grapho - Unified Personal Data Infrastructure
|
||||||
|
//
|
||||||
|
// Four data types:
|
||||||
|
// 1. Config - Declarative machine setup (NixOS + Git)
|
||||||
|
// 2. Sync - Real-time bidirectional sync (Syncthing)
|
||||||
|
// 3. Backup - Periodic snapshots (Restic)
|
||||||
|
// 4. Server - Large files on central server
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// grapho Health check (or welcome if uninitialized)
|
||||||
|
// grapho init <repo-url> Initialize from config repo
|
||||||
|
// grapho setup Interactive setup wizard
|
||||||
|
// grapho sync Sync all folders
|
||||||
|
// grapho sync status Show sync status
|
||||||
|
// grapho backup Run backup now
|
||||||
|
// grapho backup list List snapshots
|
||||||
|
// grapho status Full status dashboard
|
||||||
|
// grapho doctor Diagnose issues
|
||||||
|
// grapho help Show help
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// 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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// String comparison helpers (workaround for Lux C backend bug with == on strings)
|
||||||
|
fn isYes(s: String): Bool = String.contains(s, "yes")
|
||||||
|
fn isNo(s: String): Bool = String.contains(s, "no")
|
||||||
|
fn isActive(s: String): Bool = String.contains(s, "active")
|
||||||
|
|
||||||
|
fn graphoDir(): String with {Process} =
|
||||||
|
homeDir() + "/.config/grapho"
|
||||||
|
|
||||||
|
fn graphoConfig(): String with {Process} =
|
||||||
|
graphoDir() + "/grapho.toml"
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Initialization detection
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn isInitialized(): Bool with {Process} = {
|
||||||
|
let result = execQuiet("test -f " + graphoConfig() + " && echo yes || echo no")
|
||||||
|
result |> isYes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensureDir(path: String): Unit with {Process} = {
|
||||||
|
let ignore = Process.exec("mkdir -p " + path)
|
||||||
|
()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Directory structure
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn createDirectories(): Unit with {Process, Console} = {
|
||||||
|
let base = graphoDir()
|
||||||
|
Console.print("Creating directory structure...")
|
||||||
|
ensureDir(base)
|
||||||
|
ensureDir(base + "/config-repo")
|
||||||
|
ensureDir(base + "/syncthing/config")
|
||||||
|
ensureDir(base + "/syncthing/db")
|
||||||
|
ensureDir(base + "/sync/notes")
|
||||||
|
ensureDir(base + "/sync/documents")
|
||||||
|
ensureDir(base + "/sync/dotfiles")
|
||||||
|
ensureDir(base + "/restic/cache")
|
||||||
|
ensureDir(base + "/server")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Directories created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Welcome flow
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn showWelcome(): Unit with {Console, Process} = {
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" Welcome to grapho")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" grapho manages your data across all your devices:")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" * Config - Your machine setup, restored anywhere")
|
||||||
|
Console.print(" * Sync - Notes & docs synced across devices")
|
||||||
|
Console.print(" * Backup - Snapshots you can restore anytime")
|
||||||
|
Console.print(" * Server - Large files on your server")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" To get started, run:")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" grapho setup # Interactive setup wizard")
|
||||||
|
Console.print(" grapho init <url> # Initialize from existing config repo")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" For more info: grapho help")
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Setup wizard
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn doSetup(): Unit with {Console, Process} = {
|
||||||
|
Console.print("")
|
||||||
|
Console.print("grapho setup wizard")
|
||||||
|
Console.print("====================")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
createDirectories()
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
// Create initial config
|
||||||
|
Console.print("Creating initial configuration...")
|
||||||
|
let configContent = "[grapho]\nversion = 1\ndevice_name = \"" + execQuiet("hostname") + "\"\n\n[sync]\ngui_port = 8385\nsync_port = 22001\nfolders = [\"notes\", \"documents\", \"dotfiles\"]\n\n[backup]\nschedule = \"hourly\"\n\n[services]\n"
|
||||||
|
|
||||||
|
let writeCmd = "cat > " + graphoConfig() + " << 'GRAPHO_EOF'\n" + configContent + "GRAPHO_EOF"
|
||||||
|
let ignore = Process.exec(writeCmd)
|
||||||
|
Console.print(statusIcon(StatusOk) + " Configuration created at " + graphoConfig())
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
// Generate age key if missing
|
||||||
|
let hasAgeKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
|
||||||
|
if hasAgeKey |> isYes then
|
||||||
|
Console.print(statusIcon(StatusOk) + " Age encryption key exists")
|
||||||
|
else {
|
||||||
|
Console.print("Generating age encryption key...")
|
||||||
|
let ignore = Process.exec("age-keygen -o " + graphoDir() + "/age-key.txt 2>&1 || true")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Age key generated")
|
||||||
|
}
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
// Check for Syncthing
|
||||||
|
if hasCommand("syncthing") then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " Syncthing is available")
|
||||||
|
Console.print(" Configure via: grapho sync setup")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " Syncthing not found")
|
||||||
|
Console.print(" Install via nix or enable in your NixOS config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for restic
|
||||||
|
if hasCommand("restic") then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " Restic is available")
|
||||||
|
Console.print(" Configure via: grapho backup init")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " Restic not found")
|
||||||
|
Console.print(" Install via nix or enable in your NixOS config")
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Setup complete!")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Your grapho data is stored in: " + graphoDir())
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Next steps:")
|
||||||
|
Console.print(" grapho status # Check system health")
|
||||||
|
Console.print(" grapho sync setup # Configure device pairing")
|
||||||
|
Console.print(" grapho backup init # Configure backups")
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Init from repo
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn doInit(repoUrl: String): Unit with {Console, Process} = {
|
||||||
|
if String.length(repoUrl) == 0 then {
|
||||||
|
Console.print("Usage: grapho init <repo-url>")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Clone a grapho config repository to set up this machine.")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Example:")
|
||||||
|
Console.print(" grapho init https://github.com/user/grapho-config")
|
||||||
|
Console.print(" grapho init git@github.com:user/grapho-config.git")
|
||||||
|
} else {
|
||||||
|
Console.print("Initializing from " + repoUrl)
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
// Create base directory
|
||||||
|
createDirectories()
|
||||||
|
|
||||||
|
// Clone repo
|
||||||
|
Console.print("Cloning config repository...")
|
||||||
|
let cloneResult = Process.exec("git clone " + repoUrl + " " + graphoDir() + "/config-repo 2>&1")
|
||||||
|
if String.contains(cloneResult, "fatal") then {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Failed to clone repository")
|
||||||
|
Console.print(" " + cloneResult)
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusOk) + " Repository cloned")
|
||||||
|
|
||||||
|
// Check for flake.nix
|
||||||
|
let hasFlake = execQuiet("test -f " + graphoDir() + "/config-repo/flake.nix && echo yes || echo no")
|
||||||
|
if hasFlake |> isYes then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " Found flake.nix")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("To apply the NixOS configuration:")
|
||||||
|
Console.print(" cd " + graphoDir() + "/config-repo")
|
||||||
|
Console.print(" sudo nixos-rebuild switch --flake .#<hostname>")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " No flake.nix found")
|
||||||
|
Console.print(" This repo may not be a NixOS configuration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Syncthing helpers (isolated grapho instance)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn graphoSyncthingRunning(): Bool with {Process} = {
|
||||||
|
// Check if Syncthing is running on grapho port (8385)
|
||||||
|
let result = execQuiet("curl -s http://127.0.0.1:8385/rest/system/status 2>/dev/null | head -c 1")
|
||||||
|
String.length(result) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getSyncthingDeviceId(): String with {Process} = {
|
||||||
|
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show system 2>/dev/null | grep -o 'myID.*' | cut -d'\"' -f3")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getSyncFolderCount(): String with {Process} = {
|
||||||
|
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.folders | length' 2>/dev/null || echo 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn getSyncDeviceCount(): String with {Process} = {
|
||||||
|
execQuiet("syncthing cli --home=" + graphoDir() + "/syncthing/config show config 2>/dev/null | jq -r '.devices | length' 2>/dev/null || echo 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Sync commands
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn doSyncStatus(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho sync status")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
if hasCommand("syncthing") then {
|
||||||
|
if graphoSyncthingRunning() then {
|
||||||
|
let folders = getSyncFolderCount()
|
||||||
|
let devices = getSyncDeviceCount()
|
||||||
|
Console.print(statusIcon(StatusOk) + " Syncthing running on port 8385")
|
||||||
|
Console.print(" Folders: " + folders)
|
||||||
|
Console.print(" Devices: " + devices)
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" Web UI: http://127.0.0.1:8385")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " Grapho Syncthing not running")
|
||||||
|
Console.print("")
|
||||||
|
Console.print(" Start with: systemctl --user start syncthing-grapho")
|
||||||
|
Console.print(" Or: syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Syncthing not installed")
|
||||||
|
Console.print(" Install via nix or NixOS config")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doSyncSetup(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho sync setup")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
if hasCommand("syncthing") == false then {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Syncthing not installed")
|
||||||
|
Console.print(" Install syncthing first")
|
||||||
|
} else {
|
||||||
|
// Initialize Syncthing config if needed
|
||||||
|
let hasConfig = execQuiet("test -f " + graphoDir() + "/syncthing/config/config.xml && echo yes || echo no")
|
||||||
|
if hasConfig |> isNo then {
|
||||||
|
Console.print("Initializing Syncthing for grapho...")
|
||||||
|
let ignore = Process.exec("syncthing generate --home=" + graphoDir() + "/syncthing/config --no-default-folder 2>&1")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Syncthing initialized")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusOk) + " Syncthing already configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get device ID
|
||||||
|
Console.print("")
|
||||||
|
let deviceId = getSyncthingDeviceId()
|
||||||
|
if String.length(deviceId) > 10 then {
|
||||||
|
Console.print("Your device ID:")
|
||||||
|
Console.print(" " + deviceId)
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Share this ID with other devices to pair them.")
|
||||||
|
} else {
|
||||||
|
Console.print("Start Syncthing to get your device ID:")
|
||||||
|
Console.print(" syncthing serve --home=" + graphoDir() + "/syncthing/config --gui-address=127.0.0.1:8385")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doSync(): Unit with {Console, Process} = {
|
||||||
|
Console.print("Syncing...")
|
||||||
|
|
||||||
|
if graphoSyncthingRunning() then {
|
||||||
|
Console.print("-> Triggering Syncthing rescan")
|
||||||
|
let ignore = Process.exec("syncthing cli --home=" + graphoDir() + "/syncthing/config scan 2>/dev/null || true")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Sync triggered")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " Syncthing not running")
|
||||||
|
Console.print(" Start with: grapho sync setup")
|
||||||
|
}
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Backup commands
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn doBackupInit(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho backup init")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
if hasCommand("restic") == false then {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
||||||
|
Console.print(" Install restic first")
|
||||||
|
} else {
|
||||||
|
Console.print("Backup repository setup")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Configure your backup repository in: " + graphoConfig())
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Example configuration:")
|
||||||
|
Console.print(" [backup]")
|
||||||
|
Console.print(" repository = \"sftp:server:/backups/grapho\"")
|
||||||
|
Console.print(" schedule = \"hourly\"")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("After configuring, initialize with:")
|
||||||
|
Console.print(" restic -r <repo> init")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doBackupList(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho backup list")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
if hasCommand("restic") == false then {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
||||||
|
} else {
|
||||||
|
Console.print("Run: restic -r <your-repo> snapshots")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Check your repository location in: " + graphoConfig())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn doBackup(): Unit with {Console, Process} = {
|
||||||
|
Console.print("Running backup...")
|
||||||
|
|
||||||
|
if hasCommand("restic") then {
|
||||||
|
// Check for systemd service first
|
||||||
|
let hasService = execQuiet("systemctl cat grapho-backup.service 2>/dev/null || systemctl cat restic-backup.service 2>/dev/null")
|
||||||
|
if String.length(hasService) > 0 then {
|
||||||
|
Console.print("-> Triggering backup service")
|
||||||
|
let ignore = Process.exec("systemctl start grapho-backup.service 2>/dev/null || systemctl start restic-backup.service 2>/dev/null || true")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Backup triggered")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " No systemd backup service configured")
|
||||||
|
Console.print(" Configure in your NixOS config or run restic manually")
|
||||||
|
Console.print(" restic -r <repo> backup " + graphoDir() + "/sync")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " Restic not installed")
|
||||||
|
}
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Server commands
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn doServerStatus(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho server status")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
let serverDir = graphoDir() + "/server"
|
||||||
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
||||||
|
|
||||||
|
if isMounted |> isYes then {
|
||||||
|
let usage = execQuiet("df -h " + serverDir + " | tail -1 | tr -s ' ' | cut -d' ' -f3,2,5 | tr ' ' '/'")
|
||||||
|
Console.print(statusIcon(StatusOk) + " Server mounted at " + serverDir)
|
||||||
|
Console.print(" Usage: " + usage)
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusNone) + " Server not mounted")
|
||||||
|
Console.print(" Configure in: " + graphoConfig())
|
||||||
|
Console.print(" Mount point: " + serverDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// One-liner health check
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn showHealthCheck(): Unit with {Console, Process} = {
|
||||||
|
// Check if initialized
|
||||||
|
if isInitialized() == false then {
|
||||||
|
showWelcome()
|
||||||
|
} else {
|
||||||
|
let configOk = true
|
||||||
|
let syncOk = graphoSyncthingRunning()
|
||||||
|
let backupOk = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null") |> isActive
|
||||||
|
|
||||||
|
let folders = if syncOk then getSyncFolderCount() else "0"
|
||||||
|
let devices = if syncOk then getSyncDeviceCount() else "0"
|
||||||
|
|
||||||
|
// Determine issues
|
||||||
|
if syncOk == false || backupOk == false then {
|
||||||
|
if syncOk == false && backupOk == false then {
|
||||||
|
Console.print(statusEmoji(StatusWarn) + " Warnings")
|
||||||
|
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
|
||||||
|
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
|
||||||
|
} else if syncOk == false then {
|
||||||
|
Console.print(statusEmoji(StatusWarn) + " Sync not running")
|
||||||
|
Console.print(" " + statusIcon(StatusWarn) + " sync: not running -> systemctl --user start syncthing-grapho")
|
||||||
|
} else {
|
||||||
|
Console.print(statusEmoji(StatusWarn) + " Backup timer inactive")
|
||||||
|
Console.print(" " + statusIcon(StatusWarn) + " backup: timer inactive -> check grapho backup init")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Console.print(statusEmoji(StatusOk) + " All systems healthy (" + folders + " folders, " + devices + " devices, backup active)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Status dashboard
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn showStatus(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho status")
|
||||||
|
Console.print("")
|
||||||
|
|
||||||
|
if isInitialized() == false then {
|
||||||
|
Console.print(statusIcon(StatusNone) + " grapho not initialized")
|
||||||
|
Console.print(" Run: grapho setup")
|
||||||
|
Console.print("")
|
||||||
|
} else {
|
||||||
|
// Config
|
||||||
|
let hasRepo = execQuiet("test -d " + graphoDir() + "/config-repo/.git && echo yes || echo no")
|
||||||
|
if hasRepo |> isYes then {
|
||||||
|
let repoRemote = execQuiet("cd " + graphoDir() + "/config-repo && git remote get-url origin 2>/dev/null || echo local")
|
||||||
|
Console.print(statusIcon(StatusOk) + " config: " + repoRemote)
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusNone) + " config: no repo linked")
|
||||||
|
Console.print(" Run: grapho init <repo-url>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync
|
||||||
|
if graphoSyncthingRunning() then {
|
||||||
|
let folders = getSyncFolderCount()
|
||||||
|
let devices = getSyncDeviceCount()
|
||||||
|
Console.print(statusIcon(StatusOk) + " sync: " + folders + " folders, " + devices + " devices")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " sync: not running")
|
||||||
|
Console.print(" Run: grapho sync setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
let timerActive = execQuiet("systemctl is-active grapho-backup.timer 2>/dev/null || systemctl is-active restic-backup.timer 2>/dev/null")
|
||||||
|
if timerActive |> isActive then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " backup: timer active")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " backup: timer inactive")
|
||||||
|
Console.print(" Run: grapho backup init")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server
|
||||||
|
let serverDir = graphoDir() + "/server"
|
||||||
|
let isMounted = execQuiet("mountpoint -q " + serverDir + " && echo yes || echo no")
|
||||||
|
if isMounted |> isYes then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " server: mounted")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusNone) + " server: not configured")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Doctor command
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn showDoctor(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho doctor")
|
||||||
|
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 syncthing
|
||||||
|
if hasCommand("syncthing") then {
|
||||||
|
if graphoSyncthingRunning() then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " syncthing: running on port 8385")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " syncthing: installed but not running")
|
||||||
|
Console.print(" Fix: grapho sync setup")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " syncthing: not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check restic
|
||||||
|
if hasCommand("restic") then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " restic: installed")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " restic: not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check age
|
||||||
|
if hasCommand("age") then {
|
||||||
|
let hasKey = execQuiet("test -f " + graphoDir() + "/age-key.txt && echo yes || echo no")
|
||||||
|
if hasKey |> isYes then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " age: key exists")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusWarn) + " age: no key at " + graphoDir() + "/age-key.txt")
|
||||||
|
Console.print(" Fix: grapho setup")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " age: not installed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Directory structure
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Directory structure:")
|
||||||
|
let hasGraphoDir = execQuiet("test -d " + graphoDir() + " && echo yes || echo no")
|
||||||
|
if hasGraphoDir |> isYes then {
|
||||||
|
Console.print(statusIcon(StatusOk) + " " + graphoDir())
|
||||||
|
checkDir("config-repo")
|
||||||
|
checkDir("syncthing")
|
||||||
|
checkDir("sync")
|
||||||
|
checkDir("restic")
|
||||||
|
checkDir("server")
|
||||||
|
} else {
|
||||||
|
Console.print(statusIcon(StatusErr) + " " + graphoDir() + " does not exist")
|
||||||
|
Console.print(" Fix: grapho setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn checkDir(dir: String): Unit with {Console, Process} = {
|
||||||
|
let exists = execQuiet("test -d " + graphoDir() + "/" + dir + " && echo yes || echo no")
|
||||||
|
if exists |> isYes then
|
||||||
|
Console.print(" " + statusIcon(StatusOk) + " " + dir + "/")
|
||||||
|
else
|
||||||
|
Console.print(" " + statusIcon(StatusNone) + " " + dir + "/ (missing)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Help
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn showHelp(): Unit with {Console, Process} = {
|
||||||
|
Console.print("grapho - Personal Data Infrastructure")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Usage:")
|
||||||
|
Console.print(" grapho Health check (or welcome if new)")
|
||||||
|
Console.print(" grapho init <repo-url> Initialize from config repo")
|
||||||
|
Console.print(" grapho setup Interactive setup wizard")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Sync (Type 2 - Real-time bidirectional):")
|
||||||
|
Console.print(" grapho sync Trigger sync")
|
||||||
|
Console.print(" grapho sync status Show sync status")
|
||||||
|
Console.print(" grapho sync setup Configure Syncthing")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Backup (Type 3 - Periodic snapshots):")
|
||||||
|
Console.print(" grapho backup Run backup now")
|
||||||
|
Console.print(" grapho backup list List snapshots")
|
||||||
|
Console.print(" grapho backup init Configure backup repository")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Server (Type 4 - Central storage):")
|
||||||
|
Console.print(" grapho server Show server status")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Status:")
|
||||||
|
Console.print(" grapho status Full status dashboard")
|
||||||
|
Console.print(" grapho doctor Diagnose issues")
|
||||||
|
Console.print(" grapho help Show this help")
|
||||||
|
Console.print("")
|
||||||
|
Console.print("Data directory: " + graphoDir())
|
||||||
|
Console.print("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Main
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
fn main(): Unit with {Console, Process} = {
|
||||||
|
let args = Process.args()
|
||||||
|
let cmd = match List.get(args, 1) {
|
||||||
|
Some(c) => c,
|
||||||
|
None => ""
|
||||||
|
}
|
||||||
|
let subcmd = match List.get(args, 2) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => ""
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
"" => showHealthCheck(),
|
||||||
|
"init" => doInit(subcmd),
|
||||||
|
"setup" => doSetup(),
|
||||||
|
"sync" => {
|
||||||
|
match subcmd {
|
||||||
|
"status" => doSyncStatus(),
|
||||||
|
"setup" => doSyncSetup(),
|
||||||
|
"" => doSync(),
|
||||||
|
_ => doSyncStatus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"backup" => {
|
||||||
|
match subcmd {
|
||||||
|
"init" => doBackupInit(),
|
||||||
|
"list" => doBackupList(),
|
||||||
|
"" => doBackup(),
|
||||||
|
_ => doBackup()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server" => doServerStatus(),
|
||||||
|
"status" => showStatus(),
|
||||||
|
"doctor" => showDoctor(),
|
||||||
|
"help" => showHelp(),
|
||||||
|
"-h" => showHelp(),
|
||||||
|
"--help" => showHelp(),
|
||||||
|
_ => showHelp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = run main() with {}
|
||||||
218
docs/LUX-LIMITATIONS.md
Normal file
218
docs/LUX-LIMITATIONS.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C Backend Issues
|
||||||
|
|
||||||
|
### 7. String Equality Comparison Generates Incorrect C Code
|
||||||
|
**Severity:** High
|
||||||
|
**Status:** Bug
|
||||||
|
|
||||||
|
Using `==` to compare strings generates C code that compares pointers instead of string contents.
|
||||||
|
|
||||||
|
```lux
|
||||||
|
let result = execQuiet("echo yes")
|
||||||
|
if result == "yes" then ... // C code: (result == "yes") - pointer comparison!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Impact:** String comparisons fail in compiled binaries.
|
||||||
|
|
||||||
|
**Workaround:** Use `String.contains` for comparison:
|
||||||
|
```lux
|
||||||
|
fn isYes(s: String): Bool = String.contains(s, "yes")
|
||||||
|
if result |> isYes then ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. String.startsWith Not Available in C Backend
|
||||||
|
**Severity:** Medium
|
||||||
|
**Status:** Bug
|
||||||
|
|
||||||
|
`String.startsWith` works in interpreter but generates undefined function calls in C.
|
||||||
|
|
||||||
|
```lux
|
||||||
|
String.startsWith(s, "prefix") // C error: lux_string__startsWith undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workaround:** Use `String.contains` instead.
|
||||||
|
|
||||||
|
### 9. `let _ = expr` Pattern Not Supported
|
||||||
|
**Severity:** Low
|
||||||
|
**Status:** Bug
|
||||||
|
|
||||||
|
The underscore wildcard pattern for discarding results doesn't work.
|
||||||
|
|
||||||
|
```lux
|
||||||
|
let _ = Process.exec("...") // ERROR: Expected identifier
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workaround:** Use a named binding:
|
||||||
|
```lux
|
||||||
|
let ignore = Process.exec("...")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. List Literals and Recursion Cause Segfaults
|
||||||
|
**Severity:** High
|
||||||
|
**Status:** Bug
|
||||||
|
|
||||||
|
Combining list literals with recursive functions can cause segmentation faults in compiled binaries while working fine in interpreter.
|
||||||
|
|
||||||
|
```lux
|
||||||
|
// This crashes when compiled:
|
||||||
|
let dirs = ["a", "b", "c"]
|
||||||
|
fn processDirs(dirs: List<String>): Unit =
|
||||||
|
match List.head(dirs) {
|
||||||
|
Some(d) => { ...; match List.tail(dirs) { Some(rest) => processDirs(rest), ... } }
|
||||||
|
None => ()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Workaround:** Avoid list literals with recursive processing. Inline the operations:
|
||||||
|
```lux
|
||||||
|
fn processA(): Unit = ...
|
||||||
|
fn processB(): Unit = ...
|
||||||
|
fn processC(): Unit = ...
|
||||||
|
// Call each individually
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 (DONE)
|
||||||
|
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
|
||||||
|
8. **Fix string equality** - Use strcmp in C backend for string ==
|
||||||
|
9. **Support `let _ = `** - Allow underscore as discard binding
|
||||||
|
10. **Fix String.startsWith** in C backend
|
||||||
|
11. **Fix list literals with recursion** causing segfaults
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
6. **String comparison:** Using `String.contains` with helper functions instead of `==`
|
||||||
|
7. **Discarding results:** Using `let ignore = ...` instead of `let _ = ...`
|
||||||
|
8. **Lists with recursion:** Replaced with individual function calls
|
||||||
238
docs/MARKDOWN-EDITORS.md
Normal file
238
docs/MARKDOWN-EDITORS.md
Normal 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
89
flake.lock
generated
@@ -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",
|
||||||
|
|||||||
48
flake.nix
48
flake.nix
@@ -14,6 +14,11 @@
|
|||||||
inputs.nixpkgs.follows = "nixpkgs";
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
lux = {
|
||||||
|
url = "path:/home/blu/src/lux";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
|
||||||
# Optional: Neovim distribution
|
# Optional: Neovim distribution
|
||||||
# nixvim = {
|
# nixvim = {
|
||||||
# url = "github:nix-community/nixvim";
|
# url = "github:nix-community/nixvim";
|
||||||
@@ -21,7 +26,7 @@
|
|||||||
# };
|
# };
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, home-manager, sops-nix, ... }@inputs:
|
outputs = { self, nixpkgs, home-manager, sops-nix, lux, ... }@inputs:
|
||||||
let
|
let
|
||||||
# Supported systems
|
# Supported systems
|
||||||
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
supportedSystems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
@@ -37,6 +42,7 @@
|
|||||||
|
|
||||||
# Shared modules for all hosts
|
# Shared modules for all hosts
|
||||||
sharedModules = [
|
sharedModules = [
|
||||||
|
./modules/grapho.nix
|
||||||
./modules/nb.nix
|
./modules/nb.nix
|
||||||
./modules/syncthing.nix
|
./modules/syncthing.nix
|
||||||
./modules/backup.nix
|
./modules/backup.nix
|
||||||
@@ -60,6 +66,39 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
in {
|
in {
|
||||||
|
# Grapho CLI package
|
||||||
|
packages = forAllSystems (system:
|
||||||
|
let
|
||||||
|
pkgs = nixpkgsFor.${system};
|
||||||
|
luxPkg = lux.packages.${system}.default;
|
||||||
|
in {
|
||||||
|
grapho = pkgs.stdenv.mkDerivation {
|
||||||
|
pname = "grapho";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./cli;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ luxPkg pkgs.gcc ];
|
||||||
|
|
||||||
|
buildPhase = ''
|
||||||
|
${luxPkg}/bin/lux compile grapho.lux -o grapho
|
||||||
|
'';
|
||||||
|
|
||||||
|
installPhase = ''
|
||||||
|
mkdir -p $out/bin
|
||||||
|
cp grapho $out/bin/
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Personal data infrastructure CLI";
|
||||||
|
homepage = "https://github.com/user/grapho";
|
||||||
|
license = pkgs.lib.licenses.mit;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
default = self.packages.${system}.grapho;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
# NixOS configurations
|
# NixOS configurations
|
||||||
# Uncomment and customize for your hosts:
|
# Uncomment and customize for your hosts:
|
||||||
#
|
#
|
||||||
@@ -132,7 +171,9 @@
|
|||||||
default = pkgs.mkShell {
|
default = pkgs.mkShell {
|
||||||
name = "unsbs-dev";
|
name = "unsbs-dev";
|
||||||
|
|
||||||
packages = with pkgs; [
|
packages = [
|
||||||
|
lux.packages.${system}.default
|
||||||
|
] ++ (with pkgs; [
|
||||||
# Tier 2: Notes & Sync
|
# Tier 2: Notes & Sync
|
||||||
nb # Notebook CLI
|
nb # Notebook CLI
|
||||||
syncthing # File sync
|
syncthing # File sync
|
||||||
@@ -159,7 +200,7 @@
|
|||||||
# Nix tools
|
# Nix tools
|
||||||
nil # Nix LSP
|
nil # Nix LSP
|
||||||
nixpkgs-fmt # Nix formatter
|
nixpkgs-fmt # Nix formatter
|
||||||
];
|
]);
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System"
|
printf '\033[1m%s\033[0m\n' "Ultimate Notetaking, Sync & Backup System"
|
||||||
@@ -175,6 +216,7 @@
|
|||||||
|
|
||||||
# Export modules for use in other flakes
|
# Export modules for use in other flakes
|
||||||
nixosModules = {
|
nixosModules = {
|
||||||
|
grapho = import ./modules/grapho.nix;
|
||||||
nb = import ./modules/nb.nix;
|
nb = import ./modules/nb.nix;
|
||||||
syncthing = import ./modules/syncthing.nix;
|
syncthing = import ./modules/syncthing.nix;
|
||||||
backup = import ./modules/backup.nix;
|
backup = import ./modules/backup.nix;
|
||||||
|
|||||||
341
modules/grapho.nix
Normal file
341
modules/grapho.nix
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
# Grapho Module
|
||||||
|
#
|
||||||
|
# Unified personal data infrastructure module for NixOS.
|
||||||
|
# Sets up Syncthing (isolated) + Restic backup + directory structure.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# services.grapho.enable = true;
|
||||||
|
# services.grapho.user = "youruser";
|
||||||
|
#
|
||||||
|
# This creates:
|
||||||
|
# - ~/.config/grapho/ directory structure
|
||||||
|
# - Isolated Syncthing on port 8385 (separate from system Syncthing)
|
||||||
|
# - Restic backup timer for grapho data
|
||||||
|
|
||||||
|
{ config, lib, pkgs, ... }:
|
||||||
|
|
||||||
|
with lib;
|
||||||
|
|
||||||
|
let
|
||||||
|
cfg = config.services.grapho;
|
||||||
|
home = config.users.users.${cfg.user}.home;
|
||||||
|
graphoDir = "${home}/.config/grapho";
|
||||||
|
in {
|
||||||
|
options.services.grapho = {
|
||||||
|
enable = mkEnableOption "grapho personal data infrastructure";
|
||||||
|
|
||||||
|
user = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "User to run grapho services as.";
|
||||||
|
example = "alice";
|
||||||
|
};
|
||||||
|
|
||||||
|
group = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "users";
|
||||||
|
description = "Group to run grapho services as.";
|
||||||
|
};
|
||||||
|
|
||||||
|
# Syncthing options
|
||||||
|
syncthing = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Enable grapho's isolated Syncthing instance.";
|
||||||
|
};
|
||||||
|
|
||||||
|
guiPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 8385;
|
||||||
|
description = "Port for Syncthing web GUI (separate from system Syncthing).";
|
||||||
|
};
|
||||||
|
|
||||||
|
syncPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 22001;
|
||||||
|
description = "Port for Syncthing file sync (separate from system Syncthing).";
|
||||||
|
};
|
||||||
|
|
||||||
|
discoveryPort = mkOption {
|
||||||
|
type = types.port;
|
||||||
|
default = 21028;
|
||||||
|
description = "Port for Syncthing local discovery.";
|
||||||
|
};
|
||||||
|
|
||||||
|
openFirewall = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Open firewall for grapho's Syncthing ports.";
|
||||||
|
};
|
||||||
|
|
||||||
|
devices = mkOption {
|
||||||
|
type = types.attrsOf (types.submodule {
|
||||||
|
options = {
|
||||||
|
id = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Device ID.";
|
||||||
|
};
|
||||||
|
name = mkOption {
|
||||||
|
type = types.nullOr types.str;
|
||||||
|
default = null;
|
||||||
|
description = "Friendly name.";
|
||||||
|
};
|
||||||
|
addresses = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [ "dynamic" ];
|
||||||
|
description = "Connection addresses.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = {};
|
||||||
|
description = "Devices to connect to.";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraFolders = mkOption {
|
||||||
|
type = types.attrsOf (types.submodule {
|
||||||
|
options = {
|
||||||
|
path = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
description = "Local path to sync.";
|
||||||
|
};
|
||||||
|
devices = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [];
|
||||||
|
description = "Devices to share with.";
|
||||||
|
};
|
||||||
|
type = mkOption {
|
||||||
|
type = types.enum [ "sendreceive" "sendonly" "receiveonly" ];
|
||||||
|
default = "sendreceive";
|
||||||
|
description = "Sync type.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
default = {};
|
||||||
|
description = "Additional folders to sync (beyond default notes/documents/dotfiles).";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Backup options
|
||||||
|
backup = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable restic backup of grapho data.";
|
||||||
|
};
|
||||||
|
|
||||||
|
repository = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Restic repository location (e.g., 'sftp:server:/backups/grapho').";
|
||||||
|
example = "sftp:backup-server:/backups/grapho";
|
||||||
|
};
|
||||||
|
|
||||||
|
passwordFile = mkOption {
|
||||||
|
type = types.nullOr types.path;
|
||||||
|
default = null;
|
||||||
|
description = "Path to file containing restic repository password.";
|
||||||
|
};
|
||||||
|
|
||||||
|
schedule = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "hourly";
|
||||||
|
description = "Backup schedule (systemd timer OnCalendar syntax).";
|
||||||
|
example = "*-*-* *:00:00";
|
||||||
|
};
|
||||||
|
|
||||||
|
extraPaths = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [];
|
||||||
|
description = "Additional paths to include in backup.";
|
||||||
|
};
|
||||||
|
|
||||||
|
pruneOpts = mkOption {
|
||||||
|
type = types.listOf types.str;
|
||||||
|
default = [
|
||||||
|
"--keep-hourly 24"
|
||||||
|
"--keep-daily 7"
|
||||||
|
"--keep-weekly 4"
|
||||||
|
"--keep-monthly 12"
|
||||||
|
];
|
||||||
|
description = "Restic prune/forget options.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Server mount options
|
||||||
|
server = {
|
||||||
|
enable = mkOption {
|
||||||
|
type = types.bool;
|
||||||
|
default = false;
|
||||||
|
description = "Enable server data mount.";
|
||||||
|
};
|
||||||
|
|
||||||
|
type = mkOption {
|
||||||
|
type = types.enum [ "nfs" "sshfs" "syncthing" ];
|
||||||
|
default = "syncthing";
|
||||||
|
description = "Type of server mount.";
|
||||||
|
};
|
||||||
|
|
||||||
|
host = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Server hostname (for NFS/SSHFS).";
|
||||||
|
};
|
||||||
|
|
||||||
|
remotePath = mkOption {
|
||||||
|
type = types.str;
|
||||||
|
default = "";
|
||||||
|
description = "Path on server to mount.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = mkIf cfg.enable {
|
||||||
|
# Ensure user exists
|
||||||
|
users.users.${cfg.user} = {
|
||||||
|
isNormalUser = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
# Create grapho directory structure
|
||||||
|
systemd.tmpfiles.rules = [
|
||||||
|
"d ${graphoDir} 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/config-repo 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/syncthing/config 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/syncthing/db 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/sync 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/sync/notes 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/sync/documents 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/sync/dotfiles 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/restic/cache 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
"d ${graphoDir}/server 0755 ${cfg.user} ${cfg.group} -"
|
||||||
|
];
|
||||||
|
|
||||||
|
# Isolated Syncthing for grapho
|
||||||
|
services.syncthing = mkIf cfg.syncthing.enable {
|
||||||
|
enable = true;
|
||||||
|
user = cfg.user;
|
||||||
|
group = cfg.group;
|
||||||
|
dataDir = "${graphoDir}/sync";
|
||||||
|
configDir = "${graphoDir}/syncthing/config";
|
||||||
|
guiAddress = "127.0.0.1:${toString cfg.syncthing.guiPort}";
|
||||||
|
|
||||||
|
overrideDevices = true;
|
||||||
|
overrideFolders = true;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
devices = mapAttrs (name: device: {
|
||||||
|
inherit (device) id addresses;
|
||||||
|
name = if device.name != null then device.name else name;
|
||||||
|
}) cfg.syncthing.devices;
|
||||||
|
|
||||||
|
folders = {
|
||||||
|
# Default grapho folders
|
||||||
|
"grapho-notes" = {
|
||||||
|
path = "${graphoDir}/sync/notes";
|
||||||
|
id = "grapho-notes";
|
||||||
|
devices = attrNames cfg.syncthing.devices;
|
||||||
|
type = "sendreceive";
|
||||||
|
fsWatcherEnabled = true;
|
||||||
|
};
|
||||||
|
"grapho-documents" = {
|
||||||
|
path = "${graphoDir}/sync/documents";
|
||||||
|
id = "grapho-documents";
|
||||||
|
devices = attrNames cfg.syncthing.devices;
|
||||||
|
type = "sendreceive";
|
||||||
|
fsWatcherEnabled = true;
|
||||||
|
};
|
||||||
|
"grapho-dotfiles" = {
|
||||||
|
path = "${graphoDir}/sync/dotfiles";
|
||||||
|
id = "grapho-dotfiles";
|
||||||
|
devices = attrNames cfg.syncthing.devices;
|
||||||
|
type = "sendreceive";
|
||||||
|
fsWatcherEnabled = true;
|
||||||
|
};
|
||||||
|
} // (mapAttrs (name: folder: {
|
||||||
|
inherit (folder) path type;
|
||||||
|
id = name;
|
||||||
|
devices = if folder.devices == [] then attrNames cfg.syncthing.devices else folder.devices;
|
||||||
|
fsWatcherEnabled = true;
|
||||||
|
}) cfg.syncthing.extraFolders);
|
||||||
|
|
||||||
|
options = {
|
||||||
|
urAccepted = -1;
|
||||||
|
relaysEnabled = true;
|
||||||
|
globalAnnounceEnabled = true;
|
||||||
|
localAnnounceEnabled = true;
|
||||||
|
localAnnouncePort = cfg.syncthing.discoveryPort;
|
||||||
|
listenAddresses = [
|
||||||
|
"tcp://0.0.0.0:${toString cfg.syncthing.syncPort}"
|
||||||
|
"quic://0.0.0.0:${toString cfg.syncthing.syncPort}"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Firewall for grapho Syncthing
|
||||||
|
networking.firewall = mkIf (cfg.syncthing.enable && cfg.syncthing.openFirewall) {
|
||||||
|
allowedTCPPorts = [ cfg.syncthing.syncPort ];
|
||||||
|
allowedUDPPorts = [ cfg.syncthing.syncPort cfg.syncthing.discoveryPort ];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Restic backup service
|
||||||
|
systemd.services.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") {
|
||||||
|
description = "Grapho data backup";
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "oneshot";
|
||||||
|
User = cfg.user;
|
||||||
|
Group = cfg.group;
|
||||||
|
ExecStart = let
|
||||||
|
paths = [ "${graphoDir}/sync" ] ++ cfg.backup.extraPaths;
|
||||||
|
pathArgs = concatMapStringsSep " " (p: "'${p}'") paths;
|
||||||
|
in ''
|
||||||
|
${pkgs.restic}/bin/restic backup \
|
||||||
|
--cache-dir ${graphoDir}/restic/cache \
|
||||||
|
${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \
|
||||||
|
-r ${cfg.backup.repository} \
|
||||||
|
${pathArgs}
|
||||||
|
'';
|
||||||
|
ExecStartPost = ''
|
||||||
|
${pkgs.restic}/bin/restic forget \
|
||||||
|
--cache-dir ${graphoDir}/restic/cache \
|
||||||
|
${optionalString (cfg.backup.passwordFile != null) "--password-file ${cfg.backup.passwordFile}"} \
|
||||||
|
-r ${cfg.backup.repository} \
|
||||||
|
${concatStringsSep " " cfg.backup.pruneOpts}
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
systemd.timers.grapho-backup = mkIf (cfg.backup.enable && cfg.backup.repository != "") {
|
||||||
|
description = "Grapho backup timer";
|
||||||
|
wantedBy = [ "timers.target" ];
|
||||||
|
timerConfig = {
|
||||||
|
OnCalendar = cfg.backup.schedule;
|
||||||
|
Persistent = true;
|
||||||
|
RandomizedDelaySec = "5min";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Server mount (NFS)
|
||||||
|
fileSystems."${graphoDir}/server" = mkIf (cfg.server.enable && cfg.server.type == "nfs" && cfg.server.host != "") {
|
||||||
|
device = "${cfg.server.host}:${cfg.server.remotePath}";
|
||||||
|
fsType = "nfs";
|
||||||
|
options = [
|
||||||
|
"x-systemd.automount"
|
||||||
|
"noauto"
|
||||||
|
"x-systemd.idle-timeout=600"
|
||||||
|
"soft"
|
||||||
|
"timeo=15"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
# Install grapho CLI and dependencies
|
||||||
|
environment.systemPackages = with pkgs; [
|
||||||
|
syncthing
|
||||||
|
restic
|
||||||
|
age
|
||||||
|
jq
|
||||||
|
];
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user