;;; burnt-toast.el --- Elisp integration with the BurntToast PowerShell module -*- lexical-binding: t; coding: utf-8 -*- ;; Copyright (C) 2020 Sam Cedarbaum ;; Author: Sam Cedarbaum (scedarbaum@gmail.com) ;; Keywords: alert notifications powershell comm ;; Homepage: https://github.com/cedarbaum/burnt-toast.el ;; Version: 0.1 ;; Package-Requires: ((emacs "25.1") (dash "2.10") (alert "1.2")) ;; 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: ;; Elisp integration with BurntToast, a PowerShell module for displaying Windows 10 and Windows Server 2019 Toast Notifications. ;;; Code: (require 'dash) (require 'cl-lib) (defcustom burnt-toast-powershell-command "powershell" "Command to invoke PowerShell." :type 'string :group 'burnt-toast) (defvar burnt-toast--verbose nil "Enable verbose logging.") (defvar burnt-toast-powershell-test-hook nil "Hook to intercept powershell command for testing.") (defvar burnt-toast--install-checked nil "Cache if installation has already been checked.") (defun burnt-toast--check-installation () "Check if PowerShell and BurntToast module are installed and on PATH." (unless burnt-toast--install-checked (unless (executable-find burnt-toast-powershell-command) (error "PowerShell executable not on PATH")) (unless (eq 0 (burnt-toast--run-powershell-command "Get-Command New-BurntToastNotification" t)) (error "BurntToast module cannot be found")) (setq burnt-toast--install-checked t))) ;; Based on: https://github.com/mplscorwin/erc-burnt-toast-blob/master/erc-burnt-toast.el (defun burnt-toast--sanitize-powershell-input (string) "Return a version of STRING sanitized for use as input to PowerShell. New-lines are removed, trailing spaces are removed, and single-quotes are doubled." (when (stringp string) (replace-regexp-in-string "\s+$" "" (replace-regexp-in-string "[\t\n\r]+" "" (replace-regexp-in-string "\"" "\"\"" string))))) (defun burnt-toast--quote-and-sanitize-string (string) "Surround STRING with double quotes and sanitize it when it is non-nil." (when string (concat "\"" (burnt-toast--sanitize-powershell-input string) "\""))) (defun burnt-toast--param-to-string (obj) "Return OBJ as string when a non-nil string or an empty string otherwise." (cond ((stringp obj) obj) ((numberp obj) (number-to-string obj)) (t ""))) (defun burnt-toast--run-powershell-command (command-and-args &optional skip-install-check) "Execute a PowerShell command COMMAND-AND-ARGS. Optionally skip BurntToast installation check with SKIP-INSTALL-CHECK." (when burnt-toast--verbose (message command-and-args)) (if burnt-toast-powershell-test-hook (apply burnt-toast-powershell-test-hook `(,command-and-args)) (or skip-install-check (burnt-toast--check-installation)) (call-process burnt-toast-powershell-command nil nil nil "-NoProfile" "-NoExit" "-NonInteractive" "-WindowStyle" "Hidden" command-and-args))) (defun burnt-toast--create-ps-command (command-prefix args) "Create a new PowerShell command with prefix COMMAND-PREFIX using ARGS." (let* ((prefix-string (concat "$(" command-prefix " ")) (non-nil-args (-filter (-lambda ((_ value)) value) args)) (quoted-args (-map (-lambda ((arg value quote)) `(,arg ,(if quote (burnt-toast--quote-and-sanitize-string value) value))) non-nil-args)) (args-string-list (-map (-lambda ((arg value)) (concat "-" arg " " (burnt-toast--param-to-string value))) quoted-args)) (args-string (and args-string-list (-reduce (lambda (s1 s2) (concat s1 " " s2)) args-string-list)))) (concat prefix-string (or args-string "") ")"))) (defun burnt-toast--new-ps-object (object args) "Create a new PowerShell OBJECT with ARGS." (let* ((command-prefix (concat "New-" object))) (burnt-toast--create-ps-command command-prefix args))) (defun burnt-toast--new-ps-object-list (objects &optional process) "Create a comma separated list of OBJECTS. Optionally process each object with PROCESS function as list is built." (let* ((map-func (or process #'identity))) (if (and objects (listp objects)) (-reduce (lambda (s1 s2) (concat s1 "," s2)) (-map map-func objects)) (apply map-func `(,objects))))) (cl-defun burnt-toast--new-notification-core (&key text app-logo sound header silent snooze-and-dismiss unique-identifier expiration-time) "Create new notification with subset of arguments. Arguments are TEXT, APP-LOGO, SOUND, HEADER, SILENT, SNOOZE-AND-DISMISS, UNIQUE-IDENTIFIER, and EXPIRATION-TIME. This function should not be called directly." (let* ((processed-text (burnt-toast--new-ps-object-list text #'burnt-toast--quote-and-sanitize-string)) (ps-command (burnt-toast--new-ps-object "BurntToastNotification" `(("Text" ,processed-text) ("AppLogo" ,app-logo t) ("Sound" ,sound t) ("Header" ,header) ("Silent" ,silent) ("SnoozeAndDismiss" ,snooze-and-dismiss) ("UniqueIdentifier" ,unique-identifier) ("ExpirationTime" ,expiration-time))))) (burnt-toast--run-powershell-command ps-command))) ;;;###autoload (cl-defun burnt-toast-submit-notification (content &key app-id unique-identifier) "Submit a new notification. CONTENT is the notification's content. Should be created with (burnt-toast-bt-content-object ...). APP-ID is an the application identifier of Emacs on Windows. UNIQUE-IDENTIFIER will be assigned to the tag and group of the notification." (let* ((ps-command (burnt-toast--create-ps-command "Submit-BTNotification" `(("Content" ,content) ("AppId" ,app-id t) ("UniqueIdentifier" ,unique-identifier t))))) (burnt-toast--run-powershell-command ps-command))) ;;;###autoload (cl-defun burnt-toast-bt-header-object (&key id title) "Create a new header for a notification. ID is an identifier for the notification. It is used to correlate the notification with others. TITLE is the display name for the notification." (burnt-toast--new-ps-object "BTHeader" `(("Id" ,id) ("Title" ,title t)))) ;;;###autoload (cl-defun burnt-toast-bt-text-object (&key content max-lines) "Create a new text object. CONTENT is the text content. MAX-LINES is the maximum number of lines in the text object." (burnt-toast--new-ps-object "BTText" `(("Content" ,content t) ("MaxLines" ,max-lines)))) ;;;###autoload (cl-defun burnt-toast-bt-image-object (&key source app-logo-override) "Create a new image object. SOURCE is where the image is located. APP-LOGO-OVERRIDE is non-nil if image will be used as application icon, nil otherwise." (burnt-toast--new-ps-object "BTImage" `(("Source" ,source t) ("AppLogoOverride" ,app-logo-override)))) ;;;###autoload (cl-defun burnt-toast-bt-binding-object (&key children app-logo-override) "Create a new binding object. CHILDREN is the elements contained in the binding. APP-LOGO-OVERRIDE is the image to be used as the app logo." (burnt-toast--new-ps-object "BTBinding" `(("Children" ,(burnt-toast--new-ps-object-list children)) ("AppLogoOverride" ,app-logo-override)))) ;;;###autoload (cl-defun burnt-toast-bt-visual-object (binding-generic) "Create a new visual object. BINDING-GENERIC is the binding associated with the visual." (burnt-toast--new-ps-object "BTVisual" `(("BindingGeneric" ,binding-generic)))) ;;;###autoload (cl-defun burnt-toast-bt-content-object (visual &key audio) "Create a new content object. VISUAL is the visual associated with the content. AUDIO is an optional audio object to play." (burnt-toast--new-ps-object "BTContent" `(("Visual" ,visual) ("Audio" ,audio)))) ;;;###autoload (cl-defun burnt-toast-bt-audio-object (source) "Create a new audio object. SOURCE is the audio's source." (burnt-toast--new-ps-object "BTAudio" `(("Source" ,source)))) ;;;###autoload (cl-defun burnt-toast-datetime-seconds-from-now (seconds) "Return the DateTime SECONDS from now." (format "$([DateTime]::Now.AddSeconds(%f))" seconds)) ;;;###autoload (cl-defun burnt-toast-new-notification-with-sound (&key text app-logo sound header unique-identifier expiration-time) "Create a new notification. TEXT is the content of the notification. This can be a list of strings, in which case each entry is a new line. APP-LOGO is a path to an icon to be displayed with the notification. SOUND is the sound effect to play. HEADER is the notification's header. This should be created with (burnt-toast-bt-header-object ID HEADER). UNIQUE-IDENTIFIER a unique identifier that can be used to remove/edit the notification. EXPIRATION-TIME DateTime for notification to expire. This should be created with (burnt-toast-datetime-seconds-from-now SECONDS)." (burnt-toast--new-notification-core :text text :app-logo app-logo :sound sound :header header :unique-identifier unique-identifier :expiration-time expiration-time)) ;;;###autoload (cl-defun burnt-toast-new-notification-silent (&key text app-logo header unique-identifier expiration-time) "Create a new silent notification. TEXT is the content of the notification. This can be a list of strings, in which case each entry is a new line. APP-LOGO is a path to an icon to be displayed with the notification. HEADER is the notification's header. This should be created with (burnt-toast-bt-header-object ID HEADER). UNIQUE-IDENTIFIER a unique identifier that can be used to remove/edit the notification. EXPIRATION-TIME DateTime for notification to expire. This should be created with (burnt-toast-datetime-seconds-from-now SECONDS)." (burnt-toast--new-notification-core :text text :app-logo app-logo :silent t :header header :unique-identifier unique-identifier :expiration-time expiration-time)) ;;;###autoload (cl-defun burnt-toast-new-notification-snooze-and-dismiss-with-sound (&key text app-logo header sound unique-identifier expiration-time) "Create a new snooze-and-dismiss notification. TEXT is the content of the notification. This can be a list of strings, in which case each entry is a new line. APP-LOGO is a path to an icon to be displayed with the notification. HEADER is the notification's header. This should be created with (burnt-toast-bt-header-object ID HEADER). SOUND is the sound effect to play. UNIQUE-IDENTIFIER a unique identifier that can be used to remove/edit the notification. EXPIRATION-TIME DateTime for notification to expire. This should be created with (burnt-toast-datetime-seconds-from-now SECONDS)." (burnt-toast--new-notification-core :text text :app-logo app-logo :header header :sound sound :snooze-and-dismiss t :unique-identifier unique-identifier :expiration-time expiration-time)) ;;;###autoload (cl-defun burnt-toast-new-notification-snooze-and-dismiss-silent (&key text app-logo header unique-identifier expiration-time) "Create a new silent snooze-and-dismiss notification. TEXT is the content of the notification. This can be a list of strings, in which case each entry is a new line. APP-LOGO is a path to an icon to be displayed with the notification. HEADER is the notification's header. This should be created with (burnt-toast-bt-header-object ID HEADER). UNIQUE-IDENTIFIER a unique identifier that can be used to remove/edit the notification. EXPIRATION-TIME DateTime for notification to expire. This should be created with (burnt-toast-datetime-seconds-from-now SECONDS)." (burnt-toast--new-notification-core :text text :app-logo app-logo :silent t :snooze-and-dismiss t :header header :unique-identifier unique-identifier :expiration-time expiration-time)) ;;;###autoload (cl-defun burnt-toast-new-shoulder-tap (image person &key text app-logo header expiration-time) "Create a new shoulder tap notification. IMAGE is the image representing the contact. PERSON is the name of the contact. TEXT is the content of the fallback notification. This can be a list of strings, in which case each entry is a new line. APP-LOGO is a path to an icon to be displayed with the fallback notification. HEADER is the fallback notification's header. This should be created with (burnt-toast-bt-header-object ID HEADER). EXPIRATION-TIME DateTime for notification to expire. This should be created with (burnt-toast-datetime-seconds-from-now SECONDS)." (let* ((processed-text (burnt-toast--new-ps-object-list text #'burnt-toast--quote-and-sanitize-string)) (ps-command (burnt-toast--new-ps-object "BurntToastShoulderTap" `(("Image" ,image t) ("Person" ,person t) ("Text" ,processed-text) ("AppLogo" ,app-logo t) ("Header" ,header) ("ExpirationTime" ,expiration-time))))) (burnt-toast--run-powershell-command ps-command))) (cl-defun burnt-toast-remove-notification (&key app-id tag group) "Remove a notification. If APP-ID is specified, removes all notifications for that application. If TAG is specified, removes all notifications with that tag. If GROUP is specified, removes all notifications in that group." (let* ((ps-command (burnt-toast--create-ps-command "Remove-BTNotification" `(("AppId" ,app-id t) ("Tag" ,tag t) ("Group" ,group))))) (burnt-toast--run-powershell-command ps-command))) (provide 'burnt-toast) ;;; burnt-toast.el ends here