commit f9b73dad97f6b96a88c37d6d6e4c2888466823ac
parent 02663192cc4d2782ece36c5cfb7886e751ca371d
Author: mtmn <miro@haravara.org>
Date: Sun, 10 May 2026 00:15:25 +0200
fix: khal_notifier fires twice, lint and fmt
Diffstat:
3 files changed, 103 insertions(+), 59 deletions(-)
diff --git a/flake.lock b/flake.lock
@@ -32,11 +32,11 @@
},
"locked": {
"dir": "bandeno",
- "lastModified": 1778332863,
- "narHash": "sha256-QHiMM7U4A9naT5WcomDBW0KEgseGHwzAsgpejxD/vms=",
+ "lastModified": 1778357886,
+ "narHash": "sha256-P5Yfeb/Y9iKeT93pd7nIYmtBW+TnHSbffrT0hrZ0+Oo=",
"owner": "~mtmn",
"repo": "tools",
- "rev": "0f476415a9fc9b6e7a9538c88005c1baae89b82b",
+ "rev": "ac9d608a2ce57e13fd4e43ee82f4b59f144c718b",
"type": "sourcehut"
},
"original": {
@@ -54,11 +54,11 @@
]
},
"locked": {
- "lastModified": 1778274271,
- "narHash": "sha256-x+RldV0TIgyvFW9AtDkavdsCsghgZ4JGvSKhZts2BUk=",
+ "lastModified": 1778378436,
+ "narHash": "sha256-wQGmKhua8XBGcRk4ioXEezgg4N1ENwX39Z+CLONoyCs=",
"owner": "~mtmn",
"repo": "corpus",
- "rev": "f940cbf10297ce8caacd4b2dce47ba7f1e589560",
+ "rev": "62eb8719333225ced9b5f1c772b1f5a3235bab2e",
"type": "sourcehut"
},
"original": {
@@ -261,11 +261,11 @@
]
},
"locked": {
- "lastModified": 1778248595,
- "narHash": "sha256-dhFgEjoeJMYN/7OY6xfxS799YB4IjbbYXTjyGIJyLpc=",
+ "lastModified": 1778365864,
+ "narHash": "sha256-ImoT/wqmgMImf2dAC+E0MverAdA4QXsedOeES9B7Ezw=",
"owner": "nix-community",
"repo": "home-manager",
- "rev": "fdb2ccba9d5e1238d32e0c4a3ec1a277efa80c1d",
+ "rev": "2f419037039a152448c5f4ae9494154753d1b399",
"type": "github"
},
"original": {
diff --git a/modules/mixins/dotfiles/bin/habitual b/modules/mixins/dotfiles/bin/habitual
@@ -5,6 +5,11 @@
[clojure.tools.cli :refer [parse-opts]]
[babashka.process :refer [shell]]))
+(defn- positive-step [step part]
+ (when-not (and step (pos? step))
+ (throw (ex-info (format "Cron step must be positive: %s" part) {:part part})))
+ step)
+
(defn parse-cron-field [field lo hi]
(let [all (set (range lo (inc hi)))]
(reduce
@@ -14,13 +19,13 @@
(into acc all)
(re-matches #"\*/\d+" part)
- (let [step (parse-long (subs part 2))]
+ (let [step (positive-step (parse-long (subs part 2)) part)]
(into acc (range lo (inc hi) step)))
(re-matches #"\d+-\d+/\d+" part)
(let [[range-part step-part] (str/split part #"/")
[a b] (map parse-long (str/split range-part #"-"))
- step (parse-long step-part)]
+ step (positive-step (parse-long step-part) part)]
(into acc (range a (inc b) step)))
(re-matches #"\d+-\d+" part)
@@ -34,49 +39,71 @@
(defn parse-cron [expr]
(let [[minutes hours days months weekdays] (str/split expr #"\s+")]
- {:minutes (parse-cron-field minutes 0 59)
- :hours (parse-cron-field hours 0 23)
- :days (parse-cron-field days 1 31)
- :months (parse-cron-field months 1 12)
- :weekdays (parse-cron-field weekdays 0 6)}))
+ {:minutes (parse-cron-field minutes 0 59)
+ :hours (parse-cron-field hours 0 23)
+ :days (parse-cron-field days 1 31)
+ :months (parse-cron-field months 1 12)
+ :weekdays (parse-cron-field weekdays 0 6)
+ ;; Standard cron OR's day-of-month and day-of-week when both are
+ ;; restricted; AND's them otherwise.
+ :days-restricted? (not= (str/trim days) "*")
+ :weekdays-restricted? (not= (str/trim weekdays) "*")}))
+
+(defn matches-cron?
+ "True if the given LocalDateTime matches the parsed cron spec."
+ [cron ldt]
+ (let [month (.getMonthValue ldt) ; 1-12
+ mday (.getDayOfMonth ldt) ; 1-31
+ hour (.getHour ldt) ; 0-23
+ minute (.getMinute ldt) ; 0-59
+ weekday (-> ldt .getDayOfWeek .getValue (mod 7)) ; 0=Sun
+ dom-ok ((:days cron) mday)
+ dow-ok ((:weekdays cron) weekday)
+ day-ok (if (and (:days-restricted? cron) (:weekdays-restricted? cron))
+ (or dom-ok dow-ok)
+ (and dom-ok dow-ok))]
+ (and ((:months cron) month)
+ day-ok
+ ((:hours cron) hour)
+ ((:minutes cron) minute))))
-(defn next-cron-ms
- "Returns milliseconds until the next matching habit"
- [cron]
- (let [now (System/currentTimeMillis)
- start (-> now (quot 60000) inc (* 60000))]
+(defn next-cron-fire
+ "Find the next match strictly after `last-wall` (a LocalDateTime, or nil
+ for the first call). Returns {:delay-ms n :wall ldt}. The `last-wall`
+ guard prevents firing twice during a DST fall-back, where one wall-clock
+ minute corresponds to two epoch instants."
+ [cron last-wall]
+ (let [zone (java.time.ZoneId/systemDefault)
+ now (System/currentTimeMillis)
+ start (-> now (quot 60000) inc (* 60000))
+ limit (+ start (* 4 366 24 60 60 1000))]
(loop [t start]
- (if (> t (+ start (* 4 366 24 60 60 1000)))
- (throw (ex-info "No cron match found within 4 years" {:cron cron}))
- (let [ldt (java.time.LocalDateTime/ofInstant
- (java.time.Instant/ofEpochMilli t)
- (java.time.ZoneId/systemDefault))
- month (.getMonthValue ldt) ; 1-12
- mday (.getDayOfMonth ldt) ; 1-31
- hour (.getHour ldt) ; 0-23
- minute (.getMinute ldt) ; 0-59
- weekday (-> ldt .getDayOfWeek .getValue (mod 7))] ; 0=Sun (ISO Mon=1, so Sun=7 mod 7=0)
- (if (and ((:months cron) month)
- ((:days cron) mday)
- ((:weekdays cron) weekday)
- ((:hours cron) hour)
- ((:minutes cron) minute))
- (- t now)
- (recur (+ t 60000))))))))
+ (when (> t limit)
+ (throw (ex-info "No cron match found within 4 years" {:cron cron})))
+ (let [ldt (java.time.LocalDateTime/ofInstant
+ (java.time.Instant/ofEpochMilli t) zone)]
+ (if (and (matches-cron? cron ldt)
+ (or (nil? last-wall) (.isAfter ldt last-wall)))
+ {:delay-ms (- t now) :wall ldt}
+ (recur (+ t 60000)))))))
(defn run-job! [name cron prog]
(future
- (loop []
- (let [delay-ms (next-cron-ms cron)]
- (println (format "[INFO] Scheduling next run for '%s' in %d seconds"
- name (quot delay-ms 1000)))
- (Thread/sleep delay-ms)
- (println (format "[INFO] Running notifier for '%s'" name))
- (let [result (shell {:continue true} prog name)]
- (when (not= 0 (:exit result))
- (println (format "[WARN] Command returned %d: %s %s"
- (:exit result) prog name))))
- (recur)))))
+ (try
+ (loop [last-wall nil]
+ (let [{:keys [delay-ms wall]} (next-cron-fire cron last-wall)]
+ (println (format "[INFO] Scheduling next run for '%s' in %d seconds"
+ name (quot delay-ms 1000)))
+ (Thread/sleep delay-ms)
+ (println (format "[INFO] Running notifier for '%s'" name))
+ (let [result (shell {:continue true} prog name)]
+ (when (not= 0 (:exit result))
+ (println (format "[WARN] Command returned %d: %s %s"
+ (:exit result) prog name))))
+ (recur wall)))
+ (catch Throwable t
+ (println (format "[ERROR] Job '%s' aborted: %s"
+ name (or (ex-message t) (str t))))))))
(def cli-options
[["-p" "--prog PROG" "Program to send notifications to"
diff --git a/modules/mixins/dotfiles/bin/khal_notifier b/modules/mixins/dotfiles/bin/khal_notifier
@@ -5,8 +5,9 @@
[babashka.process :refer [shell]]
[babashka.fs :as fs]))
-(defn run-cmd [cmd args & {:keys [timeout] :or {timeout 30000}}]
+(defn run-cmd
"Returns {:status :ok, :out s, :code n} or {:status :error, :reason r}"
+ [cmd args & {:keys [timeout] :or {timeout 30000}}]
(try
(if-not (fs/which cmd)
{:status :error :reason :enoent}
@@ -107,21 +108,34 @@
(do (println "[ERROR] khal execution failed")
[]))))
-(defn now-time []
+(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]
+(defn event-started?
+ "True if the event is already under way (or over) at `now` (HH:MM strings).
+ When end < start the event spans midnight, so an end earlier than `now`
+ may still be in the future."
+ [start end now]
+ (cond
+ (str/blank? start) false
+ (str/blank? end) (<= (compare start now) 0)
+ (<= (compare start end) 0) (<= (compare start now) 0)
+ :else (or (<= (compare start now) 0)
+ (<= (compare now end) 0))))
+
+(defn filter-events
"Filter out events that have already started"
+ [events]
(let [now (now-time)]
- (remove #(and (not (str/blank? (:start-time %)))
- (<= (compare (:start-time %) now) 0))
- events)))
+ (remove #(event-started? (:start-time %) (:end-time %) now) events)))
-(defn format-event [event]
+(defn format-event
"Format an event for notification body"
+ [event]
(let [time-str (let [{:keys [start-time end-time]} event]
(cond
(and (str/blank? start-time) (str/blank? end-time))
@@ -134,8 +148,9 @@
(str time-str "\nOrganizer: " (:organizer event))
time-str)))
-(defn send-notification [summary body urgency]
+(defn send-notification
"Send a desktop notification using notify-send"
+ [summary body urgency]
(println (format "[INFO] Notifying: %s — %s" summary body))
(let [result (run-cmd "notify-send"
["--app-name" "khal_notifier"
@@ -161,8 +176,9 @@
(do (println "[ERROR] notify-send failed")
false))))
-(defn run-once [lookahead urgency skip-sync]
+(defn run-once
"Run a single check cycle"
+ [lookahead urgency skip-sync]
(when-not skip-sync
(when-not (sync-vdirsyncer)
(println "[WARN] Sync failed — continuing with local calendar data")))
@@ -176,8 +192,9 @@
body (format-event event)]
(send-notification summary body urgency))))))
-(defn run-with-refresh [seconds lookahead urgency skip-sync]
+(defn run-with-refresh
"Run continuously, checking every N seconds"
+ [seconds lookahead urgency skip-sync]
(println (format "[INFO] Running in refresh mode: checking every %d second(s)" seconds))
(loop [iteration 0]
(when (pos? iteration)