diff options
Diffstat (limited to 'modules')
| -rw-r--r-- | modules/calendar-sync.el | 10 | ||||
| -rw-r--r-- | modules/dirvish-config.el | 9 | ||||
| -rw-r--r-- | modules/google-keep-config.el | 210 | ||||
| -rw-r--r-- | modules/ledger-config.el | 40 | ||||
| -rw-r--r-- | modules/prog-general.el | 10 | ||||
| -rw-r--r-- | modules/ui-navigation.el | 10 | ||||
| -rw-r--r-- | modules/user-constants.el | 6 |
7 files changed, 276 insertions, 19 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 8d7552d3e..7ed14921f 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -540,7 +540,15 @@ Compares year, month, day, hour, minute." (plist-put result :location (plist-get exception :location))) ;; Pass through new fields if exception overrides them (when (plist-get exception :attendees) - (plist-put result :attendees (plist-get exception :attendees))) + (plist-put result :attendees (plist-get exception :attendees)) + ;; Re-derive the user's status from the overridden attendees so a + ;; singly-declined occurrence drops its inherited series "accepted" + ;; (otherwise `calendar-sync--filter-declined' can't drop it). Leave the + ;; inherited status when the override doesn't name the user. + (let ((status (calendar-sync--find-user-status + (plist-get exception :attendees) calendar-sync-user-emails))) + (when status + (plist-put result :status status)))) (when (plist-get exception :organizer) (plist-put result :organizer (plist-get exception :organizer))) (when (plist-get exception :url) diff --git a/modules/dirvish-config.el b/modules/dirvish-config.el index f33e8cf74..c4c5f1aae 100644 --- a/modules/dirvish-config.el +++ b/modules/dirvish-config.el @@ -400,18 +400,19 @@ regardless of what file or subdirectory the point is on." "Return the (PROGRAM PRE-FILE-ARG...) list for setting wallpaper under ENV. ENV is a display-server symbol: `x11' picks feh with --bg-fill, `wayland' -picks swww with the img subcommand. Any other value returns nil so the -caller can surface an \"unknown display server\" error. +picks the `set-wallpaper' script (on PATH from dotfiles; it wraps the awww +backend and persists the choice to waypaper's config). Any other value +returns nil so the caller can surface an \"unknown display server\" error. Pure helper used by `cj/set-wallpaper'." (pcase env ('x11 '("feh" "--bg-fill")) - ('wayland '("swww" "img")) + ('wayland '("set-wallpaper")) (_ nil))) (defun cj/set-wallpaper () "Set the image at point as the desktop wallpaper. -Uses feh on X11, swww on Wayland." +Uses feh on X11, the `set-wallpaper' script on Wayland." (interactive) (let* ((raw (dired-file-name-at-point)) (file (and raw (expand-file-name raw))) diff --git a/modules/google-keep-config.el b/modules/google-keep-config.el new file mode 100644 index 000000000..1738fa6e0 --- /dev/null +++ b/modules/google-keep-config.el @@ -0,0 +1,210 @@ +;;; google-keep-config.el --- Google Keep -> org integration -*- lexical-binding: t; coding: utf-8; -*- +;; author Craig Jennings <c@cjennings.net> + +;;; Commentary: +;; A read-only view of Google Keep notes as an org page. `cj/keep-refresh' +;; runs a Python gkeepapi bridge (scripts/google-keep/keep-bridge.py), parses +;; its JSON, and regenerates `keep-file' with one org header per note. Editing +;; the file does NOT sync back to Keep -- that is v2. +;; +;; The pure JSON-to-org core (the cj/keep--render* / --note-* helpers) is kept +;; free of .emacs.d specifics so it can later extract to a standalone package; +;; the IO layer and this module supply paths, auth, and keys. +;; +;; One-time setup: install the client (pip install gkeepapi), obtain a Google +;; master token, set `cj/keep-email', and store the token in authinfo.gpg as +;; machine google-keep login <you@gmail.com> password <master-token> +;; See docs/specs/google-keep-emacs-integration-spec.org. + +;;; Code: + +(require 'json) +(require 'subr-x) +(require 'system-lib) ;; cj/auth-source-secret-value, cj/executable-find-or-warn +(require 'user-constants) ;; keep-file + +;; ------------------------------ Configuration -------------------------------- + +(defgroup cj/keep nil + "Google Keep to org integration." + :group 'applications + :prefix "cj/keep-") + +(defcustom cj/keep-email nil + "Google account email for the Keep bridge, also the authinfo login. +Unset until the one-time setup is done; `cj/keep-refresh' warns when nil." + :type '(choice (const :tag "Unset" nil) string) + :group 'cj/keep) + +(defcustom cj/keep-auth-host "google-keep" + "The authinfo.gpg machine entry holding the Keep master token." + :type 'string + :group 'cj/keep) + +(defcustom cj/keep-python "python3" + "Python interpreter used to run the Keep bridge." + :type 'string + :group 'cj/keep) + +(defvar cj/keep--bridge-script + (expand-file-name "scripts/google-keep/keep-bridge.py" user-emacs-directory) + "Path to the gkeepapi bridge script.") + +(defconst cj/keep--web-base "https://keep.google.com/#NOTE/" + "Base URL for a Keep note back-link.") + +;; --------------------------- Pure core: JSON -> org -------------------------- +;; These take plain data and return strings -- no IO, no .emacs.d paths -- so +;; they unit-test directly and lift out to a package unchanged. + +(defun cj/keep--parse-json (json-string) + "Parse the bridge JSON-STRING into a list of note alists." + (json-parse-string json-string + :object-type 'alist :array-type 'list + :false-object nil :null-object nil)) + +(defun cj/keep--label-to-tag (label) + "Sanitize LABEL into a valid org tag (alphanumerics / _ / @ / # / %)." + (replace-regexp-in-string "[^[:alnum:]_@#%]" "_" label)) + +(defun cj/keep--note-tags (note) + "Return the trailing org-tag string for NOTE (labels + archived), or \"\"." + (let ((tags (append (mapcar #'cj/keep--label-to-tag (alist-get 'labels note)) + (and (alist-get 'archived note) '("archived"))))) + (if tags (concat " :" (string-join tags ":") ":") ""))) + +(defun cj/keep--note-heading (note) + "Render NOTE (an alist) as one org subtree string." + (let* ((id (alist-get 'id note)) + (title (alist-get 'title note)) + (text (alist-get 'text note)) + (heading (if (and title (> (length title) 0)) title "(untitled)"))) + (concat + "* " heading (cj/keep--note-tags note) "\n" + ":PROPERTIES:\n" + ":KEEP_ID: " (or id "") "\n" + ":PINNED: " (if (alist-get 'pinned note) "t" "nil") "\n" + ":COLOR: " (or (alist-get 'color note) "") "\n" + ":ARCHIVED: " (if (alist-get 'archived note) "t" "nil") "\n" + ":UPDATED: " (or (alist-get 'updated note) "") "\n" + ":END:\n" + (if (and id (> (length id) 0)) + (concat "[[" cj/keep--web-base id "][open in Keep]]\n") + "") + "\n" + (if (and text (> (length text) 0)) (concat text "\n") "")))) + +(defun cj/keep--sort-pinned-first (notes) + "Return NOTES with pinned ones first, original order otherwise preserved." + (let (pinned rest) + (dolist (n notes) + (if (alist-get 'pinned n) (push n pinned) (push n rest))) + (append (nreverse pinned) (nreverse rest)))) + +(defun cj/keep--render (notes &optional generated-at) + "Render NOTES (a list of alists) into the full org page string. +GENERATED-AT is an optional last-refresh timestamp string for the header." + (concat + "# Generated by cj/keep-refresh -- read-only view; edits here do NOT sync to Keep.\n" + "#+TITLE: Google Keep\n" + (if generated-at (concat "# Last refresh: " generated-at "\n") "") + "\n" + (mapconcat #'cj/keep--note-heading (cj/keep--sort-pinned-first notes) ""))) + +;; ------------------------------- IO: run + write ----------------------------- + +(defun cj/keep--write-atomically (content file) + "Write CONTENT to FILE via a temp file in FILE's directory + atomic rename." + (let ((tmp (make-temp-file + (expand-file-name (concat "." (file-name-nondirectory file) ".") + (file-name-directory file)) + nil nil content))) + (rename-file tmp file t))) + +(defun cj/keep--warn (token) + "Surface a Keep bridge failure TOKEN as a `display-warning'." + (display-warning + 'cj/keep + (pcase token + ("no-gkeepapi" "Keep bridge: gkeepapi is not installed (pip install gkeepapi).") + ("no-token" "Keep bridge: no master token in authinfo.gpg, or `cj/keep-email' is unset.") + ("auth-failed" "Keep bridge: Google rejected the credentials (token expired or revoked?).") + ("network" "Keep bridge: network error reaching Google Keep.") + (_ (format "Keep bridge failed: %s" (if (string-empty-p token) "unknown error" token)))) + :error)) + +(defun cj/keep--write-notes (json) + "Parse bridge JSON, render, and write `keep-file' atomically. +Returns the note count." + (let* ((notes (cj/keep--parse-json json)) + (org (cj/keep--render notes (format-time-string "%Y-%m-%d %H:%M")))) + (cj/keep--write-atomically org keep-file) + (length notes))) + +;;;###autoload +(defun cj/keep-refresh () + "Fetch Google Keep notes and regenerate `keep-file' (a read-only view)." + (interactive) + (let ((token (and cj/keep-email + (cj/auth-source-secret-value cj/keep-auth-host cj/keep-email)))) + (cond + ((not (file-exists-p cj/keep--bridge-script)) + (user-error "Keep bridge script not found: %s" cj/keep--bridge-script)) + ((or (not cj/keep-email) (not token)) + (cj/keep--warn "no-token")) + (t + (let* ((out (generate-new-buffer " *keep-bridge-out*")) + (err (generate-new-buffer " *keep-bridge-err*")) + (process-environment + (append (list (concat "KEEP_EMAIL=" cj/keep-email) + (concat "KEEP_MASTER_TOKEN=" token)) + process-environment))) + (message "Keep: fetching...") + (make-process + :name "keep-bridge" + :buffer out + :stderr err + :command (list cj/keep-python cj/keep--bridge-script) + :sentinel + (lambda (proc _event) + (when (memq (process-status proc) '(exit signal)) + (unwind-protect + (if (and (eq (process-status proc) 'exit) + (= (process-exit-status proc) 0)) + (let ((n (cj/keep--write-notes + (with-current-buffer out (buffer-string))))) + (message "Keep: wrote %d notes to %s" n keep-file)) + (cj/keep--warn + (string-trim (if (buffer-live-p err) + (with-current-buffer err (buffer-string)) + "")))) + (when (buffer-live-p out) (kill-buffer out)) + (when (buffer-live-p err) (kill-buffer err))))))))))) + +;;;###autoload +(defun cj/keep-open () + "Open the generated Keep org file, offering to refresh when it's absent." + (interactive) + (if (file-exists-p keep-file) + (find-file keep-file) + (if (y-or-n-p "Keep file doesn't exist yet. Refresh now? ") + (cj/keep-refresh) + (message "Run M-x cj/keep-refresh to generate it")))) + +;; --------------------------------- Glue / keys ------------------------------- + +(defvar cj/keep-prefix-map + (let ((map (make-sparse-keymap))) + (define-key map "r" #'cj/keep-refresh) + (define-key map "o" #'cj/keep-open) + map) + "Prefix keymap for Google Keep commands (bound to \\=`C-c k').") + +(keymap-global-set "C-c k" cj/keep-prefix-map) + +;; Warn at load if the interpreter is missing; gkeepapi/token failures surface +;; at refresh time via the bridge's stderr reason token. +(cj/executable-find-or-warn cj/keep-python "Google Keep bridge" 'google-keep-config) + +(provide 'google-keep-config) +;;; google-keep-config.el ends here diff --git a/modules/ledger-config.el b/modules/ledger-config.el index 5b2712b57..018601043 100644 --- a/modules/ledger-config.el +++ b/modules/ledger-config.el @@ -2,15 +2,25 @@ ;; author Craig Jennings <c@cjennings.net> ;;; Commentary: +;; Editing support for ledger-format plain-text accounting files: ledger-mode, +;; flycheck linting, company completion, clean-on-save, and a small report set. +;; The reports and reconcile shell out to the `ledger' CLI; a load-time check +;; warns when it is missing rather than letting a report fail cryptically. ;;; Code: ;; ------------------------------- Declarations -------------------------------- (declare-function ledger-mode-clean-buffer "ledger-mode") +(declare-function cj/executable-find-or-warn "system-lib") (defvar ledger-mode-map) (defvar company-backends) +(defcustom cj/ledger-clean-on-save t + "When non-nil, tidy a ledger buffer with `ledger-mode-clean-buffer' before save." + :type 'boolean + :group 'ledger) + ;; -------------------------------- Ledger Mode -------------------------------- ;; edit files in ledger format @@ -19,36 +29,38 @@ "\\.ledger\\'" "\\.journal\\'") :preface - (defun cj/ledger-save () - "Automatically clean the ledger buffer at each save." - (interactive) - (save-excursion - (when (buffer-modified-p) - (with-demoted-errors "Error cleaning ledger buffer: %S" - (ledger-mode-clean-buffer)) - (save-buffer)))) - :bind - (:map ledger-mode-map - ("C-x C-s" . cj/ledger-save)) + (defun cj/ledger--clean-before-save () + "Tidy the ledger buffer before save when `cj/ledger-clean-on-save' is set. +Errors are demoted so a malformed buffer still saves." + (when cj/ledger-clean-on-save + (with-demoted-errors "Error cleaning ledger buffer: %S" + (ledger-mode-clean-buffer)))) + (defun cj/ledger--enable-clean-on-save () + "Install the clean-on-save hook buffer-locally so it fires on every save path." + (add-hook 'before-save-hook #'cj/ledger--clean-before-save nil t)) + :hook (ledger-mode . cj/ledger--enable-clean-on-save) :custom (ledger-clear-whole-transactions t) (ledger-reconcile-default-commodity "$") (ledger-report-use-header-line nil) + (ledger-highlight-xact-under-point t) (ledger-reports '(("bal" "%(binary) --strict -f %(ledger-file) bal") ("bal this month" "%(binary) --strict -f %(ledger-file) bal -p %(month) -S amount") ("bal this year" "%(binary) --strict -f %(ledger-file) bal -p 'this year'") ("net worth" "%(binary) --strict -f %(ledger-file) bal Assets Liabilities") - ("account" "%(binary) --strict -f %(ledger-file) reg %(account)")))) + ("account" "%(binary) --strict -f %(ledger-file) reg %(account)"))) + :config + (cj/executable-find-or-warn "ledger" 'ledger-mode)) ;; ------------------------------ Flycheck Ledger ------------------------------ -;; syntax and unbalanced transaction linting +;; syntax and unbalanced-transaction linting (use-package flycheck-ledger :after ledger-mode) ;; ------------------------------- Company Ledger ------------------------------ -;; autocompletion for ledger +;; account/payee autocompletion for ledger (use-package company-ledger :after (company ledger-mode) diff --git a/modules/prog-general.el b/modules/prog-general.el index 99b3cbfab..8e317413c 100644 --- a/modules/prog-general.el +++ b/modules/prog-general.el @@ -290,6 +290,16 @@ seeded by `cj/deadgrep--initial-term'. Shared tail of the deadgrep commands." (with-eval-after-load 'dired (keymap-set dired-mode-map "G" #'cj/deadgrep-here)) +;; ------------------------------------ wgrep ---------------------------------- +;; Make a grep buffer editable, then write the edits back across files -- turns +;; a consult-grep / embark-export result into a project-wide find-and-replace. +;; In a grep buffer: C-c C-p to start editing, C-c C-c to apply. + +(use-package wgrep + :custom + (wgrep-auto-save-buffer t) ;; save the touched files when applying + (wgrep-change-readonly-file t)) ;; let edits flow into read-only buffers + ;; ---------------------------------- Snippets --------------------------------- ;; reusable code and text diff --git a/modules/ui-navigation.el b/modules/ui-navigation.el index c099e0834..cb0fc5697 100644 --- a/modules/ui-navigation.el +++ b/modules/ui-navigation.el @@ -283,5 +283,15 @@ With numeric prefix ARG, re-open the ARGth most-recently-killed file :config (winner-mode 1)) +;; ------------------------------- Cursor Jump (avy) --------------------------- +;; Jump anywhere visible by typing a few of the target's characters, then the +;; decision-tree key avy overlays. Fills the in-buffer motion gap that windmove +;; (windows) and isearch (text) leave. + +(use-package avy + :bind (("C-:" . avy-goto-char-timer) ;; type chars, pause, jump to a match + ("M-g w" . avy-goto-word-1) + ("M-g l" . avy-goto-line))) + (provide 'ui-navigation) ;;; ui-navigation.el ends here diff --git a/modules/user-constants.el b/modules/user-constants.el index b392212ed..570b142fb 100644 --- a/modules/user-constants.el +++ b/modules/user-constants.el @@ -167,6 +167,12 @@ Proton Calendar.") Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.") +(defvar keep-file (expand-file-name "data/keep.org" user-emacs-directory) + "The location of the generated org file containing Google Keep notes. +A read-only view regenerated by `cj/keep-refresh'; edits here do not +sync back to Keep. Stored in .emacs.d/data/ so each machine syncs +independently.") + (defvar reference-file (expand-file-name "reference.org" org-dir) "The location of the org file containing reference information.") |
