Files
dotfiles/doom/.config/doom/config.el

2886 lines
119 KiB
EmacsLisp
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
(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)
doom-symbol-font (font-spec :family "JetBrainsMono Nerd Font" :size 20))
;; BIGGER FONT MODE
(after! doom-big-font-mode
(setq doom-big-font-increment 8))
(setq doom-theme 'sanityinc-tomorrow-night)
(after! projectile
(setq projectile-project-root-files '(".projectile"))
(setq projectile-project-root-files-bottom-up nil)
(setq projectile-project-root-files-top-down-recurring nil)
(setq projectile-auto-discover nil))
(setq bookmark-save-flag 1)
(setq global-auto-revert-mode 1)
(map! :leader
(:prefix ("w" . "window")
:desc "Minimize window" "O" #'minimize-window)
(:prefix ("z" . "Personal")
:desc "Hermes Agent" "h" #'gortium/hermes-vterm))
(map! :leader
(:prefix ("z" . "Personal")
:desc "Hermes Agent" "h" #'gortium/hermes-vterm))
(use-package! ultra-scroll
:init
(setq scroll-margin 0 ; Required for ultra-scroll
scroll-conservatively 101 ; Prevents jumping to center
ultra-scroll-acceleration nil ; Essential for Miryoku keys
ultra-scroll-mouse-scale 0.1 ; Lower for higher precision
pixel-scroll-precision-large-scroll-height 20)
:config
(ultra-scroll-mode 1))
;; Super- auto -save
(use-package! super-save
:config
(setq super-save-auto-save-when-idle t)
(setq super-save-all-buffers t)
(setq super-save-triggers
'(switch-to-buffer other-window windmove-up windmove-down
windmove-left windmove-right next-buffer previous-buffer
evil-window-delete evil-window-vsplit evil-window-spliti
evil-prev-buffer evil-next-buffer))
(super-save-mode +1))
(defvar dotfiles-base-path
(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"))
(defvar dict-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Home-Infra/2-Assets/Dictionaries"))
(defvar journal-base-path
(file-name-as-directory "~/ExoKortex/2-Areas/Journal"))
;; Fixes.. idk why or what.. dont ask me
(require 'cl-lib)
(unless (fboundp 'find-if)
(defalias 'find-if #'cl-find-if))
(unless (fboundp 'getf)
(defalias 'getf #'cl-getf))
(setq
org-directory "~/ExoKortex"
+org-capture-todo-file "~/ExoKortex/inbox.org"
+org-capture-notes-file "~/ExoKortex/inbox.org"
+org-capture-journal-file "~/ExoKortex/inbox.org"
+org-capture-emails-file "~/ExoKortex/inbox.org"
;; +org-capture-central-project-todo-file '"refile.org"
;; +org-capture-project-todo-file '"refile.org"
org-agenda-files
(let ((all (directory-files-recursively "~/ExoKortex/" "\\.org$")))
(append '("~/ExoKortex/inbox.org"
"~/ExoKortex/projects.org"
"~/ExoKortex/areas.org"
"~/ExoKortex/journal.org"
"~/ExoKortex/telos_thierry_pouplier.org")
(seq-filter
(lambda (f) (string-match-p "/1-Plan/" f))
all)))
org-agenda-files (seq-uniq org-agenda-files)
)
(after! org
(setq org-cycle-show-planning-line t)
)
(after! org
(setq org-refile-targets
'((nil :maxlevel . 6) ;; Current buffer
(org-agenda-files :maxlevel . 6))) ;; All agenda files
)
(after! org
;; 1. Update existing faces (Org defines 1-8 by default)
(set-face-attribute 'org-document-title nil :height 2.0 :weight 'bold)
(set-face-attribute 'org-level-1 nil :weight 'semi-bold :height 1.45)
(set-face-attribute 'org-level-2 nil :weight 'semi-bold :height 1.35)
(set-face-attribute 'org-level-3 nil :weight 'semi-bold :height 1.25)
(set-face-attribute 'org-level-4 nil :weight 'semi-bold :height 1.15)
(set-face-attribute 'org-level-5 nil :weight 'normal :height 1.10)
(set-face-attribute 'org-level-6 nil :weight 'normal :height 1.05)
(set-face-attribute 'org-level-7 nil :weight 'normal :height 1.02)
(set-face-attribute 'org-level-8 nil :weight 'normal :height 1.00)
;; 2. Create missing faces (9-20) and inherit them directly from org-level-8
(cl-loop for lvl from 9 to 20
do (let ((face-name (intern (format "org-level-%d" lvl))))
(custom-declare-face face-name
'((t :inherit org-level-8))
(format "Face for org heading level %d." lvl)
:group 'org-faces)))
;; 3. Add the newly created faces to Org's level list
(setq org-level-faces
(append org-level-faces
(mapcar (lambda (lvl) (intern (format "org-level-%d" lvl)))
(number-sequence 9 20))))
(setq org-n-level-faces (length org-level-faces)))
(after! org
;; Automatically clock in when switching to STRT
(defun gortium/org-clock-in-if-starting ()
"Clock in when task is switched to STRT."
(when (and (string= org-state "STRT")
(not (bound-and-true-p org-clock-resolving-clocks-due-to-idleness)))
(org-clock-in)))
;; Automatically clock out when switching to DONE
(defun gortium/org-clock-out-if-not-starting ()
"Clock out if task was in STRT and is being changed to anything else."
(when (and (string= org-last-state "STRT")
(not (string= org-state "STRT")))
(when (org-clocking-p)
(org-clock-out))))
;; Hook both functions into todo state change
(add-hook 'org-after-todo-state-change-hook #'gortium/org-clock-in-if-starting)
(add-hook 'org-after-todo-state-change-hook #'gortium/org-clock-out-if-not-starting)
;; Enable Org Clocking
(setq org-clock-persist 'history) ;; Save clock history across sessions
(add-hook 'org-mode-hook 'org-clock-load) ; Load persisted clocks
(setq org-clock-persist 'history) ; Or 't for full persistence
(setq org-clock-in-resume t) ; Resume clock when restarting the task
;; Save clock data and state changes when exiting Emacs
(setq org-clock-out-remove-zero-time-clocks t)
(setq org-clock-in-resume t) ;; Resume clocking when moving between tasks
;; Always clock out when exiting Emacs
;; (add-hook 'kill-emacs-hook #'(lambda () (when (org-clocking-p) (org-clock-out))))
;; Mode-line clock display
(setq org-clock-mode-line-total 'auto) ;; show today's total
(org-clock-display) ;; activate the mode-line display
(custom-set-faces!
'(org-mode-line-clock
:foreground "gold"
:weight bold))
)
(after! org
(setq org-columns-default-format "%65ITEM %4TODO %3PRIORITY %ASSIGNEE %5EFFORT{:} %5CLOCKSUM %16SCHEDULED %16DEADLINE %10TAGS")
(setq org-global-properties
'(("Effort_ALL" . "0:10 0:30 1:00 2:00 4:00 8:00")
("ASSIGNEE_ALL" . "Thierry.P Cath.F Martin.K Michel.B Gabriel.C Silvie.L George.G Miguel.A Réjean.S Dominique Adnane Hatim.K Marc-Antoine.P")))
(setq org-stuck-projects
'("TODO=\"PROJ\"" ("NEXT") nil ""))
(setq org-agenda-exporter-settings '((ps-print-color-p 'nil)))
(setq
org-agenda-follow-mode nil
org-agenda-skip-deadline-if-done nil
org-agenda-skip-scheduled-if-done nil
org-agenda-skip-timestamp-if-done nil
org-agenda-sticky t
org-journal-enable-agenda-integration t
org-log-done 'time
org-agenda-span 'day
org-log-into-drawer t
org-log-redeadline 'time
org-log-reschedule 'time
org-todo-repeat-to-state '"LOOP"
org-todo-keywords
'(
(sequence
;; Tasks
"TODO(t)" "STRT(s)" "LOOP(r)" "NEXT(n)" "DELG(g)"
"WAIT(w@/!)" "HOLD(h@/!)" "IDEA(i)"
;; Framework
"EPIC(e)" "AREA(a)" "PROJ(p)"
;; Done / Cancelled
"|" "DONE(d!)" "CNCL(c@/!)")
;; Checkboxes
(sequence "[ ](T)" "[-](S)" "|" "[X](D)")
;; Special indicators
(sequence "[!](F)" "[?](W)" "|" "[i](I)")
;; Binary questions
(sequence "Y/N(M)" "|" "YES(Y)" "NOP(N)")
)
)
;; Not there yet.. Need better integration with org-modern and gruvbox theme
;; %a : org-store-link
;; %i : insert from selection
;; %? : cursor position at the end
;; %u : unactive date
;; %t : ative date
;; %:subject : subject of the message
;; %:from : The full sender string including name and address
;; %:fromname : The display name of the sender
;; %:fromaddress : The email address of the sender
;; %:to, %:toname, %toaddress : Same for the recipient
;; %:date : Date of the message
;; %:date-timestamp : The date of the message as a active org timestamp
;;
;; Original Doom capture:
;; ("p" "Templates for projects")
;; ("pt" "Project-local todo" entry (file+headline +org-capture-project-todo-file "Inbox") "* TODO %?\n %i\n %a" :prepend t)
;; ("pn" "Project-local notes" entry (file+headline +org-capture-project-notes-file "Inbox") "* %U %?\n %i\n %a" :prepend t)
;; ("o" "Centralized templates for projects")
;; ("ot" "Project todo" entry #'+org-capture-central-project-todo-file "* TODO %?\n %i\n %a" :heading "Tasks" :prepend nil)
;; ("on" "Project notes" entry #'+org-capture-central-project-notes-file "* %U %?\n %i\n %a" :heading "Notes" :prepend t)
;; Code to automate task status, but i think some package exist that do a better job
;; (defun org-summary-todo (n-done n-not-done)
;; "Switch entry to DONE when all subentries are done, to TODO otherwise."
;; (let (org-log-done org-log-states) ; turn off logging
;; (org-todo (if (= n-not-done 0) "DONE" "TODO"))))
;; (add-hook 'org-after-todo-statistics-hook 'org-summary-todo)
(setq gtd/started-head "🚀 Started:")
(setq gtd/next-action-head "👉 Next actions:")
(setq gtd/waiting-head "⏳ Waiting on:")
;; (setq gtd/complete-head "Completed items:")
(setq gtd/project-head "‼ Stuck projects:")
(setq gtd/someday-head "💡 Someday/maybe:")
(setq qaa/q "❓ All the Questions:")
(setq qaa/a " All the Answers:")
(setq review/done "🦾 Completed Tasks")
(setq review/unfinished "🔀 Unfinished Scheduled Tasks")
(setq org-agenda-start-day "+0")
(setq org-agenda-custom-commands
'(
("w" . "Work commands")
("p" . "Personal commands")
("wg" "GTD view"
(
(agenda "" ((org-agenda-span 'week)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
(todo "STRT" ((org-agenda-overriding-header gtd/started-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
(todo "NEXT" ((org-agenda-overriding-header gtd/next-action-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
))
(todo "WAIT" ((org-agenda-overriding-header gtd/waiting-head)
(org-agenda-prefix-format " %12t") ;; reserve time space
(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
))
(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
))
)
((org-agenda-tag-filter-preset '("+work")))
)
("pg" "GTD view"
(
(agenda "" ((org-agenda-span 'week)))
(todo "STRT" ((org-agenda-overriding-header gtd/started-head)))
(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)))
(todo "IDEA" ((org-agenda-overriding-header gtd/someday-head)))
)
((org-agenda-tag-filter-preset '("+perso")))
)
("wq" "All the questions.."
(
(todo "[?]" ((org-agenda-overriding-header qaa/q)))
(todo "[i]" ((org-agenda-overriding-header qaa/a)))
)
((org-agenda-tag-filter-preset '("+work")))
)
("pq" "All the questions.."
(
(todo "[?]" ((org-agenda-overriding-header qaa/q)))
(todo "[i]" ((org-agenda-overriding-header qaa/a)))
)
((org-agenda-tag-filter-preset '("+perso")))
)
("wr" "Weekly Review"
((agenda ""
((org-agenda-overriding-header review/done)
(org-agenda-skip-function '(org-agenda-skip-entry-if 'nottodo 'done))
(org-agenda-span 'week)
(org-agenda-start-day nil)))
(agenda ""
((org-agenda-overriding-header review/unfinished)
(org-agenda-skip-function '(org-agenda-skip-entry-if 'todo 'done))
(org-agenda-span 'week)
(org-agenda-start-day nil)))
)
((org-agenda-tag-filter-preset '("+work")))
)
("pr" "Weekly Review"
((agenda ""
((org-agenda-overriding-header review/done)
(org-agenda-skip-function '(org-agenda-skip-entry-if 'nottodo 'done))
(org-agenda-span 'week)
(org-agenda-start-day nil)))
(agenda ""
((org-agenda-overriding-header review/unfinished)
(org-agenda-skip-function '(org-agenda-skip-entry-if 'todo 'done))
(org-agenda-span 'week)
(org-agenda-start-day nil)))
)
((org-agenda-tag-filter-preset '("+perso")))
)
("wp" "Planning"
((agenda ""
((org-agenda-span 'month)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 Sheduled Tasks"))
)
(todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA"
((org-agenda-overriding-header "📋 Unscheduled TODOs")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled))
)
)
)
((org-agenda-tag-filter-preset '("+work")))
)
("pp" "Planning 1 month"
((agenda ""
((org-agenda-span 'month)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 Sheduled Tasks")
(org-agenda-time-grid nil)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags t))
)
(todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA"
((org-agenda-overriding-header "📋 Unscheduled TODOs")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled))
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags t)
)
)
)
((org-agenda-tag-filter-preset '("+perso")))
)
("pP" "Planning 2 months"
((agenda ""
((org-agenda-span 60)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 Sheduled Tasks")
(org-agenda-time-grid nil)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags nil))
)
(todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA"
((org-agenda-overriding-header "📋 Unscheduled TODOs")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled))
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags t)
)
)
)
((org-agenda-tag-filter-preset '("+perso")))
)
("pI" "ImmoFacile"
((agenda ""
((org-agenda-span 'month)
(org-agenda-start-day nil)
(org-agenda-overriding-header "📅 Sheduled Tasks")
(org-agenda-time-grid nil)
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags t))
)
(todo "TODO|NEXT|DELG|WAIT|HOLD|STRT|IDEA"
((org-agenda-overriding-header "📋 Unscheduled TODOs")
(org-agenda-skip-function '(org-agenda-skip-entry-if 'scheduled))
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " ") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-remove-tags t)
)
)
)
((org-agenda-tag-filter-preset '("+ImmoFacile")))
)
("wP" "Installation Bombardier"
((agenda ""
((org-agenda-span 90)
(org-agenda-start-day "2026-01-29")
(org-agenda-overriding-header "📅 Installation Bombardier")
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-time-grid nil)
)))
((org-agenda-tag-filter-preset '("+BA_ON_SITE")))
)
("wA" "Projet Airbus Vision"
((agenda ""
((org-agenda-span 120)
(org-agenda-start-day "2026-07-01")
(org-agenda-overriding-header "📅 Projet Airbus Vision")
(org-agenda-prefix-format " %12t") ;; reserve time space
(org-agenda-todo-keyword-format " %-5s") ;; fixed-width TODO
(org-agenda-tags-column -100) ;; right-align tags
(org-agenda-time-grid nil)
)))
((org-agenda-tag-filter-preset '("+airbus")))
)
)
)
)
(after! org
;; Rust code block setting
(setq rustic-babel-display-error-popup nil)
)
(after! org
(setq org-format-latex-options (plist-put org-format-latex-options :scale 2.0))
;; 1. Use XeLaTeX to generate the intermediate file for dvisvgm
;; This handles UTF-8 (like long dashes) which standard 'latex' crashes on.
(setq org-preview-latex-process-alist
'((dvisvgm
:programs ("xelatex" "dvisvgm")
:description "xdv to svg"
:message "rendering %f via xelatex..."
:image-input-type "xdv"
:image-output-type "svg"
:image-size-adjust (1.0 . 1.0)
:latex-compiler ("xelatex -interaction nonstopmode -no-pdf -output-directory %o %f")
:image-converter ("dvisvgm %f --no-fonts --exact-bbox --scale=%S --output=%O"))))
(setq org-preview-latex-default-process 'dvisvgm)
(defun gortium/org-latex-preview-all ()
"Render all LaTeX fragments in the buffer."
(interactive)
(when (derived-mode-p 'org-mode)
(org-latex-preview '(16))))
;; 1.5s delay to ensure Doom font-colors load first
;; (add-hook 'org-mode-hook
;; (lambda ()
;; (run-with-idle-timer 1.5 nil #'gortium/org-latex-preview-all)))
)
(after! ox-latex
(setq org-latex-src-block-backend 'listings)
(add-to-list 'org-latex-packages-alist '("" "listings") t)
(add-to-list 'org-latex-packages-alist '("" "xcolor") t)
(add-to-list 'org-latex-listings-langs '(text ""))
(setq org-latex-listings-options
'(("breaklines" "true")
("basicstyle" "\\small\\ttfamily")
("frame" "single")
("backgroundcolor" "\\color{gray!10}"))))
(use-package! org-fragtog
:hook (org-mode . org-fragtog-mode))
(defun gortium/org-latex-refresh-on-zoom (&rest _)
(when (derived-mode-p 'org-mode)
(let ((new-scale (+ 2 (* 0.5 (if (boundp 'text-scale-mode-amount) text-scale-mode-amount 0)))))
(setq org-format-latex-options
(plist-put org-format-latex-options :scale new-scale))
(org-clear-latex-preview)
(gortium/org-latex-preview-all))))
(advice-add 'text-scale-increase :after #'gortium/org-latex-refresh-on-zoom)
(advice-add 'text-scale-decrease :after #'gortium/org-latex-refresh-on-zoom)
(after! org
(setq
org-capture-templates
'(
("t" "TODO" entry (file+olp +org-capture-todo-file "Tasks")
"* IDEA %? \n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i" :prepend t)
("c" "Code TODO" entry
(file+olp +org-capture-todo-file "Tasks")
"* TODO %a\n:PROPERTIES:\n:CREATED: %U\n:END:\n":prepend t)
("n" "Note" entry (file+olp +org-capture-notes-file "Notes")
"* %? \n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
("w" "Question" entry (file+olp +org-capture-notes-file "Questions")
"* [?] %? \n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i")
("j" "Journal" entry (file+olp +org-capture-journal-file "Journal")
"* %?\n%a\n%i")
("m" "Meeting" entry (file+olp +org-capture-notes-file "Meetings")
"* %? %U :meeting:\n:PROPERTIES:\n:CREATED: %U\n:END:\n\n/Met with: /")
("a" "Appointment" entry (file+olp +org-capture-journal-file "Appointments")
"* %? :appointment:\n:PROPERTIES:\n:CREATED: %U\n:END:\n%a\n%i" :time-prompt t)
("e" "Email Workflow")
("ef" "Follow Up" entry (file+olp +org-capture-emails-file "Mails" "Follow Up") "* TODO Follow up with %:fromname on %a\nSCHEDULED:%t\nDEADLINE:%(org-insert-time-stamp (org-read-date nil t \"+2d\"))\n\n%i" :immediate-finish t)
("er" "Read Later" entry (file+olp +org-capture-emails-file "Mails" "Read Later") "* TODO Read: %a\nSCHEDULED:%t\nDEADLINE:%(org-insert-time-stamp (org-read-date nil t \"+2d\"))\n\n%i" :immediate-finish t)
("ew" "Waiting Response" entry
(file+olp +org-capture-emails-file "Mails" "Waiting Response") "* WAIT Waiting response from %:toname on %a\nDEADLINE:%(org-insert-time-stamp (org-read-date nil t \"+2d\"))\n\n%i" :immediate-finish t)
)
)
)
(after! org
;; Ensure exported HTML opens in the system browser, not inside Emacs
(setq org-file-apps
(append '(("\\.x?html?\\'" . default))
(assq-delete-all "\\.x?html?\\'" org-file-apps))))
(after! grip-mode
(setq grip-binary-path "/home/tpouplier/.local/bin/grip")
)
(after! markdown-mode
(setq markdown-header-scaling t)
(custom-set-faces
'(markdown-header-face-1 ((t
:inherit markdown-header-face
:foreground "#fabd2f"
:height 1.8
:weight extra-bold)))
'(markdown-header-face-2 ((t
:inherit markdown-header-face
:foreground "#fe8019"
:height 1.4
:weight extra-bold)))
'(markdown-header-face-3 ((t
:inherit markdown-header-face
:foreground "#fb4934"
:height 1.2
:weight bold)))
'(markdown-header-face-4 ((t
:inherit markdown-header-face
:foreground "#b8bb26"
:height 1.1
:weight bold)))
'(markdown-header-face-5 ((t
:inherit markdown-header-face
:foreground "#83a598"
:height 1.0
:weight bold)))
'(markdown-header-face-6 ((t
:inherit markdown-header-face
:foreground "#d3869b"
:height 1.0
:weight bold))))
)
;; Enable plantuml-mode for PlantUML files
(add-to-list 'auto-mode-alist '("\\.plantuml\\'" . plantuml-mode))
(setq org-roam-directory (file-truename "~/ExoKortex/")
org-roam-db-location (expand-file-name "org-roam.db" roam-base-path)
org-attach-id-dir "assets/"
org-roam-dailies-directory (expand-file-name "Daily" journal-base-path))
(after! org-roam
org-roam-completion-everywhere t
org-roam-db-autosync-mode 1
(require 'org-roam-dailies)
(setq org-roam-dailies-capture-templates
'(("n" "Note" entry "** %<%H:%M> 📝 %?"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:empty-lines 1)
("t" "Task" entry "** %<%H:%M> 🛠 %a"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:immediate-finish t
:empty-lines 1)
("m" "Meeting" entry "** %<%H:%M> 👥 Meeting %?"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:empty-lines 1)
("u" "Muffin" entry "** %<%H:%M> 🥐 Muffin"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:immediate-finish t
:empty-lines 1)
("e" "Event" entry "** %<%H:%M> 📅 Event: %?"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:empty-lines 1)
("c" "Clock In" entry "** %<%H:%M> ⏱ Clocked In"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:immediate-finish t
:empty-lines 1)
("o" "Clock Out" entry "** %<%H:%M> 🚪 Clocked Out"
:target (file+head+olp "%<%Y-%m-%d>.org"
"#+title: %<%Y-%m-%d>\n\n* Journal\n"
("Journal"))
:immediate-finish t
:empty-lines 1)
)
)
)
;; 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 ()
"Create a new entry in org-roam daily notes with interactive prompt.
Handles org-clock and context link capture for tasks."
(interactive)
(let* ((choices '(("Task 🛠" . task)
("Note 📝" . note)
("Meeting 👥" . meeting)
("Muffin 🥐" . muffin)
("Event 📅" . event)
("Clock In ⏱" . in)
("Clock Out 🚪" . out)))
(choice-label (completing-read "Entry type: " (mapcar #'car choices)))
(entry-type (cdr (assoc choice-label choices))))
;; Handle preconditions
(cond
((eq entry-type 'task)
(let ((check-and-clock-in
(lambda ()
(unless (org-entry-get nil "TODO" t)
(user-error "Point is not under a TODO heading"))
(org-todo "STRT")
(org-clock-in)
(org-store-link nil t))))
(cond
((eq major-mode 'org-agenda-mode)
(save-window-excursion
(org-agenda-switch-to)
(funcall check-and-clock-in)))
((eq major-mode 'org-mode)
(funcall check-and-clock-in))
(t
(user-error "Unsupported major mode for task entry")))))
((eq entry-type 'muffin)
(org-clock-out))
((eq entry-type 'out)
(org-clock-out))
((eq entry-type 'in)
(org-clock-in)))
;; Launch capture
(let ((key (pcase entry-type
('note "n") ('task "t") ('meeting "m")
('muffin "m") ('event "e")
('in "c") ('out "o"))))
(org-roam-dailies-capture-date nil nil key))))
;; Connecteam org-roam-dailies integration
(require 'request)
(defvar gortium/connecteam-api-key (auth-source-passage-get 'secret "connecteam") "Your Connecteam API Key")
(defvar gortium/connecteam-user-id "9885891" "Your Connecteam User ID")
(defvar gortium/connecteam-clock-id "9335145" "Your Connecteam time clock ID used in API calls.")
(defun gortium/connecteam-clock-in (job-id time-clock-id)
"Send a clock-in request to Connecteam API for JOB-ID and TIME-CLOCK-ID."
(request
(format "https://api.connecteam.com/time-clock/v1/time-clocks/%s/clock-in" time-clock-id)
:type "POST"
:headers `(("X-API-KEY" . ,gortium/connecteam-api-key)
("accept" . "application/json")
("content-type" . "application/json"))
:data (json-encode `((userId . ,gortium/connecteam-user-id)
(jobId . ,job-id)))
:parser 'json-read
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Connecteam clock-in successful: %s" (alist-get 'message data))))
:error (cl-function
(lambda (&rest args &key error-thrown &allow-other-keys)
(message "Connecteam clock-in failed: %S" error-thrown)))))
(defun gortium/connecteam-clock-out (time-clock-id)
"Send a clock-out request to Connecteam API for TIME-CLOCK-ID."
(request
(format "https://api.connecteam.com/time-clock/v1/time-clocks/%s/clock-out" time-clock-id)
:type "POST"
:headers `(("X-API-KEY" . ,gortium/connecteam-api-key)
("accept" . "application/json")
("content-type" . "application/json"))
:data (json-encode `((userId . ,gortium/connecteam-user-id)))
:parser 'json-read
:success (cl-function
(lambda (&key data &allow-other-keys)
(message "Connecteam clock-out successful: %s" (alist-get 'message data))))
:error (cl-function
(lambda (&rest args &key error-thrown &allow-other-keys)
(message "Connecteam clock-out failed: %S" error-thrown)))))
(defun gortium/org-clock-in-to-connecteam ()
"Clock in to Connecteam using the job ID in the current Org task."
(when (org-entry-get nil "CONNECTEAM_JOB_ID" t)
(let ((job-id (org-entry-get nil "CONNECTEAM_JOB_ID" t)))
(gortium/connecteam-clock-in job-id gortium/connecteam-clock-id))))
(add-hook 'org-clock-in-hook #'gortium/org-clock-in-to-connecteam)
(defun gortium/org-clock-out-from-connecteam ()
"Clock out from Connecteam or trigger vacation request if MUFFIN property is set."
(let ((muffin (org-entry-get nil "MUFFIN")))
(if muffin
(gortium/connecteam-vacation-request)
(gortium/connecteam-clock-out gortium/connecteam-clock-id))))
(add-hook 'org-clock-out-hook #'gortium/org-clock-out-from-connecteam)
(after! calendar
;; Define a function to open org-roam daily note for selected date in calendar
(defun gortium/org-roam-dailies-open-at-point ()
"Open org-roam daily note for date at point in calendar."
(interactive)
(let ((date (calendar-cursor-to-date)))
(org-roam-dailies-goto-date date)))
)
;; Bind RET in calendar mode to open daily note instead of default exit
(evil-define-key 'normal calendar-mode-map
(kbd "RET") #'gortium/org-roam-dailies-open-at-point)
(after! dap-mode
(require 'dap-python)
(setq dap-python-debugger 'debugpy)
(dap-ui-mode 1)
(add-hook 'dap-terminated-hook
(lambda ()
(dap-ui-controls-mode -1)
(dap-ui-mode -1))))
(after! dap-python
(dap-register-debug-template
"Python :: My Script with Args"
(list :type "python"
:args ["--start" "73"
"--input" "/home/tpouplier/ExoKortex/1-Projects/Exit_strat/Resources/G05730212_111 scan plans/curve_G05730212_111.src"
"--template" "/home/tpouplier/ExoKortex/1-Projects/Exit_strat/exit_strat/ExitPath_Template.src"
"--output" "/home/tpouplier/ExoKortex/1-Projects/Exit_strat/exit_strat/output/ExitPath.src"]
:cwd nil
:module nil
:program nil
:request "launch"
:name "Python :: My Script with Args")))
;; LSP BABY
(after! lsp-mode
(setq lsp-csharp-server-path "~/.local/tools/omnisharp-mono/omnisharp.sh")
)
;; (setq scroll-margin 10)
;; Enable visual line wrapping globally
;; (global-visual-line-mode 1)
;; Enable visual line wrapping in certain modes
(after! org
(add-hook 'org-mode-hook #'visual-line-mode))
(after! prog-mode
(add-hook 'prog-mode-hook #'visual-line-mode))
(after! text-mode
(add-hook 'text-mode-hook #'visual-line-mode))
(use-package! rainbow-delimiters
:hook ((emacs-lisp-mode lisp-mode scheme-mode clojure-mode) . rainbow-delimiters-mode))
(after! org-src
(add-hook 'org-src-mode-hook #'rainbow-delimiters-mode))
(use-package! org-phscroll
:after org)
(after! ispell
(setq ispell-program-name "hunspell"
ispell-dictionary "en_CA,fr_CA"
ispell-personal-dictionary (expand-file-name "perso.dic" dict-base-path))
;; Set the dictionary path environment variable
(setenv "DICPATH" (expand-file-name dict-base-path))
(setq ispell-hunspell-dict-paths-alist
`(("fr_CA" ,(expand-file-name "fr_CA.aff" dict-base-path))
("en_CA" ,(expand-file-name "en_CA.aff" dict-base-path))))
(setq ispell-hunspell-dictionary-alist
'(("fr_CA" "[[:alpha:]]" "[^[:alpha:]]" "[']" nil ("-d" "fr_CA") nil utf-8)
("en_CA" "[[:alpha:]]" "[^[:alpha:]]" "[']" nil ("-d" "en_CA") nil utf-8)))
;; ispell-set-spellchecker-params has to be called
;; before ispell-hunspell-add-multi-dic will work
(ispell-set-spellchecker-params)
(ispell-hunspell-add-multi-dic "en_CA,fr_CA"))
(add-hook 'writegood-mode-hook 'writegood-passive-voice-turn-off)
;; Move selected region up or down
(use-package! drag-stuff
:config
;; Enable drag-stuff-mode globally
(drag-stuff-global-mode 1)
;; Set keybindings
(map! :v "M-j" #'drag-stuff-down
:v "M-k" #'drag-stuff-up
:n "M-j" #'drag-stuff-down
:n "M-k" #'drag-stuff-up)
;; Fold bindings
(after! evil
(define-key evil-motion-state-map (kbd "z C") #'hs-hide-level))
;; Disable global bindings for C-j and C-k
(global-unset-key (kbd "C-j"))
(global-unset-key (kbd "C-k"))
;; Optionally define default keybindings (if needed)
(drag-stuff-define-keys))
(setq display-line-numbers-type 'relative)
(use-package! evil-escape
:config
(setq evil-escape-excluded-states '(normal visual multiedit emacs motion)
evil-escape-excluded-major-modes '(neotree-mode treemacs-mode vterm-mode)
evil-escape-key-sequence "jk"
evil-escape-delay 0.15)
(evil-escape-mode 1)
)
(after! nix-mode
(setq lsp-nix-nil-formatter ["nixpkgs-fmt"])
(add-hook 'nix-mode-hook #'lsp!))
(after! lsp-mode
(setq lsp-nix-server 'nil))
(add-hook 'krl-mode-hook 'font-lock-mode)
(add-hook 'krl-mode-hook 'display-line-numbers-mode)
(use-package! krl-mode
:mode (("\\.src\\'" . krl-mode)
("\\.dat\\'" . krl-mode)
("\\.sub\\'" . krl-mode)))
(use-package! auto-highlight-symbol
:hook (krl-mode . auto-highlight-symbol-mode)
:config
(setq ahs-idle-interval 0.5 ; highlight after 0.5s
ahs-default-range 'ahs-range-whole-buffer ; highlight in whole buffer
ahs-case-fold-search t ; case-INsensitive matching
ahs-include-definition t)) ; highlight definition too
(defcustom krl-formatter-script-path "~/ExoKortex/1-Projects/Exit_strat/robot_program_formater/scripts/krl_formatter.py"
"Path to the KRL formatter script."
:type 'string
:group 'krl)
(defun krl-format-buffer ()
"Format the current buffer using the KRL formatter."
(interactive)
(let ((original-point (point))
(original-line (line-number-at-pos)))
(shell-command-on-region
(point-min)
(point-max)
(concat "python " krl-formatter-script-path)
t t)
;; Try to restore cursor position
(goto-char (point-min))
(forward-line (1- original-line))
(message "KRL buffer formatted")))
(defun krl-format-region (start end)
"Format the selected region using the KRL formatter."
(interactive "r")
(shell-command-on-region
start
end
(concat "python " krl-formatter-script-path)
t t)
(message "KRL region formatted"))
;; Auto-format on save (optional)
(defun krl-format-before-save ()
"Format KRL code before saving."
(when (eq major-mode 'krl-mode)
(krl-format-buffer)))
;; Uncomment the next line to enable auto-formatting on save
;; (add-hook 'before-save-hook 'krl-format-before-save)
(use-package! hledger-mode
:config
(setq hledger-jfile "~/2-Areas/Finances/finance_vault/tpouplier.hledger")
(setq hledger-top-asset-account "Assets")
(setq hledger-top-income-account "Incomes")
(setq hledger-top-expense-account "Expenses")
(setq hledger-ratios-debt-accounts "Liabilities")
(setq hledger-ratios-assets-accounts "Assets")
(setq hledger-ratios-income-accounts "Incomes")
(setq hledger-ratios-liquid-asset-accounts "Assets:Cash")
(setq hledger-ratios--accounts "Assets:Cash")
;; (setq hledger-ratios-debt-accounts "Liabilities")
(add-to-list 'auto-mode-alist '("\\.hledger\\'" . hledger-mode))
)
(use-package! age
:demand t
:config
(setq age-program "rage")
(setq age-default-identity "~/.ssh/gortium_ssh_key")
(setq age-default-recipient "~/.ssh/gortium_ssh_key.pub")
(age-file-enable))
(require 'notifications)
(require 'cl-lib)
(defun gortium/age-notify (msg &optional simple)
"Notify about AGE operations. SIMPLE uses `message` instead of desktop notification."
(if simple
(message "%s" msg)
(if (eq system-type 'gnu/linux)
(notifications-notify
:title "age.el"
:body msg
:urgency 'low
:timeout 800)
(message "%s" msg))))
(defun gortium/age-notify-decrypt (&rest args)
"Notification hook for age decryption."
(cl-destructuring-bind (context cipher) args
(gortium/age-notify (format "Decrypting %s" (age-data-file cipher)) t)))
(defun gortium/age-notify-encrypt (&rest args)
"Notification hook for age encryption."
(cl-destructuring-bind (context plain recipients) args
(gortium/age-notify (format "Encrypting %s" (age-data-file plain)) t)))
(defun gortium/age-toggle-decrypt-notifications ()
"Toggle notifications for age decryption."
(interactive)
(if (advice-member-p #'gortium/age-notify-decrypt #'age-start-decrypt)
(progn
(advice-remove #'age-start-decrypt #'gortium/age-notify-decrypt)
(message "Disabled age decrypt notifications."))
(advice-add #'age-start-decrypt :before #'gortium/age-notify-decrypt)
(message "Enabled age decrypt notifications.")))
(defun gortium/age-toggle-encrypt-notifications ()
"Toggle notifications for age encryption."
(interactive)
(if (advice-member-p #'gortium/age-notify-encrypt #'age-start-encrypt)
(progn
(advice-remove #'age-start-encrypt #'gortium/age-notify-encrypt)
(message "Disabled age encrypt notifications."))
(advice-add #'age-start-encrypt :before #'gortium/age-notify-encrypt)
(message "Enabled age encrypt notifications.")))
;; enable notifications by default
(gortium/age-toggle-decrypt-notifications)
(gortium/age-toggle-encrypt-notifications)
(use-package! passage
:demand t
:config
;; rebind function value for pass to passage
(fset #'pass (lambda () (interactive) (passage)))
(setq age-program "rage")
(setq auth-source-passage-filename (expand-file-name "secrets" dotfiles-base-path))
(setenv "PASSAGE_IDENTITIES_FILE" (expand-file-name age-default-identity))
(setenv "PASSAGE_RECIPIENTS_FILE" (expand-file-name age-default-recipient))
(setenv "PASSAGE_AGE" "rage")
(setenv "PASSAGE_DIR" (expand-file-name "secrets" dotfiles-base-path))
)
(diff-hl-mode +1)
;; Ensure C-j works properly in insert state in vterm
(after! vterm
(add-hook 'vterm-mode-hook
(lambda ()
(evil-local-set-key 'insert (kbd "C-j") #'vterm--self-insert))))
;; GPTel AI chat for emacs
(use-package! gptel
:config
;; (advice-add 'gptel-rewrite :after #'gptel-rewrite-diff)
(add-hook 'gptel-post-stream-hook 'gptel-auto-scroll)
(setq gptel-display-buffer-action
'(display-buffer-same-window))
(defun gptel-set-default-directory ()
(unless (buffer-file-name)
(setq default-directory "~/ExoKortex/3-Resources/AI-Chat/")))
(add-hook 'gptel-mode-hook #'gptel-set-default-directory)
(setq gptel-expert-commands t
gptel-default-mode 'org-mode
;; gptel-model 'OpenRouter:deepseek/deepseek-chat-v3-0324:free
gptel-api-key (auth-source-passage-get 'secret "openrouter"))
(require 'gptel-integrations)
(gptel-make-openai "OpenRouter"
:host "openrouter.ai"
:endpoint "/api/v1/chat/completions"
:stream t
:key (auth-source-passage-get 'secret "openrouter")
:models '(deepseek/deepseek-r1-0528-qwen3-8b:free
google/gemini-2.0-flash-exp:free
deepseek/deepseek-chat-v3-0324:free
meta-llama/llama-4-maverick:free
mistralai/devstral-2512:free
qwen/qwen3-coder:free
nvidia/nemotron-3-nano-30b-a3b:free))
(gptel-make-gemini "Gemini"
:key (auth-source-passage-get 'secret "gemini")
:stream t
:models '(gemini-3-pro-preview
gemini-2.5-pro
gemini-3-pro
gemini-2.5-flash))
(gptel-make-ollama "Ollama"
:host "localhost:11434"
:stream t
:models '(deepseek-r1:1.5b
gemini-3-pro-preview
orieg/gemma3-tools:4b)) ;List of models
(gptel-make-openai "OpenWebUI"
:host "ai.aziworkhorse.duckdns.org"
:curl-args '("--insecure") ; needed for self-signed certs
:key (auth-source-passage-get 'secret "openwebui")
:endpoint "/api/chat/completions"
:stream t
:models '("orieg/gemma3-tools:1b"))
(gptel-make-openai "MistralLeChat"
:host "api.mistral.ai"
:endpoint "/v1/chat/completions"
:protocol "https"
:key (auth-source-passage-get 'secret "mistral")
:models '("devstral-2512"))
(setq-default gptel-backend (gptel-get-backend "MistralLeChat")
gptel-model 'devstral-2512))
(map! :after gptel
:leader
(
:prefix ("r" . "GPTel Rewrite")
:desc "Rewrite region" "r" #'gptel-rewrite
:desc "Show rewrite diff" "d" #'gptel--rewrite-diff
:desc "Accept rewrite" "a" #'gptel--rewrite-accept
:desc "Reject rewrite" "x" #'gptel--rewrite-reject
))
(defun gortium/magit-gptel-generate-commit ()
"Generate a professional Git commit message from staged changes."
(interactive)
(unless (and buffer-file-name
(string-match-p "COMMIT_EDITMSG" buffer-file-name))
(user-error "Not in a commit message buffer"))
(let ((diff (magit-git-string "diff" "--cached"))
(buf (current-buffer)))
(when (or (null diff) (string-empty-p diff))
(user-error "No staged changes"))
(gptel-request
(format
"You are a professional software engineer writing a Git commit message.
IMPORTANT RULES:
- You MUST only use information contained in the diff below.
- Do NOT guess or invent changes.
- If the diff is unclear, write a generic message such as:
'Update files with staged changes' and do NOT mention specific file details.
- The first line must be imperative and <= 50 characters.
- Provide a short bullet list in the body (24 bullets max).
DIFF:
%s"
diff)
:stream nil
:callback
(lambda (response _info)
(with-current-buffer buf
(erase-buffer)
(insert (string-trim response) "\n"))))))
(map! :after magit
:map git-commit-mode-map
:localleader
:desc "GPT generate commit"
"g" #'gortium/magit-gptel-generate-commit)
(after! vterm
(defun gortium/hermes-vterm ()
"Launch Hermes Agent in vterm via SSH to lazyworkhorse (CLI mode)."
(interactive)
(let ((buf (get-buffer-create "*hermes*")))
(switch-to-buffer buf)
(unless (and (eq major-mode 'vterm-mode)
(get-buffer-process buf))
(vterm-mode)
(let ((proc (get-buffer-process buf)))
(when proc
(process-send-string
proc "ssh -t lazyworkhorse 'docker exec -it hermes /opt/hermes/.venv/bin/hermes'\n")))))))
;; set `tramp-direct-async-process' locally in all ssh connections
(connection-local-set-profile-variables
'remote-direct-async-process
'((tramp-direct-async-process . t)))
(connection-local-set-profiles
'(:application tramp :protocol "ssh")
'remote-direct-async-process)
(after! tramp
(setq tramp-verbose 10)
;; Custom TRAMP configuration for Windows SSH ;; (add-to-list 'tramp-connection-properties
;; (list "ssh"
;; "Ingenuity-win"
;; "tramp-shell-prompt-pattern"
;; "\\$ $"))
)
;; Dirvish config
(after! (dirvish dired)
;; Charger l'extension rsync
(require 'dirvish-rsync)
;; Display icons, file size, timestamps, etc.
(setq dirvish-attributes
'(nerd-icons
subtree-state
file-size
file-time))
(setq dirvish-default-layout '(0.3 0.15 0.55))
;; Use a header line instead of the traditional dired modeline
(setq dirvish-use-header-line t)
;; Replace default dired mode globally
(dirvish-override-dired-mode)
;; Config rsync (les nouvelles variables)
(setq dirvish-rsync-args '("-ah" "--info=progress2"))
(setq dirvish-rsync-use-yank-menu t)
;; Remapper 'y' pour utiliser rsync au lieu du batch Emacs
(setq dirvish-yank-keys
'(("y" "Rsync here" dirvish-rsync)
("m" "Move here" dirvish-move)
("s" "Make symlinks here" dirvish-symlink)
("r" "Make relative symlinks here" dirvish-relative-symlink)
("h" "Make hardlinks here" dirvish-hardlink)))
(setq! dirvish-quick-access-entries
`(("h" "~/" "Home")
("e" "~/ExoKortex" "ExoKortex")
("p" "~/ExoKortex/1-Projects" "Projects")
("a" "~/ExoKortex/2-Areas" "Areas")
("r" "~/ExoKortex/3-Resources" "Resources")
("i" "~/ExoKortex/4-Archives" "Archives")
("d" "~/Downloads" "Downloads")
("u" "/run/media/" "Mounted drives")
("g" "~/.local/share/Trash/files" "Trash")))
)
;; Use `p` to open the yank menu
(map! :after dirvish-yank
:map dirvish-mode-map
:n "p" #'dirvish-yank-menu)
(map! :after dirvish
:leader
:desc "Keep all" "oi" #'dirvish-dwim)
;; Enable previewing of surrounding lines in consult-ripgrep
(setq consult-ripgrep-preview t)
;; Weather
(after! wttrin
(setq wttrin-default-cities '("Blainville" "Canada"))
)
;; Not working..
;; (setq weather-metno-location-name "Blainville, Canada"
;; weather-metno-location-latitude 45
;; weather-metno-location-longitude 73)
;; Modern look for org
(use-package! org-modern
:after org
:config
(global-org-modern-mode)
(setq org-modern-star 'replace)
(setq org-modern-todo-faces
'(
;; Framework
("EPIC" :foreground "#b16286" :inverse-video t :weight bold) ; large project
("AREA" :foreground "#83a598" :inverse-video t :weight bold) ; domain
("PROJ" :foreground "#458588" :inverse-video t :weight bold) ; specific project
;; Tasks
("TODO" :foreground "#fb4934") ; to do
("STRT" :foreground "#fe8019" :weight bold :inverse-video t) ; started
("NEXT" :foreground "#fb4934" :weight bold :underline t) ; next action
("LOOP" :foreground "#fabd2f") ; recurring
("DELG" :foreground "#8ec07c") ; delegated
("WAIT" :foreground "#fabd2f" :slant italic) ; waiting
("HOLD" :foreground "#7c6f64" :slant italic) ; on hold
("IDEA" :foreground "#d65d0e" :slant italic) ; idea
;; Done / Cancelled
("DONE" :foreground "#98971a") ; done
("CNCL" :foreground "#928374" :strike-through t) ; cancelled
;; Checkboxes
("[ ]" :foreground "#fb4934") ; unchecked
("[-]" :foreground "#fabd2f") ; partial
("[X]" :foreground "#98971a") ; checked
;; Special indicators
("[!]" :foreground "#fb4934" :weight bold :inverse-video t) ; urgent
("[?]" :foreground "#d79921" :weight bold) ; uncertain
("[i]" :foreground "#b16286" :slant italic) ; info
;; Binary questions
("Y/N" :foreground "#fe8019" :weight bold) ; question
("YES" :foreground "#b8bb26") ; yes
("NOP" :foreground "#928374") ; no
)
)
)
;; Flash the point (cursor) when moving between window
(use-package! beacon
:config
(beacon-mode 1))
;; Deleted file go to trash instead of been destroyed for ever... (rm -r / --do-it)
(setq delete-by-moving-to-trash t
trash-directory "~/.local/share/Trash/files/")
(after! org-msg
(setq mail-user-agent 'mu4e-user-agent)
(require 'org-msg)
(setq org-msg-options "html-postamble:nil H:5 num:nil ^:{} p:t toc:nil author:nil email:nil \\n:t"
org-msg-startup "hidestars indent inlineimages"
org-msg-greeting-fmt "\nBonjour%s,\n\n"
org-msg-greeting-name-limit 1
org-msg-default-alternatives '((new . (text html))
(reply-to-html . (text html))
(reply-to-text . (text)))
org-msg-convert-citation t)
)
(after! mu4e
(setq mu4e-maildir-initial-input mu4e-maildir-info-delimiter)
(setq mu4e-update-interval 300)
(setq mu4e-headers-auto-update t)
(setq mu4e-contexts
(list
;; --- CONTEXT: TDNDE ---
(make-mu4e-context
:name "TDNDE"
:match-func (lambda (msg)
(when msg
(string-prefix-p "/TDNDE/" (mu4e-message-field msg :maildir))))
:vars
'((user-mail-address . "tpouplier@tdnde.com")
(user-full-name . "Thierry Pouplier")
;; Folders
(mu4e-sent-folder . "/TDNDE/Sent")
(mu4e-drafts-folder . "/TDNDE/Drafts")
(mu4e-trash-folder . "/TDNDE/Trash")
(mu4e-refile-folder . "/TDNDE/Archive")
;; Shortcuts (Updated to modern Plist format)
(mu4e-maildir-shortcuts . ((:maildir "/TDNDE/Inbox" :key ?i)
(:maildir "/TDNDE/Sent" :key ?s)
(:maildir "/TDNDE/Drafts" :key ?d)
(:maildir "/TDNDE/Trash" :key ?t)
(:maildir "/TDNDE/Archive" :key ?a)))
;; SMTP
(smtpmail-smtp-user . "tpouplier@tdnde.com")
(smtpmail-stream-type . ssl)
(smtpmail-smtp-server . "smtp.hostinger.com")
(smtpmail-smtp-service . 465)
(mu4e-sent-messages-behavior . sent)
;; Context-specific signature
(org-msg-signature . "
Cdlt,
#+begin_signature
--
*Thierry Pouplier*
1319 rue Bergar, Laval, Qc, Canada \\\\
H7L 4Z7 \\\\
Tél. : +1 (450) 667-1884 \\\\
Cell. : +1 (514) 887-1674 \\\\
tpouplier@tdnde.com \\\\
www.tdnde.com \\\\
#+end_signature")))
;; --- CONTEXT: Perso ---
(make-mu4e-context
:name "Perso"
:match-func (lambda (msg)
(when msg
(string-prefix-p "/Perso/" (mu4e-message-field msg :maildir))))
;; Fixed standard single quote evaluation alignment
:vars '((user-mail-address . "thierrypouplier@gmail.com")
(user-full-name . "Thierry Pouplier")
(mu4e-sent-folder . "/Perso/Sent")
(mu4e-drafts-folder . "/Perso/Drafts")
(mu4e-trash-folder . "/Perso/Trash")
(mu4e-refile-folder . "/Perso/Archive")
;; Shortcuts (Updated to modern Plist format)
(mu4e-maildir-shortcuts . ((:maildir "/Perso/Inbox" :key ?i)
(:maildir "/Perso/Sent" :key ?s)
(:maildir "/Perso/Trash" :key ?t)
(:maildir "/Perso/Drafts" :key ?d)
(:maildir "/Perso/Archive" :key ?a)))
(smtpmail-smtp-user . "thierrypouplier@gmail.com")
(smtpmail-smtp-server . "smtp.gmail.com")
(smtpmail-smtp-service . 587)
(smtpmail-stream-type . starttls)
(mu4e-sent-messages-behavior . sent)
(org-msg-signature . "
Cordialement,
#+begin_signature
-r
*Thierry Pouplier* \\
(514) 887-1674 | thierrypouplier@gmail.com
#+end_signature")))))
(setq mu4e-context-policy 'pick-first
mu4e-compose-context-policy 'ask)
(require 'mu4e-icalendar)
(mu4e-icalendar-setup)
(setq mu4e-icalendar-diary-file nil)
(setq gnus-icalendar-org-capture-file +org-capture-journal-file
gnus-icalendar-org-capture-headline '("Journal"))
(gnus-icalendar-org-setup)
;; Separate after! blocks for clarity
(after! mu4e
(setq mu4e-modeline-support nil))
(after! mu4e
;; restore label key
(evil-define-key 'normal mu4e-headers-mode-map "l" #'mu4e-headers-mark-for-label)
(evil-define-key 'visual mu4e-headers-mode-map "l" #'mu4e-headers-mark-for-label)
(evil-define-key 'emacs mu4e-headers-mode-map "l" #'mu4e-headers-mark-for-label)
(evil-define-key 'normal mu4e-view-mode-map "l" #'mu4e-view-mark-for-label)
(evil-define-key 'visual mu4e-view-mode-map "l" #'mu4e-view-mark-for-label)
(evil-define-key 'emacs mu4e-view-mode-map "l" #'mu4e-view-mark-for-label)))
(defconst gortium/project-mu4e-keys '(?I ?S ?D ?T ?A)
"Character keys used for project mu4e maildir shortcuts (uppercase avoids conflicts).")
(defvar gortium/project-mu4e-bookmarks nil
"Cached project mu4e maildir shortcuts for re-injection when mu4e loads.")
(defun gortium/inject-project-mu4e-bookmarks ()
"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 '()))
;; 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 (cons (format "maildir:/%s.%s" (car folder) proj-tag)
(cdr folder))
project-bm)))
;; Inject into mu4e-maildir-shortcuts
(setq gortium/project-mu4e-bookmarks project-bm)
(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)
(when (get-buffer "*mu4e-main*")
(with-current-buffer "*mu4e-main*"
(mu4e--main-redraw))))))
(add-hook 'projectile-after-switch-project-hook
#'gortium/inject-project-mu4e-bookmarks)
(add-hook 'find-file-hook #'gortium/inject-project-mu4e-bookmarks)
(add-hook 'mu4e-main-mode-hook #'gortium/inject-project-mu4e-bookmarks)
(after! mu4e
(gortium/inject-project-mu4e-bookmarks))
(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"))
;; Set the default search filter (only show unread entries from the last x weeks)
(setq-default elfeed-search-filter "@4-weeks-ago +unread")
;; Automatically fetch feed updates over the network when you first open Elfeed
(add-hook! 'elfeed-search-mode-hook #'elfeed-update)
;; Optimize connection parameters to prevent lagging and queue timeouts
(setq elfeed-connection-timeout 10) ; Timeout limit for fetching a single feed (seconds)
(setq url-queue-timeout 15) ; Maximum wait time for the network queue
;; Set the function used to display entries (pop-to-buffer prevents taking over the whole screen)
(setq elfeed-show-entry-switch #'pop-to-buffer)
;; A pure MPV launcher (Bypasses buggy EMMS metadata scanners)
(defun gortium/launch-mpv-overlay (url)
"Launch MPV as a floating overlay directly via system process."
(start-process "elfeed-mpv" nil "mpv"
"--quiet" "--really-quiet"
"--geometry=30%x30%-30-50" "--ontop" "--no-border"
"--title=Elfeed MPV"
url))
;; Smart Elfeed Dispatcher
(defun gortium/elfeed-open-dispatcher ()
"Play YouTube and Podcasts directly in MPV; open web links in EWW."
(interactive)
(let* ((entry (if (eq major-mode 'elfeed-show-mode)
elfeed-show-entry
(elfeed-search-selected :single)))
(link (elfeed-entry-link entry))
;; Extract enclosures (where podcasts store their mp3s)
(enclosures (elfeed-entry-enclosures entry))
(enclosure-url (when enclosures (car (car enclosures)))))
(cond
;; Case 1: YouTube -> Direct MPV Overlay
((and link (string-match-p "youtu\\(?:\\.be\\|be\\.com\\)" link))
(message "Streaming YouTube video in MPV: %s" link)
(gortium/launch-mpv-overlay link))
;; Case 2: Podcast/Media Enclosure found -> Direct MPV Overlay
(enclosure-url
(message "Streaming podcast enclosure in MPV: %s" enclosure-url)
(gortium/launch-mpv-overlay enclosure-url))
;; Case 3: Standard Web Article (Must start with http) -> EWW
((and link (string-match-p "^http" link))
(message "Opening article in EWW (readable): %s" link)
(cond
((eq major-mode 'elfeed-show-mode)
(eww link))
(t
(let ((target-window (if (one-window-p)
(split-window-right)
(next-window))))
(select-window target-window)
(eww link))))
(add-hook 'eww-after-render-hook #'eww-readable nil t))
;; Case 4: Safety Fallback
(t
(message "No playable media or valid web link found for this entry.")))))
;; Bind the dispatcher
(map! :map elfeed-search-mode-map
"M-RET" #'gortium/elfeed-open-dispatcher)
(map! :map elfeed-show-mode-map
"M-RET" #'gortium/elfeed-open-dispatcher))
;; Initialize and configure elfeed-goodies for an enhanced UI layout
(after! elfeed-goodies
;; Enable the standard elfeed-goodies setup (adds search headers and status lines)
(elfeed-goodies/setup)
;; Choose where to split the window when viewing an entry ('bottom, 'left, 'right, 'top)
(setq elfeed-goodies/entry-pane-position 'right)
;; Define how much screen space the entry display pane takes (0.5 = 50% of the frame)
(setq elfeed-goodies/entry-pane-size 0.5)
;; Display feed tags in their own clean, separate column in the search buffer
(setq elfeed-goodies/show-tags-as-column t)
;; Prevent feed source URLs from cluttering up the title line in search results
(setq elfeed-goodies/feed-source-column-width 16))
;; Configure elfeed-tube (active when +youtube flag is enabled)
(after! elfeed-tube
;; Initialize elfeed-tube to fetch YouTube metadata and transcripts
(elfeed-tube-setup)
;; Define a quick binding to manually fetch/refresh a transcript if auto-fetch is idle
(map! :map elfeed-show-mode-map
:ne "F" #'elfeed-tube-fetch))
;; Configure EMMS to use MPV for streaming
(after! emms
(require 'emms-setup)
(emms-all)
(require 'emms-source-file)
;; Swap VLC out for MPV (which seamlessly leverages yt-dlp)
(setq emms-player-list '(emms-player-mpv))
;; Force EMMS to route HTTP/HTTPS URLs matching YouTube patterns to MPV
(emms-player-set emms-player-mpv 'regex
(rx (or (: "http" (? "s") "://" (* nonl) "youtube.com" (* nonl))
(: "http" (? "s") "://" (* nonl) "youtu.be" (* nonl))))))
(use-package! eaf
:load-path "~/.config/emacs/.local/straight/repos/emacs-application-framework"
:config
(require 'eaf)
;; Load the Browser module (for YouTube web streams)
(add-to-list 'load-path "~/.config/emacs/.local/straight/repos/emacs-application-framework/app/browser")
(require 'eaf-browser)
;; Load the Video Player module (for local video files)
(add-to-list 'load-path "~/.config/emacs/.local/straight/repos/emacs-application-framework/app/video-player")
(require 'eaf-video-player))
(defun gortium-circe-nickserv-password (server)
(auth-source-passage-get 'secret "irc"))
(after! circe
;; set your nick, username, and real name
(setq circe-nick "gortium"
circe-user-name "gortium"
circe-real-name "gortium")
(setq circe-network-options
'(("Libera Chat"
:tls t
:nick "gortium"
:sasl-username "gortium"
:nickserv-password gortium-circe-nickserv-password
:channels ("#emacs-circe")
)))
)
;; Enable midnight mode by default for PDF files
(use-package! pdf-tools
:magic ("%PDF" . pdf-view-mode)
:config
;; Install the server binary if needed (permanent)
(pdf-tools-install :no-query)
;; Hooks
;; (add-hook 'pdf-view-mode-hook #'pdf-view-midnight-minor-mode)
(add-hook 'pdf-view-mode-hook #'pdf-view-roll-minor-mode)
)
;;Refile in datetree function
(defun org-refile-to-datetree (&optional file)
"Refile a subtree to a datetree corresponding to its timestamp.
The current time is used if the entry has no timestamp.
If FILE is nil, refile in the current file."
(interactive
(list (read-file-name "Refile to file: "
nil ; directory
(buffer-file-name) ; default filename
t))) ; mustmatch
(let* ((datetree-date (or (org-entry-get nil "TIMESTAMP" t)
(org-read-date t nil "now")))
(date (org-date-to-gregorian datetree-date)))
(with-current-buffer (current-buffer)
(save-excursion
(org-cut-subtree)
(when file
(find-file file))
(org-datetree-find-date-create date)
(org-narrow-to-subtree)
(show-subtree)
(org-end-of-subtree t)
(newline)
(goto-char (point-max))
(org-paste-subtree 4)
(widen)))))
(require 'calendar)
(defun gortium/org-create-datetree-range ()
"Génère une structure datetree Org entre deux dates choisies via le calendrier interactif.
Si la date de début est validée vide, utilise la date d'aujourd'hui."
(interactive)
(let* ((start-time (org-read-date nil t nil "Date de début (Entrée pour aujourd'hui) : "))
(end-time (org-read-date nil t nil "Date de fin : "))
(start-decoded (decode-time start-time))
(end-decoded (decode-time end-time))
;; Format attendu par le calendrier Emacs : (mois jour année)
(start-date (list (decoded-time-month start-decoded)
(decoded-time-day start-decoded)
(decoded-time-year start-decoded)))
(end-date (list (decoded-time-month end-decoded)
(decoded-time-day end-decoded)
(decoded-time-year end-decoded)))
(start-abs (calendar-absolute-from-gregorian start-date))
(end-abs (calendar-absolute-from-gregorian end-date)))
(if (< end-abs start-abs)
(user-error "Erreur : La date de fin doit être après la date de début ! ")
(save-excursion
(cl-loop for abs from start-abs to end-abs
do (org-datetree-find-date-create (calendar-gregorian-from-absolute abs))))
(message "Datetree généré avec succès !"))))
;; Open all org fold in ediff
(defun org-ediff-prepare-buffer ()
(when (memq major-mode '(org-mode emacs-lisp-mode))
(outline-show-all)))
(add-hook 'ediff-prepare-buffer-hook 'org-ediff-prepare-buffer)
;;; ============================================================
;;; GORTIUM — Org chain scheduler (Safe & Optimized with Debugging)
;;; ============================================================
(require 'org)
(require 'org-id)
(require 'cl-lib)
(require 's)
;; ------------------------------------------------------------
;; Helper: Skip weekends (snap to next weekday at 08:00)
;; ------------------------------------------------------------
(defun gortium/org--skip-weekend (time)
(let* ((decoded (decode-time time))
(dow (nth 6 decoded)))
(cond
((= dow 6) ;; Saturday
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 2)))))))
((= dow 0) ;; Sunday
(apply #'encode-time
(append '(0 0 8)
(nthcdr 3 (decode-time (time-add time (days-to-time 1)))))))
(t time))))
;; ------------------------------------------------------------
;; Helper: Snap to working hours (08:0016:00)
;; ------------------------------------------------------------
(defun gortium/internal--snap-to-working-hours (time)
(let* ((day-start 8)
(day-end 16)
(t1 (gortium/org--skip-weekend time))
(d (decode-time t1))
(h (nth 2 d)))
(cond
((< h day-start)
(apply #'encode-time (append (list 0 0 day-start) (nthcdr 3 d))))
((>= h day-end)
(let ((next (time-add t1 (days-to-time 1))))
(gortium/org--skip-weekend
(apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time next)))))))
(t t1))))
(defun gortium/org--get-range-end (pos)
"Extract the END timestamp from an existing range like <A>--<B> at POS.
POS can be a marker or a cons cell (file . position)."
(let ((marker (if (markerp pos) pos
(let ((file (car pos))
(p (cdr pos)))
(with-current-buffer (find-file-noselect file)
(copy-marker p))))))
(with-current-buffer (marker-buffer marker)
(save-excursion
(goto-char marker)
(org-back-to-heading t)
(let ((subtree-end (save-excursion (org-end-of-subtree t) (point)))
(end-time nil))
(save-restriction
(narrow-to-region (point) subtree-end)
(goto-char (point-min))
(when (re-search-forward "<[^>]+>--<\\([^>]+\\)>" nil t)
(condition-case nil
(setq end-time (org-time-string-to-time (match-string 1)))
(error nil))))
end-time)))))
(defun gortium/org--get-range-start (pos)
"Extract the start timestamp from an existing range like <A>--<B>.
Returns nil if no range found (safe, non-blocking)."
(save-excursion
(goto-char pos)
(org-back-to-heading t)
(let ((end (save-excursion
(or (ignore-errors (outline-next-heading))
(point-max))
(point)))
(start-time nil))
(save-restriction
(narrow-to-region (point) end)
(goto-char (point-min))
;; Look for range in first 50 lines only (safety limit)
(let ((search-limit (save-excursion
(forward-line 50)
(point))))
(when (re-search-forward "<\\([^>]+\\)>--<[^>]+>" search-limit t)
(condition-case nil
(setq start-time (org-time-string-to-time (match-string 1)))
(error nil)))))
start-time)))
(defun gortium/internal--calculate-task-span (start-time effort-str)
"Return a list (END-TIME WEEKEND-DAYS) for given START-TIME and EFFORT string."
(if (or (null effort-str) (string-empty-p effort-str))
(list start-time 0)
(let* ((eff-mins (org-duration-to-minutes effort-str))
(total-work-mins (if (string-match-p "d" effort-str)
(* (/ eff-mins 1440.0) 480)
eff-mins))
(cursor start-time)
(wknd-count 0)
(day-start 8)
(day-end 16)
(safety-counter 0)
(max-iterations 1000)) ;; Safety limit
(while (and (> total-work-mins 0) (< safety-counter max-iterations))
(setq safety-counter (1+ safety-counter))
(let* ((decoded (decode-time cursor))
(h (nth 2 decoded))
(m (nth 1 decoded))
(dow (nth 6 decoded))
(current-abs-min (+ (* h 60) m))
(day-end-abs-min (* day-end 60))
(mins-left-today (- day-end-abs-min current-abs-min)))
(cond
((or (= dow 6) (= dow 0)) ;; weekend
(setq wknd-count (1+ wknd-count))
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= mins-left-today 0) ;; after hours
(setq cursor (apply #'encode-time (append (list 0 0 day-start)
(nthcdr 3 (decode-time
(time-add cursor (days-to-time 1))))))))
((<= total-work-mins mins-left-today)
(setq cursor (time-add cursor (seconds-to-time (* total-work-mins 60))))
(setq total-work-mins 0))
(t ;; spill to next day
(setq total-work-mins (- total-work-mins mins-left-today))
(setq cursor (time-add cursor (seconds-to-time (* mins-left-today 60))))))))
(when (>= safety-counter max-iterations)
(message "WARNING: calculate-task-span hit iteration limit for effort %s" effort-str))
(list cursor wknd-count))))
;; ------------------------------------------------------------
;; Helper: Find dependency end time
;; ------------------------------------------------------------
(defun gortium/internal--get-blocker-end (blocker-str task-end-map)
"Return the latest end time of all blockers if they are all resolved."
(let ((clean (s-trim (format "%s" blocker-str)))
(latest-time nil)
(all-resolved t))
(when (and (string-match "ids(\\(.*?\\))" clean)
(not (string-empty-p (s-trim (match-string 1 clean)))))
(dolist (tid (split-string (match-string 1 clean) "[[:space:],]+" t))
(let* ((clean-id (replace-regexp-in-string "[\"']\\|id: " "" tid))
(pos (org-id-find clean-id t))
(computed-end (gethash clean-id task-end-map))
(blocker-end nil))
;; 1. Determine this specific blocker's end time
(setq blocker-end
(cond
(computed-end computed-end) ;; Use what we just calculated in this session
(pos (let ((m (if (markerp pos) pos
(set-marker (make-marker) (cdr pos)
(find-file-noselect (car pos))))))
(with-current-buffer (marker-buffer m)
(org-with-point-at m
(cond
;; Priority 1: Use actual CLOSED timestamp if DONE
((org-entry-get nil "CLOSED")
(org-time-string-to-time (org-entry-get nil "CLOSED")))
;; Priority 2: Use range end if FIXED
((string-equal "t" (org-entry-get nil "FIXED"))
(gortium/org--get-range-end m))
;; Priority 3: Fallback to existing range end if it exists
((gortium/org--get-range-end m)
(gortium/org--get-range-end m))
(t nil))))))
(t nil)))
;; 2. Update the "Latest" tracker
(if blocker-end
(when (or (null latest-time) (time-less-p latest-time blocker-end))
(setq latest-time blocker-end))
;; If ANY blocker is not resolved/found, the whole task is not ready
(setq all-resolved nil)))))
;; Only return a time if EVERY ID in the list was successfully resolved
(when all-resolved latest-time)))
(defun gortium/internal--update-properties (pos start wknd id end task-end-map)
"Heals the property drawer, updates values, and fixes vertical spacing without leaking newlines."
(message "[DEBUG] enter update-properties for %s" id)
(save-excursion
(goto-char pos)
(org-back-to-heading t)
(let* ((subtree-start (point))
(subtree-end (save-excursion (org-end-of-subtree t) (point))))
(save-restriction
(narrow-to-region subtree-start subtree-end)
;; --- STEP 1: HEAL THE DRAWER ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(when (looking-at "^[ \t]*:PROPERTIES:[ \t]*$")
(let ((drawer-start (point)))
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(let ((drawer-end (match-end 0)))
(save-restriction
(narrow-to-region drawer-start drawer-end)
(goto-char (point-min))
(forward-line 1)
(while (re-search-forward "^[ \t]*$" nil t)
(delete-region (line-beginning-position)
(min (1+ (line-end-position)) (point-max)))))))))
;; --- STEP 2: UPDATE PROPERTY ---
(org-entry-put nil "WEEKEND_DAYS" (number-to-string (or wknd 0)))
;; --- STEP 3: REMOVE OLD RANGE ---
(goto-char (point-min))
(while (re-search-forward "^[ \t]*<.+>--<.+>[ \t]*\n?" nil t)
(replace-match ""))
;; --- STEP 4: FIND INSERTION POINT ---
(goto-char (point-min))
(forward-line 1)
(while (looking-at "^[ \t]*\\(CLOSED:\\|SCHEDULED:\\|DEADLINE:\\)")
(forward-line 1))
(while (looking-at "^[ \t]*:\\([A-Z_]+\\):[ \t]*$")
(when (re-search-forward "^[ \t]*:END:[ \t]*$" nil t)
(forward-line 1)))
;; Delete any existing blank lines at the insertion point
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2)))
;; --- STEP 5: INSERT RANGE ---
;; Ensure range starts on a new line and ends with exactly one newline
(unless (bolp) (insert "\n"))
(insert (format "<%s>--<%s>\n"
(format-time-string "%Y-%m-%d %a %H:%M" start)
(format-time-string "%Y-%m-%d %a %H:%M" end)))
;; --- STEP 6: CLEAN UP REMAINING WHITESPACE WITHIN TASK ---
(while (and (looking-at "^[ \t]*$") (not (eobp)))
(delete-region (line-beginning-position) (line-beginning-position 2))))
;; --- STEP 7: NORMALIZE SPACING BETWEEN TASKS ---
;; Instead of inserting a newline blindly, we ensure exactly one blank line
;; exists between this subtree's end and the next heading.
(goto-char (org-end-of-subtree t))
(let ((post-subtree (point)))
(save-restriction
(widen)
(goto-char post-subtree)
(delete-blank-lines)
;; Only insert a blank line if we aren't at the end of the buffer
(unless (eobp)
(insert "\n")))))
(message "[DEBUG] exit update-properties")
(puthash id end task-end-map)))
;; ------------------------------------------------------------
;; Helper: Detect circular dependencies
;; ------------------------------------------------------------
(defun gortium/internal--detect-circular-deps (tasks)
"Check for circular dependencies in TASKS.
Returns list of task IDs involved in cycles, or nil if no cycles found."
(let ((graph (make-hash-table :test 'equal))
(visiting (make-hash-table :test 'equal))
(visited (make-hash-table :test 'equal))
(cycles nil))
;; Build dependency graph
(dolist (task tasks)
(pcase-let ((`(,_pos ,id ,_effort ,blocker ,_fixed ,_sched ,_rng-start ,_offset ,_state) task))
(when (and blocker (not (string-empty-p (s-trim blocker))))
(when (string-match "ids(\\(.*?\\))" blocker)
(let ((deps (split-string (match-string 1 blocker) "[ ,]+" t)))
(puthash id (mapcar (lambda (tid)
(replace-regexp-in-string "[\"']\\|id:" "" tid))
deps)
graph))))))
;; DFS to detect cycles
(cl-labels ((dfs (node path)
(cond
((gethash node visiting)
;; Found a cycle
(push node cycles)
t)
((gethash node visited)
nil)
(t
(puthash node t visiting)
(dolist (dep (gethash node graph))
(when (dfs dep (cons node path))
(push node cycles)))
(remhash node visiting)
(puthash node t visited)
nil))))
(maphash (lambda (node _deps)
(unless (gethash node visited)
(dfs node nil)))
graph))
(delete-dups cycles)))
(advice-add 'org-roam-db-sync :before
(lambda (&rest _)
(message "[DEBUG] org-roam-db-sync invoked")))
;; --- MAIN SCHEDULER ---
(defun gortium/org-schedule-subtree-chains ()
"Standard Gortium scheduler: Correctly calculates Finish-to-Start dependencies."
(interactive)
(message "=== Starting Gortium Scheduler ===")
(let ((all-tasks '())
(task-end-times (make-hash-table :test 'equal))
(start-time (current-time))
(org-element-use-cache nil)) ;; Disable buggy cache
;; 1. COLLECT
(org-map-entries
(lambda ()
(when (org-get-todo-state)
(let* ((pos (point-marker))
(id (or (org-id-get) (org-id-get-create))))
(push (list (current-buffer) pos id
(org-entry-get pos "EFFORT")
(org-entry-get pos "BLOCKER")
(org-entry-get pos "FIXED")
(org-get-scheduled-time pos)
(org-entry-get pos "OFFSET_DAYS"))
all-tasks))))
nil nil)
(setq all-tasks (nreverse all-tasks))
;; 2. ITERATE
(let* ((remaining all-tasks)
(limit (* 20 (length remaining)))
(iter 0))
(while (and remaining (< iter limit))
(setq iter (1+ iter))
(let ((done-this-loop '()))
(dolist (task remaining)
(pcase-let ((`(,buf ,pos ,id ,effort ,blocker ,fixed ,sched ,offset) task))
(let* ((blocker-end (gortium/internal--get-blocker-end blocker task-end-times))
(has-blocker (and blocker (not (string-empty-p (s-trim blocker)))))
(is-fixed (string-equal fixed "t"))
(ready (or is-fixed (not has-blocker) blocker-end)))
(when ready
(with-current-buffer buf
(org-element-with-disabled-cache
(let* ((off-days (if (stringp offset) (string-to-number offset) 0))
(base-start (cond
;; 1. If FIXED, use its own defined start
(is-fixed (or (gortium/org--get-range-start pos) sched (current-time)))
;; 2. If it HAS a blocker, it MUST use blocker-end.
;; If blocker-end is nil, this task isn't 'ready' yet.
(has-blocker blocker-end)
;; 3. If no blocker and not fixed, use current schedule or now
(t (or sched (current-time)))))
(final-start (if is-fixed base-start
(gortium/internal--snap-to-working-hours
(time-add base-start (days-to-time off-days)))))
(span (gortium/internal--calculate-task-span final-start effort))
(final-end (car span))
(wknd (cadr span)))
(gortium/internal--update-properties pos final-start wknd id final-end task-end-times)
(push task done-this-loop))))))))
(setq remaining (cl-set-difference remaining done-this-loop))))
;; --- DIAGNOSTICS FOR UNSCHEDULED TASKS ---
(if remaining
(progn
(message "!!! WARNING: %d tasks were NOT scheduled !!!" (length remaining))
(dolist (task remaining)
(pcase-let ((`(,_buf ,pos ,id ,_eff ,blocker ,_fix ,_sch ,_off) task))
(let* ((blocker-string (format "%s" blocker))
(clean-blocker (if (string-match "ids(\\(.*?\\))" blocker-string)
(match-string 1 blocker-string)
"None")))
(message " -> Task [%s] at pos %s is BLOCKED by: %s"
id (marker-position pos) clean-blocker)))))
(message "=== All tasks scheduled successfully! ==="))
;; 3. CLEANUP
(setq org-element-use-cache t)
(org-element-cache-reset 'all)
(message "=== Scheduler completed (%d tasks, %d iterations) ===" (length all-tasks) iter)
;; (gortium/org-rollup-parent-ranges)
;; (message "=== All Scheduling and Roll-ups Complete ===")
)))
;; ---------------------------------------------
(defun gortium/org-ensure-task-properties ()
"Iterate through all tasks (TODO, NEXT, STRT, WAIT, HOLD, DONE, etc.)
and ensure the standard property drawer exists without overwriting existing data."
(interactive)
(save-excursion
(message "--- Initializing Task Properties for all states ---")
(let ((count 0)
;; List of properties to ensure exist
(props '("EFFORT" "BLOCKER" "FIXED" "WEEKEND_DAYS"
"ASSIGNEE" "RESOURCES" "CATEGORY"
"DIMENTIONS" "WEIGHT" "OFFSET_DAYS")))
(org-map-entries
(lambda ()
;; This check returns true if the heading has ANY todo keyword
(let ((todo-state (org-get-todo-state)))
(when todo-state
(cl-incf count)
;; 1. Handle ID (builtin handles 'do not replace' logic)
(org-id-get-create)
;; 2. Ensure all other keys exist
(dolist (prop props)
;; Only add the property if it's currently nil/missing
(unless (org-entry-get (point) prop)
(org-entry-put (point) prop
(if (string= prop "FIXED") "nil" "")))))))
nil nil)
(message "--- Finished: Processed %d tasks across all states ---" count))))
(defun gortium/add-ids-to-subtree ()
"Add IDs to all headings in current subtree."
(interactive)
(save-excursion
(org-back-to-heading t)
(org-map-entries
(lambda () (org-id-get-create))
nil 'tree)))
(after! org
;; ---------------------------------------------------------
;; 1. THE REUSABLE ENGINE (Domain Agnostic)
;; ---------------------------------------------------------
(defun my/org-metric-sync-engine (&key prop-name value-current prop-last-done prop-interval target-tag log-label)
"Core logic to sync parent metrics to sub-tasks and log changes.
PROP-NAME: The property triggering the change (e.g., LAST_KM).
VALUE-CURRENT: The new value being set.
PROP-LAST-DONE: Sub-task property for 'baseline' (e.g., LAST_DONE_KM).
PROP-INTERVAL: Sub-task property for 'frequency' (e.g., INTERVAL_KM).
TARGET-TAG: Filter for specific sub-tasks (e.g., SERVICE).
LOG-LABEL: The prefix used in the Logbook entry."
(save-excursion
;; A. Log the update to the Logbook with a timestamp
(when log-label
(org-add-log-setup 'note (format "%s: %s" log-label value-current) nil 'findpos))
;; B. Narrow scope to current header and scan sub-tasks
(org-narrow-to-subtree)
(org-map-entries
(lambda ()
(let* ((last-done (string-to-number (or (org-entry-get nil prop-last-done) "0")))
(interval (string-to-number (or (org-entry-get nil prop-interval) "0")))
(current (string-to-number value-current)))
;; Only process tasks that have an interval set
(when (> interval 0)
(if (>= current (+ last-done interval))
(org-todo "TODO")
(org-todo "HOLD")))))
target-tag)
(widen)))
(defun my/org-metric-prompt-engine (&key state tag prop-parent-metric prop-task-done label-prompt)
"Core logic to prompt for values when a task is marked DONE.
STATE: The new TODO state (e.g., DONE).
TAG: The tag identifying relevant tasks.
PROP-PARENT-METRIC: The property on the parent to update (e.g., LAST_KM).
PROP-TASK-DONE: The property on the task to update (e.g., LAST_DONE_KM).
LABEL-PROMPT: The text shown in the minibuffer."
(when (string= state "DONE")
(let ((tags (org-get-tags)))
(when (member tag tags)
(save-excursion
;; Inherit parent metric as the default value for the prompt
(let* ((current-val (org-entry-get nil prop-parent-metric t))
(input-val (read-string (format "%s (default %s): " label-prompt (or current-val "0"))
nil nil current-val)))
;; 1. Update the task property
(org-set-property prop-task-done input-val)
;; 2. Update the parent property (triggers the sync engine)
(save-excursion
(when (org-up-heading-safe)
(org-set-property prop-parent-metric input-val)))
(message "Synchronized %s to %s" label-prompt input-val)))))))
;; ---------------------------------------------------------
;; 2. THE DISPATCHERS (The Switchboard)
;; ---------------------------------------------------------
(defun my/org-property-change-handler (prop value)
"Routes property changes to the correct sync parameters."
(cond
;; Case: F-150 Odometer
((string= prop "LAST_KM")
(my/org-metric-sync-engine
:prop-name prop :value-current value :prop-last-done "LAST_DONE_KM"
:prop-interval "INTERVAL_KM" :target-tag "SERVICE" :log-label "Odometer Update"))))
;; Case: Equipment/Industrial Hours
;; ((string= prop "LAST_HOURS")
;; (my/org-metric-sync-engine
;; :prop-name prop :value-current value :prop-last-done "LAST_DONE_HOURS"
;; :prop-interval "INTERVAL_HOURS" :target-tag "MAINTENANCE" :log-label "Usage Hours Logged"))))
(defun my/org-todo-state-handler ()
"Safely routes DONE completions, catching any argument errors."
(interactive)
(condition-case err
(when (string= org-state "DONE")
(my/org-metric-prompt-engine
:state "DONE"
:tag "SERVICE"
:prop-parent-metric "LAST_KM"
:prop-task-done "LAST_DONE_KM"
:label-prompt "Current Odometer"))
(error (message "Handler suppressed error: %s" (error-message-string err)))))
)
;; (my/org-metric-prompt-engine
;; :state org-state :tag "MAINTENANCE" :prop-parent-metric "LAST_HOURS"
;; :prop-task-done "LAST_DONE_HOURS" :label-prompt "Equipment Hours"))
;; ---------------------------------------------------------
;; 3. THE HOOKS
;; ---------------------------------------------------------
(add-hook 'org-after-prop-change-hook #'my/org-property-change-handler)
(add-hook 'org-after-todo-state-change-hook #'my/org-todo-state-handler)
(defun my/org-smart-advance-date ()
"Jump to next repeater occurrence or add 1 day.
Suppresses all logging, notes, and custom odometer hooks."
(interactive)
(let ((org-log-done nil)
(org-log-repeat nil)
(org-todo-repeat-to-state "LOOP")
(org-after-todo-state-change-hook nil)
(org-treat-insert-todo-log-note nil)
(inhibit-modification-hooks t))
(let ((is-agenda (derived-mode-p 'org-agenda-mode)))
(cond
;; Case 1: Task has a repeater (teleport to next date)
((if is-agenda
(org-agenda-with-point-at-orig-entry nil (org-get-repeat))
(org-get-repeat))
;; This surgical strike prevents the Logbook entry entirely
(cl-letf (((symbol-function 'org-add-log-setup) (lambda (&rest _args) nil)))
(if is-agenda (org-agenda-todo "DONE") (org-todo "DONE")))
(message "Silently jumped to next occurrence."))
;; Case 2: No repeater, fallback to standard +1 day shift
(t
(if is-agenda
(org-agenda-date-later 1)
(org-timestamp-change 1 'day))
(message "No repeater found: Added 1 day."))))))
(map! :after org
(:map org-mode-map
:localleader "L" #'my/org-smart-advance-date)
(:map org-agenda-mode-map
:localleader "L" #'my/org-smart-advance-date))
;; Open link in another frame
(defun gortium/org-open-link-in-other-frame ()
"Open the Org link at point in another frame."
(interactive)
(let ((org-link-frame-setup '((file . find-file-other-frame)
(id . find-file-other-frame))))
(org-open-at-point)))
(map! :after org
:map org-mode-map
:n "gF" #'gortium/org-open-link-in-other-frame)
(defvar gortium/main-frame nil
"The main Emacs frame where buffers should be toggled to/from.")
(add-hook 'emacs-startup-hook
(lambda () (setq gortium/main-frame (selected-frame))))
(defun gortium--rightmost-non-minibuffer-window (frame)
"Return the rightmost non-minibuffer window in FRAME, or nil if none."
(let ((best-win nil)
(best-right -1))
(dolist (w (window-list frame))
(unless (window-minibuffer-p w)
(let ((right (nth 2 (window-edges w))))
(when (> right best-right)
(setq best-right right
best-win w)))))
best-win))
(defun gortium--non-minibuffer-window-count (frame)
"Return number of non-minibuffer windows in FRAME."
(let ((count 0))
(dolist (w (window-list frame))
(unless (window-minibuffer-p w)
(setq count (1+ count))))
count))
(defun gortium/toggle-buffer-to-frame ()
"Toggle the current buffer between `gortium/main-frame' and a new frame."
(interactive)
(unless (frame-live-p gortium/main-frame)
(setq gortium/main-frame (selected-frame)))
(let* ((buf (current-buffer))
(old-frame (selected-frame))
(old-win (selected-window)))
(if (eq old-frame gortium/main-frame)
;; === Move from main frame to new frame ===
(let ((new-frame (make-frame '((name . "Toggled Buffer")))))
(with-selected-frame new-frame
(switch-to-buffer buf))
(select-frame-set-input-focus new-frame)
(with-selected-frame old-frame
(with-selected-window old-win
(if (> (gortium--non-minibuffer-window-count old-frame) 1)
(delete-window old-win)
(switch-to-buffer (other-buffer buf t))))))
;; === Move from secondary frame back to main ===
(with-selected-frame gortium/main-frame
(let ((target (gortium--rightmost-non-minibuffer-window gortium/main-frame)))
(if target
(with-selected-window target
(condition-case nil
(let ((new-win (split-window-right)))
(select-window new-win)
(switch-to-buffer buf))
(error ;; too small to split, just reuse the window
(switch-to-buffer buf))))
;; fallback: no window found, just use current one
(switch-to-buffer buf))))
;; Close secondary frame or just the window
(with-selected-frame old-frame
(if (= (gortium--non-minibuffer-window-count old-frame) 1)
(delete-frame old-frame)
(when (window-live-p old-win)
(delete-window old-win)))))))
;;; ====================================================================
;;; ExoKortex Core Engine (Shared Logic)
;;; ====================================================================
(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)
"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)."
(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))
(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))
(gitignore-file (expand-file-name ".gitignore" node-dir))
(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))
(make-directory dir t))
;; 2. Enforce text-only gitignore rules if absent
(unless (file-exists-p gitignore-file)
(with-temp-file gitignore-file
(insert "# Local Node Rule: Track text layers only\n*\n!.gitignore\n!.projectile\n!.dir-locals.el\n!.bookmarks\n!1-Plan/\n!1-Plan/**\n!3-Notes/\n!3-Notes/**\n")))
;; 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* Setup\nTODO %s\n\n* Bookmarks\n** Example link\n[[file:./%s.org][Example link for %s]]\n\n* Contacts\n** Contact Name\n:PROPERTIES:\n:EMAIL: contact@example.com\n:ROLE: Client / Partner\n:END:\n\n[[mu4e:from:contact@example.com][Correspondence with Contact Name]]\n\n* Tasks\n** TODO First task for %s\n"
node-name
(format-time-string "[%Y-%m-%d %a]")
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)
(when project-tag
(let ((projectile-file (expand-file-name ".projectile" node-dir)))
(unless (file-exists-p projectile-file)
(with-temp-file projectile-file
(insert ""))))
(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
(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)))))))
;; Mark these directory-local variables as safe
(put 'gortium/project-tag 'safe-local-variable #'stringp)
(put 'gortium/context-tag 'safe-local-variable #'stringp)
(put 'gortium/project-agenda-entry 'safe-local-variable #'listp)
;; 5. Unified Git Integration Loop
(let ((default-directory node-dir))
(if (not git-existed-p)
(progn
(shell-command "git init")
(let ((add-files ".gitignore 1-Plan/ 3-Notes/"))
(when project-tag
(setq add-files (concat add-files " .projectile .dir-locals.el")))
(shell-command (concat "git add -f " add-files)))
(shell-command "git commit -m 'System: Initialize node repository'"))
;; existing repo: add new marker files if they exist
(let ((add-files ".gitignore 1-Plan/ 3-Notes/"))
(when project-tag
(setq add-files (concat add-files " .projectile .dir-locals.el")))
(shell-command (concat "git add -f " add-files)))))
;; Pass the target file location back up to the caller
org-file))
;;; ====================================================================
;;; 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 (slug for agenda/email per project filtering): ")
(read-string "Context tag (slug for agenda/email per context filtering): ")))
(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)))
(defun gortium/convert-to-node (node-dirs)
"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."
(interactive
(list
(let* ((dired-elements (when (derived-mode-p 'dired-mode)
(dired-get-marked-files nil nil nil t)))
;; Dired returns (t . ("path")) if exactly ONE file is marked explicitly,
;; or a flat list if multiple are marked.
(has-explicit-marks (or (and (listp dired-elements) (> (length dired-elements) 1))
(and (consp dired-elements) (eq (car dired-elements) t))))
(marked-dirs (when has-explicit-marks
(cl-remove-if-not #'file-directory-p
(if (eq (car-safe dired-elements) t)
(cdr dired-elements)
dired-elements)))))
(if marked-dirs
marked-dirs
;; Fallback: No explicit marks found, prompt for a single target
(let* ((cursor-path (when (derived-mode-p 'dired-mode)
(dired-get-filename nil t)))
(initial-path (cond
((and cursor-path (file-directory-p cursor-path))
cursor-path)
(cursor-path
(file-name-directory cursor-path))
(t default-directory))))
(list (read-directory-name "Directory to convert: " nil initial-path t initial-path)))))))
;; Execution Block
(let ((count 0)
last-org-file)
(dolist (dir node-dirs)
(let* ((node-dir (expand-file-name dir))
(node-name (file-name-nondirectory (directory-file-name node-dir)))
(org-file (gortium/ensure-node-structure node-dir "Audit existing assets and reconcile tracking nodes")))
(setq last-org-file org-file)
(cl-incf count)))
;; Contextual UI Feedback
(if (= count 1)
(progn
(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.
Saves only project bookmarks without contaminating global list."
(when (projectile-project-p)
(let* ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))
(saved-alist (copy-tree bookmark-alist)))
;; Save globals first, then load project bookmarks
(bookmark-save)
(if (file-exists-p proj-file)
(bookmark-load proj-file t)
(setq bookmark-alist nil))
(bookmark-store name `((filename . ,url)) nil)
(bookmark-save nil proj-file)
;; Restore globals
(setq bookmark-alist saved-alist)
(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)))
(saved-alist (copy-tree bookmark-alist))
(record (bookmark-make-record)))
;; Save current globals to disk first
(bookmark-save)
;; Load or init project bookmark alist
(if (file-exists-p proj-file)
(bookmark-load proj-file t)
(setq bookmark-alist nil))
;; Add new bookmark to alist
(bookmark-store name (cdr record) nil)
;; Write ONLY project bookmarks to .bookmarks
(bookmark-save nil proj-file)
;; Restore global bookmarks
(setq bookmark-alist saved-alist)
(message "Project bookmark \"%s\" saved to .bookmarks" name))
(user-error "Not in a Projectile project"))))))
(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
(defun gortium/list-project-bookmarks ()
"Show .bookmarks menu. Switches bookmark-default-file, restores ref on quit."
(interactive)
(if (not (projectile-project-p))
(user-error "Not in a project")
(let* ((proj-file (expand-file-name ".bookmarks" (projectile-project-root)))
(saved-alist (copy-tree bookmark-alist))
(saved-file bookmark-default-file))
(if (not (file-exists-p proj-file))
(message "No .bookmarks file in this project.")
(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 ()
(setq bookmark-default-file saved-file)
(setq bookmark-alist saved-alist))
nil t))))))
(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
is created with Org-roams templates (including :ID:)."
(interactive)
(require 'org-roam-dailies)
(let ((headline "Journal"))
;; Step 1: Ensure todays daily exists via org-roam (guarantees :ID:)
(let ((daily-file (org-roam-dailies-capture-today nil)))
;; Step 2: Cut the subtree (use org-copy-subtree if you prefer duplication)
(org-cut-subtree)
;; Step 3: Paste under the Journal headline
(with-current-buffer (find-file-noselect daily-file)
(save-excursion
(goto-char (point-min))
(when (re-search-forward (concat "^\\*+ " (regexp-quote headline) "\\b") nil t)
(let ((parent-level (org-current-level)))
(end-of-line)
(insert "\n")
(org-paste-subtree (+ 1 parent-level))))))
(message "Task refiled under '%s' in todays daily note." headline))))
(defun gortium/refile-to-daily-of-close ()
"Refile the current subtree under the 'Journal' headline in the daily note
corresponding to the task's CLOSED date. If no CLOSED property is found,
fallback to today's daily note. Ensures the daily has an Org-roam ID."
(interactive)
(require 'org-roam-dailies)
;; Step 1: Get CLOSED property timestamp
(let* ((closed-prop (org-entry-get (point) "CLOSED"))
(time (when closed-prop
(ignore-errors (org-time-string-to-time closed-prop))))
(date (or time (current-time)))
(headline "Journal"))
;; Step 2: Ensure daily file exists using org-roam (this guarantees :ID: etc.)
(let ((daily-file (org-roam-dailies-capture-date date nil)))
;; Step 3: Cut the subtree.
(org-cut-subtree)
;; Step 4: Paste into the proper daily file
(with-current-buffer (find-file-noselect daily-file)
(save-excursion
(goto-char (point-min))
;; Find the 'Journal' headline
(when (re-search-forward (concat "^\\*+ " (regexp-quote headline) "\\b") nil t)
(let ((parent-level (org-current-level)))
(end-of-line)
(insert "\n")
(org-paste-subtree (+ 1 parent-level))))))
(message "Task refiled under '%s' in %s"
headline
(file-name-nondirectory daily-file)))))
;; Like Emacs everywhere, but work in hyprland
(defun thanos/wtype-text (text)
"Process TEXT for wtype, handling newlines properly."
(let* ((has-final-newline (string-match-p "\n$" text))
(lines (split-string text "\n"))
(last-idx (1- (length lines))))
(string-join
(cl-loop for line in lines
for i from 0
collect (cond
;; Last line without final newline
((and (= i last-idx) (not has-final-newline))
(format "wtype -s 350 \"%s\""
(replace-regexp-in-string "\"" "\\\\\"" line)))
;; Any other line
(t
(format "wtype -s 350 \"%s\" && wtype -k Return"
(replace-regexp-in-string "\"" "\\\\\"" line)))))
" && ")))
(define-minor-mode thanos/type-mode
"Minor mode for inserting text via wtype."
:keymap `((,(kbd "C-c C-c") . ,(lambda () (interactive)
(call-process-shell-command
(thanos/wtype-text (buffer-string))
nil 0)
(delete-frame)))
(,(kbd "C-c C-k") . ,(lambda () (interactive)
(kill-buffer (current-buffer))))))
(defun thanos/type ()
"Launch a temporary frame with a clean buffer for typing."
(interactive)
(let ((frame (make-frame '((name . "emacs-float")
(fullscreen . 0)
(undecorated . t)
(width . 70)
(height . 20))))
(buf (get-buffer-create "emacs-float")))
(select-frame frame)
(switch-to-buffer buf)
(with-current-buffer buf
(erase-buffer)
(org-mode)
(flyspell-mode)
(thanos/type-mode)
(setq-local header-line-format
(format " %s to insert text or %s to cancel."
(propertize "C-c C-c" 'face 'help-key-binding)
(propertize "C-c C-k" 'face 'help-key-binding)))
;; Make the frame more temporary-like
(set-frame-parameter frame 'delete-before-kill-buffer t)
(set-window-dedicated-p (selected-window) t))))