summaryrefslogtreecommitdiff
path: root/contrib/lisp/org-notify.el
blob: 8bcca9f49416fd669eaa958eaf6b8b39233fc6a8 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
;;; org-notify.el --- Notifications for Org-mode

;; Copyright (C) 2012, 2013, 2014  Free Software Foundation, Inc.

;; Author: Peter M√ľnster <pmrb@free.fr>
;; Keywords: notification, todo-list, alarm, reminder, pop-up

;; This file is not part of GNU Emacs.

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; Get notifications, when there is something to do.
;; Sometimes, you need a reminder a few days before a deadline, e.g. to buy a
;; present for a birthday, and then another notification one hour before to
;; have enough time to choose the right clothes.
;; For other events, e.g. rolling the dustbin to the roadside once per week,
;; you probably need another kind of notification strategy.
;; This package tries to satisfy the various needs.

;; In order to activate this package, you must add the following code
;; into your .emacs:
;;
;;   (require 'org-notify)
;;   (org-notify-start)

;; Example setup:
;;
;; (org-notify-add 'appt
;;                 '(:time "-1s" :period "20s" :duration 10
;;                   :actions (-message -ding))
;;                 '(:time "15m" :period "2m" :duration 100
;;                   :actions -notify)
;;                 '(:time "2h" :period "5m" :actions -message)
;;                 '(:time "3d" :actions -email))
;;
;; This means for todo-items with `notify' property set to `appt': 3 days
;; before deadline, send a reminder-email, 2 hours before deadline, start to
;; send messages every 5 minutes, then 15 minutes before deadline, start to
;; pop up notification windows every 2 minutes.  The timeout of the window is
;; set to 100 seconds.  Finally, when deadline is overdue, send messages and
;; make noise."

;; Take also a look at the function `org-notify-add'.

;;; Code:

(eval-when-compile (require 'cl))
(require 'org-element)

(declare-function appt-delete-window    "appt"          ())
(declare-function notifications-notify  "notifications" (&rest prms))
(declare-function article-lapsed-string "gnus-art"      (t &optional ms))

(defgroup org-notify nil
  "Options for Org-mode notifications."
  :tag "Org Notify"
  :group 'org)

(defcustom org-notify-audible t
  "Non-nil means beep to indicate notification."
  :type 'boolean
  :group 'org-notify)

(defconst org-notify-actions
  '("show" "show" "done" "done" "hour" "one hour later" "day" "one day later"
    "week" "one week later")
  "Possible actions for call-back functions.")

(defconst org-notify-window-buffer-name "*org-notify-%s*"
  "Buffer-name for the `org-notify-action-window' function.")

(defvar org-notify-map nil
  "Mapping between names and parameter lists.")

(defvar org-notify-timer nil
  "Timer of the notification daemon.")

(defvar org-notify-parse-file nil
  "Index of current file, that `org-element-parse-buffer' is parsing.")

(defvar org-notify-on-action-map nil
  "Mapping between on-action identifiers and parameter lists.")

(defun org-notify-string->seconds (str)
  "Convert time string STR to number of seconds."
  (when str
    (let* ((conv `(("s" . 1) ("m" . 60) ("h" . ,(* 60 60))
                   ("d" . ,(* 24 60 60)) ("w" . ,(* 7 24 60 60))
                   ("M" . ,(* 30 24 60 60))))
           (letters (concat
                     (mapcar (lambda (x) (string-to-char (car x))) conv)))
           (case-fold-search nil))
      (string-match (concat "\\(-?\\)\\([0-9]+\\)\\([" letters "]\\)") str)
      (* (string-to-number (match-string 2 str))
         (cdr (assoc (match-string 3 str) conv))
         (if (= (length (match-string 1 str)) 1) -1 1)))))

(defun org-notify-convert-deadline (orig)
  "Convert original deadline from `org-element-parse-buffer' to
simple timestamp string."
  (if orig
      (replace-regexp-in-string "^<\\|>$" ""
				(plist-get (plist-get orig 'timestamp)
					   :raw-value))))

(defun org-notify-make-todo (heading &rest ignored)
  "Create one todo item."
  (macrolet ((get (k) `(plist-get list ,k))
             (pr (k v) `(setq result (plist-put result ,k ,v))))
    (let* ((list (nth 1 heading))      (notify (or (get :NOTIFY) "default"))
           (deadline (org-notify-convert-deadline (get :deadline)))
	   (heading (get :raw-value))
           result)
      (when (and (eq (get :todo-type) 'todo) heading deadline)
        (pr :heading heading)     (pr :notify (intern notify))
        (pr :begin (get :begin))
        (pr :file (nth org-notify-parse-file (org-agenda-files 'unrestricted)))
        (pr :timestamp deadline)  (pr :uid (md5 (concat heading deadline)))
        (pr :deadline (- (org-time-string-to-seconds deadline)
                         (org-float-time))))
      result)))

(defun org-notify-todo-list ()
  "Create the todo-list for one org-agenda file."
  (let* ((files (org-agenda-files 'unrestricted))
         (max (1- (length files))))
    (setq org-notify-parse-file
          (if (or (not org-notify-parse-file) (>= org-notify-parse-file max))
              0
            (1+ org-notify-parse-file)))
    (save-excursion
      (with-current-buffer (find-file-noselect
                            (nth org-notify-parse-file files))
        (org-element-map (org-element-parse-buffer 'headline)
                         'headline 'org-notify-make-todo)))))

(defun org-notify-maybe-too-late (diff period heading)
  "Print warning message, when notified significantly later than defined by
PERIOD."
  (if (> (/ diff period) 1.5)
      (message "Warning: notification for \"%s\" behind schedule!" heading))
  t)

(defun org-notify-process ()
  "Process the todo-list, and possibly notify user about upcoming or
forgotten tasks."
  (macrolet ((prm (k) `(plist-get prms ,k))  (td (k) `(plist-get todo ,k)))
    (dolist (todo (org-notify-todo-list))
      (let* ((deadline (td :deadline))  (heading (td :heading))
             (uid (td :uid))            (last-run-sym
                                         (intern (concat ":last-run-" uid))))
        (dolist (prms (plist-get org-notify-map (td :notify)))
          (when (< deadline (org-notify-string->seconds (prm :time)))
            (let ((period (org-notify-string->seconds (prm :period)))
                  (last-run (prm last-run-sym))  (now (org-float-time))
                  (actions (prm :actions))       diff  plist)
              (when (or (not last-run)
                        (and period (< period (setq diff (- now last-run)))
                             (org-notify-maybe-too-late diff period heading)))
                (setq prms (plist-put prms last-run-sym now)
                      plist (append todo prms))
                (if (if (plist-member prms :audible)
                        (prm :audible)
                      org-notify-audible)
                    (ding))
                (unless (listp actions)
                  (setq actions (list actions)))
                (dolist (action actions)
                  (funcall (if (fboundp action) action
                             (intern (concat "org-notify-action"
                                             (symbol-name action))))
                           plist))))
            (return)))))))

(defun org-notify-add (name &rest params)
  "Add a new notification type.
The NAME can be used in Org-mode property `notify'.  If NAME is
`default', the notification type applies for todo items without
the `notify' property.  This file predefines such a default
notification type.

Each element of PARAMS is a list with parameters for a given time
distance to the deadline.  This distance must increase from one
element to the next.

List of possible parameters:

  :time      Time distance to deadline, when this type of notification shall
             start.  It's a string: an integral value (positive or negative)
             followed by a unit (s, m, h, d, w, M).
  :actions   A function or a list of functions to be called to notify the
             user.  Instead of a function name, you can also supply a suffix
             of one of the various predefined `org-notify-action-xxx'
             functions.
  :period    Optional: can be used to repeat the actions periodically.
             Same format as :time.
  :duration  Some actions use this parameter to specify the duration of the
             notification.  It's an integral number in seconds.
  :audible   Overwrite the value of `org-notify-audible' for this action.

For the actions, you can use your own functions or some of the predefined
ones, whose names are prefixed with `org-notify-action-'."
  (setq org-notify-map (plist-put org-notify-map name params)))

(defun org-notify-start (&optional secs)
  "Start the notification daemon.
If SECS is positive, it's the period in seconds for processing
the notifications of one org-agenda file, and if negative,
notifications will be checked only when emacs is idle for -SECS
seconds.  The default value for SECS is 20."
  (interactive)
  (if org-notify-timer
      (org-notify-stop))
  (setq secs (or secs 20)
        org-notify-timer (if (< secs 0)
                             (run-with-idle-timer (* -1 secs) t
                                                  'org-notify-process)
                           (run-with-timer secs secs 'org-notify-process))))

(defun org-notify-stop ()
  "Stop the notification daemon."
  (when org-notify-timer
    (cancel-timer org-notify-timer)
    (setq org-notify-timer nil)))

(defun org-notify-on-action (plist key)
  "User wants to see action."
  (let ((file (plist-get plist :file))
        (begin (plist-get plist :begin)))
    (if (string-equal key "show")
        (progn
          (switch-to-buffer (find-file-noselect file))
          (org-with-wide-buffer
           (goto-char begin)
           (show-entry))
          (goto-char begin)
          (search-forward "DEADLINE: <")
          (if (display-graphic-p)
              (x-focus-frame nil)))
      (save-excursion
        (with-current-buffer (find-file-noselect file)
          (org-with-wide-buffer
           (goto-char begin)
           (search-forward "DEADLINE: <")
           (cond
            ((string-equal key "done")  (org-todo))
            ((string-equal key "hour")  (org-timestamp-change 60 'minute))
            ((string-equal key "day")   (org-timestamp-up-day))
            ((string-equal key "week")  (org-timestamp-change 7 'day)))))))))

(defun org-notify-on-action-notify (id key)
  "User wants to see action after mouse-click in notify window."
  (org-notify-on-action (plist-get org-notify-on-action-map id) key)
  (org-notify-on-close id nil))

(defun org-notify-on-action-button (button)
  "User wants to see action after button activation."
  (macrolet ((get (k) `(button-get button ,k)))
    (org-notify-on-action (get 'plist) (get 'key))
    (org-notify-delete-window (get 'buffer))
    (cancel-timer (get 'timer))))

(defun org-notify-delete-window (buffer)
  "Delete the notification window."
  (require 'appt)
  (let ((appt-buffer-name buffer)
        (appt-audible nil))
    (appt-delete-window)))

(defun org-notify-on-close (id reason)
  "Notification window has been closed."
  (setq org-notify-on-action-map (plist-put org-notify-on-action-map id nil)))

(defun org-notify-action-message (plist)
  "Print a message."
  (message "TODO: \"%s\" at %s!" (plist-get plist :heading)
           (plist-get plist :timestamp)))

(defun org-notify-action-ding (plist)
  "Make noise."
  (let ((timer (run-with-timer 0 1 'ding)))
    (run-with-timer (or (plist-get plist :duration) 3) nil
                    'cancel-timer timer)))

(defun org-notify-body-text (plist)
  "Make human readable string for remaining time to deadline."
  (require 'gnus-art)
  (format "%s\n(%s)"
          (replace-regexp-in-string
           " in the future" ""
           (article-lapsed-string
            (time-add (current-time)
                      (seconds-to-time (plist-get plist :deadline))) 2))
          (plist-get plist :timestamp)))

(defun org-notify-action-email (plist)
  "Send email to user."
  (compose-mail user-mail-address (concat "TODO: " (plist-get plist :heading)))
  (insert (org-notify-body-text plist))
  (funcall send-mail-function)
  (flet ((yes-or-no-p (prompt) t))
    (kill-buffer)))

(defun org-notify-select-highest-window ()
  "Select the highest window on the frame, that is not is not an
org-notify window.  Mostly copied from `appt-select-lowest-window'."
  (let ((highest-window (selected-window))
        (bottom-edge (nth 3 (window-edges)))
        next-bottom-edge)
    (walk-windows (lambda (w)
                    (when (and
                           (not (string-match "^\\*org-notify-.*\\*$"
                                              (buffer-name
                                               (window-buffer w))))
                           (> bottom-edge (setq next-bottom-edge
                                                (nth 3 (window-edges w)))))
                      (setq bottom-edge next-bottom-edge
                            highest-window w))) 'nomini)
    (select-window highest-window)))

(defun org-notify-action-window (plist)
  "Pop up a window, mostly copied from `appt-disp-window'."
  (save-excursion
    (macrolet ((get (k) `(plist-get plist ,k)))
      (let ((this-window (selected-window))
            (buf (get-buffer-create
                  (format org-notify-window-buffer-name (get :uid)))))
        (when (minibufferp)
          (other-window 1)
          (and (minibufferp) (display-multi-frame-p) (other-frame 1)))
        (if (cdr (assq 'unsplittable (frame-parameters)))
            (progn (set-buffer buf) (display-buffer buf))
          (unless (or (special-display-p (buffer-name buf))
                      (same-window-p (buffer-name buf)))
            (org-notify-select-highest-window)
            (when (>= (window-height) (* 2 window-min-height))
              (select-window (split-window nil nil 'above))))
          (switch-to-buffer buf))
        (setq buffer-read-only nil  buffer-undo-list t)
        (erase-buffer)
        (insert (format "TODO: %s, %s.\n" (get :heading)
                        (org-notify-body-text plist)))
        (let ((timer (run-with-timer (or (get :duration) 10) nil
                                     'org-notify-delete-window buf)))
          (dotimes (i (/ (length org-notify-actions) 2))
            (let ((key (nth (* i 2) org-notify-actions))
                  (text (nth (1+ (* i 2)) org-notify-actions)))
              (insert-button text 'action 'org-notify-on-action-button
                             'key key 'buffer buf 'plist plist 'timer timer)
              (insert "    "))))
        (shrink-window-if-larger-than-buffer (get-buffer-window buf t))
        (set-buffer-modified-p nil)       (setq buffer-read-only t)
        (raise-frame (selected-frame))    (select-window this-window)))))

(defun org-notify-action-notify (plist)
  "Pop up a notification window."
  (require 'notifications)
  (let* ((duration (plist-get plist :duration))
         (id (notifications-notify
              :title     (plist-get plist :heading)
              :body      (org-notify-body-text plist)
              :timeout   (if duration (* duration 1000))
              :actions   org-notify-actions
              :on-action 'org-notify-on-action-notify)))
    (setq org-notify-on-action-map
          (plist-put org-notify-on-action-map id plist))))

(defun org-notify-action-notify/window (plist)
  "For a graphics display, pop up a notification window, for a text
terminal an emacs window."
  (if (display-graphic-p)
      (org-notify-action-notify plist)
    (org-notify-action-window plist)))

;;; Provide a minimal default setup.
(org-notify-add 'default '(:time "1h" :actions -notify/window
				 :period "2m" :duration 60))

(provide 'org-notify)

;;; org-notify.el ends here