diff options
| author | skylar <me@casper> | 2026-11-01 07:03:26 -0800 |
|---|---|---|
| committer | skylar <me@casper> | 2026-11-01 07:03:26 -0800 |
| commit | 070a107873440d62b4519e6d91c2ba739d4b6fa2 (patch) | |
| tree | ae4e1c3540cdd01b3289ed6f96ec3ea69507f73e /config.el | |
Literate Doom Emacs configuration
config.org tangles to config.el, which loads private/config.el last when
it exists. bin/ holds the scripts some sections drive.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'config.el')
| -rw-r--r-- | config.el | 4652 |
1 files changed, 4652 insertions, 0 deletions
diff --git a/config.el b/config.el new file mode 100644 index 0000000..94cf0df --- /dev/null +++ b/config.el @@ -0,0 +1,4652 @@ +;;; config.el -*- lexical-binding: t; -*- + +;; [[file:config.org::*Tangle][Tangle:1]] +(use-package! org-auto-tangle + :hook (org-mode . org-auto-tangle-mode)) +;; Tangle:1 ends here + +;; [[file:config.org::*Helper scripts][Helper scripts:1]] +(defun my/program (name) + "Return the executable NAME, from `exec-path' or this config's bin/ directory. +Signals a `user-error' if it is in neither." + (or (executable-find name) + (let ((bundled (expand-file-name (concat "bin/" name) doom-user-dir))) + (and (file-executable-p bundled) bundled)) + (user-error "%s is not on PATH or in %sbin/" name doom-user-dir))) +;; Helper scripts:1 ends here + +;; [[file:config.org::*Fonts][Fonts:1]] +(setq doom-font (font-spec :family "JetBrainsMonoNL Nerd Font" :size 16 :weight 'semi-light)) + +(custom-set-faces! '(variable-pitch :family "DejaVu Serif" :height 1.3)) + +(add-hook! 'after-setting-font-hook + (defun my/variable-pitch-relative-height-h () + "Keep `variable-pitch' at a ratio of the default size after Doom font reloads." + (set-face-attribute 'variable-pitch nil :height 1.3))) + +;; Frank Ruehl CLM is the Frank-Rühl book face (1908), the square Hebrew of +;; older printed Qabalah. Its letters only reach JetBrains Mono's x-height, so +;; they're scaled 1.2x to sit between x-height and cap height, which still +;; leaves lines with Hebrew no taller than others. It's part of Culmus +;; (culmus.sourceforge.io). Copying its files into ~/.local/share/fonts is +;; enough; some distribution packages of it also install a fontconfig rule +;; that changes the default Hebrew font in every other program. +(set-fontset-font t 'hebrew (font-spec :family "Frank Ruehl CLM")) +(add-to-list 'face-font-rescale-alist '("Frank Ruehl CLM" . 1.2)) + +;; Without this, Emacs falls back to whatever font claims the codepoint +;; first, which is rarely one with actual emoji glyphs, hence tofu/mojibake. +;; Any color emoji font works; swap in "Noto Color Emoji" if that's the one +;; installed. +(set-fontset-font t 'emoji (font-spec :family "Apple Color Emoji")) + +;; JetBrainsMono has no astrological glyphs, and the fallback for most of +;; them is proportional Noto Sans Symbols (8-11px against a 10px cell), which +;; knocks the planet columns in Org tables out of alignment. DejaVu Sans Mono +;; is a common monospace font that covers ☉, ☽-♇ and ♈-♓. +(dolist (range '(#x2609 (#x263D . #x2653))) + (set-fontset-font t range (font-spec :family "DejaVu Sans Mono"))) + +(setq display-line-numbers-type nil) +;; Fonts:1 ends here + +;; [[file:config.org::*Theme][Theme:1]] +(use-package! auto-dark + :defer t + :init + (setq! auto-dark-themes '((doom-gruvbox) (doom-gruvbox-light)) + doom-theme nil + custom-safe-themes t) + (defun my/auto-dark-init-h () + "Enable `auto-dark-mode' once the first frame exists, then unhook." + (auto-dark-mode +1) + (remove-hook 'server-after-make-frame-hook #'my/auto-dark-init-h) + (remove-hook 'after-init-hook #'my/auto-dark-init-h)) + (add-hook (if (daemonp) 'server-after-make-frame-hook 'after-init-hook) + #'my/auto-dark-init-h -95)) +;; Theme:1 ends here + +;; [[file:config.org::*Theme][Theme:2]] +(defun my/system-theme (mode) + "Run `system-theme' MODE (\"light\" or \"dark\"), without waiting for it." + (call-process (my/program "system-theme") nil 0 nil mode)) + +(map! :leader + :desc "System light mode" "t t l" (cmd! (my/system-theme "light")) + :desc "System dark mode" "t t d" (cmd! (my/system-theme "dark"))) +;; Theme:2 ends here + +;; [[file:config.org::*Frames][Frames:1]] +(add-to-list 'default-frame-alist '(alpha-background . 85)) + +(defun my/undecorate-frame (&optional frame) + "Remove the titlebar from FRAME, or from the selected frame. +Runs after frame creation because pgtk ignores `undecorated' set any earlier." + (set-frame-parameter (or frame (selected-frame)) 'undecorated t)) + +(add-hook 'after-make-frame-functions #'my/undecorate-frame) +(add-hook 'emacs-startup-hook #'my/undecorate-frame) +;; Frames:1 ends here + +;; [[file:config.org::*Subprocess environment][Subprocess environment:1]] +(defun my/import-client-display-env-h () + "Copy display variables from the new frame's client into `process-environment'. +The daemon may have started before the graphical session exported them." + (when (display-graphic-p) + (dolist (var '("WAYLAND_DISPLAY" "DISPLAY" "XDG_SESSION_TYPE" "XDG_CURRENT_DESKTOP")) + (when-let* ((value (getenv var (selected-frame)))) + (setenv var value))))) + +(add-hook 'server-after-make-frame-hook #'my/import-client-display-env-h) +;; Subprocess environment:1 ends here + +;; [[file:config.org::*Splash screen][Splash screen:1]] +(defvar my/tarot-deck-dir (expand-file-name "~/.local/share/tarot/thoth") + "Directory of tarot card images to draw the dashboard spread from.") + +(defvar my/tarot-card-count 1 + "Number of tarot cards to draw for the dashboard spread.") + +(defvar my/tarot-card-height 500 + "Height in pixels each tarot card is resized to (magick's -resize geometry).") + +(defconst my/tarot-card-regexp "\\`[^.].*\\.\\(?:jpe?g\\|png\\|webp\\)\\'" + "Matches the file name of a card image. Dotfiles never match.") + +(defun my/tarot--cards (dir &optional recursive) + "List the card images in DIR, and in its subdirectories if RECURSIVE." + ;; `directory-files' matches case-sensitively whatever `case-fold-search' + ;; says, so filter here instead of passing it the regexp. + (let ((case-fold-search t)) + (if recursive + (directory-files-recursively dir my/tarot-card-regexp) + (seq-filter (lambda (file) + (string-match-p my/tarot-card-regexp (file-name-nondirectory file))) + (directory-files dir t))))) + +(defun my/tarot--shuffle (list) + "Return a shuffled copy of LIST (Fisher-Yates)." + (let ((vec (vconcat list))) + (dotimes (i (length vec)) + (let* ((j (+ i (random (- (length vec) i)))) + (tmp (aref vec i))) + (aset vec i (aref vec j)) + (aset vec j tmp))) + (append vec nil))) + +(defun my/tarot--montage (cards output &rest options) + "Tile CARDS side by side into OUTPUT with ImageMagick. +OPTIONS are extra montage arguments, such as -geometry and -background." + (unless (executable-find "magick") + (user-error "ImageMagick (magick) is not installed")) + (unless (zerop (apply #'call-process "magick" nil nil nil "montage" + (append cards + (list "-tile" (format "%dx1" (length cards))) + options + (list output)))) + (user-error "ImageMagick montage failed"))) + +(defun my/tarot-splash-file () + "Path to the generated tarot splash image." + (expand-file-name "doom-tarot-splash.png" + (or (getenv "XDG_CACHE_HOME") + (expand-file-name "~/.cache")))) + +(defun my/tarot--cache-dir () + "Directory holding `my/tarot-deck-dir''s cards resized to `my/tarot-card-height'." + (expand-file-name (format "tarot/%s-x%d" + (file-name-nondirectory (directory-file-name my/tarot-deck-dir)) + my/tarot-card-height) + (or (getenv "XDG_CACHE_HOME") + (expand-file-name "~/.cache")))) + +(defun my/tarot--cached-card (card) + "Path of CARD's resized copy in `my/tarot--cache-dir', whether or not it exists yet." + (expand-file-name (concat (file-name-sans-extension + (file-relative-name card my/tarot-deck-dir)) + ".png") + (my/tarot--cache-dir))) + +(defun my/tarot--resize-command (card output) + "The magick command that resizes CARD into OUTPUT." + (list "magick" card "-resize" (format "x%d" my/tarot-card-height) output)) + +(defun my/tarot--resize-temp-file () + "Make an empty file in `my/tarot--cache-dir' to resize a card into. +A resize writes there and is renamed into place only once it finishes, +so an interrupted one never leaves a truncated card in the cache." + (make-directory (my/tarot--cache-dir) t) + (make-temp-file (expand-file-name ".resizing-" (my/tarot--cache-dir)) nil ".png")) + +(defun my/tarot--install-resized (tmp card) + "Move TMP, a finished resize of CARD, to CARD's place in the cache. +Returns that place." + (let ((cached (my/tarot--cached-card card))) + (make-directory (file-name-directory cached) t) + (rename-file tmp cached t) + cached)) + +(defun my/tarot--cache-card (card) + "Return CARD's resized copy, resizing it now if it isn't cached yet." + (let ((cached (my/tarot--cached-card card))) + (if (file-exists-p cached) + cached + (let* ((tmp (my/tarot--resize-temp-file)) + (command (my/tarot--resize-command card tmp))) + (unless (zerop (apply #'call-process (car command) nil nil nil (cdr command))) + (delete-file tmp) + (user-error "ImageMagick could not resize %s" card)) + (my/tarot--install-resized tmp card))))) + +(defun my/tarot--cache-in-background (cards) + "Resize whichever of CARDS aren't cached yet, one background process at a time. +A card that fails to resize is skipped, so it can't stall the ones after it." + (when-let* ((pending (seq-remove (lambda (card) + (file-exists-p (my/tarot--cached-card card))) + cards))) + (let ((card (car pending)) + (tmp (my/tarot--resize-temp-file))) + (make-process + :name "tarot-cache" :noquery t + :command (my/tarot--resize-command card tmp) + :sentinel (lambda (process _event) + (unless (process-live-p process) + (if (zerop (process-exit-status process)) + (my/tarot--install-resized tmp card) + (delete-file tmp)) + (my/tarot--cache-in-background (cdr pending)))))))) + +(defun my/tarot-generate-splash () + "Draw a fresh tarot spread into the dashboard splash image, synchronously." + (let* ((cards (and (file-directory-p my/tarot-deck-dir) + (my/tarot--cards my/tarot-deck-dir t))) + (chosen (seq-take (my/tarot--shuffle cards) my/tarot-card-count))) + (cond + ((null chosen) + (warn "Tarot splash: no card images found in %s" my/tarot-deck-dir)) + ((not (executable-find "magick")) + (warn "Tarot splash: magick is not on PATH")) + (t + (condition-case err + (let ((resized (mapcar #'my/tarot--cache-card chosen))) + (if (cdr resized) + (my/tarot--montage resized (my/tarot-splash-file) + "-geometry" "+0+0" "-background" "black") + (copy-file (car resized) (my/tarot-splash-file) t))) + (error (warn "Tarot splash: %s; keeping the previous image" + (error-message-string err)))))))) + +(my/tarot-generate-splash) + +(when (and (file-directory-p my/tarot-deck-dir) (executable-find "magick")) + (run-with-idle-timer 5 nil #'my/tarot--cache-in-background + (my/tarot--cards my/tarot-deck-dir t))) + +(setq fancy-splash-image (my/tarot-splash-file)) + +;; Default padding is (2 . 2) character-heights above/below the banner; the +;; auto-centering only balances raw line count, not the image's actual +;; height, so the image sits a bit low. Shift it up by trimming the top pad... +(setq +dashboard-banner-vertical-padding '(0 . 2)) + +;; ...and once that pad is gone, nudge it up further by trimming lines +;; straight out of `+dashboard-resize-h''s auto-centering padding, since +;; that's computed from raw line count and can't be fixed by the pad above. +(defvar my/dashboard-extra-lift 3 + "Extra lines to shave off the dashboard's auto-centered top padding.") + +(defadvice! my/dashboard-lift-a (&rest _) + "Shift the dashboard content up by `my/dashboard-extra-lift' lines." + :after #'+dashboard-resize-h + (when (get-buffer-window (doom-fallback-buffer) t) + (with-current-buffer (doom-fallback-buffer) + (with-silent-modifications + (save-excursion + (goto-char (point-min)) + (let ((blank-end (progn (skip-chars-forward "\n") (point)))) + (delete-region (point-min) + (min blank-end (+ (point-min) my/dashboard-extra-lift))))))))) + +(defun my/tarot-reshuffle-splash () + "Draw a new tarot spread and redraw the dashboard." + (interactive) + (my/tarot-generate-splash) + ;; Only the splash image: with no argument this would also flush every + ;; other cached image, like Org's inline images and image-dired thumbnails. + (clear-image-cache (my/tarot-splash-file)) + (+dashboard-reload t)) +;; Splash screen:1 ends here + +;; [[file:config.org::*Splash screen][Splash screen:2]] +(remove-hook '+dashboard-functions #'+dashboard-widget-loaded) +(remove-hook '+dashboard-functions #'+dashboard-widget-shortmenu) + +(defface my/dashboard-quote-face + '((t (:inherit (italic variable-pitch)))) + "Face for dashboard quotes.") + +(defvar my/dashboard-quotes + '("Every man and woman is a star." + "Do as thou wilt shall be the whole of the law." + "As above, so below.") + "Quotes shown at random under the dashboard menu.") + +(defun my/dashboard-footer () + "Insert a random quote from `my/dashboard-quotes'." + (let ((line (nth (random (length my/dashboard-quotes)) my/dashboard-quotes))) + (+dashboard-insert + (with-temp-buffer + (insert (propertize " " 'display '(space . (:relative-height 2.0))) "\n") + (insert (propertize (format "❝ %s ❞" line) 'face 'my/dashboard-quote-face)) + (insert "\n") + (buffer-string))))) + +(remove-hook '+dashboard-functions #'+dashboard-widget-footer) +(add-hook '+dashboard-functions #'my/dashboard-footer :append) +;; Splash screen:2 ends here + +;; [[file:config.org::*Org][Org:1]] +(setq org-directory "~/Documents/org/" + org-startup-with-inline-images t + org-startup-folded 'overview + org-extend-today-until 8) +;; Org:1 ends here + +;; [[file:config.org::*Capture defaults][Capture defaults:1]] +(defvar my/org-capture-hidden-keys nil + "Capture template keys to omit from the interactive selection menu. +They remain reachable by calling `org-capture' with the key directly.") + +(defun my/org-capture-hide-menu-templates-a (fn &optional keys) + "Hide templates whose key is in `my/org-capture-hidden-keys' from the +interactive template menu, without affecting direct lookups by KEYS." + (if keys + (funcall fn keys) + (let ((org-capture-templates + (seq-remove (lambda (template) (member (car template) my/org-capture-hidden-keys)) + org-capture-templates))) + (funcall fn keys)))) + +(after! org-capture + (advice-add #'org-capture-select-template :around #'my/org-capture-hide-menu-templates-a) + + (setq org-capture-templates + '(("n" "Personal notes" entry + (file+headline +org-capture-notes-file "Inbox") + "* %u %?\n%i" :prepend t) + ("j" "Journal" entry + (file+olp+datetree +org-capture-journal-file) + "* %U %?\n%i" :prepend t) + + ("p" "Templates for projects") + ("pt" "Project-local todo" entry + (file+headline +org-capture-project-todo-file "Inbox") + "* TODO %?\n%i" :prepend t) + ("pn" "Project-local notes" entry + (file+headline +org-capture-project-notes-file "Inbox") + "* %U %?\n%i" :prepend t) + ("pc" "Project-local changelog" entry + (file+headline +org-capture-project-changelog-file "Unreleased") + "* %U %?\n%i" :prepend t) + + ("o" "Centralized templates for projects") + ("ot" "Project todo" entry + (function +org-capture-central-project-todo-file) + "* TODO %?\n %i" + :heading "Tasks" + :prepend nil) + ("on" "Project notes" entry + (function +org-capture-central-project-notes-file) + "* %U %?\n %i" + :heading "Notes" + :prepend t) + ("oc" "Project changelog" entry + (function +org-capture-central-project-changelog-file) + "* %U %?\n %i" + :heading "Changelog" + :prepend t)))) +;; Capture defaults:1 ends here + +;; [[file:config.org::*Vocabulary capture][Vocabulary capture:1]] +(defun my/vocab-capture-template (key description file) + "Return an org-capture template that files a drill vocabulary entry in FILE." + (list key description 'entry + (list 'file+headline file "Vocabulary") + "* Vocab word :drill:\n%^{Word}\n** Answer:\n%^{Definition}\n")) + +(after! org-capture + (dolist (template (list (my/vocab-capture-template "g" "German vocabulary" "german.org") + (my/vocab-capture-template "v" "Latin vocabulary" "latin.org"))) + (add-to-list 'org-capture-templates template))) +;; Vocabulary capture:1 ends here + +;; [[file:config.org::*Dream journal][Dream journal:1]] +(after! org-capture + (add-to-list 'org-capture-templates + '("d" "Dream" entry + (file+olp+datetree +org-capture-journal-file) + "* %U %? :dream:\n%i" :prepend t))) +;; Dream journal:1 ends here + +;; [[file:config.org::*Grimoire journal][Grimoire journal:1]] +(require 'diary-lib) ; `diary-ordinal-suffix', for "3rd decan" + +(defconst my/zodiac-signs + [("Aries" "♈" "IV The Emperor") ("Taurus" "♉" "V The Hierophant") + ("Gemini" "♊" "VI The Lovers") ("Cancer" "♋" "VII The Chariot") + ("Leo" "♌" "XI Lust") ("Virgo" "♍" "IX The Hermit") + ("Libra" "♎" "VIII Adjustment") ("Scorpio" "♏" "XIII Death") + ("Sagittarius" "♐" "XIV Art") ("Capricornus" "♑" "XV The Devil") + ("Aquarius" "♒" "XVII The Star") ("Pisces" "♓" "XVIII The Moon")] + "The twelve signs from 0° Aries, as (NAME GLYPH ATU) -- ATU being the +sign's Thoth trump.") + +(defconst my/thoth-small-cards + '(("Wands" "Dominion" "Virtue" "Completion" "Strife" "Victory" "Valour" + "Swiftness" "Strength" "Oppression") + ("Disks" "Change" "Work" "Power" "Worry" "Success" "Failure" + "Prudence" "Gain" "Wealth") + ("Swords" "Peace" "Sorrow" "Truce" "Defeat" "Science" "Futility" + "Interference" "Cruelty" "Ruin") + ("Cups" "Love" "Abundance" "Luxury" "Disappointment" "Pleasure" "Debauch" + "Indolence" "Happiness" "Satiety")) + "Each suit's Thoth titles for the Two through Ten, the suits in the +elemental order the signs repeat from Aries: Fire, Earth, Air, Water.") + +(defconst my/thoth-small-card-numbers + ["Two" "Three" "Four" "Five" "Six" "Seven" "Eight" "Nine" "Ten"]) + +(defun my/zodiac-position (longitude) + "Split ecliptic LONGITUDE into (SIGN DEGREE MINUTE): SIGN an index +into `my/zodiac-signs', DEGREE and MINUTE whole numbers within it." + ;; Through whole arcminutes, so an astrolog position like 20Lib34 + ;; comes back out as 20°34' instead of a float's 20°33'. + (let ((minutes (mod (round (* longitude 60)) (* 360 60)))) + (list (/ minutes 1800) (/ (mod minutes 1800) 60) (mod minutes 60)))) + +(defun my/zodiac-position-string (longitude) + "Format ecliptic LONGITUDE as e.g. \"20°34' ♎ Libra (Atu VIII Adjustment)\"." + (pcase-let* ((`(,sign ,degree ,minute) (my/zodiac-position longitude)) + (`(,name ,glyph ,atu) (aref my/zodiac-signs sign))) + (format "%d°%02d' %s %s (Atu %s)" degree minute glyph name atu))) + +(defun my/thoth-decan-string (longitude) + "Describe the decan containing ecliptic LONGITUDE and its Thoth small +card, e.g. \"Four of Swords (Truce): ♃ Jupiter in ♎ Libra, 3rd decan\"." + (pcase-let* ((`(,sign ,degree ,_) (my/zodiac-position longitude)) + (decan (/ degree 10)) + (`(,suit . ,titles) (nth (mod sign 4) my/thoth-small-cards)) + (rank (+ (* 3 (mod sign 3)) decan)) + (mars (seq-position my/planetary-hours-order "Mars" + (lambda (pair name) (equal (car pair) name)))) + (`(,ruler . ,ruler-glyph) + (nth (mod (+ mars (* 3 sign) decan) 7) my/planetary-hours-order)) + (`(,name ,glyph ,_) (aref my/zodiac-signs sign))) + (format "%s of %s (%s): %s %s in %s %s, %d%s decan" + (aref my/thoth-small-card-numbers rank) suit (nth rank titles) + ruler-glyph ruler glyph name + (1+ decan) (diary-ordinal-suffix (1+ decan))))) +;; Grimoire journal:1 ends here + +;; [[file:config.org::*Grimoire journal][Grimoire journal:2]] +(defun my/moon-phase-string (sun moon) + "Describe the Moon's phase from SUN and MOON ecliptic longitudes, +e.g. \"Waxing Gibbous, 78% illuminated\"." + (let* ((elongation (mod (- moon sun) 360.0)) + (phase (aref my/lunar-phases (mod (round (/ elongation 45.0)) 8)))) + (format "%s, %d%% illuminated" + (cdr phase) + (round (* 50 (- 1 (cos (degrees-to-radians elongation)))))))) +;; Grimoire journal:2 ends here + +;; [[file:config.org::*Grimoire journal][Grimoire journal:3]] +(defconst my/thelemic-weekdays + ["Solis" "Lunae" "Martis" "Mercurii" "Jovis" "Veneris" "Saturni"] + "Latin day names, indexed like `calendar-day-of-week' (Sunday first).") + +(defun my/roman-numeral (n) + "Write N in capital Roman numerals, or \"0\" for zero." + (if (zerop n) + "0" + (let ((result "")) + (pcase-dolist (`(,value . ,numeral) + '((100 . "C") (90 . "XC") (50 . "L") (40 . "XL") (10 . "X") + (9 . "IX") (5 . "V") (4 . "IV") (1 . "I"))) + (while (>= n value) + (setq result (concat result numeral) + n (- n value)))) + result))) + +(defun my/thelemic-date-string (time sun moon) + "Format TIME as a Thelemic date, given the SUN and MOON ecliptic +longitudes at TIME." + (pcase-let* ((decoded (decode-time time)) + (year (- (decoded-time-year decoded) 1904 + (if (and (<= (decoded-time-month decoded) 3) (>= sun 180)) 1 0))) + (`(,sun-sign ,sun-degree ,_) (my/zodiac-position sun)) + (`(,moon-sign ,moon-degree ,_) (my/zodiac-position moon))) + (format "☉ in %d° %s : ☽ in %d° %s : dies %s : Anno %s%s" + sun-degree (nth 1 (aref my/zodiac-signs sun-sign)) + moon-degree (nth 1 (aref my/zodiac-signs moon-sign)) + (aref my/thelemic-weekdays (decoded-time-weekday decoded)) + (my/roman-numeral (/ year 22)) + (downcase (my/roman-numeral (mod year 22)))))) +;; Grimoire journal:3 ends here + +;; [[file:config.org::*Grimoire journal][Grimoire journal:4]] +(defvar my/grimoire-row-functions + '(my/grimoire-moment-rows my/grimoire-sky-rows my/grimoire-hour-rows + my/grimoire-weather-rows my/grimoire-answer-rows) + "Functions that fill in a Grimoire entry, in order. Each is called +with the capture's time and returns a list of rows (LABEL VALUE +PROPERTY): LABEL and VALUE make a row of the entry's table, and PROPERTY +names a property set to VALUE in its drawer. Either LABEL or PROPERTY +may be nil to leave that half out. A function that signals an error +costs the entry only its own rows, with a warning.") + +(defun my/grimoire-moment-rows (time) + "Return TIME in UTC, and the coordinates the planetary hour is worked +out for, as drawer-only rows for a Grimoire entry." + (cons (list nil (format-time-string (org-time-stamp-format t t) time t) "UTC") + (and calendar-latitude calendar-longitude + (list (list nil (format "%.4f, %.4f" calendar-latitude calendar-longitude) + "COORDINATES"))))) + +(defun my/grimoire-sky-rows (time) + "Return the Sun and Moon at TIME, and any planet retrograde, as rows +for a Grimoire entry." + (let* ((longitudes (my/planet-positions--longitudes time)) + (sun (nth 1 (assoc "Sun" longitudes))) + (moon (nth 1 (assoc "Moon" longitudes))) + (retrograde + (mapconcat (lambda (planet) + (format "%s %s" (cdr planet) (car planet))) + (seq-filter (lambda (planet) + (nth 2 (assoc (car planet) longitudes))) + my/planetary-hours-order) + ", "))) + (append + (list (list "Thelemic date" (my/thelemic-date-string time sun moon) "THELEMIC_DATE") + (list "Sun" (my/zodiac-position-string sun) "SUN") + (list nil (format "%.2f" sun) "SUN_LONGITUDE") + (list "Decan" (my/thoth-decan-string sun) "DECAN") + (list "Moon" (my/zodiac-position-string moon) "MOON") + (list nil (format "%.2f" moon) "MOON_LONGITUDE") + (list "Moon phase" (my/moon-phase-string sun moon) "MOON_PHASE")) + (unless (string-empty-p retrograde) + (list (list "Retrograde" retrograde "RETROGRADE")))))) + +(defun my/grimoire-hour-rows (time) + "Return the planetary day and hour at TIME as rows for a Grimoire entry." + (pcase-let ((`((,day . ,day-glyph) (,hour . ,hour-glyph) ,number ,night) + (my/planetary-hours-at time))) + (list (list "Planetary day" (format "%s %s" day-glyph day) "PLANETARY_DAY") + (list "Planetary hour" + (format "%s %s, %d%s hour of the %s" + hour-glyph hour number (diary-ordinal-suffix number) + (if night "night" "day")) + "PLANETARY_HOUR")))) + +(defun my/grimoire-weather-rows (time) + "Return the weather at TIME, from the cached wttr.in forecast, as a row +for a Grimoire entry: the current conditions for a TIME within an hour +and a half of now, otherwise the 3-hour forecast block TIME falls in." + (let* ((current (and (equal (alist-get 'date (car (my/weather-days))) + (format-time-string "%Y-%m-%d")) + (< (abs (float-time (time-subtract time nil))) 5400) + (my/weather-current))) + (block (and (not current) + (seq-find (lambda (entry) + (and (not (time-less-p time (nth 0 entry))) + (time-less-p time (time-add (nth 0 entry) (* 3 3600))))) + (my/weather--timeline)))) + (condition (or current (nth 2 block)))) + (unless condition + (error "No forecast cached for %s" (format-time-string "%Y-%m-%d %H:%M" time))) + (list (list "Weather" + (concat (format "%s, %s°F, humidity %s%%, wind %s mph %s" + (my/weather--desc condition) + ;; Current conditions spell it temp_F, hourly entries tempF. + (or (alist-get 'temp_F condition) (alist-get 'tempF condition)) + (alist-get 'humidity condition) + (alist-get 'windspeedMiles condition) + (alist-get 'winddir16Point condition)) + (and block + (let ((start (alist-get 'time condition))) + (format " (forecast for %s–%s)" + (my/weather--hour-label start) + (my/weather--hour-label + (number-to-string + (mod (+ (string-to-number start) 300) 2400))))))) + "WEATHER")))) + +(defun my/grimoire-answer-rows (_time) + "Return the answers `my/grimoire-capture-ask' collected, as rows for +the Grimoire entry being captured." + (org-capture-get :grimoire-answers)) + +(defun my/grimoire-capture-attach () + "Fill in the entry the Grimoire capture template just placed from +`my/grimoire-row-functions': their properties in its drawer, folded, +and a table of the rest at point, leaving point below it to write the +entry." + (let* ((time (or (org-capture-get :default-time) (current-time))) + (rows (seq-mapcat (lambda (function) + (condition-case err + (funcall function time) + (error (warn "Grimoire entry has no rows from `%s': %s" + function (error-message-string err)) + nil))) + my/grimoire-row-functions))) + (save-excursion + (pcase-dolist (`(,_ ,value ,property) rows) + (when property + (org-entry-put nil property value))) + (org-fold-hide-drawer-all)) + (when-let* ((table (seq-filter #'car rows))) + (let ((start (point))) + (pcase-dolist (`(,label ,value) table) + ;; A typed | would end the cell; \vert is Org's escape for it. + (insert "| " label " | " (string-replace "|" "\\vert{}" value) " |\n")) + (save-excursion (goto-char start) (org-table-align))) + (insert "\n")))) + +(defun my/grimoire-capture-trim () + "Delete the blank lines at the end of the Grimoire entry being captured." + (save-excursion + (goto-char (point-max)) + (skip-chars-backward " \t\n") + (delete-region (point) (point-max)) + ;; Filed above a later heading, the newline ending the entry's last + ;; line lies just outside the capture's narrowing; filed at the end + ;; of the file, it's inside it, and was just deleted. + (unless (eq (save-restriction (widen) (char-after)) ?\n) + (insert "\n")))) +;; Grimoire journal:4 ends here + +;; [[file:config.org::*Grimoire journal][Grimoire journal:5]] +(defvar my/grimoire-title-history nil + "Titles given to the Grimoire capture template.") + +(defvar my/grimoire-sleep-history nil + "Answers given to the Grimoire capture template's Sleep question.") + +(defvar my/grimoire-health-history nil + "Answers given to the Grimoire capture template's Health question.") + +(defvar my/grimoire-mood-history nil + "Answers given to the Grimoire capture template's Mood question.") + +(defvar my/grimoire-duration-history nil + "Answers given to the Grimoire capture template's Duration question.") + +(defvar my/grimoire-intruding-thoughts-history nil + "Answers given to the Grimoire capture template's Intruding thoughts question.") + +(defvar my/grimoire-default-answer "Nothing to report" + "What a Grimoire capture question records when answered with a bare RET.") + +(defvar my/grimoire-questions + '(("Sleep" "SLEEP" "Sleep (when, how long, how well)" my/grimoire-sleep-history) + ("Health" "HEALTH" "Health" my/grimoire-health-history) + ("Mood" "MOOD" "Mood" my/grimoire-mood-history) + ("Duration" "DURATION" "Duration of the practice" my/grimoire-duration-history) + ("Intruding thoughts" "INTRUDING_THOUGHTS" "Intruding thoughts (number and nature)" + my/grimoire-intruding-thoughts-history)) + "Questions the Grimoire capture template asks after the title, in order, +as (LABEL PROPERTY PROMPT HISTORY RECALL). Each answer is a row LABEL in +the entry's table and the property PROPERTY in its drawer. PROMPT has no +trailing colon; `format-prompt' adds it along with the default. HISTORY +is the question's minibuffer history variable. With RECALL non-nil, +earlier answers are offered as candidates, the latest first, so RET +repeats it; otherwise, and before there are any, RET records +`my/grimoire-default-answer'.") + +(defun my/grimoire-capture-ask () + "Prompt for a Grimoire entry's title and then `my/grimoire-questions'. +Return the title, for the template's headline, and store the answers +under :grimoire-answers in the capture, as rows for +`my/grimoire-answer-rows'." + (prog1 (read-string "Title: " nil 'my/grimoire-title-history) + (org-capture-put + :grimoire-answers + (mapcar (pcase-lambda (`(,label ,property ,prompt ,history ,recall)) + (let* ((earlier (and recall + (delete-dups (copy-sequence (symbol-value history))))) + (default (or (car earlier) my/grimoire-default-answer)) + (answer (string-trim + (if recall + (completing-read (format-prompt prompt default) + earlier nil nil nil history default) + (read-string (format-prompt prompt default) + nil history default))))) + (list label (if (string-empty-p answer) default answer) property))) + my/grimoire-questions)))) + +(after! org-capture + (add-to-list 'org-capture-templates + '("m" "Grimoire" entry + (file+olp+datetree "grimoire.org" "Journal") + "* %U %(my/grimoire-capture-ask)\n%?%i" + :time-prompt t + :hook my/grimoire-capture-attach + :prepare-finalize my/grimoire-capture-trim))) +;; Grimoire journal:5 ends here + +;; [[file:config.org::*Liber Resh journal][Liber Resh journal:1]] +(defconst my/resh-stations + '(("Sunrise" "Ra" "East") + ("Noon" "Ahathoor" "South") + ("Sunset" "Tum" "West") + ("Midnight" "Khephra" "North")) + "The adorations of Liber Resh: station, god, and the quarter faced.") + +(defvar my/resh-capture-data nil + "Plist for the adoration `my/resh-log' is logging, bound only while the +\"r\" capture template is filled in. Nil when capturing by hand.") + +(defun my/resh-capture-heading () + "Return \"STATION, GOD\" for the Liber Resh capture's headline, prompting +for the station when not called through `my/resh-log'. Stores the full plist +under :resh in the capture for the template's :hook and :after-finalize." + (let* ((data (or my/resh-capture-data + (list :station (completing-read "Adoration: " my/resh-stations nil t)))) + (known (or (assoc (plist-get data :station) my/resh-stations) + (user-error "Not an adoration of Liber Resh: %s" (plist-get data :station)))) + (data (append data + (list :god (nth 1 known) + :quarter (nth 2 known) + :coordinates (and calendar-latitude calendar-longitude + (format "%.4f, %.4f" calendar-latitude calendar-longitude)))))) + (org-capture-put :resh data) + (format "%s, %s" (car known) (nth 1 known)))) + +(defun my/resh-capture-attach () + "Fill in the property drawer of the entry the Liber Resh capture template +just placed, and insert the note passed to `my/resh-log' at point." + (let ((data (org-capture-get :resh))) + (save-excursion + (pcase-dolist (`(,key . ,property) '((:god . "GOD") + (:quarter . "QUARTER") + (:sun-time . "SUN_TIME") + (:place . "PLACE") + (:coordinates . "COORDINATES"))) + (let ((value (plist-get data key))) + (when (and value (not (string-empty-p value))) + (org-entry-put nil property value))))) + (let ((note (plist-get data :note))) + (when (and note (not (string-blank-p note))) + (insert (string-trim note)))))) + +(defun my/resh-capture-done () + "Record a finished (not aborted) Liber Resh capture in the :done-file +`my/resh-log' was given, so the app can mark the adoration as logged." + (let* ((data (org-capture-get :resh)) + (file (plist-get data :done-file)) + (line (plist-get data :done-line))) + (when (and file line (not org-note-abort)) + (make-directory (file-name-directory file) t) + (write-region (concat line "\n") nil file t 'silent)))) + +(defun my/resh-log (&rest data) + "Log an adoration of Liber Resh through the \"r\" capture template. +DATA is a plist: :station (\"Sunrise\", \"Noon\", \"Sunset\" or +\"Midnight\"), and optionally :sun-time, :place, :coordinates, :note, +:done-file and :done-line. With :edit non-nil, open the capture in a +frame to finish by hand; otherwise file it immediately." + (let ((my/resh-capture-data data)) + (if (plist-get data :edit) + (+org-capture/open-frame nil "r") + (require 'org-capture) + (let ((org-capture-templates + (mapcar (lambda (template) + (if (equal (car template) "r") + (append template '(:immediate-finish t)) + template)) + org-capture-templates))) + (org-capture nil "r")))) + t) + +(after! org-capture + (add-to-list 'org-capture-templates + '("r" "Liber Resh" entry + (file+olp+datetree "grimoire.org" "Journal") + "* %U Resh: %(my/resh-capture-heading) :resh:\n%?" + :hook my/resh-capture-attach + :after-finalize my/resh-capture-done))) +;; Liber Resh journal:1 ends here + +;; [[file:config.org::*Astronomy journal][Astronomy journal:1]] +(defun my/astro-capture-utc () + "Return the capture's chosen time (the `:time-prompt' answer that +`%U' also expands) as an inactive Org timestamp in UTC." + (format-time-string (org-time-stamp-format t t) + (or (org-capture-get :default-time) (current-time)) + t)) + +(defun my/astro-capture-coordinates () + "Prompt for the coordinates of an astronomy observation, defaulting to +`calendar-latitude'/`calendar-longitude' when those are set." + (let ((default (and calendar-latitude calendar-longitude + (format "%.4f, %.4f" calendar-latitude calendar-longitude)))) + (read-string (if default + (format "Coordinates (default %s): " default) + "Coordinates (LAT, LONG): ") + nil nil default))) + +(defun my/astro-capture-planet-positions () + "Insert the planet positions table into the entry the Astronomy capture +template just placed, leaving point where it was. See +`my/astro-insert-planet-positions'." + (save-excursion + (condition-case err + (my/astro-insert-planet-positions) + (error (warn "Planet positions not inserted: %s" + (error-message-string err)))))) + +(after! org-capture + (add-to-list 'org-capture-templates + '("a" "Astronomy" entry + (file+olp+datetree +org-capture-journal-file) + "* %U %? :astronomy:observation: \n:PROPERTIES:\n:COORDINATES: %(my/astro-capture-coordinates)\n:UTC: %(my/astro-capture-utc)\n:END:\n%i" + :prepend t :time-prompt t + :hook my/astro-capture-planet-positions))) +;; Astronomy journal:1 ends here + +;; [[file:config.org::*Astronomy journal][Astronomy journal:2]] +(defun my/capture-no-extend-today-a (fn &rest args) + "Ignore `org-extend-today-until' for capture templates that want a +late-night entry to file under and timestamp as its real date/time +instead of 23:59 the previous day (currently Astronomy \"a\", +Grimoire \"m\" and Liber Resh \"r\")." + (if (member (org-capture-get :key) '("a" "m" "r")) + (let ((org-extend-today-until 0)) + (apply fn args)) + (apply fn args))) + +(advice-add #'org-capture-set-target-location :around #'my/capture-no-extend-today-a) +(advice-add #'org-capture-fill-template :around #'my/capture-no-extend-today-a) +;; Astronomy journal:2 ends here + +;; [[file:config.org::*Habits][Habits:1]] +(use-package! org-habit + :after org + :config + (setq org-habit-following-days 7 + org-habit-preceding-days 35)) +;; Habits:1 ends here + +;; [[file:config.org::*Drill][Drill:1]] +(use-package! org-drill + :commands (org-drill org-drill-resume org-drill-cram org-drill-tree org-drill-directory)) + +(setq org-agenda-tag-filter-preset '("-drill")) + +(map! :map org-mode-map :localleader :desc "Drill" "D" #'org-drill) +(map! :map org-mode-map :localleader :desc "Drill subtree" "S" #'org-drill-tree) +;; Drill:1 ends here + +;; [[file:config.org::*Flashcard capture][Flashcard capture:1]] +(defun my/flashcard-capture-target () + "Prompt for a refile location for a new flashcard, using the file open +when capture started as the \"current buffer\" refile candidates. Falls +back to Inbox > Flashcards in the todo file if no location is offered." + (let* ((original (org-capture-get :original-buffer)) + (loc (condition-case nil + (org-refile-get-location "Flashcard location" original 'confirm) + (error nil)))) + (if loc + (progn + (set-buffer (find-file-noselect (nth 1 loc))) + (goto-char (or (nth 3 loc) (point-max)))) + (set-buffer (find-file-noselect (+org-capture-todo-file))) + (+org--capture-ensure-heading '("Inbox" "Flashcards"))))) + +(after! org-capture + (add-to-list 'org-capture-templates + '("f" "Flashcard" entry + (function my/flashcard-capture-target) + "* Flashcard :drill:\n\n%?\n** Answer:\n"))) +;; Flashcard capture:1 ends here + +;; [[file:config.org::*Custom agenda views][Custom agenda views:1]] +(setq org-agenda-custom-commands + '(("p" "Public agenda (excludes :private:)" agenda "" + ((org-agenda-tag-filter-preset '("-drill" "-private")))) + ("j" "Journal" tags "LEVEL>0" + ((org-agenda-files (list (expand-file-name "journal.org" org-directory))))))) +;; Custom agenda views:1 ends here + +;; [[file:config.org::*LaTeX fragments][LaTeX fragments:1]] +(use-package! org-fragtog + :hook (org-mode . org-fragtog-mode)) +;; LaTeX fragments:1 ends here + +;; [[file:config.org::*LaTeX fragments][LaTeX fragments:2]] +(defun my/org-latex-bounded-search-a (fn limit) + "Keep `org-do-latex-and-related' from searching to the end of the buffer." + (save-restriction + (narrow-to-region (point-min) (min (point-max) (+ limit 10000))) + (funcall fn limit))) +(advice-add #'org-do-latex-and-related :around #'my/org-latex-bounded-search-a) +;; LaTeX fragments:2 ends here + +;; [[file:config.org::*Drawing][Drawing:1]] +;;; org-draw + +(use-package! org-draw + :commands (org-draw org-draw-edit org-draw-setup) + + :config + ;; Store drawings in a "figures" directory relative to the Org file. + (setq org-draw-directory "figures" + + ;; Require a one-time pairing code when connecting a tablet. + ;; Recommended if you're on a shared Wi-Fi network. + org-draw-require-pairing t + + ;; Use a fixed port if you want to make firewall/router + ;; configuration predictable. + org-draw-port 8777)) +;; Drawing:1 ends here + +;; [[file:config.org::*Image attach][Image attach:1]] +(defun my/org-attach-image (file) + "Attach FILE to the Org heading at point by moving it, and insert a link to it." + (interactive (list (read-file-name "Image: " "~/Downloads/" nil t))) + (require 'org-attach) + (let ((link (car (org-attach-attach file nil 'mv)))) + (insert "[[" link "]]\n") + (org-display-inline-images))) + +(map! :map org-mode-map :localleader :desc "Attach image" "a i" #'my/org-attach-image) +;; Image attach:1 ends here + +;; [[file:config.org::*Tables][Tables:1]] +(use-package! valign + :hook (org-mode . valign-mode) + :config + (setq valign-max-table-size 20000) + + (defun my/valign-realign-lazily-a (&rest _) + "Realign tables as jit-lock shows them, not the whole buffer at once." + (when valign-mode + (jit-lock-refontify))) + (advice-add #'valign--buffer-advice :override #'my/valign-realign-lazily-a)) + +(put-char-code-property ?| 'bidi-class 'L) +;; Tables:1 ends here + +;; [[file:config.org::*Paths][Paths:1]] +(defun my/gtd-file (name) + "Return the absolute path to the GTD file NAME (in `org-directory')." + (expand-file-name name org-directory)) +;; Paths:1 ends here + +;; [[file:config.org::*Agenda files][Agenda files:1]] +(after! org + (setq org-agenda-files + (mapcar (lambda (file) (expand-file-name file org-directory)) + '("inbox.org" "gtd.org" "tickler.org" "todo.org" "habits.org" + "journal.org" "calendar.org" "notes.org" "grimoire.org" + "german.org" "latin.org")))) +;; Agenda files:1 ends here + +;; [[file:config.org::*keywords][keywords:1]] +(after! org + (setq org-todo-keywords + '((sequence "TODO(t)" "NEXT(n)" "WAITING(w@/!)" "PROJ(p)" "|" "DONE(d)" "CANCELLED(c@)") + (sequence "[ ](T)" "[-](S)" "[?](W)" "|" "[X](D)") + (sequence "|" "OKAY(o)" "YES(y)" "NO(N)")) + org-todo-keyword-faces + '(("NEXT" . +org-todo-active) + ("[-]" . +org-todo-active) + ("WAITING" . +org-todo-onhold) + ("[?]" . +org-todo-onhold) + ("PROJ" . +org-todo-project) + ("CANCELLED" . +org-todo-cancel) + ("NO" . +org-todo-cancel)) + org-log-done 'time)) +;; keywords:1 ends here + +;; [[file:config.org::*Context tags][Context tags:1]] +(after! org + (setq org-tag-alist + '(("@home" . ?h) + ("@computer" . ?c) + ("@errands" . ?e) + ("@phone" . ?p) + ("@office" . ?o)))) +;; Context tags:1 ends here + +;; [[file:config.org::*Capture][Capture:1]] +(after! org-capture + (add-to-list 'org-capture-templates + '("t" "Todo [inbox]" entry + (file "inbox.org") + "* TODO %i%?")) + (add-to-list 'org-capture-templates + '("T" "Tickler" entry + (file "tickler.org") + "* %i%?\n%U"))) +;; Capture:1 ends here + +;; [[file:config.org::*Refiling][Refiling:1]] +(after! org + (setq org-refile-targets + `((,(my/gtd-file "gtd.org") :maxlevel . 3) + (,(my/gtd-file "someday.org") :level . 1) + (,(my/gtd-file "tickler.org") :maxlevel . 2)) + org-refile-use-outline-path 'file + org-outline-path-complete-in-steps nil + org-refile-allow-creating-parent-nodes 'confirm)) +;; Refiling:1 ends here + +;; [[file:config.org::*GTD agenda views][GTD agenda views:1]] +(after! org + (dolist (cmd '(("n" "Next actions" todo "NEXT") + ("h" "At home" tags-todo "@home/NEXT") + ("c" "At computer" tags-todo "@computer/NEXT") + ("e" "Errands" tags-todo "@errands/NEXT") + ("P" "Phone" tags-todo "@phone/NEXT") + ("w" "Waiting for" todo "WAITING") + ("r" "Weekly review" + ((agenda "" ((org-agenda-span 'week))) + (todo "NEXT") + (todo "WAITING") + (todo "PROJ"))))) + (add-to-list 'org-agenda-custom-commands cmd))) +;; GTD agenda views:1 ends here + +;; [[file:config.org::*Stuck projects][Stuck projects:1]] +(after! org + (setq org-stuck-projects '("+LEVEL=2/PROJ" ("NEXT") nil ""))) +;; Stuck projects:1 ends here + +;; [[file:config.org::*Archiving][Archiving:1]] +(after! org + (setq org-archive-location (concat (my/gtd-file "archive.org") "::* From %s"))) +;; Archiving:1 ends here + +;; [[file:config.org::*Keybindings][Keybindings:1]] +(map! :leader + :desc "GTD inbox" "n g i" (cmd! (find-file (my/gtd-file "inbox.org"))) + :desc "GTD projects/actions" "n g g" (cmd! (find-file (my/gtd-file "gtd.org"))) + :desc "GTD someday" "n g s" (cmd! (find-file (my/gtd-file "someday.org"))) + :desc "GTD next actions" "n g n" (cmd! (org-agenda nil "n")) + :desc "GTD weekly review" "n g r" (cmd! (org-agenda nil "r"))) +;; Keybindings:1 ends here + +;; [[file:config.org::*Blog][Blog:1]] +(defvar my/blog-site-directory nil + "Directory `my/blog-publish' writes blog.html and blog.rss into. +Nil means no site is set up, and publishing says so.") + +(setq org-rss-use-entry-url-as-guid nil) + +(defun my/blog-stamp-metadata () + "Give every non-draft top-level heading in the current buffer a +:PUBDATE:, :ID:, and :CUSTOM_ID:, unless it already has them. Existing +values are left untouched, so re-publishing a post already on the site +never changes its date, feed guid, or in-page anchor." + (org-map-entries + (lambda () + (unless (member "draft" (org-element-property :tags (org-element-at-point))) + (unless (org-entry-get (point) "PUBDATE") + (org-set-property "PUBDATE" (format-time-string (cdr org-time-stamp-formats)))) + (let ((id (org-id-get-create))) + (unless (org-entry-get (point) "CUSTOM_ID") + (org-set-property "CUSTOM_ID" (concat "post-" id)))))) + "LEVEL=1" nil 'comment 'archive)) + +(defun my/blog-html-headline (headline contents info) + "Transcode a top-level post HEADLINE for the blog page. +Headings nested inside a post are exported as ordinary HTML." + (if (> (org-export-get-relative-level headline info) 1) + (org-export-data-with-backend headline 'html info) + (let* ((title (org-export-data (org-element-property :title headline) info)) + (anchor (or (org-element-property :CUSTOM_ID headline) + (org-export-get-reference headline info))) + (pubdate (org-element-property :PUBDATE headline)) + (date (and pubdate + (format-time-string "%Y-%m-%d" (org-time-string-to-time pubdate))))) + (format "<h2 id=\"%s\">%s</h2>\n%s%s" + anchor title + (if date (format "<p><em>%s</em></p>\n" date) "") + (or contents ""))))) + +(defun my/blog-publish () + "Export the non-draft posts in blog.org to blog.html and blog.rss in +`my/blog-site-directory'." + (interactive) + (unless my/blog-site-directory + (user-error "Set `my/blog-site-directory' to the site checkout to publish into")) + (require 'ox-rss) + (let* ((org-file (expand-file-name "blog.org" org-directory)) + (site-dir (file-name-as-directory (expand-file-name my/blog-site-directory))) + (html-file (expand-file-name "blog.html" site-dir)) + (rss-file (expand-file-name "blog.rss" site-dir))) + (with-current-buffer (find-file-noselect org-file) + (my/blog-stamp-metadata) + (save-buffer) + (let ((title (or (cadr (assoc "TITLE" (org-collect-keywords '("TITLE")))) "Blog")) + ;; Rendered with the Org buffer current, since `with-temp-file' + ;; below would otherwise make `org-export-as' read from the + ;; (empty) temp buffer it creates for its body instead. + (body (org-export-as 'blog-html nil nil t))) + (with-temp-file html-file + (insert (format "<h1>%s</h1>\n<hr>\n" title)) + (insert body))) + (org-export-to-file 'rss rss-file)) + (message "Blog published to %s and %s" html-file rss-file))) + +;; A minimal HTML backend for the blog page: like ordinary HTML export, +;; except a top-level post's title gets its publish date shown +;; underneath, and there's no wrapping <div>s, ToC, or preamble to +;; strip via #+OPTIONS. Headings nested inside a post still fall +;; through to plain HTML export. A derived backend can only be +;; registered once its parent is, hence waiting for ox-html. +(after! ox-html + (org-export-define-derived-backend 'blog-html 'html + :translate-alist '((headline . my/blog-html-headline)))) + +(map! :leader :desc "Publish blog" "o u" #'my/blog-publish) +;; Blog:1 ends here + +;; [[file:config.org::*Tarot][Tarot:1]] +(defcustom my/tarot-directory "~/.local/share/tarot/" + "Directory containing one subdirectory of card images per deck." + :type 'directory) + +(defun my/tarot--read-deck () + "Prompt for a deck under `my/tarot-directory' and return its directory." + (let ((decks (seq-filter #'file-directory-p + (directory-files my/tarot-directory t "\\`[^.]")))) + (expand-file-name + (completing-read "Deck: " (mapcar #'file-name-nondirectory decks) nil t) + my/tarot-directory))) + +(defun my/tarot--attach-montage (cards) + "Tile CARDS into one image, attach it to the Org heading at point, and link it." + (require 'org-attach) + (let ((output (make-temp-file "tarot-" nil ".png"))) + (unwind-protect + (progn + (my/tarot--montage cards output "-geometry" "x450>+0+0") + (insert "[[" (car (org-attach-attach output nil 'cp)) "]]\n") + (org-display-inline-images)) + (delete-file output)))) + +(defun my/tarot--attach-random (cards n) + "Attach a spread of N cards drawn at random from CARDS." + (unless (<= 1 n (length cards)) + (user-error "Pick between 1 and %d cards" (length cards))) + (my/tarot--attach-montage (seq-take (my/tarot--shuffle cards) n))) + +(defun my/tarot-attach-deck-spread (deck n) + "Attach a random N-card spread from tarot DECK to the Org heading at point. +DECK is a subdirectory name under `my/tarot-directory'." + (my/tarot--attach-random (my/tarot--cards (expand-file-name deck my/tarot-directory)) n)) + +(defun my/tarot-attach-spread () + "Attach a random spread from a tarot deck to the Org heading at point." + (interactive) + (let ((cards (my/tarot--cards (my/tarot--read-deck)))) + (my/tarot--attach-random + cards (read-number (format "Cards (1-%d): " (length cards)) 3)))) + +(defun my/tarot-attach-chosen-spread () + "Attach a hand-picked spread from a tarot deck to the Org heading at point. +Prompts for a deck, then repeatedly prompts for a card to add to the spread; +finish by pressing RET on an empty prompt or by quitting the prompt (`C-g')." + (interactive) + (let* ((deck-dir (my/tarot--read-deck)) + (names (mapcar #'file-name-nondirectory (my/tarot--cards deck-dir))) + (selected nil) + (done nil)) + (while (not done) + (let ((choice (condition-case nil + (completing-read + (format "Card %d (empty to finish): " (1+ (length selected))) + names nil t) + (quit "")))) + (if (string-empty-p choice) + (setq done t) + (push (expand-file-name choice deck-dir) selected)))) + (unless selected + (user-error "No cards selected")) + (my/tarot--attach-montage (nreverse selected)))) + +(map! :map org-mode-map :localleader :desc "Tarot spread" "a t" #'my/tarot-attach-spread) +(map! :map org-mode-map :localleader :desc "Tarot spread (chosen)" "a c" #'my/tarot-attach-chosen-spread) +;; Tarot:1 ends here + +;; [[file:config.org::*Astrology][Astrology:1]] +(defconst my/astro-transit-line-re + (rx bol (* " ") (group (+ digit)) ": trans " + (group (= 7 anything)) " " + (any "(" "[") (group (= 3 alpha)) (any ")" "]") " " + (group (= 3 alpha)) " natal " + (any "(" "[") (group (= 3 alpha)) (any ")" "]") " " + (group (= 10 anything)) " - " + (group (or "pos" "neg")) " " + (group (+ (not (any " ")))) " - power: " (* " ") + (group (+ (any digit ".")))) + "Matches one aspect line of astro-transits' -Tn table.") + +(defun my/astro--transit-row (line) + "Parse a -Tn aspect LINE into a list of Org table cells, or nil if it isn't one." + (when (string-match my/astro-transit-line-re line) + ;; Collect every group before trimming any of them: `string-trim' calls + ;; `string-match' itself, which would clobber LINE's match data mid-loop. + (mapcar #'string-trim + (mapcar (lambda (n) (match-string n line)) '(1 2 3 4 6 5 7 8 9))))) + +(defun my/astro--insert-transit-table (table-file) + "Insert TABLE-FILE, astro-transits' table output, at point as an Org table." + (let (header rows footer) + (with-temp-buffer + (insert-file-contents table-file) + (dolist (line (split-string (buffer-string) "\n")) + (unless (string-empty-p line) + (let ((row (my/astro--transit-row line))) + (cond (row (push row rows)) + (rows (push line footer)) + (t (push line header))))))) + (dolist (line (nreverse header)) (insert line "\n")) + (insert "\n") + (let ((start (point))) + (insert "| # | Transiting | Sign | Aspect | Natal | Sign | Dir | Orb | Power |\n") + (insert "|-\n") + (dolist (row (nreverse rows)) + (insert "| " (string-join row " | ") " |\n")) + (save-excursion (goto-char start) (org-table-align))) + (insert "\n#+begin_example\n") + (dolist (line (nreverse footer)) (insert line "\n")) + (insert "#+end_example\n"))) + +(defun my/astro-attach-transits () + "Attach the current biwheel transit chart and table to the Org heading at point." + (interactive) + (require 'org-attach) + (let (table image) + (with-temp-buffer + (unless (zerop (call-process (my/program "astro-transits") nil t nil "--emacs")) + (user-error "astro-transits failed: %s" (buffer-string))) + (goto-char (point-min)) + (while (re-search-forward "^\\(TABLE\\|IMAGE\\)=\\(.*\\)$" nil t) + (if (string= (match-string 1) "TABLE") + (setq table (match-string 2)) + (setq image (match-string 2))))) + (unless (and table image (file-exists-p table) (file-exists-p image)) + (user-error "astro-transits did not report chart files")) + (let ((link (car (org-attach-attach image nil 'mv)))) + (insert "[[" link "]]\n\n") + (my/astro--insert-transit-table table) + (delete-file table) + (org-display-inline-images)))) + +(map! :map org-mode-map :localleader :desc "Astrology transits" "a z" #'my/astro-attach-transits) +;; Astrology:1 ends here + +;; [[file:config.org::*Daily divination][Daily divination:1]] +(defun my/divination-capture-attach () + "Attach a one-card Thoth tarot draw and the current astrology transits +to the entry just placed by the \"Daily divination\" capture template." + (my/tarot-attach-deck-spread "thoth" 1) + (my/astro-attach-transits)) + +(after! org-capture + (add-to-list 'org-capture-templates + '("i" "Daily divination" entry + (file+olp+datetree +org-capture-journal-file) + "* %U Daily divination :divination:\n%?" + :prepend t + :hook my/divination-capture-attach))) +;; Daily divination:1 ends here + +;; [[file:config.org::*Resume][Resume:1]] +(remove-hook 'org-mode-hook #'resumel-mode--maybe-turn-on) + +(defun my/resumel-maybe-turn-on-h () + "Turn on `resumel-mode' in a buffer with a #+RESUMEL_TEMPLATE: keyword. +Loads resumel only when there is one." + (when (save-excursion + (save-match-data + (goto-char (point-min)) + (re-search-forward "^[ \t]*#\\+RESUMEL_TEMPLATE:" nil t))) + (require 'resumel-mode) + (resumel-mode 1))) + +(add-hook 'org-mode-hook #'my/resumel-maybe-turn-on-h) +;; Resume:1 ends here + +;; [[file:config.org::*Calibre][Calibre:1]] +(use-package! calibredb + :commands calibredb + :init + (map! :leader :desc "Calibre library" "o c" #'calibredb) + :config + (setq calibredb-root-dir "~/Documents/calibre" + calibredb-db-dir (expand-file-name "metadata.db" calibredb-root-dir) + calibredb-library-alist `((,calibredb-root-dir)) + calibredb-format-nerd-icons t + calibredb-comment-width 100 + calibredb-title-width 60 + calibredb-id-width 4 + calibredb-size-show t + calibredb-download-dir "~/Downloads" + calibredb-fetch-metadata-source-list '("Google" "Amazon.com" "Douban Books") + calibredb-list-view-image-max-width 600 + calibredb-list-view-image-max-height 600 + calibredb-detail-view-image-max-width 300 + calibredb-detail-view-image-max-height 300) + + (map! :map calibredb-search-mode-map + :n "q" #'calibredb-search-quit + :n "n" #'calibredb-virtual-library-next + :n "N" #'calibredb-library-next + :n "p" #'calibredb-virtual-library-previous + :n "P" #'calibredb-library-previous + :n "l" #'calibredb-virtual-library-list + :n "o" #'calibredb-find-file + :n "O" #'calibredb-find-file-other-frame + :n "V" #'calibredb-open-file-with-default-tool + :n "v" #'calibredb-view + :n "d" #'calibredb-remove + :n "D" #'calibredb-remove-marked-items + :n "m" #'calibredb-mark-and-forward + :n "s" #'calibredb-set-metadata-dispatch + :n "e" #'calibredb-export-dispatch + :n "a" #'calibredb-add + :n "." #'calibredb-open-dired + :n "," #'calibredb-quick-look + :n "y" #'calibredb-yank-dispatch + :n "u" #'calibredb-unmark-and-forward + :n "DEL" #'calibredb-unmark-and-backward + :n "?" #'calibredb-dispatch + :n "/" #'calibredb-search-live-filter + :n "j" #'calibredb-next-entry + :n "k" #'calibredb-previous-entry + :n "M-f" #'calibredb-toggle-favorite-at-point + :n "M-x" #'calibredb-toggle-archive-at-point + :n "M-h" #'calibredb-toggle-highlight-at-point + :n "M-n" #'calibredb-show-next-entry + :n "M-p" #'calibredb-show-previous-entry + :n "R" #'calibredb-search-clear-filter + :n "r" #'calibredb-search-refresh-and-clear-filter + :n "<backtab>" #'calibredb-toggle-view + :n "<tab>" #'calibredb-toggle-view-at-point + :n "TAB" #'calibredb-toggle-view-at-point + :n "RET" #'calibredb-find-file) + + (map! :map calibredb-show-mode-map + :nie "q" #'calibredb-entry-quit + :nie "?" #'calibredb-entry-dispatch + :nie "RET" #'calibredb-search-ret)) + +(add-hook! '(calibredb-search-mode-hook calibredb-show-mode-hook) + #'doom-mark-buffer-as-real-h) +;; Calibre:1 ends here + +;; [[file:config.org::*EPUBs][EPUBs:1]] +(defun my/no-large-file-prompt-for-epub-a (fn size op-type filename &rest args) + "Open EPUBs without asking, however big they are." + (unless (string-match-p "\\.epub\\'" filename) + (apply fn size op-type filename args))) +(advice-add #'abort-if-file-too-large :around #'my/no-large-file-prompt-for-epub-a) + +(use-package! shrface + :defer t + :config + (shrface-basic) + (shrface-trial) + (setq shrface-href-versatile t)) + +(use-package! nov + :mode ("\\.epub\\'" . nov-mode) + :init + (add-hook 'nov-mode-hook #'shrface-mode) + :config + (require 'shrface) + (setq nov-text-width 120 + nov-render-html-function #'my/nov-render-html) + + ;; nov signals an error when an EPUB's declared unique identifier cannot be + ;; resolved, which makes the book unopenable. Return nil instead; such books + ;; then share one save-place slot, which is a fair trade. + (defun my/nov-tolerant-unique-identifier-a (orig content) + (condition-case nil + (funcall orig content) + (error nil))) + (advice-add #'nov-content-unique-identifier + :around #'my/nov-tolerant-unique-identifier-a) + + (advice-add #'shr-fill-line :around #'my/nov-mark-soft-breaks-a) + (add-hook 'nov-pre-html-render-hook #'my/nov-mixed-pitch-h) + (add-hook 'nov-mode-hook #'my/nov-setup-h 90) + (add-hook 'after-setting-font-hook #'my/nov-font-changed-h) + + (map! :map nov-mode-map + :n "+" #'text-scale-increase + :n "-" #'text-scale-decrease + :n "=" #'my/nov-reset-layout + :n ">" #'my/nov-widen + :n "<" #'my/nov-narrow)) + +(after! eww + (require 'shrface) + (add-hook 'eww-after-render-hook #'shrface-mode)) +;; EPUBs:1 ends here + +;; [[file:config.org::*Justified text][Justified text:1]] +(defun my/nov-mark-soft-breaks-a (fill-line &rest args) + "Tag the newlines `shr-fill-line' inserts to wrap a line, in nov buffers. +Those lines are the ones to justify. A paragraph's last line, and lines +ended by <br> or the end of a block, keep untagged newlines." + (if (not (derived-mode-p 'nov-mode)) + (apply fill-line args) + (let ((start (point))) + (apply fill-line args) + (save-excursion + (let ((end (point))) + (goto-char start) + (while (search-forward "\n" end t) + (put-text-property (1- (point)) (point) 'my/nov-soft-break t))))))) + +(defun my/nov--pixel-width (start end) + "Width in pixels of the text from START to END, all on one line." + ;; Without an X limit, the measurement stops at the window's edge. + (car (window-text-pixel-size nil start end t))) + +(defun my/nov--justify-line (bol eol width space-widths) + "Stretch or squeeze the spaces from BOL to EOL to make the line WIDTH pixels. +SPACE-WIDTHS caches the natural width of a space in each face." + (let ((text-start (save-excursion + ;; Past the indentation and any list bullet. + (goto-char (+ bol (or (get-text-property bol 'shr-prefix-length) 0))) + (skip-chars-forward " " eol) + (point))) + spaces) + (unless (text-property-not-all text-start eol 'shrface-headline nil) + (save-excursion + (goto-char text-start) + (while (search-forward " " eol t) + (unless (get-text-property (1- (point)) 'display) + (push (1- (point)) spaces)))) + (when spaces + (let* ((space-width (lambda (pos) + (let ((face (get-text-property pos 'face))) + (or (gethash face space-widths) + (puthash face (my/nov--pixel-width pos (1+ pos)) + space-widths))))) + (count (length spaces)) + (extra (- width (my/nov--pixel-width bol eol)))) + ;; Leave the line ragged if the gaps would swallow it, or would + ;; have to shrink below a quarter of a space. + (when (and (/= extra 0) + (< extra (/ width 2)) + (> extra (- (/ (* 3 count (funcall space-width (car spaces))) 4)))) + (let ((each (floor extra count)) + (leftover (mod extra count))) + (dolist (pos (nreverse spaces)) + (put-text-property + pos (1+ pos) 'display + `(space :width (,(+ (funcall space-width pos) each + (if (> leftover 0) 1 0))))) + (setq leftover (1- leftover)))))))))) + +(defun my/nov--justify (width) + "Justify every line in the buffer that shr wrapped, to WIDTH pixels." + (let ((space-widths (make-hash-table :test #'equal)) + (pos (point-min))) + (while (setq pos (text-property-any pos (point-max) 'my/nov-soft-break t)) + (my/nov--justify-line (save-excursion (goto-char pos) (line-beginning-position)) + pos width space-widths) + (setq pos (1+ pos))))) + +(defvar-local my/nov--rendered-layout nil + "What the current rendering was laid out for, from `my/nov--layout'.") + +(defun my/nov--window () + "The window the current nov buffer is laid out for, or nil if it isn't shown." + (if (eq (window-buffer) (current-buffer)) + (selected-window) + (get-buffer-window nil t))) + +(defun my/nov--call-in-window (fn) + "Call FN with the window the current buffer is laid out for, selected. +nov renders a book before displaying it, and text can only be measured +in a window, so then borrow the selected one, as shr itself does." + (if-let* ((window (my/nov--window))) + (with-selected-window window (funcall fn window)) + (save-window-excursion + (set-window-dedicated-p nil nil) + (set-window-buffer nil (current-buffer)) + (funcall fn (selected-window))))) + +(defun my/nov--columns (window) + "Columns to fill text to in WINDOW: `nov-text-width', unless WINDOW is narrower." + (let* ((fringes (window-fringes window)) + (room (- (window-body-width window) + ;; Without fringes, the last column goes to the + ;; continuation glyph. + (if (and (zerop (car fringes)) (zerop (cadr fringes))) 2 1)))) + (if (integerp nov-text-width) (min nov-text-width room) room))) + +(defun my/nov--layout (window) + "Everything a rendering for WINDOW depends on, besides the book itself." + (list (my/nov--columns window) + (frame-char-width (window-frame window)) + ;; Font size and mixed-pitch both live here. Copied, because + ;; `face-remap-add-relative' edits entries in place. + (copy-tree face-remapping-alist))) + +(defun my/nov-render-html () + "Like `nov-render-html', but filled to fit the window, then justified." + (my/nov--call-in-window + (lambda (window) + (let* ((columns (my/nov--columns window)) + (em (string-pixel-width (propertize "M" 'face 'shr-text) (current-buffer)))) + (let ((nov-text-width (- columns (ceiling em (frame-char-width)))) + ;; Measure every line whole, even one wider than the window. + (truncate-lines t)) + (nov-render-html) + (my/nov--justify (* columns (frame-char-width)))) + (setq my/nov--rendered-layout (my/nov--layout window)))))) +;; Justified text:1 ends here + +;; [[file:config.org::*Keeping the layout current][Keeping the layout current:1]] +(defun my/nov--text-offset (pos) + "Number of non-whitespace characters before POS." + (save-excursion + (goto-char (point-min)) + (let ((count 0)) + (while (< (point) pos) + (skip-chars-forward " \t\n\r\f" pos) + (setq count (+ count (skip-chars-forward "^ \t\n\r\f" pos)))) + count))) + +(defun my/nov--text-position (count) + "Position of the non-whitespace character that COUNT others precede. +The inverse of `my/nov--text-offset'." + (save-excursion + (goto-char (point-min)) + (while (and (> count 0) (not (eobp))) + (skip-chars-forward " \t\n\r\f") + (let ((moved (skip-chars-forward "^ \t\n\r\f" (min (point-max) (+ (point) count))))) + (if (zerop moved) + (goto-char (point-max)) + (setq count (- count moved))))) + (skip-chars-forward " \t\n\r\f") + (point))) + +(defun my/nov-rerender () + "Render the current document again, keeping the same text in view." + (let* ((window (my/nov--window)) + (start (and window (my/nov--text-offset (window-start window)))) + (pt (my/nov--text-offset (point)))) + (nov-render-document) + (goto-char (my/nov--text-position pt)) + (when window + (set-window-start window (save-excursion + (goto-char (my/nov--text-position start)) + (line-beginning-position))) + (set-window-point window (point))))) + +(defvar-local my/nov--rerender-timer nil) + +(defun my/nov--rerender-if-stale (buffer) + (when (buffer-live-p buffer) + (with-current-buffer buffer + (setq my/nov--rerender-timer nil) + (when-let* ((window (my/nov--window))) + (with-selected-window window + (unless (equal my/nov--rendered-layout (my/nov--layout window)) + (my/nov-rerender))))))) + +(defun my/nov--schedule-rerender () + "Re-render the current buffer once a burst of changes has settled." + (when (timerp my/nov--rerender-timer) + (cancel-timer my/nov--rerender-timer)) + (setq my/nov--rerender-timer + (run-with-timer 0.2 nil #'my/nov--rerender-if-stale (current-buffer)))) + +(defun my/nov--window-size-change-h (window) + (with-current-buffer (window-buffer window) + (unless (equal my/nov--rendered-layout (my/nov--layout window)) + (my/nov--schedule-rerender)))) + +(defun my/nov-mixed-pitch-h () + "Turn on mixed-pitch before nov's first rendering, not after it." + (unless (bound-and-true-p mixed-pitch-mode) + (mixed-pitch-mode 1))) + +(defun my/nov-font-changed-h () + "Lay nov buffers out again after the frame's font changes size." + (dolist (buffer (buffer-list)) + (with-current-buffer buffer + (when (derived-mode-p 'nov-mode) + (my/nov--fit-zen-width) + (my/nov--schedule-rerender))))) +;; Keeping the layout current:1 ends here + +;; [[file:config.org::*Zen, font size and width][Zen, font size and width:1]] +(defun my/nov--fit-zen-width () + "Size zen's writing area to hold `nov-text-width' columns." + (when (integerp nov-text-width) + (setq-local writeroom-width + (ceiling (+ (* nov-text-width (frame-char-width)) + (/ (frame-char-width) 2)) + (float (window-font-width)))) + (when (bound-and-true-p writeroom-mode) + (setq-local visual-fill-column-width writeroom-width) + (when-let* ((window (my/nov--window))) + (with-selected-window window + (visual-fill-column-adjust)))))) + +(defun my/nov--text-scale-h () + (my/nov--fit-zen-width) + (my/nov--schedule-rerender)) + +(defun my/nov-setup-h () + "Keep a nov buffer's layout matched to its window, font size and zen width." + (setq-local +zen-text-scale 0) + (my/nov--fit-zen-width) + (add-hook 'text-scale-mode-hook #'my/nov--text-scale-h nil t) + (add-hook 'window-size-change-functions #'my/nov--window-size-change-h nil t) + ;; nov rendered the book before running this hook, so anything earlier + ;; in it that remapped faces has made that rendering stale. + (my/nov--call-in-window + (lambda (window) + (unless (equal my/nov--rendered-layout (my/nov--layout window)) + (my/nov-rerender))))) + +(defun my/nov--set-width (columns) + (setq-local nov-text-width (max 20 columns)) + (my/nov--fit-zen-width) + (my/nov--schedule-rerender) + (message "Text width: %d columns" nov-text-width)) + +(defun my/nov-widen (&optional n) + "Widen the text by 5 columns, or N times that." + (interactive "p") + (my/nov--set-width (+ nov-text-width (* 5 n)))) + +(defun my/nov-narrow (&optional n) + "Narrow the text by 5 columns, or N times that." + (interactive "p") + (my/nov-widen (- n))) + +(defun my/nov-reset-layout () + "Put this book's font size and text width back to the defaults." + (interactive) + (kill-local-variable 'nov-text-width) + (text-scale-set 0) + (my/nov--fit-zen-width) + (my/nov--schedule-rerender)) +;; Zen, font size and width:1 ends here + +;; [[file:config.org::*Mixed pitch][Mixed pitch:1]] +(use-package! mixed-pitch + :hook ((org-mode eww-mode) . mixed-pitch-mode)) + +(setq +zen-mixed-pitch-modes '(adoc-mode rst-mode markdown-mode)) + +(after! visual-fill-column + (setq visual-fill-column-adjust-for-text-scale nil)) +;; Mixed pitch:1 ends here + +;; [[file:config.org::*Mixed pitch][Mixed pitch:2]] +(add-hook 'mixed-pitch-mode-hook + (defun my/writeroom-scale-width-for-mixed-pitch-h () + (when (and mixed-pitch-mode (not (derived-mode-p 'nov-mode))) + ;; mixed-pitch-mode turns on immediately for every org buffer, + ;; but writeroom-mode (which defines `writeroom-width') is + ;; normally only loaded lazily, on first use of zen mode. On + ;; a fresh daemon, that leaves the variable unbound here, + ;; which throws mid-hook and silently skips every mode-hook + ;; function still queued after this one -- including + ;; `org-modern-mode', which is most of org's highlighting. + (require 'writeroom-mode) + (let ((scaled (round (* (default-value 'writeroom-width) + (/ (float (window-font-width nil 'variable-pitch)) + (window-font-width nil 'default)))))) + (setq-local writeroom-width scaled) + (when (bound-and-true-p visual-fill-column-mode) + (setq-local visual-fill-column-width scaled)))))) +;; Mixed pitch:2 ends here + +;; [[file:config.org::*Dired][Dired:1]] +(after! dired-x + (setq dired-omit-files (concat dired-omit-files "\\|^\\..+\\'"))) +;; Dired:1 ends here + +;; [[file:config.org::*Reusing Dirvish's window][Reusing Dirvish's window:1]] +(defmacro my/dirvish-reusing-window (&rest body) + "Run BODY with the selected window's dedication temporarily lifted. +A plain passthrough if Dirvish (and its `dirvish-save-dedication') isn't +loaded." + (declare (indent 0) (debug (&rest form))) + `(if (fboundp 'dirvish-save-dedication) + (dirvish-save-dedication ,@body) + ,@body)) +;; Reusing Dirvish's window:1 ends here + +;; [[file:config.org::*Reading the lists][Reading the lists:1]] +(defun my/docsync--xdg-directory (variable fallback) + "Return docsync's directory under XDG VARIABLE, worked out as the script does." + (let ((base (getenv variable))) + (expand-file-name "docsync" (if (member base '(nil "")) fallback base)))) + +(defun my/docsync--config-file (name) + "Return the file NAME in docsync's config directory." + (expand-file-name name (my/docsync--xdg-directory "XDG_CONFIG_HOME" "~/.config"))) + +(defun my/docsync--state-file (name) + "Return the file NAME in docsync's state directory." + (expand-file-name name (my/docsync--xdg-directory "XDG_STATE_HOME" "~/.local/state"))) + +(defun my/docsync--list-lines (file) + "Return FILE's entries without comments, blank lines or surrounding space." + (when (file-readable-p file) + (with-temp-buffer + (insert-file-contents file) + (seq-remove (lambda (line) (or (string-empty-p line) (string-prefix-p "#" line))) + (mapcar #'string-trim (split-string (buffer-string) "\n")))))) + +(defun my/docsync--include-path (entry) + "Return include list ENTRY as the absolute path the script backs up." + (let ((path (string-remove-prefix "~/" entry))) + (directory-file-name + (if (file-name-absolute-p path) path (expand-file-name path "~"))))) + +(defun my/docsync--glob-regexp (glob) + "Translate restic exclude pattern GLOB into a regexp body. +As in restic, * and ? stay within one path component and **/ spans any +number of them. Character classes aren't translated; none of the lists +use them." + (let ((i 0) (n (length glob)) (parts nil)) + (while (< i n) + (let ((c (aref glob i))) + (cond ((and (eq c ?*) (< (1+ i) n) (eq (aref glob (1+ i)) ?*)) + (if (and (< (+ i 2) n) (eq (aref glob (+ i 2)) ?/)) + (progn (push "\\(?:.*/\\)?" parts) (setq i (+ i 3))) + (push ".*" parts) (setq i (+ i 2)))) + ((eq c ?*) (push "[^/]*" parts) (setq i (1+ i))) + ((eq c ??) (push "[^/]" parts) (setq i (1+ i))) + (t (push (regexp-quote (string c)) parts) (setq i (1+ i)))))) + (apply #'concat (nreverse parts)))) + +(defun my/docsync--exclude-matcher (entry) + "Return (ENTRY . REGEXP) for exclude list ENTRY. +REGEXP matches what restic leaves out for it: the path itself and +everything under it, anchored at the root when ENTRY is absolute (or +starts with ~/), and at any directory boundary otherwise." + (let* ((home-relative (string-prefix-p "~/" entry)) + (pattern (if home-relative + (concat (directory-file-name (expand-file-name "~")) "/" + (substring entry 2)) + entry)) + (anchored (string-prefix-p "/" pattern))) + (cons entry + (concat (if anchored "\\`" "/") + (my/docsync--glob-regexp (if anchored pattern + (string-remove-prefix "/" pattern))) + "\\(?:/.*\\)?\\'")))) + +(defvar my/docsync--lists-cache nil + "(INCLUDE-MTIME EXCLUDE-MTIME INCLUDES EXCLUDES) as last read.") + +(defun my/docsync--lists () + "Return (INCLUDES EXCLUDES), re-reading the list files when they change. +INCLUDES are absolute paths, EXCLUDES are (ENTRY . REGEXP) conses." + (let* ((include (my/docsync--config-file "include")) + (exclude (my/docsync--config-file "exclude")) + (include-mtime (file-attribute-modification-time (file-attributes include))) + (exclude-mtime (file-attribute-modification-time (file-attributes exclude)))) + (unless (and my/docsync--lists-cache + (equal include-mtime (nth 0 my/docsync--lists-cache)) + (equal exclude-mtime (nth 1 my/docsync--lists-cache))) + (setq my/docsync--lists-cache + (list include-mtime exclude-mtime + (mapcar #'my/docsync--include-path (my/docsync--list-lines include)) + (mapcar #'my/docsync--exclude-matcher (my/docsync--list-lines exclude))))) + (cddr my/docsync--lists-cache))) + +(defun my/docsync--excluded-by (file) + "Return the exclude list entry that leaves FILE out of backups, if any." + (car (seq-find (lambda (matcher) (string-match-p (cdr matcher) file)) + (cadr (my/docsync--lists))))) + +(defun my/docsync--covering (file) + "Return the listed path FILE is backed up as part of, if any." + (seq-find (lambda (path) (string-prefix-p (file-name-as-directory path) file)) + (car (my/docsync--lists)))) + +(defun my/docsync-state (file) + "Return how docsync treats FILE. +`listed' when it's in the include list itself, `inherited' when it's +inside a listed directory, `excluded' when it's inside one but an exclude +pattern leaves it out, `partial' for a directory with listed paths inside, +and nil when backups don't touch it." + (let ((file (directory-file-name (expand-file-name file))) + (includes (car (my/docsync--lists)))) + (cond ((member file includes) 'listed) + ((my/docsync--covering file) + (if (my/docsync--excluded-by file) 'excluded 'inherited)) + ((seq-some (lambda (path) (string-prefix-p (file-name-as-directory file) path)) + includes) + 'partial)))) +;; Reading the lists:1 ends here + +;; [[file:config.org::*Changing what's backed up][Changing what's backed up:1]] +(defun my/docsync--list-entry (file &optional tilde) + "Return FILE as the lists write it: relative to ~ when it's under it. +With TILDE the relative form starts with ~/, which the exclude list needs +to tell a path from a pattern that matches anywhere." + (let ((file (directory-file-name (expand-file-name file))) + (home (file-name-as-directory (expand-file-name "~")))) + (if (string-prefix-p home file) + (concat (if tilde "~/" "") (substring file (length home))) + file))) + +(defun my/docsync--edit-list (name add remove) + "Append the entries ADD to list NAME and drop the lines in REMOVE. +Lines are compared after trimming, and comments are kept." + (let ((file (my/docsync--config-file name))) + (with-temp-buffer + (when (file-exists-p file) (insert-file-contents file)) + (goto-char (point-min)) + (while (not (eobp)) + (if (member (string-trim (buffer-substring (line-beginning-position) + (line-end-position))) + remove) + (delete-region (line-beginning-position) + (min (point-max) (1+ (line-end-position)))) + (forward-line 1))) + (goto-char (point-max)) + (unless (or (bobp) (eq (char-before) ?\n)) (insert "\n")) + (dolist (entry add) (insert entry "\n")) + (make-directory (file-name-directory file) t) + (write-region nil nil file nil 'silent))) + (setq my/docsync--lists-cache nil) + (my/docsync--redraw) + (my/docsync--schedule-scan)) + +(defun my/docsync--redraw () + "Redraw the visible Dired windows and mode lines, whose markers may be stale. +Dirvish renders its attributes as a window is redisplayed, so marking the +windows for redisplay is enough; nothing needs reverting." + (dolist (window (window-list-1 nil 'nomini 'visible)) + (when (with-current-buffer (window-buffer window) (derived-mode-p 'dired-mode)) + (force-window-update window))) + (force-mode-line-update t)) + +(defun my/docsync--marked-files () + "Return the marked files in this Dired buffer, or the one at point." + (or (dired-get-marked-files nil nil nil nil t) + (user-error "No file on this line"))) + +(defun my/docsync-toggle (files) + "Start or stop backing up FILES, the marked files or the one at point. +Files that are already listed come off the include list, and the rest go +on it. A file only backed up as part of a listed directory stays put: +leaving it out is `my/docsync-toggle-exclude's job." + (interactive (list (my/docsync--marked-files)) dired-mode) + (let (add remove) + (dolist (file files) + (pcase (my/docsync-state file) + ('listed (push (directory-file-name (expand-file-name file)) remove)) + ((or 'inherited 'excluded) + (user-error "%s is backed up as part of %s; use exclude to leave it out" + (abbreviate-file-name file) + (abbreviate-file-name (my/docsync--covering file)))) + (_ (push file add)))) + (my/docsync--edit-list + "include" + (mapcar #'my/docsync--list-entry (nreverse add)) + ;; An entry can be written several ways (~/Documents, Documents, + ;; /home/.../Documents), so drop whichever lines resolve to the file. + (seq-filter (lambda (line) (member (my/docsync--include-path line) remove)) + (my/docsync--list-lines (my/docsync--config-file "include")))) + (message "docsync: %s" + (string-join + (delq nil (list (and add (format "backing up %d more" (length add))) + (and remove (format "no longer backing up %d" (length remove))))) + ", ")))) + +(defun my/docsync-toggle-exclude (files) + "Leave FILES out of backups, or put them back if they're left out. +FILES are the marked files or the one at point, and must be inside a +listed directory. Only exclusions this command wrote (one path per line) +can be taken back here; one from a wider pattern has to be edited in the +list itself." + (interactive (list (my/docsync--marked-files)) dired-mode) + (let (add remove) + (dolist (file files) + (let ((entry (my/docsync--list-entry file t))) + (pcase (my/docsync-state file) + ('inherited (push entry add)) + ('excluded + (let ((by (my/docsync--excluded-by + (directory-file-name (expand-file-name file))))) + (unless (equal by entry) + (user-error "%s is left out by the pattern %s in the exclude list" + (abbreviate-file-name file) by)) + (push entry remove))) + (_ (user-error "%s isn't inside a backed-up directory" + (abbreviate-file-name file)))))) + (my/docsync--edit-list "exclude" (nreverse add) remove) + (message "docsync: %s" + (string-join + (delq nil (list (and add (format "leaving out %d" (length add))) + (and remove (format "backing up %d again" (length remove))))) + ", ")))) + +(defun my/docsync-edit-include () + "Open docsync's include list." + (interactive) + (find-file (my/docsync--config-file "include"))) + +(defun my/docsync-edit-exclude () + "Open docsync's exclude list." + (interactive) + (find-file (my/docsync--config-file "exclude"))) + +(defun my/docsync-list () + "Show every listed path in one Dired buffer." + (interactive) + (let ((paths (seq-filter #'file-exists-p (car (my/docsync--lists))))) + (unless paths (user-error "docsync: nothing is listed")) + (my/dirvish-reusing-window + (dired (cons "/" paths))) + (rename-buffer "*docsync paths*" t))) +;; Changing what's backed up:1 ends here + +;; [[file:config.org::*What's changed since the last snapshot][What's changed since the last snapshot:1]] +(autoload 'parse-iso8601-time-string "parse-time") + +(defvar my/docsync--manifest-cache nil + "(MTIME SNAPSHOT TABLE) for the manifest as last read.") + +(defun my/docsync--unescape-tsv (field) + "Undo jq's @tsv escaping in FIELD." + (if (not (string-search "\\" field)) + field + (replace-regexp-in-string + "\\\\\\(.\\)" + (lambda (match) + (pcase (match-string 1 match) ("t" "\t") ("n" "\n") ("r" "\r") (char char))) + field t t))) + +(defun my/docsync--manifest () + "Return (SNAPSHOT TABLE) for the latest snapshot, re-read when it changes. +SNAPSHOT is its ID, nil before the first backup. TABLE maps each path +in it to (TYPE SIZE MTIME), TYPE a symbol like `file' or `dir' and MTIME +a float." + (let* ((file (my/docsync--state-file "manifest")) + (mtime (file-attribute-modification-time (file-attributes file)))) + (unless (and my/docsync--manifest-cache + (equal mtime (car my/docsync--manifest-cache))) + (let ((table (make-hash-table :test #'equal :size 4096)) + snapshot) + (when mtime + (with-temp-buffer + (insert-file-contents file) + (goto-char (point-min)) + (when (looking-at "# snapshot \\([0-9a-f]+\\)") + (setq snapshot (match-string 1))) + (while (re-search-forward + "^\\([a-z]+\\)\t\\([0-9]+\\)\t\\([^\t\n]+\\)\t\\(.*\\)$" nil t) + (puthash (my/docsync--unescape-tsv (match-string 4)) + (list (intern (match-string 1)) + (string-to-number (match-string 2)) + ;; t keeps the nanoseconds, which restic records + (float-time (parse-iso8601-time-string (match-string 3) t))) + table)))) + (setq my/docsync--manifest-cache (list mtime snapshot table)))) + (cdr my/docsync--manifest-cache))) + +(defvar my/docsync--changes nil + "Paths that differ from the latest snapshot, nil until first compared. +A hash table from path to `changed', `new' or `deleted'.") + +(defvar my/docsync--changed-dirs nil + "Hash table of every directory above a path in `my/docsync--changes'.") + +(defvar my/docsync--last-compared nil + "(LISTING MANIFEST LISTS) from the last comparison, to skip repeating it.") + +(defvar my/docsync--scanned-manifest nil + "The manifest's modification time when the last comparison started.") + +(defvar my/docsync--scan-process nil + "The running `find' behind `my/docsync-scan', if any.") + +(defvar my/docsync--scan-again nil + "Non-nil when a comparison was asked for while one was running.") + +(defvar my/docsync--scan-timer nil + "Pending timer from `my/docsync--schedule-scan'.") + +(defun my/docsync--schedule-scan (&optional delay) + "Compare with the latest snapshot in DELAY seconds (default 1). +Calls arriving while one is pending fold into it." + (unless my/docsync--scan-timer + (setq my/docsync--scan-timer + (run-at-time (or delay 1) nil + (lambda () + (setq my/docsync--scan-timer nil) + (my/docsync-scan)))))) + +(defun my/docsync--check-manifest () + "Schedule a comparison if a backup has rewritten the manifest since the last." + (unless (equal (file-attribute-modification-time + (file-attributes (my/docsync--state-file "manifest"))) + my/docsync--scanned-manifest) + (my/docsync--schedule-scan 0))) + +(defun my/docsync--prune-paths (excludes) + "Return the absolute paths among EXCLUDES that need no glob matching. +`find' can skip these whole, which keeps it out of large left-out trees." + (let ((home (file-name-as-directory (expand-file-name "~")))) + (seq-keep (lambda (matcher) + (let ((entry (car matcher))) + (when (string-prefix-p "~/" entry) + (setq entry (concat home (substring entry 2)))) + (and (string-prefix-p "/" entry) + (not (string-match-p "[*?[]" entry)) + (directory-file-name entry)))) + excludes))) + +(defun my/docsync-scan () + "Compare the backed-up files with the latest snapshot, in the background. +`find' lists them with their sizes and modification times, which is all +local and takes well under a second; the comparison with the manifest +happens when it's done, and redraws the markers." + (interactive) + (if (process-live-p my/docsync--scan-process) + (setq my/docsync--scan-again t) + (pcase-let* ((`(,includes ,excludes) (my/docsync--lists)) + (roots (seq-filter #'file-exists-p includes)) + (prunes (my/docsync--prune-paths excludes)) + (output (generate-new-buffer " *docsync-scan*")) + (errors (generate-new-buffer " *docsync-scan-errors*"))) + (setq my/docsync--scanned-manifest + (file-attribute-modification-time + (file-attributes (my/docsync--state-file "manifest")))) + (if (null roots) + (progn (kill-buffer output) (kill-buffer errors) + (my/docsync--compare nil nil)) + (setq my/docsync--scan-process + (make-process + :name "docsync-scan" :buffer output :stderr errors + :connection-type 'pipe :noquery t + :command `("find" ,@roots + ,@(when prunes + `("(" ,@(cdr (mapcan (lambda (path) (list "-o" "-path" path)) + prunes)) + ")" "-prune" "-o")) + "-printf" "%y\t%s\t%T@\t%p\\0") + :sentinel + (lambda (process _event) + (unless (process-live-p process) + (when (buffer-live-p errors) (kill-buffer errors)) + (let ((listing (with-current-buffer output (buffer-string)))) + (kill-buffer output) + (my/docsync--compare roots listing)) + (when my/docsync--scan-again + (setq my/docsync--scan-again nil) + (my/docsync--schedule-scan 0)))))))))) + +(defun my/docsync--compare (roots listing) + "Compare `find' LISTING of ROOTS with the manifest and redraw the markers. +A file is changed when its size or modification time differs from the +snapshot's, which is how restic itself decides what to read again, new +when the snapshot lacks it, and deleted when only the snapshot has it. +Most rescans find nothing new, so an identical listing against the same +manifest and lists returns at once." + (let ((key (list listing (my/docsync--manifest) (my/docsync--lists)))) + (unless (and my/docsync--changes + (equal (car key) (car my/docsync--last-compared)) + (eq (nth 1 key) (nth 1 my/docsync--last-compared)) + (eq (nth 2 key) (nth 2 my/docsync--last-compared))) + (setq my/docsync--last-compared key) + (my/docsync--compare-1 roots listing)))) + +(defun my/docsync--compare-1 (roots listing) + "Do the comparison `my/docsync--compare' describes." + (let ((manifest (cadr (my/docsync--manifest))) + (changes (make-hash-table :test #'equal)) + (dirs (make-hash-table :test #'equal)) + (seen (make-hash-table :test #'equal :size 4096))) + (dolist (record (and listing (split-string listing "\0" t))) + (let* ((fields (split-string record "\t")) + (type (nth 0 fields)) + (size (string-to-number (nth 1 fields))) + (mtime (string-to-number (nth 2 fields))) + (path (string-join (nthcdr 3 fields) "\t")) + (was (gethash path manifest))) + (unless (or (member type '("s" "p")) (my/docsync--excluded-by path)) + (puthash path t seen) + (cond ((null was) (puthash path 'new changes)) + ((equal type "d") + (unless (eq (car was) 'dir) (puthash path 'changed changes))) + ((not (and (eq (car was) (if (equal type "l") 'symlink 'file)) + (or (equal type "l") (= size (nth 1 was))) + (< (abs (- mtime (nth 2 was))) 1e-5))) + (puthash path 'changed changes)))))) + (maphash (lambda (path _) + (when (and (not (gethash path seen)) + (seq-some (lambda (root) + (or (equal path root) + (string-prefix-p (file-name-as-directory root) path))) + roots) + (not (my/docsync--excluded-by path))) + (puthash path 'deleted changes))) + manifest) + (maphash (lambda (path _) + (let ((dir path)) + (while (not (equal dir "/")) + (setq dir (directory-file-name (file-name-directory dir))) + (if (gethash dir dirs) (setq dir "/") (puthash dir t dirs))))) + changes) + (setq my/docsync--changes changes + my/docsync--changed-dirs dirs) + (my/docsync--redraw))) + +(defun my/docsync-file-status (file) + "Return FILE's backup status, the key for its marker. +`saved' when it's backed up and the same as in the latest snapshot, +`changed' when it differs (for a directory, when anything inside does), +`new' when the latest snapshot doesn't have it yet, `partial-saved' or +`partial-changed' for a directory with backed-up paths inside it, +`excluded' when an exclude pattern leaves it out, and nil when backups +don't touch it. `unknown' until the first comparison is done." + (let* ((file (directory-file-name (expand-file-name file))) + (state (my/docsync-state file))) + (cond ((memq state '(nil excluded)) state) + ((null my/docsync--changes) 'unknown) + ((eq state 'partial) + (if (gethash file my/docsync--changed-dirs) 'partial-changed 'partial-saved)) + ((eq (gethash file my/docsync--changes) 'new) 'new) + ((or (gethash file my/docsync--changes) + (gethash file my/docsync--changed-dirs)) + 'changed) + (t 'saved)))) + +(defun my/docsync-list-changes () + "Show what the next backup will pick up, in one Dired buffer. +A new directory is listed instead of everything in it. Deletions can't +be listed, so they're only counted." + (interactive) + (unless my/docsync--changes + (user-error "docsync: still comparing with the latest snapshot")) + (let (paths (deleted 0)) + (maphash (lambda (path kind) + (cond ((eq kind 'deleted) (setq deleted (1+ deleted))) + ((not (eq (gethash (directory-file-name (file-name-directory path)) + my/docsync--changes) + 'new)) + (push path paths)))) + my/docsync--changes) + (if (null paths) + (message "docsync: nothing changed since the latest snapshot%s" + (if (> deleted 0) (format " but %d deletion(s)" deleted) "")) + (my/dirvish-reusing-window + (dired (cons "/" (sort paths #'string<)))) + (rename-buffer "*docsync changes*" t) + (when (> deleted 0) + (message "docsync: also %d deletion(s), not listed" deleted))))) + +(defvar my/docsync--poll-timer + (run-with-timer 30 30 (lambda () + (when (seq-some (lambda (window) + (with-current-buffer (window-buffer window) + (derived-mode-p 'dired-mode))) + (window-list-1 nil 'nomini 'visible)) + (my/docsync-scan)))) + "Compares with the latest snapshot every 30 seconds while Dired is on screen.") + +(add-hook 'after-save-hook + (lambda () + (when (and buffer-file-name + (memq (my/docsync-state buffer-file-name) '(listed inherited))) + (my/docsync--schedule-scan)))) +;; What's changed since the last snapshot:1 ends here + +;; [[file:config.org::*In Dirvish][In Dirvish:1]] +(defvar my/docsync--last-cache nil + "(MTIME . (SUCCESS STATUS MESSAGE)) as last read from the state files.") + +(defun my/docsync-last-backup () + "Return (SUCCESS STATUS MESSAGE) from docsync's state files, or nil. +SUCCESS is the epoch of the last saved snapshot (nil if never), STATUS +the last run's outcome as a symbol (ok, warning, offline or error), and +MESSAGE what it said about it." + (let* ((result (my/docsync--state-file "last-result")) + (mtime (file-attribute-modification-time (file-attributes result)))) + (unless (and mtime (equal mtime (car my/docsync--last-cache))) + (setq my/docsync--last-cache + (cons mtime + (when-let* ((line (car (my/docsync--list-lines result))) + (fields (split-string line " " t)) + ((cdr fields))) + (let ((success (car (my/docsync--list-lines + (my/docsync--state-file "last-success"))))) + (list (and success (string-to-number success)) + (intern (nth 1 fields)) + (string-join (nthcdr 2 fields) " "))))))) + (cdr my/docsync--last-cache))) + +(defun my/docsync--age (seconds) + "Return SECONDS as a short age: 5m, 3h, 2d." + (cond ((< seconds 3600) (format "%dm" (/ seconds 60))) + ((< seconds 172800) (format "%dh" (/ seconds 3600))) + (t (format "%dd" (/ seconds 86400))))) + +(defun my/docsync-mode-line () + "Return a short mode line note of when docsync last backed up. +It's plain while backups are landing, in `warning' once the last one is +over two hours old or the last run had trouble, and in `error' after a +failed run or a day without a backup. After it comes how many files +differ from the latest snapshot, when any do." + (pcase-let* ((`(,success ,status ,msg) (my/docsync-last-backup)) + (age (and success (- (float-time) success))) + (changes (and my/docsync--changes + (hash-table-count my/docsync--changes)))) + (concat + " " + (propertize (if age (concat "backup " (my/docsync--age (truncate age))) "no backup") + 'face (cond ((or (not age) (eq status 'error) (> age 86400)) 'error) + ((or (memq status '(warning offline)) (> age 7200)) 'warning)) + 'help-echo (format "docsync: last run %s%s" status + (if (string-empty-p (or msg "")) "" (concat ": " msg)))) + (if (and changes (> changes 0)) + (propertize (format " · %d changed" changes) 'face 'my/docsync-changed + 'help-echo "Files that differ from the latest snapshot") + "") + " "))) +;; In Dirvish:1 ends here + +;; [[file:config.org::*In Dirvish][In Dirvish:2]] +(defface my/docsync-saved '((t :inherit success)) + "Face for a backed-up path that hasn't changed since the latest snapshot.") + +(defface my/docsync-changed '((t :inherit warning)) + "Face for a backed-up path that differs from the latest snapshot.") + +(defface my/docsync-excluded '((t :inherit shadow)) + "Face for a path an exclude pattern leaves out.") + +(defvar my/docsync-markers + '((saved "✓" . my/docsync-saved) + (changed "●" . my/docsync-changed) + (new "+" . my/docsync-changed) + (partial-saved "◐" . my/docsync-saved) + (partial-changed "◐" . my/docsync-changed) + (excluded "✕" . my/docsync-excluded) + (unknown "·" . my/docsync-excluded)) + "Marker and face for each `my/docsync-file-status'.") + +(after! dirvish + (dirvish-define-attribute docsync + "Whether this file is backed up and changed since; see `my/docsync-markers'." + :right 2 + :when (not (dirvish-prop :remote)) + :setup (progn (my/docsync--lists) (my/docsync--check-manifest)) + (let* ((marker (alist-get (my/docsync-file-status f-name) my/docsync-markers)) + (str (concat " " (or (car marker) " ")))) + (add-face-text-property 0 (length str) (or hl-face (cdr marker) 'default) t str) + `(right . ,str))) + + (dirvish-define-mode-line docsync + "When docsync last backed up." + (my/docsync-mode-line)) + + (add-to-list 'dirvish-attributes 'docsync t) + (unless (memq 'docsync (plist-get dirvish-mode-line-format :right)) + (setq dirvish-mode-line-format + (plist-put dirvish-mode-line-format :right + (cons 'docsync (plist-get dirvish-mode-line-format :right)))))) +;; In Dirvish:2 ends here + +;; [[file:config.org::*Running it][Running it:1]] +(defun my/docsync--show (&rest args) + "Run docsync with ARGS in the *docsync* buffer, shown at the bottom." + (let ((buffer (get-buffer-create "*docsync*"))) + (when (get-buffer-process buffer) + (user-error "docsync is already running in *docsync*")) + (with-current-buffer buffer + (special-mode) + (let ((inhibit-read-only t)) (erase-buffer))) + (make-process + :name "docsync" :buffer buffer :connection-type 'pipe + :command (cons (my/program "docsync") args) + :filter (lambda (process string) + (when (buffer-live-p (process-buffer process)) + (with-current-buffer (process-buffer process) + (let ((inhibit-read-only t)) + (save-excursion (goto-char (point-max)) (insert string)))))) + :sentinel (lambda (process event) + (unless (process-live-p process) + (message "docsync %s: %s" (car args) (string-trim event)) + (my/docsync--check-manifest)))) + (display-buffer buffer '((display-buffer-reuse-window display-buffer-at-bottom) + (window-height . 12))))) + +(defun my/docsync-backup-now () + "Take a backup now instead of waiting for the hourly timer." + (interactive) + (my/docsync--show "backup")) + +(defun my/docsync-status () + "Show when docsync last backed up, and its latest snapshots." + (interactive) + (my/docsync--show "status")) +;; Running it:1 ends here + +;; [[file:config.org::*Going back in time][Going back in time:1]] +(defvar my/docsync--mount-process nil + "The `docsync restic mount' process serving snapshots, while it runs.") + +(defvar my/docsync--mount-ready nil + "Non-nil once `my/docsync--mount-process' has said it's serving.") + +(defun my/docsync--mount-directory () + "Return where snapshots get mounted." + (expand-file-name "docsync" (or (getenv "XDG_RUNTIME_DIR") temporary-file-directory))) + +(defun my/docsync--with-mount (callback) + "Call CALLBACK with the snapshot mount's directory, mounting it if needed." + (let ((dir (my/docsync--mount-directory))) + (cond + ((and (process-live-p my/docsync--mount-process) my/docsync--mount-ready) + (funcall callback dir)) + ((process-live-p my/docsync--mount-process) + (user-error "docsync: still mounting snapshots, try again in a moment")) + (t + (make-directory dir t) + ;; Clear a mount a crashed Emacs left behind, which would otherwise + ;; make the directory unusable ("Transport endpoint is not connected"). + (call-process "fusermount3" nil nil nil "-uqz" dir) + (message "docsync: mounting snapshots...") + (setq my/docsync--mount-ready nil) + (let ((buffer (generate-new-buffer " *docsync-mount*"))) + (setq my/docsync--mount-process + (make-process + :name "docsync-mount" :buffer buffer :connection-type 'pipe + :command (list (my/program "docsync") "restic" "mount" "--no-lock" dir) + :filter (lambda (process string) + (with-current-buffer (process-buffer process) + (goto-char (point-max)) + (insert string) + (when (and (not my/docsync--mount-ready) + (save-excursion + (goto-char (point-min)) + (search-forward "Now serving" nil t))) + (setq my/docsync--mount-ready t) + (message "docsync: snapshots mounted at %s" dir) + (run-at-time 0 nil callback dir)))) + :sentinel (lambda (process _event) + (unless (process-live-p process) + (unless my/docsync--mount-ready + (message "docsync: mounting failed: %s" + (with-current-buffer (process-buffer process) + (car (last (split-string (string-trim (buffer-string)) + "\n")))))) + (setq my/docsync--mount-ready nil) + (kill-buffer (process-buffer process))))))))))) + +(defun my/docsync-unmount () + "Unmount the snapshots." + (interactive) + (if (process-live-p my/docsync--mount-process) + (progn (interrupt-process my/docsync--mount-process) + (message "docsync: snapshots unmounted")) + (message "docsync: snapshots aren't mounted"))) + +(add-hook 'kill-emacs-hook + (lambda () (when (process-live-p my/docsync--mount-process) + (interrupt-process my/docsync--mount-process)))) + +(defun my/docsync--snapshots (mount) + "Return the snapshot directory names under MOUNT, newest first." + (sort (directory-files (expand-file-name "snapshots" mount) nil "\\`[0-9]") + #'string>)) + +(defun my/docsync--read (prompt candidates) + "Read one of CANDIDATES, (LABEL . VALUE) conses, keeping their order. +Return the chosen VALUE." + (let ((choice (completing-read + prompt + (lambda (string pred action) + (if (eq action 'metadata) + '(metadata (display-sort-function . identity)) + (complete-with-action action candidates string pred))) + nil t))) + (cdr (assoc choice candidates)))) + +(defun my/docsync--snapshot-label (name) + "Return snapshot directory NAME as a date and time." + (if (string-match "\\`\\([0-9-]+\\)T\\([0-9]+:[0-9]+\\)" name) + (concat (match-string 1 name) " " (match-string 2 name)) + name)) + +(defun my/docsync--versions (mount file) + "Return FILE's distinct versions in the snapshots under MOUNT, newest first. +Each is (LABEL . PATH), PATH being that version inside the mount, labelled +with the first snapshot that has it. Versions differ by size or +modification time, which restic keeps as they were." + (let ((current (file-attributes file)) (seen nil) (versions nil)) + (dolist (name (reverse (my/docsync--snapshots mount))) + (let* ((path (concat (expand-file-name name (expand-file-name "snapshots" mount)) file)) + (attrs (file-attributes path))) + (when attrs + (let ((key (cons (file-attribute-size attrs) + (file-attribute-modification-time attrs)))) + (unless (member key seen) + (push key seen) + (push (cons (format "%s %s, modified %s%s" + (my/docsync--snapshot-label name) + (file-size-human-readable (file-attribute-size attrs)) + (format-time-string "%F %R" (cdr key)) + (if (and current + (equal key (cons (file-attribute-size current) + (file-attribute-modification-time current)))) + " (same as now)" "")) + path) + versions)))))) + versions)) + +(defun my/docsync-versions (file) + "Pick an earlier version of FILE from the backups and compare or copy it. +For a directory, open the chosen version of it in Dired instead." + (interactive (list (or (dired-get-filename nil t) (user-error "No file on this line"))) + dired-mode) + (let ((file (directory-file-name (expand-file-name file)))) + (unless (memq (my/docsync-state file) '(listed inherited)) + (user-error "%s isn't backed up" (abbreviate-file-name file))) + (my/docsync--with-mount + (lambda (mount) + (let* ((versions (or (my/docsync--versions mount file) + (user-error "No backed-up versions of %s yet" + (abbreviate-file-name file)))) + (path (my/docsync--read (format "Version of %s: " (file-name-nondirectory file)) + versions))) + (if (file-directory-p path) + (my/dirvish-reusing-window (dired path)) + (pcase (read-char-choice + (format "%s: [e]diff with now, [o]pen, [c]opy beside it as %s.~DATE~ " + (file-name-nondirectory file) (file-name-nondirectory file)) + '(?e ?o ?c)) + (?e (ediff-files path file)) + (?o (my/dirvish-reusing-window (find-file-read-only path))) + (?c (let ((copy (format "%s.~%s~" file + (format-time-string + "%Y-%m-%dT%H%M" + (file-attribute-modification-time (file-attributes path)))))) + (copy-file path copy nil t) + (dolist (buffer (dired-buffers-for-dir (file-name-directory file))) + (with-current-buffer buffer (revert-buffer))) + (message "docsync: copied to %s" (abbreviate-file-name copy))))))))))) + +(defun my/docsync-browse () + "Open this directory as it was in a chosen snapshot. +Deleted files can be copied back out with the usual Dired commands. If +the directory didn't exist yet in that snapshot, open the nearest parent +that did." + (interactive) + (let ((dir (directory-file-name (expand-file-name default-directory)))) + (my/docsync--with-mount + (lambda (mount) + (let* ((snapshots (or (my/docsync--snapshots mount) + (user-error "docsync: no snapshots yet"))) + (name (my/docsync--read "Snapshot: " + (mapcar (lambda (name) + (cons (my/docsync--snapshot-label name) name)) + snapshots))) + (root (expand-file-name name (expand-file-name "snapshots" mount))) + (target dir)) + (while (and (not (file-directory-p (concat root target))) + (not (equal target "/"))) + (setq target (directory-file-name (file-name-directory target)))) + (my/dirvish-reusing-window (dired (concat root target)))))))) + +(after! vc + ;; Don't have VC look for Git repositories inside the read-only mount. + (setq vc-ignore-dir-regexp + (format "%s\\|\\`%s" vc-ignore-dir-regexp + (regexp-quote (file-name-as-directory (my/docsync--mount-directory)))))) +;; Going back in time:1 ends here + +;; [[file:config.org::*Keys][Keys:1]] +(map! :map dired-mode-map :localleader + (:prefix ("b" . "backups") + :desc "Toggle backing up" "b" #'my/docsync-toggle + :desc "Toggle excluding" "x" #'my/docsync-toggle-exclude + :desc "Back up now" "s" #'my/docsync-backup-now + :desc "Changed since backup" "c" #'my/docsync-list-changes + :desc "Recheck changes" "r" #'my/docsync-scan + :desc "Status" "i" #'my/docsync-status + :desc "Versions of this" "v" #'my/docsync-versions + :desc "Browse a snapshot" "t" #'my/docsync-browse + :desc "Unmount snapshots" "u" #'my/docsync-unmount + :desc "List backed-up paths" "l" #'my/docsync-list + :desc "Edit include list" "e" #'my/docsync-edit-include + :desc "Edit exclude list" "E" #'my/docsync-edit-exclude)) +;; Keys:1 ends here + +;; [[file:config.org::*Media thumbnails][Media thumbnails:1]] +(defcustom my/media-video-extensions + '("mp4" "mkv" "webm" "avi" "mov" "m4v" "wmv" "flv" "mpg" "mpeg" "ogv") + "Video file extensions shown alongside images by `my/media-thumbnails'." + :type '(repeat string)) + +(defcustom my/media-video-player (or (executable-find "mpv") "xdg-open") + "Program used to open video files from the `my/media-thumbnails' grid." + :type 'string) + +(defun my/media--video-p (file) + "Return non-nil if FILE's extension is one of `my/media-video-extensions'." + (member (downcase (or (file-name-extension file) "")) my/media-video-extensions)) + +(defun my/media--file-regexp () + "Regexp matching both image and video files." + (concat (image-dired--file-name-regexp) + "\\|\\.\\(?:" (mapconcat #'regexp-quote my/media-video-extensions "\\|") "\\)\\'")) + +(defun my/media--video-thumbnail-file (file) + "Return a cached thumbnail image for video FILE, creating it if stale." + (let* ((cache (expand-file-name "video-thumbs" (image-dired-dir))) + (thumb (expand-file-name (concat (sha1 (expand-file-name file)) ".jpg") cache)) + (attr (file-attributes thumb))) + (make-directory cache t) + (when (or (not attr) + (time-less-p (file-attribute-modification-time attr) + (file-attribute-modification-time (file-attributes file)))) + (unless (zerop (call-process "ffmpegthumbnailer" nil nil nil + "-i" file "-o" thumb "-s" + (number-to-string image-dired-thumb-size))) + (message "ffmpegthumbnailer failed for %s" file))) + thumb)) + +(defun my/media--thumbnail-file (file) + "Return a thumbnail image for FILE, whether it is an image or a video." + (if (my/media--video-p file) + (my/media--video-thumbnail-file file) + (image-dired--get-create-thumbnail-file file))) + +(defun my/media-thumbnails (dir) + "Show a grid of thumbnails for every image and video in DIR. +Uses `image-dired-thumbnail-mode', so the normal image-dired keys select, +mark, and open thumbnails; opening a video plays it with +`my/media-video-player' instead of displaying it as an image." + (interactive (list (if (derived-mode-p 'dired-mode) + default-directory + (read-directory-name "Show thumbnails for directory: ")))) + (require 'image-dired) + (unless (executable-find "ffmpegthumbnailer") + (user-error "ffmpegthumbnailer is not installed")) + (let* ((dir (expand-file-name dir)) + (default-directory dir) + (files (let ((case-fold-search t)) + (sort (directory-files dir t (my/media--file-regexp)) #'string-lessp))) + (dired-buf (dired-noselect dir)) + (buf (image-dired-create-thumbnail-buffer))) + (unless files + (user-error "No image or video files in %s" dir)) + (with-current-buffer buf + (let ((inhibit-read-only t)) + (erase-buffer) + (setq-local image-dired--number-of-thumbnails (length files)) + (dolist (file files) + (image-dired-insert-thumbnail (my/media--thumbnail-file file) file dired-buf)))) + ;; Line-up must happen after the buffer is displayed: the dynamic method + ;; measures the window it is showing in to decide thumbnails per row. + (pop-to-buffer buf) + (image-dired--line-up-with-method) + (image-dired--update-header-line))) + +;; RET (`image-dired-display-this') and C-RET +;; (`image-dired-thumbnail-display-external') both hand the *original* file +;; to code that only understands images; send videos to an external player +;; instead. +(defun my/media--play-video-a (orig-fn &rest args) + "Play FILE with `my/media-video-player' if it looks like a video. +Otherwise call ORIG-FN with ARGS. FILE is either the first of ARGS, as +passed to `image-dired-display-image', or, when ARGS is empty, the file +at point, as used by `image-dired-thumbnail-display-external'." + (let ((file (or (car args) (image-dired-original-file-name)))) + (if (and file (my/media--video-p file)) + (start-process "my/media-video-player" nil my/media-video-player file) + (apply orig-fn args)))) + +;; The thumbnail grid and the single-image display buffer are both popups +;; matched by the same "^\*image-dired" rule in the dired module, so they +;; share a slot: opening an image reuses that window instead of splitting, +;; and the grid isn't shown anywhere any more. `quit-window' is supposed to +;; restore whatever the popup replaced, but that's easy to miss when nothing +;; visibly explains how to get back. Make "q" always and explicitly return +;; to the grid, landing on the same thumbnail, instead of relying on that. +(defun my/media-close-image-display () + "Close the image display popup and return to the media thumbnail grid." + (interactive) + ;; Plain `quit-window' is what Doom's own `+popup/quit-window' works around: + ;; on a popup it falls back to `switch-to-prev-buffer', which doesn't know + ;; about the thumbnail grid buried underneath and lands on Dired instead. + ;; `+popup/close' with FORCE-P bypasses this popup's `:quit nil' and just + ;; deletes the window, leaving the grid buffer intact to pop back into. + (+popup/close nil t) + (when-let* ((buf (get-buffer image-dired-thumbnail-buffer))) + (pop-to-buffer buf))) + +;; Plain Dired (not just the thumbnail grid above) hands RET on a video file +;; to `dired-find-file', which opens it in a buffer as if it were text. +;; Play it with `my/media-video-player' instead. +(defun my/media--dired-find-file-a (orig-fn &rest args) + "Play the file at point with `my/media-video-player' if it's a video. +Otherwise call ORIG-FN with ARGS." + (let ((file (dired-get-filename nil t))) + (if (and file (my/media--video-p file)) + (start-process "my/media-video-player" nil my/media-video-player file) + (apply orig-fn args)))) + +(after! dired + (advice-add #'dired-find-file :around #'my/media--dired-find-file-a)) + +(after! image-dired + (advice-add #'image-dired-display-image :around #'my/media--play-video-a) + (advice-add #'image-dired-thumbnail-display-external :around #'my/media--play-video-a) + ;; The dired module tries to put this mode in emacs state via + ;; `set-evil-initial-state!', so that a plain "q" binding below (rather + ;; than an evil-state-qualified one) is what fires. But it does so under + ;; the mode's pre-29.1 name, `image-dired-display-image-mode', which is now + ;; only an obsolete function alias, not the major mode - the real mode is + ;; `image-dired-image-mode'. Since that's never registered, Evil falls + ;; back to its parent `image-mode', which evil-collection registers as + ;; `normal' state, where its own "q" -> `quit-window' binding on + ;; `image-mode-map' shadows anything bound below. Re-register under the + ;; current name to actually get emacs state. + (set-evil-initial-state! 'image-dired-image-mode 'emacs) + (map! :map image-dired-image-mode-map "q" #'my/media-close-image-display) + ;; `image-dired-thumbnail-mode' (the grid itself, not the single-image + ;; popup above) has no explicit initial state, so it falls back to + ;; `evil-default-state', which happens to be `normal' - the state + ;; evil-collection's image-dired bindings need, since those are what + ;; make motion skip the separator between thumbnails instead of + ;; stopping on it. That fallback is easy to end up not in, e.g. by + ;; entering the grid buffer with a visual selection already active + ;; elsewhere, so pin it explicitly rather than relying on the default + ;; staying `normal' or every entry path leaving the buffer in it. + (set-evil-initial-state! 'image-dired-thumbnail-mode 'normal)) + +(map! :map dired-mode-map :localleader "m" #'my/media-thumbnails) +;; Media thumbnails:1 ends here + +;; [[file:config.org::*Slideshow][Slideshow:1]] +(defcustom my/media-slideshow-image-duration 5 + "Seconds each image is shown for during `my/media-slideshow'." + :type 'number) + +(defun my/media--dired-buffer-files () + "Return every file listed in the current Dired buffer, marked or not." + (save-excursion + (goto-char (point-min)) + (let (files) + (while (not (eobp)) + (when-let* ((file (dired-get-filename nil t))) + (push file files)) + (forward-line 1)) + (nreverse files)))) + +(defun my/media-slideshow (&optional shuffle) + "Play every image/video file in the current Dired buffer with mpv. +Uses the marked files if any are marked, otherwise every media file listed +in the buffer. With a prefix argument SHUFFLE, play them in random order." + (interactive "P" dired-mode) + (unless (executable-find "mpv") + (user-error "mpv not found")) + (require 'image-dired) + (let* ((marked (dired-get-marked-files nil 'marked)) + ;; Built once: `my/media--file-regexp' runs `regexp-opt' every + ;; time, which across a few thousand files of a big TMSU query + ;; added up to most of a second. + (regexp (my/media--file-regexp)) + (case-fold-search t) + (files (seq-filter (lambda (file) (string-match-p regexp file)) + (or marked (my/media--dired-buffer-files))))) + (unless files + (user-error "No image or video files %s" (if marked "marked" "in buffer"))) + (apply #'start-process "my/media-slideshow" nil "mpv" + "--fullscreen" + ;; Overrides this user's `loop-file=inf' in mpv.conf, which + ;; otherwise loops the first file forever and never advances. + "--loop-file=no" + (format "--image-display-duration=%s" my/media-slideshow-image-duration) + "--loop-playlist" + (append (when shuffle '("--shuffle")) + files)))) + +(defun my/media-slideshow-shuffle () + "Like `my/media-slideshow', but play files in random order." + (interactive nil dired-mode) + (my/media-slideshow t)) + +(map! :map dired-mode-map :localleader + "s" #'my/media-slideshow + "S" #'my/media-slideshow-shuffle) +;; Slideshow:1 ends here + +;; [[file:config.org::*Collage][Collage:1]] +(defun my/media-collage (output) + "Build a masonry collage from the current Dired buffer's files with imgcollage. +Uses the marked files if any are marked, otherwise every file listed in the +buffer. Prompts for OUTPUT, defaulting to a throwaway name under +`temporary-file-directory' since most collages are just for a quick look, +not a keeper -- edit the prompt to save somewhere permanent instead. Writes +the result to OUTPUT, then takes over the frame to show it: the window +layout Dired had (Dirvish's preview pane included) is saved +before switching away, and restored, along with killing the image buffer, +when `q' is pressed -- bound buffer-locally, as `my/tmsu-dired-query-quit' +binds its own `q', since image-mode is shared with every other image Emacs +displays and a mode-wide rebind would affect all of them." + (interactive + (list (read-file-name + "Collage output: " temporary-file-directory nil nil + (concat (make-temp-name (expand-file-name "collage-" temporary-file-directory)) + ".jpg"))) + dired-mode) + (let* ((program (my/program "imgcollage")) + (marked (dired-get-marked-files nil 'marked)) + (files (or marked (my/media--dired-buffer-files))) + (config (current-window-configuration))) + (unless files + (user-error "No files %s" (if marked "marked" "in buffer"))) + (let ((buf (get-buffer-create "*imgcollage*"))) + (with-current-buffer buf (erase-buffer)) + (make-process + :name "imgcollage" :buffer buf + :command (append (list program) files (list "-o" (expand-file-name output))) + :sentinel + (lambda (proc _event) + (when (memq (process-status proc) '(exit signal)) + (if (zerop (process-exit-status proc)) + (progn + (message "imgcollage: wrote %s" output) + (my/dirvish-reusing-window + (delete-other-windows) + (find-file output)) + (let ((ibuf (current-buffer))) + (evil-local-set-key + 'normal "q" + (lambda () + (interactive) + (kill-buffer ibuf) + (set-window-configuration config))))) + (progn (message "imgcollage failed, see %s" (buffer-name buf)) + (my/dirvish-reusing-window (pop-to-buffer buf))))))) + (message "imgcollage: building collage from %d file(s)..." (length files))))) + +(map! :map dired-mode-map :localleader "c" #'my/media-collage) +;; Collage:1 ends here + +;; [[file:config.org::*Movie grid][Movie grid:1]] +(load! "movie-grid") + +(map! :leader :desc "Movie grid" "o M" #'my/movie-grid) +;; Movie grid:1 ends here + +;; [[file:config.org::*Synth][Synth:1]] +(defcustom my/synth-soundfont + (seq-find #'file-exists-p '("/usr/share/soundfonts/FluidR3_GM.sf2" + "/usr/share/sounds/sf2/FluidR3_GM.sf2")) + "SoundFont file fluidsynth loads." + :type '(choice file (const nil))) + +(defcustom my/synth-audio-driver "pulseaudio" + "Audio driver fluidsynth is started with; see `fluidsynth -a help'." + :type 'string) + +(defcustom my/synth-gain 0.6 + "Master gain fluidsynth is started with, 0 < gain < 10." + :type 'number) + +(defcustom my/synth-program 0 + "General MIDI program number (0-127) notes are played on." + :type 'integer) + +(defcustom my/synth-note-duration 0.32 + "Seconds each note in `my/synth-play-notes' occupies, attack to attack." + :type 'number) + +(defvar my/synth--process nil + "The persistent fluidsynth process notes are sent to.") + +(defvar my/synth--timers nil + "Pending `run-at-time' timers for in-flight `my/synth-play-notes' playback.") + +(defun my/synth--send (process command) + "Send COMMAND, a fluidsynth shell command, to PROCESS if it's still alive." + (when (process-live-p process) + (process-send-string process command))) + +(defun my/synth--ensure-process () + "Return a live fluidsynth process fed via its stdin shell, starting one if needed." + (unless (process-live-p my/synth--process) + (unless (executable-find "fluidsynth") + (user-error "fluidsynth is not installed")) + (unless (and my/synth-soundfont (file-exists-p my/synth-soundfont)) + (user-error "my/synth-soundfont is unset or missing: %s" my/synth-soundfont)) + (setq my/synth--process + (make-process + :name "synth" :buffer "*synth*" :connection-type 'pipe :noquery t + :command (list "fluidsynth" "-a" my/synth-audio-driver "-n" "-q" + "-g" (number-to-string my/synth-gain) + my/synth-soundfont)))) + my/synth--process) + +(defun my/synth-stop () + "Cancel any pending `my/synth-play-notes' playback and silence the synth." + (interactive) + (mapc #'cancel-timer my/synth--timers) + (setq my/synth--timers nil) + (my/synth--send my/synth--process "cc 0 123 0\n")) + +(defun my/synth-play-notes (notes &optional duration) + "Play NOTES in sequence, one every DURATION seconds (`my/synth-note-duration' +by default). Each element is a MIDI note number 0-127, or nil for a rest. +Cancels any playback already in flight." + (my/synth-stop) + (let ((process (my/synth--ensure-process)) + (duration (or duration my/synth-note-duration)) + (i 0)) + (my/synth--send process (format "prog 0 %d\n" my/synth-program)) + (dolist (note notes) + (when note + (let ((start (* i duration))) + (push (run-at-time start nil + (lambda () + (my/synth--send process (format "noteon 0 %d 100\n" note)))) + my/synth--timers) + (push (run-at-time (+ start (* duration 0.9)) nil + (lambda () + (my/synth--send process (format "noteoff 0 %d\n" note)))) + my/synth--timers))) + (setq i (1+ i))))) + +(map! :leader :desc "Stop synth" "o S" #'my/synth-stop) +;; Synth:1 ends here + +;; [[file:config.org::*Music theory][Music theory:1]] +(defun my/mtheory--mod12 (n) + "Reduce N to a pitch class in 0-11." + (mod n 12)) + +(defun my/mtheory--pitch-class-to-note (pc) + "Convert pitch class PC to a note name, counting from A = 0." + (nth pc '("A" "Bb" "B" "C" "Db" "D" "Eb" "E" "F" "Gb" "G" "Ab"))) + +(defun my/mtheory--note-to-pitch-class (note) + "Convert NOTE name to a pitch class, counting from A = 0." + (cdr (assoc note + '(("A" . 0) ("Bb" . 1) ("B" . 2) ("C" . 3) + ("Db" . 4) ("D" . 5) ("Eb" . 6) ("E" . 7) + ("F" . 8) ("Gb" . 9) ("G" . 10) ("Ab" . 11))))) + +(defun my/mtheory--intervals-to-pitch-classes (intervals root) + "Generate pitch classes by stacking INTERVALS on ROOT." + (let ((result (list root)) + (current root)) + (dolist (interval intervals) + (setq current (+ current interval)) + (push (my/mtheory--mod12 current) result)) + (nreverse result))) + +(defcustom my/mtheory-play-octave 4 + "Octave (scientific pitch notation, where C4 = middle C) matrix buttons +and `my/mtheory-play-pitch-classes' play in." + :type 'integer) + +(defun my/mtheory--pitch-class-to-midi (pc) + "Convert pitch class PC (0-11, counting from A = 0) to a MIDI note number +in `my/mtheory-play-octave'." + (+ (my/mtheory--mod12 (- pc 3)) (* 12 (1+ my/mtheory-play-octave)))) + +(defun my/mtheory-play-pitch-classes (pcs) + "Play PCS, a list of pitch classes (0-11, counting from A = 0), through +`my/synth-play-notes'." + (my/synth-play-notes (mapcar #'my/mtheory--pitch-class-to-midi pcs))) + +;; A dedicated link type rather than a plain `elisp:' link so clicking a +;; matrix button doesn't trigger `org-confirm-elisp-link-function' on every +;; click -- that confirmation gate only guards the built-in "elisp" type. +;; Deferred until org loads: at Doom startup config.el runs before Org is +;; necessarily loaded, and `org-link-set-parameters' is void until then. +(after! org + (org-link-set-parameters + "synth" + :follow (lambda (path &optional _arg) (my/mtheory-play-pitch-classes (read path))))) + +(defun my/mtheory-12-tone-matrix (row) + "Insert the 12-tone matrix of ROW at point as an Org table, with play +buttons on all four sides. ROW is read as a Lisp list of the pitch classes +0-11 in any order. + +Each data row is a transposition of ROW read left to right (P); the button +to its left plays it forward, the button to its right plays it backward, the +retrograde (R). Each data column is an inversion (I) read top to bottom; the +button above it plays top-to-bottom, the button below plays bottom-to-top, +the retrograde inversion (RI). Buttons play the matrix as generated -- a +hand-edited cell doesn't change what its row/column buttons play." + (interactive (list (read (read-string "Row: ")))) + (unless (equal (sort (copy-sequence row) #'<) (number-sequence 0 11)) + (user-error "Row must contain each pitch class 0-11 exactly once")) + (let* ((p0 (mapcar (lambda (x) (my/mtheory--mod12 (- x (car row)))) row)) + (i0 (mapcar (lambda (x) (my/mtheory--mod12 (- x))) p0)) + (matrix (mapcar (lambda (transposition) + (mapcar (lambda (p) (my/mtheory--mod12 (+ p transposition))) p0)) + i0)) + (columns (mapcar (lambda (j) (mapcar (lambda (r) (nth j r)) matrix)) + (number-sequence 0 11))) + (button (lambda (glyph pcs) (format "[[synth:%S][%s]]" pcs glyph))) + (table-start (point))) + (insert "| | " + (mapconcat (lambda (col) (funcall button "▼" col)) columns " | ") + " | |\n") + (insert "|-\n") + (dolist (r matrix) + (insert "| " (funcall button "▶" r) " | " + (mapconcat #'number-to-string r " | ") + " | " (funcall button "◀" (reverse r)) " |\n")) + (insert "|-\n") + (insert "| | " + (mapconcat (lambda (col) (funcall button "▲" (reverse col))) columns " | ") + " | |\n") + (save-excursion (goto-char table-start) (org-table-align)))) + +(defun my/mtheory-table-to-notes () + "Convert the Org table at point from pitch classes to note names. Skips +over any [[synth:...][...]] play buttons, whose link path also holds +digits, so it doesn't rewrite the data those buttons play." + (interactive) + (let ((end (copy-marker (org-table-end)))) + (save-excursion + (goto-char (org-table-begin)) + (while (re-search-forward + "\\[\\[[^]]*\\]\\[[^]]*\\]\\]\\|\\b\\([0-9]+\\)\\b" end t) + (when-let* ((group (match-string 1))) + (let ((pc (string-to-number group))) + (when (< pc 12) + (replace-match (my/mtheory--pitch-class-to-note pc) t t nil 1)))))) + (set-marker end nil) + (org-table-align))) + +(defun my/mtheory-table-to-pitch-classes () + "Convert the Org table at point from note names to pitch classes. Skips +over any [[synth:...][...]] play buttons the same way +`my/mtheory-table-to-notes' does." + (interactive) + (let ((end (copy-marker (org-table-end)))) + (save-excursion + (goto-char (org-table-begin)) + (while (re-search-forward + "\\[\\[[^]]*\\]\\[[^]]*\\]\\]\\|\\b\\(A\\|Bb\\|B\\|C\\|Db\\|D\\|Eb\\|E\\|F\\|Gb\\|G\\|Ab\\)\\b" + end t) + (when-let* ((group (match-string 1))) + (replace-match (number-to-string + (my/mtheory--note-to-pitch-class group)) + t t nil 1)))) + (set-marker end nil) + (org-table-align))) + +(defun my/mtheory-intervals-to-notes (intervals) + "Insert an Org table of the INTERVALS pattern in all 12 keys." + (interactive (list (read (read-string "Intervals: ")))) + (insert (format "#+TITLE: Intervals %S\n\n" intervals)) + (insert "| Notes |\n") + (insert "|-------|\n") + (let ((table-start (point))) + (dotimes (root 12) + (insert (format "| %s |\n" + (mapconcat #'my/mtheory--pitch-class-to-note + (my/mtheory--intervals-to-pitch-classes intervals root) + " ")))) + (save-excursion + (goto-char table-start) + (org-table-align)))) + +(defun my/mtheory-respell-scale () + "Respell a scale so each note uses the next letter name; insert it at point. + +Examples: + G A B C D E Gb -> G A B C D E F# + A B Db D E Gb Ab -> A B C# D E F# G#" + (interactive) + (let* ((input (read-string "Scale: ")) + (notes (split-string input "[ \t]+" t)) + ;; Pitch class for each accepted spelling. + (pitch-table + '(("C" . 0) ("B#" . 0) + ("C#" . 1) ("Db" . 1) + ("D" . 2) + ("D#" . 3) ("Eb" . 3) + ("E" . 4) ("Fb" . 4) + ("F" . 5) ("E#" . 5) + ("F#" . 6) ("Gb" . 6) + ("G" . 7) + ("G#" . 8) ("Ab" . 8) + ("A" . 9) + ("A#" . 10) ("Bb" . 10) + ("B" . 11) ("Cb" . 11))) + ;; Given an expected letter and a pitch class, how should it be spelled? + (spell-table + '(("C" . ((0 . "C") (1 . "C#"))) + ("D" . ((2 . "D") (3 . "D#"))) + ("E" . ((4 . "E"))) + ("F" . ((5 . "F") (6 . "F#"))) + ("G" . ((7 . "G") (8 . "G#"))) + ("A" . ((9 . "A") (10 . "A#"))) + ("B" . ((11 . "B"))))) + (next-letter + '(("A" . "B") + ("B" . "C") + ("C" . "D") + ("D" . "E") + ("E" . "F") + ("F" . "G") + ("G" . "A"))) + (result nil) + (prev-letter nil)) + (dolist (note notes) + (let* ((letter (substring note 0 1)) + (pitch (cdr (assoc note pitch-table)))) + (unless pitch + (user-error "Don't know how to parse note %s" note)) + (if (null prev-letter) + ;; Keep first note exactly as entered. + (progn + (push note result) + (setq prev-letter letter)) + (let* ((expected (cdr (assoc prev-letter next-letter))) + (spelling + (or (cdr (assoc pitch (cdr (assoc expected spell-table)))) + note))) + (push spelling result) + (setq prev-letter expected))))) + (insert (mapconcat #'identity (nreverse result) " ")))) +;; Music theory:1 ends here + +;; [[file:config.org::*Notation][Notation:1]] +(autoload 'lilypond-mode "lilypond-mode" "Major mode for editing LilyPond files." t) +(add-to-list 'auto-mode-alist '("\\.i?ly\\'" . lilypond-mode)) + +(when-let* ((mode-file (locate-library "lilypond-mode"))) + (dolist (file (directory-files (file-name-directory mode-file) t + "\\`lilypond-.*\\.el\\'")) + (add-to-list 'warning-suppress-types + `(files missing-lexbind-cookie ,(abbreviate-file-name file))))) + +(setq org-babel-default-header-args:lilypond + '((:results . "file") (:exports . "results"))) + +(defcustom my/lilypond-staff-size 20 + "Staff height, in points, lilypond blocks are engraved at; LilyPond's own +default is 20. A block can override it with #(set-global-staff-size N)." + :type 'number) + +(defcustom my/lilypond-png-resolution 200 + "Resolution, in DPI, lilypond blocks with a .png :file render at." + :type 'integer) + +(defun my/lilypond--preamble () + "LilyPond source prepended to every lilypond block: English note names, the +staff size, and no indent, titles, headers or footers." + (format "\\language \"english\" +#(set-global-staff-size %s) +\\paper { + indent = 0\\mm + tagline = ##f + oddHeaderMarkup = ##f + evenHeaderMarkup = ##f + oddFooterMarkup = ##f + evenFooterMarkup = ##f + bookTitleMarkup = ##f + scoreTitleMarkup = ##f +} +" my/lilypond-staff-size)) + +(defun my/lilypond--process-basic (body params) + "Render lilypond block BODY to its :file, cropped to the music. +Overrides `org-babel-lilypond-process-basic'. The :file extension picks the +format: svg, png, pdf or eps." + (let* ((out-file (or (cdr (assq :file params)) + (user-error "lilypond blocks need a :file header argument"))) + (type (downcase (or (file-name-extension out-file) ""))) + (in-file (org-babel-temp-file "lilypond-" ".ly"))) + (with-temp-file in-file + (insert (my/lilypond--preamble) + (org-babel-expand-body:lilypond body params))) + (org-babel-eval + (string-join + (list org-babel-lilypond-ly-command + "--loglevel=ERROR" + "-dno-use-paper-size-for-page" + (concat "-dtall-page-formats=" type) + (pcase type + ("svg" "-dbackend=svg") + ("png" (format "--png -dresolution=%d" my/lilypond-png-resolution)) + ("pdf" "--pdf") + ("eps" "--eps") + (_ (user-error "Can't render lilypond to .%s; use svg, png, pdf or eps" + type))) + (concat "--output=" + (shell-quote-argument (file-name-sans-extension out-file))) + (or (cdr (assq :cmdline params)) "") + (shell-quote-argument in-file)) + " ") + "") + nil)) + +(after! ob-lilypond + (setf (alist-get "lilypond" org-src-lang-modes nil nil #'equal) 'lilypond) + (advice-add 'org-babel-lilypond-process-basic :override + #'my/lilypond--process-basic)) +;; Notation:1 ends here + +;; [[file:config.org::*Jabber][Jabber:1]] +(defvar my/jabber-accounts nil + "XMPP accounts to connect at startup, as (JID . PASS-ENTRY) pairs. +PASS-ENTRY names the password-store entry holding JID's password.") + +(defun my/jabber-connect-h () + "Connect every account in `my/jabber-accounts', if there are any." + (when my/jabber-accounts + (condition-case err + (jabber-connect-all) + (error (warn "Jabber: not connecting: %s" (error-message-string err)))))) + +(use-package! jabber + :bind-keymap ("C-x C-j" . jabber-global-keymap) + :init + (add-hook 'doom-after-init-hook #'my/jabber-connect-h) + :config + (setq jabber-account-list + (mapcar (lambda (account) + `(,(car account) (:password . ,(+pass-get-secret (cdr account))))) + my/jabber-accounts)) + (jabber-modeline-mode 1) + (add-hook 'kill-emacs-hook #'jabber-disconnect)) +;; Jabber:1 ends here + +;; [[file:config.org::*IRC][IRC:2]] +(map! :leader :desc "IRC" "o i" #'=irc) +;; IRC:2 ends here + +;; [[file:config.org::*Spelling][Spelling:1]] +(after! ispell + (setq ispell-dictionary "en_US,de_DE") + (ispell-set-spellchecker-params) + (condition-case err + (ispell-hunspell-add-multi-dic ispell-dictionary) + (error (warn "Hunspell multi-dictionary setup failed: %s" + (error-message-string err))))) + +(map! :leader :desc "Spell check buffer" "o s" #'ispell) +;; Spelling:1 ends here + +;; [[file:config.org::*Wiktionary][Wiktionary:1]] +(defun my/wiktionary-lookup () + "Look up the word at point on Wiktionary in eww." + (interactive) + (if-let* ((word (thing-at-point 'word t))) + (eww-browse-url (format "https://en.wiktionary.org/wiki/%s" + (url-hexify-string word))) + (message "No word at point"))) + +(map! :leader + :desc "Wiktionary lookup" "s w" #'my/wiktionary-lookup) +;; Wiktionary:1 ends here + +;; [[file:config.org::*Translate][Translate:1]] +(defun my/translate (language pair) + "Translate the region or a prompted LANGUAGE phrase with translate-shell PAIR." + (unless (executable-find "trans") + (user-error "translate-shell (trans) is not installed")) + (let ((text (if (use-region-p) + (buffer-substring-no-properties (region-beginning) (region-end)) + (read-string (format "%s: " language))))) + (with-help-window (format "*%s Translation*" language) + (with-current-buffer standard-output + (call-process "trans" nil standard-output nil + "-show-original" "n" pair text))))) + +(defun my/translate-latin () + "Translate the region or a prompted Latin phrase to English." + (interactive) + (my/translate "Latin" "la:en")) + +(defun my/translate-german () + "Translate the region or a prompted German phrase to English." + (interactive) + (my/translate "German" "de:en")) + +(map! :leader + :desc "Translate Latin" "o l" #'my/translate-latin + :desc "Translate German" "o g" #'my/translate-german) +;; Translate:1 ends here + +;; [[file:config.org::*Password store][Password store:1]] +(map! :leader :desc "Password store" "o p" #'pass) + +(after! pass + (set-popup-rule! "^\\*Password-Store" :ignore t)) +;; Password store:1 ends here + +;; [[file:config.org::*Email][Email:1]] +(setq sendmail-program (executable-find "msmtp") + send-mail-function #'sendmail-send-it + message-send-mail-function #'message-send-mail-with-sendmail + message-sendmail-f-is-evil t + message-sendmail-extra-arguments '("--read-envelope-from")) +;; Email:1 ends here + +;; [[file:config.org::*Email][Email:2]] +(after! mu4e + (setq mu4e-maildir (expand-file-name user-mail-address "~/.local/share/mail/") + message-send-mail-function #'message-send-mail-with-sendmail + + mu4e-drafts-folder "/Drafts" + mu4e-sent-folder "/Sent" + mu4e-trash-folder "/Trash" + mu4e-refile-folder "/Archive")) +;; Email:2 ends here + +;; [[file:config.org::*Contacts][Contacts:1]] +(use-package! bbdb + :after mu4e + :config + (setq bbdb-file "~/.local/share/bbdb") + (bbdb-initialize 'mu4e 'message)) + +(map! :leader :desc "Contacts" "o b" #'bbdb) +(map! :leader :desc "New contact" "o B" #'bbdb-create) +;; Contacts:1 ends here + +;; [[file:config.org::*Calling][Calling:1]] +(defun my/tel-string-to-cheogram-jid (tel-string) + "Normalize TEL-STRING, tel: prefix optional, to a Cheogram gateway JID." + (let* ((digits (replace-regexp-in-string "[^0-9]" "" tel-string)) + (digits (if (= (length digits) 10) (concat "1" digits) digits))) + (format "+%s@cheogram.com" digits))) + +(defun my/browse-tel-with-dino (url &rest _args) + "Open a tel: URL as an xmpp: chat with its Cheogram gateway JID in Dino." + (browse-url-xdg-open + (concat "xmpp:" (my/tel-string-to-cheogram-jid url)))) + +(require 'browse-url) +(add-to-list 'browse-url-handlers (cons "\\`tel:" #'my/browse-tel-with-dino)) + +(after! (bbdb jabber) + (defun my/bbdb-jabber-chat (phone) + "Open PHONE's Cheogram gateway JID as a chat in jabber.el, instead of dialing." + (interactive (list (bbdb-current-field))) + (when (eq (car-safe phone) 'name) + (setq phone (car (bbdb-record-phone (bbdb-current-record))))) + (when (eq (car-safe phone) 'phone) + (setq phone (car (cdr phone)))) + (or (bbdb-phone-p phone) (user-error "Not on a phone field")) + (jabber-handle-uri + (concat "xmpp:" (my/tel-string-to-cheogram-jid (bbdb-phone-string phone))))) + + (define-key bbdb-mode-map (kbd "M-c") #'my/bbdb-jabber-chat)) +;; Calling:1 ends here + +;; [[file:config.org::*osm.el][osm.el:1]] +(use-package! osm + :defer t + :init + (map! :leader :desc "Map" "o G" #'osm-prefix-map) + :config + (set-evil-initial-state! 'osm-mode 'emacs) + (map! :map osm-mode-map "p" #'my/osm-send-to-phone)) + +(defun my/osm-send-to-phone () + "Send the selected pin, or else the map's center, to the phone." + (interactive nil osm-mode) + (pcase-let ((`(,lat ,lon ,name) (osm--fetch-location-data "Sent to phone"))) + (my/localsend-send-text + (my/geo-uri lat lon (and name (my/geo-short-label name)))))) +;; osm.el:1 ends here + +;; [[file:config.org::*Sending to the phone][Sending to the phone:1]] +(defvar my/localsend-phone-alias nil + "The phone's device name in LocalSend, or nil for any phone that answers.") + +(defvar my/localsend-pin nil + "PIN the phone's LocalSend asks senders for, or nil if it asks for none.") + +(defvar my/localsend-port 53317 + "Port LocalSend listens on, 53317 unless changed in the app.") + +(defvar my/localsend--phone nil + "IP address where the phone answered the last scan.") + +(defun my/localsend--lan-prefix () + "Return the first three octets of the Wi-Fi or Ethernet IPv4 address." + (pcase (seq-find (lambda (iface) (string-match-p "\\`\\(wl\\|en\\|eth\\)" (car iface))) + (network-interface-list nil 'ipv4)) + (`(,_ . [,a ,b ,c ,_ ,_]) (format "%d.%d.%d" a b c)) + (_ (user-error "LocalSend: not on a local network")))) + +(defun my/localsend--find-phone (prefix dir) + "Return the phone's IP address among the /info answers saved in DIR. +Each file there is named for the last octet of the PREFIX address that +answered." + (seq-some + (lambda (octet) + (let ((info (ignore-errors + (with-temp-buffer + (insert-file-contents (expand-file-name octet dir)) + (json-parse-buffer :object-type 'plist))))) + (and info + (if my/localsend-phone-alias + (equal (plist-get info :alias) my/localsend-phone-alias) + (equal (plist-get info :deviceType) "mobile")) + (format "%s.%s" prefix octet)))) + (directory-files dir nil "\\`[0-9]+\\'"))) + +(defun my/localsend--scan (callback) + "Look for the phone on the LAN, and call CALLBACK with its IP address." + (let ((prefix (my/localsend--lan-prefix)) + (dir (make-temp-file "localsend-" t))) + (message "LocalSend: looking for the phone on %s.0/24..." prefix) + (make-process + :name "localsend-scan" :noquery t :connection-type 'pipe + :command (list "curl" "--silent" "--insecure" + "--parallel" "--parallel-max" "254" + "--connect-timeout" "1" "--max-time" "3" + "--output" (expand-file-name "#1" dir) + (format "https://%s.[1-254]:%d/api/localsend/v2/info" + prefix my/localsend-port)) + :sentinel + (lambda (proc _event) + (unless (process-live-p proc) + (let ((ip (my/localsend--find-phone prefix dir))) + (delete-directory dir t) + (if (not ip) + (message "LocalSend: no phone answered; is the app open on it?") + (setq my/localsend--phone ip) + (funcall callback ip)))))))) + +(defun my/localsend--send (ip text &optional if-unreachable) + "Send TEXT as a LocalSend message to the phone at IP. +When nothing answers at IP, call IF-UNREACHABLE instead of reporting it." + (let* ((size (length (encode-coding-string text 'utf-8))) + (body (json-serialize + `(:info (:alias ,(format "Emacs on %s" (system-name)) + :version "2.1" :deviceModel "Emacs" :deviceType "desktop" + :fingerprint ,(md5 (system-name)) :port ,my/localsend-port + :protocol "https" :download :false) + :files (:message (:id "message" :fileName "message.txt" + :size ,size :fileType "text/plain" + :preview ,text))))) + (proc + (make-process + :name "localsend-send" :noquery t + :connection-type 'pipe :coding 'binary + :buffer (generate-new-buffer " *localsend-send*") + :command (list "curl" "--silent" "--insecure" "--connect-timeout" "2" + "--output" "/dev/null" + "--write-out" "%{http_code} %{num_connects}" + "--header" "Content-Type: application/json" + "--data-binary" "@-" + (format "https://%s:%d/api/localsend/v2/prepare-upload%s" + ip my/localsend-port + (if my/localsend-pin + (format "?pin=%s" my/localsend-pin) + ""))) + :sentinel + (lambda (proc _event) + (unless (process-live-p proc) + (pcase-let ((`(,code ,connects) + (with-current-buffer (process-buffer proc) + (split-string (buffer-string))))) + (kill-buffer (process-buffer proc)) + (cond + ((and (equal connects "0") if-unreachable) + (funcall if-unreachable)) + ((equal connects "0") + (message "LocalSend: nothing answered at %s" ip)) + (t + (message "LocalSend: %s" + (pcase code + ((or "200" "204") "the phone has it") + ("401" "the phone wants a PIN; set `my/localsend-pin'") + ("403" "declined on the phone") + ("409" "the phone is busy with another transfer") + ("000" "lost the connection to the phone") + (_ (format "the phone answered HTTP %s" code)))))))))))) + (message "LocalSend: sending to %s..." ip) + (process-send-string proc body) + (process-send-eof proc))) + +(defun my/localsend-send-text (text) + "Send TEXT to the phone as a LocalSend message. +Interactively, send the region, or else prompt with the geo: link or +URL at point as the default." + (interactive + (list (if (use-region-p) + (buffer-substring-no-properties (region-beginning) (region-end)) + (let ((default (or (cadr (my/geo-at-point)) (thing-at-point 'url t)))) + (read-string (format-prompt "Send to phone" default) nil nil default))))) + (when (string-blank-p text) + (user-error "Nothing to send")) + (let ((send (lambda (ip) (my/localsend--send ip text)))) + (if my/localsend--phone + (my/localsend--send my/localsend--phone text + (lambda () (my/localsend--scan send))) + (my/localsend--scan send)))) + +(map! :leader :desc "Send to phone" "o y" #'my/localsend-send-text) +;; Sending to the phone:1 ends here + +;; [[file:config.org::*geo: links][geo: links:1]] +(defun my/geo-parse (uri) + "Return (LAT LON LABEL) from geo URI, or nil if it isn't one. +LABEL is the place's name from a `?q=LAT,LON(LABEL)' query, or nil." + (when (string-match "\\`geo:\\(-?[0-9.]+\\),\\(-?[0-9.]+\\)" uri) + (list (string-to-number (match-string 1 uri)) + (string-to-number (match-string 2 uri)) + (and (string-match "[?&]q=[^&]*(\\([^)]+\\))" uri) + (decode-coding-string (url-unhex-string (match-string 1 uri)) + 'utf-8))))) + +(defun my/geo-uri (lat lon &optional label) + "Return a geo URI for LAT/LON, naming the place LABEL if non-nil." + (if label + (format "geo:%.6f,%.6f?q=%.6f,%.6f(%s)" + lat lon lat lon (url-hexify-string label)) + (format "geo:%.6f,%.6f" lat lon))) + +(defun my/geo-short-label (name) + "Cut NAME, a place's description, down to the place's name. +For a building without one, keep the house number and street. Return +nil when NAME starts with coordinates, osm.el's description of a place +the lookup found nothing for." + (let ((parts (split-string name ", *" t "[ \t\n]+"))) + (cond ((or (null parts) (string-search "°" (car parts))) nil) + ((and (cdr parts) (string-match-p "\\`[0-9]+[A-Za-z]?\\'" (car parts))) + (concat (car parts) " " (cadr parts))) + (t (car parts))))) + +(defun my/geo-normalize (uri &optional label) + "Rewrite geo URI in the form `my/geo-uri' makes, naming it LABEL if given." + (pcase-let ((`(,lat ,lon ,query-label) (my/geo-parse uri))) + (unless lat + (user-error "Not a geo: URI: %s" uri)) + (my/geo-uri lat lon (let ((name (or label query-label))) + (and name (my/geo-short-label name)))))) + +(defun my/geo-at-point () + "Return the geo: URI at point as an Embark target, (geo URI BEG . END). +An Org link's description becomes the place's label." + (require 'thingatpt) + (when (or (thing-at-point-looking-at + "\\[\\[\\(geo:[^]]+\\)]\\(?:\\[\\([^]]+\\)]\\)?]" 500) + (thing-at-point-looking-at + "\\(geo:-?[0-9.]+,-?[0-9.]+[^][ \t\n\"<>]*\\)" 200)) + (let ((beg (match-beginning 0)) + (end (match-end 0)) + (uri (match-string-no-properties 1)) + (label (match-string-no-properties 2))) + `(geo ,(my/geo-normalize uri label) ,beg . ,end)))) + +(defun my/geo-read-uri (prompt) + "Read a geo URI with PROMPT, defaulting to the one at point." + (let ((default (cadr (my/geo-at-point)))) + (read-string (format-prompt prompt default) nil nil default))) + +(defun my/geo-send-to-phone (uri) + "Send the place in geo URI to the phone." + (interactive (list (my/geo-read-uri "Send place to phone"))) + (my/localsend-send-text (my/geo-normalize uri))) + +(defun my/geo-show-on-map (uri) + "Show the place in geo URI on an osm.el map." + (interactive (list (my/geo-read-uri "Show place"))) + (pcase-let ((`(,lat ,lon) (or (my/geo-parse uri) + (user-error "Not a geo: URI: %s" uri)))) + (osm-url (format "geo:%s,%s;z=17" lat lon)))) + +(after! embark + (defvar-keymap my/embark-geo-map + :doc "Embark actions for geo: links." + :parent embark-general-map + "RET" #'my/geo-send-to-phone + "p" #'my/geo-send-to-phone + "m" #'my/geo-show-on-map) + (add-to-list 'embark-keymap-alist '(geo . my/embark-geo-map)) + (add-to-list 'embark-target-finders #'my/geo-at-point)) +;; geo: links:1 ends here + +;; [[file:config.org::*RSS][RSS:1]] +(setq rmh-elfeed-org-files '("feeds.org")) + +(after! elfeed + (setq elfeed-search-title-max-width 70) + ;; `elfeed-search-filter' is `:local t' (auto-buffer-local), so a plain + ;; `setq' here only sets it in whatever buffer is current when elfeed + ;; loads -- not the *elfeed-search* buffer. Needs `setq-default'. + (setq-default elfeed-search-filter "@2-week-ago +unread -forums -firehose") + (map! :map elfeed-search-mode-map "z" #'elfeed-tree)) + +(map! :leader :desc "Elfeed" "o n" #'elfeed) +;; RSS:1 ends here + +;; [[file:config.org::*RSS][RSS:2]] +(after! elfeed + (defvar-local +elfeed-show-linked-view nil + "Non-nil while the show buffer displays fetched link content instead of +the entry itself.") + + (defun +elfeed-show--link-candidates () + "Collect (LABEL . URL) for every link elfeed rendered in this buffer." + (let ((links nil) (pos (point-min))) + (save-excursion + (while (< pos (point-max)) + (if-let* ((url (get-text-property pos 'shr-url))) + (let* ((end (or (next-single-property-change pos 'shr-url nil (point-max)) + (point-max))) + (text (string-trim (buffer-substring-no-properties pos end)))) + (unless (or (string-empty-p text) (rassoc url links)) + (push (cons text url) links)) + (setq pos end)) + (setq pos (or (next-single-property-change pos 'shr-url nil (point-max)) + (point-max)))))) + (nreverse links))) + + (defun +elfeed-show-browse-link (&optional arg) + "Prompt for one of this entry's links and replace the buffer with its +fetched content, run through eww's readability filter to strip nav/header/ +sitemap boilerplate. Press `q' to return to the normal entry. + +A prefix argument toggles two independent options, stacked by how many +times \\[universal-argument] is pressed: + C-u show the fetched page as-is, skipping the readability filter + (useful when readability guts a non-article page, e.g. a + comment thread, rather than cleaning it up) + C-u C-u fetch through `mullvad-exclude curl', bypassing the VPN -- + useful for sites that block Mullvad exit nodes + C-u C-u C-u both of the above" + (interactive "P" elfeed-show-mode) + (when +elfeed-show-linked-view + (elfeed-show-refresh) + (setq +elfeed-show-linked-view nil)) + (let* ((level (if arg (round (log (prefix-numeric-value arg) 4)) 0)) + (raw (/= 0 (logand level 1))) + (bypass-vpn (/= 0 (logand level 2))) + (links (+elfeed-show--link-candidates)) + (candidates (mapcar (lambda (l) (cons (format "%s — %s" (car l) (cdr l)) (cdr l))) + links)) + (choice (completing-read "Open link: " candidates nil t)) + (url (cdr (assoc choice candidates)))) + (message "Fetching %s%s..." url (if bypass-vpn " (via mullvad-exclude)" "")) + (cl-letf* ((real-start-process (symbol-function 'start-process)) + ((symbol-function 'start-process) + (if bypass-vpn + (lambda (name buffer program &rest args) + (apply real-start-process name buffer "mullvad-exclude" program args)) + real-start-process))) + (elfeed-show--fetch + (lambda (fetched-url content) + (let ((inhibit-read-only t) + (elfeed-transform-html-functions (unless raw (list #'elfeed-show--readable)))) + (erase-buffer) + (pcase content + (:error (insert (propertize (format "Failed to fetch %s\n" fetched-url) 'face 'error))) + (:fetching (insert (propertize "Fetching...\n" 'face 'italic))) + ((pred stringp) (elfeed-insert-html content (elfeed-compute-base fetched-url)))) + (goto-char (point-min)) + (setq +elfeed-show-linked-view t))) + :url url :force t)))) + + (defun +elfeed-show-quit () + "Return to the normal entry view if a fetched link is showing, otherwise +quit the entry buffer as usual." + (interactive nil elfeed-show-mode) + (if +elfeed-show-linked-view + (progn (elfeed-show-refresh) (setq +elfeed-show-linked-view nil)) + (quit-window))) + + ;; Bound with the `:n' (normal-state) qualifier: elfeed-show-mode runs in + ;; evil normal state, which falls back to `evil-motion-state-map' for any + ;; key it doesn't bind itself -- and motion state already claims plain "L" + ;; for `evil-window-bottom'. A state-qualified `map!' installs into the + ;; mode's own normal-state override keymap, which evil consults first. + (map! :map elfeed-show-mode-map + :n "L" #'+elfeed-show-browse-link + :n "q" #'+elfeed-show-quit)) +;; RSS:2 ends here + +;; [[file:config.org::*Solar times][Solar times:1]] +(require 'solar) + +(if-let* ((lat (getenv "ASTRO_LAT")) + (long (getenv "ASTRO_LONG"))) + (setq calendar-latitude (string-to-number lat) + calendar-longitude (string-to-number long)) + (warn "ASTRO_LAT/ASTRO_LONG are unset: no solar times, weather, or planet positions")) + +(defun my/solar-assert-coordinates () + "Signal a `user-error' unless `calendar-latitude'/`calendar-longitude' are set." + (unless (and calendar-latitude calendar-longitude) + (user-error "No location: ASTRO_LAT/ASTRO_LONG were unset when Emacs started"))) +;; Solar times:1 ends here + +;; [[file:config.org::*Solar times][Solar times:2]] +(setq lunar-phase-names '("🌑 New Moon" "🌓 First Quarter Moon" + "🌕 Full Moon" "🌗 Last Quarter Moon")) +;; Solar times:2 ends here + +;; [[file:config.org::*Solar times][Solar times:3]] +(defun my/solar-times (&optional date) + "List (SUNRISE SOLAR-NOON SUNSET SOLAR-MIDNIGHT ZONE) for DATE. +Each time is a decimal hour; ZONE is the value `solar-time-string' wants +alongside it. DATE defaults to today. nil if the sun neither rises nor +sets on DATE, as happens near the poles, or if there are no coordinates." + (when (and calendar-latitude calendar-longitude) + (pcase-let ((`(,rise ,set ,_) (solar-sunrise-sunset (or date (calendar-current-date))))) + (when (and rise set) + (let* ((rise-time (car rise)) + (set-time (car set)) + (zone (cadr rise)) + (noon (/ (+ rise-time set-time) 2))) + (list rise-time noon set-time (mod (+ noon 12) 24) zone)))))) + +(defun my/diary-solar-times () + "Diary sexp: today's sunrise, solar noon, sunset, and solar midnight." + (with-no-warnings (defvar date)) + (when (calendar-date-equal date (calendar-current-date)) + (pcase (my/solar-times date) + (`(,rise ,noon ,set ,midnight ,zone) + (format "Sunrise %s | Noon %s | Sunset %s | Midnight %s" + (solar-time-string rise zone) + (solar-time-string noon zone) + (solar-time-string set zone) + (solar-time-string midnight zone)))))) +;; Solar times:3 ends here + +;; [[file:config.org::*Planetary hours][Planetary hours:1]] +(defconst my/planetary-hours-order + '(("Saturn" . "♄") ("Jupiter" . "♃") ("Mars" . "♂") ("Sun" . "☉") + ("Venus" . "♀") ("Mercury" . "☿") ("Moon" . "☽")) + "The seven classical planets and their glyphs, slowest to fastest, in +the order they take successive turns ruling a planetary hour.") + +(defconst my/planetary-hours-day-rulers + ["Sun" "Moon" "Mars" "Mercury" "Jupiter" "Venus" "Saturn"] + "Planet ruling a day's first hour, indexed like `calendar-day-of-week' +(Sunday first) -- also that day's own namesake.") +;; Planetary hours:1 ends here + +;; [[file:config.org::*Planetary hours][Planetary hours:2]] +(defun my/planetary-hours--moment (date hour) + "The moment decimal HOUR on DATE's clock comes round, HOUR read the +way `my/solar-times' gives it: local time, DST included." + (pcase-let ((`(,month ,day ,year) date) + (seconds (round (* hour 3600)))) + (encode-time (list (% seconds 60) (% (/ seconds 60) 60) (/ seconds 3600) + day month year nil -1 nil)))) + +(defun my/planetary-hours--hours (date) + "Compute the planetary hours ruling DATE. +Returns a list (SUNRISE SUNSET HOURS), SUNRISE and SUNSET as Lisp +timestamps. HOURS has 24 entries of (FROM TO NAME GLYPH), FROM/TO Lisp +timestamps too. The first twelve entries divide sunrise to sunset +evenly, the last twelve divide that sunset to the following sunrise +evenly, by the time that actually passes rather than what the clock +says, and together they cycle through `my/planetary-hours-order' +starting from the planet that rules DATE's weekday. Signals a +`user-error' if the sun neither rises nor sets on DATE or the day +after, as happens near the poles." + (my/solar-assert-coordinates) + (pcase-let* + ((`(,rise ,_ ,set ,_ ,_) + (or (my/solar-times date) + (user-error "The sun neither rises nor sets on %s" + (calendar-date-string date t)))) + (next-date (calendar-gregorian-from-absolute + (1+ (calendar-absolute-from-gregorian date)))) + (next-rise (or (car (my/solar-times next-date)) + (user-error "The sun neither rises nor sets on %s" + (calendar-date-string next-date t)))) + (sunrise (my/planetary-hours--moment date rise)) + (sunset (my/planetary-hours--moment date set)) + (next-sunrise (my/planetary-hours--moment next-date next-rise)) + (day-hour (/ (float-time (time-subtract sunset sunrise)) 12)) + (night-hour (/ (float-time (time-subtract next-sunrise sunset)) 12)) + (ruler (aref my/planetary-hours-day-rulers (calendar-day-of-week date))) + (start (seq-position my/planetary-hours-order ruler + (lambda (pair name) (equal (car pair) name)))) + (hours nil)) + (dotimes (i 24) + (let* ((night (>= i 12)) + (length (if night night-hour day-hour)) + (from (time-add (if night sunset sunrise) (* (mod i 12) length))) + (planet (nth (mod (+ start i) 7) my/planetary-hours-order))) + (push (list from (time-add from length) (car planet) (cdr planet)) hours))) + (list sunrise sunset (nreverse hours)))) +;; Planetary hours:2 ends here + +;; [[file:config.org::*Planetary hours][Planetary hours:3]] +(defun my/planetary-hours--format-time (date time) + "Format TIME as a clock time the way `solar-time-string' writes a +sunrise, tagged with its date once it has rolled past midnight into the +day after DATE." + ;; Rounded to the minute before decoding, not by `solar-time-string', + ;; which would print 11:59:45pm as 12:00pm, and on the wrong date. + (let* ((decoded (decode-time (time-add time 30))) + (clock (solar-time-string (+ (decoded-time-hour decoded) + (/ (decoded-time-minute decoded) 60.0)) + (format-time-string "%Z" time))) + (day (list (decoded-time-month decoded) (decoded-time-day decoded) + (decoded-time-year decoded)))) + (if (calendar-date-equal day date) + clock + (format "%s (%s)" clock (calendar-date-string day t t))))) + +(defun my/planetary-hours (date) + "Show which planet rules each hour of DATE in a `*Planetary Hours*' buffer. +Twelve day hours divide sunrise to sunset evenly, then twelve night +hours divide that sunset to the next sunrise evenly; both halves cycle +through the seven classical planets in Chaldean order, starting from +the planet ruling DATE's weekday. See `my/planetary-hours--hours'." + (interactive + (list (let ((decoded (decode-time + (org-read-date nil t nil "Planetary hours for date: ")))) + (list (decoded-time-month decoded) (decoded-time-day decoded) + (decoded-time-year decoded))))) + (pcase-let ((`(,sunrise ,sunset ,hours) (my/planetary-hours--hours date))) + (with-help-window "*Planetary Hours*" + (princ (format "Planetary hours for %s\n" (calendar-date-string date))) + (princ (format "Sunrise %s, sunset %s\n" + (my/planetary-hours--format-time date sunrise) + (my/planetary-hours--format-time date sunset))) + (dotimes (i 24) + (cond ((= i 0) (princ "\nDay hours\n")) + ((= i 12) (princ "\nNight hours\n"))) + (pcase-let ((`(,from ,to ,name ,glyph) (nth i hours))) + (princ (format "%2d. %s %-8s%s – %s\n" + (1+ (mod i 12)) glyph name + (my/planetary-hours--format-time date from) + (my/planetary-hours--format-time date to)))))))) + +(map! :leader :desc "Planetary hours" "o h" #'my/planetary-hours) +;; Planetary hours:3 ends here + +;; [[file:config.org::*Planetary hours][Planetary hours:4]] +(defun my/planetary-hours-at (time) + "Return the planetary hour ruling TIME, as (DAY-RULER HOUR-RULER NUMBER NIGHT). +DAY-RULER and HOUR-RULER are (NAME . GLYPH) pairs from +`my/planetary-hours-order'; NUMBER counts 1-12 through the day or night +hours, and NIGHT is non-nil for a night hour. The planetary day begins +at sunrise, so before sunrise TIME falls in the previous date's night. +See `my/planetary-hours--hours'." + (pcase-let* ((`(,_ ,_ ,_ ,day ,month ,year . ,_) (decode-time time)) + (date (list month day year)) + (`(,sunrise ,_ ,hours) (my/planetary-hours--hours date))) + (when (time-less-p time sunrise) + (setq date (calendar-gregorian-from-absolute + (1- (calendar-absolute-from-gregorian date))) + hours (nth 2 (my/planetary-hours--hours date)))) + (let* ((index (or (seq-position hours time + (lambda (entry at) (time-less-p at (nth 1 entry)))) + 23)) + (planet (lambda (entry) (cons (nth 2 entry) (nth 3 entry))))) + (list (funcall planet (car hours)) + (funcall planet (nth index hours)) + (1+ (mod index 12)) + (>= index 12))))) +;; Planetary hours:4 ends here + +;; [[file:config.org::*Planet positions][Planet positions:1]] +(defconst my/planet-positions-abbrevs + '(("Sun" . "Sun") ("Moon" . "Moon") ("Merc" . "Mercury") ("Venu" . "Venus") + ("Mars" . "Mars") ("Jupi" . "Jupiter") ("Satu" . "Saturn")) + "Astrolog's 4-column body abbreviations from its -Z chart, to the +full names `my/planetary-hours-order' uses for the same seven +classical planets.") + +(defconst my/planet-positions-line-re + (rx bol (group (= 4 anything)) ": " + (group (= 7 anything)) " " + (group (= 7 anything)) " (") + "Matches one body's line in astrolog's -Z local-horizon chart -- a +4-column abbreviation, then fixed 7-column altitude and azimuth +fields in astrolog's signed, space-padded DD:MM' format.") + +(defun my/planet-positions--program () + "Return the astrolog executable." + (or (executable-find "astrolog") + (user-error "astrolog is not on PATH"))) + +(defun my/planet-positions--angle (string) + "Parse an astrolog DD:MM' angle STRING -- optionally signed, and +space-padded in place of a leading zero -- into decimal degrees." + (let* ((clean (string-replace " " "" string)) + (negative (string-prefix-p "-" clean)) + (digits (string-remove-suffix + "'" (replace-regexp-in-string "\\`[+-]" "" clean))) + (parts (split-string digits ":"))) + (* (if negative -1 1) + (+ (string-to-number (nth 0 parts)) + (/ (string-to-number (nth 1 parts)) 60.0))))) + +(defun my/planet-positions--dms (value positive negative) + "Format signed decimal-degree VALUE as astrolog's DD:MMH string, +using the POSITIVE/NEGATIVE hemisphere letter for a non-negative/ +negative VALUE -- the same layout astro-transits +derives from $ASTRO_LAT/$ASTRO_LONG (see [[*Astrology]]), computed +here directly from `calendar-latitude'/`calendar-longitude' instead." + (let* ((hemi (if (< value 0) negative positive)) + (abs-value (abs value)) + (d (floor abs-value)) + (m (round (* 60 (- abs-value d))))) + (when (= m 60) (setq m 0 d (1+ d))) + (format "%d:%02d%s" d m hemi))) + +(defun my/planet-positions--azimuth (raw) + "Convert astrolog's RAW -Z azimuth (degrees counterclockwise from +due east) to a compass bearing in whole degrees, 0-359 clockwise +from north." + (mod (round (- 90 raw)) 360)) + +(defun my/planet-positions--data (&optional time lat long) + "Run astrolog for TIME (default now) and LAT/LONG decimal degrees +\(default `calendar-latitude'/`calendar-longitude'), returning an +alist of (NAME ALTITUDE AZIMUTH) for the seven classical planets -- +ALTITUDE decimal degrees above (positive) or below (negative) the +horizon, AZIMUTH an integer compass bearing 0-359. See +`my/planet-positions-line-re' for the chart astrolog is asked for." + (unless (and lat long) + (my/solar-assert-coordinates)) + (let* ((program (my/planet-positions--program)) + ;; `current-time-zone' already resolves the UTC offset that held + ;; at TIME, DST included, which is exactly what astrolog's -q + ;; wants as a plain civil time with no further -z0 adjustment -- + ;; see the note above about logging a past observation. + (west-hours (/ (- (car (current-time-zone time))) 3600)) + (lat-dms (my/planet-positions--dms (or lat calendar-latitude) "N" "S")) + (long-dms (my/planet-positions--dms (or long calendar-longitude) "E" "W")) + (date-args + (if time + (pcase-let ((`(,_ ,minute ,hour ,day ,month ,year . ,_) (decode-time time))) + (let* ((period (if (< hour 12) "am" "pm")) + (hour12 (let ((h (mod hour 12))) (if (zerop h) 12 h)))) + (list "-q" (number-to-string month) (number-to-string day) + (number-to-string year) + (format "%d:%02d%s" hour12 minute period)))) + (list "-n"))) + positions) + (with-temp-buffer + (unless (zerop (apply #'call-process program nil t nil + (append (list "-zl" long-dms lat-dms + "-z" (number-to-string west-hours) "-z0" "0") + date-args + (list "-Z" "-R" "11" "12" "13" "14" "15")))) + (user-error "astrolog failed: %s" (buffer-string))) + (goto-char (point-min)) + (while (re-search-forward my/planet-positions-line-re nil t) + ;; Collect every group before parsing any of them: the angle + ;; and label parsing below call `string-match' themselves, + ;; which would clobber this loop's own match data (see + ;; `my/astro--transit-row' above for the same gotcha). + (let ((label (match-string 1)) + (alt-str (match-string 2)) + (az-str (match-string 3))) + (let ((name (cdr (assoc (string-trim label) my/planet-positions-abbrevs))) + (altitude (my/planet-positions--angle alt-str)) + (azimuth (my/planet-positions--azimuth + (my/planet-positions--angle az-str)))) + (when name (push (list name altitude azimuth) positions)))))) + (nreverse positions))) +;; Planet positions:1 ends here + +;; [[file:config.org::*Planet positions][Planet positions:2]] +(defun my/planet-positions--format-altitude (deg) + "Format non-negative decimal degrees DEG as DD°MM'." + (let* ((d (floor deg)) (m (round (* 60 (- deg d))))) + (when (= m 60) (setq m 0 d (1+ d))) + (format "%d°%02d'" d m))) + +(defconst my/planet-positions-compass-points + ["N" "NE" "E" "SE" "S" "SW" "W" "NW"] + "8-point compass abbreviations, indexed by azimuth / 45, rounded.") + +(defun my/planet-positions--lines (positions) + "Format POSITIONS (see `my/planet-positions--data') as a list of +lines, one per classical planet in the same order and with the same +glyphs as [[*Planetary hours][=my/planetary-hours=]] +\(`my/planetary-hours-order', Chaldean, slowest to fastest), each +showing its altitude above/below the horizon and compass-bearing +azimuth." + (mapcar + (lambda (planet) + (pcase-let* ((`(,name . ,glyph) planet) + (`(,_ ,altitude ,azimuth) (assoc name positions))) + (if altitude + (format "%s %-8s%s %s horizon, azimuth %3d° %-2s" + glyph name + (my/planet-positions--format-altitude (abs altitude)) + (if (>= altitude 0) "above" "below") + azimuth + (aref my/planet-positions-compass-points + (mod (round (/ azimuth 45.0)) 8))) + (format "%s %-8s(no data)" glyph name)))) + my/planetary-hours-order)) + +(defun my/planet-positions () + "Show each classical planet's current altitude and azimuth in a +`*Planet Positions*' buffer: how far above or below the horizon it +is, and its compass bearing (0-359, 0 north through 90 east, 180 +south, 270 west). See `my/planet-positions--data'." + (interactive) + (with-help-window "*Planet Positions*" + (princ (format "Planet positions for %s\n\n" + (format-time-string "%Y-%m-%d %H:%M %Z"))) + (dolist (line (my/planet-positions--lines (my/planet-positions--data))) + (princ line) (princ "\n")))) + +(map! :leader :desc "Planet positions" "o P" #'my/planet-positions) +;; Planet positions:2 ends here + +;; [[file:config.org::*Planet positions][Planet positions:3]] +(defun my/astro-log-time () + "Return the Emacs time value of the Astronomy journal entry at point: +its UTC property if it has one, as the capture template sets it, +otherwise the first timestamp on its heading, read as local time the +way `%U' left it in entries from before that property existed." + (let ((utc (org-entry-get nil "UTC"))) + (if utc + (let ((decoded (org-parse-time-string utc))) + (setf (nth 7 decoded) nil ; DST is meaningless for UTC + (nth 8 decoded) 0) + (encode-time decoded)) + (save-excursion + (org-back-to-heading t) + (let ((eol (line-end-position))) + (if (re-search-forward org-ts-regexp-both eol t) + (org-time-string-to-time (match-string 0)) + (user-error "No timestamp on this heading"))))))) + +(defun my/astro-log-coordinates () + "Return (LAT . LONG) decimal degrees parsed from the COORDINATES +property of the entry at point, as the Astronomy capture template +sets it." + (let ((value (or (org-entry-get nil "COORDINATES") + (user-error "No COORDINATES property on this entry")))) + (pcase (split-string value "," t "[ \t]+") + (`(,lat ,long) (cons (string-to-number lat) (string-to-number long))) + (_ (user-error "COORDINATES property %S is not \"LAT, LONG\"" value))))) + +(defun my/planet-positions--rows (positions) + "Format POSITIONS (see `my/planet-positions--data') as Org table +rows, one list of cell strings per classical planet, in the same +order and with the same fields as `my/planet-positions--lines'." + (mapcar + (lambda (planet) + (pcase-let* ((`(,name . ,glyph) planet) + (`(,_ ,altitude ,azimuth) (assoc name positions)) + (label (format "%s %s" glyph name))) + (if altitude + (list label + (my/planet-positions--format-altitude (abs altitude)) + (if (>= altitude 0) "above" "below") + (format "%d°" azimuth) + (aref my/planet-positions-compass-points + (mod (round (/ azimuth 45.0)) 8))) + (list label "" "" "" "")))) + my/planetary-hours-order)) + +(defun my/astro-insert-planet-positions () + "Insert the classical planets' altitude/azimuth under the Org entry at +point as an Org table, computed for its own timestamp and COORDINATES +property rather than right now. See `my/astro-log-time' and +`my/astro-log-coordinates'." + (interactive) + (let* ((time (my/astro-log-time)) + (coords (my/astro-log-coordinates)) + (rows (my/planet-positions--rows + (my/planet-positions--data time (car coords) (cdr coords))))) + (org-back-to-heading t) + (org-end-of-meta-data t) + ;; An entry with nothing after its drawer, narrowed the way a + ;; capture buffer is, ends at :END: with no newline of its own. + (unless (bolp) (insert "\n")) + (insert (format "Planet positions for %s\n\n" + (format-time-string "%Y-%m-%d %H:%M %Z" time))) + (let ((start (point))) + (insert "| Planet | Altitude | Horizon | Azimuth | Direction |\n|-\n") + (dolist (row rows) + (insert "| " (string-join row " | ") " |\n")) + (save-excursion (goto-char start) (org-table-align))) + (insert "\n"))) + +(map! :map org-mode-map :localleader :desc "Planet positions" "a h" #'my/astro-insert-planet-positions) +;; Planet positions:3 ends here + +;; [[file:config.org::*Planet positions][Planet positions:4]] +(defconst my/planet-longitudes-signs + '("Ari" "Tau" "Gem" "Can" "Leo" "Vir" "Lib" "Sco" "Sag" "Cap" "Aqu" "Pis") + "Astrolog's three-letter sign abbreviations, in zodiacal order from Aries.") + +(defconst my/planet-longitudes-line-re + (rx bol (group (= 4 anything)) ": " + (group (= 2 (any " " digit))) (group (= 3 alpha)) (group (= 2 digit)) + " " (group (any " R"))) + "Matches one body's line in astrolog's default chart listing -- the +4-column abbreviation, the position as space-padded degree, sign and +minute, then the retrograde flag.") + +(defun my/planet-positions--longitudes (time) + "Run astrolog for TIME, returning an alist of (NAME LONGITUDE RETROGRADE) +for the seven classical planets -- LONGITUDE tropical ecliptic longitude +in decimal degrees from 0° Aries, RETROGRADE non-nil if the planet is." + (pcase-let ((`(,_ ,minute ,hour ,day ,month ,year . ,_) (decode-time time t)) + (longitudes nil)) + (with-temp-buffer + (unless (zerop (call-process (my/planet-positions--program) nil t nil + "-z" "0" "-z0" "0" + "-q" (number-to-string month) (number-to-string day) + (number-to-string year) (format "%d:%02d" hour minute) + "-R0" "1" "2" "3" "4" "5" "6" "7")) + (user-error "astrolog failed: %s" (buffer-string))) + (goto-char (point-min)) + (while (re-search-forward my/planet-longitudes-line-re nil t) + ;; Collected up front, as in `my/planet-positions--data', before + ;; anything below can clobber the match data. + (let ((label (match-string 1)) + (degree (string-to-number (match-string 2))) + (sign (seq-position my/planet-longitudes-signs (match-string 3))) + (minute (string-to-number (match-string 4))) + (retrograde (equal (match-string 5) "R"))) + (when-let* ((name (cdr (assoc (string-trim label) my/planet-positions-abbrevs)))) + (push (list name (+ (* 30 sign) degree (/ minute 60.0)) retrograde) + longitudes))))) + (nreverse longitudes))) +;; Planet positions:4 ends here + +;; [[file:config.org::*Equinoxes and solstices][Equinoxes and solstices:1]] +(defun my/diary-equinox-solstice (month label) + "Diary sexp: LABEL on whichever equinox/solstice falls nearest MONTH +(3, 6, 9, or 12) in DATE's year, local to `calendar-latitude'/ +`calendar-longitude'." + (with-no-warnings (defvar date)) + (pcase-let* ((`((,event-date ,description)) + (solar-equinoxes-solstices-1 month (calendar-extract-year date)))) + (when (calendar-date-equal date event-date) + (format "%s (%s)" label description)))) +;; Equinoxes and solstices:1 ends here + +;; [[file:config.org::*The Easter cycle][The Easter cycle:1]] +(require 'holidays) + +(defun my/diary-paschal-offset (easter-fn offset label) + "Diary sexp: LABEL on the day OFFSET days from the Easter/Pascha date +EASTER-FN (`holiday-easter-etc-abs' or `holiday-greek-orthodox-easter-abs') +computes for DATE's year." + (with-no-warnings (defvar date)) + (when (= (calendar-absolute-from-gregorian date) + (+ (funcall easter-fn (calendar-extract-year date)) offset)) + label)) +;; The Easter cycle:1 ends here + +;; [[file:config.org::*Hebrew calendar][Hebrew calendar:1]] +(require 'cal-hebrew) + +(defun my/diary-hebrew-holiday (month day label) + "Diary sexp: LABEL when DATE's Hebrew-calendar equivalent is MONTH/DAY." + (with-no-warnings (defvar date)) + (let ((hebrew (calendar-hebrew-from-absolute (calendar-absolute-from-gregorian date)))) + (when (and (= (calendar-extract-month hebrew) month) + (= (calendar-extract-day hebrew) day)) + label))) +;; Hebrew calendar:1 ends here + +;; [[file:config.org::*Islamic calendar][Islamic calendar:1]] +(require 'cal-islam) + +(defun my/diary-islamic-holiday (month day label) + "Diary sexp: LABEL when DATE's Islamic-calendar equivalent is MONTH/DAY." + (with-no-warnings (defvar date)) + (let ((islamic (calendar-islamic-from-absolute (calendar-absolute-from-gregorian date)))) + (when (and (= (calendar-extract-month islamic) month) + (= (calendar-extract-day islamic) day)) + label))) +;; Islamic calendar:1 ends here + +;; [[file:config.org::*Weather][Weather:1]] +(require 'url) + +(defconst my/weather-url + (and calendar-latitude calendar-longitude + (format "https://wttr.in/%s,%s?format=j1" + (getenv "ASTRO_LAT") (getenv "ASTRO_LONG"))) + "wttr.in's JSON forecast for the coordinates in `ASTRO_LAT'/`ASTRO_LONG', +or nil if those were unset.") + +(defvar my/weather-data nil + "Parsed j1 response from the last successful `my/weather-refresh', or nil +before the first fetch completes.") +;; Weather:1 ends here + +;; [[file:config.org::*Weather][Weather:2]] +(defun my/weather-refresh (&optional callback) + "Fetch the current forecast asynchronously into `my/weather-data'. +Runs CALLBACK with no arguments once the fetch completes, whether or not +it succeeded." + (interactive) + (unless my/weather-url + (my/solar-assert-coordinates)) + (url-retrieve + my/weather-url + (lambda (status) + (unwind-protect + (unless (plist-get status :error) + (goto-char url-http-end-of-headers) + (setq my/weather-data + (json-parse-buffer :object-type 'alist :array-type 'list))) + (kill-buffer (current-buffer)) + (when callback (funcall callback)))) + nil t t)) +;; Weather:2 ends here + +;; [[file:config.org::*Weather][Weather:3]] +(defun my/weather--desc (condition) + "The description string inside a wttr.in CONDITION alist." + (when-let* ((desc (alist-get 'value (car (alist-get 'weatherDesc condition))))) + (string-trim desc))) + +(defun my/weather--emoji (description) + "Emoji for wttr.in's DESCRIPTION string, by keyword; ☁️ if nothing matches." + (let ((d (downcase description))) + (cond ((string-match-p "thunder" d) "⛈️") + ((string-match-p "snow\\|sleet\\|blizzard\\|ice" d) "❄️") + ((string-match-p "rain\\|drizzle\\|shower" d) "🌧️") + ((string-match-p "fog\\|mist\\|haze" d) "🌫️") + ((string-match-p "overcast" d) "☁️") + ((string-match-p "cloud" d) "⛅") + ((string-match-p "clear\\|sunny" d) "☀️") + (t "☁️")))) + +(defun my/weather-current () + "Current-conditions alist from `my/weather-data', or nil." + (car (alist-get 'current_condition my/weather-data))) + +(defun my/weather-days () + "This and the next two days' forecast alists from `my/weather-data'." + (alist-get 'weather my/weather-data)) +;; Weather:3 ends here + +;; [[file:config.org::*Weather][Weather:4]] +(defun my/weather--hour-label (time) + "12-hour clock label for wttr.in's hourly TIME encoding (\"0\", \"300\", +..., \"2100\", each a 3-hour step from midnight)." + (let* ((hour (/ (string-to-number time) 100)) + (h12 (let ((h (mod hour 12))) (if (zerop h) 12 h)))) + (format "%d %s" h12 (if (< hour 12) "AM" "PM")))) + +(defun my/weather--block-start (date time) + "Encoded start time of the 3-hour block on DATE (\"YYYY-MM-DD\") named +by wttr.in's hourly TIME encoding (see `my/weather--hour-label')." + (pcase-let ((`(,y ,m ,d) (mapcar #'string-to-number (split-string date "-")))) + (encode-time 0 0 (/ (string-to-number time) 100) d m y))) + +(defun my/weather--timeline () + "Every hourly entry across `my/weather-days', each as a (START DATE +HOUR) list, in chronological order -- START is when that 3-hour block +begins and DATE is the \"YYYY-MM-DD\" it belongs to." + (seq-mapcat (lambda (day) + (let ((date (alist-get 'date day))) + (mapcar (lambda (hour) + (list (my/weather--block-start date (alist-get 'time hour)) + date hour)) + (alist-get 'hourly day)))) + (my/weather-days))) + +(defun my/weather--upcoming-hours () + "The next eight 3-hour blocks (~24 hours) from `my/weather-data', as +\(DATE HOUR) pairs starting with whichever block covers right now -- +rolling into tomorrow once today's remaining blocks run out, rather than +stopping dead at midnight." + (let ((now (current-time))) + (mapcar #'cdr + (seq-take + (seq-filter (lambda (entry) (time-less-p now (time-add (nth 0 entry) (* 3 3600)))) + (my/weather--timeline)) + 8)))) + +(defun my/weather--precip-chance (day) + "Highest chance of rain or snow, as a percentage, across DAY's hourly +entries." + (seq-max (mapcar (lambda (hour) + (max (string-to-number (alist-get 'chanceofrain hour)) + (string-to-number (alist-get 'chanceofsnow hour)))) + (alist-get 'hourly day)))) +;; Weather:4 ends here + +;; [[file:config.org::*Weather][Weather:5]] +(defun my/weather-forecast () + "Show current conditions, the next 24 hours, and the next two days' +forecast in a `*Weather*' buffer. Always fetches fresh data -- see +`my/weather-refresh'." + (interactive) + (my/weather-refresh + (lambda () + (unless (my/weather-current) + (user-error "Weather fetch failed")) + (with-help-window "*Weather*" + (let* ((current (my/weather-current)) + (desc (my/weather--desc current)) + (days (my/weather-days)) + (today (format-time-string "%Y-%m-%d")) + (last-date nil)) + (princ (format "%s %s, %s°F (feels like %s°F)\n" + (my/weather--emoji desc) desc + (alist-get 'temp_F current) (alist-get 'FeelsLikeF current))) + (princ (format "Humidity %s%%, cloud cover %s%%, wind %s mph %s\n" + (alist-get 'humidity current) + (alist-get 'cloudcover current) + (alist-get 'windspeedMiles current) + (alist-get 'winddir16Point current))) + (princ (format "UV index %s, pressure %s inHg, visibility %s mi\n\n" + (alist-get 'uvIndex current) + (alist-get 'pressureInches current) + (alist-get 'visibilityMiles current))) + (princ "Next 24 hours\n") + (pcase-dolist (`(,date ,hour) (my/weather--upcoming-hours)) + (unless (equal date last-date) + (princ (format " %s\n" (if (equal date today) "Today" "Tomorrow"))) + (setq last-date date)) + (let ((desc (my/weather--desc hour))) + (princ (format " %-5s %s %-22s %3s°F rain %s%%\n" + (my/weather--hour-label (alist-get 'time hour)) + (my/weather--emoji desc) desc + (alist-get 'tempF hour) (alist-get 'chanceofrain hour))))) + (princ "\nComing days\n") + (dolist (day days) + (let* ((desc (my/weather--desc (nth 4 (alist-get 'hourly day)))) + (astronomy (car (alist-get 'astronomy day)))) + (princ (format "%s %s %-22s %s–%s°F rain %s%% ↑%s ↓%s\n" + (alist-get 'date day) (my/weather--emoji desc) desc + (alist-get 'mintempF day) (alist-get 'maxtempF day) + (my/weather--precip-chance day) + (alist-get 'sunrise astronomy) (alist-get 'sunset astronomy)))))))))) + +(map! :leader :desc "Weather forecast" "o w" #'my/weather-forecast) +;; Weather:5 ends here + +;; [[file:config.org::*Modeline][Modeline:1]] +(defvar my/modeline-segments + '(my/lunar-current-phase-emoji + my/modeline-solar-times-string + my/modeline-weather-string) + "Functions whose strings make up `my/modeline-astro-string', in order. +Each is called with no arguments and returns a string, or nil to be left +out of the bar.") +;; Modeline:1 ends here + +;; [[file:config.org::*Current lunar phase][Current lunar phase:1]] +(require 'lunar) + +(defconst my/lunar-phases + [("🌑" . "New Moon") ("🌒" . "Waxing Crescent") ("🌓" . "First Quarter") + ("🌔" . "Waxing Gibbous") ("🌕" . "Full Moon") ("🌖" . "Waning Gibbous") + ("🌗" . "Last Quarter") ("🌘" . "Waning Crescent")] + "The moon's phase, at eighths of the way through its cycle, as (EMOJI . NAME).") +;; Current lunar phase:1 ends here + +;; [[file:config.org::*Current lunar phase][Current lunar phase:2]] +(defun my/lunar-next-phase (phase-index) + "The next `lunar-phase' event for PHASE-INDEX (0 new, 2 full) on or after today." + (let* ((today (calendar-current-date)) + (index (lunar-index today)) + (event (lunar-phase index))) + (while (or (/= (nth 2 event) phase-index) + (calendar-date-compare event (list today))) + (setq index (1+ index) + event (lunar-phase index))) + event)) + +(defun my/lunar-next-events-string () + "Next new and full moon dates, soonest first, for the lunar tooltip. +Emoji rather than names, from `my/lunar-phases', and no year -- both are +redundant in a tooltip about the next few weeks." + (let ((events (list (cons (car (aref my/lunar-phases 0)) (my/lunar-next-phase 0)) + (cons (car (aref my/lunar-phases 4)) (my/lunar-next-phase 2)))) + (calendar-date-display-form '((if dayname (concat dayname ", ")) monthname " " day))) + (mapconcat (lambda (e) (format "%s %s" (car e) (calendar-date-string (cadr e)))) + (sort events (lambda (a b) (calendar-date-compare (cdr a) (cdr b)))) + "\n"))) +;; Current lunar phase:2 ends here + +;; [[file:config.org::*Current lunar phase][Current lunar phase:3]] +(defun my/lunar-current-phase-emoji () + "Emoji for the moon's current phase; its name and the next new/full moon +as a hover tooltip." + (let* ((synodic-month 29.530588853) + (reference-new-moon (encode-time 0 14 18 6 1 2000 t)) + (days-elapsed (/ (float-time (time-subtract (current-time) reference-new-moon)) + 86400.0)) + (fraction (mod (/ days-elapsed synodic-month) 1.0)) + (phase (aref my/lunar-phases (min 7 (floor (* fraction 8)))))) + (propertize (car phase) + 'help-echo (concat (cdr phase) "\n" (my/lunar-next-events-string)) + 'mouse-face 'mode-line-highlight))) +;; Current lunar phase:3 ends here + +;; [[file:config.org::*Current solar times][Current solar times:1]] +(defun my/modeline-solar-times-string () + "Sun emoji for the modeline, the day's solar times as a hover tooltip." + (when-let* ((times (my/solar-times))) + (pcase-let ((`(,rise ,noon ,set ,midnight ,zone) times)) + (propertize + "☀️" + 'help-echo (format "🌅 Sunrise %s\n☀️ Solar noon %s\n🌇 Sunset %s\n🌃 Solar midnight %s" + (solar-time-string rise zone) + (solar-time-string noon zone) + (solar-time-string set zone) + (solar-time-string midnight zone)) + 'mouse-face 'mode-line-highlight)))) +;; Current solar times:1 ends here + +;; [[file:config.org::*Current weather][Current weather:1]] +(defun my/modeline-weather-string () + "Weather emoji for the modeline; current conditions and the next two +days' highs/lows as a hover tooltip. Nil until the first `my/weather-refresh' +completes." + (when-let* ((current (my/weather-current)) + (desc (my/weather--desc current))) + (propertize + (my/weather--emoji desc) + 'help-echo (mapconcat + #'identity + (cons (format "%s, %s°F (feels like %s°F)" + desc (alist-get 'temp_F current) (alist-get 'FeelsLikeF current)) + (mapcar (lambda (day) + (format "%s: %s–%s°F" (alist-get 'date day) + (alist-get 'mintempF day) (alist-get 'maxtempF day))) + (my/weather-days))) + "\n") + 'mouse-face 'mode-line-highlight))) +;; Current weather:1 ends here + +;; [[file:config.org::*Wiring][Wiring:1]] +(defvar my/modeline-astro-string "" + "Modeline string built from `my/modeline-segments'.") +(put 'my/modeline-astro-string 'risky-local-variable t) + +(defun my/modeline-astro-update () + "Recompute `my/modeline-astro-string' and redraw the modeline." + (setq my/modeline-astro-string + (concat " " (mapconcat #'identity + (delq nil (mapcar #'funcall my/modeline-segments)) + " | "))) + (force-mode-line-update 'all)) + +(or global-mode-string (setq global-mode-string '(""))) +(add-to-list 'global-mode-string 'my/modeline-astro-string 'append) +(my/modeline-astro-update) + +(defvar my/modeline-astro-timer + (run-at-time t 60 #'my/modeline-astro-update) + "Timer that keeps `my/modeline-astro-string' refreshed every minute.") + +(defvar my/weather-refresh-timer + (when my/weather-url + (my/weather-refresh #'my/modeline-astro-update) + (run-at-time 1800 1800 (lambda () (my/weather-refresh #'my/modeline-astro-update)))) + "Timer that refetches `my/weather-data' every 30 minutes, redrawing the +modeline as soon as each fetch completes rather than waiting for the +minute-interval timer above to notice. nil when there is no location to +fetch weather for.") +;; Wiring:1 ends here + +;; [[file:config.org::*Daemon][Daemon:1]] +(defun my/restart-emacs-daemon () + "Restart the Emacs daemon via `systemctl --user restart' and reopen a frame." + (interactive) + (call-process "systemd-run" nil 0 nil + "--user" "--collect" "--" + "sh" "-c" "systemctl --user restart emacs.service && emacsclient -c")) + +(map! :leader :desc "Restart daemon (systemctl)" "q r" #'my/restart-emacs-daemon) +;; Daemon:1 ends here + +;; [[file:config.org::*Claude Code][Claude Code:1]] +(use-package! claude-code + :bind-keymap ("C-c c" . claude-code-command-map) + :init + (setq claude-code-terminal-backend 'vterm) + :config + (claude-code-mode)) +;; Claude Code:1 ends here + +;; [[file:config.org::*TMSU][TMSU:1]] +(defcustom my/tmsu-mount-directory "~/tmsu" + "Default parent directory offered when prompting for a TMSU mount point." + :type 'directory) + +(defun my/tmsu-mounts () + "Return an alist of (MOUNTPOINT . DATABASE) for mounted TMSU filesystems." + (with-temp-buffer + (process-file "tmsu" nil t nil "mount") + (mapcar (lambda (line) + (let ((fields (split-string line "\t"))) + (cons (car (last fields)) (car fields)))) + (split-string (string-trim (buffer-string)) "\n" t)))) + +(defun my/tmsu-mount (mountpoint &optional database) + "Mount a TMSU virtual filesystem at MOUNTPOINT and open it in dired. + +With a prefix arg, prompt for a specific DATABASE file; otherwise TMSU's +usual discovery (TMSU_DB, then the nearest .tmsu, then the default +database) applies." + (interactive + (list (read-directory-name "Mount point: " my/tmsu-mount-directory) + (when current-prefix-arg + (read-file-name "Database: ")))) + (make-directory mountpoint t) + (let ((args (append (when database (list (file-local-name database))) + (list (file-local-name mountpoint))))) + (unless (zerop (apply #'process-file "tmsu" nil nil nil "mount" args)) + (error "tmsu mount failed"))) + (dired mountpoint)) + +(defun my/tmsu-mount-dired (mountpoint) + "Open dired on an already-mounted TMSU virtual filesystem." + (interactive + (let ((mounts (my/tmsu-mounts))) + (unless mounts (user-error "No TMSU filesystems mounted")) + (list (completing-read "TMSU mount: " mounts nil t)))) + (dired mountpoint)) + +(defun my/tmsu-unmount (mountpoint) + "Unmount the TMSU virtual filesystem at MOUNTPOINT." + (interactive + (let ((mounts (my/tmsu-mounts))) + (unless mounts (user-error "No TMSU filesystems mounted")) + (list (if (cdr mounts) + (completing-read "Unmount: " mounts nil t) + (caar mounts))))) + (unless (zerop (process-file "tmsu" nil nil nil "unmount" (file-local-name mountpoint))) + (error "tmsu unmount failed")) + (when-let* ((buf (dired-find-buffer-nocreate (file-name-as-directory mountpoint)))) + (kill-buffer buf)) + (message "Unmounted %s" mountpoint)) + +(defun my/tmsu-dired-edit-marked () + "Edit TMSU tags of each marked file in turn, or the file at point." + (interactive) + (dolist (file (dired-get-marked-files)) + (tmsu-edit file))) + +(defun my/tmsu-init (dir) + "Initialize a new TMSU database in DIR. + +Safe to run again on a directory that already has one; TMSU leaves +the existing database untouched." + (interactive (list (if (eq major-mode 'dired-mode) + (dired-current-directory) + default-directory))) + (unless (zerop (process-file "tmsu" nil nil nil "init" (file-local-name dir))) + (error "tmsu init failed")) + (message "Initialized TMSU database in %s" dir)) + +(defun my/tmsu-dired-overlay-all () + "Show TMSU tag overlays for every tag, without prompting." + (interactive nil dired-mode) + (require 'tmsu-dired-overlay) + (unless (tmsu-database-p) + (error "No TMSU database")) + (tmsu-dired-overlay-delete-overlays) + (tmsu-dired-overlay-create-overlays)) + +(defun my/tmsu-imply--candidates () + "Return the current TMSU tag implications as \"TAG -> IMPL\" strings." + (with-temp-buffer + (process-file "tmsu" nil t nil "imply") + (split-string (string-trim (buffer-string)) "\n" t))) + +(defun my/tmsu-imply-list () + "Show the current TMSU tag implications in a buffer." + (interactive) + (unless (tmsu-database-p) + (error "No TMSU database")) + (with-current-buffer (get-buffer-create "*TMSU implications*") + (erase-buffer) + (insert (string-join (or (my/tmsu-imply--candidates) + (list "No implications defined.")) + "\n") + "\n") + (goto-char (point-min)) + (view-mode 1) + (pop-to-buffer (current-buffer)))) + +(defun my/tmsu-imply-create (tag implication) + "Make TAG imply IMPLICATION for all future TMSU queries." + (interactive + (list (completing-read "Tag: " (tmsu-get-tags) nil nil nil 'tmsu-query-history) + (completing-read "Implies: " (tmsu-get-tags) nil nil nil 'tmsu-query-history))) + (unless (zerop (process-file "tmsu" nil nil nil "imply" tag implication)) + (error "tmsu imply failed")) + (message "%s -> %s" tag implication)) + +(defun my/tmsu-imply-delete (implication) + "Delete an existing TMSU tag IMPLICATION (a \"TAG -> IMPL\" string)." + (interactive + (list (completing-read "Delete implication: " (my/tmsu-imply--candidates) nil t))) + (let* ((parts (split-string implication "\\s-*->\\s-*")) + (tag (string-trim (car parts))) + (impl (string-trim (cadr parts)))) + (unless (zerop (process-file "tmsu" nil nil nil "imply" "--delete" tag impl)) + (error "tmsu imply --delete failed")) + (message "Deleted: %s -> %s" tag impl))) + +(defun my/tmsu-imply () + "Create, list, or delete a TMSU tag implication." + (interactive) + (unless (tmsu-database-p) + (error "No TMSU database")) + (pcase (completing-read "Implication: " '("create" "list" "delete") nil t) + ("create" (call-interactively #'my/tmsu-imply-create)) + ("list" (my/tmsu-imply-list)) + ("delete" (call-interactively #'my/tmsu-imply-delete)))) + +(defun my/tmsu-repair () + "Drop TMSU tags left behind by files that no longer exist. + +Runs `tmsu repair --pretend' and shows what it found; with nothing to +fix, just reports the database clean. Otherwise asks for confirmation +before re-running with `--remove' to actually drop those files' tags." + (interactive) + (unless (tmsu-database-p) + (error "No TMSU database")) + (with-current-buffer (get-buffer-create "*TMSU repair*") + (let ((inhibit-read-only t)) + (erase-buffer) + (process-file "tmsu" nil t nil "repair" "--pretend") + (goto-char (point-min))) + (if (= (point-min) (point-max)) + (progn (message "TMSU database is clean.") + (kill-buffer)) + (view-mode 1) + (pop-to-buffer (current-buffer)) + (if (y-or-n-p "Remove tags for the missing files above? ") + (let ((inhibit-read-only t)) + (erase-buffer) + (process-file "tmsu" nil t nil "repair" "--remove") + (goto-char (point-min)) + (message "TMSU database repaired.")) + (quit-window))))) + +(use-package! tmsu + :after dired + :config + (tmsu-dired-rename-repair-mode 1)) + +(use-package! ol-tmsu + :when (executable-find "tmsu") + :after org) + +(after! (tmsu-dired dirvish) + (defvar my/tmsu-dired-query-returns (make-hash-table :test 'eq) + "Buffer -> (DV . OLD-DV-INDEX), keyed by buffer object rather than a +buffer-local variable because `tmsu-dired-query' calls +`kill-all-local-variables' on every refresh, including when reverting its +own results buffer, which would otherwise wipe the saved return state.") + + (defun my/dirvish-set-index (dv index) + "Set DV's `dv-index' slot to INDEX. + +Uses `aset' with `cl-struct-slot-offset' instead of `(setf (dv-index dv) +...)'. Dirvish's `dirvish' struct only exists once Dirvish has loaded, +but this file gets compiled before that happens; a `setf' place expanded +that early bakes in a call to a nonexistent `(setf dv-index)' function +that keeps failing even after Dirvish loads and the struct exists. `aset' +and `cl-struct-slot-offset' are plain functions resolved by name at call +time, so they aren't affected by what was or wasn't loaded at compile +time." + (aset dv (cl-struct-slot-offset 'dirvish 'index) index)) + + (defun my/tmsu-dired-query-quit () + "Leave a TMSU query view, restoring the Dirvish directory it came from." + (interactive) + (if-let* ((state (gethash (current-buffer) my/tmsu-dired-query-returns)) + (dv (car state)) + (old-index (cdr state)) + (old-buf (cdr old-index)) + ((buffer-live-p old-buf))) + (let ((qbuf (current-buffer))) + (my/dirvish-set-index dv old-index) + (my/dirvish-reusing-window (switch-to-buffer old-buf)) + (kill-buffer qbuf)) + (quit-window))) + + (defun my/tmsu-dired-query-dirvish-a (orig-fn &rest args) + "Make `tmsu-dired-query' replace the current Dirvish directory in place. + +Reuses Dirvish's window and preview layout instead of popping a separate +window, and rebinds `q' in the results buffer to return to the directory +the query was run from. A no-op outside of an active Dirvish session." + (let* ((dv (dirvish-curr)) + (old-index (and dv (dv-index dv)))) + (if (not dv) + (apply orig-fn args) + (my/dirvish-reusing-window (apply orig-fn args)) + (let ((qbuf (current-buffer))) + (with-current-buffer qbuf + (dirvish-prop :dv (dv-id dv)) + (dirvish-prop :attrs (dv-attributes dv)) + (dirvish-prop :preview-dps (dv-preview-dispatchers dv)) + (dirvish-prop :gui (display-graphic-p)) + (dirvish-prop :root default-directory) + (dirvish--setup-dired) + (add-hook 'kill-buffer-hook + (let ((buf qbuf)) + (lambda () (remhash buf my/tmsu-dired-query-returns))) + nil t) + (unless (gethash qbuf my/tmsu-dired-query-returns) + (puthash qbuf (cons dv old-index) my/tmsu-dired-query-returns)) + (evil-local-set-key 'normal "q" #'my/tmsu-dired-query-quit)) + (my/dirvish-set-index + dv (cons (file-name-as-directory (expand-file-name default-directory)) + qbuf)) + (dirvish--setup-mode-line dv))))) + + (advice-add #'tmsu-dired-query :around #'my/tmsu-dired-query-dirvish-a) + + (map! :map dirvish-mode-map + (:prefix ("t" . "tmsu") + :n "m" #'my/tmsu-mount + :n "u" #'my/tmsu-unmount + :n "j" #'my/tmsu-mount-dired + :n "i" #'my/tmsu-init + :n "a" #'tmsu-dired-tags-add + :n "r" #'tmsu-dired-tags-remove + :n "e" #'my/tmsu-dired-edit-marked + :n "d" #'tmsu-dired-edit-directory + :n "o" #'tmsu-dired-overlay + :n "O" #'my/tmsu-dired-overlay-all + :n "q" #'tmsu-dired-query + :n "I" #'my/tmsu-imply + :n "R" #'my/tmsu-repair))) +;; TMSU:1 ends here + +;; [[file:config.org::*Private overlay][Private overlay:1]] +(load! "private/config" doom-user-dir t) +;; Private overlay:1 ends here |