refactor(doom): equal-area/project node model + horizon links

Areas and projects are now treated identically: each gets a NAME tag
(drives its per-node agenda) and a CONTEXT tag (Perso/TDNDE, drives the
context GTD views). Drops the area-domain/area-tag/area-list machinery
and the injected per-area + aggregate (pw/ww) agenda views; horizon links
(purpose<->vision<->goal<->area<->project<->action) become bidirectional
PROPERTIES rather than tags, via gortium/link-horizon (NEW).
This commit is contained in:
2026-09-18 12:49:25 -04:00
parent d6731cf800
commit a59ba1138e
2 changed files with 194 additions and 338 deletions

View File

@@ -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
(list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))
(read-string "Node Name: ")
(gortium/read-emoji)
(completing-read "Area: " areas nil t)
(read-string "Project tag: ")))))
(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))
(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"
atag
area-or-domain category-emoji
(gortium/domain->mu4e area-or-domain))))
ntag context category-emoji context))) ; mu4e = context
(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 "?")))))))
(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)))
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:<uuid>][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

View File

@@ -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
(list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))
(read-string "Node Name: ")
(gortium/read-emoji)
(completing-read "Area: " areas nil t)
(read-string "Project tag: ")))))
(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))
(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"
atag
area-or-domain category-emoji
(gortium/domain->mu4e area-or-domain))))
ntag context category-emoji context))) ; mu4e = context
(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 "?")))))))
(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)))
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:<uuid>][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