From 343dc241e7ddeb97f99dc4746034ef3221ee64c2 Mon Sep 17 00:00:00 2001 From: Thierry Pouplier Date: Thu, 17 Sep 2026 15:02:59 -0400 Subject: [PATCH] =?UTF-8?q?PARA:=20rename=202-Assets=20to=202-Inputs=20(PI?= =?UTF-8?q?NO)=20=E2=80=94=20ensure-node-structure=20blueprint=20+=20Home-?= =?UTF-8?q?Infra=20roam/bookmark/dict/rss=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doom/.config/doom/README.org | 455 +++++++++++++++++++++++++---------- doom/.config/doom/config.el | 432 ++++++++++++++++++++++++--------- 2 files changed, 652 insertions(+), 235 deletions(-) diff --git a/doom/.config/doom/README.org b/doom/.config/doom/README.org index 69d93ff..6f0eac9 100644 --- a/doom/.config/doom/README.org +++ b/doom/.config/doom/README.org @@ -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 diff --git a/doom/.config/doom/config.el b/doom/.config/doom/config.el index 3e342b4..802e07e 100644 --- a/doom/.config/doom/config.el +++ b/doom/.config/doom/config.el @@ -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