nix

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

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:
Mflake.lock | 18+++++++++---------
Mmodules/mixins/dotfiles/bin/habitual | 107+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mmodules/mixins/dotfiles/bin/khal_notifier | 37+++++++++++++++++++++++++++----------
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)