Commit af90ba62e992
Changed files (9)
dots
home
common
dev
dots/config/emacs/site-lisp/ext-whisper.el
@@ -1,636 +0,0 @@
-;;; whisper.el --- Speech-to-Text interface using OpenAI's whisper model -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2022 Imran Khan.
-
-;; Author: Imran Khan <imran@khan.ovh>
-;; URL: https://github.com/natrys/whisper.el
-;; Version: 0.3.3
-;; Package-Requires: ((emacs "27.1"))
-
-;; This file is NOT part of GNU Emacs.
-
-;; This program is free software; you can redistribute it and/or modify
-;; it under the terms of the GNU General Public License as published by
-;; the Free Software Foundation, either version 3 of the License, or (at
-;; your option) any later version.
-;;
-;; This program is distributed in the hope that it will be useful,
-;; but WITHOUT ANY WARRANTY; without even the implied warranty of
-;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-;; GNU General Public License for more details.
-;;
-;; You should have received a copy of the GNU General Public License
-;; along with this program. If not, see <https://www.gnu.org/licenses/>.
-
-;;; Commentary:
-;;
-;; Speech-to-Text interface for Emacs using OpenAI's whisper model
-;; Uses the awesome C/C++ port that runs on CPU.
-;; See: https://github.com/ggerganov/whisper.cpp
-;;
-;;; Code:
-
-(require 'cl-lib)
-
-;;; User facing options
-
-(defgroup whisper ()
- "Speech-to-text interface using OpenAI's whisper model."
- :group 'external)
-
-(defcustom whisper-enable-speed-up nil
- "Whether to sacrifices some accuracy to speed up transcribing.
-
-Basically whether to use \"-su\" flag in whisper.cpp. You should experiment
-enabling it to see if it works well enough for you.
-
-It's currently disabled by upstream because of bugs, so does nothing."
- :type 'boolean
- :group 'whisper)
-
-(defcustom whisper-use-threads nil
- "How many threads to use for transcribing.
-
-Default is whisper.cpp default (which is number of cores but maxed at 4)."
- :type 'integer
- :group 'whisper)
-
-(defcustom whisper-install-directory (locate-user-emacs-file ".cache/")
- "Location of where whisper.cpp is installed."
- :type 'directory
- :group 'whisper)
-
-(defcustom whisper-recording-timeout 300
- "Number of seconds after which recording will be automatically stopped."
- :type '(choice integer (const nil))
- :group 'whisper)
-
-(defcustom whisper-model "base"
- "Which whisper model to use (default is base).
-
-Choose between: tiny, base, small, medium, large.
-
-The first four comes with .en variant that only works with English, but
-might speed up transcribing."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-language "en"
- "Set spoken language for audio.
-
-When dealing with unknown language, set this to `auto'.
-
-Be sure to use generic model (without .en suffix) when language is not English."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-translate nil
- "Whether to translate to English first, before transcribing."
- :type 'boolean
- :group 'whisper)
-
-(defcustom whisper-quantize nil
- "Whether to use quantized version of the model in whisper.cpp.
-
-Quantization is a technique to reduce the computational and memory costs of
-running inference by representing the weights and activations with low-precision
-data types. This sacrifices precision for resource efficiency. The idea is
-that quantized version of bigger model may afford you to use it (if you are RAM
-constrained e.g.) with some penalty, while still being better than the smaller
-model you would be using otherwise.
-
-Valid values are (from lowest to highest quality):
-- q4_0
-- q4_1
-- q4_k
-- q5_0
-- q5_1
-- q5_k
-- q6_k
-- q8_0"
- :type '(choice string (const nil))
- :group 'whisper)
-
-(defcustom whisper-install-whispercpp t
- "Specify whether to install whisper.cpp automatically.
-
-By default whisper.el compiles whisper.cpp automatically. But if you are on a
-platform where our automatic whisper.cpp install doesn't work but you are able
-to do so manually, you can set this to `manual' to skip our try (and failure)
-to install it automatically. Note that in case a functional install is found
-at `whisper-install-directory', we can still do model download, quantization
-automatically.
-
-But if you are planning to use something other than whisper.cpp entirely, as
-such don't want to install it nor run checks for it, you may opt out of
-whisper.cpp as a whole by setting this to nil. In that case it's your
-responsibility to override `whisper-command' with appropriate function."
- :type '(choice boolean (const manual))
- :group 'whisper)
-
-(defcustom whisper-insert-text-at-point t
- "Whether to put whisper output under point in current buffer.
-
-When nil, instead of inserting text under current point, a temporary buffer
-containing whisper output text is displayed. The buffer name is distinguised
-with current timestamp and it's the user's responsibility to kill the buffer if
-they want to."
- :type 'boolean
- :group 'whisper)
-
-(defcustom whisper-return-cursor-to-start t
- "Whether to re-position the cursor after transcription.
-
-When non-nil, the cursor is returned to the original invocation point.
-Otherwise, the cursor remains at the end of the inserted transcription."
- :type 'boolean
- :group 'whisper)
-
-(defcustom whisper-show-progress-in-mode-line t
- "Whether to show transcription progress in mode line."
- :type 'boolean
- :group 'whisper)
-
-(define-obsolete-variable-alias 'whisper-pre-process-hook 'whisper-before-transcription-hook "0.3.0")
-(defcustom whisper-before-transcription-hook '(whisper--check-buffer-read-only-p)
- "Hook run before whisper.el does anything."
- :type 'hook
- :group 'whisper)
-
-(define-obsolete-variable-alias 'whisper-post-process-hook 'whisper-after-transcription-hook "0.3.0")
-(defcustom whisper-after-transcription-hook nil
- "Hook run after whisper command finishes producing output.
-
-If you want to transform the command output text in some way before they are
-inserted into the original buffer, add your function here. Each function in
-the hook will be run in a buffer containing the whisper command output text
-as its current buffer, and with point set to beginning of that buffer."
- :type 'hook
- :group 'whisper)
-
-(defcustom whisper-after-insert-hook nil
- "Hook run after whisper command has inserted the transcription.
-
-This hook will be run from the buffer in which the transcription was inserted."
- :type 'hook
- :group 'whisper)
-
-;;; Internal variables
-
-(defvar whisper--stdout-buffer-name "*whisper-stdout*")
-(defvar whisper--stderr-buffer-name "*whisper-stderr*")
-(defvar whisper--compilation-buffer-name "*whisper-compilation*")
-
-(defvar whisper--point-buffer nil)
-(defvar whisper--compilation-buffer nil)
-
-(defvar whisper--recording-process nil)
-(defvar whisper--transcribing-process nil)
-(defvar whisper--marker (make-marker))
-
-(defvar whisper--install-path "/tmp")
-
-(defvar whisper--temp-file
- (concat (temporary-file-directory) "emacs-whisper.wav")
- "Location of the temporary audio file.")
-
-(defvar whisper--ffmpeg-input-format
- (pcase system-type
- ('gnu/linux (if (or (executable-find "pulseaudio")
- (executable-find "pipewire-pulse"))
- "pulse"
- "alsa"))
- ('darwin "avfoundation")
- ('windows-nt "dshow")
- (_ nil)))
-
-(defvar whisper--ffmpeg-input-device
- (pcase whisper--ffmpeg-input-format
- ("pulse" "default")
- (_ nil)))
-
-(defvar whisper--ffmpeg-input-file nil)
-
-(defvar whisper--using-whispercpp nil)
-
-(defvar whisper--progress-level "0")
-
-(defvar whisper--mode-line-recording-indicator
- (propertize "" 'face font-lock-warning-face))
-
-(defvar whisper--mode-line-transcribing-indicator
- (propertize "" 'face font-lock-warning-face))
-
-(defun whisper--check-buffer-read-only-p ()
- "Error out if current buffer is read-only."
- (when (and whisper-insert-text-at-point buffer-read-only)
- (error "Buffer is read-only, can't insert text here")))
-
-;; Maybe sox would be a lighter choice for something this simple?
-(defun whisper--record-command (output-file)
- "Produces FFmpeg command to be run given location of OUTPUT-FILE."
- (unless (executable-find "ffmpeg")
- (error "Needs FFmpeg to record audio"))
-
- (unless (or whisper--ffmpeg-input-file
- whisper--ffmpeg-input-format)
- (error "Set a suitable value for whisper--ffmpeg-input-format"))
-
- (unless (or whisper--ffmpeg-input-file
- whisper--ffmpeg-input-device)
- (error "Set a suitable value for whisper--ffmpeg-input-device"))
-
- `("ffmpeg"
- ,@(unless whisper--ffmpeg-input-file
- (list "-f" whisper--ffmpeg-input-format))
- "-i" ,(or whisper--ffmpeg-input-file whisper--ffmpeg-input-device)
- ,@(when (and (not whisper--ffmpeg-input-file) whisper-recording-timeout)
- (list "-t" (number-to-string whisper-recording-timeout)))
- "-ar" "16000"
- "-y" ,output-file))
-
-(defun whisper-command (input-file)
- "Produces whisper.cpp command to be run on the INPUT-FILE.
-
-If you want to use something other than whisper.cpp, you should override this
-function to produce the command for the inference engine of your choice."
- `(,(whisper--find-whispercpp-main)
- ,@(when whisper-use-threads (list "--threads" (number-to-string whisper-use-threads)))
- ;; ,@(when whisper-enable-speed-up '("--speed-up"))
- ,@(when whisper-translate '("--translate"))
- ,@(when whisper-show-progress-in-mode-line '("--print-progress"))
- "--language" ,whisper-language
- "--model" ,(whisper--model-file whisper-quantize)
- "--no-timestamps"
- "--file" ,input-file))
-
-(defalias 'whisper--transcribe-command 'whisper-command)
-(make-obsolete 'whisper--transcribe-command 'whisper-command "0.1.6")
-
-(defun whisper--mode-line-indicator (phase)
- "Determine what to show in mode line depending on PHASE."
- (if (eq phase 'recording)
- whisper--mode-line-recording-indicator
- (if whisper--using-whispercpp
- '(:eval (concat whisper--mode-line-transcribing-indicator whisper--progress-level "%%"))
- whisper--mode-line-transcribing-indicator)))
-
-(defun whisper--setup-mode-line (command phase)
- "Set up PHASE appropriate indicator in the mode line.
-
-Depending on the COMMAND we either show the indicator or hide it."
- (when whisper-show-progress-in-mode-line
- (let ((indicator `(t ,(whisper--mode-line-indicator phase))))
- (if (eq command :show)
- (cl-pushnew indicator global-mode-string :test #'equal)
- (setf global-mode-string (remove indicator global-mode-string))
- (setq whisper--progress-level "0")))
- (force-mode-line-update t)))
-
-(defun whisper--get-whispercpp-progress (_process output)
- "Notify user of transcription progress by parsing whisper.cpp OUTPUT."
- (let ((marker "whisper_print_progress_callback: progress ="))
- (when (string-match (rx-to-string `(seq bol ,marker (* blank) (group (+ digit)) "%")) output)
- (setq whisper--progress-level (match-string 1 output))
- (force-mode-line-update))))
-
-(defun whisper--using-whispercpp-p ()
- "Crude way to check we are in fact using whisper.cpp."
- (let ((command (car (whisper-command whisper--temp-file)))
- (pattern '(seq (or bol (any "/\\"))
- (or "main" "whisper-cli")
- (? ".exe")
- eol)))
- (or (string-match-p (rx-to-string pattern) command)
- ;; for the staunch Nix user
- (string-equal command "whisper-cpp"))))
-
-(defun whisper--find-whispercpp-main ()
- "Find whisper.cpp main binary in a backward compatible way."
- (executable-find "whisper-cli"))
-
-(defun whisper--record-audio ()
- "Start audio recording process in the background."
- (when whisper-insert-text-at-point
- (with-current-buffer whisper--point-buffer
- (setq whisper--marker (point-marker))))
- (if whisper--ffmpeg-input-file
- (message "[*] Pre-processing media file")
- (message "[*] Recording audio")
- (whisper--setup-mode-line :show 'recording))
- (if (string-equal whisper--ffmpeg-input-file whisper--temp-file)
- (whisper--transcribe-audio)
- (setq whisper--recording-process
- (make-process
- :name "whisper-recording"
- :command (whisper--record-command whisper--temp-file)
- :connection-type nil
- :buffer nil
- :sentinel (lambda (_process event)
- (whisper--setup-mode-line :hide 'recording)
- (cond ((or (string-equal "finished\n" event)
- ;; this is would be sane
- (string-equal "terminated\n" event)
- ;; but this is reality
- (string-equal "exited abnormally with code 255\n" event))
- (whisper--transcribe-audio))
- ((string-match-p "exited abnormally with code [0-9]+\n" event)
- (if whisper--ffmpeg-input-file
- (error "FFmpeg failed to convert given file")
- (error "FFmpeg failed to record audio")))))))))
-
-(defun whisper--transcribe-audio ()
- "Start audio transcribing process in the background."
- (message "[-] Transcribing/Translating audio")
- (setq whisper--using-whispercpp (whisper--using-whispercpp-p))
- (whisper--setup-mode-line :show 'transcribing)
- (setq whisper--transcribing-process
- (make-process
- :name "whisper-transcribing"
- :command (whisper-command whisper--temp-file)
- :connection-type nil
- :buffer (get-buffer-create whisper--stdout-buffer-name)
- :stderr (if (and whisper-show-progress-in-mode-line whisper--using-whispercpp)
- (make-pipe-process
- :name "whisper-stderr"
- :filter #'whisper--get-whispercpp-progress)
- (get-buffer-create whisper--stderr-buffer-name))
- :coding 'utf-8
- :sentinel (lambda (_process event)
- (unwind-protect
- (when-let* ((whisper--stdout-buffer (get-buffer whisper--stdout-buffer-name))
- (finished (and (buffer-live-p whisper--stdout-buffer)
- (string-equal "finished\n" event))))
- (with-current-buffer whisper--stdout-buffer
- (goto-char (point-min))
- (skip-chars-forward " \n")
- (when (> (point) (point-min))
- (delete-region (point-min) (point)))
- (goto-char (point-max))
- (skip-chars-backward " \n")
- (when (> (point-max) (point))
- (delete-region (point) (point-max)))
- (when (= (buffer-size) 0)
- (error "Whisper command produced no output"))
- (goto-char (point-min))
- (run-hook-wrapped 'whisper-after-transcription-hook
- (lambda (f)
- (with-current-buffer whisper--stdout-buffer
- (save-excursion
- (funcall f)))
- nil))
- (when (> (buffer-size) 0)
- (if whisper-insert-text-at-point
- (with-current-buffer (marker-buffer whisper--marker)
- (goto-char whisper--marker)
- (insert-buffer-substring whisper--stdout-buffer)
- (when whisper-return-cursor-to-start
- (goto-char whisper--marker)))
- (with-current-buffer
- (get-buffer-create
- (format "*whisper-%s*" (format-time-string "%+4Y%m%d%H%M%S")))
- (insert-buffer-substring whisper--stdout-buffer)
- (display-buffer (current-buffer)))))))
- (set-marker whisper--marker nil)
- (setq whisper--point-buffer nil)
- (kill-buffer whisper--stdout-buffer-name)
- (unless whisper-show-progress-in-mode-line (kill-buffer whisper--stderr-buffer-name))
- (whisper--setup-mode-line :hide 'transcribing)
- (message nil)
- (run-hooks 'whisper-after-insert-hook))))))
-
-(defun whisper--check-model-consistency ()
- "Check if chosen language and model are consistent."
- (when (and (not (string-equal "en" whisper-language))
- (string-suffix-p ".en" whisper-model))
- (error "Use generic model (non .en version) for non-English languages"))
-
- (unless (or (= 2 (length whisper-language))
- (string-equal "auto" whisper-language))
- (error (concat "Unknown language shortcode. If unsure use 'auto'. For full list, see: "
- "https://github.com/ggerganov/whisper.cpp/blob/master/whisper.cpp")))
-
- (let ((model-pattern (rx (seq bol
- (or "tiny" "base" "small" "medium"
- (seq "large" (opt (seq "-v" (any "1-3") (opt "-turbo")))))
- (opt (seq "." (= 2 (any "a-z"))))
- eol)))
- (quantization-pattern (rx (or "q4_0" "q4_1" "q4_k" "q5_0" "q5_1" "q5_k" "q6_k" "q8_0"))))
- (unless (string-match-p model-pattern whisper-model)
- (error (concat "Speech recognition model " whisper-model " not recognised. For the list, see: "
- "https://github.com/ggerganov/whisper.cpp/tree/master/models")))
- (when whisper-quantize
- (unless (string-match-p quantization-pattern whisper-quantize)
- (error "Quantization format not recognized")))))
-
-(defun whisper--model-file (quantized)
- "Return path of QUANTIZED model file relative to `whisper-install-directory'."
- (let ((base (concat
- (expand-file-name (file-name-as-directory whisper-install-directory))
- "whisper.cpp/"))
- (name (if quantized (concat whisper-model "-" whisper-quantize) whisper-model)))
- (concat base "models/ggml-" name ".bin")))
-
-(defun whisper--check-install-and-run (buffer status)
- "Run whisper after ensuring installation correctness.
-
-This is a horrible function, and in time due a rewrite. But for now I find this
-amusing and a little bit instructive as to how it became a mess.
-
-To conduct and display installation progress, `compilation-mode' is used because
-it's asynchronous and most importantly capable of handling progress output
-of programs like wget. However the asynchronicity comes with some complexity
-cost when more than one tasks are run (but only one after another), as there is
-no built-in async/await support, so need to use callbacks instead.
-
-That is done by adding the callback to `compilation-finish-functions'. Arguably
-it would be simpler to use one function per task and then chain these callbacks.
-However personally I preferred to logically group these together and handle
-synchronisation and cleaning up in one place, hence this big function.
-
-Conventionally, these callbacks are going to be called by passing current
-compilation-buffer in BUFFER and what event triggered the callback in STATUS so
-that's the function signature here. Checking if BUFFER is indeed originating
-from this particular compilation buffer and not something the user have running
-elsewhere is necessary. It's possible to make the hook buffer local, but
-compilation command starts before the hook could be added so I have some
-theoretical concern about possible race condition in that approach.
-
-Small Downside of re-using same function is that we need to differentiate
-whether this run is a callback or first normal call, that's what the made up
-status \"whisper-start\" does.
-
-The unfortunate price of asynchronicity is that this breaks dynamic binding
-because call stack is disrupted, callbacks are executed at a later time outside
-of the original dynamic context. The fault not only lies here, but ultimately
-`make-process' itself is async, so it will likely take some insane hacks that
-escapes me right now, to get let bindings work like synchronous code."
- (catch 'early-return
- (unless (string-equal "whisper-start" status)
- ;; shouldn't do anything when triggered by compilation buffers from elsewhere
- (unless (eq buffer whisper--compilation-buffer)
- (throw 'early-return nil))
-
- ;; being here means this compilation job either finished or was interrupted
- (remove-hook 'compilation-finish-functions #'whisper--check-install-and-run)
- (when (string-equal "finished\n" status)
- (kill-buffer whisper--compilation-buffer)))
-
- (let ((base (concat
- (expand-file-name (file-name-as-directory whisper-install-directory))
- "whisper.cpp/"))
- (old-bin-name (if (eq system-type 'windows-nt) "main.exe" "main"))
- (bin-name (if (eq system-type 'windows-nt) "whisper-cli.exe" "whisper-cli"))
- (compilation-buffer-name-function '(lambda (_) whisper--compilation-buffer-name)))
-
- (setq whisper--install-path base)
-
- (when (and (not (or (string-equal "interrupt\n" status)
- (string-prefix-p "exited abnormally with code" status)))
- (not (or (file-exists-p (concat base old-bin-name)) ;; old location
- (file-exists-p (concat base "build/bin/" bin-name)))))
-
- (when (eq whisper-install-whispercpp 'manual)
- (error (format "Couldn't find whisper.cpp install at: %s" base)))
-
- (if (yes-or-no-p (format "Couldn't find whisper.cpp, install it at: %s ?" base))
- (let ((make-commands
- (concat
- "mkdir -p " whisper-install-directory " && "
- "cd " whisper-install-directory " && "
- "git clone https://github.com/ggerganov/whisper.cpp && "
- "cd whisper.cpp && "
- "CLICOLOR=0 make")))
- (setq whisper--compilation-buffer (get-buffer-create whisper--compilation-buffer-name))
- (add-hook 'compilation-finish-functions #'whisper--check-install-and-run)
- (compile make-commands)
- (throw 'early-return nil))
- (error "Needs whisper.cpp to be installed")))
-
- (when (and (not (file-exists-p (whisper--model-file nil)))
- (not (or (string-equal "interrupt\n" status)
- (string-prefix-p "exited abnormally with code" status))))
- (if (yes-or-no-p (format "Speech recognition model \"%s\" isn't available, download now?" whisper-model))
- (let ((make-commands
- (concat
- "cd " base " && "
- "models/download-ggml-model.sh " whisper-model)))
- (setq whisper--compilation-buffer (get-buffer-create whisper--compilation-buffer-name))
- (add-hook 'compilation-finish-functions #'whisper--check-install-and-run)
- (compile make-commands)
- (throw 'early-return nil))
- (error "Needs speech recognition model to run whisper")))
-
- (when (and whisper-quantize
- (not (file-exists-p (whisper--model-file t)))
- (not (or (string-equal "interrupt\n" status)
- (string-prefix-p "exited abnormally with code" status))))
- (if (not (file-exists-p (concat base "build/bin/quantize")))
- (let ((make-commands
- (concat
- "cd " base " && "
- "make quantize" " && "
- "echo 'Quantizing the model....'" " && "
- "./build/bin/quantize " (whisper--model-file nil) " " (whisper--model-file t) " " whisper-quantize)))
- (setq whisper--compilation-buffer (get-buffer-create whisper--compilation-buffer-name))
- (add-hook 'compilation-finish-functions #'whisper--check-install-and-run)
- (compile make-commands)
- (throw 'early-return nil))
- (let ((quantize-command
- (concat (concat base "build/bin/quantize")
- " " (whisper--model-file nil) " " (whisper--model-file t) " " whisper-quantize)))
- (message "Running quantize binary...")
- (shell-command quantize-command)
- (throw 'early-return nil))))
-
- (when (string-equal "interrupt\n" status)
- ;; double check to be sure before cleaning up
- (when (and (file-directory-p base) (string-suffix-p "/whisper.cpp/" base))
- (if (file-exists-p (concat base "build/bin/" bin-name))
- ;; model download interrupted probably, should delete partial file
- (progn
- (message "Download interrupted, cleaning up.")
- (delete-file (concat base "models/" "ggml-" whisper-model ".bin")))
- ;; otherwise whisper.cpp compilation got interrupted
- (message "Installation interrupted, cleaning up.")
- (unless (eq whisper-install-whispercpp 'manual)
- (delete-directory whisper--install-path t))))
- (throw 'early-return nil))
-
- (when (string-prefix-p "exited abnormally with code" status)
- (if (eq whisper-install-whispercpp 'manual)
- (message "Compilation exited abnormally, but not deleting directory because installation is manual.")
- (progn
- (delete-directory whisper--install-path t)
- (message "Couldn't compile whisper.cpp. Check that you have Git, a C++ compiler and CMake installed.")))
- (display-buffer whisper--compilation-buffer)
- (throw 'early-return nil))
-
- (when (string-equal "finished\n" status)
- (unless (or whisper--ffmpeg-input-file
- (yes-or-no-p "Speech recognition model download completed, want to record audio now?"))
- (throw 'early-return nil)))
-
- ;; finally
- (whisper--record-audio))))
-
-;;;###autoload
-(defun whisper-run (&optional arg)
- "Transcribe/translate audio using whisper.
-
-When ARG is given, uses a local file as input. Otherwise records the audio.
-
-This is a dwim function that does different things depending on current state:
-
-- When inference engine (whisper.cpp) isn't installed, installs it first.
-- When speech recognition model isn't available, downloads it.
-- When installation/download is already in progress, cancels those.
-- When installation is valid, starts recording audio.
-- When recording is in progress, stops it and starts transcribing.
-- When transcribing is in progress, cancels it."
- (interactive "P")
- (if (process-live-p whisper--transcribing-process)
- (when (yes-or-no-p "A transcribing is already in progress, kill it?")
- (kill-process whisper--transcribing-process))
-
- (cond
- ((process-live-p whisper--recording-process)
- (interrupt-process whisper--recording-process))
- ((and (buffer-live-p whisper--compilation-buffer)
- (process-live-p (get-buffer-process whisper--compilation-buffer)))
- (when-let* ((proc (get-buffer-process whisper--compilation-buffer)))
- (interrupt-process proc)))
- (t
- (setq whisper--point-buffer (current-buffer))
- (run-hooks 'whisper-before-transcription-hook)
- (when whisper-install-whispercpp
- (whisper--check-model-consistency))
- (setq-default
- whisper--ffmpeg-input-file
- (pcase arg
- ('nil nil)
- ('(4)
- (when-let* ((file (expand-file-name (read-file-name "Media file: " nil nil t))))
- (unless (file-readable-p file)
- (error "Media file doesn't exist or isn't readable"))
- file))
- ((and (pred file-readable-p) file) file)))
- (setq whisper--using-whispercpp nil)
- (if whisper-install-whispercpp
- (whisper--check-install-and-run nil "whisper-start")
- ;; if user is bringing their own inference engine, we at least check the command exists
- (let ((command (car (whisper-command whisper--temp-file))))
- (if (or (file-exists-p command)
- (executable-find command))
- (whisper--record-audio)
- (error (format "Couldn't find %s in PATH, nor is it a file" command)))))))))
-
-;;;###autoload
-(defun whisper-file ()
- "Transcribe/translate local file using whisper."
- (interactive)
- (let ((current-prefix-arg '(4)))
- (call-interactively #'whisper-run)))
-
-(provide 'whisper)
-;;; whisper.el ends here
dots/config/emacs/site-lisp/goose.el
@@ -1,247 +0,0 @@
-;;; goose.el --- Integrate Goose CLI via vterm -*- lexical-binding: t; -*-
-
-;; Author: Daisuke Terada <pememo@gmail.com>
-;; Package-Requires: ((emacs "29") (vterm "0.0.2") (transient "0.9.1") (consult "2.5"))
-;; Version: 0.1.0
-;; Keywords: tools, convenience, ai
-;; URL: https://github.com/aq2bq/goose.el
-
-;;; Commentary:
-;; Seamless integration of the Goose CLI within Emacs using the `vterm` terminal emulator.
-;;
-;; Provides:
-;; - Intuitive session management: start and restart Goose CLI sessions with easy labeling (name or timestamp)
-;; - Immediate context injection (file path, buffer, region, template, text) into the Goose prompt, sent directly (no internal queuing)
-;; - Prompt templates (consult-based), auto-detected from ~/.config/goose/prompts/
-;; - Customizable context formatting, prompt directory, and keybinding (transient menu)
-;; - Designed for rapid AI prompt iteration and CLI-interactive workflows from Emacs
-;;
-;; Usage example:
-;; M-x goose-start-session
-;; M-x goose-add-context-buffer ; send current buffer to the Goose session
-;;
-
-;; The MIT License (MIT)
-;;
-;; Copyright (c) 2025 Daisuke Terada
-;;
-;; Permission is hereby granted, free of charge, to any person obtaining a copy
-;; of this software and associated documentation files (the "Software"), to deal
-;; in the Software without restriction, including without limitation the rights
-;; to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-;; copies of the Software, and to permit persons to whom the Software is
-;; furnished to do so, subject to the following conditions:
-;;
-;; The above copyright notice and this permission notice shall be included in all
-;; copies or substantial portions of the Software.
-;;
-;; THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-;; IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-;; FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-;; AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-;; LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-;; OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-;; SOFTWARE.
-
-;;; Code:
-(require 'vterm)
-(require 'transient)
-(require 'consult)
-
-(define-derived-mode goose-mode vterm-mode "Goose"
- "Major mode for Goose terminal (inherits `vterm-mode').
-This mode provides `goose-mode-hook` for customizations.
-Do not call this mode interactively. Use `goose-start-session` instead."
- (when (called-interactively-p 'interactive)
- (user-error "`goose-mode` is an internal mode. Use `M-x goose-start-session` instead")))
-
-;; Prevent interactive use from misapplying to the current buffer
-(put 'goose-mode 'function-documentation
- "Goose terminal mode. Do not call directly. Use `goose-start-session` instead.")
-(put 'goose-mode 'interactive-form
- '(progn (user-error "`goose-mode` is not meant to be called interactively. Use `M-x goose-start-session` instead")))
-
-
-(defgroup goose nil
- "Goose CLI integration using vterm."
- :group 'tools)
-
-(defcustom goose-program-name "goose"
- "Name or path of the Goose CLI executable."
- :type 'string
- :group 'goose)
-
-(defcustom goose-default-buffer-name "*goose*"
- "Default buffer name prefix for Goose sessions."
- :type 'string
- :group 'goose)
-
-(defcustom goose-prompt-directory (expand-file-name "~/.config/goose/prompts/")
- "Directory containing prompt templates for Goose integration."
- :type 'directory
- :group 'goose)
-
-(defcustom goose-context-format "%s"
- "Format string applied to CONTEXT text before sending to Goose.
-Use %s as placeholder for the raw text."
- :type 'string
- :group 'goose)
-
-(defcustom goose-context-file-path-prefix "File from path: %s"
- "Prefix format for inserting file path into Goose. %s will be replaced by the file path."
- :type 'string
- :group 'goose)
-
-(defcustom goose-context-buffer-prefix "File: %s\n%s"
- "Prefix format for inserting buffer content into Goose. %s will be replaced by file path and buffer content."
- :type 'string
- :group 'goose)
-
-(defcustom goose-context-region-prefix "File: %s\nRegion:\n%s"
- "Prefix format for inserting region content into Goose. %s will be replaced by file path and region."
- :type 'string
- :group 'goose)
-
-(defvar goose--last-args nil
- "Last Goose CLI argument list for restart.")
-
-(defvar goose--last-label nil
- "Last session label for restart.")
-
-(defun goose--session-label (name)
- "Return session label for NAME, or timestamp string if NAME is empty."
- (if (and name (not (string-empty-p name)))
- (shell-quote-argument name)
- (format-time-string "%Y%m%d-%H%M%S")))
-
-(defun goose--build-args (name)
- "Construct Goose CLI argument list for 'session --name NAME'."
- (let* ((label (goose--session-label name))
- (base-args (list "session" "--name" label)))
- base-args))
-
-(defun goose--run-session (label args)
- "Start or restart a Goose session buffer labeled LABEL with ARGS list."
- (let ((bufname (format "%s<%s>" goose-default-buffer-name label)))
- (when (get-buffer bufname)
- (kill-buffer bufname))
- (let* ((proj (when (fboundp 'project-current) (project-current)))
- (root (when proj (project-root proj)))
- (default-directory (or root default-directory))
- (vterm-buffer (generate-new-buffer bufname)))
- (with-current-buffer vterm-buffer
- (let ((vterm-shell "/bin/bash"))
- (goose-mode)
- (rename-buffer bufname t)
- (vterm-send-key "l" nil nil :ctrl) ;; suppress any previous output
- (vterm-send-string
- (mapconcat #'identity (cons goose-program-name args) " "))
- (vterm-send-return)))
- (setq goose--last-label label
- goose--last-args args)
- (pop-to-buffer vterm-buffer)
- (message "Goose session started in buffer %s (dir: %s)" bufname default-directory))))
-
-;;;###autoload
-(defun goose-start-session (&optional name)
- "Start a new Goose session with optional NAME, or switch if exists."
- (interactive "sSession name (optional): ")
- (let ((label (goose--session-label name))
- (args (goose--build-args name)))
- (goose--run-session label args)))
-
-;;;###autoload
-(defun goose-restart-session ()
- "Restart the last Goose session using previous NAME and ARGS, with confirmation."
- (interactive)
- (unless (and goose--last-label goose--last-args)
- (error "No Goose session to restart"))
- (when (yes-or-no-p (format "Restart Goose session <%s>? " goose--last-label))
- (goose--run-session goose--last-label goose--last-args)))
-
-(defun goose--session-buffer-name ()
- "Return the current Goose session buffer name."
- (format "%s<%s>" goose-default-buffer-name goose--last-label))
-
-(defun goose--insert-context (text)
- "Send TEXT as input to the current Goose session, deferring execution until RET.
-Applies `goose-context-format` to TEXT before sending.
-If the session is not started, starts it automatically."
- (let* ((bufname (goose--session-buffer-name))
- (buf (get-buffer bufname)))
- (unless buf
- (goose-start-session)
- (setq buf (get-buffer (goose--session-buffer-name))))
- (with-current-buffer buf
- (vterm-send-string (format goose-context-format text))
- (vterm-send-key "j" nil nil :ctrl))))
-
-;;;###autoload
-(defun goose-add-context-file-path ()
- "Insert the current buffer's file path into the Goose prompt."
- (interactive)
- (unless (buffer-file-name) (error "Buffer is not visiting a file"))
- (goose--insert-context (format goose-context-file-path-prefix (buffer-file-name)))
- (message "Inserted file path into prompt"))
-
-;;;###autoload
-(defun goose-add-context-buffer ()
- "Insert the current buffer's content and file path into the Goose prompt."
- (interactive)
- (goose--insert-context
- (format goose-context-buffer-prefix
- (or (buffer-file-name) "<no file>")
- (buffer-string)))
- (message "Inserted buffer content into prompt"))
-
-;;;###autoload
-(defun goose-add-context-region ()
- "Insert the active region's content and file path into the Goose prompt."
- (interactive)
- (unless (use-region-p) (error "No region selected"))
- (goose--insert-context
- (format goose-context-region-prefix
- (or (buffer-file-name) "<no file>")
- (buffer-substring-no-properties
- (region-beginning)
- (region-end))))
- (message "Inserted region into prompt"))
-
-;;;###autoload
-(defun goose-add-context-template ()
- "Insert a prompt template from `goose-prompt-directory' into the Goose prompt."
- (interactive)
- (unless (file-directory-p goose-prompt-directory)
- (error "Prompt directory %s does not exist" goose-prompt-directory))
- (let* ((files (directory-files goose-prompt-directory nil "^[^.].*"))
- (choice (consult--read files :prompt "Choose template: "))
- (content (with-temp-buffer
- (insert-file-contents
- (expand-file-name choice goose-prompt-directory))
- (buffer-string))))
- (goose--insert-context content)
- (message "Inserted template %s into prompt" choice)))
-
-;;;###autoload
-(defun goose-add-context-text (text)
- "Prompt for and insert arbitrary TEXT into the Goose prompt."
- (interactive "sText to insert: ")
- (goose--insert-context text)
- (message "Inserted text into prompt"))
-
-;;;###autoload
-(transient-define-prefix goose-transient ()
- "Transient interface for Goose commands."
- ["Goose Session"
- ("s" "Start session" goose-start-session)
- ("r" "Restart session" goose-restart-session)]
- ["Insert Context"
- ("f" "File path" goose-add-context-file-path)
- ("b" "Buffer" goose-add-context-buffer)
- ("e" "Region" goose-add-context-region)
- ("t" "Template" goose-add-context-template)
- ("x" "Text" goose-add-context-text)])
-
-
-(provide 'goose)
-;;; goose.el ends here
dots/config/emacs/site-lisp/journelly-batch-functions.el
@@ -1,474 +0,0 @@
-;;; journelly-batch-functions.el --- Batch functions for Journelly journal entries -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2025 Vincent Demeester
-
-;; Author: Vincent Demeester <vincent@demeester.fr>
-;; Keywords: org-mode, journelly, batch
-;; Version: 1.0.0
-
-;;; Commentary:
-
-;; Emacs batch mode functions for manipulating Journelly.org journal files.
-;; Journelly is an iOS app that stores journal entries in org-mode format.
-;;
-;; Format:
-;; - Single file with entries in reverse chronological order (newest first)
-;; - Each entry is a top-level heading: * [YYYY-MM-DD Day HH:MM] @ Location
-;; - Optional PROPERTIES drawer with GPS/weather metadata
-;; - Free-form org-mode content
-;;
-;; Functions:
-;; - journelly-batch-create-entry: Create new journal entry
-;; - journelly-batch-create-entry-auto: Create entry with automatic location/weather
-;; - journelly-batch-append-to-today: Append to today's entry
-;; - journelly-batch-list-entries: List recent entries
-;; - journelly-batch-search: Search entry content
-;; - journelly-batch-get-entry: Get specific entry by date/time
-;;
-;; Usage:
-;; emacs --batch \
-;; --load journelly-batch-functions.el \
-;; --eval "(journelly-batch-create-entry \
-;; \"~/desktop/org/Journelly.org\" \
-;; \"Home\" \
-;; \"Entry content\")"
-
-;;; Code:
-
-(require 'org)
-(require 'org-element)
-(require 'json)
-
-;; Load location/weather functions if available
-(let ((location-weather-file
- (expand-file-name "journelly-location-weather.el"
- (file-name-directory (or load-file-name buffer-file-name)))))
- (when (file-exists-p location-weather-file)
- (load location-weather-file)))
-
-;; Declare functions from journelly-location-weather.el (loaded conditionally above)
-(declare-function journelly-get-location "journelly-location-weather")
-(declare-function journelly-get-weather "journelly-location-weather")
-
-;;; Utility functions
-
-(defun journelly--format-timestamp ()
- "Generate org-mode timestamp for current time: [YYYY-MM-DD Day HH:MM]."
- (format-time-string "[%Y-%m-%d %a %H:%M]"))
-
-(defun journelly--format-date-only ()
- "Generate date only: YYYY-MM-DD."
- (format-time-string "%Y-%m-%d"))
-
-(defun journelly--parse-timestamp (heading)
- "Extract timestamp from HEADING.
-Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
-Returns the timestamp string or nil."
- (when (string-match "\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\) \\([A-Z][a-z][a-z]\\) \\([0-9]\\{2\\}:[0-9]\\{2\\}\\)\\]" heading)
- (match-string 0 heading)))
-
-(defun journelly--parse-location (heading)
- "Extract location from HEADING.
-Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
-Returns the location string or nil."
- (when (string-match "@ \\(.+\\)$" heading)
- (match-string 1 heading)))
-
-(defun journelly--make-heading (location)
- "Create journal entry heading with current timestamp and LOCATION."
- (format "* %s @ %s" (journelly--format-timestamp) location))
-
-(defun journelly--make-properties (latitude longitude temperature condition symbol)
- "Create PROPERTIES drawer with GPS and weather data.
-LATITUDE, LONGITUDE, TEMPERATURE, CONDITION, SYMBOL are optional strings.
-Returns nil if no properties provided."
- (let ((props '()))
- (when latitude
- (push (format ":LATITUDE: %s" latitude) props))
- (when longitude
- (push (format ":LONGITUDE: %s" longitude) props))
- (when temperature
- (push (format ":WEATHER_TEMPERATURE: %s" temperature) props))
- (when condition
- (push (format ":WEATHER_CONDITION: %s" condition) props))
- (when symbol
- (push (format ":WEATHER_SYMBOL: %s" symbol) props))
- (when props
- (concat ":PROPERTIES:\n"
- (mapconcat 'identity (nreverse props) "\n")
- "\n:END:\n"))))
-
-(defun journelly--find-header-end (buffer)
- "Find the end of the Journelly header in BUFFER.
-Returns the position after the :end: line, or nil if not found."
- (with-current-buffer buffer
- (goto-char (point-min))
- (when (re-search-forward "^:end:$" nil t)
- (forward-line 1)
- (point))))
-
-(defun journelly--json-response (success data &optional message)
- "Create JSON response object.
-SUCCESS is boolean, DATA is any JSON-serializable value.
-MESSAGE is optional error/success message."
- (let ((response `((success . ,success)
- (data . ,data))))
- (when message
- (push `(message . ,message) response))
- (json-encode response)))
-
-(defun journelly--output-json (success data &optional message)
- "Output JSON response to stdout.
-SUCCESS is boolean, DATA is the response data, MESSAGE is optional."
- (princ (journelly--json-response success data message))
- (terpri))
-
-;;; Main functions
-
-(defun journelly-batch-create-entry (file location content &optional latitude longitude temperature condition symbol content-file)
- "Create new journal entry in FILE.
-
-Arguments:
- FILE: Path to Journelly.org file
- LOCATION: Location string (e.g., \"Home\", \"Kyushu\")
- CONTENT: Entry content (can be empty string)
- LATITUDE: Optional GPS latitude
- LONGITUDE: Optional GPS longitude
- TEMPERATURE: Optional temperature (e.g., \"15,2°C\")
- CONDITION: Optional weather condition (e.g., \"Cloudy\")
- SYMBOL: Optional weather symbol (e.g., \"cloud\")
- CONTENT-FILE: Optional path to file containing content
-
-If CONTENT-FILE is provided, reads content from file instead of CONTENT arg.
-
-Returns JSON with success status and entry details."
- (condition-case err
- (let ((actual-content (if content-file
- (with-temp-buffer
- (insert-file-contents content-file)
- (buffer-string))
- content)))
- (with-temp-buffer
- (insert-file-contents file)
-
- ;; Find where to insert (after header)
- (let ((insert-pos (journelly--find-header-end (current-buffer))))
- (unless insert-pos
- (error "Could not find Journelly header end marker (:end:)"))
-
- (goto-char insert-pos)
-
- ;; Build entry
- (let ((heading (journelly--make-heading location))
- (properties (journelly--make-properties
- latitude longitude temperature condition symbol))
- (timestamp (journelly--format-timestamp)))
-
- ;; Insert entry
- (insert heading "\n")
- (when properties
- (insert properties))
- (when (and actual-content (not (string-empty-p actual-content)))
- (insert actual-content)
- (unless (string-suffix-p "\n" actual-content)
- (insert "\n")))
- (insert "\n") ;; Blank line after entry
-
- ;; Write back to file
- (write-region (point-min) (point-max) file)
-
- ;; Return success
- (journelly--output-json
- t
- `((timestamp . ,timestamp)
- (location . ,location)
- (has-properties . ,(if properties t :json-false))
- (file . ,file))
- "Journal entry created successfully")))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-(defun journelly-batch-append-to-today (file content &optional content-file)
- "Append CONTENT to today's journal entry in FILE.
-
-Arguments:
- FILE: Path to Journelly.org file
- CONTENT: Content to append
- CONTENT-FILE: Optional path to file containing content
-
-If no entry exists for today, returns error.
-Returns JSON with success status."
- (condition-case err
- (let ((actual-content (if content-file
- (with-temp-buffer
- (insert-file-contents content-file)
- (buffer-string))
- content))
- (today-date (journelly--format-date-only)))
- (with-temp-buffer
- (insert-file-contents file)
- (goto-char (point-min))
-
- ;; Find today's entry
- (let ((found nil)
- (search-pattern (format "^\\* \\[%s " today-date)))
- (while (and (not found)
- (re-search-forward search-pattern nil t))
- (setq found t))
-
- (unless found
- (error "No journal entry found for today (%s)" today-date))
-
- ;; Move to end of this entry (before next heading or end of file)
- (forward-line 1)
- (if (re-search-forward "^\\* \\[" nil t)
- (progn
- (beginning-of-line)
- (backward-char 1)) ;; Before the newline
- (goto-char (point-max)))
-
- ;; Insert content
- (insert "\n" actual-content)
- (unless (string-suffix-p "\n" actual-content)
- (insert "\n"))
-
- ;; Write back
- (write-region (point-min) (point-max) file)
-
- ;; Return success
- (journelly--output-json
- t
- `((date . ,today-date)
- (file . ,file))
- "Content appended to today's entry"))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-(defun journelly-batch-list-entries (file &optional limit)
- "List recent journal entries from FILE.
-
-Arguments:
- FILE: Path to Journelly.org file
- LIMIT: Optional number of entries to return (default 10)
-
-Returns JSON with list of entries."
- (condition-case err
- (let ((max-entries (or (and limit (string-to-number limit)) 10))
- (entries '()))
- (with-temp-buffer
- (insert-file-contents file)
- (goto-char (point-min))
-
- ;; Skip header
- (journelly--find-header-end (current-buffer))
-
- ;; Parse entries
- (while (and (< (length entries) max-entries)
- (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t))
- (let ((timestamp (match-string 1))
- (location (match-string 2))
- (has-properties nil)
- (content-preview ""))
-
- ;; Check for properties
- (save-excursion
- (forward-line 1)
- (when (looking-at "^:PROPERTIES:")
- (setq has-properties t)))
-
- ;; Get content preview (first 100 chars)
- (save-excursion
- (forward-line 1)
- (when has-properties
- (re-search-forward "^:END:$" nil t)
- (forward-line 1))
- (let ((content-start (point)))
- (if (re-search-forward "^\\* \\[" nil t)
- (beginning-of-line)
- (goto-char (point-max)))
- (setq content-preview
- (string-trim
- (buffer-substring-no-properties content-start (point))))
- (when (> (length content-preview) 100)
- (setq content-preview
- (concat (substring content-preview 0 100) "...")))))
-
- (push `((timestamp . ,timestamp)
- (location . ,location)
- (has-properties . ,(if has-properties t :json-false))
- (preview . ,content-preview))
- entries)))
-
- ;; Return results (already in reverse chronological from file)
- (journelly--output-json t (nreverse entries))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-(defun journelly-batch-search (file query)
- "Search journal entries in FILE for QUERY.
-
-Arguments:
- FILE: Path to Journelly.org file
- QUERY: Search string (case-insensitive)
-
-Returns JSON with matching entries."
- (condition-case err
- (let ((matches '())
- (query-lower (downcase query)))
- (with-temp-buffer
- (insert-file-contents file)
- (goto-char (point-min))
-
- ;; Skip header
- (journelly--find-header-end (current-buffer))
-
- ;; Search entries
- (while (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t)
- (let ((timestamp (match-string 1))
- (location (match-string 2))
- (entry-start (point))
- (entry-end nil)
- (entry-content ""))
-
- ;; Find entry end
- (save-excursion
- (if (re-search-forward "^\\* \\[" nil t)
- (setq entry-end (match-beginning 0))
- (setq entry-end (point-max))))
-
- ;; Get entry content
- (setq entry-content
- (buffer-substring-no-properties entry-start entry-end))
-
- ;; Check if query matches
- (when (string-match-p query-lower (downcase entry-content))
- (push `((timestamp . ,timestamp)
- (location . ,location)
- (content . ,(string-trim entry-content)))
- matches))))
-
- ;; Return results
- (journelly--output-json
- t
- (nreverse matches)
- (format "Found %d matching entries" (length matches)))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-(defun journelly-batch-get-entry (file date &optional time)
- "Get specific journal entry from FILE by DATE and optional TIME.
-
-Arguments:
- FILE: Path to Journelly.org file
- DATE: Date string (YYYY-MM-DD)
- TIME: Optional time string (HH:MM)
-
-Returns JSON with entry details or error if not found."
- (condition-case err
- (let ((search-pattern (if time
- (format "^\\* \\[%s .* %s\\]" date time)
- (format "^\\* \\[%s " date)))
- (found nil))
- (with-temp-buffer
- (insert-file-contents file)
- (goto-char (point-min))
-
- ;; Skip header
- (journelly--find-header-end (current-buffer))
-
- ;; Search for entry
- (when (re-search-forward search-pattern nil t)
- (beginning-of-line)
- (when (looking-at "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$")
- (let ((timestamp (match-string 1))
- (location (match-string 2))
- (entry-end nil)
- (has-properties nil)
- (properties nil)
- (content ""))
-
- (forward-line 1)
-
- ;; Check for properties
- (when (looking-at "^:PROPERTIES:")
- (setq has-properties t)
- (let ((props-start (point)))
- (re-search-forward "^:END:$" nil t)
- (setq properties
- (buffer-substring-no-properties props-start (point)))
- (forward-line 1)))
-
- ;; Get content
- (let ((content-start (point)))
- (if (re-search-forward "^\\* \\[" nil t)
- (setq entry-end (match-beginning 0))
- (setq entry-end (point-max)))
- (setq content
- (string-trim
- (buffer-substring-no-properties content-start entry-end))))
-
- (setq found `((timestamp . ,timestamp)
- (location . ,location)
- (has-properties . ,(if has-properties t :json-false))
- (properties . ,(or properties ""))
- (content . ,content))))))
-
- (if found
- (journelly--output-json t found)
- (journelly--output-json
- nil
- nil
- (format "No entry found for %s%s"
- date
- (if time (format " at %s" time) ""))))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-(defun journelly-batch-create-entry-auto (file content &optional content-file use-location use-weather)
- "Create journal entry with automatic location and/or weather detection.
-
-Arguments:
- FILE: Path to Journelly.org file
- CONTENT: Entry content
- CONTENT-FILE: Optional path to file containing content
- USE-LOCATION: If non-nil, automatically detect location
- USE-WEATHER: If non-nil, automatically detect weather
-
-Requires journelly-location-weather.el to be loaded.
-
-Returns JSON with success status and entry details."
- (condition-case err
- (progn
- (unless (fboundp 'journelly-get-location)
- (error "Location/weather functions not available. Load journelly-location-weather.el"))
-
- (let ((location-data (when use-location (journelly-get-location)))
- (weather-data (when use-weather (journelly-get-weather)))
- (actual-content (if content-file
- (with-temp-buffer
- (insert-file-contents content-file)
- (buffer-string))
- content)))
-
- ;; Extract data
- (let ((city (when location-data (cdr (assoc 'city location-data))))
- (lat (when location-data (cdr (assoc 'lat location-data))))
- (lon (when location-data (cdr (assoc 'lon location-data))))
- (temp (when weather-data (cdr (assoc 'temperature weather-data))))
- (cond (when weather-data (cdr (assoc 'condition weather-data))))
- (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
-
- ;; Create entry
- (journelly-batch-create-entry
- file
- (or city "Unknown")
- actual-content
- lat lon temp cond symbol nil))))
- (error
- (journelly--output-json nil nil (error-message-string err)))))
-
-;;; Provide
-
-(provide 'journelly-batch-functions)
-
-;;; journelly-batch-functions.el ends here
dots/config/emacs/site-lisp/journelly-location-weather.el
@@ -1,257 +0,0 @@
-;;; journelly-location-weather.el --- Location and weather helpers for Journelly -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2025 Vincent Demeester
-
-;; Author: Vincent Demeester <vincent@demeester.fr>
-;; Keywords: org-mode, journelly, location, weather
-;; Version: 1.0.0
-
-;;; Commentary:
-
-;; Emacs Lisp functions to get location and weather data for Journelly journal entries.
-;;
-;; Location:
-;; - Uses IP-based geolocation (ipinfo.io)
-;; - Returns city name and GPS coordinates
-;; - Caches results for 1 hour
-;;
-;; Weather:
-;; - Uses wttr.in weather service
-;; - Returns temperature, condition, and iOS SF Symbol
-;; - Caches results for 30 minutes
-;; - Intelligent day/night symbol mapping
-;;
-;; Functions:
-;; - journelly-get-location: Get current location via IP geolocation
-;; - journelly-get-weather: Get current weather
-;; - journelly-batch-get-location: Batch mode wrapper for location
-;; - journelly-batch-get-weather: Batch mode wrapper for weather
-;;
-;; Usage (batch mode):
-;; emacs --batch \
-;; --load journelly-location-weather.el \
-;; --eval "(journelly-batch-get-location)"
-;;
-;; emacs --batch \
-;; --load journelly-location-weather.el \
-;; --eval "(journelly-batch-get-weather)"
-
-;;; Code:
-
-(require 'url)
-(require 'json)
-
-;;; Configuration
-
-(defvar journelly-cache-dir
- (expand-file-name "journal" (or (getenv "XDG_CACHE_HOME")
- (expand-file-name ".cache" "~")))
- "Directory for caching location and weather data.")
-
-(defvar journelly-location-cache-timeout 3600
- "Location cache timeout in seconds (default: 1 hour).")
-
-(defvar journelly-weather-cache-timeout 1800
- "Weather cache timeout in seconds (default: 30 minutes).")
-
-;;; Utility functions
-
-(defun journelly--ensure-cache-dir ()
- "Ensure cache directory exists."
- (unless (file-exists-p journelly-cache-dir)
- (make-directory journelly-cache-dir t)))
-
-(defun journelly--cache-file (key)
- "Get cache file path for KEY."
- (expand-file-name (format "%s.json" key) journelly-cache-dir))
-
-(defun journelly--cache-valid-p (cache-file timeout)
- "Check if CACHE-FILE is valid within TIMEOUT seconds."
- (when (file-exists-p cache-file)
- (let* ((file-time (nth 5 (file-attributes cache-file)))
- (current-time (current-time))
- (age (float-time (time-subtract current-time file-time))))
- (< age timeout))))
-
-(defun journelly--read-cache (cache-file)
- "Read JSON data from CACHE-FILE."
- (when (file-exists-p cache-file)
- (with-temp-buffer
- (insert-file-contents cache-file)
- (goto-char (point-min))
- (json-read))))
-
-(defun journelly--write-cache (cache-file data)
- "Write DATA as JSON to CACHE-FILE."
- (journelly--ensure-cache-dir)
- (with-temp-file cache-file
- (insert (json-encode data))))
-
-(defun journelly--fetch-url (url)
- "Fetch URL and return parsed JSON response."
- (let ((url-request-method "GET")
- (url-request-extra-headers '(("User-Agent" . "Emacs/journelly"))))
- (with-current-buffer (url-retrieve-synchronously url t nil 10)
- (goto-char (point-min))
- ;; Skip HTTP headers
- (re-search-forward "^$")
- (forward-line)
- (let ((json-data (json-read)))
- (kill-buffer)
- json-data))))
-
-(defun journelly--is-night-p ()
- "Return t if current time is night (20:00-06:00)."
- (let ((hour (string-to-number (format-time-string "%H"))))
- (or (>= hour 20) (< hour 6))))
-
-;;; Location functions
-
-(defun journelly--map-weather-symbol (description &optional is-night)
- "Map weather DESCRIPTION to iOS SF Symbol name.
-If IS-NIGHT is non-nil, return night-appropriate symbols."
- (let ((desc (downcase description)))
- (if is-night
- ;; Night conditions
- (cond
- ((string-match-p "\\(clear\\|sunny\\)" desc) "moon.stars")
- ((string-match-p "partly.*cloud" desc) "cloud.moon")
- ((string-match-p "\\(rain\\|drizzle\\|shower\\)" desc) "cloud.moon.rain")
- (t "cloud.moon"))
- ;; Day conditions
- (cond
- ((string-match-p "\\(clear\\|sunny\\)" desc) "sun.max")
- ((string-match-p "partly.*cloud" desc) "cloud.sun")
- ((string-match-p "\\(cloudy\\|overcast\\)" desc) "cloud")
- ((string-match-p "heavy.*rain" desc) "cloud.heavyrain")
- ((string-match-p "\\(rain\\|shower\\)" desc) "cloud.rain")
- ((string-match-p "\\(drizzle\\|light.*rain\\)" desc) "cloud.drizzle")
- ((string-match-p "snow" desc) "cloud.snow")
- ((string-match-p "sleet" desc) "cloud.sleet")
- ((string-match-p "\\(fog\\|mist\\)" desc) "cloud.fog")
- ((string-match-p "\\(haze\\|smoke\\)" desc) "smoke")
- ((string-match-p "wind" desc) "wind")
- ((string-match-p "\\(thunder\\|storm\\)" desc) "cloud.bolt")
- (t "cloud")))))
-
-(defun journelly-get-location (&optional no-cache)
- "Get current location via IP geolocation.
-Returns alist with city, latitude, and longitude.
-If NO-CACHE is non-nil, fetch fresh data ignoring cache."
- (let ((cache-file (journelly--cache-file "location")))
- (if (and (not no-cache)
- (journelly--cache-valid-p cache-file journelly-location-cache-timeout))
- ;; Return cached data
- (journelly--read-cache cache-file)
- ;; Fetch fresh data
- (let* ((response (journelly--fetch-url "https://ipinfo.io/json"))
- (city (cdr (assoc 'city response)))
- (loc (cdr (assoc 'loc response)))
- (coords (when loc (split-string loc ",")))
- (lat (when coords (car coords)))
- (lon (when coords (cadr coords)))
- (data `((city . ,(or city "Unknown"))
- (lat . ,(or lat "0"))
- (lon . ,(or lon "0")))))
- ;; Cache the result
- (journelly--write-cache cache-file data)
- data))))
-
-(defun journelly-get-weather (&optional location no-cache)
- "Get current weather for LOCATION (city name or coordinates).
-If LOCATION is nil, uses current location via IP.
-Returns alist with temperature, condition, and symbol.
-If NO-CACHE is non-nil, fetch fresh data ignoring cache."
- (let* ((loc (or location ""))
- (cache-key (if (string-empty-p loc) "weather-auto" (format "weather-%s" loc)))
- (cache-file (journelly--cache-file cache-key)))
- (if (and (not no-cache)
- (journelly--cache-valid-p cache-file journelly-weather-cache-timeout))
- ;; Return cached data
- (journelly--read-cache cache-file)
- ;; Fetch fresh data
- (let* ((url (if (string-empty-p loc)
- "https://wttr.in/?format=j1"
- (format "https://wttr.in/%s?format=j1" (url-hexify-string loc))))
- (response (journelly--fetch-url url))
- (current (aref (cdr (assoc 'current_condition response)) 0))
- (temp-c (cdr (assoc 'temp_C current)))
- (weather-desc-array (cdr (assoc 'weatherDesc current)))
- (weather-desc (cdr (assoc 'value (aref weather-desc-array 0))))
- (temperature (format "%s°C" temp-c))
- (is-night (journelly--is-night-p))
- (symbol (journelly--map-weather-symbol weather-desc is-night))
- (data `((temperature . ,temperature)
- (condition . ,weather-desc)
- (symbol . ,symbol))))
- ;; Cache the result
- (journelly--write-cache cache-file data)
- data))))
-
-;;; Batch mode functions
-
-(defun journelly-batch-get-location (&optional format no-cache)
- "Batch mode: Get location and print to stdout.
-FORMAT can be: json (default), city, coords, lat, lon, or all.
-If NO-CACHE is non-nil, ignore cache."
- (let* ((format-type (or format "json"))
- (data (journelly-get-location no-cache))
- (city (cdr (assoc 'city data)))
- (lat (cdr (assoc 'lat data)))
- (lon (cdr (assoc 'lon data))))
- (cond
- ((string= format-type "json")
- (princ (json-encode data))
- (terpri))
- ((string= format-type "city")
- (princ city)
- (terpri))
- ((string= format-type "coords")
- (princ (format "%s,%s" lat lon))
- (terpri))
- ((string= format-type "lat")
- (princ lat)
- (terpri))
- ((string= format-type "lon")
- (princ lon)
- (terpri))
- ((string= format-type "all")
- (princ (format "%s (%s,%s)" city lat lon))
- (terpri))
- (t
- (error "Unknown format: %s" format-type)))))
-
-(defun journelly-batch-get-weather (&optional location format no-cache)
- "Batch mode: Get weather and print to stdout.
-LOCATION is optional city name or coordinates.
-FORMAT can be: json (default), temperature, condition, symbol, or all.
-If NO-CACHE is non-nil, ignore cache."
- (let* ((format-type (or format "json"))
- (data (journelly-get-weather location no-cache))
- (temperature (cdr (assoc 'temperature data)))
- (condition (cdr (assoc 'condition data)))
- (symbol (cdr (assoc 'symbol data))))
- (cond
- ((string= format-type "json")
- (princ (json-encode data))
- (terpri))
- ((string= format-type "temperature")
- (princ temperature)
- (terpri))
- ((string= format-type "condition")
- (princ condition)
- (terpri))
- ((string= format-type "symbol")
- (princ symbol)
- (terpri))
- ((string= format-type "all")
- (princ (format "%s %s (%s)" temperature condition symbol))
- (terpri))
- (t
- (error "Unknown format: %s" format-type)))))
-
-;;; Provide
-
-(provide 'journelly-location-weather)
-
-;;; journelly-location-weather.el ends here
dots/config/emacs/site-lisp/journelly-manager
@@ -1,326 +0,0 @@
-#!/usr/bin/env bash
-# journelly-manager - CLI tool for Journelly journal file manipulation via Emacs batch mode
-# Copyright (C) 2026 Vincent Demeester
-# Loads elisp from site-lisp for consistency with interactive Emacs
-
-set -euo pipefail
-
-# Configuration
-EMACS="${EMACS:-emacs}"
-EMACS_DIR="${EMACS_DIR:-$HOME/.config/emacs}"
-SITE_LISP="$EMACS_DIR/site-lisp"
-
-# Debug mode
-DEBUG="${DEBUG:-0}"
-
-# Colors for output (if not outputting JSON)
-if [[ -t 1 ]] && [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
- RED='\033[0;31m'
- GREEN='\033[0;32m'
- YELLOW='\033[1;33m'
- BLUE='\033[0;34m'
- NC='\033[0m' # No Color
-else
- RED=''
- GREEN=''
- YELLOW=''
- BLUE=''
- NC=''
-fi
-
-# Error handling
-error() {
- echo -e "${RED}Error: $*${NC}" >&2
- exit 1
-}
-
-debug() {
- if [[ "$DEBUG" == "1" ]]; then
- echo -e "${YELLOW}Debug: $*${NC}" >&2
- fi
-}
-
-info() {
- if [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
- echo -e "${BLUE}$*${NC}" >&2
- fi
-}
-
-success() {
- if [[ "${JSON_OUTPUT:-1}" != "1" ]]; then
- echo -e "${GREEN}$*${NC}" >&2
- fi
-}
-
-# Check dependencies
-check_deps() {
- if ! command -v "$EMACS" &> /dev/null; then
- error "Emacs not found. Set EMACS environment variable or install emacs."
- fi
-
- if [[ ! -d "$SITE_LISP" ]]; then
- error "Emacs site-lisp directory not found at: $SITE_LISP"
- fi
-
- if [[ ! -f "$SITE_LISP/journelly-batch-functions.el" ]]; then
- error "journelly-batch-functions.el not found in site-lisp"
- fi
-}
-
-# Run Emacs batch command
-run_batch() {
- local function_call="$1"
- debug "Running: $EMACS --batch --directory \"$SITE_LISP\" --load journelly-batch-functions.el --eval \"$function_call\""
-
- "$EMACS" --batch \
- --directory "$SITE_LISP" \
- --load journelly-batch-functions.el \
- --eval "$function_call" 2>&1
-}
-
-# Usage information
-usage() {
- cat <<EOF
-journelly-manager - CLI tool for managing Journelly journal files
-
-USAGE:
- journelly-manager <command> [arguments]
-
-COMMANDS:
- create FILE LOCATION CONTENT [options]
- Create new journal entry
-
- Options:
- --latitude=LAT GPS latitude
- --longitude=LON GPS longitude
- --temperature=TEMP Temperature (e.g., "15,2°C")
- --condition=COND Weather condition (e.g., "Cloudy")
- --symbol=SYM Weather symbol (e.g., "cloud")
-
- Examples:
- journelly-manager create ~/desktop/org/Journelly.org "Home" "Today was great"
-
- journelly-manager create ~/desktop/org/Journelly.org "Kyushu" \\
- "Work session notes" \\
- --latitude=48.8534 --longitude=2.3488 \\
- --temperature="15°C" --condition="Cloudy" --symbol="cloud"
-
- append FILE DATE CONTENT
- Append to existing journal entry by date (YYYY-MM-DD)
-
- Examples:
- journelly-manager append ~/desktop/org/Journelly.org \\
- "2026-01-16" "Additional thoughts"
-
- list FILE [--limit=N]
- List recent journal entries
-
- Options:
- --limit=N Number of entries to show (default: 10)
-
- Examples:
- journelly-manager list ~/desktop/org/Journelly.org
- journelly-manager list ~/desktop/org/Journelly.org --limit=20
-
- search FILE QUERY
- Search journal entries for keyword
-
- Examples:
- journelly-manager search ~/desktop/org/Journelly.org "wireguard"
-
- get FILE DATE
- Get specific entry by date (YYYY-MM-DD)
-
- Examples:
- journelly-manager get ~/desktop/org/Journelly.org "2026-01-16"
-
-ENVIRONMENT:
- EMACS Emacs executable (default: emacs)
- EMACS_DIR Emacs config directory (default: ~/.config/emacs)
- DEBUG Set to 1 for debug output
- JSON_OUTPUT Set to 1 for JSON output (no colors)
-
-EXAMPLES:
- # Create entry with auto location/weather (use get-location/get-weather)
- LOC=\$(get-location --json)
- WEATHER=\$(get-weather --json)
- journelly-manager create ~/desktop/org/Journelly.org \\
- "\$(echo \$LOC | jq -r .city)" "Entry content" \\
- --latitude="\$(echo \$LOC | jq -r .lat)" \\
- --longitude="\$(echo \$LOC | jq -r .lon)" \\
- --temperature="\$(echo \$WEATHER | jq -r .temperature)" \\
- --condition="\$(echo \$WEATHER | jq -r .condition)" \\
- --symbol="\$(echo \$WEATHER | jq -r .symbol)"
-
- # Append to today's entry
- journelly-manager append ~/desktop/org/Journelly.org \\
- "\$(date +%Y-%m-%d)" "More thoughts"
-
- # Search entries
- journelly-manager search ~/desktop/org/Journelly.org "claude"
-
-EOF
- exit 0
-}
-
-# Parse create command
-cmd_create() {
- local file="$1"
- local location="$2"
- local content="$3"
- shift 3
-
- local latitude="" longitude="" temperature="" condition="" symbol=""
-
- # Parse options
- while [[ $# -gt 0 ]]; do
- case "$1" in
- --latitude=*)
- latitude="${1#*=}"
- shift
- ;;
- --longitude=*)
- longitude="${1#*=}"
- shift
- ;;
- --temperature=*)
- temperature="${1#*=}"
- shift
- ;;
- --condition=*)
- condition="${1#*=}"
- shift
- ;;
- --symbol=*)
- symbol="${1#*=}"
- shift
- ;;
- *)
- error "Unknown option: $1"
- ;;
- esac
- done
-
- # Build Emacs Lisp call
- local elisp_call="(journelly-batch-create-entry \"$file\" \"$location\" \"$content\""
-
- if [[ -n "$latitude" ]]; then elisp_call="$elisp_call :latitude \"$latitude\""; fi
- if [[ -n "$longitude" ]]; then elisp_call="$elisp_call :longitude \"$longitude\""; fi
- if [[ -n "$temperature" ]]; then elisp_call="$elisp_call :temperature \"$temperature\""; fi
- if [[ -n "$condition" ]]; then elisp_call="$elisp_call :condition \"$condition\""; fi
- if [[ -n "$symbol" ]]; then elisp_call="$elisp_call :symbol \"$symbol\""; fi
-
- elisp_call="$elisp_call)"
-
- run_batch "$elisp_call"
- success "Journal entry created"
-}
-
-# Parse append command
-cmd_append() {
- local file="$1"
- local date="$2"
- local content="$3"
-
- local elisp_call="(journelly-batch-append-to-date \"$file\" \"$date\" \"$content\")"
- run_batch "$elisp_call"
- success "Content appended to entry"
-}
-
-# Parse list command
-cmd_list() {
- local file="$1"
- shift
-
- local limit="10"
-
- # Parse options
- while [[ $# -gt 0 ]]; do
- case "$1" in
- --limit=*)
- limit="${1#*=}"
- shift
- ;;
- *)
- error "Unknown option: $1"
- ;;
- esac
- done
-
- local elisp_call="(journelly-batch-list-entries \"$file\" $limit)"
- run_batch "$elisp_call"
-}
-
-# Parse search command
-cmd_search() {
- local file="$1"
- local query="$2"
-
- local elisp_call="(journelly-batch-search \"$file\" \"$query\")"
- run_batch "$elisp_call"
-}
-
-# Parse get command
-cmd_get() {
- local file="$1"
- local date="$2"
-
- local elisp_call="(journelly-batch-get-entry \"$file\" \"$date\")"
- run_batch "$elisp_call"
-}
-
-# Main command dispatcher
-main() {
- if [[ $# -eq 0 ]]; then
- usage
- fi
-
- local command="$1"
- shift
-
- case "$command" in
- -h|--help|help)
- usage
- ;;
- create)
- check_deps
- if [[ $# -lt 3 ]]; then
- error "create requires: FILE LOCATION CONTENT"
- fi
- cmd_create "$@"
- ;;
- append)
- check_deps
- if [[ $# -lt 3 ]]; then
- error "append requires: FILE DATE CONTENT"
- fi
- cmd_append "$@"
- ;;
- list)
- check_deps
- if [[ $# -lt 1 ]]; then
- error "list requires: FILE"
- fi
- cmd_list "$@"
- ;;
- search)
- check_deps
- if [[ $# -lt 2 ]]; then
- error "search requires: FILE QUERY"
- fi
- cmd_search "$@"
- ;;
- get)
- check_deps
- if [[ $# -lt 2 ]]; then
- error "get requires: FILE DATE"
- fi
- cmd_get "$@"
- ;;
- *)
- error "Unknown command: $command (try --help)"
- ;;
- esac
-}
-
-main "$@"
dots/config/emacs/site-lisp/journelly.el
@@ -1,459 +0,0 @@
-;;; journelly.el --- Smart Journelly capture with location/weather -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2026 Vincent Demeester
-
-;; Author: Vincent Demeester <vincent@demeester.fr>
-;; Keywords: org-mode, journelly, journal, capture
-;; Version: 1.0.0
-
-;;; Commentary:
-
-;; Smart capture system for Journelly.org journal entries.
-;;
-;; Features:
-;; - Create-or-append behavior: first entry creates, subsequent append with timestamps
-;; - Automatic location and weather via IP geolocation
-;; - Separate entries for regular journal and Claude sessions
-;; - Full org-capture integration
-;;
-;; Entry formats:
-;; - Regular: * [YYYY-MM-DD Day HH:MM] @ Location (hostname)
-;; - HH:MM :: entry content #tags (appended entries, tags optional)
-;; - Claude: * [YYYY-MM-DD Day HH:MM] @ Claude session
-;; - HH:MM :: session summary #auto-tagged (automated only)
-;;
-;; Usage:
-;; (require 'journelly)
-;; ;; Use org-capture: C-c o c then 'j' for journal
-;; ;; Or quick functions: M-x journelly-quick-entry
-;; ;; Claude sessions: programmatic only via journelly-claude-session
-
-;;; Code:
-
-(require 'org)
-(require 'org-capture)
-
-;; Load location/weather helpers from site-lisp
-(require 'journelly-location-weather)
-
-;; Declare functions from journelly-location-weather.el
-(declare-function journelly-get-location "journelly-location-weather")
-(declare-function journelly-get-weather "journelly-location-weather")
-
-;;; Helper Functions
-
-(defun journelly--find-todays-entry ()
- "Find today's journal entry in Journelly.org.
-Returns the position of the entry if found, nil otherwise."
- (let ((today (format-time-string "%Y-%m-%d")))
- (save-excursion
- (goto-char (point-min))
- ;; Skip the file header
- (when (re-search-forward "^:END:" nil t)
- (forward-line))
- ;; Search for today's regular entry (not Claude session)
- (when (re-search-forward
- (format "^\\* \\[%s[^]]+\\] @ \\([^C]\\|C[^l]\\)" today) nil t)
- (line-beginning-position)))))
-
-(defun journelly--find-todays-claude-entry ()
- "Find today's Claude session entry in Journelly.org.
-Returns the position of the entry if found, nil otherwise."
- (let ((today (format-time-string "%Y-%m-%d")))
- (save-excursion
- (goto-char (point-min))
- ;; Skip the file header
- (when (re-search-forward "^:END:" nil t)
- (forward-line))
- ;; Search for today's Claude session entry
- (when (re-search-forward
- (format "^\\* \\[%s.*@ Claude session" today) nil t)
- (line-beginning-position)))))
-
-(defun journelly--goto-insert-position ()
- "Navigate to the correct insert position for new journal entries.
-Goes after the file header but before existing entries."
- (goto-char (point-min))
- (if (re-search-forward "^:END:" nil t)
- (progn
- (forward-line)
- (point))
- ;; No header found, go to beginning
- (goto-char (point-min))
- (point)))
-
-(defun journelly--create-entry-heading (&optional custom-location)
- "Create journal entry heading with timestamp, location, and hostname.
-Format: * [YYYY-MM-DD Day HH:MM] @ Location (hostname)
-If CUSTOM-LOCATION is provided, uses it instead of IP geolocation.
-Returns the heading string."
- (let* ((location-data (when (not custom-location) (journelly-get-location)))
- (city (or custom-location (cdr (assoc 'city location-data))))
- (hostname (system-name))
- (timestamp (format-time-string "[%Y-%m-%d %a %H:%M]")))
- (if (string= custom-location "Claude session")
- ;; Claude session - no location
- (format "* %s @ Claude session" timestamp)
- ;; Regular entry - location (hostname)
- (format "* %s @ %s (%s)" timestamp city hostname))))
-
-(defun journelly--create-entry-properties (&optional skip-weather)
- "Create properties drawer with location and weather metadata.
-If SKIP-WEATHER is non-nil, only includes location data.
-Returns the properties string."
- (let* ((location-data (journelly-get-location))
- (lat (cdr (assoc 'lat location-data)))
- (lon (cdr (assoc 'lon location-data)))
- (weather-data (unless skip-weather (journelly-get-weather)))
- (temp (when weather-data (cdr (assoc 'temperature weather-data))))
- (condition (when weather-data (cdr (assoc 'condition weather-data))))
- (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
- (concat ":PROPERTIES:\n"
- (format ":LATITUDE: %s\n" lat)
- (format ":LONGITUDE: %s\n" lon)
- (when temp (format ":WEATHER_TEMPERATURE: %s\n" temp))
- (when condition (format ":WEATHER_CONDITION: %s\n" condition))
- (when symbol (format ":WEATHER_SYMBOL: %s\n" symbol))
- ":END:")))
-
-;;; Capture Target Functions
-
-(defun journelly-capture-target ()
- "Org capture target function for smart Journelly entries.
-Creates new entry if today's doesn't exist, or appends to existing."
- (let ((entry-pos (journelly--find-todays-entry)))
- (if entry-pos
- ;; Entry exists - go to end to append
- (progn
- (goto-char entry-pos)
- (org-end-of-subtree t)
- ;; Skip back over any trailing blank lines
- (while (and (not (bobp))
- (looking-back "^[ \t]*\n" (line-beginning-position 0)))
- (forward-line -1))
- ;; Now at the last non-blank line of content
- (end-of-line)
- (insert "\n")
- (point))
- ;; Entry doesn't exist - create new one
- (journelly--goto-insert-position)
- (insert (journelly--create-entry-heading) "\n")
- (insert (journelly--create-entry-properties) "\n")
- ;; Leave point on blank line inside the new entry, not at the
- ;; start of the next heading. org-capture checks org-at-heading-p
- ;; to set :target-entry-p; if point lands on the old first entry's
- ;; heading, the template text gets appended to that entry instead.
- (open-line 1)
- (point))))
-
-(defun journelly-claude-capture-target ()
- "Org capture target function for Claude session entries.
-Creates new entry if today's Claude session doesn't exist, or appends."
- (let ((entry-pos (journelly--find-todays-claude-entry)))
- (if entry-pos
- ;; Entry exists - go to end to append
- (progn
- (goto-char entry-pos)
- (org-end-of-subtree t)
- ;; Skip back over any trailing blank lines
- (while (and (not (bobp))
- (looking-back "^[ \t]*\n" (line-beginning-position 0)))
- (forward-line -1))
- ;; Now at the last non-blank line of content
- (end-of-line)
- (insert "\n")
- (point))
- ;; Entry doesn't exist - create new one
- (journelly--goto-insert-position)
- (insert (journelly--create-entry-heading "Claude session") "\n")
- (insert (journelly--create-entry-properties t) "\n") ;; Skip weather for Claude
- ;; Leave point on blank line inside the new entry (see
- ;; journelly-capture-target for detailed explanation).
- (open-line 1)
- (point))))
-
-;;; Interactive Functions
-
-(defun journelly-quick-entry (content)
- "Quick journal entry with CONTENT.
-Location/weather added automatically.
-Creates today's entry if it doesn't exist, or appends to existing entry."
- (interactive "sJournal: ")
- (with-current-buffer (find-file-noselect org-journelly-file)
- (save-excursion
- (journelly-capture-target)
- (insert content))
- (save-buffer))
- (message "Journal entry added"))
-
-(defun journelly--detect-tags (summary)
- "Auto-detect tags for Claude session based on context and SUMMARY.
-Returns a list of tag strings (without # prefix)."
- (let ((tags '()))
-
- ;; Detect from file extensions (git status)
- (when (file-exists-p ".git")
- (let ((changed-files (shell-command-to-string "git status --short 2>/dev/null")))
- (when (string-match-p "\\.nix" changed-files)
- (push "nixos" tags))
- (when (string-match-p "\\.go" changed-files)
- (push "golang" tags))
- (when (string-match-p "\\.el" changed-files)
- (push "emacs" tags))
- (when (string-match-p "\\.py" changed-files)
- (push "python" tags))
- (when (string-match-p "\\.rs" changed-files)
- (push "rust" tags))
- (when (string-match-p "Dockerfile\\|docker-compose" changed-files)
- (push "docker" tags))
- (when (string-match-p "\\.ya?ml" changed-files)
- (push "kubernetes" tags))
- (when (string-match-p "skills/" changed-files)
- (push "claude-skills" tags))))
-
- ;; Detect from git repository name
- (when (file-exists-p ".git")
- (let* ((remote-url (shell-command-to-string "git remote get-url origin 2>/dev/null"))
- (repo-name (when (string-match "/\\([^/]+\\)\\.git" remote-url)
- (match-string 1 remote-url))))
- (cond
- ((string= repo-name "home") (push "homelab" tags))
- ((string-match-p "tekton" repo-name) (push "tekton" tags))
- ((string-match-p "pipeline" repo-name) (push "tekton" tags)))))
-
- ;; Detect from keywords in summary
- (let ((summary-lower (downcase summary)))
- ;; Development activities
- (when (string-match-p "\\(bug\\|fix\\|debug\\)" summary-lower)
- (push "debugging" tags))
- (when (string-match-p "\\(feature\\|implement\\)" summary-lower)
- (push "development" tags))
- (when (string-match-p "refactor" summary-lower)
- (push "refactoring" tags))
- (when (string-match-p "\\(test\\|testing\\)" summary-lower)
- (push "testing" tags))
- (when (string-match-p "\\(doc\\|documentation\\)" summary-lower)
- (push "documentation" tags))
-
- ;; Infrastructure & tools
- (when (string-match-p "\\(kubernetes\\|k8s\\)" summary-lower)
- (push "kubernetes" tags))
- (when (string-match-p "docker" summary-lower)
- (push "docker" tags))
- (when (string-match-p "\\(commit\\|push\\|git\\)" summary-lower)
- (push "git" tags))
- (when (string-match-p "\\(capture\\|journal\\)" summary-lower)
- (push "journelly" tags))
-
- ;; AI/LLM
- (when (string-match-p "\\(llm\\|language model\\)" summary-lower)
- (push "llm" tags))
- (when (string-match-p "\\(ai\\|artificial intelligence\\)" summary-lower)
- (push "ai" tags))
- (when (string-match-p "claude" summary-lower)
- (push "claude" tags))
- (when (string-match-p "anthropic" summary-lower)
- (push "anthropic" tags))
- (when (string-match-p "gemini" summary-lower)
- (push "gemini" tags))
- (when (string-match-p "ollama" summary-lower)
- (push "ollama" tags))
- (when (string-match-p "\\(openai\\|chatgpt\\|gpt\\)" summary-lower)
- (push "openai" tags))
-
- ;; Cloud providers
- (when (string-match-p "\\(cloud\\|infrastructure\\)" summary-lower)
- (push "cloud" tags))
- (when (string-match-p "\\(digitalocean\\|\\bdo\\b\\)" summary-lower)
- (push "digitalocean" tags))
- (when (string-match-p "\\(gcp\\|google cloud\\)" summary-lower)
- (push "gcp" tags))
- (when (string-match-p "oracle.*cloud" summary-lower)
- (push "oracle-cloud" tags))
- (when (string-match-p "\\(aws\\|amazon web services\\)" summary-lower)
- (push "aws" tags))
- (when (string-match-p "azure" summary-lower)
- (push "azure" tags))
-
- ;; Architecture
- (when (string-match-p "\\(arm\\|aarch64\\)" summary-lower)
- (push "arm" tags))
- (when (string-match-p "x86.64" summary-lower)
- (push "x86_64" tags))
- (when (string-match-p "\\(cross.compile\\|cross compile\\)" summary-lower)
- (push "cross-compile" tags))
- (when (string-match-p "riscv" summary-lower)
- (push "riscv" tags))
-
- ;; Monitoring/Observability
- (when (string-match-p "\\(monitoring\\|observability\\)" summary-lower)
- (push "monitoring" tags))
- (when (string-match-p "prometheus" summary-lower)
- (push "prometheus" tags))
- (when (string-match-p "grafana" summary-lower)
- (push "grafana" tags))
- (when (string-match-p "\\(alert\\|alerting\\)" summary-lower)
- (push "alerting" tags))
-
- ;; Media
- (when (string-match-p "media" summary-lower)
- (push "media" tags))
- (when (string-match-p "jellyfin" summary-lower)
- (push "jellyfin" tags))
- (when (string-match-p "plex" summary-lower)
- (push "plex" tags))
-
- ;; Desktop/Window managers
- (when (string-match-p "niri" summary-lower)
- (push "niri" tags))
- (when (string-match-p "sway" summary-lower)
- (push "sway" tags))
- (when (string-match-p "wayland" summary-lower)
- (push "wayland" tags))
- (when (string-match-p "x11" summary-lower)
- (push "x11" tags))
-
- ;; Networking
- (when (string-match-p "\\(network\\|networking\\)" summary-lower)
- (push "networking" tags))
- (when (string-match-p "wireguard" summary-lower)
- (push "wireguard" tags))
- (when (string-match-p "\\(vpn\\|virtual private network\\)" summary-lower)
- (push "vpn" tags))
- (when (string-match-p "\\(dns\\|domain name\\)" summary-lower)
- (push "dns" tags))
-
- ;; Security
- (when (string-match-p "security" summary-lower)
- (push "security" tags))
- (when (string-match-p "\\(auth\\|authentication\\)" summary-lower)
- (push "auth" tags))
- (when (string-match-p "\\(encryption\\|encrypt\\)" summary-lower)
- (push "encryption" tags))
- (when (string-match-p "\\(secret\\|agenix\\)" summary-lower)
- (push "secrets" tags))
- (when (string-match-p "yubikey" summary-lower)
- (push "yubikey" tags))
-
- ;; Backup/Storage
- (when (string-match-p "backup" summary-lower)
- (push "backup" tags))
- (when (string-match-p "storage" summary-lower)
- (push "storage" tags))
- (when (string-match-p "syncthing" summary-lower)
- (push "syncthing" tags))
- (when (string-match-p "restic" summary-lower)
- (push "restic" tags))
-
- ;; Communication
- (when (string-match-p "\\(email\\|mail\\)" summary-lower)
- (push "email" tags))
- (when (string-match-p "\\(mu4e\\|notmuch\\)" summary-lower)
- (push "email" tags))
- (when (string-match-p "xmpp" summary-lower)
- (push "xmpp" tags))
-
- ;; Databases
- (when (string-match-p "\\(database\\|\\bdb\\b\\)" summary-lower)
- (push "database" tags))
- (when (string-match-p "\\(postgres\\|postgresql\\)" summary-lower)
- (push "postgres" tags))
- (when (string-match-p "sqlite" summary-lower)
- (push "sqlite" tags))
-
- ;; Web/HTTP
- (when (string-match-p "\\(web\\|http\\|https\\)" summary-lower)
- (push "web" tags))
- (when (string-match-p "nginx" summary-lower)
- (push "nginx" tags))
- (when (string-match-p "caddy" summary-lower)
- (push "caddy" tags))
-
- ;; Hardware
- (when (string-match-p "hardware" summary-lower)
- (push "hardware" tags))
- (when (string-match-p "\\(raspberry.pi\\|rpi\\)" summary-lower)
- (push "raspberry-pi" tags))
- (when (string-match-p "keyboard" summary-lower)
- (push "keyboard" tags))
-
- ;; Configuration
- (when (string-match-p "home.manager" summary-lower)
- (push "home-manager" tags))
- (when (string-match-p "flake" summary-lower)
- (push "flakes" tags))
- (when (string-match-p "\\(deploy\\|deployment\\)" summary-lower)
- (push "deployment" tags))
- (when (string-match-p "\\(ci\\|cd\\|pipeline\\)" summary-lower)
- (push "ci-cd" tags)))
-
- ;; Remove duplicates and return
- (delete-dups tags)))
-
-(defun journelly-claude-session (summary)
- "Add Claude session SUMMARY to today's Claude session entry.
-Auto-detects and appends tags based on context and content.
-Creates entry if it doesn't exist, or appends to existing entry."
- (interactive "sClaude session: ")
- (let* ((timestamp (format-time-string "%H:%M"))
- (tags (journelly--detect-tags summary))
- (tags-string (if tags
- (concat " " (mapconcat (lambda (tag) (concat "#" tag)) tags " "))
- ""))
- (entry (format "- %s :: %s%s\n" timestamp summary tags-string)))
- (with-current-buffer (find-file-noselect org-journelly-file)
- (save-excursion
- (journelly-claude-capture-target)
- (insert entry))
- (save-buffer))
- (message "Claude session logged%s" (if tags (format " with tags: %s" tags-string) ""))))
-
-(defun journelly-open ()
- "Open Journelly.org file and jump to today's entry or top."
- (interactive)
- (find-file org-journelly-file)
- (let ((entry-pos (journelly--find-todays-entry)))
- (if entry-pos
- (goto-char entry-pos)
- ;; No entry today, go to insert position
- (journelly--goto-insert-position)))
- (recenter-top-bottom 0))
-
-;;; Capture Templates Setup
-
-(defun journelly-setup-capture-templates ()
- "Setup org-capture templates for Journelly.
-Call this after org-capture is loaded and org-journelly-file is defined."
-
- ;; Remove old journelly templates if they exist
- (setq org-capture-templates
- (seq-remove (lambda (x) (member (car x) '("j" "J")))
- org-capture-templates))
-
- ;; Smart default journal entry (creates or appends with timestamp)
- (add-to-list 'org-capture-templates
- `("j" "📝 Journal entry" plain
- (file+function ,org-journelly-file journelly-capture-target)
- "- %(format-time-string \"%H:%M\") :: %?"
- :empty-lines 0)
- t))
-
-;;; Keybindings
-
-(defun journelly-setup-keybindings ()
- "Setup keybindings for Journelly functions."
- (global-set-key (kbd "C-c j j") 'journelly-quick-entry)
- (global-set-key (kbd "C-c j o") 'journelly-open))
-
-;;; Auto-setup
-
-;; Setup keybindings when loaded
-(journelly-setup-keybindings)
-
-;; Setup capture templates after org-capture is loaded
-(with-eval-after-load 'org-capture
- (journelly-setup-capture-templates))
-
-(provide 'journelly)
-
-;;; journelly.el ends here
dots/config/emacs/site-lisp/org-kanban.el
@@ -1,894 +0,0 @@
-;;; org-kanban.el --- Kanban board view for org-mode TODOs -*- lexical-binding: t; -*-
-
-;; Copyright (C) 2026 Vincent Demeester
-
-;; Author: Vincent Demeester <vincent@sbr.pm>
-;; Keywords: org, kanban, productivity
-;; Version: 0.1.0
-;; Package-Requires: ((emacs "29.1") (org-ql "0.8"))
-
-;;; Commentary:
-
-;; Interactive kanban board that renders org-mode TODOs as cards in
-;; columns based on their TODO state. Cards can be moved between
-;; columns (changing their state) with simple keybindings.
-;;
-;; Uses org-ql for querying and org-mode API for state changes.
-;; Data source is your existing todos.org file.
-;;
-;; Keybindings in org-kanban buffers:
-;; j / n / ↓ - next card down in column
-;; k / p / ↑ - previous card up in column
-;; l / → - next column
-;; h / ← - previous column
-;; > / L - move card to next state
-;; < / H - move card to previous state
-;; RET - jump to heading in org file
-;; v / SPC - preview card in side window (toggle)
-;; f - filter by section
-;; F - clear filters
-;; D - toggle DONE/CANX visibility
-;; g - refresh board
-;; q - quit
-;; TAB - cycle card detail
-;;
-;; Interactive commands:
-;; M-x org-kanban - open kanban board
-;; M-x org-kanban-work - board filtered to Work section
-
-;;; Code:
-
-(require 'org)
-(require 'org-ql)
-(require 'cl-lib)
-
-;;; Customization
-
-(defgroup org-kanban nil
- "Kanban board view for org-mode TODOs."
- :group 'org
- :prefix "org-kanban-")
-
-(defcustom org-kanban-file (expand-file-name "~/desktop/org/todos.org")
- "Path to the org file to display as a kanban board."
- :type 'file
- :group 'org-kanban)
-
-(defcustom org-kanban-columns '("TODO" "NEXT" "STRT" "WAIT")
- "TODO states to display as columns (left to right).
-DONE and CANX are toggled with `D'."
- :type '(repeat string)
- :group 'org-kanban)
-
-(defcustom org-kanban-done-columns '("DONE" "CANX")
- "Completed states, hidden by default. Toggle with `D'."
- :type '(repeat string)
- :group 'org-kanban)
-
-(defcustom org-kanban-state-order '("TODO" "NEXT" "STRT" "WAIT" "DONE" "CANX")
- "Order of states for moving cards left/right."
- :type '(repeat string)
- :group 'org-kanban)
-
-(defcustom org-kanban-column-width 30
- "Width of each column in characters."
- :type 'integer
- :group 'org-kanban)
-
-(defcustom org-kanban-max-cards nil
- "Maximum number of cards per column. Nil means no limit."
- :type '(choice (const :tag "No limit" nil) integer)
- :group 'org-kanban)
-
-(defcustom org-kanban-level 2
- "Org heading level to display as cards.
-2 means direct children of top-level sections."
- :type 'integer
- :group 'org-kanban)
-
-(defcustom org-kanban-open-in-tab t
- "If non-nil, RET opens the heading in a new tab.
-If nil, opens in a new window via `pop-to-buffer'."
- :type 'boolean
- :group 'org-kanban)
-
-(defcustom org-kanban-sort 'priority
- "Default sort order for cards within each column."
- :type '(choice (const :tag "Priority (highest first)" priority)
- (const :tag "Scheduled date (earliest first)" scheduled)
- (const :tag "Deadline (earliest first)" deadline)
- (const :tag "Alphabetical" alpha)
- (const :tag "No sorting (file order)" none))
- :group 'org-kanban)
-
-;;; Faces
-
-(defface org-kanban-column-header
- '((t :inherit fixed-pitch :weight bold :underline t))
- "Face for column headers."
- :group 'org-kanban)
-
-(defface org-kanban-card
- '((t :inherit default))
- "Face for card text."
- :group 'org-kanban)
-
-(defface org-kanban-card-selected
- '((((class color) (background dark))
- :background "#44475a" :extend t)
- (((class color) (background light))
- :background "#dde4ff" :extend t)
- (t :inverse-video t :extend t))
- "Face for the currently selected card."
- :group 'org-kanban)
-
-(defface org-kanban-indicator
- '((((background dark))
- :foreground "#ff79c6" :weight bold)
- (((background light))
- :foreground "#a626a4" :weight bold))
- "Face for the active card indicator."
- :group 'org-kanban)
-
-(defface org-kanban-priority-1
- '((t :foreground "#ff5555" :weight bold))
- "Face for priority 1 (highest)."
- :group 'org-kanban)
-
-(defface org-kanban-priority-2
- '((t :foreground "#ffb86c" :weight bold))
- "Face for priority 2."
- :group 'org-kanban)
-
-(defface org-kanban-priority-3
- '((t :foreground "#f1fa8c"))
- "Face for priority 3."
- :group 'org-kanban)
-
-(defface org-kanban-state-todo
- '((t :foreground "#8be9fd"))
- "Face for TODO state."
- :group 'org-kanban)
-
-(defface org-kanban-state-next
- '((t :foreground "#50fa7b" :weight bold))
- "Face for NEXT state."
- :group 'org-kanban)
-
-(defface org-kanban-state-strt
- '((t :foreground "#ff79c6" :weight bold))
- "Face for STRT state."
- :group 'org-kanban)
-
-(defface org-kanban-state-wait
- '((t :foreground "#ffb86c"))
- "Face for WAIT state."
- :group 'org-kanban)
-
-(defface org-kanban-state-done
- '((t :foreground "#6272a4"))
- "Face for DONE state."
- :group 'org-kanban)
-
-(defface org-kanban-state-canx
- '((t :foreground "#6272a4" :strike-through t))
- "Face for CANX state."
- :group 'org-kanban)
-
-(defface org-kanban-tag
- '((t :inherit org-tag))
- "Face for tags on cards."
- :group 'org-kanban)
-
-(defface org-kanban-date
- '((t :inherit org-date))
- "Face for dates on cards."
- :group 'org-kanban)
-
-(defface org-kanban-section-filter
- '((t :inherit font-lock-keyword-face))
- "Face for the active section filter indicator."
- :group 'org-kanban)
-
-;;; Internal variables
-
-(defvar-local org-kanban--cards nil
- "Alist of (STATE . cards) for current board.")
-
-(defvar-local org-kanban--show-done nil
- "Whether to show DONE/CANX columns.")
-
-(defvar-local org-kanban--section-filter nil
- "Current section filter, or nil for all.")
-
-(defvar-local org-kanban--selected-card nil
- "Plist of the currently selected card (:heading :state :col :row).")
-
-(defvar-local org-kanban--card-positions nil
- "Hash table mapping (col . row) to card plist.")
-
-(defvar-local org-kanban--selected-col nil
- "Column index of the currently selected card.")
-
-(defvar-local org-kanban--selected-row nil
- "Row index of the currently selected card.")
-
-(defvar-local org-kanban--sort nil
- "Current sort order. Nil means use `org-kanban-sort' default.")
-
-;;; Data fetching
-
-(defun org-kanban--fetch-cards ()
- "Fetch all TODO items from `org-kanban-file' using org-ql.
-Returns an alist of (STATE . list-of-card-plists)."
- (let ((all-states (append org-kanban-columns
- (when org-kanban--show-done
- org-kanban-done-columns)))
- (results '()))
- (dolist (state all-states)
- (let ((cards (org-ql-select org-kanban-file
- `(and (todo ,state)
- (level ,org-kanban-level)
- ,@(when org-kanban--section-filter
- `((ancestors
- (and (level 1)
- (heading ,org-kanban--section-filter))))))
- :action (lambda ()
- (let* ((element (org-element-at-point))
- (priority-raw (org-element-property :priority element))
- (priority-num (when priority-raw
- (if (< priority-raw 10)
- priority-raw ;; already a number (1-5)
- (- priority-raw 48)))) ;; ASCII char (?1=49 -> 1)
- (tags (mapcar #'substring-no-properties (org-get-tags nil t)))
- (scheduled (org-entry-get nil "SCHEDULED"))
- (deadline (org-entry-get nil "DEADLINE"))
- (section (save-excursion
- (while (> (org-current-level) 1)
- (org-up-heading-safe))
- (substring-no-properties (org-get-heading t t t t)))))
- (list :heading (org-kanban--render-links
- (substring-no-properties (org-get-heading t t t t)))
- :state state
- :priority priority-num
- :tags tags
- :scheduled scheduled
- :deadline deadline
- :section section
- :marker (point-marker)))))))
- (push (cons state (org-kanban--sort-cards cards)) results)))
- (nreverse results)))
-
-(defun org-kanban--sort-cards (cards)
- "Sort CARDS according to the current sort order."
- (let ((order (or org-kanban--sort org-kanban-sort)))
- (pcase order
- ('none cards)
- ('priority
- (sort cards
- (lambda (a b)
- (let ((pa (or (plist-get a :priority) 99))
- (pb (or (plist-get b :priority) 99)))
- (< pa pb)))))
- ('scheduled
- (sort cards
- (lambda (a b)
- (let ((sa (or (plist-get a :scheduled) "9999"))
- (sb (or (plist-get b :scheduled) "9999")))
- (string< sa sb)))))
- ('deadline
- (sort cards
- (lambda (a b)
- (let ((da (or (plist-get a :deadline) "9999"))
- (db (or (plist-get b :deadline) "9999")))
- (string< da db)))))
- ('alpha
- (sort cards
- (lambda (a b)
- (string< (plist-get a :heading) (plist-get b :heading)))))
- (_ cards))))
-
-;;; Text processing
-
-(defun org-kanban--render-links (str)
- "Replace org links in STR with their description.
-\=[[url][desc]] becomes desc, [[url]] becomes url."
- (let ((result str))
- ;; [[target][description]] → description
- (setq result (replace-regexp-in-string
- "\\[\\[\\([^]]*\\)\\]\\[\\([^]]*\\)\\]\\]"
- "\\2" result))
- ;; [[target]] → target
- (setq result (replace-regexp-in-string
- "\\[\\[\\([^]]*\\)\\]\\]"
- "\\1" result))
- result))
-
-;;; Rendering
-
-(defun org-kanban--truncate (str width)
- "Truncate STR to WIDTH display columns, adding ellipsis if needed."
- (if (> (string-width str) width)
- (concat (truncate-string-to-width str (- width 1)) "…")
- str))
-
-(defun org-kanban--pad (str width)
- "Pad STR to WIDTH display columns with spaces."
- (let ((truncated (org-kanban--truncate str width)))
- (concat truncated (make-string (max 0 (- width (string-width truncated))) ?\s))))
-
-(defun org-kanban--state-face (state)
- "Return the face for STATE."
- (pcase state
- ("TODO" 'org-kanban-state-todo)
- ("NEXT" 'org-kanban-state-next)
- ("STRT" 'org-kanban-state-strt)
- ("WAIT" 'org-kanban-state-wait)
- ("DONE" 'org-kanban-state-done)
- ("CANX" 'org-kanban-state-canx)
- (_ 'default)))
-
-(defun org-kanban--priority-face (priority)
- "Return the face for PRIORITY number."
- (pcase priority
- (1 'org-kanban-priority-1)
- (2 'org-kanban-priority-2)
- (3 'org-kanban-priority-3)
- (_ nil)))
-
-(defun org-kanban--format-card-line1 (card width &optional selected)
- "Format first line of CARD (heading) to fit WIDTH.
-If SELECTED is non-nil, prepend a visible indicator."
- (let* ((indicator (if selected
- (propertize "▶ " 'face 'org-kanban-indicator)
- " "))
- (inner-width (- width 2)) ;; reserve 2 chars for indicator
- (heading (plist-get card :heading))
- (priority (plist-get card :priority))
- (prefix (if priority (format "[#%d] " priority) ""))
- (avail (- inner-width (string-width prefix)))
- (truncated (org-kanban--truncate heading avail))
- (line (concat prefix truncated))
- (result (org-kanban--pad line inner-width)))
- ;; Apply priority face to prefix
- (when (and priority (org-kanban--priority-face priority))
- (put-text-property 0 (min (length prefix) (length result)) 'face
- (org-kanban--priority-face priority) result))
- (concat indicator result)))
-
-(defun org-kanban--format-card-line2 (card width &optional selected)
- "Format second line of CARD (metadata) to fit WIDTH.
-If SELECTED is non-nil, prepend a continuation indicator."
- (let* ((tags (plist-get card :tags))
- (scheduled (plist-get card :scheduled))
- (deadline (plist-get card :deadline))
- (section (plist-get card :section))
- (parts '()))
- ;; Add date info
- (when deadline
- (push (propertize (format "⚑%s" (org-kanban--short-date deadline))
- 'face 'org-kanban-date)
- parts))
- (when scheduled
- (push (propertize (format "▸%s" (org-kanban--short-date scheduled))
- 'face 'org-kanban-date)
- parts))
- ;; Add section
- (when (and section (not org-kanban--section-filter))
- (push (propertize (org-kanban--truncate section 10)
- 'face 'org-kanban-section-filter)
- parts))
- ;; Add first tag
- (when tags
- (push (propertize (format ":%s:" (car tags))
- 'face 'org-kanban-tag)
- parts))
- (let* ((indicator (if selected
- (propertize "│ " 'face 'org-kanban-indicator)
- " "))
- (inner-width (- width 2))
- (line (string-join (nreverse parts) " ")))
- (concat indicator (org-kanban--pad line inner-width)))))
-
-(defun org-kanban--short-date (date-str)
- "Extract short date from DATE-STR like '<2026-04-07 Tue>'."
- (if (and date-str (string-match "\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)" date-str))
- (let ((date (match-string 1 date-str)))
- (format "%s/%s" (substring date 5 7) (substring date 8 10)))
- ""))
-
-(defun org-kanban--render ()
- "Render the kanban board into the current buffer."
- (let* ((inhibit-read-only t)
- (columns (append org-kanban-columns
- (when org-kanban--show-done
- org-kanban-done-columns)))
- (col-width org-kanban-column-width)
- (separator "│")
- (card-positions (make-hash-table :test 'equal))
- ;; Calculate max rows
- (max-rows (apply #'max 0
- (mapcar (lambda (state)
- (length (alist-get state org-kanban--cards
- nil nil #'string=)))
- columns))))
- (erase-buffer)
-
- ;; Title line
- (insert (propertize " Org Kanban" 'face '(:inherit fixed-pitch :weight bold :height 1.3)))
- (when org-kanban--section-filter
- (insert " "
- (propertize (format "⟨%s⟩" org-kanban--section-filter)
- 'face 'org-kanban-section-filter)))
- ;; Show current sort
- (let ((sort-name (or org-kanban--sort org-kanban-sort)))
- (unless (eq sort-name 'none)
- (insert " "
- (propertize (format "↕%s" sort-name) 'face 'shadow))))
- (unless org-kanban--show-done
- (insert " "
- (propertize "(D to show done)" 'face 'shadow)))
- (insert "\n\n")
-
- ;; Column headers
- (insert " ")
- (dotimes (col-idx (length columns))
- (let* ((state (nth col-idx columns))
- (count (length (alist-get state org-kanban--cards nil nil #'string=)))
- (header (format "%s (%d)" state count)))
- (insert (propertize (org-kanban--pad header col-width)
- 'face (list 'org-kanban-column-header
- (org-kanban--state-face state))))
- (when (< col-idx (1- (length columns)))
- (insert " " separator " "))))
- (insert "\n")
-
- ;; Separator line
- (insert " ")
- (dotimes (col-idx (length columns))
- (insert (make-string col-width ?─))
- (when (< col-idx (1- (length columns)))
- (insert "─┼─")))
- (insert "\n")
-
- ;; Card rows
- (dotimes (row max-rows)
- ;; Line 1: heading
- (insert " ")
- (dotimes (col-idx (length columns))
- (let* ((state (nth col-idx columns))
- (cards (alist-get state org-kanban--cards nil nil #'string=))
- (card (nth row cards)))
- (if card
- (let* ((sel (and (eql col-idx org-kanban--selected-col)
- (eql row org-kanban--selected-row)))
- (start (point)))
- (insert (org-kanban--format-card-line1 card col-width sel))
- ;; Store card position
- (puthash (cons col-idx row) card card-positions)
- ;; Add text properties for navigation
- (put-text-property start (point) 'org-kanban-card card)
- (put-text-property start (point) 'org-kanban-col col-idx)
- (put-text-property start (point) 'org-kanban-row row))
- (insert (make-string col-width ?\s)))
- (when (< col-idx (1- (length columns)))
- (insert " " separator " "))))
- (insert "\n")
-
- ;; Line 2: metadata
- (insert " ")
- (dotimes (col-idx (length columns))
- (let* ((state (nth col-idx columns))
- (cards (alist-get state org-kanban--cards nil nil #'string=))
- (card (nth row cards)))
- (if card
- (let* ((sel (and (eql col-idx org-kanban--selected-col)
- (eql row org-kanban--selected-row)))
- (start (point)))
- (insert (org-kanban--format-card-line2 card col-width sel))
- (put-text-property start (point) 'org-kanban-card card)
- (put-text-property start (point) 'org-kanban-col col-idx)
- (put-text-property start (point) 'org-kanban-row row))
- (insert (make-string col-width ?\s)))
- (when (< col-idx (1- (length columns)))
- (insert " " separator " "))))
- (insert "\n")
-
- ;; Blank line between cards
- (insert " ")
- (dotimes (col-idx (length columns))
- (insert (make-string col-width ?\s))
- (when (< col-idx (1- (length columns)))
- (insert " " separator " ")))
- (insert "\n"))
-
- (setq org-kanban--card-positions card-positions)
-
- ;; Footer with keybinding help
- (insert "\n")
- (insert (propertize " hjkl/←↑↓→" 'face 'font-lock-keyword-face) " nav "
- (propertize "</H" 'face 'font-lock-keyword-face) " state← "
- (propertize ">/L" 'face 'font-lock-keyword-face) " state→ "
- (propertize "RET" 'face 'font-lock-keyword-face) " goto "
- (propertize "v/SPC" 'face 'font-lock-keyword-face) " preview "
- (propertize "f" 'face 'font-lock-keyword-face) " filter "
- (propertize "s" 'face 'font-lock-keyword-face) " sort "
- (propertize "D" 'face 'font-lock-keyword-face) " done "
- (propertize "g" 'face 'font-lock-keyword-face) " refresh "
- (propertize "q" 'face 'font-lock-keyword-face) " quit")))
-
-;;; Selection and preview
-
-(defun org-kanban--update-preview (card)
- "Update the preview window with CARD content, if visible."
- (let ((preview-win (get-buffer-window "*org-kanban-preview*")))
- (when preview-win
- (let ((marker (plist-get card :marker)))
- (when (and marker (marker-buffer marker))
- (let ((content (with-current-buffer (marker-buffer marker)
- (save-excursion
- (goto-char marker)
- (org-fold-show-entry)
- (buffer-substring
- (point)
- (save-excursion (org-end-of-subtree t t) (point)))))))
- (with-current-buffer (get-buffer-create "*org-kanban-preview*")
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert content)
- (org-mode)
- (goto-char (point-min))
- (org-fold-show-all)
- (org-cycle-hide-drawers 'all)
- (read-only-mode 1)))))))))
-
-;;; Navigation
-
-(defun org-kanban--goto-card (col row)
- "Select the card at COL and ROW.
-Re-renders the board to update the indicator, then moves point."
- (when (gethash (cons col row) org-kanban--card-positions)
- (setq org-kanban--selected-col col
- org-kanban--selected-row row)
- ;; Re-render to show the indicator (uses cached card data)
- (org-kanban--render)
- ;; Move point to the selected card
- (goto-char (point-min))
- (let ((found nil))
- (while (and (not found) (not (eobp)))
- (when (and (equal (get-text-property (point) 'org-kanban-col) col)
- (equal (get-text-property (point) 'org-kanban-row) row))
- (setq found t))
- (unless found
- (goto-char (next-single-property-change (point) 'org-kanban-card nil (point-max))))))
- ;; Update selected card and preview
- (let ((card (gethash (cons col row) org-kanban--card-positions)))
- (when card
- (setq org-kanban--selected-card card)
- (org-kanban--update-preview card)))))
-
-(defun org-kanban-next-card ()
- "Move to the next card (down or next column)."
- (interactive)
- (let ((col (get-text-property (point) 'org-kanban-col))
- (row (get-text-property (point) 'org-kanban-row)))
- (cond
- ;; Try next row in same column
- ((and col row (gethash (cons col (1+ row)) org-kanban--card-positions))
- (org-kanban--goto-card col (1+ row)))
- ;; Try first card in next column
- ((and col (gethash (cons (1+ col) 0) org-kanban--card-positions))
- (org-kanban--goto-card (1+ col) 0))
- ;; Wrap to first card
- ((gethash (cons 0 0) org-kanban--card-positions)
- (org-kanban--goto-card 0 0))
- ;; No card at point, find first card
- (t (org-kanban--goto-card 0 0)))))
-
-(defun org-kanban-prev-card ()
- "Move to the previous card (up or previous column)."
- (interactive)
- (let ((col (get-text-property (point) 'org-kanban-col))
- (row (get-text-property (point) 'org-kanban-row)))
- (cond
- ;; Try previous row in same column
- ((and col row (> row 0)
- (gethash (cons col (1- row)) org-kanban--card-positions))
- (org-kanban--goto-card col (1- row)))
- ;; Try last card in previous column
- ((and col (> col 0))
- (let ((prev-col (1- col))
- (r 0))
- (while (gethash (cons prev-col (1+ r)) org-kanban--card-positions)
- (cl-incf r))
- (org-kanban--goto-card prev-col r)))
- ;; No card at point, find first card
- (t (org-kanban--goto-card 0 0)))))
-
-(defun org-kanban--col-count (col)
- "Return the number of cards in column COL."
- (let ((count 0))
- (while (gethash (cons col count) org-kanban--card-positions)
- (cl-incf count))
- count))
-
-(defun org-kanban-next-column ()
- "Move to the next column (right), keeping the same row or nearest card."
- (interactive)
- (let ((col (or org-kanban--selected-col 0))
- (row (or org-kanban--selected-row 0))
- (ncols (length (append org-kanban-columns
- (when org-kanban--show-done org-kanban-done-columns)))))
- (cl-loop for c from (1+ col) below ncols
- for cnt = (org-kanban--col-count c)
- when (> cnt 0)
- do (org-kanban--goto-card c (min row (1- cnt)))
- and return nil
- finally
- ;; Wrap around from the start
- (cl-loop for c from 0 below col
- for cnt = (org-kanban--col-count c)
- when (> cnt 0)
- do (org-kanban--goto-card c (min row (1- cnt)))
- and return nil))))
-
-(defun org-kanban-prev-column ()
- "Move to the previous column (left), keeping the same row or nearest card."
- (interactive)
- (let ((col (or org-kanban--selected-col 0))
- (row (or org-kanban--selected-row 0))
- (ncols (length (append org-kanban-columns
- (when org-kanban--show-done org-kanban-done-columns)))))
- (cl-loop for c downfrom (1- col) to 0
- for cnt = (org-kanban--col-count c)
- when (> cnt 0)
- do (org-kanban--goto-card c (min row (1- cnt)))
- and return nil
- finally
- ;; Wrap around from the end
- (cl-loop for c downfrom (1- ncols) above col
- for cnt = (org-kanban--col-count c)
- when (> cnt 0)
- do (org-kanban--goto-card c (min row (1- cnt)))
- and return nil))))
-
-;;; Card operations
-
-(defun org-kanban--move-card (direction)
- "Move the card at point in DIRECTION (:next or :prev) in the state order."
- (let ((card (get-text-property (point) 'org-kanban-card)))
- (when card
- (let* ((current-state (plist-get card :state))
- (marker (plist-get card :marker))
- (heading (plist-get card :heading))
- (idx (cl-position current-state org-kanban-state-order :test #'string=))
- (new-idx (pcase direction
- (:next (min (1- (length org-kanban-state-order)) (1+ idx)))
- (:prev (max 0 (1- idx)))))
- (new-state (nth new-idx org-kanban-state-order)))
- (unless (string= current-state new-state)
- ;; Change the state in the org file
- (with-current-buffer (marker-buffer marker)
- (save-excursion
- (goto-char marker)
- (org-todo new-state)))
- (message "Moved \"%s\" → %s" heading new-state)
- ;; Refresh board, try to stay near current position
- (let ((col (get-text-property (point) 'org-kanban-col))
- (row (get-text-property (point) 'org-kanban-row)))
- (org-kanban-refresh)
- ;; Try to navigate back to a reasonable position
- (or (ignore-errors (org-kanban--goto-card col row) t)
- (ignore-errors (org-kanban--goto-card col (max 0 (1- row))) t)
- (org-kanban--goto-card 0 0))))))))
-
-(defun org-kanban-move-right ()
- "Move the card at point to the next state."
- (interactive)
- (org-kanban--move-card :next))
-
-(defun org-kanban-move-left ()
- "Move the card at point to the previous state."
- (interactive)
- (org-kanban--move-card :prev))
-
-(defun org-kanban-goto-heading ()
- "Jump to the org heading for the card at point.
-Opens in a new tab if `org-kanban-open-in-tab' is non-nil."
- (interactive)
- (let ((card (get-text-property (point) 'org-kanban-card)))
- (when card
- (let ((marker (plist-get card :marker)))
- (when (and marker (marker-buffer marker))
- (if org-kanban-open-in-tab
- (progn
- (tab-bar-new-tab)
- (switch-to-buffer (marker-buffer marker)))
- (pop-to-buffer (marker-buffer marker)))
- (goto-char marker)
- (org-reveal)
- (org-fold-show-entry))))))
-
-(defun org-kanban-preview ()
- "Preview the card at point in a side window (toggle)."
- (interactive)
- (let ((win (get-buffer-window "*org-kanban-preview*")))
- (if win
- (quit-window nil win)
- (let* ((card (get-text-property (point) 'org-kanban-card))
- (marker (and card (plist-get card :marker))))
- (when (and marker (marker-buffer marker))
- (let ((content
- (with-current-buffer (marker-buffer marker)
- (save-excursion
- (goto-char marker)
- (org-fold-show-entry)
- (buffer-substring
- (point)
- (save-excursion
- (org-end-of-subtree t t)
- (point)))))))
- (with-current-buffer (get-buffer-create "*org-kanban-preview*")
- (let ((inhibit-read-only t))
- (erase-buffer)
- (insert content)
- (org-mode)
- (goto-char (point-min))
- (org-fold-show-all)
- (org-cycle-hide-drawers 'all)
- (read-only-mode 1)))
- (display-buffer "*org-kanban-preview*"
- '((display-buffer-in-side-window)
- (side . bottom)
- (window-height . 0.35)))))))))
-
-;;; Filtering
-
-(defun org-kanban-filter-section ()
- "Filter the board to show only items from a specific section."
- (interactive)
- (let* ((sections (org-ql-select org-kanban-file
- '(level 1)
- :action (lambda () (substring-no-properties (org-get-heading t t t t)))))
- (choice (completing-read "Filter by section (empty to clear): "
- sections nil nil)))
- (setq org-kanban--section-filter (if (string-empty-p choice) nil choice))
- (org-kanban-refresh)))
-
-(defun org-kanban-clear-filter ()
- "Clear the section filter."
- (interactive)
- (setq org-kanban--section-filter nil)
- (org-kanban-refresh))
-
-(defun org-kanban-cycle-sort ()
- "Cycle through sort orders: priority → scheduled → deadline → alpha → none."
- (interactive)
- (let* ((current (or org-kanban--sort org-kanban-sort))
- (order '(priority scheduled deadline alpha none))
- (idx (cl-position current order))
- (next (nth (mod (1+ (or idx 0)) (length order)) order)))
- (setq org-kanban--sort next)
- (setq org-kanban--cards (org-kanban--fetch-cards))
- (setq org-kanban--selected-col nil
- org-kanban--selected-row nil)
- (org-kanban--render)
- (goto-char (point-min))
- (when (gethash (cons 0 0) org-kanban--card-positions)
- (org-kanban--goto-card 0 0))
- (message "Sort: %s" next)))
-
-(defun org-kanban-toggle-done ()
- "Toggle visibility of DONE/CANX columns."
- (interactive)
- (setq org-kanban--show-done (not org-kanban--show-done))
- (org-kanban-refresh))
-
-;;; Refresh
-
-(defun org-kanban-refresh ()
- "Refresh the kanban board."
- (interactive)
- (setq org-kanban--cards (org-kanban--fetch-cards))
- (setq org-kanban--selected-col nil
- org-kanban--selected-row nil)
- (org-kanban--render)
- (goto-char (point-min))
- ;; Navigate to first card
- (when (gethash (cons 0 0) org-kanban--card-positions)
- (org-kanban--goto-card 0 0)))
-
-;;; Major mode
-
-(defvar org-kanban-mode-map
- (let ((map (make-sparse-keymap)))
- ;; Navigation
- (define-key map (kbd "j") #'org-kanban-next-card)
- (define-key map (kbd "n") #'org-kanban-next-card)
- (define-key map (kbd "<down>") #'org-kanban-next-card)
- (define-key map (kbd "k") #'org-kanban-prev-card)
- (define-key map (kbd "p") #'org-kanban-prev-card)
- (define-key map (kbd "<up>") #'org-kanban-prev-card)
- ;; Column navigation
- (define-key map (kbd "l") #'org-kanban-next-column)
- (define-key map (kbd "<right>") #'org-kanban-next-column)
- (define-key map (kbd "h") #'org-kanban-prev-column)
- (define-key map (kbd "<left>") #'org-kanban-prev-column)
- ;; Move cards (change state)
- (define-key map (kbd ">") #'org-kanban-move-right)
- (define-key map (kbd "L") #'org-kanban-move-right)
- (define-key map (kbd "<") #'org-kanban-move-left)
- (define-key map (kbd "H") #'org-kanban-move-left)
- ;; Actions
- (define-key map (kbd "RET") #'org-kanban-goto-heading)
- (define-key map (kbd "v") #'org-kanban-preview)
- (define-key map (kbd "SPC") #'org-kanban-preview)
- (define-key map (kbd "f") #'org-kanban-filter-section)
- (define-key map (kbd "F") #'org-kanban-clear-filter)
- (define-key map (kbd "s") #'org-kanban-cycle-sort)
- (define-key map (kbd "D") #'org-kanban-toggle-done)
- (define-key map (kbd "g") #'org-kanban-refresh)
- (define-key map (kbd "q") #'quit-window)
- map)
- "Keymap for `org-kanban-mode'.")
-
-(define-derived-mode org-kanban-mode special-mode "OrgKanban"
- "Major mode for the org-mode kanban board.
-\\{org-kanban-mode-map}"
- (setq-local buffer-read-only t)
- (setq-local truncate-lines t)
- (setq-local cursor-type nil)
- ;; Force monospace fixed-pitch font at uniform height to fix alignment
- ;; with mixed-fonts themes (modus-themes-mixed-fonts, variable-pitch headings)
- (face-remap-add-relative 'default :inherit 'fixed-pitch :height 1.0)
-)
-
-;;; Entry points
-
-;;;###autoload
-(defun org-kanban (&optional file)
- "Open the kanban board for FILE (defaults to `org-kanban-file')."
- (interactive)
- (let ((buf (get-buffer-create "*org-kanban*")))
- (with-current-buffer buf
- (org-kanban-mode)
- (when file
- (setq-local org-kanban-file file))
- (org-kanban-refresh))
- (pop-to-buffer buf)))
-
-;;;###autoload
-(defun org-kanban-work ()
- "Open the kanban board filtered to the Work section."
- (interactive)
- (let ((buf (get-buffer-create "*org-kanban*")))
- (with-current-buffer buf
- (org-kanban-mode)
- (setq org-kanban--section-filter "Work")
- (org-kanban-refresh))
- (pop-to-buffer buf)))
-
-;;;###autoload
-(defun org-kanban-projects ()
- "Open the kanban board filtered to the Projects section."
- (interactive)
- (let ((buf (get-buffer-create "*org-kanban*")))
- (with-current-buffer buf
- (org-kanban-mode)
- (setq org-kanban--section-filter "Projects")
- (org-kanban-refresh))
- (pop-to-buffer buf)))
-
-;;;###autoload
-(defun org-kanban-systems ()
- "Open the kanban board filtered to the Systems section."
- (interactive)
- (let ((buf (get-buffer-create "*org-kanban*")))
- (with-current-buffer buf
- (org-kanban-mode)
- (setq org-kanban--section-filter "Systems")
- (org-kanban-refresh))
- (pop-to-buffer buf)))
-
-(provide 'org-kanban)
-;;; org-kanban.el ends here
dots/config/emacs/site-lisp/whisper.el
@@ -1,208 +0,0 @@
-;;; whisper.el --- Record audio and transcribe using whisper-cli -*- lexical-binding: t; -*-
-
-;; Package-Requires: ((emacs "25.1"))
-
-;;; Commentary:
-;; This library provides functions to record audio using ffmpeg and
-;; transcribe it using the whisper-cli command-line tool.
-
-;;; Code:
-
-(defgroup whisper nil
- "Settings for the whisper audio transcription library."
- :group 'tools)
-
-(defcustom whisper-cli-executable "whisper-cli"
- "Path to the whisper-cli executable."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-model "base"
- "The whisper model to use for transcription (e.g., tiny, base, small, medium, large)."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-language "en"
- "The language for transcription (e.g., en, es, fr, de)."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-ffmpeg-timeout 300
- "Default timeout in seconds for ffmpeg recording."
- :type 'integer
- :group 'whisper)
-
-(defcustom whisper-ffmpeg-audio-input-source "pulse"
- "FFmpeg audio input source. For PulseAudio, usually 'pulse'.
-For ALSA, it might be 'hw:0'. For macOS, it might be ':0' (for default input).
-You might need to adjust this based on your system's ffmpeg configuration."
- :type 'string
- :group 'whisper)
-
-(defcustom whisper-ffmpeg-audio-input-device "default"
- "FFmpeg audio input device. For PulseAudio, often 'default'.
-For ALSA, it might be something like 'plughw:1,0'.
-For macOS, check available devices with `ffmpeg -f avfoundation -list_devices true -i \"\"`."
- :type 'string
- :group 'whisper)
-
-
-(defvar whisper--recording-process nil
- "Holds the ffmpeg recording process.")
-(defvar whisper--original-mode-line-format mode-line-format
- "To store the original mode-line format.")
-
-(defun whisper--start-mode-line-indicator (indicator)
- "Display an INDICATOR in the mode line."
- (setq whisper--original-mode-line-format mode-line-format)
- (setq-default mode-line-format
- (cons (format " %s " indicator)
- (if (listp mode-line-format) mode-line-format (list mode-line-format)))))
-
-(defun whisper--stop-mode-line-indicator ()
- "Restore the original mode line."
- (setq-default mode-line-format whisper--original-mode-line-format))
-
-(defun whisper--record-audio (output-file-basename timeout callback)
- "Record audio using ffmpeg.
-A temporary WAV file will be created based on OUTPUT-FILE-BASENAME.
-Recording runs for TIMEOUT seconds, or until the process is interrupted.
-Then, execute CALLBACK function with the path to the recorded audio file.
-The CALLBACK is responsible for processing and eventually deleting the audio file."
- (whisper--start-mode-line-indicator "")
- (message "Recording audio for up to %d seconds (or run 'whisper-run' again to stop early)..." timeout)
- (let* ((temp-wav-file (make-temp-file "whisper-audio-" nil ".wav"))
- (process-environment (copy-sequence process-environment))
- (ffmpeg-command
- (list "ffmpeg"
- "-y" ; Overwrite output files without asking
- "-f" whisper-ffmpeg-audio-input-source
- "-i" whisper-ffmpeg-audio-input-device
- "-t" (number-to-string timeout)
- temp-wav-file)))
- (setenv "LC_ALL" "C" process-environment) ; Ensure consistent ffmpeg output
- (setq whisper--recording-process
- (apply #'start-process "whisper-ffmpeg" "*whisper-ffmpeg-output*" ffmpeg-command))
-
- (set-process-sentinel
- whisper--recording-process
- (lambda (proc _event)
- (let ((status (process-status proc))
- (audio-file-processed nil)) ; Flag to track if callback was called
- (unwind-protect
- (cond
- ((memq status '(exit signal)) ; Process has definitely terminated
- (if (and (file-exists-p temp-wav-file)
- ;; Check if file has content (size > 0)
- (> (nth 7 (file-attributes temp-wav-file)) 0))
- (progn
- (message "Recording finished/stopped. Audio file: %s" temp-wav-file)
- (setq audio-file-processed t)
- (funcall callback temp-wav-file)) ; Pass to callback for transcription
- (progn
- (message "Recording failed or produced no usable audio data."))))
- (t ; Other statuses - should not happen often for a finished process
- (message "Recording process ended in unexpected state: %s" status)))
- ;; Cleanup actions
- (setq whisper--recording-process nil) ; Clear the process variable
- (whisper--stop-mode-line-indicator) ; Always restore mode line
- ;; Delete the temp wav file only if it was not passed to the callback
- (when (and (not audio-file-processed) (file-exists-p temp-wav-file))
- (message "Deleting unused/empty temp audio file: %s" temp-wav-file)
- (delete-file temp-wav-file))
- ))))
- whisper--recording-process))
-
-(defun whisper--transcribe (audio-file callback)
- "Transcribe AUDIO-FILE using whisper-cli and call CALLBACK with transcription."
- (whisper--start-mode-line-indicator "")
- (message "Transcribing audio...")
- (let* ((temp-output-file (make-temp-file "whisper-transcription-" nil ".txt"))
- (command (list whisper-cli-executable
- audio-file
- "--model" whisper-model
- "--language" whisper-language
- "--output_txt" ; Ensure whisper-cli outputs a .txt file
- "--output_dir" (file-name-directory temp-output-file))))
- (message "Running command: %s" (string-join command " "))
- (let ((process (apply #'start-process "whisper-cli" "*whisper-cli-output*" command)))
- (set-process-sentinel
- process
- (lambda (_proc _event)
- (whisper--stop-mode-line-indicator)
- (unwind-protect
- (if (and (eq (process-status process) 'exit)
- (= (process-exit-status process) 0))
- (let* ((expected-txt-name (concat (file-name-sans-extension audio-file) ".txt"))
- (transcription-file (expand-file-name expected-txt-name (file-name-directory temp-output-file))))
- (if (file-exists-p transcription-file)
- (progn
- (message "Transcription successful.")
- (with-temp-buffer
- (insert-file-contents transcription-file)
- (funcall callback (buffer-string)))
- (delete-file transcription-file)) ; Delete the .txt file created by whisper-cli
- (message "Transcription output file not found: %s" transcription-file)))
- (message "whisper-cli transcription failed. Check *whisper-cli-output* buffer."))
- (when (file-exists-p audio-file)
- (delete-file audio-file)) ; Clean up the audio file
- (when (file-exists-p temp-output-file)
- (delete-file temp-output-file)) ; Clean up the placeholder temp file
- ))))))
-
-;;;###autoload
-;;;###autoload
-(defun whisper-run ()
- "Record audio, transcribe it, and insert the text into the current buffer.
-If a recording is already in progress (started by this command),
-running `whisper-run` again will stop the current recording, and
-transcription will proceed on the audio captured so far.
-Uses `whisper-ffmpeg-timeout` for recording duration if starting anew."
- (interactive)
- (if (and whisper--recording-process (process-live-p whisper--recording-process))
- (progn
- (message "Stopping current recording...")
- (interrupt-process whisper--recording-process)
- ;; The sentinel of the existing whisper--recording-process will handle
- ;; the audio file and initiate transcription.
- )
- ;; Else, no recording in progress, so start a new one.
- (whisper--record-audio
- "whisper-rec-" ; Base name for make-temp-file
- whisper-ffmpeg-timeout
- (lambda (audio-file) ; This is the callback from whisper--record-audio
- ;; audio-file here is the temp-wav-file from whisper--record-audio
- (if (and audio-file (file-exists-p audio-file))
- (whisper--transcribe
- audio-file ; whisper--transcribe is now responsible for this audio-file
- (lambda (transcription)
- (if (string-empty-p transcription)
- (message "Transcription is empty.")
- (insert transcription))
- (message "Transcription inserted.")))
- (message "No valid audio file was recorded to transcribe."))))))
-
-;;;###autoload
-(defun whisper-file (file)
- "Record audio, transcribe it, and append the text to the specified FILE.
-Uses `whisper-ffmpeg-timeout` for recording duration."
- (interactive "FAppend transcription to file: ")
- (unless (file-writable-p (file-name-directory file))
- (error "Directory for file %s is not writable" file))
- (whisper--record-audio
- "whisper-temp-output.wav" ; Not directly used
- whisper-ffmpeg-timeout
- (lambda (audio-file)
- (whisper--transcribe
- audio-file
- (lambda (transcription)
- (if (string-empty-p transcription)
- (message "Transcription is empty. Nothing appended to %s." file)
- (with-temp-buffer
- (insert transcription)
- (append-to-file nil nil file))
- (message "Transcription appended to %s." file)))))))
-
-(provide 'whisper)
-
-;;; whisper.el ends here
home/common/dev/emacs.nix
@@ -18,26 +18,18 @@ let
'';
myExtraPackages =
epkgs: with epkgs; [
- ace-window
acp
- adoc-mode
age
agent-shell
aggressive-indent
- aidermacs
alert
async
avy
beginend
cape
- casual
- casual-avy
- chatgpt-shell
- shell-maker
consult
consult-dir
consult-denote
- consult-project-extra
consult-vc-modified-files
consult-gh
consult-gh-embark
@@ -45,27 +37,18 @@ let
# copilot
# copilot-chat
corfu
- corfu-candidate-overlay
dape
dash
denote
denote-org
# denote-journal
# denote-sequence # maybe ?
- denote-menu
- detached
devdocs
diff-hl
- dired-collapse
dired-narrow
- dired-rsync
- diredfl
- dockerfile-mode
# doom-modeline
dwim-shell-command
- easy-kill
eat
- edit-indirect
editorconfig
eldoc-box
pr-review
@@ -77,53 +60,25 @@ let
esup
# flimenu
flymake-yamllint
- (ghostel.overrideAttrs (old: {
- # Exclude evil integration files to avoid build failure when evil is not installed
- preBuild = (old.preBuild or "") + ''
- rm -f "$NIX_BUILD_TOP/working/ghostel/evil-ghostel.el" "$NIX_BUILD_TOP/working/ghostel/ghostel-evil.el" 2>/dev/null || true
- '';
- # Install pre-built native module (Nix store is read-only, can't download at runtime)
- postInstall = (old.postInstall or "") + ''
- local dest=$(find $out/share/emacs/site-lisp/elpa -maxdepth 1 -name 'ghostel-*' -type d)
- install -m444 ${
- pkgs.fetchurl {
- url = "https://github.com/dakra/ghostel/releases/download/v0.13.0/ghostel-module-x86_64-linux.so";
- hash = "sha256-C+hinkx7uKIQw04TNevVZ6ybutpqm62wqo62q2KSbUk=";
- }
- } $dest/ghostel-module.so
- '';
- }))
git-modes
- go-mode
- gotest
gotest-ts
gptel
- hardhat
helpful
- highlight
- highlight-indentation
htmlize
# ibuffer-vc
- indent-bars
jinx
# jira
# jiralib2
- json-mode
- kubed
kkp
# ligature
macrostep
magit
- magit-popup
# marginalia
markdown-mode
mcp
minions
modus-themes
- doom-themes
- multi-vterm
mu4e
- mwim
nix-mode
nix-ts-mode
nixpkgs-fmt
@@ -136,23 +91,15 @@ let
orderless
org
org-appear
- org-contrib
- org-download
- org-gcal
- org-modern
org-nix-shell
org-ql
- org-review
org-rich-yank
org-tree-slide
org-web-tools
orgalist
orgit
outline-indent
- ox-pandoc
- ox-jira
ox-tufte
- pandoc-mode
pi-coding-agent
pkgs.phscroll
popon
@@ -160,10 +107,7 @@ let
rg
ready-player
scopeline
- scratch
shr-tag-pre-highlight
- smartparens
- substitute
surround
symbol-overlay
tempel
@@ -174,13 +118,11 @@ let
treesit-fold
treesit-grammars.with-all-grammars # see how much it weight
# vc-jj
- verb
# vertico
visual-fill-column
visual-regexp
vterm
vundo
- web-mode
wgrep
with-editor
# xeft