mova: add rofi quick-capture into org-agenda-api

Fire-and-forget capture of tasks/notes into mova. The rofi front-end
drops a job onto a persistent on-disk queue and a systemd user service
(.service + retry .timer + enqueue .path) POSTs it to /capture, retrying
across network/API outages and reboots until it lands.

hyper+T is the fast path (Enter -> Today/NEXT capture-n, Alt+i -> Inbox
capture-i). hyper+SHIFT+T (--pick) lists all capture templates (fetched
live, cached for offline use). A bare template key argument
(e.g. `mova_capture.sh capture-z`) captures straight to that template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 13:16:33 -07:00
parent 459256a685
commit 0592807638
5 changed files with 308 additions and 0 deletions

View File

@@ -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 .. " + 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 .. " + 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 .. " + 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 .. " + 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 .. " + SHIFT + C", exec("rofi_tmcodex.sh resume"), desc("Resume Codex session"))
bind(hyper .. " + L", exec("hypr_rofi_layout"), desc("Open Hyprland layout menu")) bind(hyper .. " + L", exec("hypr_rofi_layout"), desc("Open Hyprland layout menu"))

161
dotfiles/lib/bin/mova_capture.sh Executable file
View File

@@ -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 <template-key> 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 '<b>Enter</b> Today · NEXT <b>Alt+i</b> 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

View File

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

View File

@@ -190,6 +190,7 @@
./fonts.nix ./fonts.nix
./hyprland.nix ./hyprland.nix
./keyd.nix ./keyd.nix
./mova-capture.nix
./wlsunset.nix ./wlsunset.nix
]; ];

55
nixos/mova-capture.nix Normal file
View File

@@ -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"];
};
};
}