alarm + timer
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.custom.alarms-timers;
|
||||
runtime = pkgs.writeShellScriptBin "alarm-runtime" ''
|
||||
set -efu
|
||||
|
||||
state_dir="''${XDG_STATE_HOME:-$HOME/.local/state}/alarm"
|
||||
state_file="$state_dir/events.json"
|
||||
lock_file="$state_dir/events.lock"
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
init_state() {
|
||||
if [ ! -s "$state_file" ] || ! jq -e '(.nextId | numbers) and (.events | arrays)' "$state_file" >/dev/null 2>&1; then
|
||||
printf '%s\n' '{"nextId":1,"events":[]}' > "$state_file"
|
||||
fi
|
||||
}
|
||||
|
||||
write_state() {
|
||||
tmp=$(mktemp "$state_dir/events.XXXXXX")
|
||||
jq -c . > "$tmp"
|
||||
mv -f "$tmp" "$state_file"
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '%s\n' "usage: timer DURATION|list|cancel ID" " alarm [YYYY-MM-DD] HH:MM [DESCRIPTION...]" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'alarm: %s\n' "$1" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
parse_duration() {
|
||||
[ -n "$1" ] && [[ "$1" != *[!0-9smh]* ]] || return 1
|
||||
value=''${1%[smh]}
|
||||
unit=''${1#''${value}}
|
||||
[ -n "$value" ] && [ "$value" -gt 0 ] || return 1
|
||||
case "$unit" in s) printf '%s\n' "$value" ;; m) printf '%s\n' "$((value * 60))" ;; h) printf '%s\n' "$((value * 3600))" ;; *) return 1 ;; esac
|
||||
}
|
||||
|
||||
valid_time() { [[ "$1" =~ ^([01][0-9]|2[0-3]):[0-5][0-9]$ ]]; }
|
||||
valid_date() { [[ "$1" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; }
|
||||
|
||||
create_timer() {
|
||||
seconds=$(parse_duration "$1") || die "duration must be a positive number followed by s, m, or h"
|
||||
now=$(date +%s)
|
||||
(
|
||||
flock -x 9
|
||||
init_state
|
||||
id=$(jq -r '.nextId' "$state_file")
|
||||
jq --argjson id "$id" --argjson due "$((now + seconds))" \
|
||||
'.nextId = ($id + 1) | .events += [{id:$id,kind:"timer",due:$due,description:null}]' \
|
||||
"$state_file" | write_state
|
||||
printf '%s\n' "$id"
|
||||
) 9>"$lock_file"
|
||||
}
|
||||
|
||||
create_alarm() {
|
||||
if valid_date "$1"; then
|
||||
[ "$#" -ge 2 ] || usage
|
||||
alarm_date=$1; alarm_time=$2; shift 2
|
||||
valid_time "$alarm_time" || die "time must use HH:MM"
|
||||
else
|
||||
alarm_date=$(date +%F); alarm_time=$1; shift
|
||||
valid_time "$alarm_time" || die "time must use HH:MM"
|
||||
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid local time"
|
||||
[ "$candidate" -gt "$(date +%s)" ] || alarm_date=$(date -d tomorrow +%F)
|
||||
fi
|
||||
candidate=$(date -d "$alarm_date $alarm_time" +%s) || die "invalid date or time"
|
||||
[ "$(date -d "@$candidate" +%F\ %H:%M)" = "$alarm_date $alarm_time" ] || die "invalid date or time"
|
||||
[ "$candidate" -gt "$(date +%s)" ] || die "explicit alarm date is in the past"
|
||||
description="$*"
|
||||
(
|
||||
flock -x 9
|
||||
init_state
|
||||
if jq -e --argjson due "$candidate" '.events[] | select(.kind == "alarm" and .due == $due)' "$state_file" >/dev/null; then
|
||||
die "an alarm already exists at that time"
|
||||
fi
|
||||
id=$(jq -r '.nextId' "$state_file")
|
||||
jq --argjson id "$id" --argjson due "$candidate" --arg description "$description" \
|
||||
'.nextId = ($id + 1) | .events += [{id:$id,kind:"alarm",due:$due,description:(if $description == "" then null else $description end)}]' \
|
||||
"$state_file" | write_state
|
||||
printf '%s\n' "$id"
|
||||
) 9>"$lock_file"
|
||||
}
|
||||
|
||||
list_events() {
|
||||
kind=$1
|
||||
(
|
||||
flock -s 9
|
||||
init_state
|
||||
if ! jq -e --arg kind "$kind" '.events | map(select(.kind == $kind)) | length > 0' "$state_file" >/dev/null; then
|
||||
printf 'No %s events scheduled.\n' "$kind"
|
||||
exit 0
|
||||
fi
|
||||
jq -r --arg kind "$kind" '
|
||||
.events | map(select(.kind == $kind)) | sort_by(.due)[] |
|
||||
"\(.id) \(if .kind == "timer" then ("due " + ( .due | todateiso8601)) else ("at " + (.due | strftime("%Y-%m-%d %H:%M"))) end)\(if .description then " - " + .description else "" end)"
|
||||
' "$state_file"
|
||||
) 9>"$lock_file"
|
||||
}
|
||||
|
||||
cancel_event() {
|
||||
kind=$1; id=$2
|
||||
[[ "$id" =~ ^[0-9]+$ ]] || die "ID must be numeric"
|
||||
(
|
||||
flock -x 9
|
||||
init_state
|
||||
if ! jq -e --arg kind "$kind" --argjson id "$id" '.events[] | select(.id == $id and .kind == $kind)' "$state_file" >/dev/null; then
|
||||
die "no $kind with ID $id"
|
||||
fi
|
||||
jq --arg kind "$kind" --argjson id "$id" '.events |= map(select(.id != $id or .kind != $kind))' "$state_file" | write_state
|
||||
printf 'Cancelled %s %s.\n' "$kind" "$id"
|
||||
) 9>"$lock_file"
|
||||
}
|
||||
|
||||
notify_event() {
|
||||
kind=$1; due=$2; description=$3
|
||||
title=$(printf '%s' "$kind" | tr '[:lower:]' '[:upper:]')
|
||||
body="Due $(date -d "@$due" '+%Y-%m-%d %H:%M')"
|
||||
[ -n "$description" ] && body="$body - $description"
|
||||
notify-send -u critical "$title" "$body" || true
|
||||
timeout 1 pw-play "${pkgs.sound-theme-freedesktop}/share/sounds/freedesktop/stereo/alarm-clock-elapsed.oga" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
scheduler() {
|
||||
started=$(date +%s)
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
event=$(mktemp)
|
||||
(
|
||||
flock -x 9
|
||||
init_state
|
||||
jq --argjson started "$started" --argjson now "$now" \
|
||||
' .events |= map(select(.due >= $started or .due > $now))' "$state_file" | write_state
|
||||
jq -c --argjson started "$started" --argjson now "$now" \
|
||||
'[.events[] | select(.due >= $started and .due <= $now)] | sort_by(.due) | .[0] // empty' "$state_file" > "$event"
|
||||
if [ -s "$event" ]; then
|
||||
jq --argjson id "$(jq -r .id "$event")" '.events |= map(select(.id != $id))' "$state_file" | write_state
|
||||
fi
|
||||
) 9>"$lock_file"
|
||||
if [ -s "$event" ]; then
|
||||
kind=$(jq -r .kind "$event"); due=$(jq -r .due "$event"); description=$(jq -r '.description // empty' "$event")
|
||||
rm -f "$event"
|
||||
notify_event "$kind" "$due" "$description"
|
||||
else
|
||||
rm -f "$event"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
waybar() {
|
||||
now=$(date +%s)
|
||||
init_state
|
||||
jq -cn --argjson now "$now" --slurpfile state "$state_file" '
|
||||
($state[0].events | map(select((.id|numbers) and (.kind == "timer" or .kind == "alarm") and (.due|numbers) and .due >= $now)) | sort_by(.due) | .[0:3]) as $events |
|
||||
{text: ($events | map(if .kind == "timer" then " " + (if (.due - $now) < 60 then ((.due - $now)|tostring) else (((.due - $now) / 3600)|floor|tostring|if length < 2 then "0" + . else . end) + ":" + ((((.due - $now) % 3600) / 60)|floor|tostring|if length < 2 then "0" + . else . end) end) else " " + (.due | strftime("%H:%M")) end) | join(" ")), tooltip: ($events | map((.kind + " " + (.due | strftime("%Y-%m-%d %H:%M")) + (if .description then " - " + .description else "" end))) | join("\n"))}
|
||||
'
|
||||
}
|
||||
|
||||
command=''${1:-}
|
||||
case "$command" in
|
||||
timer) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events timer ;; cancel) [ "$#" = 3 ] || usage; cancel_event timer "$3" ;; *) [ "$#" = 2 ] || usage; create_timer "$2" ;; esac ;;
|
||||
alarm) [ "$#" -ge 2 ] || usage; case "$2" in list) list_events alarm ;; cancel) [ "$#" = 3 ] || usage; cancel_event alarm "$3" ;; *) create_alarm ''${@:2} ;; esac ;;
|
||||
scheduler) scheduler ;;
|
||||
waybar) waybar ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
'';
|
||||
timer = pkgs.writeShellScriptBin "timer" ''exec ${runtime}/bin/alarm-runtime timer "$@"'';
|
||||
alarm = pkgs.writeShellScriptBin "alarm" ''exec ${runtime}/bin/alarm-runtime alarm "$@"'';
|
||||
scheduler = pkgs.writeShellScriptBin "alarm-scheduler" ''exec ${runtime}/bin/alarm-runtime scheduler'';
|
||||
waybar = pkgs.writeShellScriptBin "alarm-waybar" ''exec ${runtime}/bin/alarm-runtime waybar'';
|
||||
in
|
||||
{
|
||||
options.custom.alarms-timers.enable = lib.mkEnableOption "terminal alarms and timers";
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [ timer alarm scheduler waybar pkgs.jq pkgs.libnotify pkgs.pipewire pkgs.sound-theme-freedesktop ];
|
||||
|
||||
home.activation.alarmsTimersState = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
run mkdir -p "$HOME/.local/state/alarm"
|
||||
if [ ! -s "$HOME/.local/state/alarm/events.json" ]; then
|
||||
run printf '%s\n' '{"nextId":1,"events":[]}' > "$HOME/.local/state/alarm/events.json"
|
||||
fi
|
||||
'';
|
||||
|
||||
systemd.user.services.alarm-scheduler = {
|
||||
Unit = {
|
||||
Description = "Terminal alarm and timer scheduler";
|
||||
After = [ "graphical-session.target" ];
|
||||
PartOf = [ "graphical-session.target" ];
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = "${scheduler}/bin/alarm-scheduler";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 2;
|
||||
};
|
||||
Install.WantedBy = [ "graphical-session.target" ];
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user