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:
@@ -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:<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
|
||||
|
||||
@@ -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:<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
|
||||
|
||||
Reference in New Issue
Block a user