diff options
Diffstat (limited to 'chime.el')
| -rw-r--r-- | chime.el | 1825 |
1 files changed, 1825 insertions, 0 deletions
diff --git a/chime.el b/chime.el new file mode 100644 index 0000000..08c9632 --- /dev/null +++ b/chime.el @@ -0,0 +1,1825 @@ +;;; chime.el --- CHIME Heralds Imminent Events -*- lexical-binding: t -*- + +;; Copyright (C) 2017 Artem Khramov +;; Copyright (C) 2024-2025 Craig Jennings + +;; Current Author/Maintainer: Craig Jennings <c@cjennings.net> +;; Original Author: Artem Khramov <akhramov+emacs@pm.me> +;; Created: 6 Jan 2017 +;; Version: 0.6.0 +;; Package-Requires: ((alert "1.2") (async "1.9.3") (dash "2.18.0") (emacs "26.1")) +;; Keywords: notification alert org org-agenda agenda calendar chime sound +;; URL: https://github.com/cjennings/chime.el + +;; 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: + +;; CHIME (CHIME Heralds Imminent Events) - Customizable org-agenda notifications +;; +;; This package provides visual and audible notifications for upcoming org-agenda +;; events with modeline display of the next upcoming event. +;; +;; Features: +;; - Visual notifications with customizable alert times +;; - Audible chime sound when notifications appear +;; - Modeline display of next upcoming event +;; - Support for SCHEDULED, DEADLINE, and plain timestamps +;; - Repeating timestamp support (+1w, .+1d, ++1w) +;; - Async background checking (runs every minute) +;; +;; Quick Start: +;; (require 'chime) +;; (setq chime-alert-intervals '((5 . medium) (0 . high))) ; 5 min before and at event time +;; (chime-mode 1) +;; +;; Manual check: M-x chime-check +;; +;; Notification intervals and severity can be customized globally via +;; `chime-alert-intervals'. +;; +;; Filter notifications using `chime-keyword-whitelist' and +;; `chime-keyword-blacklist' variables. +;; +;; See README.org for complete documentation. + +;;; Code: + +(require 'dash) +(require 'alert) +(require 'async) +(require 'org-agenda) +(require 'org-duration) +(require 'cl-lib) + +;; Declare functions from chime-debug.el (loaded conditionally) +(declare-function chime-debug-monitor-event-loading "chime-debug") + +(defgroup chime nil + "Chime customization options." + :group 'org) + +(defcustom chime-alert-intervals '((10 . medium)) + "Alert intervals with severity levels for upcoming events. +Each element is a cons cell (MINUTES . SEVERITY) where: +- MINUTES: Number of minutes before event to notify (0 = at event time) +- SEVERITY: Alert urgency level (high, medium, or low) + +Example configurations: + ;; Single notification at event time with high urgency + \\='((0 . high)) + + ;; Multiple notifications with escalating urgency + \\='((60 . low) ;; 1 hour before: low urgency + (30 . low) ;; 30 min before: low urgency + (10 . medium) ;; 10 min before: medium urgency + (0 . high)) ;; At event: high urgency + + ;; Same severity for all notifications + \\='((15 . medium) (5 . medium) (0 . medium)) + +Each interval's severity affects how the notification is displayed +by your system's notification daemon." + :package-version '(chime . "0.7.0") + :group 'chime + :type '(repeat (cons (integer :tag "Minutes before event") + (symbol :tag "Severity"))) + :set (lambda (symbol value) + (unless (listp value) + (user-error "chime-alert-intervals must be a list of cons cells, got: %S" value)) + (dolist (interval value) + (unless (consp interval) + (user-error "Each interval must be a cons cell (MINUTES . SEVERITY), got: %S" interval)) + (let ((minutes (car interval)) + (severity (cdr interval))) + (unless (integerp minutes) + (user-error "Alert time must be an integer, got: %S" minutes)) + (when (< minutes 0) + (user-error "Alert time cannot be negative, got: %d" minutes)) + (unless (memq severity '(high medium low)) + (user-error "Severity must be high, medium, or low, got: %S" severity)))) + (set-default symbol value))) + +(defcustom chime-check-interval 60 + "How often to check for upcoming events, in seconds. +Chime will poll your agenda files at this interval to check for +notifications. Lower values make notifications more responsive but +increase system load. Higher values reduce polling overhead but may +delay notifications slightly. + +Minimum recommended value: 10 seconds. +Default: 60 seconds (1 minute). + +Note: Changes take effect after restarting chime-mode." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'integer + :set (lambda (symbol value) + (unless (integerp value) + (user-error "Check interval must be an integer, got: %S" value)) + (when (< value 10) + (warn "chime-check-interval: Values below 10 seconds may cause excessive polling and system load")) + (when (<= value 0) + (user-error "Check interval must be positive, got: %d" value)) + (set-default symbol value))) + +(defcustom chime-notification-title "Agenda" + "Notifications title." + :package-version '(chime . "0.1.0") + :group 'chime + :type 'string) + +(defcustom chime-notification-icon nil + "Path to notification icon file." + :package-version '(chime . "0.4.1") + :group 'chime + :type 'string) + +(defcustom chime-keyword-whitelist nil + "Receive notifications for these keywords only. +Leave this variable blank if you do not want to filter anything." + :package-version '(chime . "0.2.2") + :group 'chime + :type '(repeat string)) + +(defcustom chime-keyword-blacklist nil + "Never receive notifications for these keywords." + :package-version '(chime . "0.2.2") + :group 'chime + :type '(repeat string)) + +(defcustom chime-tags-whitelist nil + "Receive notifications for these tags only. +Leave this variable blank if you do not want to filter anything." + :package-version '(chime . "0.3.1") + :group 'chime + :type '(repeat string)) + +(defcustom chime-tags-blacklist nil + "Never receive notifications for these tags." + :package-version '(chime . "0.3.1") + :group 'chime + :type '(repeat string)) + +(defcustom chime-display-time-format-string "%I:%M %p" + "Format string for displaying event times. +Passed to `format-time-string' when displaying notification times. +Uses standard time format codes: + %I - Hour (01-12, 12-hour format) + %H - Hour (00-23, 24-hour format) + %M - Minutes (00-59) + %p - AM/PM designation (uppercase) + %P - am/pm designation (lowercase) + +Common formats: + \"%I:%M %p\" -> \"02:30 PM\" (12-hour with AM/PM, default) + \"%H:%M\" -> \"14:30\" (24-hour) + \"%I:%M%p\" -> \"02:30PM\" (12-hour, no space before AM/PM) + \"%l:%M %p\" -> \" 2:30 PM\" (12-hour, space-padded hour) + +Note: Avoid using seconds (%S) as chime polls once per minute." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'string + :set (lambda (symbol value) + (when (and value (stringp value) (string-match-p "%S" value)) + (warn "chime-display-time-format-string: Using seconds (%%S) is not recommended as chime polls once per minute")) + (set-default symbol value))) + +(defcustom chime-time-left-format-at-event "right now" + "Format string for when event time has arrived (0 or negative seconds). +This is a literal string with no format codes." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-time-left-format-short "in %M" + "Format string for times under 1 hour. +Uses `format-seconds' codes: + %m - minutes as number only (e.g., \"37\") + %M - minutes with unit name (e.g., \"37 minutes\") + +Examples: + \"in %M\" -> \"in 37 minutes\" + \"in %mm\" -> \"in 37m\" + \"%m min\" -> \"37 min\"" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-time-left-format-long "in %H %M" + "Format string for times 1 hour or longer. +Uses `format-seconds' codes: + %h - hours as number only (e.g., \"1\") + %H - hours with unit name (e.g., \"1 hour\") + %m - minutes as number only (e.g., \"37\") + %M - minutes with unit name (e.g., \"37 minutes\") + +Examples: + \"in %H %M\" -> \"in 1 hour 37 minutes\" + \"in %hh %mm\" -> \"in 1h 37m\" + \"(%h hr %m min)\" -> \"(1 hr 37 min)\" + \"%hh%mm\" -> \"1h37m\"" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-predicate-whitelist nil + "Receive notifications for events matching these predicates only. +Each function should take an event POM and return non-nil iff that event should +trigger a notification. Leave this variable blank if you do not want to filter +anything." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(function)) + +(defcustom chime-additional-environment-regexes nil + "Additional regular expressions for async environment injection. +These regexes are provided to `async-inject-environment' before +running the async command to check notifications." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(string)) + +(defcustom chime-predicate-blacklist + '(chime-done-keywords-predicate) + "Never receive notifications for events matching these predicates. +Each function should take an event POM and return non-nil iff that event should +not trigger a notification." + :package-version '(chime . "0.5.0") + :group 'chime + :type '(function)) + +(defcustom chime-extra-alert-plist nil + "Additional arguments that should be passed to invocations of `alert'." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'plist) + +(defcustom chime-day-wide-alert-times '("08:00") + "List of time strings for day-wide event alerts. +Each string specifies a time of day when day-wide events should trigger. +Defaults to 08:00 (morning reminder for all-day events happening today). +Set to nil to disable all-day event notifications entirely. + +Example: \\='(\"08:00\" \"17:00\") for morning and evening reminders." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(repeat string)) + +(defcustom chime-show-any-overdue-with-day-wide-alerts t + "Show any overdue TODO items along with day wide alerts whenever they are shown." + :package-version '(chime . "0.5.0") + :group 'chime + :type 'boolean) + +(defcustom chime-day-wide-advance-notice nil + "Number of days before all-day events to show advance notifications. +When nil, only notify on the day of the event. +When 1, also notify the day before at `chime-day-wide-alert-times'. +When 2, notify two days before, etc. + +Useful for events requiring preparation, such as birthdays (buying gifts) +or multi-day conferences (packing, travel arrangements). + +Note: This only affects notifications, not tooltip/modeline display. + +Example: With value 1 and alert times \\='(\"08:00\"), you'll get: + - \"Blake's birthday is tomorrow\" at 08:00 the day before + - \"Blake's birthday is today\" at 08:00 on the day" + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "Same day only" nil) + (integer :tag "Days in advance"))) + +(defcustom chime-tooltip-show-all-day-events t + "Whether to show all-day events in the tooltip. +When nil, all-day events (birthdays, multi-day conferences, etc.) are +hidden from the tooltip but can still trigger notifications. +When t, all-day events appear in the tooltip for planning purposes. + +All-day events are never shown in the modeline (only in tooltip). + +This is useful for seeing upcoming birthdays, holidays, and multi-day +events without cluttering the modeline with non-time-sensitive items." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-enable-modeline t + "Whether to display upcoming events in the modeline. +When nil, chime will not modify the modeline at all." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-modeline-lighter " 🔔" + "Text to display in the modeline when chime-mode is enabled. +This is the mode lighter that appears in the modeline to indicate +chime-mode is active." + :package-version '(chime . "0.7.0") + :group 'chime + :type 'string) + +(defcustom chime-modeline-lookahead-minutes 60 + "Minutes ahead to look for next event to display in modeline. +Should be larger than notification alert times for advance awareness. +Set to 0 to disable modeline display. +This setting only takes effect when `chime-enable-modeline' is non-nil." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(integer :tag "Minutes")) + +(defcustom chime-modeline-format " ⏰ %s" + "Format string for modeline display. +%s will be replaced with the event description (time and title)." + :package-version '(chime . "0.5.1") + :group 'chime + :type 'string) + +(defcustom chime-calendar-url nil + "URL to your calendar for browser access. +When set, left-clicking the modeline icon/text opens this URL in your +browser. Right-clicking jumps to the next event in your org file. + +Set this to your calendar's web interface, such as: + - Google Calendar: \"https://calendar.google.com\" + - Outlook: \"https://outlook.office.com/calendar\" + - Custom calendar URL + +When nil (default), left-click does nothing." + :package-version '(chime . "0.7.0") + :group 'chime + :type '(choice (const :tag "No calendar URL" nil) + (string :tag "Calendar URL"))) + +(defcustom chime-tooltip-lookahead-hours 8760 + "Hours ahead to look for events in tooltip. +Separate from modeline lookahead window. +Default is 8760 hours (1 year), showing all future events. +The actual number of events shown is limited by +`chime-modeline-tooltip-max-events'. + +Set to a smaller value to limit tooltip by time as well as count. +Example: Set to 24 to show only today's and tomorrow's events, +or keep at default to show next N events regardless of distance." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(integer :tag "Hours")) + +(defcustom chime-modeline-tooltip-max-events 5 + "Maximum number of events to show in modeline tooltip. +Set to nil to show all events within tooltip lookahead window." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (integer :tag "Maximum events") + (const :tag "Show all" nil))) + +(defcustom chime-modeline-no-events-text " ⏰" + "Text to display in modeline when no events are within lookahead window. +Shows an alarm icon by default. +When nil, nothing is shown in the modeline when no upcoming events. +When a string, that text is displayed. + +This only applies when events exist beyond the lookahead window. +If there are no events at all, the modeline is always empty. + +Examples: + \" ⏰\" - Alarm icon (default) + \" 🔕\" - Muted bell emoji + nil - Show nothing (clean modeline) + \" No events\" - Show text message" + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "Show nothing" nil) + (string :tag "Custom text"))) + +(defcustom chime-notification-text-format "%t at %T (%u)" + "Format string for notification text display. +Available placeholders: + %t - Event title + %T - Event time (formatted per `chime-display-time-format-string') + %u - Time until event (formatted per time-left format settings) + +Examples: + \"%t at %T (%u)\" -> \"Team Meeting at 02:30 PM (in 10 minutes)\" (default) + \"%t at %T\" -> \"Team Meeting at 02:30 PM\" (no countdown) + \"%t (%u)\" -> \"Team Meeting (in 10 minutes)\" (no time) + \"%t - %T\" -> \"Team Meeting - 02:30 PM\" (custom separator) + \"%t\" -> \"Team Meeting\" (title only)" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'string) + +(defcustom chime-max-title-length nil + "Maximum length for event titles in notifications. +When non-nil, truncate titles longer than this value with \"...\". +When nil, show full title without truncation. + +This affects ONLY the event title (%t in `chime-notification-text-format'), +NOT the icon, time, or countdown. The icon is part of +`chime-modeline-format' and is added separately. + +Examples (assuming format \"%t (%u)\" and icon \" ⏰ \"): + nil -> \" ⏰ Very Long Meeting Title That Goes On ( in 10m)\" + 25 -> \" ⏰ Very Long Meeting Titl... ( in 10m)\" + 15 -> \" ⏰ Very Long Me... ( in 10m)\" + 10 -> \" ⏰ Very Lo... ( in 10m)\" + +The limit includes the \"...\" suffix (3 chars), so a limit of 15 +means up to 12 chars of title plus \"...\". + +Minimum recommended value: 10 characters." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "No truncation (show full title)" nil) + (integer :tag "Maximum title length")) + :set (lambda (symbol value) + (when (and value (integerp value) (< value 5)) + (warn "chime-max-title-length: Values below 5 may produce illegible titles")) + (set-default symbol value))) + +(defcustom chime-tooltip-header-format "Upcoming Events as of %a %b %d %Y @ %I:%M %p" + "Format string for tooltip header showing current date/time. +Uses `format-time-string' codes. +See Info node `(elisp)Time Parsing' for details. + +Common format codes: + %a - Abbreviated weekday (Mon, Tue, ...) + %A - Full weekday name (Monday, Tuesday, ...) + %b - Abbreviated month (Jan, Feb, ...) + %B - Full month name (January, February, ...) + %d - Day of month, zero-padded (01-31) + %e - Day of month, space-padded ( 1-31) + %Y - Four-digit year (2025) + %I - Hour (01-12, 12-hour format) + %H - Hour (00-23, 24-hour format) + %M - Minute (00-59) + %p - AM/PM indicator + +Default: \"Upcoming Events as of %a %b %d %Y @ %I:%M %p\" +Result: \"Upcoming Events as of Tue Nov 04 2025 @ 08:25 PM\"" + :package-version '(chime . "0.7.0") + :group 'chime + :type 'string) + +(defcustom chime-play-sound t + "Whether to play a sound when notifications are displayed. +When non-nil, plays the sound file specified in `chime-sound-file'." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +(defcustom chime-sound-file + (expand-file-name "sounds/chime.wav" + (file-name-directory (or load-file-name buffer-file-name))) + "Path to sound file to play when notifications are displayed. +Defaults to the bundled chime.wav file. +Set to nil to disable sound completely (no sound file, no beep). +Should be an absolute path to a .wav, .au, or other sound file +supported by your system." + :package-version '(chime . "0.6.0") + :group 'chime + :type '(choice (const :tag "No sound" nil) + (file :tag "Sound file path"))) + +(defcustom chime-startup-delay 10 + "Seconds to wait before first event check after chime-mode is enabled. +This delay allows org-agenda-files and related infrastructure to finish +loading before chime attempts to check for events. + +Default of 10 seconds works well for most configurations. Adjust if: +- You have custom org-agenda-files setup that takes longer to initialize +- You want faster startup (reduce to 5) and know org is ready +- You see \"found 0 events\" messages (increase to 15 or 20) + +Set to 0 to check immediately (not recommended unless you're sure +org-agenda-files is populated at startup)." + :package-version '(chime . "0.6.0") + :group 'chime + :type 'integer + :set (lambda (symbol value) + (unless (and (integerp value) (>= value 0)) + (user-error "chime-startup-delay must be a non-negative integer, got: %s" value)) + (set-default symbol value))) + +(defcustom chime-debug nil + "Enable debug functions for troubleshooting chime behavior. +When non-nil, loads chime-debug.el which provides: +- `chime--debug-dump-events' - Show all stored upcoming events +- `chime--debug-dump-tooltip' - Show tooltip content +- `chime--debug-config' - Show complete configuration dump +- `chime-debug-monitor-event-loading' - Monitor event loading timing + +These functions write detailed information to the *Messages* buffer +without cluttering the echo area. + +When enabled, automatically monitors event loading to help diagnose +timing issues where the modeline takes a while to populate after +Emacs startup. + +Set to t to enable debug functions: + (setq chime-debug t) + (require \\='chime)" + :package-version '(chime . "0.6.0") + :group 'chime + :type 'boolean) + +;; Load debug functions if enabled +(when chime-debug + (require 'chime-debug + (expand-file-name "chime-debug.el" + (file-name-directory (or load-file-name buffer-file-name))) + t)) + +;; Load org-contacts integration if configured +;; Note: The actual template setup happens in chime-org-contacts.el +;; when org-capture is loaded, so users can defer org loading +(with-eval-after-load 'org-capture + (when (and (boundp 'chime-org-contacts-file) + chime-org-contacts-file) + (require 'chime-org-contacts + (expand-file-name "chime-org-contacts.el" + (file-name-directory (or load-file-name buffer-file-name))) + t))) + +(defvar chime--timer nil + "Timer value.") + +(defvar chime--process nil + "Currently-running async process.") + +(defvar chime--agenda-buffer-name "*chime-agenda*" + "Name for temporary \\='org-agenda\\=' buffer.") + +(defvar chime--last-check-time (seconds-to-time 0) + "Last time checked for events.") + +(defvar chime--upcoming-events nil + "List of upcoming events with full data for tooltip and clicking. +Each event includes marker, title, times, and intervals.") + +(defvar chime--validation-done nil + "Whether configuration validation has been performed. +Validation runs on the first call to `chime-check', after `chime-startup-delay' +has elapsed. This gives startup hooks time to populate org-agenda-files.") + +(defvar chime--validation-retry-count 0 + "Number of times validation has failed and been retried. +Reset to 0 when validation succeeds. Used to provide graceful retry +behavior for users with async org-agenda-files initialization.") + +(defcustom chime-validation-max-retries 3 + "Maximum number of times to retry validation before showing error. +When org-agenda-files is empty on startup, chime will retry validation +on each check cycle (every `chime-check-interval' seconds) until either: + - Validation succeeds (org-agenda-files is populated) + - This retry limit is exceeded (error is shown) + +This accommodates users with async initialization code that populates +org-agenda-files after a delay (e.g., via idle timers). + +Set to 0 to show errors immediately without retrying. +Default is 3 retries (with 30-60s check intervals, this gives ~1.5-3 minutes +for org-agenda-files to be populated)." + :type 'integer + :group 'chime) + +(defvar chime-modeline-string nil + "Modeline string showing next upcoming event.") +;;;###autoload(put 'chime-modeline-string 'risky-local-variable t) +(put 'chime-modeline-string 'risky-local-variable t) + +(defun chime--time= (&rest list) + "Compare timestamps. +Comparison is performed by converted each element of LIST onto string +in order to ignore seconds." + (->> list + (--map (format-time-string "%d:%H:%M" it)) + (-uniq) + (length) + (= 1))) + +(defun chime--today () + "Get the timestamp for the beginning of current day." + (apply 'encode-time + (append '(0 0 0) (nthcdr 3 (decode-time (current-time)))))) + +(defun chime--timestamp-within-interval-p (timestamp interval) + "Check whether TIMESTAMP is within notification INTERVAL. +Returns non-nil if TIMESTAMP matches current time plus INTERVAL minutes. +Returns nil if TIMESTAMP or INTERVAL is invalid." + (and timestamp + interval + (numberp interval) + ;; Validate timestamp is a proper time value (list of integers) + (listp timestamp) + (chime--time= + (time-add (current-time) (seconds-to-time (* 60 interval))) + timestamp))) + +(defun chime--notifications (event) + "Get notifications for given EVENT. +Returns a list of time information interval pairs. +Each pair is ((TIMESTAMP . TIME-VALUE) (MINUTES . SEVERITY))." + (->> (list + (chime--filter-day-wide-events (cdr (assoc 'times event))) + (cdr (assoc 'intervals event))) + (apply '-table-flat (lambda (ts int) (list ts int))) + ;; When no values are provided for table flat, we get the second values + ;; paired with nil. + (--filter (not (null (car it)))) + ;; Extract minutes from (minutes . severity) cons for time matching + (--filter (chime--timestamp-within-interval-p (cdar it) (car (cadr it)))))) + +(defun chime--has-timestamp (s) + "Check if S contain a timestamp with a time component. +Returns non-nil only if the timestamp includes HH:MM time information." + (and s + (stringp s) + (string-match org-ts-regexp0 s) + (match-beginning 7))) + +(defun chime--filter-day-wide-events (times) + "Filter TIMES list to include only events with timestamps." + (--filter (chime--has-timestamp (car it)) times)) + +(defun chime--time-left (seconds) + "Human-friendly representation for SECONDS. +Format is controlled by `chime-time-left-format-at-event', +`chime-time-left-format-short', and `chime-time-left-format-long'." + (-> seconds + (pcase + ((pred (>= 0)) chime-time-left-format-at-event) + ((pred (>= 3600)) chime-time-left-format-short) + (_ chime-time-left-format-long)) + + (format-seconds seconds))) + +(defun chime--get-hh-mm-from-org-time-string (time-string) + "Convert given org time-string TIME-STRING into string with \\='hh:mm\\=' format." + (format-time-string + chime-display-time-format-string + (encode-time (org-parse-time-string time-string)))) + +(defun chime--truncate-title (title) + "Truncate TITLE to `chime-max-title-length' if set. +Returns the truncated title with \"...\" appended if truncated, +or the original title if no truncation is needed. +Returns empty string if TITLE is nil." + (let ((title-str (or title ""))) + (if (and chime-max-title-length + (integerp chime-max-title-length) + (> chime-max-title-length 0) + (> (length title-str) chime-max-title-length)) + (concat (substring title-str 0 (max 0 (- chime-max-title-length 3))) "...") + title-str))) + +(defun chime--notification-text (str-interval event) + "For given STR-INTERVAL list and EVENT get notification wording. +STR-INTERVAL is (TIMESTAMP-STRING . (MINUTES . SEVERITY)). +Format is controlled by `chime-notification-text-format'. +Title is truncated per `chime-max-title-length' if set." + (let* ((title (cdr (assoc 'title event))) + (minutes (car (cdr str-interval)))) + (format-spec chime-notification-text-format + `((?t . ,(chime--truncate-title title)) + (?T . ,(chime--get-hh-mm-from-org-time-string (car str-interval))) + (?u . ,(chime--time-left (* 60 minutes))))))) + +(defun chime-get-minutes-into-day (time) + "Get minutes elapsed since midnight for TIME string." + (org-duration-to-minutes (org-get-time-of-day time t))) + +(defun chime-get-hours-minutes-from-time (time-string) + "Extract hours and minutes from TIME-STRING. +Returns a list of (HOURS MINUTES)." + (let ((total-minutes (truncate (chime-get-minutes-into-day time-string)))) + (list (/ total-minutes 60) + (mod total-minutes 60)))) + +(defun chime-set-hours-minutes-for-time (time hours minutes) + "Set HOURS and MINUTES for TIME, preserving date components." + (cl-destructuring-bind (_s _m _h day month year dow dst utcoff) (decode-time time) + (encode-time 0 minutes hours day month year dow dst utcoff))) + +(defun chime-current-time-matches-time-of-day-string (time-of-day-string) + "Check if current time matches TIME-OF-DAY-STRING." + (let ((now (current-time))) + (chime--time= + now + (apply 'chime-set-hours-minutes-for-time + now + (chime-get-hours-minutes-from-time time-of-day-string))))) + +(defun chime-current-time-is-day-wide-time () + "Check if current time matches any day-wide alert time." + (--any (chime-current-time-matches-time-of-day-string it) + chime-day-wide-alert-times)) + +(defun chime-day-wide-notifications (events) + "Generate notification texts for day-wide EVENTS. +Returns a list of (MESSAGE . SEVERITY) cons cells with \\='medium severity." + (->> events + (-filter 'chime-display-as-day-wide-event) + (-map 'chime--day-wide-notification-text) + (-uniq) + ;; Wrap messages in cons cells with default 'medium' severity + (--map (cons it 'medium)))) + +(defun chime-display-as-day-wide-event (event) + "Check if EVENT should be displayed as a day-wide event. +Considers both events happening today and advance notices for future events. + +When `chime-show-any-overdue-with-day-wide-alerts' is t (default): + - Shows overdue TODO items (timed events that passed) + - Shows all-day events from today or earlier + +When nil: + - Shows only today's events (both timed and all-day) + - Hides overdue items from past days" + (or + ;; Events happening today or in the past + (and (chime-event-has-any-passed-time event) + (or chime-show-any-overdue-with-day-wide-alerts + ;; When overdue alerts disabled, only show today's events + (chime-event-is-today event))) + ;; Advance notice for upcoming all-day events + (and chime-day-wide-advance-notice + (chime-event-has-any-day-wide-timestamp event) + (chime-event-within-advance-notice-window event)))) + +(defun chime-event-has-any-day-wide-timestamp (event) + "Check if EVENT has any day-wide (no time component) timestamps." + (--any (not (chime--has-timestamp (car it))) + (cdr (assoc 'times event)))) + +(defun chime-event-within-advance-notice-window (event) + "Check if EVENT has any day-wide timestamps within advance notice window. +Returns t if any all-day timestamp is between tomorrow and N days from now, +where N is `chime-day-wide-advance-notice'." + (when chime-day-wide-advance-notice + (let* ((now (current-time)) + ;; Calculate time range: start of tomorrow to end of N days from now + (window-end (time-add now (seconds-to-time + (* 86400 (1+ chime-day-wide-advance-notice))))) + (all-times (cdr (assoc 'times event)))) + (--any + (when-let* ((timestamp-str (car it)) + ;; Only check all-day events (those without time component) + (is-all-day (not (chime--has-timestamp timestamp-str))) + ;; Parse the date portion even without time + (parsed (org-parse-time-string timestamp-str)) + ;; Use nth accessors for Emacs 26 compatibility + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + ;; Convert to time at start of day (00:00:00) + (let ((event-time (encode-time 0 0 0 day month year))) + ;; Check if event is within the advance notice window + (and (time-less-p now event-time) ;; Event is in future + (time-less-p event-time window-end)))) ;; Event is within window + all-times)))) + +(defun chime-event-has-any-passed-time (event) + "Check if EVENT has any timestamps in the past or today. +For all-day events, checks if the date is today or earlier." + (--any + (let ((timestamp-str (car it)) + (parsed-time (cdr it))) + (if parsed-time + ;; Timed event: check if time has passed + (time-less-p parsed-time (current-time)) + ;; All-day event: check if date is today or earlier + (when-let* ((parsed (org-parse-time-string timestamp-str)) + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-date (encode-time 0 0 0 day month year)) + (today-start (let ((now (decode-time (current-time)))) + (encode-time 0 0 0 + (decoded-time-day now) + (decoded-time-month now) + (decoded-time-year now))))) + (not (time-less-p today-start event-date)))))) + (cdr (assoc 'times event)))) + +(defun chime-event-is-today (event) + "Check if EVENT has any timestamps that are specifically today (not past days). +For all-day events, checks if the date is exactly today. +For timed events, checks if the time is today (past or future)." + (--any + (let ((timestamp-str (car it)) + (parsed-time (cdr it))) + (if parsed-time + ;; Timed event: check if it's today (could be future time today) + (let* ((decoded (decode-time parsed-time)) + (event-day (decoded-time-day decoded)) + (event-month (decoded-time-month decoded)) + (event-year (decoded-time-year decoded)) + (today (decode-time)) + (today-day (decoded-time-day today)) + (today-month (decoded-time-month today)) + (today-year (decoded-time-year today))) + (and (= event-day today-day) + (= event-month today-month) + (= event-year today-year))) + ;; All-day event: check if date is exactly today + (when-let* ((parsed (org-parse-time-string timestamp-str)) + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-date (encode-time 0 0 0 day month year)) + (today-start (let ((now (decode-time (current-time)))) + (encode-time 0 0 0 + (decoded-time-day now) + (decoded-time-month now) + (decoded-time-year now))))) + (time-equal-p event-date today-start))))) + (cdr (assoc 'times event)))) + +(defun chime--day-wide-notification-text (event) + "Generate notification text for day-wide EVENT. +Handles both same-day events and advance notices." + (let* ((title (cdr (assoc 'title event))) + (all-times (cdr (assoc 'times event))) + (is-today (chime-event-has-any-passed-time event)) + (is-advance-notice (and chime-day-wide-advance-notice + (chime-event-within-advance-notice-window event)))) + (cond + ;; Event is today + (is-today + (format "%s is due or scheduled today" title)) + ;; Event is within advance notice window + (is-advance-notice + ;; Calculate days until event + (let* ((now (current-time)) + (days-until + (-min + (--map + (when-let* ((timestamp-str (car it)) + (is-all-day (not (chime--has-timestamp timestamp-str))) + (parsed (org-parse-time-string timestamp-str)) + ;; Use nth accessors for Emacs 26 compatibility + (year (nth 5 parsed)) + (month (nth 4 parsed)) + (day (nth 3 parsed))) + (let* ((event-time (encode-time 0 0 0 day month year)) + (seconds-until (time-subtract event-time now)) + (days (/ (float-time seconds-until) 86400.0))) + (ceiling days))) + all-times)))) + (cond + ((= days-until 1) + (format "%s is tomorrow" title)) + ((= days-until 2) + (format "%s is in 2 days" title)) + (t + (format "%s is in %d days" title days-until))))) + ;; Fallback (shouldn't happen) + (t + (format "%s is due or scheduled today" title))))) + +(defun chime--check-event (event) + "Get notifications for given EVENT. +Returns a list of (MESSAGE . SEVERITY) cons cells." + (->> (chime--notifications event) + (--map (let* ((notif it) + (timestamp-str (caar notif)) + (interval-cons (cadr notif)) ; (minutes . severity) + (severity (cdr interval-cons)) + (message (chime--notification-text + `(,timestamp-str . ,interval-cons) + event))) + (cons message severity))))) + +(defun chime--jump-to-event (event) + "Jump to EVENT's org entry in its file. +Reconstructs marker from serialized file path and position." + (interactive) + (when-let* ((file (cdr (assoc 'marker-file event))) + (pos (cdr (assoc 'marker-pos event)))) + (when (file-exists-p file) + (find-file file) + (goto-char pos) + ;; Use org-fold-show-entry (Org 9.6+) if available, fallback to org-show-entry + (if (fboundp 'org-fold-show-entry) + (org-fold-show-entry) + (with-no-warnings + (org-show-entry)))))) + +(defun chime--open-calendar-url () + "Open calendar URL in browser if `chime-calendar-url' is set." + (interactive) + (when chime-calendar-url + (browse-url chime-calendar-url))) + +(defun chime--jump-to-first-event () + "Jump to first event in `chime--upcoming-events' list." + (interactive) + (when-let* ((first-event (car chime--upcoming-events)) + (event (car first-event))) + (chime--jump-to-event event))) + +(defun chime--format-event-for-tooltip (event-time-str minutes-until title) + "Format a single event line for tooltip display. +EVENT-TIME-STR is the time string, MINUTES-UNTIL is minutes until event, +TITLE is the event title." + (let ((time-display (chime--get-hh-mm-from-org-time-string event-time-str)) + (countdown (cond + ((< minutes-until 1440) ;; Less than 24 hours + (format "(%s)" (chime--time-left (* minutes-until 60)))) + (t + ;; 24+ hours: show days and hours + (let* ((days (truncate (/ minutes-until 1440))) + (remaining-minutes (truncate (mod minutes-until 1440))) + (hours (truncate (/ remaining-minutes 60)))) + (if (> hours 0) + (format "(in %d day%s %d hour%s)" + days (if (= days 1) "" "s") + hours (if (= hours 1) "" "s")) + (format "(in %d day%s)" + days (if (= days 1) "" "s")))))))) + (format "%s at %s %s" title time-display countdown))) + +(defun chime--group-events-by-day (upcoming-events) + "Group UPCOMING-EVENTS by day. +Returns an alist of (DATE-STRING . EVENTS-LIST)." + (let ((grouped '()) + (now (current-time))) + (dolist (item upcoming-events) + (let* ((event-time (cdr (nth 1 item))) + (_minutes-until (nth 2 item)) + ;; Get date components for calendar day comparison + (now-decoded (decode-time now)) + (event-decoded (decode-time event-time))) + (when event-decoded + (let* ((now-day (decoded-time-day now-decoded)) + (now-month (decoded-time-month now-decoded)) + (now-year (decoded-time-year now-decoded)) + (event-day (decoded-time-day event-decoded)) + (event-month (decoded-time-month event-decoded)) + (event-year (decoded-time-year event-decoded)) + ;; Check if same calendar day (not just < 24 hours) + (same-day-p (and (= now-day event-day) + (= now-month event-month) + (= now-year event-year))) + ;; Check if tomorrow (next calendar day) + (tomorrow-decoded (decode-time (time-add now (days-to-time 1)))) + (tomorrow-p (and (= event-day (decoded-time-day tomorrow-decoded)) + (= event-month (decoded-time-month tomorrow-decoded)) + (= event-year (decoded-time-year tomorrow-decoded)))) + (date-string (cond + (same-day-p + (format-time-string "Today, %b %d" now)) + (tomorrow-p + (format-time-string "Tomorrow, %b %d" + (time-add now (days-to-time 1)))) + (t ;; Future days + (format-time-string "%A, %b %d" event-time))))) + (let ((day-group (assoc date-string grouped))) + (if day-group + (setcdr day-group (append (cdr day-group) (list item))) + (push (cons date-string (list item)) grouped))))))) + (nreverse grouped))) + +(defun chime--make-tooltip (upcoming-events) + "Generate tooltip text showing UPCOMING-EVENTS grouped by day." + (if (null upcoming-events) + nil + (let* ((max-events (or chime-modeline-tooltip-max-events (length upcoming-events))) + (events-to-show (seq-take upcoming-events max-events)) + (remaining (- (length upcoming-events) (length events-to-show))) + (grouped (chime--group-events-by-day events-to-show)) + (header (concat (format-time-string chime-tooltip-header-format) "\n")) + (lines (list header))) + ;; Build tooltip text + (dolist (day-group grouped) + (let ((date-str (car day-group)) + (day-events (cdr day-group))) + (push (format "\n%s:\n" date-str) lines) + (push "─────────────\n" lines) + (dolist (item day-events) + (let* ((event (car item)) + (event-time-str (car (nth 1 item))) + (minutes-until (nth 2 item)) + (title (cdr (assoc 'title event)))) + (push (format "%s\n" + (chime--format-event-for-tooltip + event-time-str minutes-until title)) + lines))))) + ;; Add "... and N more" if needed + (when (> remaining 0) + (push (format "\n... and %d more event%s" + remaining + (if (> remaining 1) "s" "")) + lines)) + (apply #'concat (nreverse lines))))) + +(defun chime--make-no-events-tooltip (lookahead-minutes) + "Generate tooltip text when no events exist within LOOKAHEAD-MINUTES." + (let* ((hours (/ lookahead-minutes 60)) + (days (/ hours 24)) + (timeframe (cond + ((>= days 7) (format "%d days" days)) + ((>= hours 24) (format "%.1f days" (/ hours 24.0))) + ((>= hours 1) (format "%d hours" hours)) + (t (format "%d minutes" lookahead-minutes)))) + (header (format-time-string chime-tooltip-header-format)) + (increase-var "chime-tooltip-lookahead-hours")) + (concat header "\n" + "─────────────────────\n" + (format "No calendar events in\nthe next %s.\n\n" timeframe) + (format "Increase `%s`\nto expand scope.\n\n" increase-var) + "Left-click: Open calendar"))) + +(defun chime--propertize-modeline-string (text soonest-event) + "Add tooltip and click handlers to modeline TEXT for SOONEST-EVENT. +Left-click opens calendar URL (if set), right-click jumps to event." + (if (null chime--upcoming-events) + text + (let ((map (make-sparse-keymap)) + (tooltip (chime--make-tooltip chime--upcoming-events))) + ;; Left-click: open calendar URL + (define-key map [mode-line mouse-1] #'chime--open-calendar-url) + ;; Right-click: jump to event + (define-key map [mode-line mouse-3] + (lambda () + (interactive) + (chime--jump-to-event soonest-event))) + (propertize text + 'help-echo tooltip + 'mouse-face 'mode-line-highlight + 'local-map map)))) + +(defun chime--deduplicate-events-by-title (upcoming-events) + "Deduplicate UPCOMING-EVENTS by title, keeping soonest occurrence. + +UPCOMING-EVENTS should be a list where each element is +\(EVENT TIME-INFO MINUTES). +Returns a new list with only the soonest occurrence of each +unique title. + +This prevents recurring events from appearing multiple times in +the tooltip when `org-agenda-list' expands them into separate +event objects." + (let ((title-hash (make-hash-table :test 'equal))) + (dolist (item upcoming-events) + (let* ((event (car item)) + (title (cdr (assoc 'title event))) + (minutes (caddr item)) + (existing (gethash title title-hash))) + ;; Only keep if this is the first occurrence or soonest so far + (when (or (not existing) + (< minutes (caddr existing))) + (puthash title item title-hash)))) + (hash-table-values title-hash))) + +(defun chime--find-soonest-time-in-window (times now lookahead-minutes) + "Find soonest time from TIMES list within LOOKAHEAD-MINUTES from NOW. +TIMES is a list of (TIME-STRING . TIME-OBJECT) cons cells. +Returns (TIME-STRING . TIME-OBJECT MINUTES-UNTIL) or nil if none found." + (let ((soonest-time-info nil) + (soonest-minutes nil)) + (dolist (time-info times) + (when-let* ((time-str (car time-info)) + (event-time (cdr time-info)) + (seconds-until (- (float-time event-time) (float-time now))) + (minutes-until (/ seconds-until 60))) + (when (and (> minutes-until 0) + (<= minutes-until lookahead-minutes)) + (when (or (not soonest-minutes) + (< minutes-until soonest-minutes)) + (setq soonest-minutes minutes-until) + (setq soonest-time-info time-info))))) + (when soonest-time-info + (list (car soonest-time-info) (cdr soonest-time-info) soonest-minutes)))) + +(defun chime--build-upcoming-events-list (events now tooltip-lookahead-minutes show-all-day-p) + "Build list of upcoming events within TOOLTIP-LOOKAHEAD-MINUTES from NOW. +EVENTS is the list of events to process. +If SHOW-ALL-DAY-P is non-nil, include all-day events in the list. +Returns sorted, deduplicated list of (EVENT TIME-INFO MINUTES-UNTIL) tuples." + (let ((upcoming '())) + ;; Collect events with their soonest timestamp within tooltip window + (dolist (event events) + (let* ((all-times (cdr (assoc 'times event))) + (times-for-tooltip (if show-all-day-p + all-times + (chime--filter-day-wide-events all-times))) + (soonest (chime--find-soonest-time-in-window + times-for-tooltip now tooltip-lookahead-minutes))) + (when soonest + (push (list event (cons (nth 0 soonest) (nth 1 soonest)) (nth 2 soonest)) + upcoming)))) + ;; Sort by time (soonest first) + (setq upcoming (sort upcoming (lambda (a b) (< (nth 2 a) (nth 2 b))))) + ;; Deduplicate by title - keep only soonest occurrence + (setq upcoming (chime--deduplicate-events-by-title upcoming)) + ;; Re-sort after deduplication + (sort upcoming (lambda (a b) (< (nth 2 a) (nth 2 b)))))) + +(defun chime--find-soonest-modeline-event (events now modeline-lookahead-minutes) + "Find soonest timed event for modeline from EVENTS within MODELINE-LOOKAHEAD-MINUTES. +NOW is the current time. +Returns (EVENT TIME-STR MINUTES-UNTIL EVENT-TEXT) or nil if none found." + (let ((soonest-event nil) + (soonest-event-text nil) + (soonest-minutes nil) + (soonest-time-info nil)) + (dolist (event events) + (let* ((all-times (cdr (assoc 'times event))) + ;; Always filter all-day events for modeline (need specific time) + (times-for-modeline (chime--filter-day-wide-events all-times)) + (soonest (chime--find-soonest-time-in-window + times-for-modeline now modeline-lookahead-minutes))) + (when soonest + (let ((minutes (nth 2 soonest))) + (when (or (not soonest-minutes) + (< minutes soonest-minutes)) + (setq soonest-minutes minutes) + (setq soonest-event event) + (setq soonest-time-info (cons (nth 0 soonest) (nth 1 soonest))) + (setq soonest-event-text + (chime--notification-text + `(,(car soonest-time-info) . (,soonest-minutes . medium)) + event))))))) + (when soonest-event + (list soonest-event (car soonest-time-info) soonest-minutes soonest-event-text)))) + +(defun chime--update-modeline (events) + "Update modeline with next upcoming event from EVENTS. +Orchestrates filtering, finding soonest event, and updating display. +Shows soonest event within `chime-modeline-lookahead-minutes' in modeline. +Tooltip shows events within `chime-tooltip-lookahead-hours' hours." + (if (or (not chime-enable-modeline) + (not chime-modeline-lookahead-minutes) + (zerop chime-modeline-lookahead-minutes)) + (progn + (setq chime-modeline-string nil) + (setq chime--upcoming-events nil)) + (let* ((now (current-time)) + (tooltip-lookahead-minutes (if chime-tooltip-lookahead-hours + (* chime-tooltip-lookahead-hours 60) + chime-modeline-lookahead-minutes)) + ;; Build list of upcoming events for tooltip + (upcoming (chime--build-upcoming-events-list + events now tooltip-lookahead-minutes + chime-tooltip-show-all-day-events)) + ;; Find soonest event for modeline display + (soonest-modeline (chime--find-soonest-modeline-event + events now chime-modeline-lookahead-minutes))) + ;; Store upcoming events for tooltip + (setq chime--upcoming-events upcoming) + ;; Format and set modeline string + (setq chime-modeline-string + (if soonest-modeline + ;; Show soonest event in modeline + (chime--propertize-modeline-string + (format chime-modeline-format (nth 3 soonest-modeline)) + (nth 0 soonest-modeline)) + ;; Show icon when no event in modeline window + (when chime-modeline-no-events-text + (let ((map (make-sparse-keymap)) + (tooltip-text (if upcoming + (chime--make-tooltip upcoming) + (chime--make-no-events-tooltip tooltip-lookahead-minutes)))) + (define-key map [mode-line mouse-1] #'chime--open-calendar-url) + (when upcoming + (define-key map [mode-line mouse-3] #'chime--jump-to-first-event)) + (propertize chime-modeline-no-events-text + 'help-echo tooltip-text + 'mouse-face 'mode-line-highlight + 'local-map map))))) + ;; Force update ALL windows/modelines + (force-mode-line-update t)))) + +(defun chime--get-tags (marker) + "Retrieve tags of MARKER." + (-> (org-entry-get marker "TAGS") + (or "") + (org-split-string ":"))) + +(defun chime--whitelist-predicates () + "Return list of whitelist predicate functions. +Combines keyword, tag, and custom predicate whitelists." + (->> `([,chime-keyword-whitelist + (lambda (it) + (-contains-p chime-keyword-whitelist + (org-with-point-at it (org-get-todo-state))))] + + [,chime-tags-whitelist + (lambda (it) + (-intersection chime-tags-whitelist + (chime--get-tags it)))] + + [,chime-predicate-whitelist + (lambda (marker) + (--some? (funcall it marker) chime-predicate-whitelist))]) + (--filter (aref it 0)) + (--map (aref it 1)))) + +(defun chime--blacklist-predicates () + "Return list of blacklist predicate functions. +Combines keyword, tag, and custom predicate blacklists." + (->> `([,chime-keyword-blacklist + (lambda (it) + (-contains-p chime-keyword-blacklist + (org-with-point-at it (org-get-todo-state))))] + + [,chime-tags-blacklist + (lambda (it) + (-intersection chime-tags-blacklist + (chime--get-tags it)))] + + [,chime-predicate-blacklist + (lambda (marker) + (--some? (funcall it marker) chime-predicate-blacklist))]) + (--filter (aref it 0)) + (--map (aref it 1)))) + +(defun chime-done-keywords-predicate (marker) + "Check if entry at MARKER has a done keyword." + (with-current-buffer (marker-buffer marker) + (save-excursion + (goto-char (marker-position marker)) + (member (nth 2 (org-heading-components)) org-done-keywords)))) + +(defun chime--apply-whitelist (markers) + "Apply whitelist to MARKERS." + (-if-let (whitelist-predicates (chime--whitelist-predicates)) + (-> (apply '-orfn whitelist-predicates) + (-filter markers)) + markers)) + +(defun chime--apply-blacklist (markers) + "Apply blacklist to MARKERS." + (-if-let (blacklist-predicates (chime--blacklist-predicates)) + (-> (apply '-orfn blacklist-predicates) + (-remove markers)) + markers)) + +(defconst chime-default-environment-regex + (macroexpand + `(rx string-start + (or ,@(mapcar (lambda (literal) (list 'literal literal)) + (list + "org-agenda-files" + "load-path" + "org-todo-keywords" + "chime-alert-intervals" + "chime-keyword-whitelist" + "chime-keyword-blacklist" + "chime-tags-whitelist" + "chime-tags-blacklist" + "chime-predicate-whitelist" + "chime-predicate-blacklist"))) + string-end))) + + +(defun chime-environment-regex () + "Generate regex for environment variables to inject into async process." + (macroexpand + `(rx (or + ,@(mapcar (lambda (regexp) (list 'regexp regexp)) + (cons chime-default-environment-regex + chime-additional-environment-regexes)))))) + +(defun chime--retrieve-events () + "Get events from agenda view." + `(lambda () + (setf org-agenda-use-time-grid nil) + (setf org-agenda-compact-blocks t) + ,(async-inject-variables (chime-environment-regex)) + + (package-initialize) + (require 'chime) + + ;; Load optional dependencies for org-mode diary sexps + ;; Many users have sexp entries like %%(org-contacts-anniversaries) in their + ;; org files, which generate dynamic agenda entries. These sexps are evaluated + ;; when org-agenda-list runs, so the required packages must be loaded in this + ;; async subprocess. We use (require ... nil t) to avoid errors if packages + ;; aren't installed - the sexp will simply fail gracefully with a "Bad sexp" + ;; warning that won't break event retrieval. + (require 'org-contacts nil t) + + ;; Calculate agenda span based on max lookahead (convert to days, round up) + ;; Use the larger of modeline-lookahead (minutes) and tooltip-lookahead (hours) to ensure + ;; we fetch enough events for both. Add 1 day buffer to account for partial days. + (let* ((tooltip-lookahead-minutes (if chime-tooltip-lookahead-hours + (* chime-tooltip-lookahead-hours 60) + chime-modeline-lookahead-minutes)) + (max-lookahead-minutes (max chime-modeline-lookahead-minutes tooltip-lookahead-minutes)) + (max-lookahead-days (ceiling (/ max-lookahead-minutes 1440.0))) + (agenda-span (+ max-lookahead-days 1))) + (org-agenda-list agenda-span (org-read-date nil nil "today"))) + + (->> (org-split-string (buffer-string) "\n") + (--map (plist-get + (org-fix-agenda-info (text-properties-at 0 it)) + 'org-marker)) + (-non-nil) + (chime--apply-whitelist) + (chime--apply-blacklist) + (-map 'chime--gather-info)))) + +(defun chime--notify (msg-severity) + "Notify about an event using `alert' library. +MSG-SEVERITY is a cons cell (MESSAGE . SEVERITY) where MESSAGE is the +notification text and SEVERITY is one of high, medium, or low." + (let* ((event-msg (if (consp msg-severity) (car msg-severity) msg-severity)) + (severity (if (consp msg-severity) (cdr msg-severity) 'medium))) + ;; Play sound if enabled and sound file is specified + (when (and chime-play-sound chime-sound-file) + (condition-case err + (when (file-exists-p chime-sound-file) + (play-sound-file chime-sound-file)) + (error + (message "chime: Failed to play sound: %s" + (error-message-string err))))) + ;; Show visual notification + (apply + 'alert event-msg + :icon chime-notification-icon + :title chime-notification-title + :severity severity + :category 'chime + chime-extra-alert-plist))) + +(defun chime--convert-12hour-to-24hour (timestamp hour) + "Convert HOUR from 12-hour to 24-hour format based on TIMESTAMP's am/pm suffix. +TIMESTAMP is the original timestamp string (e.g., \"<2025-11-05 Wed 1:30pm>\"). +HOUR is the hour value from org-parse-time-string (1-12 for 12-hour format). + +Returns converted hour in 24-hour format (0-23): +- 12pm → 12 (noon) +- 1-11pm → 13-23 (add 12) +- 12am → 0 (midnight) +- 1-11am → 1-11 (no change) +- No am/pm → HOUR unchanged (24-hour format)" + (let ((is-pm (string-match-p "[0-9]:[0-9]\\{2\\}[[:space:]]*pm" (downcase timestamp))) + (is-am (string-match-p "[0-9]:[0-9]\\{2\\}[[:space:]]*am" (downcase timestamp)))) + (cond + ;; 12pm = 12:00 (noon), don't add 12 + ((and is-pm (= hour 12)) 12) + ;; 1-11pm: add 12 to get 13-23 + (is-pm (+ hour 12)) + ;; 12am = 00:00 (midnight) + ((and is-am (= hour 12)) 0) + ;; 1-11am or 24-hour format: use as-is + (t hour)))) + +(defun chime--timestamp-parse (timestamp) + "Parse TIMESTAMP and return time in list-of-integer format. +Returns nil if parsing fails or timestamp is malformed." + (condition-case err + (when (and timestamp + (stringp timestamp) + (not (string-empty-p timestamp)) + ;; Validate angle bracket format + (string-match-p "<.*>" timestamp) + ;; Ensure timestamp has time component (HH:MM format) + (string-match-p "[0-9]\\{1,2\\}:[0-9]\\{2\\}" timestamp)) + (let ((parsed (org-parse-time-string timestamp)) + (today (format-time-string "<%Y-%m-%d>"))) + (when (and parsed + (decoded-time-hour parsed) + (decoded-time-minute parsed)) + ;; Validate date components are in reasonable ranges + (let* ((month (decoded-time-month parsed)) + (day (decoded-time-day parsed)) + (raw-hour (decoded-time-hour parsed)) + (minute (decoded-time-minute parsed)) + ;; Convert 12-hour am/pm format to 24-hour format + (hour (chime--convert-12hour-to-24hour timestamp raw-hour))) + (when (and month day hour minute + (>= month 1) (<= month 12) + (>= day 1) (<= day 31) + (>= hour 0) (<= hour 23) + (>= minute 0) (<= minute 59)) + ;; seconds-to-time returns also milliseconds and nanoseconds so we + ;; have to "trim" the list + (butlast + (seconds-to-time + (time-add + ;; we get the cycled absolute day (not hour and minutes) + (org-time-from-absolute (org-closest-date timestamp today 'past)) + ;; so we have to add the minutes too + (+ (* hour 3600) + (* minute 60)))) + 2)))))) + (error + (message "chime: Failed to parse timestamp '%s': %s" + timestamp (error-message-string err)) + nil))) + +(defun chime--extract-time (marker) + "Extract timestamps from MARKER using source-aware extraction. + +For org-gcal events (those with :entry-id: property): + - Extract ONLY from :org-gcal: drawer + (ignores SCHEDULED/DEADLINE and body text) + - This prevents showing stale timestamps after rescheduling + +For regular org events: + - Prefer SCHEDULED and DEADLINE from properties + - Fall back to plain timestamps in entry body + +Timestamps are extracted as cons cells: +\(org-formatted-string . parsed-time)." + (org-with-point-at marker + (let ((is-gcal-event (org-entry-get marker "entry-id"))) + (if is-gcal-event + ;; org-gcal event: extract ONLY from :org-gcal: drawer + (let ((timestamps nil)) + (save-excursion + (org-back-to-heading t) + ;; Search for :org-gcal: drawer + (when (re-search-forward "^[ \t]*:org-gcal:" + (save-excursion (org-end-of-subtree t) (point)) + t) + (let ((drawer-start (point)) + (drawer-end (save-excursion + (if (re-search-forward "^[ \t]*:END:" + (save-excursion (org-end-of-subtree t) (point)) + t) + (match-beginning 0) + (point))))) + ;; Extract timestamps within drawer boundaries + (goto-char drawer-start) + (while (re-search-forward org-ts-regexp drawer-end t) + (let ((timestamp-str (match-string 0))) + (push (cons timestamp-str + (chime--timestamp-parse timestamp-str)) + timestamps)))))) + (-non-nil (nreverse timestamps))) + ;; Regular org event: prefer SCHEDULED/DEADLINE, fall back to plain timestamps + (let ((property-timestamps + ;; Extract SCHEDULED and DEADLINE from properties + (-non-nil + (--map + (let ((org-timestamp (org-entry-get marker it))) + (and org-timestamp + (cons org-timestamp + (chime--timestamp-parse org-timestamp)))) + '("DEADLINE" "SCHEDULED")))) + (plain-timestamps + ;; Extract plain timestamps from entry body + ;; Skip planning lines (SCHEDULED, DEADLINE, CLOSED) to avoid duplicates + (let ((timestamps nil)) + (save-excursion + ;; Skip heading and planning lines, but NOT other drawers (nil arg) + (org-end-of-meta-data nil) + (let ((start (point)) + (end (save-excursion (org-end-of-subtree t) (point)))) + ;; Only search if there's content after metadata + (when (< start end) + (goto-char start) + ;; Search for timestamps until end of entry + (while (re-search-forward org-ts-regexp end t) + (let ((timestamp-str (match-string 0))) + (push (cons timestamp-str + (chime--timestamp-parse timestamp-str)) + timestamps)))))) + (nreverse timestamps)))) + ;; Combine property and plain timestamps, removing duplicates and nils + (-non-nil (append property-timestamps plain-timestamps))))))) + +(defun chime--sanitize-title (title) + "Sanitize TITLE to prevent Lisp read syntax errors during async serialization. +Balances unmatched parentheses, brackets, and braces by adding matching pairs. +Returns sanitized title or empty string if TITLE is nil." + (if (not title) + "" + (let ((chars (string-to-list title)) + (stack '()) ; Stack to track opening delimiters in order + (result '())) + ;; Process each character + (dolist (char chars) + (cond + ;; Opening delimiters - add to stack and result + ((memq char '(?\( ?\[ ?\{)) + (push char stack) + (push char result)) + ;; Closing delimiters - check if they match + ((eq char ?\)) + (if (and stack (eq (car stack) ?\()) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing paren - skip it + nil)) + ((eq char ?\]) + (if (and stack (eq (car stack) ?\[)) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing bracket - skip it + nil)) + ((eq char ?\}) + (if (and stack (eq (car stack) ?\{)) + (progn + (pop stack) + (push char result)) + ;; Unmatched closing brace - skip it + nil)) + ;; Regular characters - add to result + (t + (push char result)))) + ;; Add closing delimiters for any remaining opening delimiters + (dolist (opener stack) + (cond + ((eq opener ?\() (push ?\) result)) + ((eq opener ?\[) (push ?\] result)) + ((eq opener ?\{) (push ?\} result)))) + ;; Convert back to string (reverse because we built it backwards) + (concat (nreverse result))))) + +(defun chime--extract-title (marker) + "Extract event title from MARKER. +MARKER acts like the event's identifier. +Title is sanitized to prevent Lisp read syntax errors." + (org-with-point-at marker + (-let (((_lvl _reduced-lvl _todo _priority title _tags) + (org-heading-components))) + (chime--sanitize-title title)))) + +(defun chime--gather-info (marker) + "Collect information about an event. +MARKER acts like event's identifier. +Returns file path and position instead of marker object for proper +async serialization (markers can't be serialized across processes, +especially when buffer names contain angle brackets)." + `((times . ,(chime--extract-time marker)) + (title . ,(chime--extract-title marker)) + (intervals . ,chime-alert-intervals) + (marker-file . ,(buffer-file-name (marker-buffer marker))) + (marker-pos . ,(marker-position marker)))) + +;;;###autoload +(defun chime-validate-configuration () + "Validate chime's runtime environment and configuration. +Returns a list of (SEVERITY MESSAGE) pairs, or nil if all checks pass. +SEVERITY is one of: :error :warning :info + +Checks performed: +- org-agenda-files is set and non-empty +- org-agenda-files exist on disk +- org-agenda package is loadable +- global-mode-string available (for modeline display) + +When called interactively, displays results via message/warning system. +When called programmatically, returns structured validation results." + (interactive) + (let ((issues '())) + + ;; Critical: org-agenda-files must be set and non-empty + (unless (and (boundp 'org-agenda-files) + org-agenda-files + (listp org-agenda-files) + (> (length org-agenda-files) 0)) + (push '(:error "org-agenda-files is not set or empty.\nChime cannot check for events without org files to monitor.\n\nSet org-agenda-files in your config:\n (setq org-agenda-files '(\"~/org/inbox.org\" \"~/org/work.org\"))") + issues)) + + ;; Warning: Check if files actually exist + (when (and (boundp 'org-agenda-files) + org-agenda-files + (listp org-agenda-files)) + (let ((missing-files + (cl-remove-if #'file-exists-p org-agenda-files))) + (when missing-files + (push `(:warning ,(format "%d org-agenda-files don't exist:\n %s\n\nChime will skip these files during event checks." + (length missing-files) + (mapconcat #'identity missing-files "\n "))) + issues)))) + + ;; Check org-agenda is loadable + (unless (require 'org-agenda nil t) + (push '(:error "Cannot load org-agenda.\nEnsure org-mode is installed and available in load-path.") + issues)) + + ;; Check modeline support (if enabled) + (when (and chime-enable-modeline + (not (boundp 'global-mode-string))) + (push '(:warning "global-mode-string not available.\nModeline display may not work in this Emacs version.") + issues)) + + ;; Display results if interactive + (when (called-interactively-p 'any) + (if (null issues) + (message "Chime: ✓ All validation checks passed!") + ;; Show errors and warnings + (let ((errors (cl-remove-if-not (lambda (i) (eq (car i) :error)) issues)) + (warnings (cl-remove-if-not (lambda (i) (eq (car i) :warning)) issues))) + (when errors + (dolist (err errors) + (display-warning 'chime (cadr err) :error))) + (when warnings + (dolist (warn warnings) + (display-warning 'chime (cadr warn) :warning)))))) + + ;; Return issues for programmatic use + issues)) + +(defun chime--stop () + "Stop the notification timer and cancel any in-progress check." + (-some-> chime--timer (cancel-timer)) + (when chime--process + (interrupt-process chime--process) + (setq chime--process nil)) + ;; Reset validation state so it runs again on next start + (setq chime--validation-done nil) + (setq chime--validation-retry-count 0)) + +(defun chime--start () + "Start the notification timer. Cancel old one, if any. +Timer interval is controlled by `chime-check-interval'. +First check runs after `chime-startup-delay' seconds to allow +org-agenda-files to load. + +Configuration validation happens on the first `chime-check' call, +after the startup delay has elapsed. This gives startup hooks time +to populate org-agenda-files." + (chime--stop) + + ;; Wait chime-startup-delay seconds before first check + ;; This allows org-agenda-files and related infrastructure to finish loading + (when (featurep 'chime-debug) + (chime--log-silently "Chime: Scheduling first check in %d seconds" chime-startup-delay)) + + ;; Schedule repeating timer: first run at t=chime-startup-delay, then every chime-check-interval + (--> (run-at-time chime-startup-delay chime-check-interval 'chime-check) + (setf chime--timer it))) + +(defun chime--process-notifications (events) + "Process EVENTS and send notifications for upcoming items. +Handles both regular event notifications and day-wide alerts." + (-each + (->> events + (-map 'chime--check-event) + (-flatten) + (-uniq)) + 'chime--notify) + (when (chime-current-time-is-day-wide-time) + (mapc 'chime--notify + (chime-day-wide-notifications events)))) + +(defun chime--fetch-and-process (callback) + "Asynchronously fetch events from agenda and invoke CALLBACK with them. +Manages async process state and last-check-time internally. +Does nothing if a check is already in progress." + (unless (and chime--process + (process-live-p chime--process)) + (setq chime--process + (let ((default-directory user-emacs-directory) + (async-prompt-for-password nil) + (async-process-noquery-on-exit t)) + (async-start + (chime--retrieve-events) + (lambda (events) + (setq chime--process nil) + (setq chime--last-check-time (current-time)) + ;; Handle errors from async process + (condition-case err + (progn + ;; Check if events is an error signal from async process + (if (and (listp events) + (eq (car events) 'async-signal)) + (progn + ;; Async process returned an error + (when (featurep 'chime-debug) + (chime--debug-log-async-error (cdr events))) + (chime--log-silently "Chime: Async error: %s" + (error-message-string (cdr events))) + (message "Chime: Event check failed - see *Messages* for details")) + ;; Success - process events normally + (when (featurep 'chime-debug) + (chime--debug-log-async-complete events)) + (funcall callback events))) + (error + ;; Error occurred in callback processing + (when (featurep 'chime-debug) + (chime--debug-log-async-error err)) + (chime--log-silently "Chime: Error processing events: %s" + (error-message-string err)) + (message "Chime: Error processing events - see *Messages* for details"))))))))) + +(defun chime--log-silently (format-string &rest args) + "Append formatted message to *Messages* buffer without echoing. +FORMAT-STRING and ARGS are passed to `format'." + (let ((inhibit-read-only t)) + (with-current-buffer (get-buffer-create "*Messages*") + (goto-char (point-max)) + (unless (bolp) (insert "\n")) + (insert (apply #'format format-string args)) + (unless (bolp) (insert "\n"))))) + +;;;###autoload +(cl-defun chime-check () + "Parse agenda view and notify about upcoming events. + +Do nothing if a check is already in progress in the background. + +On the first call after `chime-mode' is enabled, validates the runtime +configuration. This happens after `chime-startup-delay', giving startup +hooks time to populate org-agenda-files. If validation fails, logs an +error and skips the check." + (interactive) + + ;; Validate configuration on first check only + (unless chime--validation-done + (let ((issues (chime-validate-configuration))) + (if (cl-some (lambda (i) (eq (car i) :error)) issues) + (progn + ;; Critical errors found - increment retry counter + (setq chime--validation-retry-count (1+ chime--validation-retry-count)) + + ;; Check if we've exceeded max retries + (if (> chime--validation-retry-count chime-validation-max-retries) + ;; Max retries exceeded - show full error + (let ((errors (cl-remove-if-not (lambda (i) (eq (car i) :error)) issues))) + (chime--log-silently "Chime: Configuration validation failed with %d error(s) after %d retries:" + (length errors) + chime--validation-retry-count) + (dolist (err errors) + (chime--log-silently "") + (chime--log-silently "ERROR: %s" (cadr err))) + (message "Chime: Configuration errors detected (see *Messages* buffer for details)")) + ;; Still within retry limit - show friendly waiting message + (message "Chime: Waiting for org-agenda-files to load... (attempt %d/%d)" + chime--validation-retry-count + chime-validation-max-retries)) + + ;; Don't mark validation as done - will retry on next check + ;; in case dependencies load later + ;; Don't proceed with check + (cl-return-from chime-check nil)) + ;; No errors - mark validation as done and reset retry counter + (setq chime--validation-done t) + (setq chime--validation-retry-count 0)))) + + ;; Validation passed or already done - proceed with check + (chime--fetch-and-process + (lambda (events) + (chime--process-notifications events) + (chime--update-modeline events)))) + +;;;###autoload +(defun chime-refresh-modeline () + "Update modeline display with latest events without sending notifications. + +Useful after external calendar sync operations (e.g., org-gcal-sync). +Does nothing if a check is already in progress in the background." + (interactive) + (chime--fetch-and-process + (lambda (events) + (chime--update-modeline events)))) + +;;;###autoload +(define-minor-mode chime-mode + "Toggle org notifications globally. +When enabled parses your agenda once a minute and emits notifications +if needed." + :global + :lighter chime-modeline-lighter + (if chime-mode + (progn + (chime--start) + ;; Add modeline string to global-mode-string + (when (and chime-enable-modeline + (> chime-modeline-lookahead-minutes 0)) + (if global-mode-string + (add-to-list 'global-mode-string 'chime-modeline-string 'append) + (setq global-mode-string '("" chime-modeline-string))))) + (progn + (chime--stop) + ;; Remove modeline string from global-mode-string + (setq global-mode-string + (delq 'chime-modeline-string global-mode-string)) + (setq chime-modeline-string nil) + ;; Force update ALL windows/modelines, not just current buffer + (force-mode-line-update t)))) + +;; Automatically enable debug features when debug mode is on +;; Only enable in the main Emacs process, not in async subprocesses. +;; We detect async context by checking if this is an interactive session. +;; Async child processes run in batch mode with noninteractive=t. +(when (and chime-debug + (not noninteractive)) + (chime-debug-monitor-event-loading) + (chime-debug-enable-async-monitoring)) + +(provide 'chime) +;;; chime.el ends here |
