aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/calendar-sync.el10
-rw-r--r--modules/google-keep-config.el210
-rw-r--r--modules/prog-general.el10
-rw-r--r--modules/ui-navigation.el10
-rw-r--r--modules/user-constants.el6
5 files changed, 245 insertions, 1 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/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/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.")