~cytrogen/.emacs.d

df10ed13470f3f18a090490bd2680abe65f24ba5 — Cytrogen a month ago 1c4c31f
feat(calendar): 添加 Google Calendar/Tasks 双向同步

- org-gcal 双向同步 Google Calendar 事件到 calendar.org
- calfw + calfw-org 可视化日历界面
- org-gtasks 可选集成(从 site-lisp/ 加载)
- OAuth2 凭据从外部文件 calendar-secrets.el 加载(已 gitignore)
- 自动同步:打开 agenda 时防抖同步(间隔 300 秒)
- 添加日历 Capture 模板(advice 注入 org-structure 之后)
- 自定义 Agenda 视图:今日/本周/本月日历
- 交互命令:my/calendar-open, my/calendar-sync, my/calendar-push
1 files changed, 208 insertions(+), 0 deletions(-)

A config/pkg-calendar.el
A config/pkg-calendar.el => config/pkg-calendar.el +208 -0
@@ 0,0 1,208 @@
;;; pkg-calendar.el --- Google Calendar & Tasks integration -*- lexical-binding: t -*-

;; Copyright (C) 2026 Cytrogen

;;; Commentary:

;; Google Calendar and Google Tasks integration for Emacs.
;; - org-gcal: bidirectional Google Calendar sync
;; - calfw + calfw-org: visual calendar views
;; - org-gtasks: Google Tasks sync (optional, from site-lisp/)
;;
;; Credentials are stored in ~/.emacs.d/calendar-secrets.el (gitignored)

;;; Code:

;;; ============================================================
;;; Credentials
;;; ============================================================

(defvar my/calendar-client-id nil)
(defvar my/calendar-client-secret nil)
(defvar my/calendar-email nil)
(defvar my/calendar-secrets-file
  (expand-file-name "calendar-secrets.el" user-emacs-directory))

(defun my/calendar--load-secrets ()
  "Load calendar OAuth credentials from external file."
  (when (file-exists-p my/calendar-secrets-file)
    (load-file my/calendar-secrets-file)))

(my/calendar--load-secrets)

;;; ============================================================
;;; Calendar org file
;;; ============================================================

(defvar my/calendar-org-file
  (expand-file-name "calendar.org" org-directory))

;;; ============================================================
;;; org-gcal - Google Calendar sync
;;; ============================================================

(unless (package-installed-p 'org-gcal)
  (package-refresh-contents)
  (package-install 'org-gcal))

;; Set credentials before require to avoid org-gcal's load-time warning
(when (and my/calendar-client-id my/calendar-client-secret my/calendar-email)
  (setq org-gcal-client-id my/calendar-client-id
        org-gcal-client-secret my/calendar-client-secret
        org-gcal-fetch-file-alist `((,my/calendar-email . ,my/calendar-org-file))))

;; Encrypt OAuth token store with user's GPG key
(when my/calendar-email
  (setq plstore-encrypt-to (list my/calendar-email)))

;; Use absolute path for oauth2-auto plstore
(setq oauth2-auto-plstore
      (expand-file-name "oauth2-auto.plist" user-emacs-directory))

;; Workaround: oauth2-auto bug where file-equal-p fails on new files,
;; causing false "BUG: Attempted to write" errors.
;; See https://github.com/rhaps0dy/emacs-oauth2-auto/issues/6
(with-eval-after-load 'oauth2-auto
  (defun oauth2-auto--insert-break-on-secret-entries (&rest _args)
    "Disabled: workaround for oauth2-auto issue #6."))

;; Use system pinentry for GPG decryption (loopback fails in async callbacks)
(setq epg-pinentry-mode nil)

(require 'org-gcal)

;;; ============================================================
;;; calfw - Visual calendar
;;; ============================================================

(unless (package-installed-p 'calfw)
  (package-refresh-contents)
  (package-install 'calfw))
(unless (package-installed-p 'calfw-org)
  (package-install 'calfw-org))
(require 'calfw)
(require 'calfw-org)

;;; ============================================================
;;; org-gtasks - Google Tasks (optional)
;;; ============================================================

(let ((gtasks-path (expand-file-name "site-lisp/org-gtasks" user-emacs-directory)))
  (when (file-directory-p gtasks-path)
    (add-to-list 'load-path gtasks-path)
    (let ((gtasks-el (expand-file-name "org-gtasks.el" gtasks-path)))
      (when (file-exists-p gtasks-el)
        (load-file gtasks-el)))
    (when (and (fboundp 'org-gtasks-register-account) my/calendar-client-id my/calendar-client-secret)
      (let ((gtasks-dir (expand-file-name "gtasks/" org-directory)))
        (unless (file-directory-p gtasks-dir)
          (make-directory gtasks-dir t))
        (org-gtasks-register-account
         :name "Google"
         :directory gtasks-dir
         :client-id my/calendar-client-id
         :client-secret my/calendar-client-secret)))))

;;; ============================================================
;;; Auto-sync (debounced)
;;; ============================================================

(defvar my/calendar--last-sync-time 0)
(defvar my/calendar-sync-interval 300
  "Minimum seconds between automatic syncs.")

(defun my/calendar--maybe-sync ()
  "Auto-sync Google Calendar if enough time has elapsed."
  (when (and my/calendar-client-id my/calendar-client-secret
             (> (- (float-time) my/calendar--last-sync-time)
                my/calendar-sync-interval))
    (setq my/calendar--last-sync-time (float-time))
    (ignore-errors (org-gcal-fetch))))

(add-hook 'org-agenda-mode-hook #'my/calendar--maybe-sync)

;;; ============================================================
;;; Capture template integration
;;; ============================================================

(defun my/calendar--add-capture-templates (&rest _)
  "Append calendar capture templates after org-structure templates."
  (add-to-list 'org-capture-templates '("G" "Calendar (日历)") t)
  (add-to-list 'org-capture-templates
    `("Ge" "日历事件" entry (file ,my/calendar-org-file)
      ,(concat "* %^{事件}\n"
               "  :PROPERTIES:\n"
               "  :calendar-id: " (or my/calendar-email "") "\n"
               "  :END:\n"
               "  %^{开始时间}T--%^{结束时间}T\n  %?")
      :empty-lines 1) t))

(advice-add 'my/apply-org-structure :after #'my/calendar--add-capture-templates)

;;; ============================================================
;;; Agenda custom views
;;; ============================================================

(with-eval-after-load 'org-agenda
  (setq org-agenda-custom-commands
        (append org-agenda-custom-commands
                '(("c" . "Calendar (日历)")
                  ("cw" "本周日历" agenda ""
                   ((org-agenda-span 'week)
                    (org-agenda-files (list my/calendar-org-file))
                    (org-agenda-overriding-header "本周日历事件")))
                  ("cm" "本月日历" agenda ""
                   ((org-agenda-span 'month)
                    (org-agenda-files (list my/calendar-org-file))
                    (org-agenda-overriding-header "本月日历事件")))
                  ("cd" "今日总览" agenda ""
                   ((org-agenda-span 'day)
                    (org-agenda-overriding-header "今日安排")))))))

;;; ============================================================
;;; Interactive Commands
;;; ============================================================

(defun my/calendar-open ()
  "Sync Google Calendar and open calfw visual calendar."
  (interactive)
  (when (and my/calendar-client-id my/calendar-client-secret)
    (ignore-errors (org-gcal-fetch)))
  (calfw-org-open-calendar nil "Google Calendar" "medium purple"))

(defun my/calendar-sync ()
  "Force sync Google Calendar events."
  (interactive)
  (if (and my/calendar-client-id my/calendar-client-secret)
      (progn
        (setq my/calendar--last-sync-time (float-time))
        (org-gcal-fetch)
        (message "Google Calendar 同步中..."))
    (message "日历凭据未配置,请编辑 calendar-secrets.el")))

(defun my/calendar-push ()
  "Push current org entry to Google Calendar."
  (interactive)
  (if (and my/calendar-client-id my/calendar-client-secret)
      (org-gcal-post-at-point)
    (message "日历凭据未配置,请编辑 calendar-secrets.el")))

(defun my/gtasks-sync ()
  "Sync Google Tasks."
  (interactive)
  (if (fboundp 'org-gtasks-push-fetch)
      (org-gtasks-push-fetch "Google")
    (message "org-gtasks 未安装。请 git clone 到 site-lisp/org-gtasks/")))

(defun my/calendar-status ()
  "Show current calendar integration status."
  (interactive)
  (message (concat "Calendar: "
                   (if my/calendar-client-id "OAuth已配置" "OAuth未配置")
                   " | Email: " (or my/calendar-email "未设置")
                   " | org-gcal: " (if (featurep 'org-gcal) "已加载" "未加载")
                   " | calfw: " (if (featurep 'calfw) "已加载" "未加载")
                   " | org-gtasks: " (if (fboundp 'org-gtasks-register-account) "已加载" "未安装"))))

(provide 'pkg-calendar)
;;; pkg-calendar.el ends here