跳到主要内容

用Emacs管理日记

· 阅读需 11 分钟

这篇文章介绍了我使用的一种管理日记的方案。

此前曾经使用的日记管理方案

坦率地说,我有写日记的习惯。尽管写日记不是值得声张的正经习惯,但是由于写日记有很多好处,我不得不这么做。人难以记住生活中很多微末之事,如果强行记住它们则占据很多心力。这种记忆体现在决策层面,面对生活中许多各有利弊的选择,如果不能充分考虑为什么做或者不做的理由,那么往往踌躇不定,“尽管去做”的豪情荡然无存。

在此之前我通过多种方式来管理日记。最初我觉得日记只要是一组纯文本就可以了。这样做的后果是我从来没有撰写过它。因为每次输入今日的日期是很重的负担,它将随手一记的举动变成了如同启动一个项目一样正式。随着我接触到Obsidian,我开始用它来管理日记。但同时Obsidian上有我的很多笔记,以及整理的不成体系的碎片知识(大都是简要的索引和纸质记录的补充和连结)。很快我就对Obsidian中的这些日志失望,它们不属于知识库,占用索引,同时可能会无意中在公共场合被打开,让人觉得尴尬。

于是我后来尝试了eDiary。尝试它的理由是我不需要用基于Web技术的沉重组件来管理日记。它看起来像是Win32或者什么别的原生的东西。这样的东西看起来是可靠的。后来我发现它性能很差,偶然地尝试使用Wine在Linux上运行它发现有些地方并不兼容。除此之外,它将日记和附件打包在同一个文件中存储,这让人怀疑它的性能是否良好。这样做未必不好,但实际上将日记分散成文件保存在文件系统上并无不可,至少后者的可靠性是经过检验的。由于我对它所知甚少,也不清楚它的加密是否可靠。它支持很不错的导出功能。但是我还是觉得这样的工具显得过重,不便于迁移、归档保存、事后回顾。

于是我借助AI编写了之前所说的AnyNews。它很简单,加密功能让它稍显复杂。我毫不怀疑编写它在我(尽管身为文科生)自身的能力范围以内。但是由于我预期中只有我一个用户,因此我宁可用几个小时让AI代劳。但是它存在安全问题,显而易见。因为它通过外部编辑器修改日记,导致明文的日记在临时目录中。并且最初的版本存在Bug,在Windows平台调用加密工具时传递了程序自身的句柄,导致继承句柄的加密工具(这里是GPG代理)持有临时文件,以至于该临时文件无法被程序正常删除。

尽管关于句柄的Bug已经修复,但明文隐患是不可忽视的。究其原因,恐怕在于大多数编辑器尽管可以从标准输入中读入内容,但无法在退出时将缓冲区直接写入到标准输出中。这个问题难以通过优化日记管理器本身的设计来解决。

因此我打算将这个工作流迁移到编辑器上。考虑到Vim的模态编辑和中文文字撰写格格不入,Visual Studio Code的扩展结构完整而正式,我觉得迁移到Emacs上是方便可行的。因此我配置了Emacs并且将日记这个工作流迁移到Emacs上。

配置Emacs

Emacs的配置文件对于非Emacs用户而言显然很晦涩。尽管如此,由于介绍Emacs的文章应有尽有,这里只贴上我的配置文件。如果试着仿效、复刻、理解这些配置乃至理解我管理日记的思路和逻辑,AI往往可以提供丰俭由人的帮助。只需要将本文贴到AI对话框里面就好了。

使用的配置文件如下:

~/.emacs.d/init.el
(set-face-attribute 'default nil :font "Sarasa Mono SC Nerd Font-12") ;设置更纱黑体Nerd变体。该字体包含中英文的等宽字型。
(set-fontset-font t 'emoji (font-spec :family "Segoe UI Emoji")) ;表情符号支持。

(pixel-scroll-precision-mode t) ;让图片滚动时不再被视为整行。

(global-display-line-numbers-mode t) ;行号
(defun open-init-file()
(interactive)
(find-file "~/.emacs.d/init.el")) ;打开配置文件的快捷方式。
(setq default-frame-alist '((height . 26)));设置启动时的行高为26

;; EasyPG
(require 'epa-file)
(epa-file-enable)
(setq epa-armor t)

;; 包管理器
(require 'package)
(setq package-archives '(("gnu" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/gnu/")
("nongnu" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/nongnu/")
("melpa" . "https://mirrors.tuna.tsinghua.edu.cn/elpa/melpa/")))

;; 主题
(load-theme 'dracula t)


;; 插件
(add-to-list 'load-path "~/.emacs.d/anynews")
(require 'anynews)
(set 'anynews-root "~/Documents/Diary/")
(set 'anynews-ext ".md.gpg")
(set 'anynews-template-command "sed \"s/^/# /\"")
(set 'anynews-date-limit 3650)
(global-set-key (kbd "C-x C-a") 'anynews)

;; 图标
(use-package all-the-icons
:if (display-graphic-p))
(add-hook 'dired-mode-hook 'all-the-icons-dired-mode)

;; 侧栏
(use-package dired-sidebar
:bind (("C-x C-n" . dired-sidebar-toggle-sidebar))
:ensure t
:commands (dired-sidebar-toggle-sidebar)
:init
(add-hook 'dired-sidebar-mode-hook
(lambda ()
(unless (file-remote-p default-directory)
(auto-revert-mode))))
:config
(push 'toggle-window-split dired-sidebar-toggle-hidden-commands)
(push 'rotate-windows dired-sidebar-toggle-hidden-commands)
(setq dired-sidebar-theme 'icons) ; all-the-icons图标支持
(setq dired-sidebar-subtree-line-prefix "__")
(setq dired-sidebar-use-term-integration t))

;; 备份文件集中
(setq backup-directory-alist
`(("." . "~/.emacs.d/backups")))
(setq version-control t)
(setq kept-new-versions 5)
(setq kept-old-versions 2)
(setq delete-old-versions t)
(setq backup-by-copying t)
(setq auto-save-file-name-transforms
`((".*" ,(expand-file-name "~/.emacs.d/autosaves/") t)))
~/.emacs.d/anynews/anynews.el
;;; anynews.el --- A simple tool for diary management
;;; -*- lexical-binding: t; -*-
;;; Written by Claude. Modified by li-zhenyu.
;;;
;;; The MIT License (MIT)
;;; Copyright (c) 2026 li-zhenyu
;;;
;;; 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.


;; Configure
(defgroup anynews nil
"Anynews: A simple tool for diary management."
:group 'files)

(defcustom anynews-root "~/diary"
"Path to your diary."
:type 'directory)

(defcustom anynews-ext ".md.gpg"
"The extension of your diary."
:type 'string)

(defcustom anynews-template-command "sed \"s/^/# /\""
"Command from STDIN to STDOUT as template while creating a file."
:type 'string)

(defcustom anynews-date-limit 3650
"Maxium relative date."
:type 'integer)

(defun anynews-parse-date (input)
"Parse the date. Return (year month day) or an error."
(cond
((or (null input) (string= input ""))
(let ((now (decode-time)))
(list (nth 5 now) (nth 4 now) (nth 3 now))))

((string-match-p "^@[0-6]$" input)
(let* ((weekday (string-to-number (substring input 1)))
(now (decode-time))
(today-wday (nth 6 now))
(diff (mod (- today-wday weekday) 7)))
(anynews-relative-date diff)))

((string-match-p "^[+-]?[0-9]+$" input)
(let ((days (string-to-number input)))
(when (> (abs days) anynews-date-limit)
(error "The relative date reaches the maxium: %d (Maxium: %d)" days anynews-date-limit))
(anynews-relative-date days)))

((string-match-p "^[0-9]\\{4\\}-[0-9]\\{1,2\\}-[0-9]\\{1,2\\}$" input)
(let ((parts (split-string input "-")))
(list (string-to-number (nth 0 parts))
(string-to-number (nth 1 parts))
(string-to-number (nth 2 parts)))))

(t (error "Invalid date: %s" input))))

(defun anynews-relative-date (days)
"Return a relative date. positive refers to the past date."
(let* ((target (time-subtract (current-time)
(days-to-time days)))
(decoded (decode-time target)))
(list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded))))

(defun anynews-date-to-path (year month day)
(concat anynews-root
(format "%04d/%02d/%02d" year month day)
anynews-ext))

(defun anynews-generate-template (date-obj)
"Generate template content by executing shell command with date via STDIN."
(let* ((year (nth 0 date-obj))
(month (nth 1 date-obj))
(day (nth 2 date-obj))
(date-iso (format "%04d-%02d-%02d" year month day))
(shell (if (eq system-type 'windows-nt) "cmd" "sh"))
(shell-arg (if (eq system-type 'windows-nt) "/c" "-c"))
(full-cmd (format "%s %s %s" shell shell-arg anynews-template-command))
(debug-buf (get-buffer-create "*anynews-debug*")))
(with-temp-buffer
(insert date-iso)
(let ((exit-code (shell-command-on-region
(point-min) (point-max)
anynews-template-command
(current-buffer) t
"*anynews-error*" t)))
(if (zerop exit-code)
(buffer-string)
;; 写入 debug buffer
(with-current-buffer debug-buf
(erase-buffer)
(insert
(format "
Command: %s\n\
Input: %s\n\
Exit Code: %d\n\
STDERR:\n%s\n\
"
full-cmd
date-iso
exit-code
(with-current-buffer "*anynews-error*" (buffer-string))
date-iso
anynews-template-command)))
(pop-to-buffer debug-buf)
(error "Template command failed! Check *anynews-debug* buffer"))))))

(defun anynews (date-input)
"Open a diary."
;; 日期解析规则:
;; 留空代表当天
;; 1、+1、-1代表相对日期,正数代表过去。
;; 2026-1-1代表绝对日期。
;; @n代表星期。从@0周日到@6周六。
(interactive "sDate (\"\", +1, -1, 2026-1-1, @0-6): ")
(let* ((date-obj (anynews-parse-date date-input))
(year (nth 0 date-obj))
(month (nth 1 date-obj))
(day (nth 2 date-obj))
(path (anynews-date-to-path year month day))
(dir (file-name-directory path))
(buf (get-file-buffer path)))
(unless (file-exists-p dir)
(make-directory dir t))
(cond
(buf
(switch-to-buffer buf)
(message "Switched to existing buffer: %s" (buffer-name buf)))
((file-exists-p path)
(find-file path))
(t
(let ((new-buf (find-file-noselect path)))
(with-current-buffer new-buf
(insert (anynews-generate-template date-obj)))
(switch-to-buffer new-buf)
(message "New diary created: %s (save to write to disk)" path))))))
(provide 'anynews)
;;; anynews.el ends here

问题和解答

为什么不用org-journal?

我不喜欢Org。它绑定Emacs。我对Emacs持观望态度。Org支持日程管理、时间管理、文档写作、博客撰写、排版导出、文学编程、数值计算……是个无底洞。我不确定是否可以搞定它。反观Markdown,如果只是使用核心部分的语法的话,几乎可以导出为任何格式,并且使用大部分工具都可以做到。

除此之外,org-journal好像使用平铺的文件排列方式(未经考据)。这不太符合个人审美。

还有,我很喜欢之前设计的AnyNews,包括它打开文件的方式,和那个“程序就是模板”的奇妙小想法。

为什么换用Emacs?

安全和方便。比如可以在侧栏中查看已经写好的日记,然后打开它们。

新版日记管理器和旧版的区别?

新版基于Emacs,旧版是C语言编写的。后者是个单独的小程序。新版放弃了追加功能和自带的加密(因为Emacs的EasyPG已经足够)。这样同时还带来了管理明文日记的能力。放弃追加功能一方面是迁就Emacs,避免重复劳动,另一方面也是考虑到如果使用GPG的话,那么gpg-agent已经让解密很轻,不再频繁了。

新版还加入了检索一周的相对日期(从周一到周日)的能力。

为什么喜欢“程序就是模板”的小巧思?

我实际使用的模板,效果是这样的:

# 2026年7月28日

## 概况
- 农历丙午年六月十五
- 周二
- 某地天气:Patchy rain nearby +26°C
- 某地天气:Sunny +33°C

如果不是当日生成,就是这样:

# 2026年7月30日

## 概况

- 农历丙午年六月十七
- 周四
- 补记

这是因为wttr.in只能查询当日的天气。