;;; +org-reminders.el --- Send org task reminders via ntfy -*- lexical-binding: t; -*- ;;; Commentary: ;; Scans org-agenda-files for TODO entries that carry a timestamp ;; (SCHEDULED:, DEADLINE:, or an inline active timestamp under the heading) ;; and publishes ntfy notifications ahead of time. ;; ;; Every timed entry gets a default reminder (`org-reminder-default-interval'), ;; plus any offsets listed in a `:REMINDER:' property. Example: ;; ;; ** TODO Book vet appointment :errand: ;; :PROPERTIES: ;; :ID: abcd-1234 ;; :REMINDER: 2d 3h 30m ;; :END: ;; <2026-08-20 Wed 9:00-9:30> ;; ;; fires ntfy 2 days, 3 hours and 30 minutes before 2026-08-20 09:00 (and ;; the default 15m before too). `:REMINDER: none' suppresses everything. ;;; Code: (require 'cl-lib) (require 'seq) (require 'org) (defvar org-reminder-ntfy-base "https://ntfy.unbl.ink" "Base URL of the ntfy server.") (defvar org-reminder-ntfy-topic "reminders" "ntfy topic to publish task reminders to.") (defvar org-reminder-state-file "~/.cache/org-reminders.el" "File persisting already-sent reminders.") (defvar org-reminder-grace-minutes 10 "A reminder may be fired up to this many minutes late and still send. Avoids a backlog flood after the Emacs daemon was down.") (defvar org-reminder-default-interval "15m" "Default reminder offset for timed entries lacking a :REMINDER: property. Set to \"none\" to disable the default entirely.") (defvar org-reminder-state (make-hash-table :test 'equal) "Hash of already-sent reminders: key (ID::LABEL) -> fire time.") (defvar org-reminder-file-cache (make-hash-table :test 'equal) "Maps file -> (MTIME . SPECS) so unchanged files are only parsed once.") (defvar org-reminder-last-refresh 0 "Timestamp of the last org-agenda-files refresh.") (defun org-reminder-parse-intervals (str) "Parse reminder offset string STR into a list of (LABEL . SECONDS). STR looks like \"2d 3h 30m\" (also accepts commas or semicolons). Unrecognized tokens are ignored." (let ((intervals nil)) (dolist (tok (split-string str "[,; ]+" t)) (when (string-match "\\`\\([0-9]+\\)\\([wdhm]\\)\\'" tok) (let* ((n (string-to-number (match-string 1 tok))) (unit (match-string 2 tok)) (secs (* n (pcase unit ("w" 604800) ("d" 86400) ("h" 3600) ("m" 60))))) (push (cons tok secs) intervals)))) (nreverse intervals))) (defun org-reminder-default-intervals () "Return the default reminder interval list." (if (and org-reminder-default-interval (not (string-match-p "\\`none\\'" (downcase (string-trim org-reminder-default-interval))))) (org-reminder-parse-intervals org-reminder-default-interval) nil)) (defun org-reminder-task-intervals (rem) "Return reminder intervals for a task given its :REMINDER: value REM. The default interval is appended unless REM suppresses it." (cond ((null rem) (org-reminder-default-intervals)) ((string-match-p "\\`none\\'" (downcase (string-trim rem))) nil) (t (append (org-reminder-parse-intervals rem) (org-reminder-default-intervals))))) (defun org-reminder-ts-to-seconds (ts) "Parse org timestamp string TS into epoch seconds (range start)." (when ts (float-time (encode-time (org-parse-time-string ts))))) (defconst org-reminder-ts-regexp "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>\n]*>" "Regex matching a single active org timestamp.") (defun org-reminder-first-inline-ts (limit) "Return epoch seconds of first active timestamp at/after point, before LIMIT." (save-excursion (let ((found nil)) (while (and (not found) (re-search-forward org-reminder-ts-regexp limit t)) (setq found (match-string 0))) (and found (org-reminder-ts-to-seconds found))))) (defun org-reminder-parse-file (file) "Parse FILE and return its list of reminder specs. Each spec is (ID HEADING FIRE-TIME LABEL SECONDS)." (let (specs) (with-temp-buffer (insert-file-contents file) (delay-mode-hooks (org-mode)) (save-excursion (goto-char (point-min)) (while (re-search-forward "^\\*+ " nil t) (let ((subtree-end (save-excursion (org-end-of-subtree t t)))) (if (and (org-get-todo-state) (not (org-entry-is-done-p))) (let* ((heading (string-trim (org-get-heading t t t t))) (id (or (org-entry-get nil "ID" t) (format "%s|%s" file heading))) (rem (org-entry-get nil "REMINDER" t)) (anchor (progn (org-end-of-meta-data t) (org-reminder-first-inline-ts subtree-end)))) (when anchor (dolist (interval (org-reminder-task-intervals rem)) (push (list id heading (- anchor (cdr interval)) (car interval) (cdr interval)) specs))) (goto-char subtree-end)) (goto-char (line-end-position)))))) (nreverse specs)))) (defun org-reminder-agenda-files () "Return the list of agenda files, refreshing it (throttled) from vulpea." (when (> (- (float-time) org-reminder-last-refresh) 600) (setq org-reminder-last-refresh (float-time)) (condition-case nil (when (fboundp 'vulpea-agenda-files-update) (vulpea-agenda-files-update)) (error nil))) (condition-case nil (org-agenda-files) (error nil))) (defun org-reminder-collect (&optional files) "Return reminder specs for FILES (default: `org-reminder-agenda-files'). Uses an mtime cache so unchanged files are not re-parsed." (let ((specs nil) (files (seq-uniq (or files (org-reminder-agenda-files))))) (dolist (file files) (when (file-exists-p file) (let* ((mtime (nth 5 (file-attributes file))) (cached (gethash file org-reminder-file-cache))) (if (and cached (equal (cdr cached) mtime)) (setq specs (append specs (cddr cached))) (let ((new (org-reminder-parse-file file))) (puthash file (cons mtime new) org-reminder-file-cache) (setq specs (append specs new))))))) specs)) (defun org-reminder-human-delta (secs) "Format SECS as a human friendly duration." (let* ((s (round secs)) (m (/ s 60)) (h (/ m 60)) (d (/ h 24))) (cond ((>= d 1) (format "%dd %dh" d (mod h 24))) ((>= h 1) (format "%dh %dm" h (mod m 60))) ((>= m 1) (format "%dm" m)) (t (format "%ds" s))))) (defun org-reminder-send (title body) "Send a ntfy notification with TITLE and BODY." (let* ((url (format "%s/%s" org-reminder-ntfy-base org-reminder-ntfy-topic)) (buf (get-buffer-create "*org-reminders-ntfy*")) (exit (call-process "curl" nil buf nil "-s" "-f" "-H" (format "Title: %s" (substring-no-properties title)) "-H" "Tags: bell" "-d" body url))) (unless (zerop exit) (message "org-reminders: ntfy send failed (%s): %s" exit (with-current-buffer buf (buffer-string)))) (kill-buffer buf))) (defun org-reminder-state-load () "Load the sent-reminder state from `org-reminder-state-file'." (setq org-reminder-state (make-hash-table :test 'equal)) (let ((file (expand-file-name org-reminder-state-file))) (when (file-exists-p file) (condition-case nil (let ((alist (read (with-temp-buffer (insert-file-contents file) (buffer-string))))) (dolist (pair alist) (puthash (car pair) (cdr pair) org-reminder-state))) (error (message "org-reminders: could not read state file %s" file)))))) (defun org-reminder-state-save () "Persist `org-reminder-state' to `org-reminder-state-file'." (let ((file (expand-file-name org-reminder-state-file)) (alist nil)) (maphash (lambda (k v) (push (cons k v) alist)) org-reminder-state) (make-directory (file-name-directory file) t) (with-temp-file file (prin1 alist (current-buffer))))) (defun org-reminder-prune-state (now) "Drop sent-reminder entries older than 30 days." (maphash (lambda (k v) (when (< v (- now (* 30 86400))) (remhash k org-reminder-state))) org-reminder-state)) ;;;###autoload (defun org-reminder-check () "Send any due org task reminders via ntfy." (interactive) (condition-case err (let ((now (float-time))) (dolist (spec (org-reminder-collect)) (pcase spec (`(,id ,heading ,fire ,label ,secs) (let ((key (format "%s::%s" id label))) (when (and (<= fire now) (< now (+ fire (* org-reminder-grace-minutes 60))) (not (gethash key org-reminder-state))) (org-reminder-send (format "Reminder: %s" heading) (format "Due %s — %s away" (format-time-string "%a %b %e %H:%M" (+ fire secs)) (org-reminder-human-delta (- (+ fire secs) now)))) (puthash key now org-reminder-state) (org-reminder-state-save) (message "org-reminders: sent '%s' (%s)" heading label))))) (org-reminder-prune-state now))) (error (message "org-reminders: check failed: %S" err)))) (defun org-reminder-start () "Load state and start the periodic reminder check." (org-reminder-state-load) (run-with-timer 60 60 #'org-reminder-check)) (provide '+org-reminders) (org-reminder-start) ;;; +org-reminders.el ends here