aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-10 14:34:52 -0500
committerCraig Jennings <c@cjennings.net>2026-05-10 14:34:52 -0500
commitc44a52a7905b605a6537e3ff9bb4fe3afede0485 (patch)
tree07315442c51d38f1a601a28bec2ea5817bdb9509
parentaa72245a2a1715ef4fb8b1c3019826540320be80 (diff)
downloaddotemacs-c44a52a7905b605a6537e3ff9bb4fe3afede0485.tar.gz
dotemacs-c44a52a7905b605a6537e3ff9bb4fe3afede0485.zip
refactor(cj-org-text): extract Org-safe text sanitizers from calendar-sync
Phase 3 of utility-consolidation. Three sanitizers moved from calendar-sync.el into a new cj-org-text.el module so other consumers (web-clipper, AI conversation, mail-to-org capture) can compose Org content from external text without depending on calendar: - `calendar-sync--sanitize-org-body' -> `cj/org-sanitize-body-text' - `calendar-sync--sanitize-org-property-value' -> `cj/org-sanitize-property-value' - `calendar-sync--sanitize-org-heading' -> `cj/org-sanitize-heading' The helpers stay pure (string in, string out, nil-safe) and have no Org-mode dependency, so they work in batch and in tests without loading Org. Migrate calendar-sync.el to use the new public names: drop the three local defuns, add `(require \='cj-org-text)', update the six call sites in `calendar-sync--make-event-entry'. Move the existing 17-test file to `tests/test-cj-org-text-sanitize.el', rename test names to match the new helpers, add 1 nil-input test for `cj/org-sanitize-heading' that wasn't in the original file. Total: 18 Normal/Boundary tests across the three helpers.
-rw-r--r--modules/calendar-sync.el38
-rw-r--r--modules/cj-org-text.el58
-rw-r--r--tests/test-calendar-sync--sanitize-org-body.el98
-rw-r--r--tests/test-cj-org-text-sanitize.el111
4 files changed, 176 insertions, 129 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index f87d0192..890dd9df 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -71,6 +71,7 @@
(require 'cl-lib)
(require 'subr-x)
+(require 'cj-org-text)
(defun calendar-sync--log-silently (format-string &rest args)
"Log FORMAT-STRING with ARGS without requiring the full config."
@@ -294,31 +295,6 @@ Returns nil for nil input. Returns empty string for whitespace-only input."
(when text
(string-trim (calendar-sync--strip-html (calendar-sync--unescape-ics-text text)))))
-(defun calendar-sync--sanitize-org-body (text)
- "Sanitize TEXT for safe inclusion as org body content.
-Replaces leading asterisks with dashes to prevent lines from being
-parsed as org headings. Handles multiple levels (e.g. ** becomes --)."
- (when text
- (replace-regexp-in-string
- "^\\(\\*+\\) "
- (lambda (match)
- (concat (make-string (length (match-string 1 match)) ?-) " "))
- text)))
-
-(defun calendar-sync--sanitize-org-property-value (text)
- "Sanitize TEXT for safe inclusion as a single Org property value."
- (when text
- (string-trim
- (replace-regexp-in-string
- "[[:space:]\n\r]+"
- " "
- text))))
-
-(defun calendar-sync--sanitize-org-heading (text)
- "Sanitize TEXT for safe inclusion as a single Org heading title."
- (calendar-sync--sanitize-org-property-value
- (calendar-sync--sanitize-org-body text)))
-
;;; Date Utilities
(defun calendar-sync--add-months (date months)
@@ -1091,7 +1067,7 @@ Cleans text fields (description, location, summary) via `calendar-sync--clean-te
"Convert parsed EVENT plist to org entry string.
Produces property drawer with LOCATION, ORGANIZER, STATUS, URL when present.
Description appears as body text after the drawer."
- (let* ((summary (calendar-sync--sanitize-org-heading
+ (let* ((summary (cj/org-sanitize-heading
(or (plist-get event :summary) "(No Title)")))
(description (plist-get event :description))
(location (plist-get event :location))
@@ -1106,22 +1082,22 @@ Description appears as body text after the drawer."
;; Collect non-nil properties
(when (and location (not (string-empty-p location)))
(push (format ":LOCATION: %s"
- (calendar-sync--sanitize-org-property-value location))
+ (cj/org-sanitize-property-value location))
props))
(when organizer
(let ((org-name (or (plist-get organizer :cn)
(plist-get organizer :email))))
(when org-name
(push (format ":ORGANIZER: %s"
- (calendar-sync--sanitize-org-property-value org-name))
+ (cj/org-sanitize-property-value org-name))
props))))
(when (and status (not (string-empty-p status)))
(push (format ":STATUS: %s"
- (calendar-sync--sanitize-org-property-value status))
+ (cj/org-sanitize-property-value status))
props))
(when (and url (not (string-empty-p url)))
(push (format ":URL: %s"
- (calendar-sync--sanitize-org-property-value url))
+ (cj/org-sanitize-property-value url))
props))
(setq props (nreverse props))
;; Build output
@@ -1134,7 +1110,7 @@ Description appears as body text after the drawer."
(push ":END:" parts))
;; Add description as body text (sanitized to prevent org heading conflicts)
(when (and description (not (string-empty-p description)))
- (push (calendar-sync--sanitize-org-body description) parts))
+ (push (cj/org-sanitize-body-text description) parts))
(string-join (nreverse parts) "\n"))))
(defun calendar-sync--event-start-time (event)
diff --git a/modules/cj-org-text.el b/modules/cj-org-text.el
new file mode 100644
index 00000000..69224573
--- /dev/null
+++ b/modules/cj-org-text.el
@@ -0,0 +1,58 @@
+;;; cj-org-text.el --- Pure helpers for sanitizing external text into Org -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;;; Commentary:
+
+;; Pure string helpers for safely composing Org-mode content from
+;; external text (calendar event bodies, web-clipped HTML, mail
+;; subject lines, AI conversation transcripts, etc.).
+;;
+;; The shared concern is that text from outside sources can contain
+;; characters that disturb Org structure if pasted verbatim:
+;;
+;; - leading `*' creates an unintended heading,
+;; - newlines inside a property value spawn extra drawer lines,
+;; - newlines inside a heading split it into two outline entries.
+;;
+;; These helpers neutralize each pattern with predictable, testable
+;; replacements. They are pure (string in, string out, nil-safe) and
+;; have no Org-mode dependency, so they remain useful in batch and in
+;; tests without loading Org.
+
+;;; Code:
+
+(defun cj/org-sanitize-body-text (text)
+ "Sanitize TEXT for safe inclusion as Org body content.
+Replaces leading asterisks with dashes so external lines aren't
+parsed as Org headings. Handles multiple levels (`**' becomes `--').
+Returns nil for nil input."
+ (when text
+ (replace-regexp-in-string
+ "^\\(\\*+\\) "
+ (lambda (match)
+ (concat (make-string (length (match-string 1 match)) ?-) " "))
+ text)))
+
+(defun cj/org-sanitize-property-value (text)
+ "Sanitize TEXT for safe inclusion as a single Org property value.
+Collapses whitespace and newlines into single spaces and trims, so the
+result fits on one line of an Org property drawer. Returns nil for
+nil input."
+ (when text
+ (string-trim
+ (replace-regexp-in-string
+ "[[:space:]\n\r]+"
+ " "
+ text))))
+
+(defun cj/org-sanitize-heading (text)
+ "Sanitize TEXT for safe inclusion as a single Org heading title.
+Composes `cj/org-sanitize-body-text' (neutralizes leading stars) and
+`cj/org-sanitize-property-value' (flattens to a single line). Returns
+nil for nil input."
+ (cj/org-sanitize-property-value
+ (cj/org-sanitize-body-text text)))
+
+(provide 'cj-org-text)
+;;; cj-org-text.el ends here
diff --git a/tests/test-calendar-sync--sanitize-org-body.el b/tests/test-calendar-sync--sanitize-org-body.el
deleted file mode 100644
index 636946db..00000000
--- a/tests/test-calendar-sync--sanitize-org-body.el
+++ /dev/null
@@ -1,98 +0,0 @@
-;;; test-calendar-sync--sanitize-org-body.el --- Tests for org body sanitization -*- lexical-binding: t; -*-
-
-;;; Commentary:
-;; Unit tests for calendar-sync--sanitize-org-body.
-;; Ensures description text with org-special syntax (leading asterisks)
-;; is escaped to prevent corruption of the org file structure.
-
-;;; Code:
-
-(require 'ert)
-(require 'testutil-calendar-sync)
-(require 'calendar-sync)
-
-;;; Normal Cases
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-single-asterisk ()
- "Single leading asterisk replaced with dash."
- (should (equal "- item one" (calendar-sync--sanitize-org-body "* item one"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-double-asterisk ()
- "Double leading asterisks replaced with double dashes."
- (should (equal "-- sub-item" (calendar-sync--sanitize-org-body "** sub-item"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-triple-asterisk ()
- "Triple leading asterisks replaced with triple dashes."
- (should (equal "--- deep item" (calendar-sync--sanitize-org-body "*** deep item"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-multiline ()
- "Multiple lines with asterisks all get sanitized."
- (let ((input "Format:\n* What did you do yesterday?\n* What are you doing today?\n* Is anything in your way?")
- (expected "Format:\n- What did you do yesterday?\n- What are you doing today?\n- Is anything in your way?"))
- (should (equal expected (calendar-sync--sanitize-org-body input)))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-mixed-lines ()
- "Only lines starting with asterisks are changed."
- (let ((input "Normal line\n* Bullet line\nAnother normal line"))
- (should (equal "Normal line\n- Bullet line\nAnother normal line"
- (calendar-sync--sanitize-org-body input)))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-normal-mixed-levels ()
- "Lines with different asterisk counts are each handled."
- (let ((input "* Top\n** Middle\n*** Bottom"))
- (should (equal "- Top\n-- Middle\n--- Bottom"
- (calendar-sync--sanitize-org-body input)))))
-
-;;; Boundary Cases
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-nil-input ()
- "Nil input returns nil."
- (should (null (calendar-sync--sanitize-org-body nil))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-empty-string ()
- "Empty string returns empty string."
- (should (equal "" (calendar-sync--sanitize-org-body ""))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-no-asterisks ()
- "Text without leading asterisks is returned unchanged."
- (let ((input "Just a normal description\nwith multiple lines"))
- (should (equal input (calendar-sync--sanitize-org-body input)))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-asterisk-mid-line ()
- "Asterisks not at line start are left alone."
- (should (equal "Use * for emphasis" (calendar-sync--sanitize-org-body "Use * for emphasis"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-asterisk-no-space ()
- "Asterisk at line start without trailing space is not a heading — left alone."
- (should (equal "*bold text*" (calendar-sync--sanitize-org-body "*bold text*"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-body-boundary-asterisk-only ()
- "Lone asterisk with space at start of line is sanitized."
- (should (equal "- " (calendar-sync--sanitize-org-body "* "))))
-
-;;; Heading and Property Sanitizers
-
-(ert-deftest test-calendar-sync--sanitize-org-heading-flattens-newlines ()
- "Heading text should stay on one Org heading line."
- (should (equal "Planning Agenda"
- (calendar-sync--sanitize-org-heading "Planning\nAgenda"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-heading-replaces-leading-stars ()
- "Heading text should not start with Org heading stars."
- (should (equal "- Planning -- Hidden"
- (calendar-sync--sanitize-org-heading "* Planning\n** Hidden"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-property-value-flattens-structure ()
- "Property values should not create extra property drawer lines."
- (should (equal "Room 1 :END: * Not a heading"
- (calendar-sync--sanitize-org-property-value
- "Room 1\n:END:\n* Not a heading"))))
-
-(ert-deftest test-calendar-sync--sanitize-org-property-value-trims-and-collapses ()
- "Property values should be compact single-line values."
- (should (equal "alpha beta gamma"
- (calendar-sync--sanitize-org-property-value
- " alpha\t beta\n\n gamma "))))
-
-(provide 'test-calendar-sync--sanitize-org-body)
-;;; test-calendar-sync--sanitize-org-body.el ends here
diff --git a/tests/test-cj-org-text-sanitize.el b/tests/test-cj-org-text-sanitize.el
new file mode 100644
index 00000000..cdad9af5
--- /dev/null
+++ b/tests/test-cj-org-text-sanitize.el
@@ -0,0 +1,111 @@
+;;; test-cj-org-text-sanitize.el --- Tests for the Org-safe text sanitizers -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for `cj/org-sanitize-body-text', `cj/org-sanitize-property-value',
+;; and `cj/org-sanitize-heading' in cj-org-text.el. Pure string helpers
+;; for safely composing Org content from external sources (calendar
+;; bodies, web-clipped HTML, mail subjects, AI transcripts). Originally
+;; lived in calendar-sync.el under `calendar-sync--sanitize-*' names.
+
+;;; Code:
+
+(require 'ert)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'cj-org-text)
+
+;;; cj/org-sanitize-body-text -- Normal cases
+
+(ert-deftest test-cj-org-sanitize-body-single-asterisk ()
+ "Normal: single leading asterisk replaced with dash."
+ (should (equal "- item one" (cj/org-sanitize-body-text "* item one"))))
+
+(ert-deftest test-cj-org-sanitize-body-double-asterisk ()
+ "Normal: double leading asterisks replaced with double dashes."
+ (should (equal "-- sub-item" (cj/org-sanitize-body-text "** sub-item"))))
+
+(ert-deftest test-cj-org-sanitize-body-triple-asterisk ()
+ "Normal: triple leading asterisks replaced with triple dashes."
+ (should (equal "--- deep item" (cj/org-sanitize-body-text "*** deep item"))))
+
+(ert-deftest test-cj-org-sanitize-body-multiline ()
+ "Normal: multiple lines with leading stars all get sanitized."
+ (let ((input "Format:\n* What did you do yesterday?\n* What are you doing today?\n* Is anything in your way?")
+ (expected "Format:\n- What did you do yesterday?\n- What are you doing today?\n- Is anything in your way?"))
+ (should (equal expected (cj/org-sanitize-body-text input)))))
+
+(ert-deftest test-cj-org-sanitize-body-mixed-lines ()
+ "Normal: only lines starting with asterisks change."
+ (let ((input "Normal line\n* Bullet line\nAnother normal line"))
+ (should (equal "Normal line\n- Bullet line\nAnother normal line"
+ (cj/org-sanitize-body-text input)))))
+
+(ert-deftest test-cj-org-sanitize-body-mixed-levels ()
+ "Normal: lines with different asterisk counts each handled independently."
+ (let ((input "* Top\n** Middle\n*** Bottom"))
+ (should (equal "- Top\n-- Middle\n--- Bottom"
+ (cj/org-sanitize-body-text input)))))
+
+;;; cj/org-sanitize-body-text -- Boundary cases
+
+(ert-deftest test-cj-org-sanitize-body-nil-input ()
+ "Boundary: nil input returns nil."
+ (should (null (cj/org-sanitize-body-text nil))))
+
+(ert-deftest test-cj-org-sanitize-body-empty-string ()
+ "Boundary: empty string returns empty string."
+ (should (equal "" (cj/org-sanitize-body-text ""))))
+
+(ert-deftest test-cj-org-sanitize-body-no-asterisks ()
+ "Boundary: text without leading asterisks is returned unchanged."
+ (let ((input "Just a normal description\nwith multiple lines"))
+ (should (equal input (cj/org-sanitize-body-text input)))))
+
+(ert-deftest test-cj-org-sanitize-body-asterisk-mid-line ()
+ "Boundary: asterisks not at line start are left alone."
+ (should (equal "Use * for emphasis" (cj/org-sanitize-body-text "Use * for emphasis"))))
+
+(ert-deftest test-cj-org-sanitize-body-asterisk-no-space ()
+ "Boundary: asterisk at line start without trailing space is not a heading -- left alone."
+ (should (equal "*bold text*" (cj/org-sanitize-body-text "*bold text*"))))
+
+(ert-deftest test-cj-org-sanitize-body-asterisk-only ()
+ "Boundary: lone asterisk with space at start of line is sanitized."
+ (should (equal "- " (cj/org-sanitize-body-text "* "))))
+
+;;; cj/org-sanitize-heading
+
+(ert-deftest test-cj-org-sanitize-heading-flattens-newlines ()
+ "Normal: heading text stays on one Org heading line."
+ (should (equal "Planning Agenda"
+ (cj/org-sanitize-heading "Planning\nAgenda"))))
+
+(ert-deftest test-cj-org-sanitize-heading-replaces-leading-stars ()
+ "Normal: heading text doesn't start with Org heading stars."
+ (should (equal "- Planning -- Hidden"
+ (cj/org-sanitize-heading "* Planning\n** Hidden"))))
+
+(ert-deftest test-cj-org-sanitize-heading-nil-input ()
+ "Boundary: nil input returns nil."
+ (should (null (cj/org-sanitize-heading nil))))
+
+;;; cj/org-sanitize-property-value
+
+(ert-deftest test-cj-org-sanitize-property-value-flattens-structure ()
+ "Normal: property values do not create extra property drawer lines."
+ (should (equal "Room 1 :END: * Not a heading"
+ (cj/org-sanitize-property-value
+ "Room 1\n:END:\n* Not a heading"))))
+
+(ert-deftest test-cj-org-sanitize-property-value-trims-and-collapses ()
+ "Normal: property values are compact single-line values."
+ (should (equal "alpha beta gamma"
+ (cj/org-sanitize-property-value
+ " alpha\t beta\n\n gamma "))))
+
+(ert-deftest test-cj-org-sanitize-property-value-nil-input ()
+ "Boundary: nil input returns nil."
+ (should (null (cj/org-sanitize-property-value nil))))
+
+(provide 'test-cj-org-text-sanitize)
+;;; test-cj-org-text-sanitize.el ends here