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 .. " + 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
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
|
./fonts.nix
|
||||||
./hyprland.nix
|
./hyprland.nix
|
||||||
./keyd.nix
|
./keyd.nix
|
||||||
|
./mova-capture.nix
|
||||||
./wlsunset.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