commit d22ef44738b3c6942da01760b8d60a19b8f41560 parent 490ae317402073ab077ec17e97ff73d51f6b29a8 Author: mtmn <miro@haravara.org> Date: Thu, 9 Apr 2026 15:08:33 +0200 feat: add khal_notifier bin Diffstat:
| A | modules/mixins/dotfiles/bin/khal_notifier | | | 224 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ |
1 file changed, 224 insertions(+), 0 deletions(-)
diff --git a/modules/mixins/dotfiles/bin/khal_notifier b/modules/mixins/dotfiles/bin/khal_notifier @@ -0,0 +1,224 @@ +#!/usr/bin/env bb + +(ns khal-notifier + (:require [clojure.string :as str] + [babashka.process :refer [shell]] + [babashka.fs :as fs])) + +(defn run-cmd [cmd args & {:keys [timeout] :or {timeout 30000}}] + "Returns {:status :ok, :out s, :code n} or {:status :error, :reason r}" + (try + (if-not (fs/which cmd) + {:status :error :reason :enoent} + (let [result (apply shell {:out :string :err :string :continue true :timeout timeout} + cmd args)] + {:status :ok :out (:out result) :code (:exit result)})) + (catch java.util.concurrent.TimeoutException _ + {:status :error :reason :timeout}) + (catch Exception e + {:status :error :reason e}))) + +(defn sync-vdirsyncer [] + (println "[INFO] Syncing calendars with vdirsyncer") + (let [result (run-cmd "vdirsyncer" ["sync"] :timeout 60000)] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + (do (println "[INFO] vdirsyncer sync complete") + true) + + (= :ok (:status result)) + (do (println (format "[WARN] vdirsyncer exited with code %d: %s" + (:code result) + (str/trim (or (:out result) "")))) + false) + + (= :enoent (:reason result)) + (do (println "[ERROR] vdirsyncer not found") + false) + + (= :timeout (:reason result)) + (do (println "[ERROR] vdirsyncer timed out after 60 seconds") + false) + + :else + (do (println (format "[ERROR] vdirsyncer failed: %s" + (ex-message (:reason result)))) + false)))) + +(defn get-upcoming-events [lookahead] + (println (format "[INFO] Checking khal for events in the next %s" lookahead)) + (let [result (run-cmd "khal" ["list" "now" lookahead "--format" "{start-time}|{end-time}|{title}|{organizer}"])] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + (let [lines (->> (:out result) + str/split-lines + (map str/trim) + (remove str/blank?) + (filter #(str/includes? % "|"))) + events (mapv (fn [line] + (let [parts (str/split line #"\|" 4) + cnt (count parts)] + (cond + (= cnt 4) + {:start-time (str/trim (nth parts 0)) + :end-time (str/trim (nth parts 1)) + :title (str/trim (nth parts 2)) + :organizer (str/trim (nth parts 3))} + + (= cnt 3) + {:start-time (str/trim (nth parts 0)) + :end-time (str/trim (nth parts 1)) + :title (str/trim (nth parts 2)) + :organizer ""} + + (= cnt 2) + {:start-time (str/trim (nth parts 0)) + :end-time "" + :title (str/trim (nth parts 1)) + :organizer ""} + + :else + {:start-time "" + :end-time "" + :title (str/trim (nth parts 0)) + :organizer ""}))) + lines) + events (remove #(str/blank? (:title %)) events)] + (println (format "[INFO] Found %d upcoming event(s)" (count events))) + events) + + (= :ok (:status result)) + (do (println (format "[WARN] khal exited with code %d: %s" + (:code result) + (str/trim (or (:out result) "")))) + []) + + (= :enoent (:reason result)) + (do (println "[ERROR] khal not found") + []) + + (= :timeout (:reason result)) + (do (println "[ERROR] khal timed out") + []) + + :else + (do (println "[ERROR] khal execution failed") + [])))) + +(defn now-time [] + "Get current time as HH:MM string" + (-> (shell {:out :string :continue true} "date" "+%H:%M") + :out + str/trim)) + +(defn filter-events [events] + "Filter out events that have already started" + (let [now (now-time)] + (remove #(and (not (str/blank? (:start-time %))) + (<= (compare (:start-time %) now) 0)) + events))) + +(defn format-event [event] + "Format an event for notification body" + (let [time-str (let [{:keys [start-time end-time]} event] + (cond + (and (str/blank? start-time) (str/blank? end-time)) + "All day" + (str/blank? end-time) + start-time + :else + (str start-time " - " end-time)))] + (if (not (str/blank? (:organizer event))) + (str time-str "\nOrganizer: " (:organizer event)) + time-str))) + +(defn send-notification [summary body urgency] + "Send a desktop notification using notify-send" + (println (format "[INFO] Notifying: %s — %s" summary body)) + (let [result (run-cmd "notify-send" + ["--app-name" "khal_notifier" + "--urgency" urgency + summary + body])] + (cond + (and (= :ok (:status result)) + (zero? (:code result))) + true + + (= :ok (:status result)) + (do (println (format "[ERROR] notify-send failed (code %d): %s" + (:code result) + (str/trim (or (:out result) "")))) + false) + + (= :enoent (:reason result)) + (do (println "[ERROR] notify-send not found") + false) + + :else + (do (println "[ERROR] notify-send failed") + false)))) + +(defn run-once [lookahead urgency skip-sync] + "Run a single check cycle" + (when-not skip-sync + (when-not (sync-vdirsyncer) + (println "[WARN] Sync failed — continuing with local calendar data"))) + + (let [events (get-upcoming-events lookahead) + filtered (filter-events events)] + (if (empty? filtered) + (println "[INFO] No upcoming events") + (doseq [event filtered] + (let [summary (:title event) + body (format-event event)] + (send-notification summary body urgency)))))) + +(defn run-with-refresh [seconds lookahead urgency skip-sync] + "Run continuously, checking every N seconds" + (println (format "[INFO] Running in refresh mode: checking every %d second(s)" seconds)) + (loop [iteration 0] + (when (pos? iteration) + (println (format "[INFO] Waiting %d second(s) before next refresh" seconds)) + (Thread/sleep (* seconds 1000))) + (println (format "[INFO] Run %d" (inc iteration))) + (run-once lookahead urgency skip-sync) + (recur (inc iteration)))) + +(defn print-usage [] + (println "Usage: khal_notifier [options]") + (println) + (println "Options:") + (println " --lookahead How far ahead to check (default: 15m)") + (println " --skip-sync Skip vdirsyncer sync") + (println " --urgency Notification urgency (default: critical)") + (println " --refresh Run continuously, re-checking every N seconds (default: 300)") + (println " --help Show this help")) + +(defn parse-args [args] + (loop [pairs (seq args) + acc {}] + (if (empty? pairs) + acc + (let [k (first pairs) + v (second pairs)] + (if (and v (not (str/starts-with? v "--"))) + (recur (nnext pairs) + (assoc acc k v)) + (recur (next pairs) + (assoc acc k true))))))) + +(defn -main [& args] + (let [opts (parse-args args)] + (if (get opts "--help") + (do (print-usage) + (System/exit 0)) + (let [lookahead (get opts "--lookahead" "15m") + urgency (get opts "--urgency" "critical") + skip-sync (contains? opts "--skip-sync") + refresh (parse-long (get opts "--refresh" "300"))] + (run-with-refresh refresh lookahead urgency skip-sync))))) + +(apply -main *command-line-args*)