(defun org-filetags-extract (content) "Extracts the list of tags from a #+FILETAGS: line." (let ((lines (uiop:split-string content :separator '(#\Newline)))) (dolist (line lines) (when (uiop:string-prefix-p "#+FILETAGS:" (string-trim '(#\Space) line)) (let ((tag-str (string-trim " :" (subseq (string-trim '(#\Space) line) 10)))) (return-from org-filetags-extract (mapcar (lambda (tag) (format nil ":~a" (string-trim '(#\Space) tag))) (uiop:split-string tag-str :separator '(#\space #\tab)))))))) nil) (defun org-privacy-tag-p (tags-list) "Returns T if any tag in TAGS-LIST matches bouncer-privacy-tags." (let ((privacy-tags (symbol-value (find-symbol "BOUNCER-PRIVACY-TAGS" :passepartout)))) (when (and tags-list privacy-tags) (some (lambda (tag) (some (lambda (private-tag) (string-equal (string-trim '(#\: #\space) tag) (string-trim '(#\: #\space) private-tag)) privacy-tags)) tags-list))))) (defun org-privacy-strip (content) "Removes Org headlines whose :TAGS: property contains a privacy-filtered tag. Returns the filtered content as a string." (let* ((lines (uiop:split-string content :separator '(#\Newline))) (result-lines nil) (skip-depth nil) (current-tags nil) (in-properties nil)) (dolist (line lines) (cond (skip-depth ;; We're inside a skipped subtree (when (and (uiop:string-prefix-p "*" (string-trim '(#\Space) line)) (<= (length (string-trim '(#\Space) line)) skip-depth)) (setf skip-depth nil))) ((uiop:string-prefix-p ":PROPERTIES:" (string-trim '(#\Space) line)) (setf in-properties t) (push line result-lines)) ((uiop:string-prefix-p ":END:" (string-trim '(#\Space) line)) (setf in-properties nil) (when current-tags (when (org-privacy-tag-p (reverse current-tags)) (setf skip-depth (length (car (last result-lines (1+ (position-if (lambda (l) (uiop:string-prefix-p "*" (string-trim '(#\Space) l))) (reverse result-lines)))))))) (setf current-tags nil)) (push line result-lines)) ((and in-properties (uiop:string-prefix-p ":TAGS:" (string-trim '(#\Space) line))) (let ((tag-val (string-trim '(#\Space) (subseq (string-trim '(#\Space) line) 6)))) (setf current-tags (uiop:split-string tag-val :separator '(#\space #\tab)))) (push line result-lines)) (t (push line result-lines)))) (format nil "~{~a~%~}" (nreverse result-lines)))) (defun org-read-file (filepath) "Reads an Org file into a string, applying privacy filtering." (let* ((raw (uiop:read-file-string filepath)) (filetags (org-filetags-extract raw))) (if (org-privacy-tag-p filetags) (progn (log-message "UTILS-ORG: Blocked read of ~a — file-level privacy tag(s) ~a" filepath filetags) nil) (org-privacy-strip raw)))) (defun org-write-file (filepath content) "Writes content to an Org file." (uiop:with-output-file (s filepath :if-exists :supersede) (format s "~a" content))) (defun org-id-generate () "Generates a new UUID for an Org node." (string-downcase (format nil "~a" (uuid:make-v4-uuid)))) (defun org-id-format (id) "Ensures the ID has the 'id:' prefix." (if (uiop:string-prefix-p "id:" id) id (format nil "id:~a" id))) (defun org-property-set (ast target-id property value) "Recursively sets a property on a headline with a matching ID in the AST." (let ((type (getf ast :type)) (props (getf ast :properties)) (contents (getf ast :contents))) (when (and (eq type :HEADLINE) (string= (getf props :ID) target-id)) (setf (getf (getf ast :properties) property) value) (return-from org-property-set t)) (dolist (child contents) (when (listp child) (when (org-property-set child target-id property value) (return-from org-property-set t))))) nil) (defun org-todo-set (ast target-id status) "Sets the TODO status of a headline in the AST." (org-property-set ast target-id :TODO status)) (defun org-headline-add (ast parent-id title) "Adds a new headline as a child of the parent-id in the AST." (let* ((type (getf ast :type)) (props (getf ast :properties)) (id (getf props :ID)) (contents (getf ast :contents))) (when (and (eq type :HEADLINE) (string= id parent-id)) (let ((new-node (list :type :HEADLINE :properties (list :ID (org-id-format (org-id-generate)) :TITLE title) :contents nil))) (setf (getf ast :contents) (append contents (list new-node))) (return-from org-headline-add t))) (dolist (child contents) (when (listp child) (when (org-headline-add child parent-id title) (return-from org-headline-add t))))) nil) (defun org-headline-find-by-id (ast id) "Finds a headline by its ID in the AST." (let ((props (getf ast :properties))) (when (string= (getf props :ID) id) (return-from org-headline-find-by-id ast)) (dolist (child (getf ast :contents)) (when (listp child) (let ((found (org-headline-find-by-id child id))) (when found (return-from org-headline-find-by-id found))))) nil)) (defun org-headline-find-by-title (ast title) "Finds a headline by its title in the AST." (let ((props (getf ast :properties))) (when (string-equal (getf props :TITLE) title) (return-from org-headline-find-by-title ast)) (dolist (child (getf ast :contents)) (when (listp child) (let ((found (org-headline-find-by-title child title))) (when found (return-from org-headline-find-by-title found))))) nil)) (defun org-subtree-extract (org-content heading-name) "Extracts a subtree by heading name from Org text. Returns the subtree content as a string (headline + body + children), or nil if not found." (let* ((lines (uiop:split-string org-content :separator '(#\Newline))) (target-depth nil) (in-target nil) (result nil)) (loop for line in lines for trimmed = (string-trim '(#\Space) line) do (let ((depth (when (uiop:string-prefix-p "*" trimmed) (length (subseq trimmed 0 (position-if (lambda (c) (not (char= c #\*))) trimmed))))) (headline-title (when (uiop:string-prefix-p "*" trimmed) (string-trim '(#\* #\Space) trimmed)))) (when depth (when (string-equal headline-title heading-name) (setf target-depth depth in-target t)) (when (and in-target target-depth (<= depth target-depth) (not (string-equal headline-title heading-name))) (return-from org-subtree-extract (format nil "~{~a~^~%~}" (nreverse result))))) (when in-target (push line result)))) (when result (format nil "~{~a~^~%~}" (nreverse result))))) (defun org-heading-list (org-content) "Returns a list of all top-level heading names in Org text." (let* ((lines (uiop:split-string org-content :separator '(#\Newline))) (headings nil)) (dolist (line lines) (let ((trimmed (string-trim '(#\Space) line))) (when (uiop:string-prefix-p "* " trimmed) (let ((title (string-trim '(#\* #\Space) trimmed))) (unless (find title headings :test #'string-equal) (push title headings)))))) (nreverse headings))) (defun org-modify (filepath old-text new-text) "Replaces all occurrences of OLD-TEXT with NEW-TEXT in filepath. Returns T if OLD-TEXT was found and replaced, nil if not found." (when (not (uiop:file-exists-p filepath)) (log-message "UTILS-ORG: org-modify: file not found: ~a" filepath) (return-from org-modify nil)) (let* ((content (uiop:read-file-string filepath)) (pos (search old-text content :test #'string=))) (unless pos (log-message "UTILS-ORG: org-modify: text not found in ~a" filepath) (return-from org-modify nil)) (let ((modified (cl-ppcre:regex-replace-all (cl-ppcre:quote-meta-chars old-text) content new-text))) (org-write-file filepath modified) (log-message "UTILS-ORG: Modified ~a (~d chars replaced)" filepath (length old-text)) t))) (defun org-ast-render (ast &key (depth 1)) "Converts a plist AST node back to Org text. AST format: (:TYPE :HEADLINE :properties (:ID ... :TITLE ... :TAGS (...)) :contents (child-ast ...))" (let* ((type (getf ast :TYPE)) (props (getf ast :properties)) (title (or (getf props :TITLE) "Untitled")) (tags (getf props :TAGS)) (todo (getf props :TODO-STATE)) (children (getf ast :contents)) (raw-content (getf ast :raw-content)) (stars (make-string depth :initial-element #\*)) (output "")) (unless (eq type :HEADLINE) (return-from org-ast-render (or raw-content ""))) ;; Headline (setf output (format nil "~a~@[ ~a~] ~a" stars todo title)) (when tags (let ((tag-str (format nil "~{~a~^:~}" (mapcar (lambda (t) (string-trim '(#\:) t)) tags)))) (setf output (concatenate 'string output (format nil " :~a::~%" tag-str)))) (setf output (concatenate 'string output (string #\Newline)))) (unless tags (setf output (concatenate 'string output (string #\Newline)))) ;; Property drawer (setf output (concatenate 'string output ":PROPERTIES:" (string #\Newline))) (loop for (k v) on props by #'cddr do (unless (or (eq k :TITLE) (eq k :TAGS)) (setf output (concatenate 'string output (format nil ":~a: ~a~%" k v))))) (setf output (concatenate 'string output ":END:" (string #\Newline))) ;; Content (when raw-content (setf output (concatenate 'string output raw-content (string #\Newline)))) ;; Children (dolist (child children) (when (listp child) (setf output (concatenate 'string output (org-ast-render child :depth (1+ depth)))))) output)) (defskill :passepartout-programming-org :priority 100 :trigger (lambda (ctx) (declare (ignore ctx)) nil)) (defpackage :passepartout-utils-org-tests (:use :cl :fiveam :passepartout) (:export #:utils-org-suite)) (in-package :passepartout-utils-org-tests) (def-suite utils-org-suite :description "Tests for Utils Org skill.") (in-suite utils-org-suite) (test id-generation (let ((id1 (org-id-generate)) (id2 (org-id-generate))) (is (plusp (length id1))) (is (not (string= id1 id2))))) (test id-format (let ((formatted (org-id-format "abc12345"))) (is (search "id:" formatted)))) (test property-setter (let ((ast (list :type :HEADLINE :properties (list :ID "id:test123" :TITLE "Test") :contents nil))) (org-property-set ast "id:test123" :STATUS "ACTIVE") (is (string= (getf (getf ast :properties) :STATUS) "ACTIVE")))) (test todo-setter (let ((ast (list :type :HEADLINE :properties (list :ID "id:todo001" :TITLE "Task") :contents nil))) (org-todo-set ast "id:todo001" "DONE") (is (string= (getf (getf ast :properties) :TODO) "DONE"))))