diff --git a/dotfiles/config/hypr/hyprland/binds.lua b/dotfiles/config/hypr/hyprland/binds.lua index f4a676bd..e6ae51b3 100644 --- a/dotfiles/config/hypr/hyprland/binds.lua +++ b/dotfiles/config/hypr/hyprland/binds.lua @@ -68,6 +68,8 @@ function M.setup(ctx) bind(hyper .. " + V", exec([[cliphist list | rofi -dmenu -p "Clipboard" | cliphist decode | wl-copy]]), desc("Open clipboard history")) bind(hyper .. " + P", exec("rofi-pass"), desc("Open password menu")) bind(hyper .. " + N", exec("rofi_codex_desktop_project.sh"), desc("Start Codex Desktop thread from project")) + bind(hyper .. " + T", exec("mova_capture.sh"), desc("Quick-capture task/note into mova (Enter: Today/NEXT, Alt+i: Inbox)")) + bind(hyper .. " + SHIFT + T", exec("mova_capture.sh --pick"), desc("Quick-capture into mova, picking a capture template")) bind(hyper .. " + C", exec("rofi_ai_scratchpad.sh"), desc("Choose AI scratchpad (Codex/Claude)")) bind(hyper .. " + SHIFT + C", exec("rofi_tmcodex.sh resume"), desc("Resume Codex session")) bind(hyper .. " + L", exec("hypr_rofi_layout"), desc("Open Hyprland layout menu")) diff --git a/dotfiles/lib/bin/mova_capture.sh b/dotfiles/lib/bin/mova_capture.sh new file mode 100755 index 00000000..c9c9d01d --- /dev/null +++ b/dotfiles/lib/bin/mova_capture.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# +# Quick-capture a task/note into mova (org-agenda-api) via rofi. +# +# Fire-and-forget: the note is written to a persistent on-disk queue and a +# background systemd user service (mova-capture-drain) POSTs it to the API, +# retrying across network outages / API downtime / reboots until it lands. +# See mova_capture_drain (the worker) and nixos/mova-capture.nix (the units). +# +# Modes: +# mova_capture.sh Fast text box. Enter -> Today/NEXT +# (capture-n), Alt+i -> Inbox (capture-i). +# mova_capture.sh --pick Pick any capture template from a rofi +# list (fetched live, cached for offline), +# then type the note. +# mova_capture.sh Skip the picker; capture straight to that +# template, e.g. `mova_capture.sh capture-z`. + +set -uo pipefail + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/mova-capture" +queue_dir="$state_dir/queue" +cache_file="$state_dir/templates.json" +mkdir -p "$queue_dir" + +url="${MOVA_API_URL:-https://org-agenda-api.rocket-sense.duckdns.org}" +api_user="${MOVA_API_USER:-imalison}" +pass_entry="${MOVA_API_PASS_ENTRY:-org-agenda-api-imalison}" + +notify() { + command -v notify-send >/dev/null 2>&1 && notify-send -a "mova capture" "$@" || true +} + +# Read a single line of text from a rofi text box. Empty dmenu input makes rofi +# a plain input field: Enter returns the typed text; custom keybindings return +# it with exit 10/11/... The caller inspects $? via the global `input_code`. +input_code=0 +read_text() { + local prompt="$1" + shift + local text + text="$(printf '' | rofi -dmenu -p "$prompt" -lines 0 "$@")" + input_code=$? + # Collapse newlines and trim surrounding whitespace. + text="${text//$'\n'/ }" + text="${text#"${text%%[![:space:]]*}"}" + text="${text%"${text##*[![:space:]]}"}" + printf '%s' "$text" +} + +enqueue() { + local template="$1" label="$2" title="$3" + local id tmp final + id="$(date +%s%N)-$$-${RANDOM}" + tmp="$queue_dir/.$id.json.tmp" + final="$queue_dir/$id.json" + + if ! jq -n \ + --arg template "$template" \ + --arg title "$title" \ + --arg label "$label" \ + --arg created "$(date -Is)" \ + '{template: $template, values: {Title: $title}, label: $label, created: $created}' \ + >"$tmp"; then + notify "Capture FAILED to queue" "$title" + rm -f "$tmp" + return 1 + fi + mv "$tmp" "$final" + + # Kick the drain now for immediacy; the .path unit would fire it anyway, and + # the .timer retries anything that does not land. Fall back to a direct run + # if systemd is unavailable (e.g. non-NixOS host). + if ! systemctl --user start mova-capture-drain.service 2>/dev/null; then + if command -v mova_capture_drain >/dev/null 2>&1; then + setsid mova_capture_drain >/dev/null 2>&1 & + fi + fi + + notify "Queued → $label" "$title" +} + +# Echo the template map ({key:{name:...}}) as JSON. Prefers a live fetch (and +# refreshes the cache), falls back to the cache, then to a minimal built-in set +# so the picker still works fully offline. +fetch_templates() { + local pw json + pw="$(pass show "$pass_entry" 2>/dev/null | head -1)" + if [[ -n "$pw" ]]; then + json="$(curl -sS -m 8 -u "$api_user:$pw" "$url/capture-templates" 2>/dev/null)" + if [[ -n "$json" ]] && printf '%s' "$json" | jq -e 'type == "object"' >/dev/null 2>&1; then + printf '%s' "$json" >"$cache_file" + printf '%s' "$json" + return 0 + fi + fi + if [[ -r "$cache_file" ]]; then + cat "$cache_file" + return 0 + fi + printf '%s' '{"capture-n":{"name":"Next (Scheduled Today)"},"capture-i":{"name":"Inbox"},"default":{"name":"GTD Todo"}}' +} + +# Resolve a template's display name from the cache without touching the network. +name_for_key() { + local key="$1" + [[ -r "$cache_file" ]] || return 0 + jq -r --arg k "$key" '.[$k].name // empty' "$cache_file" 2>/dev/null || true +} + +mode="default" +forced_template="" +case "${1-}" in + "") mode="default" ;; + --pick | -p | pick) mode="pick" ;; + -h | --help) + sed -n '3,25p' "$0" + exit 0 + ;; + *) + mode="forced" + forced_template="$1" + ;; +esac + +case "$mode" in + default) + text="$(read_text "Capture" \ + -mesg 'Enter Today · NEXT Alt+i Inbox' \ + -kb-custom-1 "Alt+i")" + [[ $input_code -eq 1 ]] && exit 0 + [[ -n "$text" ]] || exit 0 + case $input_code in + 0) enqueue "capture-n" "Today · NEXT" "$text" ;; + 10) enqueue "capture-i" "Inbox" "$text" ;; + esac + ;; + + pick) + templates="$(fetch_templates)" + selection="$(printf '%s' "$templates" \ + | jq -r 'to_entries[] | "\(.value.name)\t\(.key)"' \ + | rofi -dmenu -i -p "Template")" + [[ -n "$selection" ]] || exit 0 + template="${selection##*$'\t'}" + label="${selection%%$'\t'*}" + text="$(read_text "$label")" + [[ $input_code -eq 1 ]] && exit 0 + [[ -n "$text" ]] || exit 0 + enqueue "$template" "$label" "$text" + ;; + + forced) + label="$(name_for_key "$forced_template")" + [[ -n "$label" ]] || label="$forced_template" + text="$(read_text "$label")" + [[ $input_code -eq 1 ]] && exit 0 + [[ -n "$text" ]] || exit 0 + enqueue "$forced_template" "$label" "$text" + ;; +esac diff --git a/dotfiles/lib/bin/mova_capture_drain b/dotfiles/lib/bin/mova_capture_drain new file mode 100755 index 00000000..2b007d13 --- /dev/null +++ b/dotfiles/lib/bin/mova_capture_drain @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# +# Drain the mova quick-capture queue into org-agenda-api. +# +# Reads job files written by mova_capture.sh and POSTs each to /capture. +# - success -> job deleted, success notification +# - network / 5xx -> job kept, retried on the next drain (persistent) +# - 4xx (rejected) -> job moved to failed/ so it cannot loop forever +# +# Designed to be run by a systemd user service triggered on enqueue (.path), +# on a retry timer (.timer), and directly by the front-end for immediacy. +# Credentials come from `pass` at runtime; if gpg-agent is locked the drain +# exits quietly and leaves the queue for a later run. + +set -uo pipefail + +state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/mova-capture" +queue_dir="$state_dir/queue" +failed_dir="$state_dir/failed" + +url="${MOVA_API_URL:-https://org-agenda-api.rocket-sense.duckdns.org}" +api_user="${MOVA_API_USER:-imalison}" +pass_entry="${MOVA_API_PASS_ENTRY:-org-agenda-api-imalison}" + +mkdir -p "$queue_dir" + +notify() { + command -v notify-send >/dev/null 2>&1 && notify-send -a "mova capture" "$@" || true +} + +shopt -s nullglob +jobs=("$queue_dir"/*.json) +((${#jobs[@]})) || exit 0 + +# Serialize drains so a manual kick and the timer/path trigger can't double-POST +# the same job. Non-blocking: if another drain holds the lock, let it finish. +lockfile="$state_dir/drain.lock" +exec 9>"$lockfile" +flock -n 9 || exit 0 + +# Re-check after acquiring the lock; a concurrent drain may have emptied it. +jobs=("$queue_dir"/*.json) +((${#jobs[@]})) || exit 0 + +password="$(pass show "$pass_entry" 2>/dev/null | head -1)" +if [[ -z "$password" ]]; then + # gpg-agent likely locked or pass unavailable; keep the queue for later. + exit 0 +fi + +for job in "${jobs[@]}"; do + payload="$(jq -c '{template: .template, values: .values}' "$job" 2>/dev/null)" + if [[ -z "$payload" || "$payload" == "null" ]]; then + mkdir -p "$failed_dir" + mv "$job" "$failed_dir/" + notify "Capture unreadable — moved to failed/" "$(basename "$job")" + continue + fi + title="$(jq -r '.values.Title // ""' "$job" 2>/dev/null)" + label="$(jq -r '.label // .template' "$job" 2>/dev/null)" + + response="$(curl -sS -m 30 -u "$api_user:$password" \ + -X POST "$url/capture" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + -w $'\n%{http_code}' 2>/dev/null)" + if [[ $? -ne 0 || -z "$response" ]]; then + # Network error / DNS / timeout: keep the job and retry next drain. + continue + fi + + http_code="${response##*$'\n'}" + body="${response%$'\n'*}" + status="$(printf '%s' "$body" | jq -r '.status // ""' 2>/dev/null)" + + if [[ "$http_code" == 2* && "$status" == "created" ]]; then + rm -f "$job" + notify "Captured → $label" "$title" + elif [[ "$http_code" == 4* ]]; then + # Client error (bad template, auth): it won't fix itself on retry. + mkdir -p "$failed_dir" + mv "$job" "$failed_dir/" + message="$(printf '%s' "$body" | jq -r '.message // empty' 2>/dev/null)" + notify "Capture rejected ($http_code) → $label" "${title}${message:+ — $message}" + else + # 5xx or unexpected body: keep and retry. + continue + fi +done diff --git a/nixos/desktop.nix b/nixos/desktop.nix index bcb4521a..61d910d5 100644 --- a/nixos/desktop.nix +++ b/nixos/desktop.nix @@ -190,6 +190,7 @@ ./fonts.nix ./hyprland.nix ./keyd.nix + ./mova-capture.nix ./wlsunset.nix ]; diff --git a/nixos/mova-capture.nix b/nixos/mova-capture.nix new file mode 100644 index 00000000..66e11af9 --- /dev/null +++ b/nixos/mova-capture.nix @@ -0,0 +1,55 @@ +# Quick-capture into mova / org-agenda-api. +# +# The rofi front-end (dotfiles/lib/bin/mova_capture.sh) drops a job file into +# ~/.local/state/mova-capture/queue and this module runs the drain worker that +# POSTs it to the API, retrying persistently until it lands: +# - .path fires the drain the moment a job is enqueued +# - .timer retries anything still queued (network/API outages, reboots) +{ + config, + pkgs, + lib, + ... +}: let + worktree = config.dotfiles-worktree; + drain = "${worktree}/dotfiles/lib/bin/mova_capture_drain"; + drainPath = lib.makeBinPath [ + pkgs.bash + pkgs.coreutils + pkgs.curl + pkgs.jq + pkgs.pass + pkgs.gnupg + pkgs.util-linux # flock + pkgs.libnotify + ]; +in { + home-manager.users.imalison = { + systemd.user.services.mova-capture-drain = { + Unit.Description = "Drain queued mova/org-agenda-api quick-captures"; + Service = { + Type = "oneshot"; + Environment = ["PATH=${drainPath}"]; + ExecStart = "${pkgs.bash}/bin/bash ${drain}"; + }; + }; + + # Retry loop: drain is idempotent and exits immediately when the queue is + # empty, so a steady tick is cheap and covers the offline/reboot case. + systemd.user.timers.mova-capture-drain = { + Unit.Description = "Retry queued mova quick-captures"; + Timer = { + OnBootSec = "2min"; + OnUnitInactiveSec = "2min"; + }; + Install.WantedBy = ["timers.target"]; + }; + + # Instant drain when the front-end enqueues a job. + systemd.user.paths.mova-capture-drain = { + Unit.Description = "Trigger mova capture drain on enqueue"; + Path.PathExistsGlob = "%h/.local/state/mova-capture/queue/*.json"; + Install.WantedBy = ["paths.target"]; + }; + }; +}