summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-03 08:33:33 -0500
committerCraig Jennings <c@cjennings.net>2026-04-03 08:33:33 -0500
commiteb01b3d24739e916d9dca33f5f039650a9de8457 (patch)
treeacd939426b9386b3b4b7d415e775b10781c682b9 /modules
parent3f7e427cf7530e38b7eb915c0dd49bd9318c9992 (diff)
feat(music): add random-aware next/previous; refactor music + calendar-syncHEADmain
Music: random mode now respected by next/previous keys. Previous navigates a 50-track play history ring buffer. Fixed playlist replacement bug. 24 new tests. Calendar-sync: consolidated duplicate parse functions, extracted timezone localization helper, unified expand-daily/monthly/yearly into parameterized function, removed dead code. 33 new characterization tests. -90 lines.
Diffstat (limited to 'modules')
-rw-r--r--modules/calendar-sync.el228
-rw-r--r--modules/music-config.el82
2 files changed, 136 insertions, 174 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 610281d2..fc9736b5 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -69,7 +69,6 @@
;;; Code:
-(require 'org)
(require 'user-constants) ; For gcal-file, pcal-file paths
;;; Configuration
@@ -383,28 +382,32 @@ Returns nil if not found."
(when (and event-str (stringp event-str))
(calendar-sync--get-property-line event-str "RECURRENCE-ID")))
-(defun calendar-sync--parse-recurrence-id (recurrence-id-value)
- "Parse RECURRENCE-ID-VALUE into (year month day hour minute) list.
-Returns nil for invalid input. For date-only values, returns (year month day nil nil)."
- (when (and recurrence-id-value
- (stringp recurrence-id-value)
- (not (string-empty-p recurrence-id-value)))
+(defun calendar-sync--parse-ics-datetime (value)
+ "Parse iCal datetime VALUE into (year month day hour minute) list.
+Returns nil for invalid input. For date-only values, returns (year month day nil nil).
+Handles formats: 20260203T090000Z, 20260203T090000, 20260203."
+ (when (and value
+ (stringp value)
+ (not (string-empty-p value)))
(cond
;; DateTime format: 20260203T090000Z or 20260203T090000
- ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" recurrence-id-value)
- (list (string-to-number (match-string 1 recurrence-id-value))
- (string-to-number (match-string 2 recurrence-id-value))
- (string-to-number (match-string 3 recurrence-id-value))
- (string-to-number (match-string 4 recurrence-id-value))
- (string-to-number (match-string 5 recurrence-id-value))))
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" value)
+ (list (string-to-number (match-string 1 value))
+ (string-to-number (match-string 2 value))
+ (string-to-number (match-string 3 value))
+ (string-to-number (match-string 4 value))
+ (string-to-number (match-string 5 value))))
;; Date-only format: 20260203
- ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" recurrence-id-value)
- (list (string-to-number (match-string 1 recurrence-id-value))
- (string-to-number (match-string 2 recurrence-id-value))
- (string-to-number (match-string 3 recurrence-id-value))
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" value)
+ (list (string-to-number (match-string 1 value))
+ (string-to-number (match-string 2 value))
+ (string-to-number (match-string 3 value))
nil nil))
(t nil))))
+(defalias 'calendar-sync--parse-recurrence-id #'calendar-sync--parse-ics-datetime
+ "Parse RECURRENCE-ID value. See `calendar-sync--parse-ics-datetime'.")
+
(defun calendar-sync--collect-recurrence-exceptions (ics-content)
"Collect all RECURRENCE-ID events from ICS-CONTENT.
Returns hash table mapping UID to list of exception event plists.
@@ -438,31 +441,9 @@ Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, e
(location (calendar-sync--clean-text
(calendar-sync--get-property event-str "LOCATION"))))
(when (and recurrence-id-parsed start-parsed)
- ;; Convert RECURRENCE-ID to local time
- ;; Handle: UTC (Z suffix), TZID, or assume local
(let ((local-recurrence-id
- (cond
- ;; UTC time (Z suffix) - convert from UTC
- (recurrence-id-is-utc
- (calendar-sync--convert-utc-to-local
- (nth 0 recurrence-id-parsed)
- (nth 1 recurrence-id-parsed)
- (nth 2 recurrence-id-parsed)
- (or (nth 3 recurrence-id-parsed) 0)
- (or (nth 4 recurrence-id-parsed) 0)
- 0)) ; seconds
- ;; TZID specified - convert from that timezone
- (recurrence-id-tzid
- (or (calendar-sync--convert-tz-to-local
- (nth 0 recurrence-id-parsed)
- (nth 1 recurrence-id-parsed)
- (nth 2 recurrence-id-parsed)
- (or (nth 3 recurrence-id-parsed) 0)
- (or (nth 4 recurrence-id-parsed) 0)
- recurrence-id-tzid)
- recurrence-id-parsed))
- ;; No timezone info - assume local
- (t recurrence-id-parsed))))
+ (calendar-sync--localize-parsed-datetime
+ recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid)))
(let ((exception-plist
(list :recurrence-id local-recurrence-id
:recurrence-id-raw recurrence-id
@@ -562,27 +543,8 @@ Returns nil if not found."
(when (string-match pattern event-str)
(match-string 1 event-str)))))
-(defun calendar-sync--parse-exdate (exdate-value)
- "Parse EXDATE-VALUE into (year month day hour minute) list.
-Returns nil for invalid input. For date-only values, returns (year month day nil nil)."
- (when (and exdate-value
- (stringp exdate-value)
- (not (string-empty-p exdate-value)))
- (cond
- ;; DateTime format: 20260203T130000Z or 20260203T130000
- ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" exdate-value)
- (list (string-to-number (match-string 1 exdate-value))
- (string-to-number (match-string 2 exdate-value))
- (string-to-number (match-string 3 exdate-value))
- (string-to-number (match-string 4 exdate-value))
- (string-to-number (match-string 5 exdate-value))))
- ;; Date-only format: 20260203
- ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" exdate-value)
- (list (string-to-number (match-string 1 exdate-value))
- (string-to-number (match-string 2 exdate-value))
- (string-to-number (match-string 3 exdate-value))
- nil nil))
- (t nil))))
+(defalias 'calendar-sync--parse-exdate #'calendar-sync--parse-ics-datetime
+ "Parse EXDATE value. See `calendar-sync--parse-ics-datetime'.")
(defun calendar-sync--collect-exdates (event-str)
"Collect all excluded dates from EVENT-STR, handling timezone conversion.
@@ -600,30 +562,9 @@ Converts TZID-qualified and UTC times to local time."
(exdate-is-utc (and exdate-value (string-suffix-p "Z" exdate-value)))
(exdate-parsed (calendar-sync--parse-exdate exdate-value)))
(when exdate-parsed
- (let ((local-exdate
- (cond
- ;; UTC time (Z suffix) - convert from UTC
- (exdate-is-utc
- (calendar-sync--convert-utc-to-local
- (nth 0 exdate-parsed)
- (nth 1 exdate-parsed)
- (nth 2 exdate-parsed)
- (or (nth 3 exdate-parsed) 0)
- (or (nth 4 exdate-parsed) 0)
- 0))
- ;; TZID specified - convert from that timezone
- (exdate-tzid
- (or (calendar-sync--convert-tz-to-local
- (nth 0 exdate-parsed)
- (nth 1 exdate-parsed)
- (nth 2 exdate-parsed)
- (or (nth 3 exdate-parsed) 0)
- (or (nth 4 exdate-parsed) 0)
- exdate-tzid)
- exdate-parsed))
- ;; No timezone info - use as-is (local time)
- (t exdate-parsed))))
- (push local-exdate result)))))
+ (push (calendar-sync--localize-parsed-datetime
+ exdate-parsed exdate-is-utc exdate-tzid)
+ result))))
(nreverse result))))
(defun calendar-sync--exdate-matches-p (occurrence-start exdate)
@@ -835,6 +776,25 @@ TZ database as the `date' command."
source-tz (error-message-string err))
nil))))
+(defun calendar-sync--localize-parsed-datetime (parsed is-utc tzid)
+ "Convert PARSED datetime to local time using timezone info.
+PARSED is (year month day hour minute) or (year month day nil nil).
+IS-UTC non-nil means the value had a Z suffix.
+TZID is a timezone string like \"Europe/Lisbon\", or nil.
+Returns PARSED converted to local time, or PARSED unchanged if no conversion needed."
+ (cond
+ (is-utc
+ (calendar-sync--convert-utc-to-local
+ (nth 0 parsed) (nth 1 parsed) (nth 2 parsed)
+ (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) 0))
+ (tzid
+ (or (calendar-sync--convert-tz-to-local
+ (nth 0 parsed) (nth 1 parsed) (nth 2 parsed)
+ (or (nth 3 parsed) 0) (or (nth 4 parsed) 0)
+ tzid)
+ parsed))
+ (t parsed)))
+
(defun calendar-sync--parse-timestamp (timestamp-str &optional tzid)
"Parse iCal timestamp string TIMESTAMP-STR.
Returns (year month day hour minute) or (year month day) for all-day events.
@@ -942,9 +902,10 @@ Returns plist with :freq :interval :byday :until :count."
(setq result (plist-put result :interval 1)))
result))
-(defun calendar-sync--expand-daily (base-event rrule range)
- "Expand daily recurring event.
-BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+(defun calendar-sync--expand-simple-recurrence (base-event rrule range advance-fn)
+ "Expand a simple (non-weekly) recurring event using ADVANCE-FN to step dates.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range.
+ADVANCE-FN takes (current-date interval) and returns the next date."
(let* ((start (plist-get base-event :start))
(interval (plist-get rrule :interval))
(until (plist-get rrule :until))
@@ -953,29 +914,27 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
(num-generated 0)
(range-end-time (cadr range)))
- ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
- ;; For COUNT, generate all occurrences from start regardless of range
(while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
(or (not until) (calendar-sync--before-date-p current-date until))
(or (not count) (< num-generated count)))
(let ((occurrence-datetime (append current-date (nthcdr 3 start))))
- ;; Check UNTIL date first
- (when (or (not until) (calendar-sync--before-date-p current-date until))
- ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
- (when (or (not count) (< num-generated count))
- (setq num-generated (1+ num-generated))
- ;; Only add to output if within date range
- (when (calendar-sync--date-in-range-p occurrence-datetime range)
- (push (calendar-sync--create-occurrence base-event occurrence-datetime)
- occurrences)))))
- (setq current-date (calendar-sync--add-days current-date interval)))
+ (setq num-generated (1+ num-generated))
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))
+ (setq current-date (funcall advance-fn current-date interval)))
(nreverse occurrences)))
+(defun calendar-sync--expand-daily (base-event rrule range)
+ "Expand daily recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (calendar-sync--expand-simple-recurrence
+ base-event rrule range #'calendar-sync--add-days))
+
(defun calendar-sync--expand-weekly (base-event rrule range)
"Expand weekly recurring event.
BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(let* ((start (plist-get base-event :start))
- (end (plist-get base-event :end))
(interval (plist-get rrule :interval))
(byday (plist-get rrule :byday))
(until (plist-get rrule :until))
@@ -1024,60 +983,15 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(defun calendar-sync--expand-monthly (base-event rrule range)
"Expand monthly recurring event.
BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
- (let* ((start (plist-get base-event :start))
- (interval (plist-get rrule :interval))
- (until (plist-get rrule :until))
- (count (plist-get rrule :count))
- (occurrences '())
- (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
- (num-generated 0)
- (range-end-time (cadr range)))
- ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
- ;; For COUNT, generate all occurrences from start regardless of range
- (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
- (or (not until) (calendar-sync--before-date-p current-date until))
- (or (not count) (< num-generated count)))
- (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
- ;; Check UNTIL date first
- (when (or (not until) (calendar-sync--before-date-p current-date until))
- ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
- (when (or (not count) (< num-generated count))
- (setq num-generated (1+ num-generated))
- ;; Only add to output if within date range
- (when (calendar-sync--date-in-range-p occurrence-datetime range)
- (push (calendar-sync--create-occurrence base-event occurrence-datetime)
- occurrences)))))
- (setq current-date (calendar-sync--add-months current-date interval)))
- (nreverse occurrences)))
+ (calendar-sync--expand-simple-recurrence
+ base-event rrule range #'calendar-sync--add-months))
(defun calendar-sync--expand-yearly (base-event rrule range)
"Expand yearly recurring event.
BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
- (let* ((start (plist-get base-event :start))
- (interval (plist-get rrule :interval))
- (until (plist-get rrule :until))
- (count (plist-get rrule :count))
- (occurrences '())
- (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
- (num-generated 0)
- (range-end-time (cadr range)))
- ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
- ;; For COUNT, generate all occurrences from start regardless of range
- (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
- (or (not until) (calendar-sync--before-date-p current-date until))
- (or (not count) (< num-generated count)))
- (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
- ;; Check UNTIL date first
- (when (or (not until) (calendar-sync--before-date-p current-date until))
- ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
- (when (or (not count) (< num-generated count))
- (setq num-generated (1+ num-generated))
- ;; Only add to output if within date range
- (when (calendar-sync--date-in-range-p occurrence-datetime range)
- (push (calendar-sync--create-occurrence base-event occurrence-datetime)
- occurrences)))))
- (setq current-date (calendar-sync--add-months current-date (* 12 interval))))
- (nreverse occurrences)))
+ (calendar-sync--expand-simple-recurrence
+ base-event rrule range
+ (lambda (date interval) (calendar-sync--add-months date (* 12 interval)))))
(defun calendar-sync--expand-recurring-event (event-str range)
"Expand recurring event EVENT-STR into individual occurrences within RANGE.
@@ -1253,8 +1167,7 @@ RECURRENCE-ID exceptions are applied to override specific occurrences."
"\n")
nil)))
(error
- (setq calendar-sync--last-error (error-message-string err))
- (cj/log-silently "calendar-sync: Parse error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Parse error: %s" (error-message-string err))
nil)))
;;; Sync functions
@@ -1285,15 +1198,12 @@ invoked when the fetch completes, either successfully or with an error."
(if (and (eq (process-status process) 'exit)
(= (process-exit-status process) 0))
(calendar-sync--normalize-line-endings (buffer-string))
- (setq calendar-sync--last-error
- (format "curl failed: %s" (string-trim event)))
- (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event))
nil))))
(kill-buffer buf)
(funcall callback content))))))))
(error
- (setq calendar-sync--last-error (error-message-string err))
- (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Fetch error: %s" (error-message-string err))
(funcall callback nil))))
(defun calendar-sync--write-file (content file)
diff --git a/modules/music-config.el b/modules/music-config.el
index 46474a23..08ce0658 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -408,6 +408,62 @@ Offers completion over existing names but allows new names."
(user-error "Playlist file no longer exists: %s"
(file-name-nondirectory path))))))
+;;; Commands: random-aware navigation
+
+(defvar cj/music--random-history nil
+ "List of recently played track names during random mode, most recent first.")
+
+(defvar cj/music--random-history-max 50
+ "Maximum number of tracks to keep in random playback history.")
+
+(defun cj/music--record-random-history ()
+ "Push the current track onto random history if random mode is active.
+Intended for use on `emms-player-started-hook'."
+ (when emms-random-playlist
+ (when-let ((track (emms-playlist-current-selected-track)))
+ (let ((name (emms-track-name track)))
+ (unless (equal name (car cj/music--random-history))
+ (push name cj/music--random-history)
+ (when (> (length cj/music--random-history) cj/music--random-history-max)
+ (setq cj/music--random-history
+ (seq-take cj/music--random-history cj/music--random-history-max))))))))
+
+(defun cj/music-next ()
+ "Play next track. Respects random mode — picks a random track if active."
+ (interactive)
+ (if emms-random-playlist
+ (emms-random)
+ (emms-next)))
+
+(defun cj/music--find-track-in-playlist (track-name)
+ "Return buffer position of TRACK-NAME in the current playlist, or nil."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-min))
+ (let ((found nil))
+ (while (and (not found) (not (eobp)))
+ (when-let ((track (emms-playlist-track-at (point))))
+ (when (equal (emms-track-name track) track-name)
+ (setq found (point))))
+ (unless found (forward-line 1)))
+ found))))
+
+(defun cj/music-previous ()
+ "Play previous track. In random mode, go back through playback history."
+ (interactive)
+ (if (and emms-random-playlist cj/music--random-history)
+ (let* ((track-name (pop cj/music--random-history))
+ (pos (cj/music--find-track-in-playlist track-name)))
+ (if pos
+ (progn
+ (emms-playlist-select pos)
+ (emms-start))
+ (message "Track no longer in playlist: %s"
+ (file-name-nondirectory track-name))))
+ (when (and emms-random-playlist (null cj/music--random-history))
+ (message "No random history to go back to"))
+ (emms-previous)))
+
;;; Commands: consume mode
(defvar cj/music-consume-mode nil
@@ -443,8 +499,7 @@ Intended for use on `emms-player-finished-hook'."
(unless (featurep 'emms)
(require 'emms))
- (emms-playing-time-disable-display)
- (emms-mode-line-mode -1))
+)
(defun cj/music-playlist-toggle ()
@@ -525,8 +580,8 @@ Dirs added recursively."
"R" #'cj/music-create-radio-station
"SPC" #'emms-pause
"s" #'emms-stop
- "n" #'emms-next
- "p" #'emms-previous
+ "n" #'cj/music-next
+ "p" #'cj/music-previous
"g" #'emms-playlist-mode-go
"Z" #'emms-shuffle
"r" #'emms-toggle-repeat-playlist
@@ -587,11 +642,9 @@ Dirs added recursively."
;; Update supported file types for mpv player
(setq emms-player-mpv-regexp
- (rx (or
- ;; Stream URLs
- (seq bos (or "http" "https" "mms") "://")
- ;; Local music files by extension
- (seq "." (or "aac" "flac" "m4a" "mp3" "ogg" "opus" "wav") eos))))
+ (concat "\\(?:\\`\\(?:https?\\|mms\\)://\\)\\|\\(?:\\."
+ (regexp-opt cj/music-file-extensions)
+ "\\'\\)"))
;; Keep cj/music-playlist-file in sync if playlist is cleared
(defun cj/music--after-playlist-clear (&rest _)
@@ -770,6 +823,7 @@ For URL tracks: decoded URL."
(add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t))
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
+ (add-hook 'emms-player-started-hook #'cj/music--record-random-history)
(add-hook 'emms-player-started-hook #'cj/music--update-header)
(add-hook 'emms-player-stopped-hook #'cj/music--update-header)
(add-hook 'emms-player-paused-hook #'cj/music--update-header)
@@ -789,10 +843,10 @@ For URL tracks: decoded URL."
("p" . emms-playlist-mode-go)
("SPC" . emms-pause)
("s" . emms-stop)
- ("n" . emms-next)
- (">" . emms-next)
- ("P" . emms-previous)
- ("<" . emms-previous)
+ ("n" . cj/music-next)
+ (">" . cj/music-next)
+ ("P" . cj/music-previous)
+ ("<" . cj/music-previous)
("f" . emms-seek-forward)
("b" . emms-seek-backward)
("q" . emms-playlist-mode-bury-buffer)
@@ -826,8 +880,6 @@ For URL tracks: decoded URL."
("=" . emms-volume-raise)
("-" . emms-volume-lower)))
-;; Quick toggle key - use autoload to avoid loading emms at startup
-(autoload 'cj/music-playlist-toggle "music-config" "Toggle EMMS playlist window." t)
(keymap-global-set "<f10>" #'cj/music-playlist-toggle)
;;; Radio station creation