248 lines
9.7 KiB
EmacsLisp
248 lines
9.7 KiB
EmacsLisp
;;; +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 "org-tasks"
|
|
"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 applied to every timed entry without 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 the first active timestamp at or 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))))
|
|
(when (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)))))
|
|
(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 %e %b %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
|