org-notify.el 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. ;;; org-notify.el --- Notifications for Org-mode
  2. ;; Copyright (C) 2012-2021 Free Software Foundation, Inc.
  3. ;; Author: Peter Münster <pmrb@free.fr>
  4. ;; Keywords: notification, todo-list, alarm, reminder, pop-up
  5. ;; This file is not part of GNU Emacs.
  6. ;; This program is free software; you can redistribute it and/or modify
  7. ;; it under the terms of the GNU General Public License as published by
  8. ;; the Free Software Foundation, either version 3 of the License, or
  9. ;; (at your option) any later version.
  10. ;; This program is distributed in the hope that it will be useful,
  11. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. ;; GNU General Public License for more details.
  14. ;; You should have received a copy of the GNU General Public License
  15. ;; along with this program. If not, see <https://www.gnu.org/licenses/>.
  16. ;;; Commentary:
  17. ;; Get notifications, when there is something to do.
  18. ;; Sometimes, you need a reminder a few days before a deadline, e.g. to buy a
  19. ;; present for a birthday, and then another notification one hour before to
  20. ;; have enough time to choose the right clothes.
  21. ;; For other events, e.g. rolling the dustbin to the roadside once per week,
  22. ;; you probably need another kind of notification strategy.
  23. ;; This package tries to satisfy the various needs.
  24. ;; In order to activate this package, you must add the following code
  25. ;; into your .emacs:
  26. ;;
  27. ;; (require 'org-notify)
  28. ;; (org-notify-start)
  29. ;; Example setup:
  30. ;;
  31. ;; (org-notify-add 'appt
  32. ;; '(:time "-1s" :period "20s" :duration 10
  33. ;; :actions (-message -ding))
  34. ;; '(:time "15m" :period "2m" :duration 100
  35. ;; :actions -notify)
  36. ;; '(:time "2h" :period "5m" :actions -message)
  37. ;; '(:time "3d" :actions -email))
  38. ;;
  39. ;; This means for todo-items with `notify' property set to `appt': 3 days
  40. ;; before deadline, send a reminder-email, 2 hours before deadline, start to
  41. ;; send messages every 5 minutes, then 15 minutes before deadline, start to
  42. ;; pop up notification windows every 2 minutes. The timeout of the window is
  43. ;; set to 100 seconds. Finally, when deadline is overdue, send messages and
  44. ;; make noise."
  45. ;; Take also a look at the function `org-notify-add'.
  46. ;;; Code:
  47. (eval-when-compile (require 'cl-lib))
  48. (require 'org-element)
  49. (declare-function appt-delete-window "appt" ())
  50. (declare-function notifications-notify "notifications" (&rest prms))
  51. (declare-function article-lapsed-string "gnus-art" (t &optional ms))
  52. (defgroup org-notify nil
  53. "Options for Org-mode notifications."
  54. :tag "Org Notify"
  55. :group 'org)
  56. (defcustom org-notify-audible t
  57. "Non-nil means beep to indicate notification."
  58. :type 'boolean
  59. :group 'org-notify)
  60. (defcustom org-notify-max-notifications-per-run 3
  61. "Maximum number of notifications per run of `org-notify-process'."
  62. :type 'integer
  63. :group 'org-notify)
  64. (defconst org-notify-actions
  65. '("show" "show" "done" "done" "hour" "one hour later" "day" "one day later"
  66. "week" "one week later")
  67. "Possible actions for call-back functions.")
  68. (defconst org-notify-window-buffer-name "*org-notify-%s*"
  69. "Buffer-name for the `org-notify-action-window' function.")
  70. (defvar org-notify-map nil
  71. "Mapping between names and parameter lists.")
  72. (defvar org-notify-timer nil
  73. "Timer of the notification daemon.")
  74. (defvar org-notify-parse-file nil
  75. "Index of current file, that `org-element-parse-buffer' is parsing.")
  76. (defvar org-notify-on-action-map nil
  77. "Mapping between on-action identifiers and parameter lists.")
  78. (defun org-notify-string->seconds (str)
  79. "Convert time string STR to number of seconds."
  80. (when str
  81. (let* ((conv `(("s" . 1) ("m" . 60) ("h" . ,(* 60 60))
  82. ("d" . ,(* 24 60 60)) ("w" . ,(* 7 24 60 60))
  83. ("M" . ,(* 30 24 60 60))))
  84. (letters (concat
  85. (mapcar (lambda (x) (string-to-char (car x))) conv)))
  86. (case-fold-search nil))
  87. (string-match (concat "\\(-?\\)\\([0-9]+\\)\\([" letters "]\\)") str)
  88. (* (string-to-number (match-string 2 str))
  89. (cdr (assoc (match-string 3 str) conv))
  90. (if (= (length (match-string 1 str)) 1) -1 1)))))
  91. (defun org-notify-convert-deadline (orig)
  92. "Convert original deadline from `org-element-parse-buffer' to
  93. simple timestamp string."
  94. (if orig
  95. (replace-regexp-in-string "^<\\|>$" ""
  96. (plist-get (plist-get orig 'timestamp)
  97. :raw-value))))
  98. (defun org-notify-make-todo (heading &rest ignored)
  99. "Create one todo item."
  100. (cl-macrolet ((get (k) `(plist-get list ,k))
  101. (pr (k v) `(setq result (plist-put result ,k ,v))))
  102. (let* ((list (nth 1 heading)) (notify (or (get :NOTIFY) "default"))
  103. (deadline (org-notify-convert-deadline (get :deadline)))
  104. (heading (get :raw-value))
  105. result)
  106. (when (and (eq (get :todo-type) 'todo) heading deadline)
  107. (pr :heading heading) (pr :notify (intern notify))
  108. (pr :begin (get :begin))
  109. (pr :file (nth org-notify-parse-file (org-agenda-files 'unrestricted)))
  110. (pr :timestamp deadline) (pr :uid (md5 (concat heading deadline)))
  111. (pr :deadline (- (org-time-string-to-seconds deadline)
  112. (float-time))))
  113. result)))
  114. (defun org-notify-todo-list ()
  115. "Create the todo-list for one org-agenda file."
  116. (let* ((files (org-agenda-files 'unrestricted))
  117. (max (1- (length files))))
  118. (when files
  119. (setq org-notify-parse-file
  120. (if (or (not org-notify-parse-file) (>= org-notify-parse-file max))
  121. 0
  122. (1+ org-notify-parse-file)))
  123. (save-excursion
  124. (with-current-buffer (find-file-noselect
  125. (nth org-notify-parse-file files))
  126. (org-element-map (org-element-parse-buffer 'headline)
  127. 'headline 'org-notify-make-todo))))))
  128. (defun org-notify-maybe-too-late (diff period heading)
  129. "Print warning message, when notified significantly later than defined by
  130. PERIOD."
  131. (if (> (/ diff period) 1.5)
  132. (message "Warning: notification for \"%s\" behind schedule!" heading))
  133. t)
  134. (cl-defun org-notify-process ()
  135. "Process the todo-list, and possibly notify user about upcoming or
  136. forgotten tasks."
  137. (let ((notification-cnt 0))
  138. (cl-macrolet ((prm (k) `(plist-get prms ,k)) (td (k) `(plist-get todo ,k)))
  139. (dolist (todo (org-notify-todo-list))
  140. (let* ((deadline (td :deadline)) (heading (td :heading))
  141. (uid (td :uid)) (last-run-sym
  142. (intern (concat ":last-run-" uid))))
  143. (cl-dolist (prms (plist-get org-notify-map (td :notify)))
  144. (when (< deadline (org-notify-string->seconds (prm :time)))
  145. (let ((period (org-notify-string->seconds (prm :period)))
  146. (last-run (prm last-run-sym)) (now (float-time))
  147. (actions (prm :actions)) diff plist)
  148. (when (or (not last-run)
  149. (and period (< period (setq diff (- now last-run)))
  150. (org-notify-maybe-too-late diff period heading)))
  151. (setq prms (plist-put prms last-run-sym now)
  152. plist (append todo prms))
  153. (if (if (plist-member prms :audible)
  154. (prm :audible)
  155. org-notify-audible)
  156. (ding))
  157. (unless (listp actions)
  158. (setq actions (list actions)))
  159. (cl-incf notification-cnt)
  160. (dolist (action actions)
  161. (funcall (if (fboundp action) action
  162. (intern (concat "org-notify-action"
  163. (symbol-name action))))
  164. plist))
  165. (when (>= notification-cnt org-notify-max-notifications-per-run)
  166. (cl-return-from org-notify-process)))
  167. (cl-return)))))))))
  168. (defun org-notify-add (name &rest params)
  169. "Add a new notification type.
  170. The NAME can be used in Org-mode property `notify'. If NAME is
  171. `default', the notification type applies for todo items without
  172. the `notify' property. This file predefines such a default
  173. notification type.
  174. Each element of PARAMS is a list with parameters for a given time
  175. distance to the deadline. This distance must increase from one
  176. element to the next.
  177. List of possible parameters:
  178. :time Time distance to deadline, when this type of notification shall
  179. start. It's a string: an integral value (positive or negative)
  180. followed by a unit (s, m, h, d, w, M).
  181. :actions A function or a list of functions to be called to notify the
  182. user. Instead of a function name, you can also supply a suffix
  183. of one of the various predefined `org-notify-action-xxx'
  184. functions.
  185. :period Optional: can be used to repeat the actions periodically.
  186. Same format as :time.
  187. :duration Some actions use this parameter to specify the duration of the
  188. notification. It's an integral number in seconds.
  189. :audible Overwrite the value of `org-notify-audible' for this action.
  190. For the actions, you can use your own functions or some of the predefined
  191. ones, whose names are prefixed with `org-notify-action-'."
  192. (setq org-notify-map (plist-put org-notify-map name params)))
  193. (defun org-notify-start (&optional secs)
  194. "Start the notification daemon.
  195. If SECS is positive, it's the period in seconds for processing
  196. the notifications of one org-agenda file, and if negative,
  197. notifications will be checked only when emacs is idle for -SECS
  198. seconds. The default value for SECS is 20."
  199. (interactive)
  200. (if org-notify-timer
  201. (org-notify-stop))
  202. (setq secs (or secs 20)
  203. org-notify-timer (if (< secs 0)
  204. (run-with-idle-timer (* -1 secs) t
  205. 'org-notify-process)
  206. (run-with-timer secs secs 'org-notify-process))))
  207. (defun org-notify-stop ()
  208. "Stop the notification daemon."
  209. (when org-notify-timer
  210. (cancel-timer org-notify-timer)
  211. (setq org-notify-timer nil)))
  212. (defun org-notify-on-action (plist key)
  213. "User wants to see action."
  214. (let ((file (plist-get plist :file))
  215. (begin (plist-get plist :begin)))
  216. (if (string-equal key "show")
  217. (progn
  218. (switch-to-buffer (find-file-noselect file))
  219. (org-with-wide-buffer
  220. (goto-char begin)
  221. (outline-show-entry))
  222. (goto-char begin)
  223. (search-forward "DEADLINE: <")
  224. (search-forward ":")
  225. (if (display-graphic-p)
  226. (x-focus-frame nil)))
  227. (save-excursion
  228. (with-current-buffer (find-file-noselect file)
  229. (org-with-wide-buffer
  230. (goto-char begin)
  231. (search-forward "DEADLINE: <")
  232. (cond
  233. ((string-equal key "done") (org-todo))
  234. ((string-equal key "hour") (org-timestamp-change 60 'minute))
  235. ((string-equal key "day") (org-timestamp-up-day))
  236. ((string-equal key "week") (org-timestamp-change 7 'day)))))))))
  237. (defun org-notify-on-action-notify (id key)
  238. "User wants to see action after mouse-click in notify window."
  239. (org-notify-on-action (plist-get org-notify-on-action-map id) key)
  240. (org-notify-on-close id nil))
  241. (defun org-notify-on-action-button (button)
  242. "User wants to see action after button activation."
  243. (cl-macrolet ((get (k) `(button-get button ,k)))
  244. (org-notify-on-action (get 'plist) (get 'key))
  245. (org-notify-delete-window (get 'buffer))
  246. (cancel-timer (get 'timer))))
  247. (defun org-notify-delete-window (buffer)
  248. "Delete the notification window."
  249. (require 'appt)
  250. (let ((appt-buffer-name buffer)
  251. (appt-audible nil))
  252. (appt-delete-window)))
  253. (defun org-notify-on-close (id reason)
  254. "Notification window has been closed."
  255. (setq org-notify-on-action-map (plist-put org-notify-on-action-map id nil)))
  256. (defun org-notify-action-message (plist)
  257. "Print a message."
  258. (message "TODO: \"%s\" at %s!" (plist-get plist :heading)
  259. (plist-get plist :timestamp)))
  260. (defun org-notify-action-ding (plist)
  261. "Make noise."
  262. (let ((timer (run-with-timer 0 1 'ding)))
  263. (run-with-timer (or (plist-get plist :duration) 3) nil
  264. 'cancel-timer timer)))
  265. (defun org-notify-body-text (plist)
  266. "Make human readable string for remaining time to deadline."
  267. (require 'gnus-art)
  268. (format "%s\n(%s)"
  269. (replace-regexp-in-string
  270. " in the future" ""
  271. (article-lapsed-string
  272. (time-add (current-time)
  273. (seconds-to-time (plist-get plist :deadline))) 2))
  274. (plist-get plist :timestamp)))
  275. (defun org-notify-action-email (plist)
  276. "Send email to user."
  277. (compose-mail user-mail-address (concat "TODO: " (plist-get plist :heading)))
  278. (insert (org-notify-body-text plist))
  279. (funcall send-mail-function)
  280. (cl-letf (((symbol-function 'yes-or-no-p) (lambda (x) t)))
  281. (kill-buffer)))
  282. (defun org-notify-select-highest-window ()
  283. "Select the highest window on the frame, that is not is not an
  284. org-notify window. Mostly copied from `appt-select-lowest-window'."
  285. (let ((highest-window (selected-window))
  286. (bottom-edge (nth 3 (window-edges)))
  287. next-bottom-edge)
  288. (walk-windows (lambda (w)
  289. (when (and
  290. (not (string-match "^\\*org-notify-.*\\*$"
  291. (buffer-name
  292. (window-buffer w))))
  293. (> bottom-edge (setq next-bottom-edge
  294. (nth 3 (window-edges w)))))
  295. (setq bottom-edge next-bottom-edge
  296. highest-window w))) 'nomini)
  297. (select-window highest-window)))
  298. (defun org-notify-action-window (plist)
  299. "Pop up a window, mostly copied from `appt-disp-window'."
  300. (save-excursion
  301. (cl-macrolet ((get (k) `(plist-get plist ,k)))
  302. (let ((this-window (selected-window))
  303. (buf (get-buffer-create
  304. (format org-notify-window-buffer-name (get :uid)))))
  305. (when (minibufferp)
  306. (other-window 1)
  307. (and (minibufferp) (display-multi-frame-p) (other-frame 1)))
  308. (if (cdr (assq 'unsplittable (frame-parameters)))
  309. (progn (set-buffer buf) (display-buffer buf))
  310. (unless (or (special-display-p (buffer-name buf))
  311. (same-window-p (buffer-name buf)))
  312. (org-notify-select-highest-window)
  313. (when (>= (window-height) (* 2 window-min-height))
  314. (select-window (split-window nil nil 'above))))
  315. (switch-to-buffer buf))
  316. (setq buffer-read-only nil buffer-undo-list t)
  317. (erase-buffer)
  318. (insert (format "TODO: %s, %s.\n" (get :heading)
  319. (org-notify-body-text plist)))
  320. (let ((timer (run-with-timer (or (get :duration) 10) nil
  321. 'org-notify-delete-window buf)))
  322. (dotimes (i (/ (length org-notify-actions) 2))
  323. (let ((key (nth (* i 2) org-notify-actions))
  324. (text (nth (1+ (* i 2)) org-notify-actions)))
  325. (insert-button text 'action 'org-notify-on-action-button
  326. 'key key 'buffer buf 'plist plist 'timer timer)
  327. (insert " "))))
  328. (shrink-window-if-larger-than-buffer (get-buffer-window buf t))
  329. (set-buffer-modified-p nil) (setq buffer-read-only t)
  330. (raise-frame (selected-frame)) (select-window this-window)))))
  331. (defun org-notify-action-notify (plist)
  332. "Pop up a notification window."
  333. (require 'notifications)
  334. (let* ((duration (plist-get plist :duration))
  335. (id (notifications-notify
  336. :title (plist-get plist :heading)
  337. :body (org-notify-body-text plist)
  338. :timeout (if duration (* duration 1000))
  339. :urgency (plist-get plist :urgency)
  340. :actions org-notify-actions
  341. :on-action 'org-notify-on-action-notify)))
  342. (setq org-notify-on-action-map
  343. (plist-put org-notify-on-action-map id plist))))
  344. (defun org-notify-action-notify/window (plist)
  345. "For a graphics display, pop up a notification window, for a text
  346. terminal an emacs window."
  347. (if (display-graphic-p)
  348. (org-notify-action-notify plist)
  349. (org-notify-action-window plist)))
  350. ;;; Provide a minimal default setup.
  351. (org-notify-add 'default '(:time "1h" :actions -notify/window
  352. :period "2m" :duration 60))
  353. (provide 'org-notify)
  354. ;;; org-notify.el ends here