diff --git a/doom/.config/doom/README.org b/doom/.config/doom/README.org index 1addd5f..a7e782b 100644 --- a/doom/.config/doom/README.org +++ b/doom/.config/doom/README.org @@ -3915,106 +3915,37 @@ gating; when nil it falls back to CONTEXT-TAG." ;;; Interactive Entry Points ;;; ==================================================================== -;; Area / project tag helpers -(defun gortium/area-list () - "Return the sorted list of area node names under 2-Areas/." - (let ((areas-dir (expand-file-name "2-Areas" gortium/exokortex-root))) - (when (file-directory-p areas-dir) - (sort (seq-filter (lambda (n) (not (string-prefix-p "." n))) - (directory-files areas-dir nil "^[^.]")) - #'string<)))) +;; Node creation — areas and projects are equal: each gets a NAME tag +;; (drives its own per-node agenda) and a CONTEXT tag (Perso/TDNDE, drives the +;; context GTD views). Horizon links (project<->goal, project<->area, area<->goal) +;; are bidirectional PROPERTIES, not tags. -(defun gortium/domain->mu4e (domain) - "Map an area DOMAIN (personal/work) to its mu4e context name." - (if (string= domain "work") "TDNDE" "Perso")) - -(defun gortium/area-domain (area-name) - "Return the domain (personal|work) of AREA-NAME from its plan FILETAGS." - (let ((af (expand-file-name (concat area-name ".org") - (expand-file-name "1-Plan" - (expand-file-name area-name - (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (when (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (when (re-search-forward "^#\\+FILETAGS:.*:\\\(personal\\|work\\):" nil t) - (match-string 1)))))) - -(defvar gortium/area-view-keys nil - "List of injected per-area/aggregate agenda command keys (for exact removal).") - -(defun gortium/area-tag (area-name) - "Return AREA-NAME's own tag (FILETAGS slot-1); defaults to the area name." - (let ((af (expand-file-name (concat area-name ".org") - (expand-file-name "1-Plan" - (expand-file-name area-name - (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (if (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (if (re-search-forward "^#\\+FILETAGS:[ \t]*:?\\\([^:]+\\):" nil t) - (match-string 1) - area-name)) - area-name))) - -(defun gortium/create-node (category node-name category-emoji area-or-domain tag) - "Create a brand new standardized PARA Node inside your ExoKortex vault. -Category 2-Areas creates an AREA: gets Name, Emoji, Domain (personal/work) -and writes FILETAGS _:name:domain:_. Other categories create a PROJECT: -gets Name, Emoji, Area (dropdown of 2-Areas/*) and Tag, writes -FILETAGS _:tag:area:_ and inherits the area's mu4e context for dir-locals." +(defun gortium/create-node (category node-name category-emoji name-tag context) + "Create a brand new standardized PARA node (area or project, treated equally). +Both get Name, Emoji, a NAME tag (drives the per-node custom agenda) and a +CONTEXT (Perso/TDNDE, drives the context GTD views). FILETAGS = :name-tag:context:. +Horizon links are separate bidirectional properties, added later." (interactive - (let* ((category (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))) - (areas (gortium/area-list))) - (if (string= category "2-Areas") - (list category - (read-string "Area Name: ") - (gortium/read-emoji) - (completing-read "Domain: " '("personal" "work")) - (read-string "Area tag (default = area name): ")) - (list category - (read-string "Node Name: ") - (gortium/read-emoji) - (completing-read "Area: " areas nil t) - (read-string "Project tag: "))))) + (list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources")) + (read-string "Node Name: ") + (gortium/read-emoji) + (read-string "Name tag (default = node name): ") + (completing-read "Context: " '("Perso" "TDNDE")))) (let* ((category-dir (expand-file-name category gortium/exokortex-root)) - (node-dir (expand-file-name node-name category-dir))) - (if (string= category "2-Areas") - ;; AREA: FILETAGS = :tag:domain: (tag chosen, defaults to area name) - (let* ((atag (if (string-empty-p tag) node-name tag)) - (org-file (gortium/ensure-node-structure node-dir - "Initialize project context" - atag - area-or-domain category-emoji - (gortium/domain->mu4e area-or-domain)))) - (find-file org-file) - (message "ExoKortex Area '%s' created (tag: %s, domain: %s, mu4e: %s)." - node-name atag area-or-domain (gortium/domain->mu4e area-or-domain))) - ;; PROJECT: tag = tag, slot-2 = area; mu4e context inherited from area - (let* ((area-file (expand-file-name (concat area-or-domain ".org") - (expand-file-name - "1-Plan" - (expand-file-name area-or-domain - (expand-file-name "2-Areas" gortium/exokortex-root))))) - (area-mu4e (when (file-exists-p area-file) - (with-temp-buffer - (insert-file-contents area-file) - (when (re-search-forward "^#\\+FILETAGS:.*:\\(personal\\|work\\):" nil t) - (gortium/domain->mu4e (match-string 1))))))) - (let ((org-file (gortium/ensure-node-structure node-dir - "Initialize project context" - tag area-or-domain category-emoji area-mu4e))) - (find-file org-file) - (message "ExoKortex Project '%s' created (tag: %s, area: %s, mu4e: %s)." - node-name tag area-or-domain (or area-mu4e "?"))))))) + (node-dir (expand-file-name node-name category-dir)) + (ntag (if (string-empty-p name-tag) node-name name-tag)) + (org-file (gortium/ensure-node-structure node-dir + "Initialize project context" + ntag context category-emoji context))) ; mu4e = context + (find-file org-file) + (message "ExoKortex %s '%s' created (tag: %s, context: %s)." + category node-name ntag context))) -(defun gortium/convert-to-node (node-dirs &optional project-tag area-or-domain category-emoji mu4e-context) +(defun gortium/convert-to-node (node-dirs &optional name-tag context category-emoji) "Retrofit existing workspace directories into tracked ExoKortex nodes. If folders are explicitly marked in Dirvish/Dired, it batch-processes all of them. Otherwise, prompts for a single directory, defaulting to the item under the cursor. -Optional PROJECT-TAG and AREA-OR-DOMAIN set the plan FILETAGS (project: _:tag:area:_, -area: _:name:domain:_). Optional CATEGORY-EMOJI is the #+CATEGORY:. Optional -MU4E-CONTEXT (Perso/TDNDE) is the dir-locals gortium/context-tag." +Each node gets a NAME tag and a CONTEXT (Perso/TDNDE). FILETAGS = :name-tag:context:." (interactive (let* ((dired-elements (when (derived-mode-p 'dired-mode) (dired-get-marked-files nil nil nil t))) @@ -4035,98 +3966,94 @@ MU4E-CONTEXT (Perso/TDNDE) is the dir-locals gortium/context-tag." (file-name-directory cursor-path)) (t default-directory)))) (list (read-directory-name "Directory to convert: " initial-path initial-path t))))) - (is-area (string= (file-name-nondirectory - (directory-file-name (expand-file-name ".." (car dirs)))) - "2-Areas")) - (area-or-domain (if is-area - (completing-read "Domain: " '("personal" "work")) - (completing-read "Area: " (gortium/area-list) nil t))) - (tag (unless is-area (read-string "Project tag: "))) (emoji (let ((gortium--emoji (gortium/read-emoji))) (if (string-empty-p gortium--emoji) nil gortium--emoji)))) - (list dirs tag area-or-domain emoji nil))) + (list dirs + (read-string "Name tag (default = node name): ") + (completing-read "Context: " '("Perso" "TDNDE")) + emoji))) (let ((count 0) last-org-file) (dolist (dir node-dirs) (let* ((node-dir (expand-file-name dir)) - (is-area (string= (file-name-nondirectory (directory-file-name (expand-file-name ".." node-dir))) - "2-Areas")) - (tag2 (unless (string-empty-p project-tag) project-tag)) - (mctx (or mu4e-context - (when (and (not is-area) area-or-domain) - (let ((af (expand-file-name - (concat area-or-domain ".org") - (expand-file-name "1-Plan" - (expand-file-name area-or-domain (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (when (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (when (re-search-forward "^#\\+FILETAGS:.*:\\(personal\\|work\\):" nil t) - (gortium/domain->mu4e (match-string 1))))))))) - (ctx2 (if is-area area-or-domain (or area-or-domain ""))) + (default-tag (file-name-nondirectory (directory-file-name node-dir))) + (tag2 (if (string-empty-p name-tag) default-tag name-tag)) (org-file (gortium/ensure-node-structure node-dir - "Audit existing assets and reconcile tracking nodes" - (if is-area (file-name-nondirectory (directory-file-name node-dir)) project-tag) - ctx2 category-emoji mctx))) + "Audit existing assets and reconcile tracking nodes" + tag2 context category-emoji context))) (setq last-org-file org-file) (cl-incf count))) - (if (= count 1) (message "Directory successfully converted to ExoKortex node.") (message "Successfully batch-converted %d directories to ExoKortex nodes!" count)))) +(defun gortium/link-horizon () + "Create a bidirectional horizon property link between the current org node +and another org node. Stamps a live :ID: on both, then writes the reciprocal +property pair (the FROM tag learned from the pair map) onto each node:: + purpose<->vision DRIVES/DRIVEN-BY; vision<->goal YIELDS/YIELDED-BY; + goal<->area FRAMES/FRAMED-BY; area<->project GENERATES/GENERATED-BY; + project<->action NEEDS/NEEDED-BY; project<->goal ALIGNS-WITH/ALIGNED-BY; + area<->goal SUPPORTS/SUPPORTED-BY; project<->area BELONGS-TO/OWNS. +Tags are stored in the :PROPERTIES: drawer as [[id:][name]] links." + (interactive) + (let* ((this-file (buffer-file-name)) + (other-file (read-file-name "Link to org file: " nil nil t nil + (lambda (f) (string-suffix-p ".org" f)))) + (from (downcase (completing-read "Type of the node you are linking FROM: " + '("purpose" "vision" "goal" "area" "project" "action")))) + (to (downcase (completing-read "Type of the node you are linking TO: " + '("purpose" "vision" "goal" "area" "project" "action")))) + (edge (gortium/horizon-edge from to)) + (id-a (gortium/stamp-id this-file)) + (id-b (gortium/stamp-id other-file))) + ;; write A->B on this file, B->A on the other + (gortium/write-horizon-edge this-file (car edge) id-b) + (gortium/write-horizon-edge other-file (cadr edge) id-a) + (message "Linked '%s' -> '%s' (and reciprocal)." this-file other-file))) -(defun gortium/area-view-body () - "The block list shared by every area/aggregate GTD view." - (list - (list "agenda" "" - (list (list 'org-agenda-span 'week))) - (list "todo" "STRT" (list (list 'org-agenda-overriding-header gtd/started-head))) - (list "todo" "NEXT" (list (list 'org-agenda-overriding-header gtd/next-action-head))) - (list "todo" "WAIT" (list (list 'org-agenda-overriding-header gtd/waiting-head))) - (list "gortium/stuck-plans" "" (list (list 'org-agenda-overriding-header gtd/project-head))) - (list "todo" "IDEA" (list (list 'org-agenda-overriding-header gtd/someday-head))))) +(defun gortium/horizon-edge (from to) + "Return ((FROM->TO) (TO->FROM)) property names for a FROM/TO altitude pair." + (let* ((pairs '(("purpose" "vision" "DRIVES" "DRIVEN-BY") + ("vision" "goal" "YIELDS" "YIELDED-BY") + ("goal" "area" "FRAMES" "FRAMED-BY") + ("area" "project" "GENERATES" "GENERATED-BY") + ("project" "action" "NEEDS" "NEEDED-BY") + ("project" "goal" "ALIGNS-WITH" "ALIGNED-BY") + ("area" "goal" "SUPPORTS" "SUPPORTED-BY") + ("project" "area" "BELONGS-TO" "OWNS"))) + (direct (cl-find-if (lambda (pr) (and (string= (car pr) from) (string= (cadr pr) to))) + pairs)) + (rev (cl-find-if (lambda (pr) (and (string= (car pr) to) (string= (cadr pr) from))) + pairs))) + (cond + (direct (list (nth 2 direct) (nth 3 direct))) + (rev (list (nth 3 rev) (nth 2 rev))) + (t (error "No predefined horizon edge %s<->%s; add it to the pair map." + from to))))) -(defun gortium/inject-area-views () - "Recompute per-area + aggregate (personal/work) agenda views from 2-Areas/*. -Removes prior injected keys (gortium/area-view-keys) exactly, then rebuilds. -Per-area key: \"a\"+area-name (unique). Aggregates: \"pw\" (all personal), \"ww\" (all work)." - (let* ((areas (gortium/area-list)) - (personal-tags - (mapcar (lambda (a) (concat "+" (gortium/area-tag a))) - (seq-filter (lambda (a) (string= (gortium/area-domain a) "personal")) areas))) - (work-tags - (mapcar (lambda (a) (concat "+" (gortium/area-tag a))) - (seq-filter (lambda (a) (string= (gortium/area-domain a) "work")) areas)))) - ;; 1) drop stale injected keys - (setq org-agenda-custom-commands - (cl-remove-if (lambda (e) (member (car e) gortium/area-view-keys)) - org-agenda-custom-commands)) - (setq gortium/area-view-keys nil) - ;; 2) per-area views - (dolist (a areas) - (let ((key (concat "a" a))) - (push key gortium/area-view-keys) - (push (list key (format "GTD — %s" a) - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset (list (concat "+" (gortium/area-tag a)))))) - org-agenda-custom-commands))) - ;; 3) aggregates - (when personal-tags - (push "pw" gortium/area-view-keys) - (push (list "pw" "📋 All personal areas — GTD" - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset personal-tags))) - org-agenda-custom-commands)) - (when work-tags - (push "ww" gortium/area-view-keys) - (push (list "ww" "💼 All work areas — GTD" - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset work-tags))) - org-agenda-custom-commands)))) +(defun gortium/write-horizon-edge (file prop target-id) + "Ensure FILE's first headline has an :ID: and set property PROP to +[[id:TARGET-ID]]. Saves the buffer so the reciprocal link persists." + (with-current-buffer (find-file-noselect file) + (org-mode) + (goto-char (point-min)) + (unless (org-at-heading-p) + (if (re-search-forward "^\\*+[ \t]+" nil t) + (goto-char (match-beginning 0)))) + (org-id-get-create) + (org-set-property prop (format "[[id:%s][%s]]" target-id + (or (org-get-heading t t t t) "link"))) + (save-buffer))) + +(defun gortium/stamp-id (file) + "Return the :ID: of the first headline in FILE, creating it if absent." + (with-current-buffer (find-file-noselect file) + (org-mode) + (goto-char (point-min)) + (if (re-search-forward "^\\*+" nil t) (goto-char (match-beginning 0))) + (org-id-get-create))) -(gortium/inject-area-views) -(add-hook 'org-agenda-mode-hook #'gortium/inject-area-views) (defun gortium/read-dir-local (var) "Read variable VAR from .dir-locals.el in `default-directory'. @@ -4141,6 +4068,7 @@ Returns nil if file doesn't exist or VAR isn't set." (when nil-entry (cdr (assq var (cdr nil-entry))))))))) + (defun gortium/project-aware-agenda () "Launch org-agenda scoped to the current Projectile project. Reads `gortium/project-agenda-entry' from .dir-locals.el and merges diff --git a/doom/.config/doom/config.el b/doom/.config/doom/config.el index d78cefc..382b4c7 100644 --- a/doom/.config/doom/config.el +++ b/doom/.config/doom/config.el @@ -2972,106 +2972,37 @@ gating; when nil it falls back to CONTEXT-TAG." ;;; Interactive Entry Points ;;; ==================================================================== -;; Area / project tag helpers -(defun gortium/area-list () - "Return the sorted list of area node names under 2-Areas/." - (let ((areas-dir (expand-file-name "2-Areas" gortium/exokortex-root))) - (when (file-directory-p areas-dir) - (sort (seq-filter (lambda (n) (not (string-prefix-p "." n))) - (directory-files areas-dir nil "^[^.]")) - #'string<)))) +;; Node creation — areas and projects are equal: each gets a NAME tag +;; (drives its own per-node agenda) and a CONTEXT tag (Perso/TDNDE, drives the +;; context GTD views). Horizon links (project<->goal, project<->area, area<->goal) +;; are bidirectional PROPERTIES, not tags. -(defun gortium/domain->mu4e (domain) - "Map an area DOMAIN (personal/work) to its mu4e context name." - (if (string= domain "work") "TDNDE" "Perso")) - -(defun gortium/area-domain (area-name) - "Return the domain (personal|work) of AREA-NAME from its plan FILETAGS." - (let ((af (expand-file-name (concat area-name ".org") - (expand-file-name "1-Plan" - (expand-file-name area-name - (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (when (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (when (re-search-forward "^#\\+FILETAGS:.*:\\\(personal\\|work\\):" nil t) - (match-string 1)))))) - -(defvar gortium/area-view-keys nil - "List of injected per-area/aggregate agenda command keys (for exact removal).") - -(defun gortium/area-tag (area-name) - "Return AREA-NAME's own tag (FILETAGS slot-1); defaults to the area name." - (let ((af (expand-file-name (concat area-name ".org") - (expand-file-name "1-Plan" - (expand-file-name area-name - (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (if (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (if (re-search-forward "^#\\+FILETAGS:[ \t]*:?\\\([^:]+\\):" nil t) - (match-string 1) - area-name)) - area-name))) - -(defun gortium/create-node (category node-name category-emoji area-or-domain tag) - "Create a brand new standardized PARA Node inside your ExoKortex vault. -Category 2-Areas creates an AREA: gets Name, Emoji, Domain (personal/work) -and writes FILETAGS _:name:domain:_. Other categories create a PROJECT: -gets Name, Emoji, Area (dropdown of 2-Areas/*) and Tag, writes -FILETAGS _:tag:area:_ and inherits the area's mu4e context for dir-locals." +(defun gortium/create-node (category node-name category-emoji name-tag context) + "Create a brand new standardized PARA node (area or project, treated equally). +Both get Name, Emoji, a NAME tag (drives the per-node custom agenda) and a +CONTEXT (Perso/TDNDE, drives the context GTD views). FILETAGS = :name-tag:context:. +Horizon links are separate bidirectional properties, added later." (interactive - (let* ((category (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))) - (areas (gortium/area-list))) - (if (string= category "2-Areas") - (list category - (read-string "Area Name: ") - (gortium/read-emoji) - (completing-read "Domain: " '("personal" "work")) - (read-string "Area tag (default = area name): ")) - (list category - (read-string "Node Name: ") - (gortium/read-emoji) - (completing-read "Area: " areas nil t) - (read-string "Project tag: "))))) + (list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources")) + (read-string "Node Name: ") + (gortium/read-emoji) + (read-string "Name tag (default = node name): ") + (completing-read "Context: " '("Perso" "TDNDE")))) (let* ((category-dir (expand-file-name category gortium/exokortex-root)) - (node-dir (expand-file-name node-name category-dir))) - (if (string= category "2-Areas") - ;; AREA: FILETAGS = :tag:domain: (tag chosen, defaults to area name) - (let* ((atag (if (string-empty-p tag) node-name tag)) - (org-file (gortium/ensure-node-structure node-dir - "Initialize project context" - atag - area-or-domain category-emoji - (gortium/domain->mu4e area-or-domain)))) - (find-file org-file) - (message "ExoKortex Area '%s' created (tag: %s, domain: %s, mu4e: %s)." - node-name atag area-or-domain (gortium/domain->mu4e area-or-domain))) - ;; PROJECT: tag = tag, slot-2 = area; mu4e context inherited from area - (let* ((area-file (expand-file-name (concat area-or-domain ".org") - (expand-file-name - "1-Plan" - (expand-file-name area-or-domain - (expand-file-name "2-Areas" gortium/exokortex-root))))) - (area-mu4e (when (file-exists-p area-file) - (with-temp-buffer - (insert-file-contents area-file) - (when (re-search-forward "^#\\+FILETAGS:.*:\\(personal\\|work\\):" nil t) - (gortium/domain->mu4e (match-string 1))))))) - (let ((org-file (gortium/ensure-node-structure node-dir - "Initialize project context" - tag area-or-domain category-emoji area-mu4e))) - (find-file org-file) - (message "ExoKortex Project '%s' created (tag: %s, area: %s, mu4e: %s)." - node-name tag area-or-domain (or area-mu4e "?"))))))) + (node-dir (expand-file-name node-name category-dir)) + (ntag (if (string-empty-p name-tag) node-name name-tag)) + (org-file (gortium/ensure-node-structure node-dir + "Initialize project context" + ntag context category-emoji context))) ; mu4e = context + (find-file org-file) + (message "ExoKortex %s '%s' created (tag: %s, context: %s)." + category node-name ntag context))) -(defun gortium/convert-to-node (node-dirs &optional project-tag area-or-domain category-emoji mu4e-context) +(defun gortium/convert-to-node (node-dirs &optional name-tag context category-emoji) "Retrofit existing workspace directories into tracked ExoKortex nodes. If folders are explicitly marked in Dirvish/Dired, it batch-processes all of them. Otherwise, prompts for a single directory, defaulting to the item under the cursor. -Optional PROJECT-TAG and AREA-OR-DOMAIN set the plan FILETAGS (project: _:tag:area:_, -area: _:name:domain:_). Optional CATEGORY-EMOJI is the #+CATEGORY:. Optional -MU4E-CONTEXT (Perso/TDNDE) is the dir-locals gortium/context-tag." +Each node gets a NAME tag and a CONTEXT (Perso/TDNDE). FILETAGS = :name-tag:context:." (interactive (let* ((dired-elements (when (derived-mode-p 'dired-mode) (dired-get-marked-files nil nil nil t))) @@ -3092,98 +3023,94 @@ MU4E-CONTEXT (Perso/TDNDE) is the dir-locals gortium/context-tag." (file-name-directory cursor-path)) (t default-directory)))) (list (read-directory-name "Directory to convert: " initial-path initial-path t))))) - (is-area (string= (file-name-nondirectory - (directory-file-name (expand-file-name ".." (car dirs)))) - "2-Areas")) - (area-or-domain (if is-area - (completing-read "Domain: " '("personal" "work")) - (completing-read "Area: " (gortium/area-list) nil t))) - (tag (unless is-area (read-string "Project tag: "))) (emoji (let ((gortium--emoji (gortium/read-emoji))) (if (string-empty-p gortium--emoji) nil gortium--emoji)))) - (list dirs tag area-or-domain emoji nil))) + (list dirs + (read-string "Name tag (default = node name): ") + (completing-read "Context: " '("Perso" "TDNDE")) + emoji))) (let ((count 0) last-org-file) (dolist (dir node-dirs) (let* ((node-dir (expand-file-name dir)) - (is-area (string= (file-name-nondirectory (directory-file-name (expand-file-name ".." node-dir))) - "2-Areas")) - (tag2 (unless (string-empty-p project-tag) project-tag)) - (mctx (or mu4e-context - (when (and (not is-area) area-or-domain) - (let ((af (expand-file-name - (concat area-or-domain ".org") - (expand-file-name "1-Plan" - (expand-file-name area-or-domain (expand-file-name "2-Areas" gortium/exokortex-root)))))) - (when (file-exists-p af) - (with-temp-buffer - (insert-file-contents af) - (when (re-search-forward "^#\\+FILETAGS:.*:\\(personal\\|work\\):" nil t) - (gortium/domain->mu4e (match-string 1))))))))) - (ctx2 (if is-area area-or-domain (or area-or-domain ""))) + (default-tag (file-name-nondirectory (directory-file-name node-dir))) + (tag2 (if (string-empty-p name-tag) default-tag name-tag)) (org-file (gortium/ensure-node-structure node-dir - "Audit existing assets and reconcile tracking nodes" - (if is-area (file-name-nondirectory (directory-file-name node-dir)) project-tag) - ctx2 category-emoji mctx))) + "Audit existing assets and reconcile tracking nodes" + tag2 context category-emoji context))) (setq last-org-file org-file) (cl-incf count))) - (if (= count 1) (message "Directory successfully converted to ExoKortex node.") (message "Successfully batch-converted %d directories to ExoKortex nodes!" count)))) +(defun gortium/link-horizon () + "Create a bidirectional horizon property link between the current org node +and another org node. Stamps a live :ID: on both, then writes the reciprocal +property pair (the FROM tag learned from the pair map) onto each node:: + purpose<->vision DRIVES/DRIVEN-BY; vision<->goal YIELDS/YIELDED-BY; + goal<->area FRAMES/FRAMED-BY; area<->project GENERATES/GENERATED-BY; + project<->action NEEDS/NEEDED-BY; project<->goal ALIGNS-WITH/ALIGNED-BY; + area<->goal SUPPORTS/SUPPORTED-BY; project<->area BELONGS-TO/OWNS. +Tags are stored in the :PROPERTIES: drawer as [[id:][name]] links." + (interactive) + (let* ((this-file (buffer-file-name)) + (other-file (read-file-name "Link to org file: " nil nil t nil + (lambda (f) (string-suffix-p ".org" f)))) + (from (downcase (completing-read "Type of the node you are linking FROM: " + '("purpose" "vision" "goal" "area" "project" "action")))) + (to (downcase (completing-read "Type of the node you are linking TO: " + '("purpose" "vision" "goal" "area" "project" "action")))) + (edge (gortium/horizon-edge from to)) + (id-a (gortium/stamp-id this-file)) + (id-b (gortium/stamp-id other-file))) + ;; write A->B on this file, B->A on the other + (gortium/write-horizon-edge this-file (car edge) id-b) + (gortium/write-horizon-edge other-file (cadr edge) id-a) + (message "Linked '%s' -> '%s' (and reciprocal)." this-file other-file))) -(defun gortium/area-view-body () - "The block list shared by every area/aggregate GTD view." - (list - (list "agenda" "" - (list (list 'org-agenda-span 'week))) - (list "todo" "STRT" (list (list 'org-agenda-overriding-header gtd/started-head))) - (list "todo" "NEXT" (list (list 'org-agenda-overriding-header gtd/next-action-head))) - (list "todo" "WAIT" (list (list 'org-agenda-overriding-header gtd/waiting-head))) - (list "gortium/stuck-plans" "" (list (list 'org-agenda-overriding-header gtd/project-head))) - (list "todo" "IDEA" (list (list 'org-agenda-overriding-header gtd/someday-head))))) +(defun gortium/horizon-edge (from to) + "Return ((FROM->TO) (TO->FROM)) property names for a FROM/TO altitude pair." + (let* ((pairs '(("purpose" "vision" "DRIVES" "DRIVEN-BY") + ("vision" "goal" "YIELDS" "YIELDED-BY") + ("goal" "area" "FRAMES" "FRAMED-BY") + ("area" "project" "GENERATES" "GENERATED-BY") + ("project" "action" "NEEDS" "NEEDED-BY") + ("project" "goal" "ALIGNS-WITH" "ALIGNED-BY") + ("area" "goal" "SUPPORTS" "SUPPORTED-BY") + ("project" "area" "BELONGS-TO" "OWNS"))) + (direct (cl-find-if (lambda (pr) (and (string= (car pr) from) (string= (cadr pr) to))) + pairs)) + (rev (cl-find-if (lambda (pr) (and (string= (car pr) to) (string= (cadr pr) from))) + pairs))) + (cond + (direct (list (nth 2 direct) (nth 3 direct))) + (rev (list (nth 3 rev) (nth 2 rev))) + (t (error "No predefined horizon edge %s<->%s; add it to the pair map." + from to))))) -(defun gortium/inject-area-views () - "Recompute per-area + aggregate (personal/work) agenda views from 2-Areas/*. -Removes prior injected keys (gortium/area-view-keys) exactly, then rebuilds. -Per-area key: \"a\"+area-name (unique). Aggregates: \"pw\" (all personal), \"ww\" (all work)." - (let* ((areas (gortium/area-list)) - (personal-tags - (mapcar (lambda (a) (concat "+" (gortium/area-tag a))) - (seq-filter (lambda (a) (string= (gortium/area-domain a) "personal")) areas))) - (work-tags - (mapcar (lambda (a) (concat "+" (gortium/area-tag a))) - (seq-filter (lambda (a) (string= (gortium/area-domain a) "work")) areas)))) - ;; 1) drop stale injected keys - (setq org-agenda-custom-commands - (cl-remove-if (lambda (e) (member (car e) gortium/area-view-keys)) - org-agenda-custom-commands)) - (setq gortium/area-view-keys nil) - ;; 2) per-area views - (dolist (a areas) - (let ((key (concat "a" a))) - (push key gortium/area-view-keys) - (push (list key (format "GTD — %s" a) - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset (list (concat "+" (gortium/area-tag a)))))) - org-agenda-custom-commands))) - ;; 3) aggregates - (when personal-tags - (push "pw" gortium/area-view-keys) - (push (list "pw" "📋 All personal areas — GTD" - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset personal-tags))) - org-agenda-custom-commands)) - (when work-tags - (push "ww" gortium/area-view-keys) - (push (list "ww" "💼 All work areas — GTD" - (gortium/area-view-body) - (list (list 'org-agenda-tag-filter-preset work-tags))) - org-agenda-custom-commands)))) +(defun gortium/write-horizon-edge (file prop target-id) + "Ensure FILE's first headline has an :ID: and set property PROP to +[[id:TARGET-ID]]. Saves the buffer so the reciprocal link persists." + (with-current-buffer (find-file-noselect file) + (org-mode) + (goto-char (point-min)) + (unless (org-at-heading-p) + (if (re-search-forward "^\\*+[ \t]+" nil t) + (goto-char (match-beginning 0)))) + (org-id-get-create) + (org-set-property prop (format "[[id:%s][%s]]" target-id + (or (org-get-heading t t t t) "link"))) + (save-buffer))) + +(defun gortium/stamp-id (file) + "Return the :ID: of the first headline in FILE, creating it if absent." + (with-current-buffer (find-file-noselect file) + (org-mode) + (goto-char (point-min)) + (if (re-search-forward "^\\*+" nil t) (goto-char (match-beginning 0))) + (org-id-get-create))) -(gortium/inject-area-views) -(add-hook 'org-agenda-mode-hook #'gortium/inject-area-views) (defun gortium/read-dir-local (var) "Read variable VAR from .dir-locals.el in `default-directory'. @@ -3198,6 +3125,7 @@ Returns nil if file doesn't exist or VAR isn't set." (when nil-entry (cdr (assq var (cdr nil-entry))))))))) + (defun gortium/project-aware-agenda () "Launch org-agenda scoped to the current Projectile project. Reads `gortium/project-agenda-entry' from .dir-locals.el and merges