summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-11-17 02:54:02 -0600
committerCraig Jennings <c@cjennings.net>2025-11-17 02:54:02 -0600
commit0afa3fba94157d5e18f9a086e0b67b7cfd2aedf0 (patch)
tree08178068136b5bdf2847921cf14b65cce7f09f5a
parentcee86049043be47d82a274a1202641fc0a5c68b4 (diff)
fix(calendar-sync): Remove carriage return characters from synced events
Problem: Google Calendar .ics files use CRLF line endings (RFC 5545 spec), which resulted in 11,685 ^M (CR) characters appearing in gcal.org, particularly at the end of org header lines. Solution: - Created calendar-sync--normalize-line-endings function to strip all \r characters from .ics content - Integrated into calendar-sync--fetch-ics immediately after curl download - Ensures clean Unix LF-only line endings throughout parsing pipeline Testing: - Added comprehensive test suite: test-calendar-sync--normalize-line-endings.el - 16 tests covering Normal, Boundary, and Error cases - All 56 existing calendar-sync tests still pass (no regressions) - Verified: gcal.org now has 0 CR characters (was 11,685) Files modified: - modules/calendar-sync.el: Added normalize function, updated fetch function - tests/test-calendar-sync--normalize-line-endings.el: New comprehensive test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--modules/calendar-sync.el18
-rw-r--r--tests/test-calendar-sync--normalize-line-endings.el186
2 files changed, 202 insertions, 2 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 8450b282..feb7188d 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -144,6 +144,20 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
(error
(message "calendar-sync: Error loading state: %s" (error-message-string err))))))
+;;; Line Ending Normalization
+
+(defun calendar-sync--normalize-line-endings (content)
+ "Normalize line endings in CONTENT to Unix format (LF only).
+Removes all carriage return characters (\\r) from CONTENT.
+The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs
+and org-mode expect LF only. This function ensures consistent line
+endings throughout the parsing pipeline.
+
+Returns CONTENT with all \\r characters removed."
+ (if (not (stringp content))
+ content
+ (replace-regexp-in-string "\r" "" content)))
+
;;; .ics Parsing
(defun calendar-sync--split-events (ics-content)
@@ -293,7 +307,7 @@ Events are sorted chronologically by start time."
(defun calendar-sync--fetch-ics (url)
"Fetch .ics file from URL using curl.
-Returns .ics content as string, or nil on error.
+Returns .ics content as string with normalized Unix line endings (LF only), or nil on error.
Uses curl instead of url-retrieve-synchronously to avoid daemon mode hanging."
(condition-case err
(with-temp-buffer
@@ -303,7 +317,7 @@ Uses curl instead of url-retrieve-synchronously to avoid daemon mode hanging."
"-m" "10" ; Max 10 seconds
url)))
(if (= exit-code 0)
- (buffer-string)
+ (calendar-sync--normalize-line-endings (buffer-string))
(setq calendar-sync--last-error (format "curl exited with code %d" exit-code))
(message "calendar-sync: Fetch error: %s" calendar-sync--last-error)
nil)))
diff --git a/tests/test-calendar-sync--normalize-line-endings.el b/tests/test-calendar-sync--normalize-line-endings.el
new file mode 100644
index 00000000..7f0830cc
--- /dev/null
+++ b/tests/test-calendar-sync--normalize-line-endings.el
@@ -0,0 +1,186 @@
+;;; test-calendar-sync--normalize-line-endings.el --- Tests for calendar-sync--normalize-line-endings -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--normalize-line-endings function.
+;; Tests conversion of various line ending formats to Unix LF-only format.
+;; Covers Normal, Boundary, and Error cases.
+;;
+;; The iCalendar format (RFC 5545) uses CRLF line endings (\r\n),
+;; but Emacs and org-mode expect LF only (\n). This function ensures
+;; consistent line endings throughout the parsing pipeline.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--normalize-line-endings-normal-crlf-to-lf ()
+ "Test that CRLF line endings are converted to LF only.
+
+Input: String with \\r\\n (Windows/DOS line endings)
+Expected: String with \\n only (Unix line endings)"
+ (let* ((input "line1\r\nline2\r\nline3\r\n")
+ (expected "line1\nline2\nline3\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-normal-lf-unchanged ()
+ "Test that LF-only content is returned unchanged.
+
+Input: String with \\n only (already Unix format)
+Expected: Same string (no modification needed)"
+ (let* ((input "line1\nline2\nline3\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= input result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-normal-mixed-endings ()
+ "Test that mixed line endings are normalized to LF only.
+
+Input: String with both \\r\\n (CRLF) and \\n (LF)
+Expected: String with \\n only everywhere"
+ (let* ((input "line1\r\nline2\nline3\r\nline4\n")
+ (expected "line1\nline2\nline3\nline4\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-normal-ics-vevent-block ()
+ "Test normalization of realistic iCalendar VEVENT block with CRLF.
+
+Input: VEVENT block with CRLF line endings (as per RFC 5545)
+Expected: Same structure with LF only"
+ (let* ((input "BEGIN:VEVENT\r\nSUMMARY:Test Event\r\nDTSTART:20251116T140000Z\r\nEND:VEVENT\r\n")
+ (expected "BEGIN:VEVENT\nSUMMARY:Test Event\nDTSTART:20251116T140000Z\nEND:VEVENT\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-empty-string ()
+ "Test that empty string is handled correctly.
+
+Input: Empty string
+Expected: Empty string (no crash)"
+ (let ((result (calendar-sync--normalize-line-endings "")))
+ (should (string= "" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-no-line-endings ()
+ "Test that string with no line endings is unchanged.
+
+Input: Plain text with no \\r or \\n
+Expected: Same string unchanged"
+ (let* ((input "no line endings here")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= input result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-only-cr ()
+ "Test that bare CR characters (old Mac format) are removed.
+
+Input: String with \\r only (classic Mac OS line endings)
+Expected: String with \\r removed (results in run-together text)"
+ (let* ((input "line1\rline2\rline3\r")
+ (expected "line1line2line3")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-cr-in-middle ()
+ "Test that CR characters in middle of content are removed.
+
+Input: String with \\r not followed by \\n (unusual but possible)
+Expected: All \\r removed regardless of position"
+ (let* ((input "line1\r\ntext\rwith\rmiddle\r\nline2")
+ (expected "line1\ntextwithmiddle\nline2")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-multiple-cr ()
+ "Test that multiple consecutive CR characters are all removed.
+
+Input: String with \\r\\r or \\r\\r\\n sequences
+Expected: All \\r characters removed"
+ (let* ((input "line1\r\r\nline2\r\r\r\nline3")
+ (expected "line1\nline2\nline3")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-single-line ()
+ "Test normalization of single line with trailing CRLF.
+
+Input: Single line of text ending with \\r\\n
+Expected: Single line ending with \\n"
+ (let* ((input "single line\r\n")
+ (expected "single line\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-only-line-endings ()
+ "Test string containing only line ending characters.
+
+Input: String of only \\r\\n sequences
+Expected: String of only \\n (CR stripped)"
+ (let* ((input "\r\n\r\n\r\n")
+ (expected "\n\n\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-unicode-content ()
+ "Test normalization preserves Unicode characters.
+
+Input: String with Unicode and CRLF line endings
+Expected: Unicode preserved, only CR removed"
+ (let* ((input "emoji 🎉\r\nchinese 中文\r\narabic العربية\r\n")
+ (expected "emoji 🎉\nchinese 中文\narabic العربية\n")
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (string= expected result))
+ (should-not (string-match-p "\r" result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-boundary-very-long-string ()
+ "Test normalization of large string with many line endings.
+
+Input: String with 1000 lines with CRLF
+Expected: Same content with LF only, performance acceptable"
+ (let* ((line "This is line content with some text\r\n")
+ (input (apply #'concat (make-list 1000 line)))
+ (result (calendar-sync--normalize-line-endings input)))
+ (should (= (length input) (+ (length result) 1000))) ; 1000 \r removed
+ (should-not (string-match-p "\r" result))
+ (should (string-match-p "^This is line content" result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--normalize-line-endings-error-nil-input ()
+ "Test that nil input is handled gracefully.
+
+Input: nil
+Expected: nil (defensive programming, no crash)"
+ (let ((result (calendar-sync--normalize-line-endings nil)))
+ (should (null result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-error-non-string-input ()
+ "Test that non-string input is returned unchanged.
+
+Input: Integer (wrong type)
+Expected: Same value returned (defensive, don't crash)"
+ (let ((result (calendar-sync--normalize-line-endings 42)))
+ (should (= 42 result))))
+
+(ert-deftest test-calendar-sync--normalize-line-endings-error-symbol-input ()
+ "Test that symbol input is handled gracefully.
+
+Input: Symbol (wrong type)
+Expected: Symbol returned unchanged"
+ (let ((result (calendar-sync--normalize-line-endings 'some-symbol)))
+ (should (eq 'some-symbol result))))
+
+(provide 'test-calendar-sync--normalize-line-endings)
+;;; test-calendar-sync--normalize-line-endings.el ends here