;;; excorporate-diary.el --- Diary integration -*- lexical-binding: t -*- ;; Copyright (C) 2018-2021 Free Software Foundation, Inc. ;; Author: Thomas Fitzsimmons ;; Keywords: calendar ;; 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 . ;;; Commentary: ;; Wrap interactive `diary-lib' functions so that they query the ;; Exchange server asynchronously, then display retrieved results ;; interleaved with local diary entries. ;;; Code: (require 'diary-lib) (require 'calendar) (require 'icalendar) (require 'appt) (require 'excorporate) (require 'nadvice) ;; For Emacs versions less than 27.1, which do not have the fix for ;; Bug#35645, work around the issue where `icalendar-import-buffer' ;; pops up the diary file buffer. (defun exco-diary-diary-make-entry (string &optional nonmarking file) "Insert a diary entry STRING which may be NONMARKING in FILE. If omitted, NONMARKING defaults to nil and FILE defaults to `diary-file'." (with-current-buffer (find-file-noselect (or file diary-file)) (when (eq major-mode (default-value 'major-mode)) (diary-mode)) (widen) (diary-unhide-everything) (goto-char (point-max)) (when (let ((case-fold-search t)) (search-backward "Local Variables:" (max (- (point-max) 3000) (point-min)) t)) (beginning-of-line) (insert "\n") (forward-line -1)) (insert (if (bolp) "" "\n") (if nonmarking diary-nonmarking-symbol "") string))) (defun exco-diary-icalendar--add-diary-entry-around (original &rest arguments) "Prevent whitespace workaround from selecting diary buffer. Also prevent `diary-make-entry' from putting the diary file where (other-buffer (current-buffer)) will return it. ORIGINAL and ARGUMENTS are the original function and arguments respectively." (cl-letf (((symbol-function #'find-file) (symbol-function #'find-file-noselect)) ;; This override suppresses diary-make-entry's window ;; and buffer manipulations. ((symbol-function #'diary-make-entry) (symbol-function #'exco-diary-diary-make-entry))) (apply original arguments))) (unless (string-match "omit-trailing-space" (documentation 'diary-make-entry)) (advice-add #'icalendar--add-diary-entry :around #'exco-diary-icalendar--add-diary-entry-around)) (defvar excorporate-diary-today-file (locate-user-emacs-file "excorporate/diary-excorporate-today") "The diary file where Excorporate should save today's meetings. This file will be #include'd in `diary-file' by `excorporate-diary-enable'.") (defvar excorporate-diary-transient-file (locate-user-emacs-file "excorporate/diary-excorporate-transient") "The diary file where Excorporate should save retrieved meetings. This file will be #include'd in `diary-file' by `excorporate-diary-enable'.") (defun exco-diary-initialize (today) "Initialize diary files used by Excorporate. Run before retrieving diary entries from servers. TODAY is t to initialize for today's date, nil otherwise." ;; Keep today's entries if running on a day other than today. If ;; retrieving results for today, delete results from days other than ;; today, in case the transient file (having been filled in on a ;; prior day) contains duplicate or stale results for today. (let ((files (if today (list excorporate-diary-today-file excorporate-diary-transient-file) (list excorporate-diary-transient-file)))) (dolist (file files) (let ((directory (file-name-directory file))) (unless (file-exists-p directory) (make-directory directory)) (with-current-buffer (find-file-noselect file) (delete-region (point-min) (point-max)) ;; Do not call `save-buffer' to avoid any hooks from being ;; run. Otherwise `appt-update-list' in ;; `write-file-functions' can cause an infinite ;; connnection-callback loop. (basic-save-buffer-1)))))) ;; Literal percent signs (%) are not supported in a diary entry since ;; they're interpreted as format strings by `diary-sexp-entry', so ;; encode them during entry insertion, then unescape them during ;; display. This is needed so that, e.g., encoded meeting URLs that ;; contain literal percent signs (%) work with `browse-url'. (defun exco-diary--fix-percent-signs () "Replace percent-sign placeholders with percent signs." (goto-char (point-min)) (let ((inhibit-read-only t)) (while (re-search-forward "" nil t) (replace-match "%")))) (defun exco-diary-appt-disp-window (min-to-app new-time appt-msg) "Replace Excorporate diary percent signs. For MIN-TO-APP, NEW-TIME and APPT-MSG documentation, see `appt-disp-window'." (appt-disp-window min-to-app new-time appt-msg) (with-current-buffer (get-buffer-create appt-buffer-name) (let ((inhibit-read-only t)) (exco-diary--fix-percent-signs)))) (defun exco-diary-insert-meeting (finalize subject start _end _location _main-invitees _optional-invitees icalendar-text) "Insert a retrieved meeting into the diary. See also the documentation for `exco-calendar-item-iterate'. The arguments are SUBJECT, a string, the subject of the meeting, START, the start date and time in Emacs internal representation, and ICALENDAR-TEXT, iCalendar text representing the meeting. _END, _LOCATION, _MAIN-INVITEES, and _OPTIONAL-INVITEES are unused. Call FINALIZE after the meeting has been inserted." (when (not (string-match "^Cancel[l]?ed: " subject)) ;; FIXME: Sometimes meetings are duplicated if they have ;; overlapping (and (diary-cyclic ...) (diary-block ...)) ranges, ;; e.g., one in the today file and one in the transient file. ;; Maybe we should de-duplicate them in the final display. If the ;; meeting start time is sometime today then put it in today's ;; diary file, otherwise put it in the transient one. (let* ((time (decode-time (current-time))) (now (list (elt time 3) (elt time 4) (elt time 5))) (dawn (apply #'encode-time 0 0 0 now)) (dusk (time-add dawn (seconds-to-time 86400))) (file (if (and (time-less-p dawn start) (time-less-p start dusk)) excorporate-diary-today-file excorporate-diary-transient-file))) (with-temp-buffer (insert icalendar-text) ;; FIXME: Maybe some users of multiple calendars will want to ;; know the source calendar's name for each diary entry. ;; There is no great way to achieve that right now, but one ;; idea is to add X-WR-CALNAME support to ;; icalendar-import-buffer, replace the ;; exco-diary-insert-meeting argument to ;; exco-calendar-item-with-details-iterate with: ;; ;; (lambda (&rest arguments) ;; (apply #'exco-diary-insert-meeting identifier arguments)) ;; ;; and uncomment the following code. ;; ;; (goto-char (point-min)) ;; (while (re-search-forward ;; "^SUMMARY\\([^:]*\\):\\(.*\\(\n[ ].*\\)*\\)" nil t) ;; (insert (format "\nX-WR-CALNAME: (%s)" identifier))) ;; Escape literal percent signs (%). Use less-than sign (<) ;; and greater-than sign (>) which are forbidden URL ;; characters, so that in the plain text diary file, ;; percent-encoded URLs become completely invalid rather than ;; slightly wrong. (goto-char (point-min)) (while (re-search-forward "%" nil t) (replace-match "")) (icalendar-import-buffer file t)))) (funcall finalize)) ;; Bound in appt-check. (defvar appt-display-diary) (defun exco-diary-diary-advice (today date advisee &rest arguments) "Advise `diary' and `diary-view-entries' to add Excorporate support. TODAY is today's date in `calendar-current-date' format. DATE is the desired date to retrieve meetings for, in the same format. ADVISEE is the original function being advised. ARGUMENTS are the arguments to the advisee." ;; FIXME: Currently numeric arguments to `diary' and ;; `diary-view-entries' are ignored. (exco-connection-iterate (lambda () (message "Retrieving diary entries via Excorporate...") (exco-diary-initialize (calendar-date-equal today date))) (lambda (identifier callback) (cl-destructuring-bind (month day year) date (exco-get-meetings-for-day identifier month day year callback))) (lambda (identifier response finalizer) (exco-calendar-item-with-details-iterate identifier response #'exco-diary-insert-meeting finalizer)) (lambda () (apply advisee arguments) ;; Warning: It is crucial to set appt-display-diary to nil here, ;; so that diary advice isn't entered repeatedly (ultimately via ;; the `appt-update-list' hook in `write-file-functions'), which ;; would create a connection-callback loop. (let ((appt-display-diary nil)) (appt-check t)) (message "Done retrieving diary entries via Excorporate.")) t) ;; Just return nil from this advice. We eventually run the advisee ;; asynchronously so there is no way of providing the same return ;; value as the unadvised `diary' and `diary-view-entries' ;; functions. Luckily they seem to only be used interactively, at ;; least within Emacs itself. nil) (defun exco-diary-diary-around (original-diary &rest arguments) "Call `diary' asynchronously. Retrieve diary entries via Excorporate before showing results. ORIGINAL-DIARY is the original `diary' function, and ARGUMENTS are the arguments to it." (let ((today (calendar-current-date)) (date (calendar-current-date))) (apply #'exco-diary-diary-advice today date original-diary arguments))) (defun exco-diary-diary-view-entries-override (&rest arguments) "Override `diary-view-entries' to make it asynchronous. Retrieve diary entries via Excorporate before showing results. ARGUMENTS are the arguments to `diary-view-entries'." (interactive "p") (diary-check-diary-file) (let ((today (calendar-current-date)) (date (calendar-cursor-to-date t))) (apply #'exco-diary-diary-advice today date #'diary-list-entries date arguments))) ;;;###autoload (defun excorporate-diary-enable () "Enable Excorporate diary support." (interactive) ;; Create the directory for Excorporate diary files if it doesn't ;; already exist. (exco-diary-initialize t) ;; Remove advice first so that `diary' will not be run by any save ;; hooks. (advice-remove #'diary #'exco-diary-diary-around) (advice-remove #'diary-view-entries #'exco-diary-diary-view-entries-override) (with-current-buffer (find-file-noselect diary-file) (dolist (file (list excorporate-diary-transient-file excorporate-diary-today-file)) (save-excursion (goto-char (point-min)) (when (not (re-search-forward (concat "^ *" diary-include-string " *\"" file "\"") nil t)) (let ((include-string (concat diary-include-string " \"" file "\""))) (if (string-match "omit-trailing-space" (documentation 'diary-make-entry)) (with-no-warnings (diary-make-entry include-string nil nil t t)) (exco-diary-diary-make-entry include-string))) (save-buffer))))) (advice-add #'diary :around #'exco-diary-diary-around) (advice-add #'diary-view-entries :override #'exco-diary-diary-view-entries-override) (add-hook 'diary-list-entries-hook #'diary-sort-entries) (add-hook 'diary-list-entries-hook #'diary-include-other-diary-files) (add-hook 'diary-fancy-display-mode-hook #'exco-diary--fix-percent-signs) (unless (eq appt-disp-window-function 'exco-diary-appt-disp-window) (if (eq appt-disp-window-function 'appt-disp-window) ;; exco-diary-appt-disp-window is compatible with ;; appt-disp-window, so override it. (setq appt-disp-window-function 'exco-diary-appt-disp-window) (warn (format (concat "Excorporate diary support needs appt-disp-window" " but appt-disp-window-function is currently %S") appt-disp-window-function)))) (unless (eq diary-display-function 'diary-fancy-display) (warn (format (concat "Excorporate diary support needs diary-fancy-display" " but diary-display-function is currently %S") diary-display-function))) (appt-activate 1) (message "Excorporate diary support enabled.")) ;;;###autoload (defun excorporate-diary-disable () "Disable Excorporate diary support." (interactive) (advice-remove #'diary #'exco-diary-diary-around) (advice-remove #'diary-view-entries #'exco-diary-diary-view-entries-override) (remove-hook 'diary-fancy-display-mode-hook #'exco-diary--fix-percent-signs) (when (eq appt-disp-window-function 'exco-diary-appt-disp-window) (setq appt-disp-window-function 'appt-disp-window)) (with-current-buffer (find-file-noselect diary-file) (dolist (file (list excorporate-diary-transient-file excorporate-diary-today-file)) (save-excursion (goto-char (point-min)) (when (search-forward (concat diary-include-string " \"" file "\"") nil t) (delete-region (progn (beginning-of-line) (point)) (progn (forward-line 1) (point))) (save-buffer))))) (message "Excorporate diary support disabled.")) (provide 'excorporate-diary) ;;; excorporate-diary.el ends here