config.el 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486
  1. ;;; $DOOMDIR/config.el -*- lexical-binding: t; -*-
  2. (setq user-full-name "Colin Powell"
  3. user-mail-address "colin@unbl.ink")
  4. (nyan-mode)
  5. (after! envrc
  6. (envrc-global-mode))
  7. ;; load pinentry
  8. (when (require 'pinentry nil t)
  9. (pinentry-start))
  10. (require 'server)
  11. (unless (server-running-p)
  12. (server-start))
  13. (setq doom-theme 'doom-xcode
  14. doom-font (font-spec :family "Iosevka" :size 14 :weight 'regular)
  15. doom-big-font (font-spec :family "Iosevka" :size 18 :weight 'regular)
  16. doom-variable-pitch-font (font-spec :family "Overpass" :size 12))
  17. (setq display-line-numbers-type t)
  18. ;; change `org-directory'. It must be set before org loads!
  19. (setq org-directory "~/var/org/")
  20. (load! "+agenda-fix")
  21. (defun vulpea-agenda-files-update (&rest _)
  22. (setq org-agenda-files vulpea-project-files))
  23. (setq org-roam-directory "~/var/org/"
  24. org-roam-dailies-directory "dailies")
  25. (advice-add 'org-agenda :before #'vulpea-agenda-files-update)
  26. (advice-add 'org-todo-list :before #'vulpea-agenda-files-update)
  27. (load! "+django-tests")
  28. (map! :after python
  29. :map python-mode-map
  30. :localleader
  31. (:prefix ("t" . "tests")
  32. :desc "django test at point" "t" #'django-run-test-at-point
  33. :desc "django tests for file" "f" #'django-run-tests-for-current-file
  34. :desc "django all tests" "a" (cmd! (django-run-tests "" nil))))
  35. (setq +format-on-save-disabled-modes (add-to-list '+format-on-save-disabled-modes 'typescript-mode))
  36. (map! ;; Easier window movement
  37. :n "C-h" 'evil-window-left
  38. :n "C-j" 'evil-window-down
  39. :n "C-k" 'evil-window-up
  40. :n "C-l" 'evil-window-right
  41. (:map evil-treemacs-state-map
  42. "C-h" 'evil-window-left
  43. "C-l" 'evil-window-right)
  44. :leader
  45. (:prefix "f"
  46. :desc "Find file in dotfiles" "t" #'+hlissner/find-in-dotfiles
  47. :desc "Browse dotfiles" "T" #'+hlissner/browse-dotfiles)
  48. (:prefix "b"
  49. :desc "Black format buffer" "f" #'blacken-buffer
  50. :desc "isort buffer" "I" #'py-isort-buffer
  51. :desc "Links in buffer" "l" #'ace-link-org))
  52. (defun unfill-paragraph ()
  53. "Takes a multi-line paragraph and makes it into a single line of text."
  54. (interactive)
  55. (let ((fill-column (point-max)))
  56. (fill-paragraph nil)))
  57. (define-key global-map "\M-z" 'unfill-paragraph)
  58. (defun file-notify-rm-all-watches ()
  59. "Remove all existing file notification watches from Emacs."
  60. (interactive)
  61. (maphash
  62. (lambda (key _value)
  63. (file-notify-rm-watch key))
  64. file-notify-descriptors))
  65. (setq frame-title-format
  66. '(""
  67. (:eval
  68. (if (s-contains-p org-roam-directory (or buffer-file-name ""))
  69. (replace-regexp-in-string
  70. ".*/[0-9]*-?" "☰ "
  71. (subst-char-in-string ?_ ? buffer-file-name))
  72. "%b"))
  73. (:eval
  74. (let ((project-name (projectile-project-name)))
  75. (unless (string= "-" project-name)
  76. (format (if (buffer-modified-p) " ◉ %s" "  ●  %s") project-name))))))
  77. (setq mm-text-html-renderer 'w3m)
  78. (setq w3m-fill-column 88)
  79. (setq lsp-lens-enable 1
  80. lsp-ui-sideline-enable 1
  81. lsp-enable-links 1
  82. lsp-headerline-breadcrumb-enable 1
  83. lsp-modeline-code-actions-enable 1
  84. lsp-modeline-diagnostics-enable 1
  85. lsp-completion-show-detail 1
  86. lsp-file-watch-threshold nil)
  87. ;; check for hosts folder and find any init-HOSTNAME.el files in there and load them
  88. (defvar host (substring (shell-command-to-string "hostname") 0 -1))
  89. (defvar host-dir "~/.config/doom/hosts/")
  90. (add-load-path! host-dir)
  91. ;; Setup nov.el mode for epubs and change font
  92. (add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode))
  93. (defun my-nov-font-setup ()
  94. (face-remap-add-relative 'variable-pitch :family "Overpass"
  95. :height 1.0))
  96. (add-hook 'nov-mode-hook 'my-nov-font-setup)
  97. ;;(let ((init-host-feature (intern (concat "init-" host ".el"))))
  98. ;; (load-file init-host-feature))
  99. (defvar host-init (concat "~/.config/doom/hosts/init-" host ".el"))
  100. (if (file-exists-p host-init)
  101. (load-file host-init))
  102. (load-file "~/.config/doom/+agenda-fix.el")
  103. ;; Enable org-modern mode per buffer
  104. ;(add-hook 'org-mode-hook #'org-modern-mode)
  105. ;(add-hook 'org-agenda-finalize-hook #'org-modern-agenda)
  106. ;; Or globally
  107. (with-eval-after-load 'org (global-org-modern-mode))
  108. (require 'cl-lib)
  109. (defun eshell-load-bash-aliases ()
  110. "Read Bash aliases and add them to the list of eshell aliases."
  111. ;; Bash needs to be run - temporarily - interactively
  112. ;; in order to get the list of aliases.
  113. (with-temp-buffer
  114. (call-process "bash" nil '(t nil) nil "-ci" "alias")
  115. (goto-char (point-min))
  116. (cl-letf (((symbol-function 'eshell-write-aliases-list) #'ignore))
  117. (while (re-search-forward "alias \\(.+\\)='\\(.+\\)'$" nil t)
  118. (eshell/alias (match-string 1) (match-string 2))))
  119. (eshell-write-aliases-list)))
  120. ;; We only want Bash aliases to be loaded when Eshell loads its own aliases,
  121. ;; rather than every time `eshell-mode' is enabled.
  122. (add-hook 'eshell-alias-load-hook 'eshell-load-bash-aliases)
  123. (defun eshell-run-direnv-allow()
  124. (direnv-allow))
  125. (add-hook 'eshell-directory-change-hook 'eshell-run-direnv-allow)
  126. (defun org-raw-timestamp-to-iso (raw-ts)
  127. "Convert Org RAW-TS like `<2025-06-12 Thu 14:00>` to `YYYY-MM-DDThh:mm:ss`."
  128. (when raw-ts
  129. (let* ((ts (org-parse-time-string raw-ts))
  130. (year (nth 5 ts)) (mon (nth 4 ts)) (day (nth 3 ts))
  131. (hour (nth 2 ts) 0) (min (nth 1 ts) 0))
  132. (format "%04d-%02d-%02dT%02d:%02d:00" year mon day hour min))))
  133. (defun org-extract-labeled-timestamps ()
  134. "Return an alist of labeled ISO-formatted timestamps in the current Org subtree."
  135. (save-restriction
  136. (org-narrow-to-subtree)
  137. (let ((parsed (org-element-parse-buffer))
  138. (labeled-ts '()))
  139. (org-element-map parsed '(timestamp)
  140. (lambda (ts)
  141. (let* ((type (org-element-property :type ts))
  142. (raw (org-element-property :raw-value ts))
  143. (time (org-parse-time-string raw t))
  144. (date (format "%04d-%02d-%02d"
  145. (nth 5 time) (nth 4 time) (nth 3 time)))
  146. (hour (nth 2 time))
  147. (min (nth 1 time))
  148. (with-time (and hour min (format "%sT%02d:%02d" date hour min)))
  149. (label (cond
  150. ((eq type 'active) "timestamp")
  151. ((eq type 'inactive) "inactive-timestamp")
  152. (t "timestamp"))))
  153. (push (cons label (or with-time date)) labeled-ts))))
  154. ;; Add planning info from heading (DEADLINE, SCHEDULED, CLOSED)
  155. (dolist (key '("DEADLINE" "SCHEDULED" "CLOSED"))
  156. (let ((raw (org-entry-get nil key t)))
  157. (when raw
  158. (let* ((ts (org-parse-time-string raw t))
  159. (date (format "%04d-%02d-%02d" (nth 5 ts) (nth 4 ts) (nth 3 ts)))
  160. (hour (nth 2 ts))
  161. (min (nth 1 ts))
  162. (with-time (and hour min (format "%sT%02d:%02d" date hour min))))
  163. (push (cons (downcase key) (or with-time date)) labeled-ts)))))
  164. (delete-dups labeled-ts))))
  165. (defun org-get-body ()
  166. "Return the body text under the current Org heading as a string."
  167. (save-excursion
  168. (org-back-to-heading t)
  169. (let ((start (progn (forward-line) (point)))
  170. (end (progn (org-end-of-subtree t t) (point))))
  171. (buffer-substring-no-properties start end))))
  172. (defun org-strip-timestamps-from-text (text)
  173. "Remove Org timestamps and planning lines from TEXT."
  174. (let* ((timestamp-re (rx (or (seq "<" (+ (not (any ">"))) ">")
  175. (seq "[" (+ (not (any "]"))) "]"))))
  176. (planning-line-re (rx line-start (zero-or-more space)
  177. (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " "))
  178. ;; Step 1: remove full planning lines
  179. (without-planning-lines
  180. (replace-regexp-in-string
  181. (concat planning-line-re ".*\n?") "" text))
  182. ;; Step 2: remove inline timestamps
  183. (without-inline
  184. (replace-regexp-in-string timestamp-re "" without-planning-lines)))
  185. (string-trim without-inline)))
  186. (defun org-extract-drawers ()
  187. "Extract all drawers (like LOGBOOK, PROPERTIES, etc.) from current org entry.
  188. Returns an alist of (DRAWER-NAME . CONTENT) pairs.
  189. - PROPERTIES content is parsed into (KEY . VALUE)
  190. - Other drawers are returned as lists of lines (strings)"
  191. (save-excursion
  192. (org-back-to-heading t)
  193. (let ((end (save-excursion (org-end-of-subtree t t)))
  194. (drawers '()))
  195. (while (re-search-forward "^\\s-*:\\([A-Z]+\\):\\s-*$" end t)
  196. (let* ((name (match-string 1))
  197. (start (match-end 0))
  198. (drawer-end (when (re-search-forward "^\\s-*:END:\\s-*$" end t)
  199. (match-beginning 0))))
  200. (when drawer-end
  201. (let ((content (buffer-substring-no-properties start drawer-end)))
  202. (setq drawers
  203. (cons
  204. (cons name
  205. (if (string= name "PROPERTIES")
  206. ;; parse :KEY: VALUE
  207. (org-parse-properties content)
  208. ;; just return line list
  209. (split-string content "\n" t "[ \t]+")))
  210. drawers))))))
  211. (reverse drawers))))
  212. (defun org-parse-properties (content)
  213. "Parse PROPERTIES drawer content into an alist."
  214. (let ((lines (split-string content "\n" t))
  215. (props '()))
  216. (dolist (line lines)
  217. (when (string-match "^\\s-*:\\([^:]+\\):\\s-*\\(.*\\)$" line)
  218. (push (cons (match-string 1 line) (match-string 2 line)) props)))
  219. (reverse props)))
  220. (defun org-clean-body-text (text)
  221. "Remove planning lines, timestamps, and drawers from TEXT."
  222. (let* ((timestamp-re
  223. (rx (or (seq "<" (+ (not (any ">"))) ">")
  224. (seq "[" (+ (not (any "]"))) "]"))))
  225. (planning-re
  226. (rx line-start (zero-or-more space)
  227. (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
  228. (text (replace-regexp-in-string planning-re "" text))
  229. (text (replace-regexp-in-string timestamp-re "" text))
  230. (text (org-strip-all-drawers text)))
  231. (string-trim text)))
  232. (defun org-strip-timestamps-drawers-notes-from-text (text)
  233. "Strip timestamps, planning lines, drawers, and note blocks from Org TEXT."
  234. (let* ((timestamp-re
  235. (rx (or (seq "<" (+ (not (any ">"))) ">")
  236. (seq "[" (+ (not (any "]"))) "]"))))
  237. (planning-re
  238. (rx line-start (zero-or-more space)
  239. (or "DEADLINE:" "SCHEDULED:" "CLOSED:") " " (* nonl) "\n"))
  240. (drawer-re
  241. "^\\s-*:[A-Z]+:\\(?:.\\|\n\\)*?:END:\n?")
  242. (note-block-re
  243. (rx-to-string
  244. `(and bol (* space) "- Note taken on "
  245. (or "[" "<") (+ (not (any "]>"))) (or "]" ">")
  246. (*? anything)
  247. (or "\n\n" eos))
  248. t)))
  249. ;; Strip drawers first
  250. (setq text (replace-regexp-in-string drawer-re "" text))
  251. ;; Strip entire note blocks (greedy match up to next blank line or end)
  252. (setq text (replace-regexp-in-string note-block-re "" text))
  253. ;; Strip planning lines and timestamps
  254. (setq text (replace-regexp-in-string planning-re "" text))
  255. (setq text (replace-regexp-in-string timestamp-re "" text))
  256. ;; Trim and return
  257. (string-trim text)))
  258. (defun org-get-body-stripped ()
  259. "Get cleaned Org entry body without timestamps, planning lines, drawers, or notes."
  260. (org-strip-timestamps-drawers-notes-from-text (org-get-body)))
  261. (defun org-extract-notes ()
  262. "Extract notes from Org entry, each as an alist with `timestamp` and `content`."
  263. (save-excursion
  264. (org-back-to-heading t)
  265. (let ((start (progn (forward-line) (point)))
  266. (end (progn (org-end-of-subtree t t) (point)))
  267. result) ;; ✅ initialize result list
  268. (save-restriction
  269. (narrow-to-region start end)
  270. (goto-char (point-min))
  271. (while (re-search-forward "^\\s-*[-+] Note taken on \\[\\([^]]+\\)\\]\\s-*\\(?:\\\\\\\\\\)?\\s-*$" nil t)
  272. (let* ((raw-ts (match-string 1))
  273. (timestamp (let* ((ts (org-parse-time-string raw-ts t)))
  274. (format "%04d-%02d-%02dT%02d:%02d"
  275. (nth 5 ts) (nth 4 ts) (nth 3 ts)
  276. (nth 2 ts) (nth 1 ts))))
  277. (note-start (progn
  278. (forward-line)
  279. ;; allow one optional blank line
  280. (when (looking-at-p "^\\s-*$") (forward-line))
  281. (point)))
  282. (note-end (or (save-excursion
  283. (re-search-forward "^\\s-*[-+] Note taken on \\[" nil t))
  284. (point-max)))
  285. (content (string-trim
  286. (buffer-substring-no-properties note-start (1- note-end)))))
  287. (push `(("timestamp" . ,timestamp)
  288. ("content" . ,content))
  289. result))))
  290. (nreverse result))))
  291. (require 'subr-x) ;; for string-trim and string functions, usually loaded by default
  292. (defun my-org-generate-uuid ()
  293. "Generate a random UUID string."
  294. (let ((uuid (md5 (format "%s%s%s%s%s"
  295. (user-uid)
  296. (emacs-pid)
  297. (float-time)
  298. (random)
  299. (emacs-pid)))))
  300. (concat (substring uuid 0 8) "-"
  301. (substring uuid 8 12) "-"
  302. (substring uuid 12 16) "-"
  303. (substring uuid 16 20) "-"
  304. (substring uuid 20 32))))
  305. (defun my-org-get-or-create-id ()
  306. "Get the ID property of the current Org heading, or create and set one if missing.
  307. Returns the ID string."
  308. (let ((id (org-entry-get nil "ID")))
  309. (unless id
  310. (setq id (my-org-generate-uuid))
  311. (org-entry-put nil "ID" id)
  312. (save-buffer)) ;; optional: save file after inserting ID
  313. id))
  314. (defun org-clock-on-state-change ()
  315. "Clock in when state is STRT, clock out otherwise."
  316. (when (and (derived-mode-p 'org-mode)
  317. (not (org-before-first-heading-p)))
  318. (pcase org-state
  319. ("STRT"
  320. (unless org-clock-marker
  321. (org-clock-in)))
  322. ((or "DONE" "CANC" "WAIT" "HOLD" "TODO")
  323. (when org-clock-marker
  324. (org-clock-out))))))
  325. (defun send-org-todo-to-endpoint-on-state-change ()
  326. "Send the current Org-mode TODO item to an HTTP endpoint."
  327. (interactive)
  328. (when (org-at-heading-p)
  329. (let ((state (org-get-todo-state)))
  330. (when (member state '("STRT" "DONE"))
  331. (let* ((heading (org-get-heading t t t t))
  332. (current-time (format-time-string "%Y-%m-%dT%H:%M:%SZ" (current-time) t)) ;; UTC ISO8601
  333. (tags (org-get-tags))
  334. (timestamps (org-extract-labeled-timestamps))
  335. (drawers (org-extract-drawers))
  336. (properties (cdr (assoc "PROPERTIES" drawers)))
  337. (todo-id (my-org-get-or-create-id))
  338. (body (org-get-body-stripped))
  339. (notes (org-extract-notes))
  340. (properties (org-entry-properties))
  341. (endpoint "https://life.lab.unbl.ink/webhook/emacs/")
  342. (data `(("description" . ,heading)
  343. ("labels" . ,tags)
  344. ("state" . ,state)
  345. ("timestamps" . ,timestamps)
  346. ("notes" . ,notes)
  347. ("drawers" . ,drawers)
  348. ("emacs_id" . ,todo-id)
  349. ("updated_at" . ,current-time)
  350. ("source" . "orgmode")
  351. ("properties" . ,properties)
  352. ("body" . ,body))))
  353. (request
  354. endpoint
  355. :type "POST"
  356. :headers '(("Content-Type" . "application/json"))
  357. :data (json-encode data)
  358. :headers '(("Authorization" . "Token 58e898c0e88bd6333b1a9e8de82e81f36c4b64e")
  359. ("Content-Type" . "application/json"))
  360. :parser 'json-read
  361. :success (cl-function
  362. (lambda (&key data &allow-other-keys)
  363. (message "Sent TODO: %s" data)))
  364. :error (cl-function
  365. (lambda (&rest args &key error-thrown &allow-other-keys)
  366. (message "Error sending TODO: %S" error-thrown))))))
  367. (org-clock-on-state-change)
  368. )))
  369. (add-hook 'org-after-todo-state-change-hook #'send-org-todo-to-endpoint-on-state-change)
  370. (defun life-scrobble-url ()
  371. "Open https://life.lab.unbl.ink/ with scrobble_url set to a URL.
  372. - If in an `eww` buffer, use its current URL.
  373. - Otherwise, use the clipboard/kill ring.
  374. Always open the result in `eww`."
  375. (interactive)
  376. (let* ((url (cond
  377. ;; In eww, grab current page URL
  378. ((derived-mode-p 'eww-mode)
  379. (plist-get eww-data :url))
  380. ;; Else clipboard
  381. (t (current-kill 0 t))))
  382. (encoded (url-hexify-string url))
  383. (scrobble-url (concat "https://life.lab.unbl.ink/?scrobble_url=" encoded)))
  384. (eww scrobble-url)))
  385. ;; Bind globally to C-c l
  386. (global-set-key (kbd "C-c l") #'life-scrobble-url)
  387. (after! magit
  388. (defvar my/ssh-key-injector-script
  389. (expand-file-name "~/.bin/load_keys"))
  390. (defun my/ssh-agent-has-keys-p (&rest _ignore)
  391. "Non-nil if ssh-agent currently has at least one identity loaded."
  392. (eq 0 (call-process "ssh-add" nil nil nil "-l")))
  393. (defun my/ensure-ssh-keys-loaded (&rest _ignore)
  394. "Ensure ssh-agent has keys loaded; if not, run injector script."
  395. (unless (my/ssh-agent-has-keys-p)
  396. (unless (file-executable-p my/ssh-key-injector-script)
  397. (user-error "SSH injector script not executable: %s" my/ssh-key-injector-script))
  398. (let ((buf (get-buffer-create "*ssh-key-injector*")))
  399. (with-current-buffer buf (erase-buffer))
  400. (let ((exit (call-process-shell-command my/ssh-key-injector-script nil buf t)))
  401. (unless (eq exit 0)
  402. (display-buffer buf)
  403. (user-error "SSH key injection failed (see *ssh-key-injector*)"))))))
  404. ;; IMPORTANT: remove then re-add, so we don't keep an old advised function object around
  405. (advice-remove 'magit-status #'my/ensure-ssh-keys-loaded)
  406. (advice-add 'magit-status :before #'my/ensure-ssh-keys-loaded)
  407. ;; optional:
  408. (advice-remove 'magit-fetch #'my/ensure-ssh-keys-loaded)
  409. (advice-add 'magit-fetch :before #'my/ensure-ssh-keys-loaded)
  410. (advice-remove 'magit-push #'my/ensure-ssh-keys-loaded)
  411. (advice-add 'magit-push :before #'my/ensure-ssh-keys-loaded)
  412. (advice-remove 'magit-pull #'my/ensure-ssh-keys-loaded)
  413. (advice-add 'magit-pull :before #'my/ensure-ssh-keys-loaded))
  414. (defun my/disable-apheleia-in-certain-projects ()
  415. (when-let ((root (and (fboundp 'project-root)
  416. (project-root (project-current)))))
  417. (when (member (file-name-nondirectory
  418. (directory-file-name root))
  419. '("hungryroot"))
  420. (apheleia-mode -1))))
  421. (add-hook 'find-file-hook #'my/disable-apheleia-in-certain-projects)