From 3c59427c1516eec3bac8b5c171374e23dec99ac1 Mon Sep 17 00:00:00 2001 From: Thierry Pouplier Date: Fri, 17 Jul 2026 14:54:16 -0400 Subject: [PATCH] feat: .bookmarks file, mu4e maildirs, dir-locals format --- doom/.config/doom/README.org | 576 +++++++++++++++++------------------ doom/.config/doom/config.el | 347 ++++++++++++++++----- 2 files changed, 546 insertions(+), 377 deletions(-) diff --git a/doom/.config/doom/README.org b/doom/.config/doom/README.org index a9a407a..4a3e22f 100644 --- a/doom/.config/doom/README.org +++ b/doom/.config/doom/README.org @@ -48,11 +48,8 @@ they are implemented. Place your private configurations. It is optional. #+begin_src emacs-lisp -(setq org-src-coderef-format nil) - -(setq org-src-coderef-format nil) - (setq user-full-name "Thierry Pouplier" + user-mail-address "tpouplier@tdnde.com" user-mail-address "tpouplier@tdnde.com") #+end_src @@ -2294,39 +2291,48 @@ Cordialement, "Cached project mu4e maildir shortcuts for re-injection when mu4e loads.") (defun gortium/inject-project-mu4e-bookmarks () - "Inject project-specific mu4e bookmark shortcuts [PI]/[PS]/[PD]/[PT]/[PA]. -Caches them for re-injection when mu4e loads." + "Inject project mu4e maildirs into mu4e-maildir-shortcuts. +Keys [PI]/[PS]/[PD]/[PT]/[PA] (uppercase). Filters by context-tag: +only injects when the project's context-tag matches current mu4e context." (let* ((proj-root (and (projectile-project-p) (projectile-project-root))) (proj-tag (when proj-root (let ((default-directory proj-root)) (gortium/read-dir-local 'gortium/project-tag)))) + (context-tag (when proj-root + (let ((default-directory proj-root)) + (gortium/read-dir-local 'gortium/context-tag)))) + (cur-context (and (boundp 'mu4e-context-current) + (car-safe mu4e-context-current))) (project-bm '())) - (when proj-tag + ;; Only inject if context-tag matches current mu4e context + ;; (if no context-tag set, always inject) + (when (and proj-tag + (or (null context-tag) + (and cur-context + (string= context-tag + (mu4e-context-name cur-context))))) (dolist (folder '(("Inbox" . ?I) ("Sent" . ?S) ("Drafts" . ?D) ("Trash" . ?T) ("Archive" . ?A))) - (push (list :name (format "[%s] %s" proj-tag (car folder)) - :query (format "maildir:/%s.%s" (car folder) proj-tag) - :key (cdr folder)) + (push (cons (format "[%s] %s" proj-tag (car folder)) + (format "maildir:/%s.%s" (car folder) proj-tag)) project-bm))) - ;; Cache for later re-injection + ;; Inject into mu4e-maildir-shortcuts (setq gortium/project-mu4e-bookmarks project-bm) - ;; Inject now if mu4e is already loaded - (when (boundp 'mu4e-bookmarks) - (setq mu4e-bookmarks - (append - (seq-filter (lambda (bm) - (if (and (listp bm) (keywordp (car-safe bm))) - (let ((key (plist-get bm :key))) - (not (member key gortium/project-mu4e-keys))) - t)) - mu4e-bookmarks) - gortium/project-mu4e-bookmarks)) - ;; Clear caches so new bookmarks appear + (when (and (boundp 'mu4e-maildir-shortcuts) project-bm) + ;; Remove stale project entries + (setq mu4e-maildir-shortcuts + (seq-filter (lambda (s) + (not (and (consp s) (stringp (car s)) + (string-match "^\\[[A-Z]+\\]" (car s))))) + mu4e-maildir-shortcuts)) + ;; Add current project entries + (setq mu4e-maildir-shortcuts + (append mu4e-maildir-shortcuts project-bm)) + ;; Refresh (setq mu4e--bookmark-items-cached nil) - ;; Re-render main view if visible (when (get-buffer "*mu4e-main*") (with-current-buffer "*mu4e-main*" (mu4e--main-redraw)))))) @@ -3340,23 +3346,24 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (let ((dir-locals-file (expand-file-name ".dir-locals.el" node-dir))) (unless (file-exists-p dir-locals-file) (with-temp-file dir-locals-file - (prin1 - `((nil . ((gortium/project-tag . ,project-tag) - (gortium/project-agenda-entry . - (("P" ,node-name - ((agenda "" ((org-agenda-span 14) - (org-agenda-overriding-header - ,(format "📅 %s" node-name)))) - (todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA" - ((org-agenda-overriding-header "📋 Unscheduled") - (org-agenda-skip-function - '(org-agenda-skip-entry-if 'scheduled))))) - - ((org-agenda-tag-filter-preset (list ,(concat "+" project-tag))))))) - ,@(if context-tag - (list (cons 'gortium/context-tag context-tag)) - nil)))) - (current-buffer)))))) + (let* ((vars `((gortium/project-tag . ,project-tag) + (gortium/project-agenda-entry . + (("P" ,node-name + ((agenda "" ((org-agenda-span 14) + (org-agenda-overriding-header + ,(format "📅 %s" node-name)))) + (todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA" + ((org-agenda-overriding-header "📋 Unscheduled") + (org-agenda-skip-function + '(org-agenda-skip-entry-if 'scheduled))))) + ((org-agenda-tag-filter-preset + (list ,(concat "+" project-tag))))))) + ,@(if context-tag + (list (cons 'gortium/context-tag context-tag)) + nil)))) + (princ "((nil . " (current-buffer)) + (prin1 vars (current-buffer)) + (princ ")" (current-buffer))))))) ;; 5. Unified Git Integration Loop (let ((default-directory node-dir)) @@ -3446,6 +3453,235 @@ Otherwise, prompts for a single directory, defaulting to the item under the curs (message "Successfully batch-converted %d directories to ExoKortex nodes!" count)))) #+end_src +** Project-Aware Agenda + +Reads =.dir-locals.el= from the current Projectile root and injects the +project-specific custom agenda command. Use ~SPC o P~ instead of ~SPC o a~ to +get a context-scoped agenda. + +#+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/project-aware-agenda () + "Launch org-agenda scoped to the current Projectile project. +Reads `gortium/project-agenda-entry' from .dir-locals.el and merges +it into `org-agenda-custom-commands'. Restricts `org-agenda-files' +to .org files under the project root." + (interactive) + (if (projectile-project-p) + (let* ((proj-root (projectile-project-root)) + (default-directory proj-root) + (agenda-entry (gortium/read-dir-local 'gortium/project-agenda-entry)) + (org-agenda-files (directory-files-recursively proj-root "\\.org$")) + (org-agenda-custom-commands + (append org-agenda-custom-commands + (when agenda-entry (list agenda-entry))))) + (org-agenda)) + (org-agenda))) + +(map! :leader + :desc "Project-aware agenda" + "o P" #'gortium/project-aware-agenda) + +;; Remove stale project entries and inject current project's agenda +(defvar gortium/project-agenda-commands nil + "Cached project agenda commands.") + +(defun gortium/inject-project-agenda () + "Inject the current project's custom agenda commands into +`org-agenda-custom-commands', or remove them if no project." + (let* ((proj-root (and (projectile-project-p) (projectile-project-root))) + (project-commands nil)) + (when proj-root + (let ((default-directory proj-root)) + (setq project-commands + (gortium/read-dir-local 'gortium/project-agenda-entry)))) + ;; Remove stale project entries (key "P") + (setq org-agenda-custom-commands + (seq-filter (lambda (entry) + (not (and (stringp (car entry)) + (string= "P" (car entry))))) + org-agenda-custom-commands)) + ;; Add current project commands + (when project-commands + (setq org-agenda-custom-commands + (append org-agenda-custom-commands project-commands))))) + +;; Hook into project switching +(add-hook 'projectile-after-switch-project-hook + #'gortium/inject-project-agenda) +;; Also inject at startup and on file open in a project +(add-hook 'find-file-hook #'gortium/inject-project-agenda) + +(defvar gortium/project-bookmark-names nil + "Bookmark names currently in the Plan file for the active project.") + +(defun gortium//project-plan-file () + "Return the first .org file under 1-Plan/ at the project root, or nil." + (when-let* ((root (projectile-project-root))) + (car (directory-files (expand-file-name "1-Plan" root) t "\.org\'" t)))) + +(defun gortium//project-tag () + "Read the project tag from .dir-locals.el in the current project." + (when (projectile-project-p) + (let ((default-directory (projectile-project-root))) + (gortium/read-dir-local 'gortium/project-tag)))) + +(defun gortium//read-plan-bookmarks () + "Parse * Bookmarks from the Plan file. +Returns alist ((NAME . URL) ...) where URL is the link target." + (when-let* ((plan-file (gortium//project-plan-file)) + (_ (file-exists-p plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (goto-char (point-min)) + (when (re-search-forward "^\* Bookmarks" nil t) + (forward-line 1) + (let ((bookmarks '())) + (while (not (or (eobp) (looking-at "^\* "))) + (cond + ;; ** Name\n[[file:path][Name]] + ((looking-at "^\*\* \(.+\) +\[\[file:\(.+\)\]\[\(.+\)\]\]") + (push (cons (match-string 1) (match-string 2)) bookmarks) + (forward-line 2)) + ;; standalone [[url][name]] + ((looking-at "\[\[\(.+\)\]\[\(.+\)\]\]") + (unless (string-prefix-p "mu4e:" (match-string 1)) + (push (cons (match-string 2) (match-string 1)) bookmarks)) + (forward-line 1)) + (t (forward-line 1)))) + (setq gortium/project-bookmark-names (mapcar #'car bookmarks)) + (nreverse bookmarks)))))) + +(defun gortium//append-bookmark-to-plan (name url) + "Add a bookmark NAME with URL to the project's .bookmarks file." + (when (projectile-project-p) + (let ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))) + (bookmark-store name `((filename . ,url)) nil) + (bookmark-save nil proj-file) + (message "Bookmark added to .bookmarks")))) +(defun gortium//remove-bookmark-from-plan (name) + "Remove a bookmark entry from * Bookmarks in the Plan file." + (when-let* ((plan-file (gortium//project-plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (goto-char (point-min)) + (when (re-search-forward (format "^\*\* %s +" (regexp-quote name)) nil t) + (forward-line -1) + (let ((start (point))) + (forward-line 2) + (delete-region start (point))) + (write-region (point-min) (point-max) plan-file nil 'silent) + (message "Bookmark '%s' removed from Plan file" name) + (setq gortium/project-bookmark-names + (delete name gortium/project-bookmark-names)))))) + +(defun gortium/bookmark-set () + "Save a bookmark. Prompts: (g)lobal or (p)roject. +Project bookmarks go to .bookmarks file in project root." + (interactive) + (let ((choice (read-char-choice "Save: (g)lobal / (p)roject? " '(?g ?p)))) + (pcase choice + (?g (call-interactively 'bookmark-set)) + (?p + (if (projectile-project-p) + (let* ((tag (or (gortium//project-tag) "project")) + (name (read-string (format "[%s] Bookmark name: " tag))) + (proj-file (expand-file-name + ".bookmarks" (projectile-project-root))) + (bm (bookmark-make-record))) + (bookmark-store name (cdr bm) nil) + (bookmark-save nil proj-file) + (message "Project bookmark saved to .bookmarks")) + (user-error "Not in a Projectile project")))))) + +(defun gortium/list-project-bookmarks () + "Show project bookmarks from .bookmarks in project root." + (interactive) + (if (not (projectile-project-p)) + (user-error "Not in a project") + (let ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))) + (if (not (file-exists-p proj-file)) + (message "No .bookmarks file in this project.") + (let ((saved-alist (copy-tree bookmark-alist)) + (saved-file bookmark-default-file)) + (setq bookmark-default-file proj-file) + (bookmark-load proj-file t) + (bookmark-bmenu-list) + (with-current-buffer "*Bookmark List*" + (add-hook 'kill-buffer-hook + (lambda () + (ignore-errors (bookmark-save)) + (setq bookmark-default-file saved-file) + (setq bookmark-alist saved-alist)) + nil t))))))) +(defun gortium//sync-plan-to-bookmark-file (bookmarks) + "Write BOOKMARKS alist back to the Plan file under * Bookmarks." + (when-let* ((plan-file (gortium//project-plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (when (re-search-forward "^\* Bookmarks" nil t) + (forward-line 1) + (let ((start (point))) + (if (re-search-forward "^\* " nil t) + (forward-line -1) + (goto-char (point-max))) + (delete-region start (point))) + (dolist (bm (nreverse (copy-tree bookmarks))) + (insert (format "[[%s][%s]] +" (cdr bm) (car bm)))) + (write-region (point-min) (point-max) plan-file nil 'silent))))) + +(defun gortium/transfer-to-project () + "Transfer a global bookmark to the current project's Plan file." + (interactive) + (if (projectile-project-p) + (let* ((name (completing-read "Transfer to project: " (bookmark-all-names) nil t)) + (file (bookmark-location name))) + (when (and name file (y-or-n-p (format "Transfer '%s' to project? " name))) + (gortium//append-bookmark-to-plan name file) + (bookmark-delete name) + (bookmark-save) + (message "Transferred '%s' to project %s" name + (or (gortium//project-tag) "")))) + (user-error "Not in a Projectile project"))) + +(defun gortium/transfer-to-global () + "Transfer a project bookmark to the global bookmarks list." + (interactive) + (let* ((bookmarks (gortium//read-plan-bookmarks)) + (names (mapcar #'car bookmarks))) + (if names + (let* ((name (completing-read "Transfer to global: " names nil t)) + (url (cdr (assoc name bookmarks)))) + (when (and name url (y-or-n-p (format "Transfer '%s' to global? " name))) + (bookmark-store name `((filename . ,url)) t) + (bookmark-save) + (gortium//remove-bookmark-from-plan name) + (message "Transferred '%s' to global" name))) + (message "No project bookmarks to transfer.")))) + +;; Bindings +(map! :leader + :desc "Set bookmark (global/project)" + "b m" #'gortium/bookmark-set + :desc "Jump to project bookmark" + "p RET" #'gortium/list-project-bookmarks) +#+end_src + ** Refile task to Roam dailies #+begin_src emacs-lisp @@ -3510,54 +3746,6 @@ fallback to today's daily note. Ensures the daily has an Org-roam ID." headline (file-name-nondirectory daily-file))))) #+end_src -** Project-Aware Agenda - -Reads =.dir-locals.el= from the current Projectile root and injects the -project-specific custom agenda command. Use ~SPC o P~ instead of ~SPC o a~ to -get a context-scoped agenda. - -#+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/project-aware-agenda () - "Launch org-agenda scoped to the current Projectile project. -Reads `gortium/project-agenda-entry' from .dir-locals.el and merges -it into `org-agenda-custom-commands'. Restricts `org-agenda-files' -to .org files under the project root." - (interactive) - (if (projectile-project-p) - (let* ((proj-root (projectile-project-root)) - (default-directory proj-root) - (agenda-entry (gortium/read-dir-local 'gortium/project-agenda-entry)) - (org-agenda-files (directory-files-recursively proj-root "\\.org$")) - (org-agenda-custom-commands - (append org-agenda-custom-commands - (when agenda-entry (list agenda-entry))))) - (org-agenda)) - (org-agenda))) - -(map! :leader - :desc "Project-aware agenda" - "o P" #'gortium/project-aware-agenda) - -;; Load project bookmark functions -(require 'gortium-bookmarks nil t) -#+end_src - -#+RESULTS: -: gortium/project-aware-agenda - ** Emacs-Everywhere for Hyprland #+begin_src emacs-lisp @@ -3738,214 +3926,4 @@ Need chrome... :( ** Project-Aware Agenda -#+begin_src emacs-lisp :tangle no -;; Remove stale project entries and inject current project's agenda -(defvar gortium/project-agenda-commands nil - "Cached project agenda commands.") -(defun gortium/inject-project-agenda () - "Inject the current project's custom agenda commands into -`org-agenda-custom-commands', or remove them if no project." - (let* ((proj-root (and (projectile-project-p) (projectile-project-root))) - (project-commands nil)) - (when proj-root - (let ((default-directory proj-root)) - (setq project-commands - (gortium/read-dir-local 'gortium/project-agenda-entry)))) - ;; Remove stale project entries (key "P") - (setq org-agenda-custom-commands - (seq-filter (lambda (entry) - (not (and (stringp (car entry)) - (string= "P" (car entry))))) - org-agenda-custom-commands)) - ;; Add current project commands - (when project-commands - (setq org-agenda-custom-commands - (append org-agenda-custom-commands project-commands))))) - -;; Hook into project switching -(add-hook 'projectile-after-switch-project-hook - #'gortium/inject-project-agenda) -;; Also inject at startup and on file open in a project -(add-hook 'find-file-hook #'gortium/inject-project-agenda) - -;; Load project bookmark functions -(require 'gortium-bookmarks nil t) - -;; Load project bookmark functions -(require 'gortium-bookmarks nil t) - -(defvar gortium/project-bookmark-names nil - "Bookmark names currently in the Plan file for the active project.") - -(defun gortium//project-plan-file () - "Return the first .org file under 1-Plan/ at the project root, or nil." - (when-let* ((root (projectile-project-root))) - (car (directory-files (expand-file-name "1-Plan" root) t "\.org\'" t)))) - -(defun gortium//project-tag () - "Read the project tag from .dir-locals.el in the current project." - (when (projectile-project-p) - (let ((default-directory (projectile-project-root))) - (gortium/read-dir-local 'gortium/project-tag)))) - -(defun gortium//read-plan-bookmarks () - "Parse * Bookmarks from the Plan file. -Returns alist ((NAME . URL) ...) where URL is the link target." - (when-let* ((plan-file (gortium//project-plan-file)) - (_ (file-exists-p plan-file))) - (with-temp-buffer - (insert-file-contents plan-file) - (goto-char (point-min)) - (when (re-search-forward "^\* Bookmarks" nil t) - (forward-line 1) - (let ((bookmarks '())) - (while (not (or (eobp) (looking-at "^\* "))) - (cond - ;; ** Name\n[[file:path][Name]] - ((looking-at "^\*\* \(.+\) -\[\[file:\(.+\)\]\[\(.+\)\]\]") - (push (cons (match-string 1) (match-string 2)) bookmarks) - (forward-line 2)) - ;; standalone [[url][name]] - ((looking-at "\[\[\(.+\)\]\[\(.+\)\]\]") - (unless (string-prefix-p "mu4e:" (match-string 1)) - (push (cons (match-string 2) (match-string 1)) bookmarks)) - (forward-line 1)) - (t (forward-line 1)))) - (setq gortium/project-bookmark-names (mapcar #'car bookmarks)) - (nreverse bookmarks)))))) - -(defun gortium//append-bookmark-to-plan (name url) - "Append a bookmark as [[url][name]] under * Bookmarks in the Plan file. -NAME is the display name, URL is the link target (file path or web URL)." - (when-let* ((plan-file (gortium//project-plan-file))) - (with-temp-buffer - (insert-file-contents plan-file) - (goto-char (point-min)) - (if (re-search-forward "^\* Bookmarks" nil t) - (forward-line 1) - (goto-char (point-max)) - (insert "\n* Bookmarks\n")) - (insert (format "[[%s][%s]] -" url name)) - (write-region (point-min) (point-max) plan-file nil 'silent) - (message "Bookmark added to %s" (file-name-nondirectory plan-file)) - (push name gortium/project-bookmark-names)))) - -(defun gortium//remove-bookmark-from-plan (name) - "Remove a bookmark entry from * Bookmarks in the Plan file." - (when-let* ((plan-file (gortium//project-plan-file))) - (with-temp-buffer - (insert-file-contents plan-file) - (goto-char (point-min)) - (when (re-search-forward (format "^\*\* %s -" (regexp-quote name)) nil t) - (forward-line -1) - (let ((start (point))) - (forward-line 2) - (delete-region start (point))) - (write-region (point-min) (point-max) plan-file nil 'silent) - (message "Bookmark '%s' removed from Plan file" name) - (setq gortium/project-bookmark-names - (delete name gortium/project-bookmark-names)))))) - -(defun gortium/bookmark-set () - "Save a bookmark. Prompts: (g)lobal or (p)roject. -Project bookmarks go to * Bookmarks in the Plan file. -The prompt shows the project tag so you can verify the target." - (interactive) - (let ((choice (read-char-choice "Save: (g)lobal / (p)roject? " '(?g ?p)))) - (pcase choice - (?g (call-interactively 'bookmark-set)) - (?p - (if (projectile-project-p) - (let* ((tag (or (gortium//project-tag) "project")) - (name (read-string (format "[%s] Bookmark name: " tag))) - (file (buffer-file-name))) - (gortium//append-bookmark-to-plan name file) - (message "Project bookmark saved to Plan file.")) - (user-error "Not in a Projectile project")))))) - -(defun gortium/list-project-bookmarks () - "Show project bookmarks in the bookmark menu (with categories). -Temporarily replaces bookmark-alist so the menu shows project bookmarks -in their native categories. On quit restores global bookmarks." - (interactive) - (let* ((bookmarks (gortium//read-plan-bookmarks)) - (names (mapcar #'car bookmarks))) - (if (not names) - (message "No bookmarks found in this project's Plan file.") - (let ((saved-alist (copy-tree bookmark-alist))) - (setq bookmark-alist nil) - (dolist (bm (nreverse (copy-tree bookmarks))) - (bookmark-store (car bm) `((filename . ,(cdr bm))) nil)) - (bookmark-bmenu-list) - (with-current-buffer "*Bookmark List*" - (add-hook 'kill-buffer-hook - (lambda () - (setq bookmark-alist saved-alist) - ;; Write changes back to Plan file - (when-let* ((plan-file (gortium//project-plan-file)) - (entries (bookmark-maybe-sort-alist bookmark-alist))) - (gortium//sync-plan-to-bookmark-file - (mapcar (lambda (e) - (cons (car e) - (alist-get 'filename (cdr e)))) - entries)))) - nil t)))))) - -(defun gortium//sync-plan-to-bookmark-file (bookmarks) - "Write BOOKMARKS alist back to the Plan file under * Bookmarks." - (when-let* ((plan-file (gortium//project-plan-file))) - (with-temp-buffer - (insert-file-contents plan-file) - (when (re-search-forward "^\* Bookmarks" nil t) - (forward-line 1) - (let ((start (point))) - (if (re-search-forward "^\* " nil t) - (forward-line -1) - (goto-char (point-max))) - (delete-region start (point))) - (dolist (bm (nreverse (copy-tree bookmarks))) - (insert (format "[[%s][%s]] -" (cdr bm) (car bm)))) - (write-region (point-min) (point-max) plan-file nil 'silent))))) - -(defun gortium/transfer-to-project () - "Transfer a global bookmark to the current project's Plan file." - (interactive) - (if (projectile-project-p) - (let* ((name (completing-read "Transfer to project: " (bookmark-all-names) nil t)) - (file (bookmark-location name))) - (when (and name file (y-or-n-p (format "Transfer '%s' to project? " name))) - (gortium//append-bookmark-to-plan name file) - (bookmark-delete name) - (bookmark-save) - (message "Transferred '%s' to project %s" name - (or (gortium//project-tag) "")))) - (user-error "Not in a Projectile project"))) - -(defun gortium/transfer-to-global () - "Transfer a project bookmark to the global bookmarks list." - (interactive) - (let* ((bookmarks (gortium//read-plan-bookmarks)) - (names (mapcar #'car bookmarks))) - (if names - (let* ((name (completing-read "Transfer to global: " names nil t)) - (url (cdr (assoc name bookmarks)))) - (when (and name url (y-or-n-p (format "Transfer '%s' to global? " name))) - (bookmark-store name `((filename . ,url)) t) - (bookmark-save) - (gortium//remove-bookmark-from-plan name) - (message "Transferred '%s' to global" name))) - (message "No project bookmarks to transfer.")))) - -;; Bindings -(map! :leader - :desc "Set bookmark (global/project)" - "b m" #'gortium/bookmark-set - :desc "Jump to project bookmark" - "p RET" #'gortium/list-project-bookmarks) - -#+end_src diff --git a/doom/.config/doom/config.el b/doom/.config/doom/config.el index d485358..1799123 100644 --- a/doom/.config/doom/config.el +++ b/doom/.config/doom/config.el @@ -1,10 +1,7 @@ ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*- -(setq org-src-coderef-format nil) - -(setq org-src-coderef-format nil) - (setq user-full-name "Thierry Pouplier" + user-mail-address "tpouplier@tdnde.com" user-mail-address "tpouplier@tdnde.com") (setq doom-font (font-spec :family "JetBrainsMono Nerd Font" :size 16) @@ -1448,39 +1445,48 @@ Cordialement, "Cached project mu4e maildir shortcuts for re-injection when mu4e loads.") (defun gortium/inject-project-mu4e-bookmarks () - "Inject project-specific mu4e bookmark shortcuts [PI]/[PS]/[PD]/[PT]/[PA]. -Caches them for re-injection when mu4e loads." + "Inject project mu4e maildirs into mu4e-maildir-shortcuts. +Keys [PI]/[PS]/[PD]/[PT]/[PA] (uppercase). Filters by context-tag: +only injects when the project's context-tag matches current mu4e context." (let* ((proj-root (and (projectile-project-p) (projectile-project-root))) (proj-tag (when proj-root (let ((default-directory proj-root)) (gortium/read-dir-local 'gortium/project-tag)))) + (context-tag (when proj-root + (let ((default-directory proj-root)) + (gortium/read-dir-local 'gortium/context-tag)))) + (cur-context (and (boundp 'mu4e-context-current) + (car-safe mu4e-context-current))) (project-bm '())) - (when proj-tag + ;; Only inject if context-tag matches current mu4e context + ;; (if no context-tag set, always inject) + (when (and proj-tag + (or (null context-tag) + (and cur-context + (string= context-tag + (mu4e-context-name cur-context))))) (dolist (folder '(("Inbox" . ?I) ("Sent" . ?S) ("Drafts" . ?D) ("Trash" . ?T) ("Archive" . ?A))) - (push (list :name (format "[%s] %s" proj-tag (car folder)) - :query (format "maildir:/%s.%s" (car folder) proj-tag) - :key (cdr folder)) + (push (cons (format "[%s] %s" proj-tag (car folder)) + (format "maildir:/%s.%s" (car folder) proj-tag)) project-bm))) - ;; Cache for later re-injection + ;; Inject into mu4e-maildir-shortcuts (setq gortium/project-mu4e-bookmarks project-bm) - ;; Inject now if mu4e is already loaded - (when (boundp 'mu4e-bookmarks) - (setq mu4e-bookmarks - (append - (seq-filter (lambda (bm) - (if (and (listp bm) (keywordp (car-safe bm))) - (let ((key (plist-get bm :key))) - (not (member key gortium/project-mu4e-keys))) - t)) - mu4e-bookmarks) - gortium/project-mu4e-bookmarks)) - ;; Clear caches so new bookmarks appear + (when (and (boundp 'mu4e-maildir-shortcuts) project-bm) + ;; Remove stale project entries + (setq mu4e-maildir-shortcuts + (seq-filter (lambda (s) + (not (and (consp s) (stringp (car s)) + (string-match "^\\[[A-Z]+\\]" (car s))))) + mu4e-maildir-shortcuts)) + ;; Add current project entries + (setq mu4e-maildir-shortcuts + (append mu4e-maildir-shortcuts project-bm)) + ;; Refresh (setq mu4e--bookmark-items-cached nil) - ;; Re-render main view if visible (when (get-buffer "*mu4e-main*") (with-current-buffer "*mu4e-main*" (mu4e--main-redraw)))))) @@ -2418,23 +2424,24 @@ with a project-tagged custom agenda entry and optional CONTEXT-TAG (mu4e/agenda) (let ((dir-locals-file (expand-file-name ".dir-locals.el" node-dir))) (unless (file-exists-p dir-locals-file) (with-temp-file dir-locals-file - (prin1 - `((nil . ((gortium/project-tag . ,project-tag) - (gortium/project-agenda-entry . - (("P" ,node-name - ((agenda "" ((org-agenda-span 14) - (org-agenda-overriding-header - ,(format "📅 %s" node-name)))) - (todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA" - ((org-agenda-overriding-header "📋 Unscheduled") - (org-agenda-skip-function - '(org-agenda-skip-entry-if 'scheduled))))) - - ((org-agenda-tag-filter-preset (list ,(concat "+" project-tag))))))) - ,@(if context-tag - (list (cons 'gortium/context-tag context-tag)) - nil)))) - (current-buffer)))))) + (let* ((vars `((gortium/project-tag . ,project-tag) + (gortium/project-agenda-entry . + (("P" ,node-name + ((agenda "" ((org-agenda-span 14) + (org-agenda-overriding-header + ,(format "📅 %s" node-name)))) + (todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA" + ((org-agenda-overriding-header "📋 Unscheduled") + (org-agenda-skip-function + '(org-agenda-skip-entry-if 'scheduled))))) + ((org-agenda-tag-filter-preset + (list ,(concat "+" project-tag))))))) + ,@(if context-tag + (list (cons 'gortium/context-tag context-tag)) + nil)))) + (princ "((nil . " (current-buffer)) + (prin1 vars (current-buffer)) + (princ ")" (current-buffer))))))) ;; 5. Unified Git Integration Loop (let ((default-directory node-dir)) @@ -2523,6 +2530,227 @@ Otherwise, prompts for a single directory, defaulting to the item under the curs (message "Directory successfully converted to ExoKortex node.")) (message "Successfully batch-converted %d directories to ExoKortex nodes!" count)))) +(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/project-aware-agenda () + "Launch org-agenda scoped to the current Projectile project. +Reads `gortium/project-agenda-entry' from .dir-locals.el and merges +it into `org-agenda-custom-commands'. Restricts `org-agenda-files' +to .org files under the project root." + (interactive) + (if (projectile-project-p) + (let* ((proj-root (projectile-project-root)) + (default-directory proj-root) + (agenda-entry (gortium/read-dir-local 'gortium/project-agenda-entry)) + (org-agenda-files (directory-files-recursively proj-root "\\.org$")) + (org-agenda-custom-commands + (append org-agenda-custom-commands + (when agenda-entry (list agenda-entry))))) + (org-agenda)) + (org-agenda))) + +(map! :leader + :desc "Project-aware agenda" + "o P" #'gortium/project-aware-agenda) + +;; Remove stale project entries and inject current project's agenda +(defvar gortium/project-agenda-commands nil + "Cached project agenda commands.") + +(defun gortium/inject-project-agenda () + "Inject the current project's custom agenda commands into +`org-agenda-custom-commands', or remove them if no project." + (let* ((proj-root (and (projectile-project-p) (projectile-project-root))) + (project-commands nil)) + (when proj-root + (let ((default-directory proj-root)) + (setq project-commands + (gortium/read-dir-local 'gortium/project-agenda-entry)))) + ;; Remove stale project entries (key "P") + (setq org-agenda-custom-commands + (seq-filter (lambda (entry) + (not (and (stringp (car entry)) + (string= "P" (car entry))))) + org-agenda-custom-commands)) + ;; Add current project commands + (when project-commands + (setq org-agenda-custom-commands + (append org-agenda-custom-commands project-commands))))) + +;; Hook into project switching +(add-hook 'projectile-after-switch-project-hook + #'gortium/inject-project-agenda) +;; Also inject at startup and on file open in a project +(add-hook 'find-file-hook #'gortium/inject-project-agenda) + +(defvar gortium/project-bookmark-names nil + "Bookmark names currently in the Plan file for the active project.") + +(defun gortium//project-plan-file () + "Return the first .org file under 1-Plan/ at the project root, or nil." + (when-let* ((root (projectile-project-root))) + (car (directory-files (expand-file-name "1-Plan" root) t "\.org\'" t)))) + +(defun gortium//project-tag () + "Read the project tag from .dir-locals.el in the current project." + (when (projectile-project-p) + (let ((default-directory (projectile-project-root))) + (gortium/read-dir-local 'gortium/project-tag)))) + +(defun gortium//read-plan-bookmarks () + "Parse * Bookmarks from the Plan file. +Returns alist ((NAME . URL) ...) where URL is the link target." + (when-let* ((plan-file (gortium//project-plan-file)) + (_ (file-exists-p plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (goto-char (point-min)) + (when (re-search-forward "^\* Bookmarks" nil t) + (forward-line 1) + (let ((bookmarks '())) + (while (not (or (eobp) (looking-at "^\* "))) + (cond + ;; ** Name\n[[file:path][Name]] + ((looking-at "^\*\* \(.+\) +\[\[file:\(.+\)\]\[\(.+\)\]\]") + (push (cons (match-string 1) (match-string 2)) bookmarks) + (forward-line 2)) + ;; standalone [[url][name]] + ((looking-at "\[\[\(.+\)\]\[\(.+\)\]\]") + (unless (string-prefix-p "mu4e:" (match-string 1)) + (push (cons (match-string 2) (match-string 1)) bookmarks)) + (forward-line 1)) + (t (forward-line 1)))) + (setq gortium/project-bookmark-names (mapcar #'car bookmarks)) + (nreverse bookmarks)))))) + +(defun gortium//append-bookmark-to-plan (name url) + "Add a bookmark NAME with URL to the project's .bookmarks file." + (when (projectile-project-p) + (let ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))) + (bookmark-store name `((filename . ,url)) nil) + (bookmark-save nil proj-file) + (message "Bookmark added to .bookmarks")))) +(defun gortium//remove-bookmark-from-plan (name) + "Remove a bookmark entry from * Bookmarks in the Plan file." + (when-let* ((plan-file (gortium//project-plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (goto-char (point-min)) + (when (re-search-forward (format "^\*\* %s +" (regexp-quote name)) nil t) + (forward-line -1) + (let ((start (point))) + (forward-line 2) + (delete-region start (point))) + (write-region (point-min) (point-max) plan-file nil 'silent) + (message "Bookmark '%s' removed from Plan file" name) + (setq gortium/project-bookmark-names + (delete name gortium/project-bookmark-names)))))) + +(defun gortium/bookmark-set () + "Save a bookmark. Prompts: (g)lobal or (p)roject. +Project bookmarks go to .bookmarks file in project root." + (interactive) + (let ((choice (read-char-choice "Save: (g)lobal / (p)roject? " '(?g ?p)))) + (pcase choice + (?g (call-interactively 'bookmark-set)) + (?p + (if (projectile-project-p) + (let* ((tag (or (gortium//project-tag) "project")) + (name (read-string (format "[%s] Bookmark name: " tag))) + (proj-file (expand-file-name + ".bookmarks" (projectile-project-root))) + (bm (bookmark-make-record))) + (bookmark-store name (cdr bm) nil) + (bookmark-save nil proj-file) + (message "Project bookmark saved to .bookmarks")) + (user-error "Not in a Projectile project")))))) + +(defun gortium/list-project-bookmarks () + "Show project bookmarks from .bookmarks in project root." + (interactive) + (if (not (projectile-project-p)) + (user-error "Not in a project") + (let ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))) + (if (not (file-exists-p proj-file)) + (message "No .bookmarks file in this project.") + (let ((saved-alist (copy-tree bookmark-alist)) + (saved-file bookmark-default-file)) + (setq bookmark-default-file proj-file) + (bookmark-load proj-file t) + (bookmark-bmenu-list) + (with-current-buffer "*Bookmark List*" + (add-hook 'kill-buffer-hook + (lambda () + (ignore-errors (bookmark-save)) + (setq bookmark-default-file saved-file) + (setq bookmark-alist saved-alist)) + nil t))))))) +(defun gortium//sync-plan-to-bookmark-file (bookmarks) + "Write BOOKMARKS alist back to the Plan file under * Bookmarks." + (when-let* ((plan-file (gortium//project-plan-file))) + (with-temp-buffer + (insert-file-contents plan-file) + (when (re-search-forward "^\* Bookmarks" nil t) + (forward-line 1) + (let ((start (point))) + (if (re-search-forward "^\* " nil t) + (forward-line -1) + (goto-char (point-max))) + (delete-region start (point))) + (dolist (bm (nreverse (copy-tree bookmarks))) + (insert (format "[[%s][%s]] +" (cdr bm) (car bm)))) + (write-region (point-min) (point-max) plan-file nil 'silent))))) + +(defun gortium/transfer-to-project () + "Transfer a global bookmark to the current project's Plan file." + (interactive) + (if (projectile-project-p) + (let* ((name (completing-read "Transfer to project: " (bookmark-all-names) nil t)) + (file (bookmark-location name))) + (when (and name file (y-or-n-p (format "Transfer '%s' to project? " name))) + (gortium//append-bookmark-to-plan name file) + (bookmark-delete name) + (bookmark-save) + (message "Transferred '%s' to project %s" name + (or (gortium//project-tag) "")))) + (user-error "Not in a Projectile project"))) + +(defun gortium/transfer-to-global () + "Transfer a project bookmark to the global bookmarks list." + (interactive) + (let* ((bookmarks (gortium//read-plan-bookmarks)) + (names (mapcar #'car bookmarks))) + (if names + (let* ((name (completing-read "Transfer to global: " names nil t)) + (url (cdr (assoc name bookmarks)))) + (when (and name url (y-or-n-p (format "Transfer '%s' to global? " name))) + (bookmark-store name `((filename . ,url)) t) + (bookmark-save) + (gortium//remove-bookmark-from-plan name) + (message "Transferred '%s' to global" name))) + (message "No project bookmarks to transfer.")))) + +;; Bindings +(map! :leader + :desc "Set bookmark (global/project)" + "b m" #'gortium/bookmark-set + :desc "Jump to project bookmark" + "p RET" #'gortium/list-project-bookmarks) + (defun gortium/refile-to-today-daily () "Refile the current subtree under the 'Journal' headline in today's daily note. This version uses `org-roam-dailies-capture-today` to ensure the daily note @@ -2582,43 +2810,6 @@ fallback to today's daily note. Ensures the daily has an Org-roam ID." headline (file-name-nondirectory daily-file))))) -(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/project-aware-agenda () - "Launch org-agenda scoped to the current Projectile project. -Reads `gortium/project-agenda-entry' from .dir-locals.el and merges -it into `org-agenda-custom-commands'. Restricts `org-agenda-files' -to .org files under the project root." - (interactive) - (if (projectile-project-p) - (let* ((proj-root (projectile-project-root)) - (default-directory proj-root) - (agenda-entry (gortium/read-dir-local 'gortium/project-agenda-entry)) - (org-agenda-files (directory-files-recursively proj-root "\\.org$")) - (org-agenda-custom-commands - (append org-agenda-custom-commands - (when agenda-entry (list agenda-entry))))) - (org-agenda)) - (org-agenda))) - -(map! :leader - :desc "Project-aware agenda" - "o P" #'gortium/project-aware-agenda) - -;; Load project bookmark functions -(require 'gortium-bookmarks nil t) - ;; Like Emacs everywhere, but work in hyprland (defun thanos/wtype-text (text) "Process TEXT for wtype, handling newlines properly."