;;; 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 "

%s

\n%s%s" anchor title (if date (format "

%s

\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 "

%s

\n
\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
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 "" #'calibredb-toggle-view :n "" #'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
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