diff --git a/emacs/.config/doom/+org-reminders.el b/emacs/.config/doom/+org-reminders.el new file mode 100644 index 0000000..c8fc96f --- /dev/null +++ b/emacs/.config/doom/+org-reminders.el @@ -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 diff --git a/emacs/.config/doom/config.el b/emacs/.config/doom/config.el index 58ba87e..c89a5c2 100644 --- a/emacs/.config/doom/config.el +++ b/emacs/.config/doom/config.el @@ -42,6 +42,7 @@ (setq org-directory "~/var/org/") (load! "+agenda-fix") +(load! "+org-reminders") (defun vulpea-agenda-files-update (&rest _) (setq org-agenda-files vulpea-project-files)) @@ -663,3 +664,7 @@ Always open the result in `eww`." message-sendmail-envelope-from 'header message-sendmail-extra-arguments '("--read-envelope-from") mail-envelope-from 'header) + +(use-package! repeat-todo + :after org + (repeat-todo-mode-enable)) diff --git a/emacs/.config/doom/init.el b/emacs/.config/doom/init.el index 3e6531c..23c99f5 100644 --- a/emacs/.config/doom/init.el +++ b/emacs/.config/doom/init.el @@ -30,7 +30,7 @@ :ui ;;deft ; notational velocity for Emacs doom ; what makes DOOM look the way it does - doom-dashboard ; a nifty splash screen for Emacs + dashboard ; a nifty splash screen for Emacs ;;doom-quit ; DOOM quit-message prompts when you quit Emacs ;;(emoji +unicode) ; 🙂 hl-todo ; highlight TODO/FIXME/NOTE/DEPRECATED/HACK/REVIEW diff --git a/emacs/.config/doom/packages.el b/emacs/.config/doom/packages.el index 2cbe085..8be1a13 100644 --- a/emacs/.config/doom/packages.el +++ b/emacs/.config/doom/packages.el @@ -23,11 +23,9 @@ (package! agent-shell) (package! auth-source-pass) -(package! org-todoist +(package! repeat-todo :recipe (:host github - :repo "lillenne/org-todoist" - :branch "main" - :files ("org-todoist.el"))) + :repo "cashpw/repeat-todo")) ;; To install a package directly from a remote git repo, you must specify a ;; `:recipe'. You'll find documentation on what `:recipe' accepts here: