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