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*)