diff options
| -rw-r--r-- | modules/calendar-sync.el | 38 | ||||
| -rw-r--r-- | modules/cj-org-text.el | 58 | ||||
| -rw-r--r-- | tests/test-calendar-sync--sanitize-org-body.el | 98 | ||||
| -rw-r--r-- | tests/test-cj-org-text-sanitize.el | 111 |
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 |
