[emacs] Add org reminders and repeats

This commit is contained in:
2026-08-09 13:01:41 -04:00
parent 9735e42cdd
commit b7b25e8d24
4 changed files with 255 additions and 5 deletions

View File

@ -0,0 +1,247 @@
;;; +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