From eb01b3d24739e916d9dca33f5f039650a9de8457 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 3 Apr 2026 08:33:33 -0500 Subject: feat(music): add random-aware next/previous; refactor music + calendar-sync 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. --- modules/calendar-sync.el | 228 ++++++++++++++--------------------------------- modules/music-config.el | 82 +++++++++++++---- 2 files changed, 136 insertions(+), 174 deletions(-) (limited to 'modules') 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 "" #'cj/music-playlist-toggle) ;;; Radio station creation -- cgit v1.2.3