;;; -*- lexical-binding: t -*- (defun my/update-org-agenda () (interactive) (let ((project-files (when (file-directory-p (concat org-directory "/projects")) (thread-last "/projects" (concat org-directory) (directory-files) (mapcar (lambda (f) (concat org-directory "/projects/" f))) (seq-filter (lambda (f) (not (file-directory-p f)))))))) (setq org-agenda-files (seq-filter #'file-exists-p (append project-files (mapcar (lambda (f) (concat org-directory "/" f)) '("inbox.org" "misc/habit.org" "contacts.org"))))) (setq org-refile-targets `(,@(mapcar (lambda (f) `(,f . (:tag . "refile"))) project-files) ,@(mapcar (lambda (f) `(,f . (:regexp . "Tasks"))) project-files))) (when (file-exists-p (concat org-directory "/scripts/refile.el")) (load-file (concat org-directory "/scripts/refile.el")) (run-hooks 'my/org-refile-hooks)))) (setq org-roam-directory (concat org-directory "/roam")) (with-eval-after-load 'org (require 'seq) (my/update-org-agenda)) (setq org-refile-use-outline-path 'file) (setq org-outline-path-complete-in-steps nil) (setq org-extend-today-until 4) (defun my/generate-inbox-note-name () (format "%s/inbox-notes/%s%s.org" org-directory (format-time-string "%Y%m%d%H%M%S") (let ((note-name (read-string "Note name: "))) (if (not (string-empty-p note-name)) (string-replace " " "-" (concat "-" (downcase note-name))) "")))) (setq org-capture-templates `(("i" "Inbox" entry (file "inbox.org") ,(concat "* TODO %?\n" "/Entered on/ %U")) ("e" "email" entry (file "inbox.org") ,(concat "* TODO %:from %:subject \n" "/Entered on/ %U\n" "/Received on/ %:date-timestamp-inactive\n" "%a\n")) ("f" "elfeed" entry (file "inbox.org") ,(concat "* TODO %:elfeed-entry-title\n" "/Entered on/ %U\n" "%a\n")) ("n" "note" plain (file my/generate-inbox-note-name) ,(concat "#+TODO: PROCESSED(p)\n" "\n" "* %?\n" "/Entered on/ %U")))) (use-package org-clock-agg :straight (:host github :repo "SqrtMinusOne/org-clock-agg") :commands (org-clock-agg) :init (with-eval-after-load 'org (my-leader-def "ol" #'org-clock-agg)) :config (setq org-clock-agg-node-format "%-%(+ title-width)t %20c %8z %s/%S") (setq org-clock-agg-node-title-width-delta 47) (push (cons "Agenda+Archive" (append (org-agenda-files) (thread-last "/projects/archive" (concat org-directory) (directory-files) (mapcar (lambda (f) (concat org-directory "/projects/archive/" f))) (seq-filter (lambda (f) (not (file-directory-p f))))))) org-clock-agg-files-preset)) (with-eval-after-load 'org (setq org-clock-persist 'clock) (org-clock-persistence-insinuate)) (with-eval-after-load 'org (add-to-list 'org-global-properties '("Effort_ALL" . "0 0:05 0:10 0:15 0:30 0:45 1:00 1:30 2:00 4:00 8:00"))) (setq org-log-done 'time) (defun my/org-clock-in--fix-mode-line () (when (memq 'org-mode-line-string global-mode-string) (let (new-global-mode-string appended (is-first t)) (dolist (item global-mode-string) (cond ((or (equal item '(:eval (exwm-modeline-segment))) (equal item '(:eval (persp-mode-line)))) (unless appended (when is-first (push "" new-global-mode-string)) (push 'org-mode-line-string new-global-mode-string) (setq appended t)) (push item new-global-mode-string)) ((equal item 'org-mode-line-string)) (t (push item new-global-mode-string))) (setq is-first nil)) (unless appended (push 'org-mode-line-string new-global-mode-string)) (setq global-mode-string (nreverse new-global-mode-string))))) (add-hook 'org-clock-in-hook #'my/org-clock-in--fix-mode-line) (defun my/org-clock-in-prompt-time (&optional select) (interactive "P") (org-clock-in select (encode-time (org-parse-time-string (org-read-date t))))) (with-eval-after-load 'org (my-leader-def :keymaps 'org-mode-map :infix "SPC" "I" #'my/org-clock-in-prompt-time)) (defun my/org-clock-get-total-minutes-at-point () "Get total clocked time for heading at point." (let* ((element (org-element-at-point-no-context)) (s (buffer-substring-no-properties (org-element-property :begin element) (org-element-property :end element)))) (with-temp-buffer (insert s) (let (org-mode-hook) (org-mode)) (org-clock-sum) org-clock-file-total-minutes))) (defconst my/org-clock-total-prop :CLOCK_TOTAL) (defun my/org-clock-set-total-clocked () "Set total clocked time for heading at point." (interactive) (save-excursion (org-back-to-heading t) (org-set-property (substring (symbol-name my/org-clock-total-prop) 1) (org-duration-from-minutes (my/org-clock-get-total-minutes-at-point))))) (add-hook 'org-clock-in-hook #'my/org-clock-set-total-clocked) (add-hook 'org-clock-out-hook #'my/org-clock-set-total-clocked) (add-hook 'org-clock-cancel-hook #'my/org-clock-set-total-clocked) (defun my/org-clock-recent () (interactive) (let* ((entries (org-ql-query :select #'element-with-markers :from (org-agenda-files) :where '(clocked :from -1))) (entries-data (mapcar (lambda (e) (cons (org-element-property :raw-value e) e)) entries))) (unless entries (user-error "No recently clocked entries!")) entries-data (let* ((entry (alist-get (completing-read "Entry: " entries-data) entries-data nil nil #'equal)) (marker (org-element-property :org-marker entry))) (pop-to-buffer-same-window (marker-buffer marker)) (goto-char marker)))) (with-eval-after-load 'org (my-leader-def :keymaps 'org-mode-map :infix "SPC" "C" #'my/org-clock-recent)) (defun my/org-fix-task-kind () (interactive) (let ((entries (org-ql-query :select #'element-with-markers :from (current-buffer) :where '(and (olp "Tasks") (not (property "TASK_KIND")) (clocked))))) (org-fold-show-all) (dolist (entry entries) (let ((marker (org-element-property :org-marker entry))) (org-with-point-at marker (let ((value (org-read-property-value "TASK_KIND"))) (org-set-property "TASK_KIND" value))))))) (use-package org-super-agenda :straight t :after (org) :config ;; Alphapapa doesn't like evil (general-define-key :keymaps '(org-super-agenda-header-map) "h" nil "j" nil "k" nil "l" nil) (org-super-agenda--def-auto-group outline-path-file "their outline paths & files" :key-form (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item) ;; org-ql depends on f and s anyway (s-join "/" (cons (f-filename (buffer-file-name)) (org-get-outline-path)))))) (defun my/org-super-agenda--make-agenda-header-around (fun name) (remove-text-properties 0 (length name) '(line-prefix nil) name) (remove-text-properties 0 (length name) '(wrap-prefix nil) name) (funcall fun (substring-no-properties name))) (with-eval-after-load 'org-super-agenda (advice-add 'org-super-agenda--make-agenda-header :around #'my/org-super-agenda--make-agenda-header-around)) (use-package org-ql :after (org) :straight t :config (setq org-ql-ask-unsafe-queries nil) :init ;; See https://github.com/alphapapa/org-ql/pull/237 (setq org-ql-regexp-part-ts-time (rx " " (repeat 1 2 digit) ":" (repeat 2 digit) (optional "-" (repeat 1 2 digit) ":" (repeat 2 digit)))) (my-leader-def :infix "o" "v" #'org-ql-view "q" #'org-ql-search)) (cl-defun my/org-ql-view-recent-items (&key num-days (type 'ts) (files (org-agenda-files)) (groups '((:auto-outline-path-file t) (:auto-todo t)))) "Show items in FILES from last NUM-DAYS days with timestamps of TYPE. TYPE may be `ts', `ts-active', `ts-inactive', `clocked', or `closed'." (interactive (list :num-days (read-number "Days: ") :type (->> '(ts ts-active ts-inactive clocked closed) (completing-read "Timestamp type: ") intern))) ;; It doesn't make much sense to use other date-based selectors to ;; look into the past, so to prevent confusion, we won't allow them. (-let* ((query (pcase-exhaustive type ((or 'ts 'ts-active 'ts-inactive) `(,type :from ,(- num-days) :to 0)) ((or 'clocked 'closed) `(,type :from ,(- num-days) :to 0))))) (org-ql-search files query :title "Recent items" :sort '(todo priority date) :super-groups groups))) (defun my/org-ql-all-todo () (interactive) ;; The hack I borrowed from notmuch to make " " a separator (let* ((crm-separator " ") (crm-local-completion-map (let ((map (make-sparse-keymap))) (set-keymap-parent map crm-local-completion-map) (define-key map " " 'self-insert-command) map)) (vertico-sort-function nil) (categories (completing-read-multiple "Categories: " '("TEACH" "EDU" "JOB" "LIFE" "COMP")))) (org-ql-search (org-agenda-files) `(and (todo) ,@(unless (seq-empty-p categories) `((category ,@categories)))) :sort '(priority todo deadline) :super-groups '((:auto-outline-path-file t))))) (defun my/org-ql-clocked-today () (interactive) (let ((today (format-time-string "%Y-%m-%d" (days-to-time (- (org-today) (time-to-days 0)))))) (org-ql-search (org-agenda-files) `(clocked :from ,today) :title "Clocked today" :sort '(todo priority date) :super-groups '((:auto-outline-path-file t) (:auto-todo t))))) (defun my/org-ql-closed-today () (interactive) (let ((today (format-time-string "%Y-%m-%d" (days-to-time (- (org-today) (time-to-days 0)))))) (org-ql-search (org-agenda-files) `(closed :from ,today) :title "Closed today" :sort '(todo priority date) :super-groups '((:auto-outline-path-file t) (:auto-todo t))))) (setq org-ql-views (list (cons "Overview: All TODO" #'my/org-ql-all-todo) (cons "Review: Stale tasks" (list :buffers-files #'org-agenda-files :query '(and (todo) (not (tags "nots")) (not (ts :from -14))) :title "Review: Stale tasks" :sort '(todo priority date) :super-groups '((:auto-outline-path-file t)))) (cons "Review: Unclocked tasks" (list :buffers-files #'org-agenda-files :query '(and (done) (ts :from -14) (not (clocked)) (not (tags "nots"))) :title "Review: Unclocked tasks" :sort '(todo priority date) :super-groups '((:auto-outline-path-file t)))) (cons "Review: Recently timestamped" #'my/org-ql-view-recent-items) (cons "Review: Clocked today" #'my/org-ql-clocked-today) (cons "Review: Closed today" #'my/org-ql-closed-today) (cons "Fix: tasks without TASK_KIND" (lambda () (interactive) (org-ql-search (current-buffer) '(and (olp "Tasks") (not (property "TASK_KIND")) (clocked)) :super-groups '((:auto-outline-path-file t))))))) (defun my/org-ql-view--format-element-override (element) "Format ELEMENT for `org-ql-view'. Check `org-ql-view--format-element' for the original implementation and lots of comments which are too long for my Emacs config." (if (not element) "" (setf element (org-ql-view--resolve-element-properties element)) (let* ((properties (cadr element)) (properties (cl-loop for (key val) on properties by #'cddr for symbol = (intern (cl-subseq (symbol-name key) 1)) unless (member symbol '(parent)) append (list symbol val))) (title (--> (org-ql-view--add-faces element) (org-element-property :raw-value it) (org-link-display-format it))) (todo-keyword (-some--> (org-element-property :todo-keyword element) (org-ql-view--add-todo-face it))) (tag-list (if org-use-tag-inheritance (if-let ((marker (or (org-element-property :org-hd-marker element) (org-element-property :org-marker element)))) (with-current-buffer (marker-buffer marker) (org-with-wide-buffer (goto-char marker) (cl-loop for type in (org-ql--tags-at marker) unless (or (eq 'org-ql-nil type) (not type)) append type))) (display-warning 'org-ql (format "No marker found for item: %s" title)) (org-element-property :tags element)) (org-element-property :tags element))) (tag-string (when tag-list (--> tag-list (s-join ":" it) (s-wrap it ":") (org-add-props it nil 'face 'org-tag)))) ;; (category (org-element-property :category element)) (priority-string (-some->> (org-element-property :priority element) (char-to-string) (format "[#%s]") (org-ql-view--add-priority-face))) (clock-string (let ((effort (org-element-property :EFFORT element)) (clocked (org-element-property my/org-clock-total-prop element))) (cond ((and clocked effort) (format "[%s/%s]" clocked effort)) ((and clocked (not effort) (format "[%s]" clocked))) ((and (not clocked) effort) (format "[EST: %s]" effort))))) (habit-property (org-with-point-at (or (org-element-property :org-hd-marker element) (org-element-property :org-marker element)) (when (org-is-habit-p) (org-habit-parse-todo)))) (due-string (pcase (org-element-property :relative-due-date element) ('nil "") (string (format " %s " (org-add-props string nil 'face 'org-ql-view-due-date))))) (string (s-join " " (-non-nil (list todo-keyword priority-string title due-string clock-string tag-string))))) (remove-list-of-text-properties 0 (length string) '(line-prefix) string) (--> string (concat " " it) (org-add-props it properties 'org-agenda-type 'search 'todo-state todo-keyword 'tags tag-list 'org-habit-p habit-property))))) (with-eval-after-load 'org-ql (advice-add #'org-ql-view--format-element :override #'my/org-ql-view--format-element-override)) (use-package org-habit-stats :straight (:host github :repo "ml729/org-habit-stats") :after (org) :config (general-define-key :keymaps '(org-habit-stats-mode-map) :states '(normal emacs) "q" #'org-habit-stats-exit "<" #'org-habit-stats-calendar-scroll-left ">" #'org-habit-stats-calendar-scroll-right "[" #'org-habit-stats-scroll-graph-left "]" #'org-habit-stats-scroll-graph-right "{" #'org-habit-stats-scroll-graph-left-big "}" #'org-habit-stats-scroll-graph-right-big "." #'org-habit-stats-view-next-habit "," #'org-habit-stats-view-previous-habit) (add-hook 'org-after-todo-state-change-hook 'org-habit-stats-update-properties)) (defun my/org-match-at-point-p (match) "Return non-nil if headline at point matches MATCH. Here MATCH is a match string of the same format used by `org-tags-view'." (funcall (cdr (org-make-tags-matcher match)) (org-get-todo-state) (org-get-tags-at) (org-reduced-level (org-current-level)))) (defun my/org-agenda-skip-without-match (match) "Skip current headline unless it matches MATCH. Return nil if headline containing point matches MATCH (which should be a match string of the same format used by `org-tags-view'). If headline does not match, return the position of the next headline in current buffer. Intended for use with `org-agenda-skip-function', where this will skip exactly those headlines that do not match." (save-excursion (unless (org-at-heading-p) (org-back-to-heading)) (let ((next-headline (save-excursion (or (outline-next-heading) (point-max))))) (if (my/org-match-at-point-p match) nil next-headline)))) (defun my/org-scheduled-get-time () (let ((scheduled (org-get-scheduled-time (point)))) (if scheduled (format-time-string "%Y-%m-%d" scheduled) ""))) (setq org-agenda-hide-tags-regexp (rx (or "org" "refile" "proj" "habit"))) (setq org-agenda-custom-commands `(("p" "My outline" ((agenda "" ((org-agenda-skip-function '(my/org-agenda-skip-without-match "-habit")))) (tags-todo "inbox" ((org-agenda-overriding-header "Inbox") (org-agenda-prefix-format " %i %-12:c") (org-agenda-hide-tags-regexp "."))) (tags-todo "+waitlist+SCHEDULED<=\"<+14d>\"" ((org-agenda-overriding-header "Waitlist") (org-agenda-hide-tags-regexp "waitlist") (org-agenda-prefix-format " %i %-12:c %-12(my/org-scheduled-get-time)"))) (tags-todo "habit+SCHEDULED<=\"<+0d>\"" ((org-agenda-overriding-header "Habits") (org-agenda-prefix-format " %i %-12:c") (org-agenda-hide-tags-regexp "."))))))) (use-package org-yaap :straight (org-yaap :type git :host gitlab :repo "SqrtMinusOne/org-yaap") :after (org) :if (not my/nested-emacs) :disabled t :config (org-yaap-mode 1) (setq org-yaap-alert-before '(10 1)) (setq org-yaap-alert-title "PROXIMITY ALERT") (setq org-yaap-todo-keywords-only '("FUTURE"))) (setq my/org-alert-notify-times '(600 60)) (setq my/org-alert--alerts (make-hash-table :test #'equal)) (defun my/org-alert--is-scheduled (label time) "Check if LABEL is scheduled to be shown an TIME." (gethash (cons label time) my/org-alert--alerts nil)) (defun my/org-alert--schedule (label time) "Schedule LABEL to be shown at TIME, unless it's already scheduled." (unless (my/org-alert--is-scheduled label time) (puthash (cons label time) (run-at-time time nil (lambda () (alert label :title "PROXIMITY ALERT"))) my/org-alert--alerts))) (defun my/org-alert-cleanup (&optional keys) "Unschedule items that do not appear in KEYS. KEYS is a list of cons cells like (