nix

configuration files that power my machines
Log | Files | Refs | README | LICENSE

khal_notifier (6076B)


      1 #!/usr/bin/env bb
      2 ;; vim: ft=clojure
      3 
      4 (ns khal-notifier
      5   (:require [clojure.string :as str]
      6             [babashka.process :refer [shell]]
      7             [babashka.fs :as fs]))
      8 
      9 (defn run-cmd [cmd args & {:keys [timeout] :or {timeout 30000}}]
     10   (try
     11     (if-not (fs/which cmd)
     12       {:status :error :reason :enoent}
     13       (let [result (apply shell {:out :string :err :string :timeout timeout} cmd args)]
     14         {:status :ok :out (:out result) :code (:exit result)}))
     15     (catch java.util.concurrent.TimeoutException _
     16       {:status :error :reason :timeout})
     17     (catch Exception e
     18       {:status :error :reason e})))
     19 
     20 (defn sync-pimsync []
     21   (println "[INFO] Syncing calendars with pimsync")
     22   (let [result (run-cmd "pimsync" ["sync"] :timeout 60000)]
     23     (cond
     24       (and (= :ok (:status result)) (zero? (:code result)))
     25       (do (println "[INFO] pimsync sync complete") true)
     26 
     27       (= :ok (:status result))
     28       (do (println (format "[WARN] pimsync exited with code %d: %s"
     29                            (:code result) (str/trim (or (:out result) ""))))
     30           false)
     31 
     32       (= :enoent (:reason result)) (do (println "[ERROR] pimsync not found") false)
     33       (= :timeout (:reason result)) (do (println "[ERROR] pimsync timed out") false)
     34       :else (do (println (format "[ERROR] pimsync failed: %s" (ex-message (:reason result))))
     35                 false))))
     36 
     37 (defn parse-event-line [line]
     38   (let [parts (str/split line #"\|" 4)
     39         cnt (count parts)]
     40     (case cnt
     41       4 {:start-time (nth parts 0) :end-time (nth parts 1)
     42          :title (nth parts 2) :organizer (nth parts 3)}
     43       3 {:start-time (nth parts 0) :end-time (nth parts 1)
     44          :title (nth parts 2) :organizer ""}
     45       2 {:start-time (nth parts 0) :end-time ""
     46          :title (nth parts 1) :organizer ""}
     47       {:start-time "" :end-time "" :title (nth parts 0) :organizer ""})))
     48 
     49 (defn get-upcoming-events [lookahead]
     50   (println (format "[INFO] Checking khal for events in the next %s" lookahead))
     51   (let [result (run-cmd "khal" ["list" "now" lookahead "--format"
     52                                 "{start-time}|{end-time}|{title}|{organizer}"])]
     53     (cond
     54       (and (= :ok (:status result)) (zero? (:code result)))
     55       (let [events (->> (:out result)
     56                         str/split-lines
     57                         (map str/trim)
     58                         (remove str/blank?)
     59                         (filter #(str/includes? % "|"))
     60                         (map parse-event-line)
     61                         (remove #(str/blank? (:title %)))
     62                         vec)]
     63         (println (format "[INFO] Found %d upcoming event(s)" (count events)))
     64         events)
     65 
     66       (= :ok (:status result))
     67       (do (println (format "[WARN] khal exited with code %d: %s"
     68                            (:code result) (str/trim (or (:out result) ""))))
     69           [])
     70 
     71       (= :enoent (:reason result)) (do (println "[ERROR] khal not found") [])
     72       (= :timeout (:reason result)) (do (println "[ERROR] khal timed out") [])
     73       :else (do (println "[ERROR] khal execution failed") []))))
     74 
     75 (defn now-time []
     76   (-> (shell {:out :string} "date" "+%H:%M") :out str/trim))
     77 
     78 (defn event-started? [start end now]
     79   (cond
     80     (str/blank? start) false
     81     (str/blank? end) (<= (compare start now) 0)
     82     (<= (compare start end) 0) (<= (compare start now) 0)
     83     :else (or (<= (compare start now) 0) (<= (compare now end) 0))))
     84 
     85 (defn filter-events [events]
     86   (let [now (now-time)]
     87     (remove #(event-started? (:start-time %) (:end-time %) now) events)))
     88 
     89 (defn format-event [event]
     90   (let [time-str (cond
     91                    (and (str/blank? (:start-time event)) (str/blank? (:end-time event))) "All day"
     92                    (str/blank? (:end-time event)) (:start-time event)
     93                    :else (str (:start-time event) " - " (:end-time event)))]
     94     (if (str/blank? (:organizer event))
     95       time-str
     96       (str time-str "\nOrganizer: " (:organizer event)))))
     97 
     98 (defn send-notification [summary body]
     99   (println (format "[INFO] Notifying: %s — %s" summary body))
    100   (let [result (run-cmd "notify-send"
    101                         ["--app-name" "khal_notifier"
    102                          "--urgency" "critical"
    103                          summary body])]
    104     (cond
    105       (and (= :ok (:status result)) (zero? (:code result))) true
    106       (= :ok (:status result))
    107       (do (println (format "[ERROR] notify-send failed (code %d): %s"
    108                            (:code result) (str/trim (or (:out result) ""))))
    109           false)
    110       (= :enoent (:reason result)) (do (println "[ERROR] notify-send not found") false)
    111       :else (do (println "[ERROR] notify-send failed") false))))
    112 
    113 (defn run-once [lookahead skip-sync]
    114   (when-not skip-sync
    115     (when-not (sync-pimsync)
    116       (println "[WARN] Sync failed — continuing with local calendar data")))
    117   (let [events (get-upcoming-events lookahead)
    118         filtered (filter-events events)]
    119     (if (empty? filtered)
    120       (println "[INFO] No upcoming events")
    121       (doseq [event filtered]
    122         (send-notification (:title event) (format-event event))))))
    123 
    124 (defn parse-opts [args]
    125   (loop [remaining args
    126          opts {:lookahead "60m" :skip-sync false :help false}]
    127     (if (empty? remaining)
    128       opts
    129       (let [arg (first remaining)]
    130         (cond
    131           (= arg "--help")      (recur (rest remaining) (assoc opts :help true))
    132           (= arg "--skip-sync") (recur (rest remaining) (assoc opts :skip-sync true))
    133           (= arg "--lookahead") (recur (drop 2 remaining) (assoc opts :lookahead (second remaining)))
    134           :else                (recur (rest remaining) opts))))))
    135 
    136 (defn print-usage []
    137   (println "Usage: khal_notifier [options]")
    138   (println)
    139   (println "Options:")
    140   (println "  --lookahead TIME  How far ahead to check (default: 60m)")
    141   (println "  --skip-sync       Skip pimsync sync")
    142   (println "  --help            Show this help"))
    143 
    144 (defn -main [& args]
    145   (let [{:keys [lookahead skip-sync help]} (parse-opts args)]
    146     (if help
    147       (do (print-usage) (System/exit 0))
    148       (run-once lookahead skip-sync))))
    149 
    150 (apply -main *command-line-args*)