;;; chime.el --- CHIME Heralds Imminent Modeline Events -*- lexical-binding: t -*- ;; Copyright (C) 2017 Artem Khramov ;; Copyright (C) 2024-2026 Craig Jennings ;; Current Author/Maintainer: Craig Jennings ;; Original Author: Artem Khramov ;; Created: 6 Jan 2017 ;; Version: 0.7.0 ;; Package-Requires: ((alert "1.2") (async "1.9.3") (dash "2.18.0") (emacs "27.1")) ;; Keywords: notification alert org org-agenda calendar ;; URL: https://github.com/cjennings/chime ;; 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: ;; CHIME (CHIME Heralds Imminent Modeline 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-include-filters' and ;; `chime-exclude-filters' alists (keys: keywords, tags, predicates). ;; ;; See README.org for complete documentation. ;;; Code: ;;;; Dependencies (require 'dash) (require 'alert) (require 'async) (require 'org-agenda) (require 'org-duration) (require 'cl-lib) (require 'subr-x) ;; Declare functions from chime-debug.el (loaded conditionally) (declare-function chime-debug-monitor-event-loading "chime-debug") (declare-function chime-debug-enable-async-monitoring "chime-debug") (declare-function chime--debug-log-async-error "chime-debug") (declare-function chime--debug-log-async-complete "chime-debug") ;;;; Customization Variables (defgroup chime nil "Chime customization options." :group 'org) (defun chime--validate-integer-setting (symbol value min allow-nil) "Reject bad integer values for SYMBOL at customize time. VALUE is what the user is trying to set. MIN is the inclusive floor. When ALLOW-NIL is non-nil, nil is accepted; otherwise nil fails like any other non-integer. Returns VALUE on success so the caller can chain into `set-default'. The error is a `user-error' so `customize-set-variable' surfaces it as a configuration problem rather than a generic error." (cond ((and allow-nil (null value)) value) ((not (integerp value)) (user-error "%s must be %s, got: %S" symbol (if allow-nil "nil or an integer" "an integer") value)) ((< value min) (user-error "%s must be >= %d, got: %d" symbol min value)) (t value))) (defun chime--validate-day-wide-alert-times (symbol value) "Reject bad day-wide alert time VALUE for SYMBOL at customize time. VALUE must be nil or a list of strings accepted by `org-get-time-of-day' as clock times within a single day. Returns VALUE on success." (unless (listp value) (user-error "%s must be nil or a list of time strings, got: %S" symbol value)) (dolist (time-string value) (unless (stringp time-string) (user-error "%s entries must be strings, got: %S" symbol time-string)) (let ((parsed-time (org-get-time-of-day time-string t))) (unless parsed-time (user-error "%s contains invalid time string: %S" symbol time-string)) (let ((minutes (org-duration-to-minutes parsed-time))) (unless (< -1 minutes 1440) (user-error "%s time must be between 00:00 and 23:59, got: %S" symbol time-string))))) value) (defcustom chime-alert-intervals '((10 . medium) (0 . high)) "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 '(choice (const :tag "No icon" nil) (file :tag "Icon file path"))) (defcustom chime-include-filters nil "Filters that INCLUDE events for notifications. An alist where each entry is (TYPE . VALUES): (keywords . (\"TODO\" \"MEETING\")) - org TODO keywords (tags . (\"work\" \"urgent\")) - org tags (predicates . (my-custom-predicate)) - functions taking a marker When nil, all events pass. When set, an event must match at least one listed value in any one filter type to pass." :package-version '(chime . "0.8.0") :group 'chime :type '(alist :key-type (choice (const keywords) (const tags) (const predicates)) :value-type (repeat sexp))) (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-formats '((at-event . "right now") (short . "in %M") (long . "in %H %M")) "Format strings for time-until-event display, keyed by regime. An alist with three keys: at-event - Literal string when event time has arrived (0 or negative seconds). No format codes. short - `format-seconds' template for times under 1 hour. long - `format-seconds' template for times 1 hour or longer. Available `format-seconds' codes for short/long: %m - minutes as number only (e.g., \"37\") %M - minutes with unit name (e.g., \"37 minutes\") %h - hours as number only (e.g., \"1\") %H - hours with unit name (e.g., \"1 hour\") Examples for short: \"in %M\" -> \"in 37 minutes\" \"in %mm\" -> \"in 37m\" \"%m min\" -> \"37 min\" Examples for long: \"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.8.0") :group 'chime :type '(alist :key-type (choice (const at-event) (const short) (const long)) :value-type string)) (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 '(repeat string)) (defcustom chime-exclude-filters '((predicates . (chime-done-keywords-predicate chime-declined-events-predicate))) "Filters that EXCLUDE events from notifications. Same structure as `chime-include-filters'. An event matching ANY value in ANY filter type is suppressed. Defaults to excluding entries with a done keyword (DONE, CANCELLED, etc. per `org-done-keywords') and Google Calendar invitations the user has declined." :package-version '(chime . "0.8.0") :group 'chime :type '(alist :key-type (choice (const keywords) (const tags) (const predicates)) :value-type (repeat sexp))) (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. Accepted formats are the Org time-of-day formats accepted by `org-get-time-of-day', including 24-hour strings like \"08:00\" and 12-hour strings like \"8:00am\". 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) :set (lambda (symbol value) (chime--validate-day-wide-alert-times symbol value) (set-default symbol value))) (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")) :set (lambda (symbol value) (chime--validate-integer-setting symbol value 0 t) (set-default symbol value))) (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 "" "Minor mode lighter shown in the modeline mode list. Empty by default because the event display in `global-mode-string' already indicates chime is active. Set to a string like \" Chime\" or \" 🔔\" if you want a separate mode indicator." :package-version '(chime . "0.7.0") :group 'chime :type 'string) (defcustom chime-modeline-lookahead-minutes 120 "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") :set (lambda (symbol value) (chime--validate-integer-setting symbol value 0 nil) (set-default symbol value))) (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 168 "Hours ahead to look for events in tooltip. Separate from modeline lookahead window. Default is 168 hours (1 week). The actual number of events shown is limited by `chime-modeline-tooltip-max-events'. Set to a larger value (e.g., 8760 for 1 year) to see distant events, or smaller (e.g., 24) for just today and tomorrow. Note: larger values increase the `org-agenda-list' span in the async subprocess, which may slow event checks for large org collections." :package-version '(chime . "0.6.0") :group 'chime :type '(integer :tag "Hours") :set (lambda (symbol value) (chime--validate-integer-setting symbol value 1 nil) (set-default symbol value))) (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)) :set (lambda (symbol value) (chime--validate-integer-setting symbol value 1 t) (set-default symbol value))) (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-tooltip-event-format "%t at %T %u" "Format string for one event line in the tooltip. Available placeholders: %t - Event title %T - Event time, formatted per `chime-display-time-format-string' %u - Time until event, wrapped in parentheses by default" :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-today-label "Today" "Relative day label for today's events in the tooltip." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-tomorrow-label "Tomorrow" "Relative day label for tomorrow's events in the tooltip." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-relative-day-format "%s, %b %d" "Format string for today/tomorrow tooltip section labels. The first `%s' is replaced with `chime-tooltip-today-label' or `chime-tooltip-tomorrow-label'. Other format codes are passed to `format-time-string'." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-future-day-format "%A, %b %d" "Format string for non-relative tooltip section labels. Passed to `format-time-string'." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-section-separator "─────────────" "Separator text inserted below each tooltip day section heading." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-no-events-separator "─────────────────────" "Separator text inserted below the no-events tooltip header." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-more-events-format "... and %d more event%s" "Format string for the tooltip overflow line. The first format argument is the remaining event count. The second is the English plural suffix, either \"\" or \"s\", for backward-compatible defaults." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-countdown-wrapper "(%s)" "Format string that wraps tooltip countdown text." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-countdown-prefix "in" "Prefix used by tooltip-specific day/hour countdown text." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-day-unit-labels '("day" . "days") "Singular and plural day unit labels for tooltip countdown text." :package-version '(chime . "0.8.0") :group 'chime :type '(cons (string :tag "Singular") (string :tag "Plural"))) (defcustom chime-tooltip-hour-unit-labels '("hour" . "hours") "Singular and plural hour unit labels for tooltip countdown text." :package-version '(chime . "0.8.0") :group 'chime :type '(cons (string :tag "Singular") (string :tag "Plural"))) (defcustom chime-tooltip-no-events-format "No calendar events in\nthe next %s." "Format string for no-events tooltip body. The single format argument is the lookahead timeframe." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-increase-lookahead-format "Increase `%s`\nto expand scope." "Format string for no-events tooltip lookahead guidance. The single format argument is the option name to customize." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-tooltip-left-click-label "Left-click: Open calendar" "Tooltip text describing the left-click calendar action." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validating-message "Chime: Validating configuration..." "Banner printed to *Messages* when validation runs interactively." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validation-summary-format "Chime: %d error%s, %d warning%s." "Format string for the validation summary line in *Messages*. Receives four arguments: error count, error plural suffix, warning count, warning plural suffix." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-async-failure-tooltip "Event check failed — check *Messages* buffer" "Modeline tooltip shown when an async event check fails." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validation-errors-message "Chime: Configuration errors detected (see *Messages* buffer for details)" "Banner printed to *Messages* after validation has exhausted retries." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validation-error-tooltip "Configuration error — check *Messages* buffer" "Modeline tooltip shown after validation has exhausted retries." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validation-waiting-message-format "Chime: Waiting for org-agenda-files to load... (attempt %d/%d)" "Format string for the validation-retry banner in *Messages*. Receives two arguments: current attempt number and the configured maximum." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-validation-waiting-tooltip-format "Waiting for org-agenda-files... (attempt %d/%d)" "Format string for the validation-retry modeline tooltip. Receives two arguments: current attempt number and the configured maximum." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-modeline-initial-tooltip "Chime: waiting for first event check..." "Modeline tooltip shown before the first event check completes." :package-version '(chime . "0.8.0") :group 'chime :type 'string) (defcustom chime-sound-file (expand-file-name "sounds/chime.wav" (file-name-directory (or load-file-name (locate-library "chime") 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-max-consecutive-failures 5 "Number of consecutive async failures before displaying a warning. When event checks fail this many times in a row, a warning is shown via `display-warning'. The counter resets on any successful check. Set to 0 to disable failure warnings." :package-version '(chime . "0.6.0") :group 'chime :type 'integer :set (lambda (symbol value) (chime--validate-integer-setting symbol value 0 nil) (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)) ;; The optional org-contacts integration in chime-org-contacts.el is loaded ;; by the user, not by chime itself. See the README for the recommended ;; `(with-eval-after-load 'org-capture (require 'chime-org-contacts))' or ;; `use-package chime-org-contacts :after org-capture' setup. ;;;; Internal State (defvar chime--timer nil "Timer value.") (defvar chime--process nil "Currently-running async process.") (defvar chime--consecutive-async-failures 0 "Count of consecutive async check failures. After `chime-max-consecutive-failures' failures, a warning is displayed.") (defvar chime--deprecated-property-warned nil "Non-nil once the deprecated per-event-property warning has fired this session. The warning is shown at most once, so this stays set until Emacs restarts.") (defvar chime--last-check-time (seconds-to-time 0) "Last time checked for events.") (defvar chime--upcoming-events nil "Cached tooltip event tuples for the current modeline state. Each element has the shape (EVENT TIME-INFO MINUTES-UNTIL), where EVENT is the internal event alist documented by `chime--valid-event-p', TIME-INFO is (TIMESTAMP-STRING . PARSED-TIME), and MINUTES-UNTIL is the numeric offset from the last modeline refresh time.") (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.") (defvar 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 or this retry limit is exceeded. This is internal because no user has reason to tune the defensive startup-timing window through Customize. If you need to override the default for a specific environment, `setq' the variable in your init.") (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) ;;;; Event Data Contract ;; Internal events are serialized through async.el, so they intentionally use a ;; plain alist instead of markers or structs. Keep all production event ;; construction funneled through `chime--make-event' so the shape stays explicit. ;; ;; Example: ;; ((times . (("<2026-05-10 Sun 09:30>" . (26760 32460)))) ;; (title . "Planning") ;; (intervals . ((10 . medium) (0 . high))) ;; (marker-file . "/path/to/agenda.org") ;; (marker-pos . 1234)) ;; ;; When a heading sets its intervals through a deprecated per-event property, ;; the event also carries a `deprecated-property' key naming it, so the parent ;; process can warn the user once. (defconst chime--event-required-keys '(times title intervals) "Required keys for internal Chime event alists.") (defun chime--event-times (event) "Return EVENT's timestamp entries. Each entry is (TIMESTAMP-STRING . PARSED-TIME). PARSED-TIME is nil for all-day timestamps." (cdr (assoc 'times event))) (defun chime--event-title (event) "Return EVENT's display title." (cdr (assoc 'title event))) (defun chime--event-intervals (event) "Return EVENT's alert intervals." (cdr (assoc 'intervals event))) (defun chime--event-marker-file (event) "Return EVENT's source org file path, or nil for synthesized events." (cdr (assoc 'marker-file event))) (defun chime--event-marker-pos (event) "Return EVENT's source buffer position, or nil for synthesized events." (cdr (assoc 'marker-pos event))) (defun chime--event-deprecated-property (event) "Return the deprecated property name that set EVENT's intervals, or nil." (cdr (assoc 'deprecated-property event))) (defun chime--event-time-entry-p (entry) "Return non-nil when ENTRY matches Chime's timestamp entry contract." (and (consp entry) (stringp (car entry)) (let ((time-value (cdr entry))) (or (null time-value) (listp time-value) (numberp time-value))))) (defun chime--event-interval-entry-p (entry) "Return non-nil when ENTRY matches Chime's alert interval contract." (and (consp entry) (integerp (car entry)) (<= 0 (car entry)) (memq (cdr entry) '(high medium low)))) (defun chime--valid-event-p (event) "Return non-nil when EVENT follows Chime's internal event alist contract. The canonical event alist has these keys: - `times': list of (TIMESTAMP-STRING . PARSED-TIME) entries - `title': sanitized display string - `intervals': list of (MINUTES . SEVERITY) alert intervals - `marker-file': optional source org file path - `marker-pos': optional source buffer position `marker-file' and `marker-pos' are stored instead of marker objects so events can cross the async process boundary." (and (listp event) (--all? (assoc it event) chime--event-required-keys) (listp (chime--event-times event)) (--all? (chime--event-time-entry-p it) (chime--event-times event)) (stringp (chime--event-title event)) (listp (chime--event-intervals event)) (--all? (chime--event-interval-entry-p it) (chime--event-intervals event)) (let ((marker-file (chime--event-marker-file event)) (marker-pos (chime--event-marker-pos event))) (and (or (null marker-file) (stringp marker-file)) (or (null marker-pos) (integerp marker-pos)))))) (defun chime--make-event (times title intervals &optional marker-file marker-pos deprecated-property) "Create an internal Chime event alist. TIMES, TITLE, INTERVALS, MARKER-FILE, and MARKER-POS follow the contract documented by `chime--valid-event-p'. DEPRECATED-PROPERTY, when non-nil, is the name of a deprecated per-event property that supplied INTERVALS; it is carried on the event so the parent process can warn the user once." (let ((event `((times . ,times) (title . ,title) (intervals . ,intervals) (marker-file . ,marker-file) (marker-pos . ,marker-pos) ,@(when deprecated-property (list (cons 'deprecated-property deprecated-property)))))) (unless (chime--valid-event-p event) (error "Invalid Chime event: %S" event)) event)) ;;;; Time/Date Utilities (defun chime--time= (&rest list) "Compare timestamps. Comparison is performed by converting each element of LIST to a string in order to ignore seconds." (->> list (--map (format-time-string "%Y-%m-%d %H:%M" it)) (-uniq) (length) (= 1))) (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) ;; Emacs time values are either (HI LO) lists or integer/float seconds (or (listp timestamp) (numberp 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))." ;; Cartesian product of (timestamp-info × interval-info) via -table-flat, ;; then filter to pairs where the timestamp falls within the interval window. ;; Each result is ((ts-str . time-val) (minutes . severity)). (->> (list (chime--filter-day-wide-events (chime--event-times event)) (chime--event-intervals event)) (apply '-table-flat (lambda (ts int) (list ts int))) ;; -table-flat pairs nil with intervals when times list is empty (--filter (not (null (car it)))) ;; cdar = parsed time from (ts-str . time-val); car of cadr = minutes (--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-formats' (keys: at-event, short, long)." ;; Don't fold this into a `(-> seconds (pcase ...) ...)' threading form — ;; edebug's defun parser rejects threaded pcase, which breaks coverage ;; instrumentation (undercover) on this file. (let* ((regime (pcase seconds ((pred (>= 0)) 'at-event) ((pred (>= 3600)) 'short) (_ 'long))) (format-string (alist-get regime chime-time-left-formats))) (format-seconds format-string 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 (chime--event-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)) ;;;; All-Day Event Handling (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))) (chime--event-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 (chime--event-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)) (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." (let* ((now (current-time)) (now-decoded (decode-time now)) ;; explicit arg for testability (today-start (encode-time 0 0 0 (decoded-time-day now-decoded) (decoded-time-month now-decoded) (decoded-time-year now-decoded)))) (--any (let ((timestamp-str (car it)) (parsed-time (cdr it))) (if parsed-time (time-less-p parsed-time now) (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))) (not (time-less-p today-start event-date)))))) (chime--event-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)." (let* ((now-decoded (decode-time (current-time))) ;; explicit arg for testability (today-day (decoded-time-day now-decoded)) (today-month (decoded-time-month now-decoded)) (today-year (decoded-time-year now-decoded)) (today-start (encode-time 0 0 0 today-day today-month today-year))) (--any (let ((timestamp-str (car it)) (parsed-time (cdr it))) (if parsed-time (let* ((decoded (decode-time parsed-time)) (event-day (decoded-time-day decoded)) (event-month (decoded-time-month decoded)) (event-year (decoded-time-year decoded))) (and (= event-day today-day) (= event-month today-month) (= event-year today-year))) (when-let* ((parsed (org-parse-time-string timestamp-str)) (year (nth 5 parsed)) (month (nth 4 parsed)) (day (nth 3 parsed))) (time-equal-p (encode-time 0 0 0 day month year) today-start)))) (chime--event-times event)))) (defun chime--days-until-event (all-times) "Calculate minimum days until the soonest all-day timestamp in ALL-TIMES. ALL-TIMES is a list of (TIMESTAMP-STR . TIME-OBJECT) cons cells. Returns integer days (ceiling), or nil if no all-day timestamps found." (let ((now (current-time))) (-min (--map (when-let* ((timestamp-str (car it)) (is-all-day (not (chime--has-timestamp timestamp-str))) (parsed (org-parse-time-string timestamp-str)) (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))) (ceiling (/ (float-time seconds-until) 86400.0)))) all-times)))) (defun chime--day-wide-notification-text (event) "Generate notification text for day-wide EVENT. Handles both same-day events and advance notices." (let* ((title (chime--event-title 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 (is-today (format "%s is due or scheduled today" title)) (is-advance-notice (let ((days-until (chime--days-until-event (chime--event-times event)))) (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))))) (t (format "%s is due or scheduled today" title))))) ;;;; Event Checking & Navigation (defun chime--check-event (event) "Return notification messages currently due for EVENT. EVENT must follow the internal event contract documented by `chime--valid-event-p'. Each timestamp in EVENT is paired with each configured alert interval; pairs whose timestamp equals current time plus the interval become user-facing (MESSAGE . SEVERITY) cons cells. All-day timestamps are ignored here because day-wide notifications are scheduled separately by `chime--day-wide-notifications'." ;; Each notif from chime--notifications is ((ts-str . time-val) (min . sev)) (->> (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 (chime--event-marker-file event)) (pos (chime--event-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))) ;;;; Modeline & Tooltip Display (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* ((title (or title "")) (time-display (or (chime--get-hh-mm-from-org-time-string event-time-str) "")) (countdown (cond ((< minutes-until 1440) ;; Less than 24 hours (format chime-tooltip-countdown-wrapper (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))) (day-label (if (= days 1) (car chime-tooltip-day-unit-labels) (cdr chime-tooltip-day-unit-labels))) (hour-label (if (= hours 1) (car chime-tooltip-hour-unit-labels) (cdr chime-tooltip-hour-unit-labels))) (countdown-text (if (> hours 0) (format "%s %d %s %d %s" chime-tooltip-countdown-prefix days day-label hours hour-label) (format "%s %d %s" chime-tooltip-countdown-prefix days day-label)))) (format chime-tooltip-countdown-wrapper countdown-text)))))) (replace-regexp-in-string "%[tTu]" (lambda (placeholder) (pcase placeholder ("%t" title) ("%T" time-display) ("%u" countdown) (_ placeholder))) chime-tooltip-event-format t t))) (defun chime--tooltip-relative-day-format (label) "Return tooltip relative day format with LABEL substituted." (replace-regexp-in-string "%s" label chime-tooltip-relative-day-format t t)) (defun chime--day-label-for-event-time (event-time now tomorrow) "Return the date-group label for EVENT-TIME. NOW is the reference \"now\" time (typically `current-time') and TOMORROW is NOW plus 24 hours. When EVENT-TIME falls on the same calendar day as NOW, returns \"Today, \". When it falls on the same calendar day as TOMORROW, returns \"Tomorrow, \". Otherwise returns the full weekday and date, e.g. \"Wednesday, Nov 05\"." (let ((event-decoded (decode-time event-time)) (now-decoded (decode-time now)) (tomorrow-decoded (decode-time tomorrow))) (cond ((and (= (decoded-time-day event-decoded) (decoded-time-day now-decoded)) (= (decoded-time-month event-decoded) (decoded-time-month now-decoded)) (= (decoded-time-year event-decoded) (decoded-time-year now-decoded))) (format-time-string (chime--tooltip-relative-day-format chime-tooltip-today-label) now)) ((and (= (decoded-time-day event-decoded) (decoded-time-day tomorrow-decoded)) (= (decoded-time-month event-decoded) (decoded-time-month tomorrow-decoded)) (= (decoded-time-year event-decoded) (decoded-time-year tomorrow-decoded))) (format-time-string (chime--tooltip-relative-day-format chime-tooltip-tomorrow-label) tomorrow)) (t (format-time-string chime-tooltip-future-day-format event-time))))) (defun chime--group-events-by-day (upcoming-events) "Group UPCOMING-EVENTS by day. UPCOMING-EVENTS is a list of \\=(EVENT TIME-INFO MINUTES-UNTIL) tuples, as stored in `chime--upcoming-events'. Returns an alist of \\=(DATE-STRING . EVENTS-LIST), preserving the order of first appearance in UPCOMING-EVENTS." (let* ((grouped '()) (now (current-time)) (tomorrow (time-add now (days-to-time 1)))) (dolist (item upcoming-events) (let ((event-time (cdr (nth 1 item)))) (when event-time (let* ((date-string (chime--day-label-for-event-time event-time now tomorrow)) (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. UPCOMING-EVENTS is a list of (EVENT TIME-INFO MINUTES-UNTIL) tuples. The result is plain text suitable for the modeline `help-echo' property." (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 (format "%s\n" chime-tooltip-section-separator) lines) ;; Each item is (event (ts-str . time-val) minutes-until) (dolist (item day-events) (let* ((event (car item)) (event-time-str (car (nth 1 item))) (minutes-until (nth 2 item)) (title (chime--event-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%s" (format chime-tooltip-more-events-format 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 day%s" days (if (= days 1) "" "s"))) ((>= hours 24) (let ((d (/ hours 24.0))) (format "%.1f day%s" d (if (= d 1.0) "" "s")))) ((>= hours 1) (format "%d hour%s" hours (if (= hours 1) "" "s"))) (t (format "%d minute%s" lookahead-minutes (if (= lookahead-minutes 1) "" "s"))))) (header (format-time-string chime-tooltip-header-format)) (increase-var "chime-tooltip-lookahead-hours")) (concat header "\n" chime-tooltip-no-events-separator "\n" (format "%s\n\n" (format chime-tooltip-no-events-format timeframe)) (format "%s\n\n" (format chime-tooltip-increase-lookahead-format increase-var)) chime-tooltip-left-click-label))) (defun chime--propertize-modeline-string (text) "Add tooltip and click handlers to modeline TEXT. 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] #'chime--jump-to-first-event) (propertize text 'help-echo tooltip 'mouse-face 'mode-line-highlight 'local-map map)))) (defun chime--deduplicate-events-by-title (upcoming-events) "Collapse UPCOMING-EVENTS that come from the same source heading. UPCOMING-EVENTS should be a list where each element is \(EVENT TIME-INFO MINUTES). Returns a new list with one entry per source heading, keeping the soonest occurrence. The dedup key is the heading's marker (`marker-file' + `marker-pos' on the event alist) so two distinct headings sharing a display title both survive — for example, two separate \"1:1\" entries on different days. When marker info is missing (typically synthesized test events), the key falls back to the title so older callers and fixtures keep working. The function still earns its keep against `org-agenda-list', which expands a recurring entry into multiple instances all sharing one marker; those collapse to a single soonest tooltip line." (let ((id-hash (make-hash-table :test 'equal))) (dolist (item upcoming-events) (let* ((event (car item)) (minutes (caddr item)) (marker-file (chime--event-marker-file event)) (marker-pos (chime--event-marker-pos event)) (key (if (and marker-file marker-pos) (cons marker-file marker-pos) (chime--event-title event))) (existing (gethash key id-hash))) (when (or (not existing) (< minutes (caddr existing))) (puthash key item id-hash)))) (hash-table-values id-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 internal event alists to process. If SHOW-ALL-DAY-P is non-nil, all-day timestamps are eligible for tooltip display; otherwise only timed timestamps are considered. 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 (chime--event-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)))) ;; Deduplicate by title - keep only soonest occurrence (setq upcoming (chime--deduplicate-events-by-title upcoming)) ;; Sort by time (soonest first) (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. EVENTS is a list of internal event alists. NOW is the reference time. Search is limited to timed timestamps within MODELINE-LOOKAHEAD-MINUTES of NOW. All-day timestamps are deliberately excluded because the modeline shows a clock-relative next event, while all-day awareness belongs in the day-wide notification and tooltip paths. Returns (EVENT TIME-STR MINUTES-UNTIL EVENT-TEXT), or nil if no timed event falls inside the modeline window." (let ((soonest-event nil) (soonest-event-text nil) (soonest-minutes nil) (soonest-time-info nil)) (dolist (event events) (let* ((all-times (chime--event-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--render-modeline-string (soonest-modeline upcoming tooltip-lookahead-minutes) "Build the propertized modeline string for current event state. SOONEST-MODELINE is (EVENT TIME-STR MINUTES TEXT) when a timed event falls inside the modeline window, otherwise nil. UPCOMING is the tooltip-event list (already stored in `chime--upcoming-events' by the caller). TOOLTIP-LOOKAHEAD-MINUTES drives the no-events tooltip message. Returns a propertized string, or nil when nothing should be shown." (cond (soonest-modeline (chime--propertize-modeline-string (format chime-modeline-format (nth 3 soonest-modeline)))) (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))))) (defun chime--update-modeline (events) "Update Chime's modeline cache and rendered modeline text from EVENTS. EVENTS is a list of internal event alists returned by `chime--retrieve-events'. This function computes two related views: - `chime-modeline-string' shows the soonest timed event inside `chime-modeline-lookahead-minutes'. - `chime--upcoming-events' stores sorted tooltip tuples for all events inside `chime-tooltip-lookahead-hours'. When the modeline is disabled, or its lookahead is nil/zero, both caches are cleared so stale tooltip click targets are not left behind." (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)) (upcoming (chime--build-upcoming-events-list events now tooltip-lookahead-minutes chime-tooltip-show-all-day-events)) (soonest-modeline (chime--find-soonest-modeline-event events now chime-modeline-lookahead-minutes))) (setq chime--upcoming-events upcoming) (setq chime-modeline-string (chime--render-modeline-string soonest-modeline upcoming tooltip-lookahead-minutes)) (force-mode-line-update t)))) ;;;; Whitelist/Blacklist Filtering (defun chime--get-tags (marker) "Retrieve tags of MARKER." (when-let* ((tags-str (org-entry-get marker "TAGS"))) (org-split-string tags-str ":"))) (defun chime--filter-predicates (filters) "Build a list of predicate functions from a FILTERS alist. FILTERS is in the shape used by `chime-include-filters' and `chime-exclude-filters' — `(keywords . VALUES)', `(tags . VALUES)', `(predicates . FUNCTIONS)'. Each non-empty entry contributes one marker-taking predicate. An empty FILTERS list returns nil so callers can short-circuit the filter pass." (let ((keywords (alist-get 'keywords filters)) (tags (alist-get 'tags filters)) (predicates (alist-get 'predicates filters)) (preds nil)) (when keywords (push (lambda (marker) (-contains-p keywords (org-with-point-at marker (org-get-todo-state)))) preds)) (when tags (push (lambda (marker) (-intersection tags (chime--get-tags marker))) preds)) (when predicates (push (lambda (marker) (--some? (funcall it marker) predicates)) preds)) (nreverse preds))) (defun chime--include-filter-predicates () "Predicates derived from `chime-include-filters'." (chime--filter-predicates chime-include-filters)) (defun chime--exclude-filter-predicates () "Predicates derived from `chime-exclude-filters'." (chime--filter-predicates chime-exclude-filters)) (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-declined-events-predicate (marker) "Return non-nil when entry at MARKER carries `:STATUS: declined'. This is the marker org-gcal writes for events the user has declined in their calendar. Only the literal lowercase `declined' value is matched because that is what real org-gcal exports use." (let ((status (org-entry-get marker "STATUS"))) (and status (string= status "declined")))) (defun chime--apply-include-filters (markers) "Keep MARKERS that match any predicate in `chime-include-filters'." (-if-let (preds (chime--include-filter-predicates)) (-> (apply '-orfn preds) (-filter markers)) markers)) (defun chime--apply-exclude-filters (markers) "Drop MARKERS that match any predicate in `chime-exclude-filters'." (-if-let (preds (chime--exclude-filter-predicates)) (-> (apply '-orfn preds) (-remove markers)) markers)) ;;;; Async Event Retrieval ;; Expand rx at load-time to produce a regex string matching variable names ;; that async-inject-variables should copy into the subprocess environment. (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-include-filters" "chime-exclude-filters"))) 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 () "Return an async child-process form that retrieves Chime events. The returned lambda runs in a separate Emacs process so agenda parsing, filtering, and timestamp extraction do not block the user's interactive session. It reconstructs enough parent configuration with `async-inject-variables' to build the same agenda view, then returns a list of internal event alists." ;; Returns a backquoted lambda that runs in a separate Emacs process via async. ;; The unquoted ,(async-inject-variables ...) splices variable bindings from ;; the parent process; everything else executes in the child. `(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) ;; Fetch enough agenda days to satisfy both the modeline and tooltip. ;; The extra day covers partial-day lookaheads near midnight. (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-include-filters) (chime--apply-exclude-filters) (-map 'chime--gather-info)))) ;;;; Notification Dispatch (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 a file is configured (set chime-sound-file to nil to disable) (when 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))) ;;;; Timestamp Parsing (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 &optional context) "Parse timed org TIMESTAMP into Chime's serialized time value. Returns a two-integer Emacs time list suitable for async serialization, or nil when TIMESTAMP is nil, malformed, all-day, or otherwise unparsable. TIMESTAMP must be an org timestamp string with a clock component, such as \"<2026-05-10 Sun 09:30>\" or \"<2026-05-10 Sun 9:30am>\". Repeating timestamps are resolved through `org-closest-date' relative to today, so a recurring event contributes the nearest relevant occurrence rather than the literal date embedded in the source text. Optional CONTEXT is included in parse error messages and is typically the event title." (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 (HI LO USEC PSEC); drop USEC/PSEC (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: %s" timestamp (if context (format " in '%s'" context) "") (error-message-string err)) nil))) (defun chime--extract-gcal-timestamps (heading) "Extract timestamps from :org-gcal: drawer in current entry. HEADING is the entry title for error context. Returns list of (TIMESTAMP-STR . PARSED-TIME) cons cells." (let ((timestamps nil)) (save-excursion (org-back-to-heading t) (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))))) (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 heading)) timestamps)))))) (-non-nil (nreverse timestamps)))) (defun chime--extract-property-timestamps (marker heading) "Extract SCHEDULED and DEADLINE timestamps from MARKER's properties. HEADING is the entry title for error context. Returns list of (TIMESTAMP-STR . PARSED-TIME) cons cells." (-non-nil (--map (let ((org-timestamp (org-entry-get marker it))) (and org-timestamp (cons org-timestamp (chime--timestamp-parse org-timestamp heading)))) '("DEADLINE" "SCHEDULED")))) (defun chime--extract-plain-timestamps (heading) "Extract plain timestamps from current entry body. Skips planning lines (SCHEDULED, DEADLINE, CLOSED) to avoid duplicates. HEADING is the entry title for error context. Returns list of (TIMESTAMP-STR . PARSED-TIME) cons cells." (let ((timestamps nil)) (save-excursion (org-end-of-meta-data nil) ;; nil = skip planning lines only, not drawers (let ((start (point)) (end (save-excursion (org-end-of-subtree t) (point)))) (when (< start end) (goto-char start) (while (re-search-forward org-ts-regexp end t) (let ((timestamp-str (match-string 0))) (push (cons timestamp-str (chime--timestamp-parse timestamp-str heading)) timestamps)))))) (nreverse timestamps))) (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). The org-gcal branch is intentionally stricter than regular org extraction: org-gcal keeps the authoritative event time in its :org-gcal: drawer, while planning lines can lag behind after remote calendar edits." (org-with-point-at marker (let ((is-gcal-event (org-entry-get marker "entry-id")) (heading (nth 4 (org-heading-components)))) (if is-gcal-event (chime--extract-gcal-timestamps heading) (-non-nil (append (chime--extract-property-timestamps marker heading) (chime--extract-plain-timestamps heading))))))) ;;;; Event Info Extraction (defun chime--sanitize-title (title) "Sanitize TITLE to prevent Lisp read syntax errors during async serialization. TITLE comes from `org-heading-components' and is later carried through async.el as part of an event alist. Unbalanced delimiters in headings can produce strings that are awkward to serialize or inspect in tests, so this helper removes unmatched closing delimiters and appends matching closing delimiters for unmatched openings. 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--parse-notify-before-value (value) "Parse a per-event notify-before property VALUE string. Return a non-negative integer number of minutes, or nil when VALUE is not a string representing one. Negative, fractional, suffixed, empty, and nil values all return nil." (when (stringp value) (let ((trimmed (string-trim value))) (and (string-match-p "\\`[0-9]+\\'" trimmed) (string-to-number trimmed))))) (defun chime--read-interval-override (marker property deprecated-name) "Return (INTERVALS . DEPRECATED-NAME) when MARKER's PROPERTY is a valid override. INTERVALS is ((MINUTES . medium)) for a non-negative integer property value. Return nil when the property is absent. When the property is present but malformed, log a message naming the heading and return nil so the caller can fall through to the next source." (let ((raw (org-entry-get marker property))) (when raw (let ((minutes (chime--parse-notify-before-value raw))) (if minutes (cons (list (cons minutes 'medium)) deprecated-name) (message "chime: ignoring invalid :%s: value %S in heading %S" property raw (chime--extract-title marker)) nil))))) (defun chime--intervals-for-marker (marker) "Return MARKER's alert intervals as a cons (INTERVALS . DEPRECATED-PROP). When the heading sets `:CHIME_NOTIFY_BEFORE:' (or the deprecated `:WILD_NOTIFIER_NOTIFY_BEFORE:' alias) to a non-negative integer N, INTERVALS is ((N . medium)) and the global `chime-alert-intervals' is ignored for this event. `:CHIME_NOTIFY_BEFORE:' wins when both are set. DEPRECATED-PROP is the property name string when the deprecated alias supplied the value, nil otherwise. Malformed property values are logged and fall back to the global setting." (or (chime--read-interval-override marker "CHIME_NOTIFY_BEFORE" nil) (chime--read-interval-override marker "WILD_NOTIFIER_NOTIFY_BEFORE" "WILD_NOTIFIER_NOTIFY_BEFORE") (cons chime-alert-intervals nil))) (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). Alert intervals come from `chime--intervals-for-marker', which honors a per-event `:CHIME_NOTIFY_BEFORE:' property override." (let* ((interval-spec (chime--intervals-for-marker marker)) (intervals (car interval-spec)) (deprecated (cdr interval-spec))) (chime--make-event (chime--extract-time marker) (chime--extract-title marker) intervals (buffer-file-name (marker-buffer marker)) (marker-position marker) deprecated))) ;;;; Configuration Validation (defun chime--missing-org-agenda-files-message (missing) "Return validation warning text for missing org agenda file entries MISSING." (format "%d org-agenda-files entries don't exist:\n %s\n\nChime will skip these during event checks." (length missing) (mapconcat (lambda (path) (format "%s (%s)" path (if (string-suffix-p "/" path) "directory" "file"))) missing "\n "))) (defun chime--configuration-check-results () "Return full configuration validation check results. Each result has shape (SEVERITY DESCRIPTION MESSAGE), where SEVERITY is `:ok', `:warning', or `:error'. DESCRIPTION names the check performed. MESSAGE is nil for passing checks and contains issue details otherwise." (let* ((agenda-files-valid (and (boundp 'org-agenda-files) org-agenda-files (listp org-agenda-files) (> (length org-agenda-files) 0))) (results (list (if agenda-files-valid (list :ok "org-agenda-files is set" nil) (list :error "org-agenda-files is set" "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\"))"))))) (when agenda-files-valid (let ((missing (cl-remove-if #'file-exists-p org-agenda-files))) (push (if missing (list :warning (format "org-agenda-files entries exist on disk (%d entries)" (length org-agenda-files)) (chime--missing-org-agenda-files-message missing)) (list :ok (format "org-agenda-files entries exist on disk (%d entries)" (length org-agenda-files)) nil)) results))) (push (if (require 'org-agenda nil t) (list :ok "org-agenda is loadable" nil) (list :error "org-agenda is loadable" "Cannot load org-agenda\nEnsure org-mode is installed and available in load-path")) results) (push (if chime-enable-modeline (if (boundp 'global-mode-string) (list :ok "global-mode-string is available" nil) (list :warning "global-mode-string is available" "global-mode-string not available.\nModeline display may not work in this Emacs version.")) (list :ok "global-mode-string check skipped because modeline is disabled" nil)) results) (nreverse results))) (defun chime--validation-issues-from-results (results) "Project validation RESULTS to public (SEVERITY MESSAGE) issue pairs." (->> results (cl-remove-if (lambda (result) (eq (car result) :ok))) (mapcar (lambda (result) (list (car result) (caddr result)))))) (defun chime--display-validation-results (results) "Display full validation RESULTS in the *Messages* buffer." (let ((errors 0) (warnings 0)) (message "%s" chime-validating-message) (dolist (result results) (pcase-let ((`(,severity ,description ,detail) result)) (pcase severity (:error (cl-incf errors)) (:warning (cl-incf warnings))) (message "[%s] %s" (pcase severity (:ok "ok") (:warning "warn") (:error "error")) description) (when detail (message " %s" detail)))) (message chime-validation-summary-format errors (if (= errors 1) "" "s") warnings (if (= warnings 1) "" "s")))) ;;;###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 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 all check results in the *Messages* buffer. When called programmatically, returns structured validation results." (interactive) (let* ((results (chime--configuration-check-results)) (issues (chime--validation-issues-from-results results))) (when (called-interactively-p 'any) (chime--display-validation-results results)) issues)) ;;;; Core Lifecycle (defun chime--stop () "Stop the notification timer and cancel any in-progress check." (-some-> chime--timer (cancel-timer)) (setq chime--timer nil) (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 (-mapcat 'chime--check-event) (-uniq)) 'chime--notify) (when (chime--current-time-is-day-wide-time) (let ((day-wide (chime--day-wide-notifications events))) (when day-wide (if (= 1 (length day-wide)) ;; Single event: send as normal notification (chime--notify (car day-wide)) ;; Multiple events: bundle into one notification, one sound (let* ((messages (mapcar #'car day-wide)) (body (mapconcat #'identity messages "\n"))) (chime--notify (cons body 'medium)))))))) (defun chime--maybe-warn-persistent-failures () "Warn user if async failures have reached the threshold. Shows a warning via `display-warning' when `chime--consecutive-async-failures' reaches `chime-max-consecutive-failures'. Only warns once at the threshold." (when (and (> chime-max-consecutive-failures 0) (= chime--consecutive-async-failures chime-max-consecutive-failures)) (display-warning 'chime (format "Event checks have failed %d times in a row.\nCheck your org-agenda-files configuration." chime--consecutive-async-failures) :warning))) (defun chime--maybe-warn-deprecated-properties (events) "Warn once per session if any of EVENTS used a deprecated per-event property. Does nothing once the session guard `chime--deprecated-property-warned' is set." (unless chime--deprecated-property-warned (let ((event (cl-find-if #'chime--event-deprecated-property events))) (when event (setq chime--deprecated-property-warned t) (display-warning 'chime (format "Heading %S uses the deprecated property :%s:.\nUse :CHIME_NOTIFY_BEFORE: instead." (chime--event-title event) (chime--event-deprecated-property event)) :warning))))) (defun chime--record-async-failure (err prefix) "Record an async failure ERR. PREFIX names the failure category in the log. Increments the consecutive-failure counter, sends a debug log when the debug module is loaded, writes a silent log line, may emit the persistent-failure warning, and switches the modeline to its error state." (cl-incf chime--consecutive-async-failures) (when (featurep 'chime-debug) (chime--debug-log-async-error err)) (chime--log-silently "Chime: %s: %s" prefix (error-message-string err)) (chime--maybe-warn-persistent-failures) (chime--set-modeline-error-state chime-async-failure-tooltip)) (defun chime--handle-async-success (callback events) "Process a successful async fetch. Invoke CALLBACK with EVENTS. Resets the consecutive-failure counter, sends a debug-completion log when the debug module is loaded, and warns once per session if any event used a deprecated per-event property." (setq chime--consecutive-async-failures 0) (when (featurep 'chime-debug) (chime--debug-log-async-complete events)) (chime--maybe-warn-deprecated-properties events) (funcall callback 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)) (condition-case err (if (and (listp events) (eq (car events) 'async-signal)) (chime--record-async-failure (cdr events) "Async error") (chime--handle-async-success callback events)) (error (chime--record-async-failure err "Error processing events"))))))))) (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"))))) (defun chime--maybe-validate () "Run startup validation if not yet done. Return t if OK to proceed. Handles retry logic for async org-agenda-files initialization. Returns nil if validation failed and check should be skipped." (if chime--validation-done t (let ((issues (chime-validate-configuration))) (if (cl-some (lambda (i) (eq (car i) :error)) issues) (progn (setq chime--validation-retry-count (1+ chime--validation-retry-count)) (if (> chime--validation-retry-count chime--validation-max-retries) (progn (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 "%s" chime-validation-errors-message) ;; Update modeline tooltip to show error state (chime--set-modeline-error-state chime-validation-error-tooltip)) (message chime-validation-waiting-message-format chime--validation-retry-count chime--validation-max-retries) ;; Update modeline tooltip to show waiting state (chime--set-modeline-error-state (format chime-validation-waiting-tooltip-format chime--validation-retry-count chime--validation-max-retries))) nil) (setq chime--validation-done t) (setq chime--validation-retry-count 0) t)))) ;;;###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--maybe-validate) (cl-return-from chime-check nil)) ;; 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. Validates configuration through the same startup gate as `chime-check' before fetching events." (interactive) (when (chime--maybe-validate) (chime--fetch-and-process (lambda (events) (chime--update-modeline events))))) (defun chime--set-modeline-error-state (error-message) "Update modeline icon tooltip to show ERROR-MESSAGE. Keeps the icon visible so the user knows chime is running but has a problem." (when chime-modeline-no-events-text (let ((map (make-sparse-keymap))) (define-key map [mode-line mouse-1] #'chime--open-calendar-url) (setq chime-modeline-string (propertize chime-modeline-no-events-text 'help-echo (format "Chime: %s" error-message) 'mouse-face 'mode-line-highlight 'local-map map)) (force-mode-line-update t)))) (defun chime--make-initial-modeline-string () "Create the initial modeline string shown before the first check completes. Uses `chime-modeline-no-events-text' with a loading tooltip." (when chime-modeline-no-events-text (let ((map (make-sparse-keymap))) (define-key map [mode-line mouse-1] #'chime--open-calendar-url) (propertize chime-modeline-no-events-text 'help-echo chime-modeline-initial-tooltip 'mouse-face 'mode-line-highlight 'local-map map)))) ;;;###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)) ;; Set icon immediately so the user sees chime is active (setq chime-modeline-string (chime--make-initial-modeline-string)) (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