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:
@@ -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"))
|
||||
|
||||
161
dotfiles/lib/bin/mova_capture.sh
Executable file
161
dotfiles/lib/bin/mova_capture.sh
Executable 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
|
||||
89
dotfiles/lib/bin/mova_capture_drain
Executable file
89
dotfiles/lib/bin/mova_capture_drain
Executable 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
|
||||
@@ -190,6 +190,7 @@
|
||||
./fonts.nix
|
||||
./hyprland.nix
|
||||
./keyd.nix
|
||||
./mova-capture.nix
|
||||
./wlsunset.nix
|
||||
];
|
||||
|
||||
|
||||
55
nixos/mova-capture.nix
Normal file
55
nixos/mova-capture.nix
Normal 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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user