PARA: rename 2-Assets to 2-Inputs (PINO) — ensure-node-structure blueprint + Home-Infra roam/bookmark/dict/rss paths

This commit is contained in:
2026-09-17 15:02:59 -04:00
parent 4770d08127
commit 343dc241e7
2 changed files with 652 additions and 235 deletions

View File

@@ -128,7 +128,7 @@ No more auto-discovery of random =.git= repos.
#+begin_src emacs-lisp
(setq bookmark-save-flag 1)
(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark"))
(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/bookmark"))
#+end_src
** Buffer auto reload
@@ -182,10 +182,10 @@ My current workflow consist in having the 3-5 files I work on open in vertical s
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/4-Outputs/dotfiles"))
(defvar roam-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Assets/Roam"))
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/Roam"))
(defvar dict-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Assets/Dictionaries"))
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/Dictionaries"))
(defvar journal-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Journal"))
@@ -336,8 +336,37 @@ org-agenda-files (seq-uniq org-agenda-files)
(setq org-stuck-projects
'("TODO=\"PROJ\"" ("NEXT") nil ""))
(setq org-agenda-exporter-settings '((ps-print-color-p 'nil)))
(defun gortium/stuck-plans (&rest _)
"Agenda block: list plan files that contain no NEXT headline = stuck.
Each line jumps to the file (org-hd-marker on the first headline)."
(org-agenda-prepare)
(let ((inhibit-read-only t)
(case-fold-search nil)
(root (expand-file-name "~/ExoKortex")))
(when org-agenda-overriding-header
(insert (org-add-props
(concat org-agenda-overriding-header "\n") nil
'face 'org-agenda-structure)))
(dolist (f (directory-files-recursively root "\\.org\\'" t))
(when (and (string-match-p "1-Plan/" f)
(not (string-match-p "\\.stversions\\|4-Archives" f)))
(let ((stuck-p (with-temp-buffer
(insert-file-contents f)
(not (re-search-forward "^\\*+[ \t]+NEXT\\b" nil t)))))
(when stuck-p
(let ((txt (concat "" (file-name-nondirectory f) " "))
(marker (with-current-buffer (find-file-noselect f)
(org-agenda-new-marker
(save-excursion
(goto-char (point-min))
(if (re-search-forward "^\\*" nil t)
(match-beginning 0) (point-min)))))))
(org-add-props txt nil
'org-marker marker 'org-hd-marker marker
'txt txt 'type "stuck-plans" 'face 'org-agenda)
(insert txt "\n"))))))))
(setq org-agenda-exporter-settings '((ps-print-color-p 'nil)))
(setq
org-agenda-follow-mode nil
org-agenda-skip-deadline-if-done t
@@ -438,11 +467,7 @@ org-agenda-files (seq-uniq org-agenda-files)
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
;; (todo "DONE" ((org-agenda-overriding-header gtd/complete-head)))
(stuck "" ((org-stuck-projects '("TODO=\"PROJ\"" ("NEXT" "STRT") nil ""))
(org-agenda-overriding-header gtd/project-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
(gortium/stuck-plans "" ((org-agenda-overriding-header gtd/project-head)))
(todo "IDEA" ((org-agenda-overriding-header gtd/someday-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
@@ -458,8 +483,7 @@ org-agenda-files (seq-uniq org-agenda-files)
(todo "NEXT" ((org-agenda-overriding-header gtd/next-action-head)))
(todo "WAIT" ((org-agenda-overriding-header gtd/waiting-head)))
;; (todo "DONE" ((org-agenda-overriding-header gtd/complete-head)))
(stuck "" ((org-stuck-projects '("TODO=\"PROJ\"" ("NEXT" "STRT") nil ""))
(org-agenda-overriding-header gtd/project-head)))
(gortium/stuck-plans "" ((org-agenda-overriding-header gtd/project-head)))
(todo "IDEA" ((org-agenda-overriding-header gtd/someday-head)))
)
((org-agenda-tag-filter-preset '("+Perso")))
@@ -723,6 +747,12 @@ org-agenda-files (seq-uniq org-agenda-files)
"* [?] %? \n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
("e" "Event" entry (file+olp +org-capture-todo-file "Events")
"* %? %^t\n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
;; Calendar event capture for gnus-icalendar (its insinuate appends a
;; "#" template; we declare it here so it survives independent of load order
;; and its name dedup-guard skips appending a duplicate).
("#" "used by gnus-icalendar-org"
entry (file+olp +org-capture-todo-file "Events")
"%i" :immediate-finish t)
("C" "Contact" entry (file+olp +org-capture-todo-file "Contacts") "* %?\n:PROPERTIES:\n:EMAIL: %:fromemail\n:ROLE: \n:END:\n%a\n%i")
)
)
@@ -1131,6 +1161,88 @@ Broken for now..
)
#+end_src
** Typed link filter
Insert an [[id:..]] link restricted to the node types a property may point at,
per the relationships table in types-ontology.org (CORE/KNOWLEDGE/COLLECTIVE
types as tags). Candidates are filtered to nodes carrying one of the allowed
target tags, so "what is linkable" is guided by the ontology, not every roam
node. Binding: SPC n r t.
#+begin_src emacs-lisp
(defconst gortium/link-targets
'(("DRIVES" . ("goal"))
("APPLIED-TO" . ("goal"))
("TARGETS" . ("outcome"))
("RANKS-BEFORE" . ("goal"))
("GOALS" . ("goal"))
("STRATEGIES" . ("strategy"))
("PARENT" . ("project"))
("DEPENDS-ON" . ("task"))
("CONTRADICTS" . ("claim"))
("CONCLUDES" . ("claim"))
("WITHIN" . ("frame"))
;; SOURCE/INFORMS are ambiguous across FROM types; union of allowed targets
("SOURCE" . ("argument" "data-source"))
("IS-ABOUT" . ("problem" "threat" "risk"))
("ENDORSES" . ("solution"))
("SUPPORTS" . ("claim"))
("CONTAINS" . ("problem"))
("SOLVES" . ("problem"))
("HAMPERS" . ("goal"))
("MEASURES" . ("goal"))
("TESTS" . ("claim"))
("PRODUCES" . ("dataset"))
("PREDICTS" . ("outcome"))
("FROM" . ("experiment" "project"))
("AGAINST" . ("outcome"))
("SHAPES" . ("goal"))
("INFORMS" . ("strategy" "narrative" "value"))
("REVEALS" . ("challenge"))
("COMPOSES" . ("model"))
("USES" . ("model"))
("SELECTS" . ("claim"))
("OMITS" . ("claim"))
("INVOLVED-IN" . ("project"))
("MEMBER-OF" . ("organization"))
("ASSERTS" . ("argument"))
("HOLDS" . ("belief"))
("GUIDED-BY" . ("mission"))
("TELLS" . ("narrative"))
("SUPPORTED-BY" . ("data-source"))
("FUNDS" . ("project"))
("SEEDS" . ("project"))
("RESTS-ON" . ("claim"))
("GENERATES" . ("strategy"))
("THREATENS" . ("value" "goal" "project"))
("CONSISTS-OF" . ("goal"))
("MOTIVATES" . ("project"))))
(defun gortium/--property-under-point ()
"Return the property key on the current :PROPERTIES: drawer line, or nil."
(save-excursion
(beginning-of-line)
(and (looking-at "^:\\([A-Z0-9_-]+\\):")
(match-string-no-properties 1))))
(defun gortium/typed-link-insert ()
"Insert an [[id:..]] link filtered to the types the property under point allows.
Unknown property falls back to an unfiltered org-roam-node-insert."
(interactive)
(let* ((prop (or (gortium/--property-under-point)
(completing-read "Property: "
(mapcar #'car gortium/link-targets))))
(allowed (cdr (assoc prop gortium/link-targets)))
(filter (when allowed
(lambda (node)
(cl-intersection (org-roam-node-tags node) allowed
:test #'equal)))))
(org-roam-node-insert filter)))
(map! :leader
:desc "Insert typed node link" "n r t" #'gortium/typed-link-insert)
#+end_src
** Custom function
*** New Entry
@@ -2669,7 +2781,7 @@ only injects when the project's context-tag matches current mu4e context."
#+begin_src emacs-lisp
(after! elfeed
;; Define where your feed sources are kept (Org-mode file integration)
(setq rmh-elfeed-org-files '("~/ExoKortex/2-Areas/Home-Infra/2-Assets/rss-feed.org"))
(setq rmh-elfeed-org-files '("~/ExoKortex/2-Areas/Home-Infra/2-Inputs/rss-feed.org"))
;; Set the default search filter (only show unread entries from the last x weeks)
(setq-default elfeed-search-filter "@4-weeks-ago +unread")
@@ -3675,16 +3787,43 @@ Suppresses all logging, notes, and custom odometer hooks."
(defvar gortium/exokortex-root "~/ExoKortex"
"The absolute root directory of your ExoKortex.")
(defun gortium/ensure-node-structure (node-dir &optional default-todo project-tag context-tag)
(defun gortium/read-emoji ()
"Prompt for a category label: an emoji or any short text.
TAB completes emoji names (with glyph preview); RET accepts the typed text as-is.
Returns the chosen emoji glyph when a name is picked, otherwise the raw input
(or \"\" if blank)."
(require 'emoji)
(emoji--init)
(let* ((candidates (hash-table-keys emoji--all-bases))
(name (completing-read
"Category label: "
(completion-table-with-metadata
candidates
`((affixation-function
. ,(lambda (strings)
(mapcar (lambda (s)
(list s ""
(concat (or (gethash s emoji--all-bases) " ")
"\t")))
strings)))))
nil nil "" nil nil t))
(glyph (and (not (string-empty-p name))
(gethash name emoji--all-bases))))
(or glyph name)))
(defun gortium/ensure-node-structure (node-dir &optional default-todo project-tag context-tag category-emoji mu4e-context)
"Ensure NODE-DIR conforms to the ExoKortex node blueprint.
Creates folders, writes text-layer filters, and handles Git injection.
When PROJECT-TAG is provided, also creates .projectile and .dir-locals.el
with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)."
with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda).
CATEGORY-EMOJI, when given, is written as the #+CATEGORY: of the plan file.
MU4E-CONTEXT (Perso/TDNDE) overrides the dir-locals gortium/context-tag for mu4e
gating; when nil it falls back to CONTEXT-TAG."
(let* ((node-dir (expand-file-name node-dir))
(node-name (file-name-nondirectory (directory-file-name node-dir)))
;; Blueprint Paths
(plan-dir (expand-file-name "1-Plan" node-dir))
(assets-dir (expand-file-name "2-Assets" node-dir))
(inputs-dir (expand-file-name "2-Inputs" node-dir))
(notes-dir (expand-file-name "3-Notes" node-dir))
(outputs-dir (expand-file-name "4-Outputs" node-dir))
(org-file (expand-file-name (concat node-name ".org") plan-dir))
@@ -3692,7 +3831,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
(git-existed-p (file-exists-p (expand-file-name ".git" node-dir))))
;; 1. Build directory layers smoothly (mkdir -p)
(dolist (dir (list plan-dir assets-dir notes-dir outputs-dir))
(dolist (dir (list plan-dir inputs-dir notes-dir outputs-dir))
(make-directory dir t))
;; 2. Enforce text-only gitignore rules if absent
@@ -3703,15 +3842,13 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
;; 3. Build index Org block if absent
(unless (file-exists-p org-file)
(with-temp-file org-file
(insert (format "#+TITLE: %s\n#+DATE: %s\n#+CATEGORY: %s\n#+FILETAGS: :%s:%s:\n\n* Contacts\n** Contact Name\n:PROPERTIES:\n:EMAIL:[[mu4e:from:contact@example.com]]\n:ROLE: Client / Partner\n:END:\n\n* Tasks\n** TODO First task for %s\n"
(insert (format "#+TITLE: %s\n#+DATE: %s\n#+CATEGORY: %s\n#+FILETAGS: :%s:%s:\n\n* Tasks\n** TODO First task for %s\n\n* Contacts\n\n* Notes\n\n* Questions\n\n* Events\n"
node-name
(format-time-string "[%Y-%m-%d %a]")
node-name
(or category-emoji node-name)
(or project-tag "project")
(or context-tag "")
(or default-todo "Initialize project context")
node-name
node-name
node-name))))
;; 4. ExoKortex project markers (if project-tag provided)
@@ -3737,7 +3874,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
((org-agenda-tag-filter-preset
(list ,(concat "+" project-tag)))))))
,@(if context-tag
(list (cons 'gortium/context-tag context-tag))
(list (cons 'gortium/context-tag (or mu4e-context context-tag)))
nil))))
(princ "((nil . " (current-buffer))
(prin1 vars (current-buffer))
@@ -3778,28 +3915,106 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
;;; Interactive Entry Points
;;; ====================================================================
(defun gortium/create-node (category node-name tag context-tag)
"Create a brand new standardized PARA Node inside your ExoKortex vault.
TAG is a short slug for agenda/email filtering (e.g. 'airbus_vision').
CONTEXT-TAG is the mu4e/agenda context (e.g. 'TDNDE', 'Perso')."
(interactive
(list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))
(read-string "Node Name: ")
(read-string "Project tag: ")
(read-string "Context tag: ")))
(let* ((category-dir (expand-file-name category gortium/exokortex-root))
(node-dir (expand-file-name node-name category-dir))
(org-file (gortium/ensure-node-structure node-dir
"Initialize project context" tag context-tag)))
(find-file org-file)
(message "ExoKortex Node '%s' created in %s with tag '%s' (context: %s)."
node-name category tag context-tag)))
;; 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<))))
(defun gortium/convert-to-node (node-dirs &optional project-tag context-tag)
(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."
(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: ")))))
(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 "?")))))))
(defun gortium/convert-to-node (node-dirs &optional project-tag area-or-domain category-emoji mu4e-context)
"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 CONTEXT-TAG can be given to create .projectile and .dir-locals.el."
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."
(interactive
(let* ((dired-elements (when (derived-mode-p 'dired-mode)
(dired-get-marked-files nil nil nil t)))
@@ -3820,106 +4035,98 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir
(file-name-directory cursor-path))
(t default-directory))))
(list (read-directory-name "Directory to convert: " initial-path initial-path t)))))
(tag (read-string "Project tag (blank to skip): "))
(ctx (read-string "Context tag (or blank): ")))
(list dirs tag ctx)))
(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)))
(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 "")))
(org-file (gortium/ensure-node-structure node-dir
"Audit existing assets and reconcile tracking nodes"
(unless (string-empty-p project-tag) project-tag)
(unless (string-empty-p context-tag) context-tag))))
(if is-area (file-name-nondirectory (directory-file-name node-dir)) project-tag)
ctx2 category-emoji mctx)))
(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))))
#+end_src
** ExoKortex Archive / Restore
(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)))))
Moves node(s) between categories. Operates on marked Dired items, or the
item at cursor when nothing is marked.
#+begin_src emacs-lisp
(defun gortium/dired-node-dirs ()
"Return marked Dired subdirectories, or the cursor dir if nothing marked."
(when (derived-mode-p 'dired-mode)
(let* ((elements (dired-get-marked-files nil nil nil t))
(has-marks (or (and (listp elements) (> (length elements) 1))
(and (consp elements) (eq (car elements) t))))
(marked (when has-marks
(cl-remove-if-not #'file-directory-p
(if (eq (car-safe elements) t)
(cdr elements)
elements)))))
(or marked
(let ((cursor (dired-get-filename nil t)))
(when (and cursor (file-directory-p cursor))
(list cursor)))))))
(defun gortium/move-node (src-category dst-category node-dirs)
"Move NODE-DIRS (absolute paths) from SRC-CATEGORY to DST-CATEGORY.
Markers (.projectile/.dir-locals/.hermes-sessions/.git) move with each dir."
(let ((root (expand-file-name gortium/exokortex-root))
(moved 0))
(dolist (dir node-dirs)
(let* ((name (file-name-nondirectory (directory-file-name dir)))
(src (expand-file-name name (expand-file-name src-category root)))
(dst (expand-file-name name (expand-file-name dst-category root))))
(unless (file-directory-p src)
(user-error "No node '%s' in %s" name src-category))
(when (file-exists-p dst)
(user-error "%s already has a node named '%s'" dst-category name))
(make-directory (file-name-directory dst) t)
(rename-file src dst)
;; Stale path: drop from the Projectile cache so the switch-list cleans up.
(when (fboundp 'projectile-remove-known-project)
(projectile-remove-known-project src))
(cl-incf moved)))
(message "Archived/restored %d node(s): %s → %s" moved src-category dst-category)))
(defun gortium/archive-node ()
"Move marked (or cursor) nodes from 1-Projects to 4-Archives."
(interactive)
(let ((dirs (gortium/dired-node-dirs)))
(unless dirs
(user-error "No node under Dired cursor / no marked directories"))
(gortium/move-node "1-Projects" "4-Archives" dirs)))
(defun gortium/restore-node (category)
"Move marked (or cursor) archived nodes back into CATEGORY."
(interactive
(list (completing-read "Restore to category: "
'("1-Projects" "2-Areas" "3-Resources"))))
(let ((dirs (gortium/dired-node-dirs)))
(unless dirs
(user-error "No node under Dired cursor / no marked directories"))
(gortium/move-node "4-Archives" category dirs)))
#+end_src
** Project-Aware Agenda
Reads =.dir-locals.el= from the current Projectile root and injects the
project-specific custom agenda command.
#+begin_src emacs-lisp
(defun gortium/read-dir-local (var)
"Read variable VAR from .dir-locals.el in `default-directory'.
Returns nil if file doesn't exist or VAR isn't set."
(let ((dir-locals (expand-file-name ".dir-locals.el" default-directory)))
(when (file-exists-p dir-locals)
(with-temp-buffer
(insert-file-contents dir-locals)
(goto-char (point-min))
(let* ((sexp (ignore-errors (read (current-buffer))))
(nil-entry (assq nil sexp)))
(when nil-entry
(cdr (assq var (cdr nil-entry)))))))))
(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))))
(gortium/inject-area-views)
(add-hook 'org-agenda-mode-hook #'gortium/inject-area-views)
(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

@@ -36,7 +36,7 @@
(setq projectile-auto-discover nil))
(setq bookmark-save-flag 1)
(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Assets/bookmark"))
(setq bookmark-default-file (expand-file-name "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/bookmark"))
(setq global-auto-revert-mode 1)
@@ -69,10 +69,10 @@
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/4-Outputs/dotfiles"))
(defvar roam-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Assets/Roam"))
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/Roam"))
(defvar dict-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Assets/Dictionaries"))
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Inputs/Dictionaries"))
(defvar journal-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Journal"))
@@ -195,8 +195,37 @@ org-agenda-files (seq-uniq org-agenda-files)
(setq org-stuck-projects
'("TODO=\"PROJ\"" ("NEXT") nil ""))
(setq org-agenda-exporter-settings '((ps-print-color-p 'nil)))
(defun gortium/stuck-plans (&rest _)
"Agenda block: list plan files that contain no NEXT headline = stuck.
Each line jumps to the file (org-hd-marker on the first headline)."
(org-agenda-prepare)
(let ((inhibit-read-only t)
(case-fold-search nil)
(root (expand-file-name "~/ExoKortex")))
(when org-agenda-overriding-header
(insert (org-add-props
(concat org-agenda-overriding-header "\n") nil
'face 'org-agenda-structure)))
(dolist (f (directory-files-recursively root "\\.org\\'" t))
(when (and (string-match-p "1-Plan/" f)
(not (string-match-p "\\.stversions\\|4-Archives" f)))
(let ((stuck-p (with-temp-buffer
(insert-file-contents f)
(not (re-search-forward "^\\*+[ \t]+NEXT\\b" nil t)))))
(when stuck-p
(let ((txt (concat "" (file-name-nondirectory f) " "))
(marker (with-current-buffer (find-file-noselect f)
(org-agenda-new-marker
(save-excursion
(goto-char (point-min))
(if (re-search-forward "^\\*" nil t)
(match-beginning 0) (point-min)))))))
(org-add-props txt nil
'org-marker marker 'org-hd-marker marker
'txt txt 'type "stuck-plans" 'face 'org-agenda)
(insert txt "\n"))))))))
(setq org-agenda-exporter-settings '((ps-print-color-p 'nil)))
(setq
org-agenda-follow-mode nil
org-agenda-skip-deadline-if-done t
@@ -297,11 +326,7 @@ org-agenda-files (seq-uniq org-agenda-files)
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
;; (todo "DONE" ((org-agenda-overriding-header gtd/complete-head)))
(stuck "" ((org-stuck-projects '("TODO=\"PROJ\"" ("NEXT" "STRT") nil ""))
(org-agenda-overriding-header gtd/project-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
(gortium/stuck-plans "" ((org-agenda-overriding-header gtd/project-head)))
(todo "IDEA" ((org-agenda-overriding-header gtd/someday-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
@@ -317,8 +342,7 @@ org-agenda-files (seq-uniq org-agenda-files)
(todo "NEXT" ((org-agenda-overriding-header gtd/next-action-head)))
(todo "WAIT" ((org-agenda-overriding-header gtd/waiting-head)))
;; (todo "DONE" ((org-agenda-overriding-header gtd/complete-head)))
(stuck "" ((org-stuck-projects '("TODO=\"PROJ\"" ("NEXT" "STRT") nil ""))
(org-agenda-overriding-header gtd/project-head)))
(gortium/stuck-plans "" ((org-agenda-overriding-header gtd/project-head)))
(todo "IDEA" ((org-agenda-overriding-header gtd/someday-head)))
)
((org-agenda-tag-filter-preset '("+Perso")))
@@ -568,6 +592,12 @@ org-agenda-files (seq-uniq org-agenda-files)
"* [?] %? \n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
("e" "Event" entry (file+olp +org-capture-todo-file "Events")
"* %? %^t\n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
;; Calendar event capture for gnus-icalendar (its insinuate appends a
;; "#" template; we declare it here so it survives independent of load order
;; and its name dedup-guard skips appending a duplicate).
("#" "used by gnus-icalendar-org"
entry (file+olp +org-capture-todo-file "Events")
"%i" :immediate-finish t)
("C" "Contact" entry (file+olp +org-capture-todo-file "Contacts") "* %?\n:PROPERTIES:\n:EMAIL: %:fromemail\n:ROLE: \n:END:\n%a\n%i")
)
)
@@ -713,6 +743,78 @@ org-agenda-files (seq-uniq org-agenda-files)
)
)
(defconst gortium/link-targets
'(("DRIVES" . ("goal"))
("APPLIED-TO" . ("goal"))
("TARGETS" . ("outcome"))
("RANKS-BEFORE" . ("goal"))
("GOALS" . ("goal"))
("STRATEGIES" . ("strategy"))
("PARENT" . ("project"))
("DEPENDS-ON" . ("task"))
("CONTRADICTS" . ("claim"))
("CONCLUDES" . ("claim"))
("WITHIN" . ("frame"))
;; SOURCE/INFORMS are ambiguous across FROM types; union of allowed targets
("SOURCE" . ("argument" "data-source"))
("IS-ABOUT" . ("problem" "threat" "risk"))
("ENDORSES" . ("solution"))
("SUPPORTS" . ("claim"))
("CONTAINS" . ("problem"))
("SOLVES" . ("problem"))
("HAMPERS" . ("goal"))
("MEASURES" . ("goal"))
("TESTS" . ("claim"))
("PRODUCES" . ("dataset"))
("PREDICTS" . ("outcome"))
("FROM" . ("experiment" "project"))
("AGAINST" . ("outcome"))
("SHAPES" . ("goal"))
("INFORMS" . ("strategy" "narrative" "value"))
("REVEALS" . ("challenge"))
("COMPOSES" . ("model"))
("USES" . ("model"))
("SELECTS" . ("claim"))
("OMITS" . ("claim"))
("INVOLVED-IN" . ("project"))
("MEMBER-OF" . ("organization"))
("ASSERTS" . ("argument"))
("HOLDS" . ("belief"))
("GUIDED-BY" . ("mission"))
("TELLS" . ("narrative"))
("SUPPORTED-BY" . ("data-source"))
("FUNDS" . ("project"))
("SEEDS" . ("project"))
("RESTS-ON" . ("claim"))
("GENERATES" . ("strategy"))
("THREATENS" . ("value" "goal" "project"))
("CONSISTS-OF" . ("goal"))
("MOTIVATES" . ("project"))))
(defun gortium/--property-under-point ()
"Return the property key on the current :PROPERTIES: drawer line, or nil."
(save-excursion
(beginning-of-line)
(and (looking-at "^:\\([A-Z0-9_-]+\\):")
(match-string-no-properties 1))))
(defun gortium/typed-link-insert ()
"Insert an [[id:..]] link filtered to the types the property under point allows.
Unknown property falls back to an unfiltered org-roam-node-insert."
(interactive)
(let* ((prop (or (gortium/--property-under-point)
(completing-read "Property: "
(mapcar #'car gortium/link-targets))))
(allowed (cdr (assoc prop gortium/link-targets)))
(filter (when allowed
(lambda (node)
(cl-intersection (org-roam-node-tags node) allowed
:test #'equal)))))
(org-roam-node-insert filter)))
(map! :leader
:desc "Insert typed node link" "n r t" #'gortium/typed-link-insert)
;; Org-roam-dailies new entry help function
;; Adapted from https://rostre.bearblog.dev/building-my-ideal-emacs-journal/
(defun gortium/org-roam-dailies-new-entry ()
@@ -1812,7 +1914,7 @@ only injects when the project's context-tag matches current mu4e context."
(after! elfeed
;; Define where your feed sources are kept (Org-mode file integration)
(setq rmh-elfeed-org-files '("~/ExoKortex/2-Areas/Home-Infra/2-Assets/rss-feed.org"))
(setq rmh-elfeed-org-files '("~/ExoKortex/2-Areas/Home-Infra/2-Inputs/rss-feed.org"))
;; Set the default search filter (only show unread entries from the last x weeks)
(setq-default elfeed-search-filter "@4-weeks-ago +unread")
@@ -2742,16 +2844,43 @@ Suppresses all logging, notes, and custom odometer hooks."
(defvar gortium/exokortex-root "~/ExoKortex"
"The absolute root directory of your ExoKortex.")
(defun gortium/ensure-node-structure (node-dir &optional default-todo project-tag context-tag)
(defun gortium/read-emoji ()
"Prompt for a category label: an emoji or any short text.
TAB completes emoji names (with glyph preview); RET accepts the typed text as-is.
Returns the chosen emoji glyph when a name is picked, otherwise the raw input
(or \"\" if blank)."
(require 'emoji)
(emoji--init)
(let* ((candidates (hash-table-keys emoji--all-bases))
(name (completing-read
"Category label: "
(completion-table-with-metadata
candidates
`((affixation-function
. ,(lambda (strings)
(mapcar (lambda (s)
(list s ""
(concat (or (gethash s emoji--all-bases) " ")
"\t")))
strings)))))
nil nil "" nil nil t))
(glyph (and (not (string-empty-p name))
(gethash name emoji--all-bases))))
(or glyph name)))
(defun gortium/ensure-node-structure (node-dir &optional default-todo project-tag context-tag category-emoji mu4e-context)
"Ensure NODE-DIR conforms to the ExoKortex node blueprint.
Creates folders, writes text-layer filters, and handles Git injection.
When PROJECT-TAG is provided, also creates .projectile and .dir-locals.el
with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)."
with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda).
CATEGORY-EMOJI, when given, is written as the #+CATEGORY: of the plan file.
MU4E-CONTEXT (Perso/TDNDE) overrides the dir-locals gortium/context-tag for mu4e
gating; when nil it falls back to CONTEXT-TAG."
(let* ((node-dir (expand-file-name node-dir))
(node-name (file-name-nondirectory (directory-file-name node-dir)))
;; Blueprint Paths
(plan-dir (expand-file-name "1-Plan" node-dir))
(assets-dir (expand-file-name "2-Assets" node-dir))
(inputs-dir (expand-file-name "2-Inputs" node-dir))
(notes-dir (expand-file-name "3-Notes" node-dir))
(outputs-dir (expand-file-name "4-Outputs" node-dir))
(org-file (expand-file-name (concat node-name ".org") plan-dir))
@@ -2759,7 +2888,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
(git-existed-p (file-exists-p (expand-file-name ".git" node-dir))))
;; 1. Build directory layers smoothly (mkdir -p)
(dolist (dir (list plan-dir assets-dir notes-dir outputs-dir))
(dolist (dir (list plan-dir inputs-dir notes-dir outputs-dir))
(make-directory dir t))
;; 2. Enforce text-only gitignore rules if absent
@@ -2770,15 +2899,13 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
;; 3. Build index Org block if absent
(unless (file-exists-p org-file)
(with-temp-file org-file
(insert (format "#+TITLE: %s\n#+DATE: %s\n#+CATEGORY: %s\n#+FILETAGS: :%s:%s:\n\n* Contacts\n** Contact Name\n:PROPERTIES:\n:EMAIL:[[mu4e:from:contact@example.com]]\n:ROLE: Client / Partner\n:END:\n\n* Tasks\n** TODO First task for %s\n"
(insert (format "#+TITLE: %s\n#+DATE: %s\n#+CATEGORY: %s\n#+FILETAGS: :%s:%s:\n\n* Tasks\n** TODO First task for %s\n\n* Contacts\n\n* Notes\n\n* Questions\n\n* Events\n"
node-name
(format-time-string "[%Y-%m-%d %a]")
node-name
(or category-emoji node-name)
(or project-tag "project")
(or context-tag "")
(or default-todo "Initialize project context")
node-name
node-name
node-name))))
;; 4. ExoKortex project markers (if project-tag provided)
@@ -2804,7 +2931,7 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
((org-agenda-tag-filter-preset
(list ,(concat "+" project-tag)))))))
,@(if context-tag
(list (cons 'gortium/context-tag context-tag))
(list (cons 'gortium/context-tag (or mu4e-context context-tag)))
nil))))
(princ "((nil . " (current-buffer))
(prin1 vars (current-buffer))
@@ -2845,28 +2972,106 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda)
;;; Interactive Entry Points
;;; ====================================================================
(defun gortium/create-node (category node-name tag context-tag)
"Create a brand new standardized PARA Node inside your ExoKortex vault.
TAG is a short slug for agenda/email filtering (e.g. 'airbus_vision').
CONTEXT-TAG is the mu4e/agenda context (e.g. 'TDNDE', 'Perso')."
(interactive
(list (completing-read "Category: " '("1-Projects" "2-Areas" "3-Resources"))
(read-string "Node Name: ")
(read-string "Project tag: ")
(read-string "Context tag: ")))
(let* ((category-dir (expand-file-name category gortium/exokortex-root))
(node-dir (expand-file-name node-name category-dir))
(org-file (gortium/ensure-node-structure node-dir
"Initialize project context" tag context-tag)))
(find-file org-file)
(message "ExoKortex Node '%s' created in %s with tag '%s' (context: %s)."
node-name category tag context-tag)))
;; 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<))))
(defun gortium/convert-to-node (node-dirs &optional project-tag context-tag)
(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."
(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: ")))))
(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 "?")))))))
(defun gortium/convert-to-node (node-dirs &optional project-tag area-or-domain category-emoji mu4e-context)
"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 CONTEXT-TAG can be given to create .projectile and .dir-locals.el."
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."
(interactive
(let* ((dired-elements (when (derived-mode-p 'dired-mode)
(dired-get-marked-files nil nil nil t)))
@@ -2887,18 +3092,40 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir
(file-name-directory cursor-path))
(t default-directory))))
(list (read-directory-name "Directory to convert: " initial-path initial-path t)))))
(tag (read-string "Project tag (blank to skip): "))
(ctx (read-string "Context tag (or blank): ")))
(list dirs tag ctx)))
(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)))
(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 "")))
(org-file (gortium/ensure-node-structure node-dir
"Audit existing assets and reconcile tracking nodes"
(unless (string-empty-p project-tag) project-tag)
(unless (string-empty-p context-tag) context-tag))))
(if is-area (file-name-nondirectory (directory-file-name node-dir)) project-tag)
ctx2 category-emoji mctx)))
(setq last-org-file org-file)
(cl-incf count)))
@@ -2906,74 +3133,57 @@ Optional PROJECT-TAG and CONTEXT-TAG can be given to create .projectile and .dir
(message "Directory successfully converted to ExoKortex node.")
(message "Successfully batch-converted %d directories to ExoKortex nodes!" count))))
(defun gortium/dired-node-dirs ()
"Return marked Dired subdirectories, or the cursor dir if nothing marked."
(when (derived-mode-p 'dired-mode)
(let* ((elements (dired-get-marked-files nil nil nil t))
(has-marks (or (and (listp elements) (> (length elements) 1))
(and (consp elements) (eq (car elements) t))))
(marked (when has-marks
(cl-remove-if-not #'file-directory-p
(if (eq (car-safe elements) t)
(cdr elements)
elements)))))
(or marked
(let ((cursor (dired-get-filename nil t)))
(when (and cursor (file-directory-p cursor))
(list cursor)))))))
(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/move-node (src-category dst-category node-dirs)
"Move NODE-DIRS (absolute paths) from SRC-CATEGORY to DST-CATEGORY.
Markers (.projectile/.dir-locals/.hermes-sessions/.git) move with each dir."
(let ((root (expand-file-name gortium/exokortex-root))
(moved 0))
(dolist (dir node-dirs)
(let* ((name (file-name-nondirectory (directory-file-name dir)))
(src (expand-file-name name (expand-file-name src-category root)))
(dst (expand-file-name name (expand-file-name dst-category root))))
(unless (file-directory-p src)
(user-error "No node '%s' in %s" name src-category))
(when (file-exists-p dst)
(user-error "%s already has a node named '%s'" dst-category name))
(make-directory (file-name-directory dst) t)
(rename-file src dst)
;; Stale path: drop from the Projectile cache so the switch-list cleans up.
(when (fboundp 'projectile-remove-known-project)
(projectile-remove-known-project src))
(cl-incf moved)))
(message "Archived/restored %d node(s): %s → %s" moved src-category dst-category)))
(defun gortium/archive-node ()
"Move marked (or cursor) nodes from 1-Projects to 4-Archives."
(interactive)
(let ((dirs (gortium/dired-node-dirs)))
(unless dirs
(user-error "No node under Dired cursor / no marked directories"))
(gortium/move-node "1-Projects" "4-Archives" dirs)))
(defun gortium/restore-node (category)
"Move marked (or cursor) archived nodes back into CATEGORY."
(interactive
(list (completing-read "Restore to category: "
'("1-Projects" "2-Areas" "3-Resources"))))
(let ((dirs (gortium/dired-node-dirs)))
(unless dirs
(user-error "No node under Dired cursor / no marked directories"))
(gortium/move-node "4-Archives" category dirs)))
(defun gortium/read-dir-local (var)
"Read variable VAR from .dir-locals.el in `default-directory'.
Returns nil if file doesn't exist or VAR isn't set."
(let ((dir-locals (expand-file-name ".dir-locals.el" default-directory)))
(when (file-exists-p dir-locals)
(with-temp-buffer
(insert-file-contents dir-locals)
(goto-char (point-min))
(let* ((sexp (ignore-errors (read (current-buffer))))
(nil-entry (assq nil sexp)))
(when nil-entry
(cdr (assq var (cdr nil-entry)))))))))
(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))))
(gortium/inject-area-views)
(add-hook 'org-agenda-mode-hook #'gortium/inject-area-views)
(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