~cytrogen/.emacs.d

a4473bffc429eee3dc06eb71c1996ba575380766 — Cytrogen a month ago 40b26f9
feat(blog): 添加完整博客工作流:月刊、多格式导出、Webmention

月刊系统: 中国传统历法命名(天干地支 + 月份),自动创建/打开
当月文件,inbox 条目批量转入月刊

Hexo 导出: Org → Markdown 转换,自动生成目录,支持 Hexo 块标签
({% note %}、{% em %}),图片插入与优化

Gemini 导出: Org → gemtext 转换,ASCII art 格式化,链接重排,
Capsule 首页索引生成,Mastodon URL 集成

Webmention 集成: require webmention.el(显式路径加载),
发布后通知被引用站点

内容处理: 标题 slugify、引用来源提取、结构解析、
自定义链接类型(em 着重号、ruby 注音)
1 files changed, 1099 insertions(+), 166 deletions(-)

M config/pkg-blog.el
M config/pkg-blog.el => config/pkg-blog.el +1099 -166
@@ 1,6 1,6 @@
;;; pkg-blog.el --- Blog workflow for monthly posts -*- lexical-binding: t -*-

;; Copyright (C) 2024 Cytrogen
;; Copyright (C) 2026 Cytrogen

;; This file contains:
;; - Monthly blog post creation and management


@@ 38,6 38,16 @@
  :type 'directory
  :group 'my/blog)

(defcustom my/blog-gemini-dir "~/Projects/gemini/"
  "Directory for Gemini capsule files (.gmi)."
  :type 'directory
  :group 'my/blog)

(defcustom my/blog-mastodon-url "https://m.otter.homes/@Cytrogen"
  "Mastodon profile URL for Gemini diary section."
  :type 'string
  :group 'my/blog)

;;; Dynamic Directory Setup

(defvar my/blog-dirs-config-file (concat user-emacs-directory ".blog-dirs")


@@ 47,8 57,9 @@
  "Load Blog directories from config file or prompt user."
  (let ((source-dir nil)
        (export-dir nil)
        (gemini-dir nil)
        (config-changed nil))
    

    ;; 1. Try to read from file
    (when (file-exists-p my/blog-dirs-config-file)
      (with-temp-buffer


@@ 62,32 73,42 @@
               ((string-match "^SOURCE=\\(.*\\)" line)
                (setq source-dir (string-trim (match-string 1 line))))
               ((string-match "^EXPORT=\\(.*\\)" line)
                (setq export-dir (string-trim (match-string 1 line))))))
                (setq export-dir (string-trim (match-string 1 line))))
               ((string-match "^GEMINI=\\(.*\\)" line)
                (setq gemini-dir (string-trim (match-string 1 line))))))
            (forward-line 1)))))
    

    ;; 2. Validate Source Dir
    (unless (and source-dir (file-directory-p source-dir))
      (setq source-dir (read-directory-name "请选择博客 Org 源码目录 (Select Blog Source Dir): " (bound-and-true-p org-directory)))
      (unless (file-directory-p source-dir)
        (make-directory source-dir t))
      (setq config-changed t))
      

    ;; 3. Validate Export Dir
    (unless (and export-dir (file-directory-p export-dir))
      (setq export-dir (read-directory-name "请选择博客发布目录 (Select Blog Export Dir, e.g. source/_posts): " "D:/"))
      (unless (file-directory-p export-dir)
        (make-directory export-dir t))
      (setq config-changed t))
      
    ;; 4. Save if changed

    ;; 4. Validate Gemini Dir
    (unless (and gemini-dir (file-directory-p gemini-dir))
      (setq gemini-dir (read-directory-name "请选择 Gemini capsule 目录 (Select Gemini Dir): " "~/Projects/"))
      (unless (file-directory-p gemini-dir)
        (make-directory gemini-dir t))
      (setq config-changed t))

    ;; 5. Save if changed
    (when config-changed
      (with-temp-file my/blog-dirs-config-file
        (insert (format "SOURCE=%s\nEXPORT=%s\n" source-dir export-dir))))
    
    ;; 5. Apply settings
        (insert (format "SOURCE=%s\nEXPORT=%s\nGEMINI=%s\n" source-dir export-dir gemini-dir))))

    ;; 6. Apply settings
    (setq my/blog-org-dir (file-name-as-directory source-dir))
    (setq my/blog-export-dir (file-name-as-directory export-dir))
    (message "Blog directories loaded.\nSource: %s\nExport: %s" my/blog-org-dir my/blog-export-dir)))
    (setq my/blog-gemini-dir (file-name-as-directory gemini-dir))
    (message "Blog directories loaded.\nSource: %s\nExport: %s\nGemini: %s" my/blog-org-dir my/blog-export-dir my/blog-gemini-dir)))

;; Execute setup immediately
(my/setup-blog-directories)


@@ 95,8 116,15 @@
(defcustom my/blog-monthly-sections
  '(("商业与社会" . "商业与社会")
    ("心理与关系" . "心理与关系")
    ("科学与自然" . "科学与自然")
    ("技术与创造" . "技术与创造")
    ("技术与创造/有趣项目" . "有趣项目")
    ("折腾博客" . "折腾博客")
    ("编程历程" . "编程历程")
    ("书籍" . "书籍")
    ("影视" . "影视")
    ("音乐" . "音乐")
    ("日记片段" . "日记片段")
    ("生活与文娱" . "生活与文娱"))
  "Sections available in monthly posts. Format: ((display . headline) ...)."
  :type '(alist :key-type string :value-type string)


@@ 167,10 195,32 @@
  "Convert TEXT to URL-friendly slug."
  (let ((slug text))
    (setq slug (replace-regexp-in-string "[《》「」『』【】]" "" slug))
    (setq slug (replace-regexp-in-string "[[:punct:]]" "" slug))
    (setq slug (replace-regexp-in-string "[[:punct:]]" " " slug))
    (setq slug (replace-regexp-in-string "[[:space:]]+" "-" slug))
    (setq slug (replace-regexp-in-string "\\`-\\|-\\'" "" slug))
    (downcase slug)))

(defun my/blog--clean-title-for-md (title)
  "Convert org inline markup in TITLE for markdown display."
  (let ((result title))
    ;; Single pass: =...= and ~...~ → backtick (first marker wins)
    (while (string-match "\\(?:=\\([^ =\n]\\(?:[^=\n]*[^ =\n]\\)?\\)=\\)\\|\\(?:~\\([^ ~\n]\\(?:[^~\n]*[^ ~\n]\\)?\\)~\\)" result)
      (let ((text (or (match-string 1 result) (match-string 2 result))))
        (setq result (replace-match (format "`%s`" text) t t result))))
    result))

(defun my/blog--clean-title-for-gmi (title)
  "Strip org inline markup from TITLE for gemtext display."
  (let ((result title))
    ;; Single pass: =...= and ~...~ → plain text (first marker wins)
    (while (string-match "\\(?:=\\([^ =\n]\\(?:[^=\n]*[^ =\n]\\)?\\)=\\)\\|\\(?:~\\([^ ~\n]\\(?:[^~\n]*[^ ~\n]\\)?\\)~\\)" result)
      (let ((text (or (match-string 1 result) (match-string 2 result))))
        (setq result (replace-match text t t result))))
    (setq result (replace-regexp-in-string "\\*\\([^ *\n]\\(?:[^*\n]*[^ *\n]\\)?\\)\\*" "\\1" result))
    (setq result (replace-regexp-in-string "/\\([^ /\n]\\(?:[^/\n]*[^ /\n]\\)?\\)/" "\\1" result))
    (setq result (replace-regexp-in-string "_{\\([^}\n]+\\)}_" "\\1" result))
    result))

(defun my/blog--collect-monthly-posts (year month)
  "Scan _posts directory and return posts from YEAR-MONTH.
Returns list of (title . url) pairs."


@@ 324,21 374,21 @@ Prompts for month name and year."
;;; Refile to Monthly

(defun my/blog-refile-to-monthly ()
  "Refile current org entry to a section in the current monthly."
  "Refile current org entry to a section in a monthly file.
Prompts for target monthly file and section."
  (interactive)
  (unless (org-at-heading-p)
    (org-back-to-heading t))

  (let* ((monthly-file (my/blog--current-monthly-file))
  (let* ((monthly-dir (my/blog--monthly-dir-full))
         (files (and (file-directory-p monthly-dir)
                     (directory-files monthly-dir nil "^[0-9]\\{4\\}-[0-9]\\{2\\}\\.org$")))
         (choice (completing-read "Monthly file: " (reverse files) nil t))
         (monthly-file (expand-file-name choice monthly-dir))
         (sections (mapcar #'car my/blog-monthly-sections))
         (target-selection (completing-read "Refile to section: " sections nil t))
         ;; Get the actual headline from the alist (handles paths like "技术与创造/有趣项目")
         (target-headline (cdr (assoc target-selection my/blog-monthly-sections))))

    ;; Ensure monthly file exists
    (unless (file-exists-p monthly-file)
      (my/blog-create-monthly))

    ;; Special handling for "有趣项目" - convert to list item
    (if (string= target-headline "有趣项目")
        (my/blog--refile-as-list-item monthly-file target-headline)


@@ 346,7 396,8 @@ Prompts for month name and year."
      (let* ((entry-title (org-get-heading t t t t))
             (custom-id (my/blog--slugify entry-title)))
        (org-set-property "CUSTOM_ID" custom-id)
        (let ((org-refile-targets `((,monthly-file :maxlevel . 3))))
        (let ((org-refile-targets `((,monthly-file :maxlevel . 3)))
              (org-reverse-note-order t))
          (org-refile nil nil
                      (list target-headline monthly-file nil
                            (with-current-buffer (find-file-noselect monthly-file)


@@ 372,6 423,7 @@ Prompts for month name and year."
                        (format "- %s: %s" title content)))))
    ;; Delete original entry
    (org-cut-subtree)
    (save-buffer)
    ;; Insert as list item in target
    (with-current-buffer (find-file-noselect file)
      (goto-char (point-min))


@@ 402,6 454,34 @@ Prompts for month name and year."
    (when (re-search-forward (format "^#\\+%s:[ \t]*\\(.*\\)$" prop) nil t)
      (string-trim (match-string-no-properties 1)))))

(defun my/blog--extract-source-from-content (content)
  "Extract source URL and date from CONTENT end.
Returns plist (:source URL :date DATE) or nil if not found."
  (let ((source nil)
        (date nil))
    ;; Match source: URL at end of content
    (when (string-match "source:\\s-*\\(https?://[^\n]+\\)\\s-*$" content)
      (setq source (string-trim (match-string 1 content))))
    ;; Match org timestamp [YYYY-MM-DD ...]
    (when (string-match "\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^]]*\\)\\]\\s-*$" content)
      (setq date (match-string 1 content)))
    (when (or source date)
      (list :source source :date date))))

(defun my/blog--strip-source-from-content (content)
  "Remove source line and date line from CONTENT end.
Returns cleaned content string."
  (let ((result (string-trim-right content)))
    ;; Remove org timestamp line at end (e.g. [2026-01-25 Sun 01:13])
    (setq result (replace-regexp-in-string
                  "[\n\r]*\\[?[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^]\n]*\\]?[ \t]*\\'"
                  "" result))
    ;; Remove source: URL line at end
    (setq result (replace-regexp-in-string
                  "[\n\r]*source:[ \t]*https?://[^\n]+[ \t]*\\'"
                  "" result))
    (string-trim result)))

(defun my/blog--collect-structure (buffer)
  "Collect document structure from org BUFFER.
Returns list of (level title properties content subsections)."


@@ 434,8 514,14 @@ Returns list of (level title properties content subsections)."
                 (begin (org-element-property :contents-begin hl))
                 (end (org-element-property :contents-end hl))
                 (props (org-entry-properties (org-element-property :begin hl)))
                 (source (cdr (assoc "SOURCE" props)))
                 (custom-id (cdr (assoc "CUSTOM_ID" props)))
                 (source (let ((s (or (cdr (assoc "SOURCE" props))
                                      (cdr (assoc "URL" props)))))
                           (when (and s (not (string-empty-p s))) s)))
                 (custom-id (let ((id (cdr (assoc "CUSTOM_ID" props))))
                              (when id
                                (let ((cleaned (string-trim
                                                (replace-regexp-in-string "[[:cntrl:]]" "" id))))
                                  (unless (string-empty-p cleaned) cleaned)))))
                 (content ""))
            ;; Get content (only direct content, not sub-headlines)
            ;; Skip PROPERTIES drawer


@@ 452,10 538,18 @@ Returns list of (level title properties content subsections)."
                                      end)))
                  (setq content (string-trim
                                 (buffer-substring-no-properties content-start content-end))))))
            ;; For Level 2 headings: extract source from content if no SOURCE property
            (when (and (= level 2) (not source) (not (string-empty-p content)))
              (let ((extracted (my/blog--extract-source-from-content content)))
                (when extracted
                  (setq source (plist-get extracted :source))
                  (setq content (my/blog--strip-source-from-content content)))))
            (push (list :level level
                        :title title
                        :source source
                        :custom-id (or custom-id (my/blog--slugify title))
                        :custom-id (if (and custom-id (not (string-empty-p custom-id)))
                                       custom-id
                                     (my/blog--slugify title))
                        :content content)
                  result))))
      (list :intro intro-text :sections (reverse result)))))


@@ 475,11 569,14 @@ Returns list of (level title properties content subsections)."
      (let ((title (plist-get sec :title))
            (level (plist-get sec :level))
            (slug (plist-get sec :custom-id)))
        (when (and (= level 1) (member title input-sections))
          (setq current-section title)
          (setq toc (concat toc (format "#### %s\n\n" title))))
        (when (= level 1)
          (if (member title input-sections)
              (progn
                (setq current-section title)
                (setq toc (concat toc (format "\n#### %s\n\n" title))))
            (setq current-section nil)))
        (when (and (= level 2) current-section (member current-section input-sections))
          (setq toc (concat toc (format "- [%s](#%s)\n" title slug))))))
          (setq toc (concat toc (format "- [%s](#%s)\n" (my/blog--clean-title-for-md title) slug))))))
    ;; Build 输出 section
    (setq toc (concat toc "\n## 输出\n\n"))
    ;; Main output sections (skip empty ones except 我写的文章 which has auto-fill)


@@ 488,11 585,20 @@ Returns list of (level title properties content subsections)."
        (let ((title (plist-get sec :title))
              (level (plist-get sec :level))
              (slug (plist-get sec :custom-id))
              (content (plist-get sec :content)))
              (content (plist-get sec :content))
              (has-subsections nil))
          (when (and (= level 1) (member title output-main-sections))
            ;; Skip if it's skippable and empty
            ;; Check if this section has subsections with content
            (let ((sec-idx (cl-position sec sections)))
              (cl-loop for i from (1+ sec-idx) below (length sections)
                       for next-sec = (nth i sections)
                       while (> (plist-get next-sec :level) 1)
                       when (not (string-empty-p (or (plist-get next-sec :content) "")))
                       do (setq has-subsections t)))
            ;; Skip if it's skippable, empty, and has no subsections
            (unless (and (member title skippable)
                         (or (null content) (string-empty-p content)))
                         (or (null content) (string-empty-p content))
                         (not has-subsections))
              (setq toc (concat toc (format "- [%s](#%s)\n" title slug))))))))
    ;; 书籍/影视/音乐 special format
    (dolist (sec sections)


@@ 538,6 644,17 @@ Returns list of (level title properties content subsections)."
              (setq counter (1+ counter))))))

      ;; Step 2: All conversions on non-code content
      ;; Remove standalone timestamp lines (e.g. [2026-01-11 Sun 20:03])
      (goto-char (point-min))
      (while (re-search-forward
              "^[ \t]*\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\( [A-Za-z]\\{2,3\\}\\)?\\( [0-9]\\{2\\}:[0-9]\\{2\\}\\)?\\][ \t]*\n?" nil t)
        (replace-match ""))

      ;; Remove #+RESULTS lines (content is wrapped in BEGIN_EXAMPLE)
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+RESULTS\\(?:\\[.*\\]\\)?:?[ \t]*\n" nil t)
        (replace-match ""))

      ;; Convert org headings to markdown headings
      (goto-char (point-min))
      (while (re-search-forward "^\\(\\*+\\) " nil t)


@@ 545,6 662,40 @@ Returns list of (level title properties content subsections)."
               (level (length stars))
               (hashes (make-string level ?#)))
          (replace-match (concat hashes " "))))
      ;; Convert org table separator lines: |---+---| -> |---|---|
      (goto-char (point-min))
      (while (re-search-forward "^\\([ \t]*\\)|\\([-+|]+\\)|[ \t]*$" nil t)
        (let ((indent (match-string 1))
              (sep (match-string 2)))
          (when (string-match-p "+" sep)
            (replace-match (concat indent "|" (replace-regexp-in-string "+" "|" sep) "|")))))
      ;; Ensure markdown tables have separator line after header row
      (goto-char (point-min))
      (while (not (eobp))
        (if (looking-at "^\\([ \t]*\\)\\(|.*|\\)[ \t]*$")
            (let ((indent (match-string 1))
                  (header (match-string 2)))
              (forward-line 1)
              (let ((is-sep (and (looking-at "^[ \t]*\\(|[-| \t:]\\+|\\)[ \t]*$")
                                 (string-match-p "-" (match-string 1)))))
                (if is-sep
                    (forward-line 1)
                  ;; Count columns and insert separator
                  (let ((col-count 0)
                        (pos 0)
                        (content (substring header 1 -1)))
                    (while (string-match "|" content pos)
                      (setq col-count (1+ col-count)
                            pos (match-end 0)))
                    (setq col-count (1+ col-count))
                    (insert indent "|"
                            (mapconcat (lambda (_) "---|") (number-sequence 1 col-count) "")
                            "\n"))))
              ;; Skip rest of table rows
              (while (and (not (eobp))
                          (looking-at "^[ \t]*|.*|[ \t]*$"))
                (forward-line 1)))
          (forward-line 1)))
      ;; Convert #+MORE -> <!--more--> (case insensitive)
      (goto-char (point-min))
      (let ((case-fold-search t))


@@ 554,63 705,187 @@ Returns list of (level title properties content subsections)."
      (goto-char (point-min))
      (while (re-search-forward "\\\\\\\\\\s-*$" nil t)
        (replace-match "<br>"))
      ;; Convert bold: *text* -> **text**
      (goto-char (point-min))
      (while (re-search-forward "\\*\\([^*\n]+\\)\\*" nil t)
        (replace-match "**\\1**"))
      ;; Convert italic: /text/ -> *text*
      (goto-char (point-min))
      (while (re-search-forward "/\\([^/\n]+\\)/" nil t)
        (replace-match "*\\1*"))
      ;; Convert underline: _text_ -> <u>text</u>
      (goto-char (point-min))
      (while (re-search-forward "_\\([^_\n]+\\)_" nil t)
        (replace-match "<u>\\1</u>"))
      ;; Convert image links BEFORE other link conversions
      ;; [[file:path/image.png][alt]] or [[./path/image.png][alt]] → ![alt](image.png)
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?\\([^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "![\\3](\\1)"))
      ;; [[file:image.png]] or [[./image.png]] → ![](image.png)
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?\\([^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\)\\]\\]" nil t)
        (replace-match "![](\\1)"))
      ;; Convert links: [[url][text]] -> [text](url)
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "[\\2](\\1)"))
      ;; Convert plain links: [[url]] -> <url>
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "<\\1>"))
      ;; Convert code: ~text~ -> `text`
      (goto-char (point-min))
      (while (re-search-forward "~\\([^~\n]+\\)~" nil t)
        (replace-match "`\\1`"))
      ;; Convert verbatim: =text= -> `text`
      (goto-char (point-min))
      (while (re-search-forward "=\\([^=\n]+\\)=" nil t)
        (replace-match "`\\1`"))
      ;; Convert BEGIN_QUOTE blocks
      ;; Step 1.5: Convert and protect links BEFORE text formatting
      ;; This prevents underscores in filenames from being converted to <u>
      (let ((links '())
            (link-counter 0))
        ;; Convert image links: [[file:path/image.png][alt]] → ![alt](image.png)
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?\\([^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
          (let* ((path (match-string-no-properties 1))
                 (alt (match-string-no-properties 3))
                 (placeholder (format "<<<IMGLINK%d>>>" link-counter))
                 (md-link (format "![%s](%s)" alt path)))
            (push (cons placeholder md-link) links)
            (replace-match placeholder)
            (setq link-counter (1+ link-counter))))
        ;; [[file:image.png]] or [[./image.png]] → ![](image.png)
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?\\([^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\)\\]\\]" nil t)
          (let* ((path (match-string-no-properties 1))
                 (placeholder (format "<<<IMGLINK%d>>>" link-counter))
                 (md-link (format "![](%s)" path)))
            (push (cons placeholder md-link) links)
            (replace-match placeholder)
            (setq link-counter (1+ link-counter))))
        ;; Convert ruby links: [[ruby:reading][text]] -> {% ruby reading %}text{% endruby %}
        ;; Must be before general link conversion
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[ruby:\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
          (replace-match "{% ruby \\1 %}\\2{% endruby %}"))

        ;; Convert em links: [[em:text]] -> {% em %}text{% endem %}
        ;; Must be before general link conversion
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[em:\\([^]]+\\)\\]\\]" nil t)
          (replace-match "{% em %}\\1{% endem %}"))

        ;; Convert links: [[url][text]] -> [text](url)
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
          (let* ((url (match-string-no-properties 1))
                 (text (match-string-no-properties 2))
                 (placeholder (format "<<<LINK%d>>>" link-counter))
                 (md-link (format "[%s](%s)" text url)))
            (push (cons placeholder md-link) links)
            (replace-match placeholder)
            (setq link-counter (1+ link-counter))))

        ;; Convert plain links: [[url]] -> <url>
        (goto-char (point-min))
        (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t)
          (let* ((url (match-string-no-properties 1))
                 (placeholder (format "<<<LINK%d>>>" link-counter))
                 (md-link (format "<%s>" url)))
            (push (cons placeholder md-link) links)
            (replace-match placeholder)
            (setq link-counter (1+ link-counter))))

        ;; Protect bare URLs and wrap in <> (Markdown autolink)
        ;; Strip trailing CJK punctuation that is not part of the URL
        (goto-char (point-min))
        (while (re-search-forward "https?://[^] \n\t>)]+" nil t)
          (let* ((raw (match-string-no-properties 0))
                 (url (replace-regexp-in-string "[。,、;:!?)」』】〉》]+$" "" raw))
                 (placeholder (format "<<<LINK%d>>>" link-counter))
                 (md-link (format "<%s>" url)))
            ;; Only replace the URL portion, leave trailing punctuation in place
            (replace-match (concat placeholder (substring raw (length url))) t t)
            (push (cons placeholder md-link) links)
            (setq link-counter (1+ link-counter))))

        ;; Now do text formatting (links are protected)
        ;; Protect code/verbatim with placeholders (like links)
        (let ((inline-codes '())
              (code-counter 0)
              (math-exprs '())
              (math-counter 0))
          ;; Convert and protect code/verbatim: =text= and ~text~ -> placeholder
          ;; Single pass so whichever marker appears first wins (handles ~ inside =...=)
          ;; Content must not start/end with space (org emphasis rule)
          (goto-char (point-min))
          (while (re-search-forward "\\(?:=\\([^ =\n]\\(?:[^=\n]*[^ =\n]\\)?\\)=\\)\\|\\(?:~\\([^ ~\n]\\(?:[^~\n]*[^ ~\n]\\)?\\)~\\)" nil t)
            (let* ((raw-text (or (match-string-no-properties 1) (match-string-no-properties 2)))
                   (text (replace-regexp-in-string "\\\\slash{}" "/" raw-text))
                   (placeholder (format "<<<CODE%d>>>" code-counter))
                   (md-code (format "`%s`" text)))
              (push (cons placeholder md-code) inline-codes)
              (replace-match placeholder)
              (setq code-counter (1+ code-counter))))
          ;; Protect display math ($$...$$) from text formatting
          (goto-char (point-min))
          (while (search-forward "$$" nil t)
            (let ((start (- (point) 2)))
              (when (search-forward "$$" nil t)
                (let* ((end (point))
                       (math-text (buffer-substring-no-properties start end))
                       (placeholder (format "<<<MATH%d>>>" math-counter)))
                  (push (cons placeholder math-text) math-exprs)
                  (delete-region start end)
                  (goto-char start)
                  (insert placeholder)
                  (setq math-counter (1+ math-counter))))))
          ;; Protect inline math ($...$) from text formatting
          (goto-char (point-min))
          (while (re-search-forward "\\$\\([^$\n]+\\)\\$" nil t)
            (let* ((math-text (match-string-no-properties 0))
                   (placeholder (format "<<<MATH%d>>>" math-counter)))
              (push (cons placeholder math-text) math-exprs)
              (replace-match placeholder t t)
              (setq math-counter (1+ math-counter))))
          ;; Convert bold: *text* -> **text**
          (goto-char (point-min))
          (while (re-search-forward "\\*\\([^ *\n]\\(?:[^*\n]*[^ *\n]\\)?\\)\\*" nil t)
            (replace-match "**\\1**"))
          ;; Convert italic: /text/ -> *text*
          (goto-char (point-min))
          (while (re-search-forward "/\\([^ /\n]\\(?:[^/\n]*[^ /\n]\\)?\\)/" nil t)
            (replace-match "*\\1*"))
          ;; Convert org entity \slash{} -> / (after italic to avoid /text/ false matches)
          (goto-char (point-min))
          (while (search-forward "\\slash{}" nil t)
            (replace-match "/" t t))
          ;; Convert underline: _{text}_ -> <u>text</u>
          (goto-char (point-min))
          (while (re-search-forward "_{\\([^}\n]+\\)}_" nil t)
            (replace-match "<u>\\1</u>"))
          ;; Restore math expressions
          (dolist (math math-exprs)
            (goto-char (point-min))
            (when (search-forward (car math) nil t)
              (replace-match (cdr math) t t)))
          ;; Restore inline codes
          (dolist (code inline-codes)
            (goto-char (point-min))
            (when (search-forward (car code) nil t)
              (replace-match (cdr code) t t))))

        ;; Restore links
        (dolist (link links)
          (goto-char (point-min))
          (when (search-forward (car link) nil t)
            (replace-match (cdr link) t t))))
      ;; Convert BEGIN_QUOTE blocks to markdown blockquote (supports indented blocks)
      (goto-char (point-min))
      (while (re-search-forward "^#\\+BEGIN_QUOTE" nil t)
        (replace-match ""))
      (while (re-search-forward "^\\([ \t]*\\)#\\+BEGIN_QUOTE[ \t]*\n" nil t)
        (let ((indent (match-string 1))
              (indent-len (length (match-string 1))))
          (replace-match "")
          (let ((start (point)))
            (when (re-search-forward (concat "^" (regexp-quote indent) "#\\+END_QUOTE") nil t)
              (let ((end (match-beginning 0)))
                (save-excursion
                  (goto-char start)
                  (while (< (point) end)
                    ;; Insert "> " after the indent (skip past indent first)
                    (when (looking-at (regexp-quote indent))
                      (goto-char (match-end 0)))
                    (insert "> ")
                    (setq end (+ end 2))
                    (forward-line 1)))
                (goto-char end)
                (delete-region (point) (line-end-position))
                (when (looking-at "\n")
                  (delete-char 1)))))))
      ;; Convert BEGIN_EXAMPLE blocks to markdown code blocks
      (goto-char (point-min))
      (while (re-search-forward "^#\\+END_QUOTE" nil t)
        (replace-match ""))
      ;; Convert other special blocks to Hexo tags (but not SRC/QUOTE)
      (while (re-search-forward "^[ \t]*#\\+BEGIN_EXAMPLE[ \t]*\n" nil t)
        (replace-match "```\n")
        (when (re-search-forward "^[ \t]*#\\+END_EXAMPLE[ \t]*$" nil t)
          (replace-match "```")))
      ;; Convert other special blocks to Hexo tags (but not SRC/QUOTE/EXAMPLE)
      (goto-char (point-min))
      (while (re-search-forward "^#\\+BEGIN_\\([A-Za-z]+\\)\\(.*\\)$" nil t)
        (let ((tag (downcase (match-string-no-properties 1)))
              (args (string-trim (or (match-string-no-properties 2) ""))))
          (unless (member tag '("src" "quote"))
          (unless (member tag '("src" "quote" "example"))
            (replace-match (if (string-empty-p args)
                               (format "{%% %s %%}" tag)
                             (format "{%% %s %s %%}" tag args))))))
      (goto-char (point-min))
      (while (re-search-forward "^#\\+END_\\([A-Za-z]+\\)" nil t)
        (let ((tag (downcase (match-string-no-properties 1))))
          (unless (member tag '("src" "quote"))
          (unless (member tag '("src" "quote" "example"))
            (replace-match (format "{%% end%s %%}" tag)))))

      ;; Remove any remaining PROPERTIES drawers


@@ 628,12 903,10 @@ Returns list of (level title properties content subsections)."
      (while (re-search-forward "\\(\\*\\*\\|`\\|</u>\\) +\\(\\cC\\)" nil t)
        (replace-match "\\1\\2"))
      ;; Handle single * (italic) separately to avoid conflict with **
      ;; Remove space between CJK char and opening italic *
      (goto-char (point-min))
      (while (re-search-forward "\\(\\cC\\) +\\(\\*[^*]\\)" nil t)
        (replace-match "\\1\\2"))
      (goto-char (point-min))
      (while (re-search-forward "\\([^*]\\*\\) +\\(\\cC\\)" nil t)
        (replace-match "\\1\\2"))
      ;; Remove space before CJK punctuation
      (goto-char (point-min))
      (while (re-search-forward "\\(\\cC\\|\\*\\*\\|`\\|</u>\\) +\\(\\cP\\)" nil t)


@@ 646,6 919,194 @@ Returns list of (level title properties content subsections)."
          (replace-match (cdr block) t t))))
    (buffer-string)))

(defun my/blog--convert-org-to-gmi (content)
  "Convert org CONTENT to gemtext.
Returns (TEXT . LINKS) where TEXT is the gemtext body and
LINKS is a list of (url . label) pairs for => lines."
  (with-temp-buffer
    (insert content)
    (let ((code-blocks '())
          (counter 0)
          (collected-links '()))
      ;; Step 1: Protect code blocks with placeholders
      (goto-char (point-min))
      (while (re-search-forward "^#\\+BEGIN_SRC\\s-*\\(.*\\)$" nil t)
        (let ((lang (string-trim (match-string-no-properties 1)))
              (start (match-beginning 0)))
          (when (re-search-forward "^#\\+END_SRC" nil t)
            (let* ((end (match-end 0))
                   (block-content (buffer-substring
                                   (save-excursion
                                     (goto-char start)
                                     (forward-line 1)
                                     (point))
                                   (save-excursion
                                     (goto-char end)
                                     (beginning-of-line)
                                     (point))))
                   (placeholder (format "<<<GMICODEBLOCK%d>>>" counter))
                   (gmi-block (format "```%s\n%s```" lang block-content)))
              (push (cons placeholder gmi-block) code-blocks)
              (delete-region start end)
              (goto-char start)
              (insert placeholder)
              (setq counter (1+ counter))))))

      ;; Step 2: Delete standalone timestamp lines
      (goto-char (point-min))
      (while (re-search-forward
              "^[ \t]*\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)\\( [A-Za-z]\\{2,3\\}\\)?\\( [0-9]\\{2\\}:[0-9]\\{2\\}\\)?\\][ \t]*\n?" nil t)
        (replace-match ""))

      ;; Step 3: Delete #+RESULTS lines
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+RESULTS\\(?:\\[.*\\]\\)?:?[ \t]*\n" nil t)
        (replace-match ""))

      ;; Step 4: Convert sub-headings (* → ##, ** → ###, deeper → ###)
      (goto-char (point-min))
      (while (re-search-forward "^\\(\\*+\\) " nil t)
        (let* ((stars (match-string 1))
               (level (length stars))
               (hashes (make-string (min (1+ level) 3) ?#)))
          (replace-match (concat hashes " "))))

      ;; Step 5: Link handling
      ;; 5a: ruby links: [[ruby:reading][text]] → text(reading)
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[ruby:\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\2(\\1)"))

      ;; 5b: em links: [[em:text]] → text
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[em:\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\1"))

      ;; 5c: Image links with alt → just alt text
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?[^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\2"))

      ;; 5c2: Image links without alt → remove
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(?:file:\\|\\.?/\\)?[^]]*\\.\\(png\\|jpg\\|jpeg\\|gif\\|webp\\|svg\\)\\]\\]" nil t)
        (replace-match ""))

      ;; 5d: External links with text: [[https://...][text]] → text, collect link
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(https?://[^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (let ((url (match-string-no-properties 1))
              (text (match-string-no-properties 2)))
          (push (cons url text) collected-links)
          (replace-match text t t)))

      ;; 5e: External links without text: [[https://...]] → remove, collect link
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\(https?://[^]]+\\)\\]\\]" nil t)
        (let ((url (match-string-no-properties 1)))
          (push (cons url url) collected-links)
          (replace-match "" t t)))

      ;; 5f: Internal path links with text: [[/posts/...][text]] → plain text
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[/[^]]+\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\1"))

      ;; 5g: Any remaining internal links: [[something]] → plain text
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\1"))

      ;; 5h: Remaining described links: [[something][text]] → plain text
      (goto-char (point-min))
      (while (re-search-forward "\\[\\[\\([^]]+\\)\\]\\[\\([^]]+\\)\\]\\]" nil t)
        (replace-match "\\2"))

      ;; Step 6: Strip emphasis markers
      ;; Content must not start/end with space (org emphasis rule)
      ;; =verbatim= / ~code~ → plain text (single pass, first marker wins)
      (goto-char (point-min))
      (while (re-search-forward "\\(?:=\\([^ =\n]\\(?:[^=\n]*[^ =\n]\\)?\\)=\\)\\|\\(?:~\\([^ ~\n]\\(?:[^~\n]*[^ ~\n]\\)?\\)~\\)" nil t)
        (let ((text (or (match-string-no-properties 1) (match-string-no-properties 2))))
          (replace-match text t t)))
      ;; *bold* → bold
      (goto-char (point-min))
      (while (re-search-forward "\\*\\([^ *\n]\\(?:[^*\n]*[^ *\n]\\)?\\)\\*" nil t)
        (replace-match "\\1"))
      ;; /italic/ → italic
      (goto-char (point-min))
      (while (re-search-forward "/\\([^ /\n]\\(?:[^/\n]*[^ /\n]\\)?\\)/" nil t)
        (replace-match "\\1"))
      ;; _{underline}_ → underline
      (goto-char (point-min))
      (while (re-search-forward "_{\\([^}\n]+\\)}_" nil t)
        (replace-match "\\1"))

      ;; Step 7: Quote blocks → > prefix
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+BEGIN_QUOTE[ \t]*\n" nil t)
        (replace-match "")
        (let ((start (point)))
          (when (re-search-forward "^[ \t]*#\\+END_QUOTE[ \t]*\n?" nil t)
            (replace-match "")
            (let ((end (point)))
              (save-excursion
                (goto-char start)
                (while (< (point) end)
                  (insert "> ")
                  (setq end (+ end 2))
                  (forward-line 1)))))))

      ;; Step 8: Example blocks → ``` fences
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+BEGIN_EXAMPLE[ \t]*\n" nil t)
        (replace-match "```\n")
        (when (re-search-forward "^[ \t]*#\\+END_EXAMPLE[ \t]*$" nil t)
          (replace-match "```")))

      ;; Step 9: Other blocks → strip markers, keep content
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+BEGIN_[A-Za-z]+.*\n" nil t)
        (replace-match ""))
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*#\\+END_[A-Za-z]+[ \t]*\n?" nil t)
        (replace-match ""))

      ;; Step 10: Remove PROPERTIES drawers
      (goto-char (point-min))
      (while (re-search-forward "^[ \t]*:PROPERTIES:[ \t]*\n\\(?:.*\n\\)*?[ \t]*:END:[ \t]*\n?" nil t)
        (replace-match ""))

      ;; Step 11: Remove #+MORE
      (goto-char (point-min))
      (let ((case-fold-search t))
        (while (re-search-forward "^#\\+MORE\\s-*\n?" nil t)
          (replace-match "")))

      ;; Step 12: List items: - item → * item (Gemini format)
      (goto-char (point-min))
      (while (re-search-forward "^\\([ \t]*\\)- " nil t)
        (replace-match "* "))

      ;; Step 13: Restore code blocks
      (dolist (block code-blocks)
        (goto-char (point-min))
        (when (search-forward (car block) nil t)
          (replace-match (cdr block) t t)))

      ;; Step 14: Deduplicate links (keep first occurrence)
      (setq collected-links (nreverse collected-links))
      (let ((seen (make-hash-table :test 'equal))
            (unique '()))
        (dolist (link collected-links)
          (unless (gethash (car link) seen)
            (puthash (car link) t seen)
            (push link unique)))
        (setq collected-links (nreverse unique)))

      ;; Step 15: Return (text . links)
      (cons (string-trim (buffer-string)) collected-links))))

(defun my/blog-export-monthly (&optional file)
  "Export monthly org FILE to Hexo markdown.
If FILE is nil, use current buffer's file or current monthly."


@@ 666,28 1127,40 @@ If FILE is nil, use current buffer's file or current monthly."
      (let* ((title (my/blog--get-org-property "TITLE"))
             (date (or (my/blog--get-org-property "DATE")
                       (format-time-string "%Y-%m-%d %H:%M:%S")))
             (custom-fm (my/blog--get-org-property "HUGO_CUSTOM_FRONT_MATTER"))
             (lang (if (and custom-fm (string-match ":lang \\([^ ]+\\)" custom-fm))
                       (match-string 1 custom-fm) "zh"))
             (mathjax (if (and custom-fm (string-match ":mathjax \\([^ ]+\\)" custom-fm))
                         (match-string 1 custom-fm) "false"))
             (hidden (if (and custom-fm (string-match ":hidden \\([^ ]+\\)" custom-fm))
                         (match-string 1 custom-fm) "false"))
             (categories (or (my/blog--get-org-property "CATEGORIES") "想法迭代"))
             (structure (my/blog--collect-structure (current-buffer)))
             (intro (plist-get structure :intro))
             (sections (plist-get structure :sections))
             (export-file (expand-file-name (concat title ".md") my/blog-export-dir))
             (year-month (if (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)" date)
                             (format "%s-%s" (match-string 1 date) (match-string-no-properties 2 date))
                           (format-time-string "%Y-%m"))))
             (year-month (or (my/blog--get-org-property "DIARY_MONTH")
                             (if (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)" date)
                                 (format "%s-%s" (match-string 1 date) (match-string-no-properties 2 date))
                               (format-time-string "%Y-%m")))))

        ;; Generate markdown content
        (with-temp-buffer
          ;; Front matter
          (insert "---\n")
          (insert (format "title: %s\n" title))
          (insert "lang: zh\n")
          (insert "categories:\n  - 想法迭代\n")
          (insert "mathjax: false\n")
          (insert "hidden: false\n")
          (insert (format "lang: %s\n" lang))
          (insert (format "categories:\n  - %s\n" categories))
          (insert (format "mathjax: %s\n" mathjax))
          (insert (format "hidden: %s\n" hidden))
          ;; Don't include abbrlink - let hexo-abbrlink auto-generate it
          (insert (format "date: %s\n" (if (string-match ":" date) date (concat date " 00:00:00"))))
          (insert (format "diary_month: %s\n" year-month))
          (insert (format "neodb_month: %s\n" year-month))
          (insert "tags:\n")
          (insert "description:\n")
          (insert "syndicate: true\n")
          (insert "in_reply_to: true\n")
          (insert "---\n\n")

          ;; Intro


@@ 709,62 1182,246 @@ If FILE is nil, use current buffer's file or current monthly."
            ;; Main content
            (let ((skippable-sections '("折腾博客" "编程历程"))
                  (hr-inserted nil))
              (dolist (sec sections)
                (let ((level (plist-get sec :level))
                      (title (plist-get sec :title))
                      (source (plist-get sec :source))
                      (content (plist-get sec :content))
                      (has-subsections nil))
                  ;; Check if this section has subsections with content
                  (when (= level 1)
                    (let ((sec-idx (cl-position sec sections)))
                      (cl-loop for i from (1+ sec-idx) below (length sections)
                               for next-sec = (nth i sections)
                               while (> (plist-get next-sec :level) 1)
                               when (not (string-empty-p (or (plist-get next-sec :content) "")))
                               do (setq has-subsections t))))
                  ;; Special handling for 我写的文章
                  (when (and (= level 1)
                             (string= title "我写的文章")
                             (or (null content) (string-empty-p content))
                             (not has-subsections)
                             monthly-posts)
                    ;; Auto-fill with scanned posts
                    (setq content (mapconcat
                                   (lambda (post)
                                     (format "- [《%s》](%s)\n" (car post) (cdr post)))
                                   monthly-posts
                                   "")))
                  ;; Skip empty skippable sections (no content and no subsections)
                  (unless (and (= level 1)
                               (member title skippable-sections)
                               (or (null content) (string-empty-p content))
                               (not has-subsections))
                    ;; Insert hr before first output section
              (let ((pending-neodb-tag nil)
                    (pending-diary-tag nil))
                (dolist (sec sections)
                  (let ((level (plist-get sec :level))
                        (title (plist-get sec :title))
                        (source (plist-get sec :source))
                        (content (plist-get sec :content))
                        (has-subsections nil))
                    ;; When encountering a new Level 1 section, insert pending tags first
                    (when (= level 1)
                      (when pending-diary-tag
                        (insert pending-diary-tag)
                        (setq pending-diary-tag nil))
                      (when pending-neodb-tag
                        (insert pending-neodb-tag)
                        (setq pending-neodb-tag nil)))
                    ;; Check if this section has subsections with content
                    (when (= level 1)
                      (let ((sec-idx (cl-position sec sections)))
                        (cl-loop for i from (1+ sec-idx) below (length sections)
                                 for next-sec = (nth i sections)
                                 while (> (plist-get next-sec :level) 1)
                                 when (not (string-empty-p (or (plist-get next-sec :content) "")))
                                 do (setq has-subsections t))))
                    ;; Special handling for 我写的文章
                    (when (and (= level 1)
                               (not hr-inserted)
                               (not (member title '("商业与社会" "心理与关系" "科学与自然" "技术与创造"))))
                      (insert "\n---\n")
                      (setq hr-inserted t))
                    (cond
                     ;; Level 1 -> ## heading
                     ((= level 1)
                      (insert (format "\n## %s\n\n" title)))
                     ;; Level 2 -> #### heading (with optional link)
                     ((= level 2)
                      (if source
                          (insert (format "#### [《%s》](%s)\n\n" title source))
                        (insert (format "#### %s\n\n" title)))))
                    ;; Content
                    (when (and content (not (string-empty-p content)))
                      (insert (my/blog--convert-org-to-md content) "\n\n")))))))
                               (string= title "我写的文章")
                               (or (null content) (string-empty-p content))
                               (not has-subsections)
                               monthly-posts)
                      ;; Auto-fill with scanned posts
                      (setq content (mapconcat
                                     (lambda (post)
                                       (format "- [《%s》](%s)\n" (car post) (cdr post)))
                                     monthly-posts
                                     "")))
                    ;; Skip empty skippable sections (no content and no subsections)
                    (unless (and (= level 1)
                                 (member title skippable-sections)
                                 (or (null content) (string-empty-p content))
                                 (not has-subsections))
                      ;; Insert hr before first output section
                      (when (and (= level 1)
                                 (not hr-inserted)
                                 (not (member title '("商业与社会" "心理与关系" "科学与自然" "技术与创造"))))
                        (insert "\n---\n")
                        (setq hr-inserted t))
                      (cond
                       ;; Level 1 -> ## heading
                       ((= level 1)
                        (insert (format "\n## %s\n\n" title))
                        ;; Set pending diary tags for 日记片段 section (inserted before next Level 1)
                        (when (string= title "日记片段")
                          (setq pending-diary-tag "{% details_toggle diary_sections %}\n\n{% diary_aggregator %}\n\n")))
                       ;; Level 2 -> #### heading (with optional link)
                       ((= level 2)
                        (let ((clean-title (my/blog--clean-title-for-md title))
                              (slug (plist-get sec :custom-id)))
                          (insert (format "<a id=\"%s\"></a>\n\n" slug))
                          (if source
                              (insert (format "#### [《%s》](%s)\n\n" clean-title source))
                            (insert (format "#### %s\n\n" clean-title)))))
                       ;; Level 3 -> ##### subheading
                       ((= level 3)
                        (insert (format "##### %s\n\n" title))))
                      ;; Content
                      (when (and content (not (string-empty-p content)))
                        (insert (my/blog--convert-org-to-md content) "\n\n"))
                      ;; Set pending neodb_aggregator for media sections (inserted before next Level 1)
                      (when (= level 1)
                        (cond
                         ((string= title "书籍")
                          (setq pending-neodb-tag "#### 其他评价\n\n{% neodb_aggregator 书籍 %}\n\n"))
                         ((string= title "音乐")
                          (setq pending-neodb-tag "#### 其他评价\n\n{% neodb_aggregator 音乐 %}\n\n"))
                         ((string= title "影视")
                          (setq pending-neodb-tag "#### 其他评价\n\n{% neodb_aggregator 影视 %}\n\n")))))))
                ;; Insert the last pending tags after loop ends
                (when pending-diary-tag
                  (insert pending-diary-tag))
                (when pending-neodb-tag
                  (insert pending-neodb-tag)))))

          ;; Write to file
          (write-region (point-min) (point-max) export-file))

        (message "Exported to: %s" export-file)
        ;; Also export to Gemini
        (my/blog--export-to-gemini org-file)
        (find-file export-file)))))

;;; Update Monthly Posts List

(defun my/blog--collect-posts-by-diary-month (year-month)
  "Scan markdown files in export dir for articles with date matching YEAR-MONTH.
YEAR-MONTH should be in format \"YYYY-MM\".
Returns list of (title . url) pairs."
  (let ((results '()))
    (when (and my/blog-export-dir (file-directory-p my/blog-export-dir))
      (dolist (file (directory-files my/blog-export-dir t "\\.md$"))
        ;; Skip monthly posts (they have year-month pattern in filename)
        (unless (string-match-p "[0-9]\\{4\\}-[0-9]\\{2\\}\\.md$" file)
          (with-temp-buffer
            (insert-file-contents file nil 0 800) ; Read front matter
            (goto-char (point-min))
            (when (looking-at "---")
              (let ((date nil)
                    (title nil)
                    (abbrlink nil))
                ;; Extract date (YAML format: date: 2026-01-19 or date: 2026-01-19 12:00:00)
                (when (re-search-forward "^date:\\s-*\\([0-9-]+\\)" nil t)
                  (setq date (match-string-no-properties 1)))
                ;; Extract title
                (goto-char (point-min))
                (when (re-search-forward "^title:\\s-*\\(.+\\)$" nil t)
                  (setq title (string-trim (match-string-no-properties 1))))
                ;; Extract abbrlink (remove quotes if present)
                (goto-char (point-min))
                (when (re-search-forward "^abbrlink:\\s-*\\(.+\\)$" nil t)
                  (setq abbrlink (string-trim (match-string-no-properties 1) "[\"']" "[\"']")))
                ;; Check if date's year-month matches
                (when (and date title abbrlink
                           (string-prefix-p year-month date))
                  (push (cons title (format "/posts/%s/" abbrlink)) results))))))))
    (nreverse results)))

(defun my/blog--get-monthly-year-month ()
  "Get year-month string from current monthly org file.
Returns string like \"2026-01\" or nil if not in a monthly file."
  (let ((filename (buffer-file-name)))
    (when (and filename
               (string-match "\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)\\.org$" filename))
      (format "%s-%s" (match-string 1 filename) (match-string 2 filename)))))

(defun my/blog-update-monthly-posts ()
  "Update the \"我写的文章\" section in current monthly with matching posts.
Scans posts/*.org for articles with DIARY_MONTH matching current monthly."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (error "Not in org-mode"))
  (let ((year-month (my/blog--get-monthly-year-month)))
    (unless year-month
      (error "Not in a monthly file (expected filename like YYYY-MM.org)"))
    (let ((posts (my/blog--collect-posts-by-diary-month year-month)))
      (save-excursion
        (goto-char (point-min))
        (if (re-search-forward "^\\* 我写的文章" nil t)
            (progn
              ;; Move past the heading and PROPERTIES drawer
              (org-end-of-meta-data t)
              (let ((content-start (point))
                    (content-end (save-excursion
                                   (if (re-search-forward "^\\* " nil t)
                                       (line-beginning-position)
                                     (point-max)))))
                ;; Delete existing content
                (delete-region content-start content-end)
                ;; Insert new content
                (goto-char content-start)
                (if posts
                    (progn
                      (insert "\n")
                      (dolist (post posts)
                        (insert (format "- [[%s][《%s》]]\n" (cdr post) (car post))))
                      (insert "\n")
                      (message "Updated \"我写的文章\" with %d posts" (length posts)))
                  (insert "\n\n")
                  (message "No posts found for %s" year-month))))
          (error "Section \"我写的文章\" not found"))))))

;;; Inbox for Topic Entries (no source link)

(defun my/blog--create-inbox-file (file)
  "Create blog-inbox.org with section headings."
  (with-temp-file file
    (insert "#+TITLE: 月刊条目收集\n\n")
    (dolist (sec my/blog-monthly-sections)
      (insert (format "* %s\n\n" (cdr sec))))))

(defun my/blog--goto-inbox-section ()
  "Prompt for section and go there in blog-inbox.org."
  (let* ((sections (mapcar #'car my/blog-monthly-sections))
         (section (completing-read "Section: " sections nil t))
         (headline (cdr (assoc section my/blog-monthly-sections)))
         (file (expand-file-name "blog-inbox.org" my/blog-org-dir)))
    (unless (file-exists-p file)
      (my/blog--create-inbox-file file))
    (set-buffer (org-capture-target-buffer file))
    (goto-char (point-min))
    (if (re-search-forward (format "^\\* %s" (regexp-quote headline)) nil t)
        (beginning-of-line)
      (goto-char (point-max)))))

(defun my/blog-batch-refile-inbox ()
  "Refile all entries from blog-inbox.org to current monthly by section."
  (interactive)
  (let* ((inbox-file (expand-file-name "blog-inbox.org" my/blog-org-dir))
         (monthly-file (my/blog--current-monthly-file))
         (count 0))
    (unless (file-exists-p inbox-file)
      (error "No inbox file: %s" inbox-file))
    (unless (file-exists-p monthly-file)
      (error "No current monthly file: %s" monthly-file))
    (with-current-buffer (find-file-noselect inbox-file)
      (org-map-entries
       (lambda ()
         (when (= (org-current-level) 1)
           (let ((section-name (org-get-heading t t t t)))
             ;; Process each level-2 child under this section
             (save-excursion
               (let ((section-end (save-excursion (org-end-of-subtree t t))))
                 (org-forward-heading-same-level 0 t)
                 (when (outline-next-heading)
                   (while (and (<= (point) section-end)
                               (= (org-current-level) 2))
                     ;; Determine target headline in monthly
                     (let* ((target-headline section-name)
                            (is-project (string= target-headline "技术与创造")))
                       ;; Refile this entry
                       (if (and is-project
                                (string= (org-get-heading t t t t) "有趣项目"))
                           ;; Skip "有趣项目" sub-entries - handle differently
                           (outline-next-heading)
                         (let ((custom-id (org-entry-get nil "CUSTOM_ID")))
                           (unless custom-id
                             (org-set-property "CUSTOM_ID"
                                               (my/blog--slugify (org-get-heading t t t t))))
                           (let ((org-refile-targets `((,monthly-file :maxlevel . 3))))
                             (org-refile nil nil
                                         (list target-headline monthly-file nil
                                               (with-current-buffer (find-file-noselect monthly-file)
                                                 (org-find-exact-headline-in-buffer target-headline)))))
                           (setq count (1+ count))))))))))))
       nil 'file))
    (with-current-buffer (find-file-noselect inbox-file)
      (save-buffer))
    (with-current-buffer (find-file-noselect monthly-file)
      (save-buffer))
    (message "Refiled %d entries from inbox to monthly." count)))

;;; Capture Templates for Monthly

(defun my/blog--capture-target-monthly (section)


@@ 795,36 1452,40 @@ Prompts for section selection."
;; Add a simple menu entry for monthly capture
(defun my/blog--add-monthly-capture-templates ()
  "Add monthly capture templates to `org-capture-templates'."
  ;; We use function-based targets for dynamic file resolution
  (add-to-list 'org-capture-templates
               '("m" "Monthly (博客月刊)") t)
  (add-to-list 'org-capture-templates
               `("ma" "→ 商业与社会" entry
                 (function (lambda () (my/blog--goto-monthly-section "商业与社会")))
                 "** %^{标题}\n:PROPERTIES:\n:CUSTOM_ID: %(my/blog--slugify \"%\\1\")\n:SOURCE: %^{URL}\n:END:\n\n%?\n\n%U") t)
  (add-to-list 'org-capture-templates
               `("mb" "→ 心理与关系" entry
                 (function (lambda () (my/blog--goto-monthly-section "心理与关系")))
               `("mm" "→ 月刊条目 (有链接)" entry
                 (function my/blog--goto-monthly-section-prompt)
                 "** %^{标题}\n:PROPERTIES:\n:CUSTOM_ID: %(my/blog--slugify \"%\\1\")\n:SOURCE: %^{URL}\n:END:\n\n%?\n\n%U") t)
  (add-to-list 'org-capture-templates
               `("mc" "→ 技术与创造" entry
                 (function (lambda () (my/blog--goto-monthly-section "技术与创造")))
                 "** %^{标题}\n:PROPERTIES:\n:CUSTOM_ID: %(my/blog--slugify \"%\\1\")\n:SOURCE: %^{URL}\n:END:\n\n%?\n\n%U") t)
  (add-to-list 'org-capture-templates
               `("md" "→ 生活与文娱" entry
                 (function (lambda () (my/blog--goto-monthly-section "生活与文娱")))
                 "** %^{标题}\n:PROPERTIES:\n:CUSTOM_ID: %(my/blog--slugify \"%\\1\")\n:SOURCE: %^{URL}\n:END:\n\n%?\n\n%U") t))

(defun my/blog--goto-monthly-section (section)
  "Go to SECTION in current monthly file for capture."
  (let ((file (my/blog--current-monthly-file)))
               `("mt" "→ 话题条目 (无链接)" entry
                 (function my/blog--goto-inbox-section)
                 "** %^{标题}\n:PROPERTIES:\n:CUSTOM_ID: %(my/blog--slugify \"%\\1\")\n:END:\n\n%?\n\n%U") t))

(defun my/blog--goto-monthly-section-prompt ()
  "Prompt for monthly file and section, then go there."
  (let* ((monthly-dir (my/blog--monthly-dir-full))
         (files (and (file-directory-p monthly-dir)
                     (directory-files monthly-dir nil "^[0-9]\\{4\\}-[0-9]\\{2\\}\\.org$")))
         (choice (completing-read "Monthly file: " (reverse files) nil t))
         (sections (mapcar #'car my/blog-monthly-sections))
         (section (completing-read "Section: " sections nil t))
         (headline (cdr (assoc section my/blog-monthly-sections))))
    (my/blog--goto-monthly-section headline
                                   (expand-file-name choice monthly-dir))))

(defun my/blog--goto-monthly-section (section &optional file)
  "Go to SECTION in monthly FILE for capture.
If FILE is nil, use current monthly file."
  (let ((file (or file (my/blog--current-monthly-file))))
    ;; Ensure monthly exists
    (unless (file-exists-p file)
      (my/blog-create-monthly))
    (set-buffer (org-capture-target-buffer file))
    (goto-char (point-min))
    (if (re-search-forward (format "^\\* %s" (regexp-quote section)) nil t)
        (org-end-of-subtree)
    (if (re-search-forward (format "^\\*+ %s" (regexp-quote section)) nil t)
        (beginning-of-line)
      (goto-char (point-max)))))

;; Add templates after org-capture is loaded


@@ 857,6 1518,8 @@ If FILE is nil, use current buffer's file."
             (lang (or (my/blog--get-org-property "LANG") "zh"))
             (mathjax (or (my/blog--get-org-property "MATHJAX") "false"))
             (hidden (or (my/blog--get-org-property "HIDDEN") "false"))
             (syndicate (my/blog--get-org-property "SYNDICATE"))
             (syndication (my/blog--get-org-property "SYNDICATION"))
             (abbrlink (or (my/blog--get-org-property "ABBRLINK") ""))
             (tags (or (my/blog--get-org-property "TAGS") ""))
             (description (or (my/blog--get-org-property "DESCRIPTION") ""))


@@ 875,6 1538,11 @@ If FILE is nil, use current buffer's file."
          (insert (format "categories:\n  - %s\n" categories))
          (insert (format "mathjax: %s\n" mathjax))
          (insert (format "hidden: %s\n" hidden))
          (cond
           (syndication
            (insert (format "syndication: %s\n" syndication)))
           (syndicate
            (insert (format "syndicate: %s\n" syndicate))))
          ;; Only include abbrlink if explicitly set (let hexo-abbrlink auto-generate if empty)
          (when (and abbrlink (not (string-empty-p abbrlink)))
            (insert (format "abbrlink: %s\n" abbrlink)))


@@ 888,6 1556,7 @@ If FILE is nil, use current buffer's file."
                  (insert (format "  - %s\n" tag))))
            (insert "tags:\n"))
          (insert (format "description: %s\n" description))
          (insert "in_reply_to: true\n")
          (insert "---\n\n")

          ;; Description/excerpt if provided


@@ 900,7 1569,9 @@ If FILE is nil, use current buffer's file."
          ;; Save
          (write-region (point-min) (point-max) export-file))

        (message "Exported to: %s" export-file)))))
        (message "Exported to: %s" export-file)
        ;; Also export to Gemini
        (my/blog--export-to-gemini org-file)))))

(defun my/blog--get-post-body ()
  "Get the body content of the org file (excluding properties header and drawers)."


@@ 972,6 1643,7 @@ Creates a new org file in the blog posts directory with front matter."
        (insert "#+LANG: zh\n")
        (insert "#+MATHJAX: false\n")
        (insert "#+HIDDEN: false\n")
        (insert "#+SYNDICATE: true\n")
        (insert "#+ABBRLINK:\n")
        (insert (format "#+TAGS: %s\n" (if (string-empty-p tags) "" tags)))
        (insert (format "#+DESCRIPTION: %s\n\n" (if (string-empty-p description) "" description)))


@@ 982,7 1654,8 @@ Creates a new org file in the blog posts directory with front matter."

    ;; Ask whether to delete original entry
    (when (y-or-n-p "删除原条目? ")
      (org-cut-subtree))
      (org-cut-subtree)
      (save-buffer))

    ;; Open the new file
    (find-file filepath)


@@ 990,14 1663,43 @@ Creates a new org file in the blog posts directory with front matter."

;;; Image Insertion

;; Immich configuration
(defcustom my/blog-immich-host nil
  "Immich server host (e.g. \"http://192.168.1.119:12283\")."
  :type '(choice (const nil) string)
  :group 'my/blog)

(defcustom my/blog-immich-api-key nil
  "Immich API key for downloading images."
  :type '(choice (const nil) string)
  :group 'my/blog)

(defun my/blog--url-p (string)
  "Return non-nil if STRING looks like a URL."
  (string-match-p "^https?://" string))

(defun my/blog--download-image (url target-file)
  "Download image from URL to TARGET-FILE."
(defun my/blog--immich-url-p (url)
  "Return non-nil if URL is an Immich photo page URL."
  (and my/blog-immich-host
       (string-prefix-p my/blog-immich-host url)
       (string-match-p "/photos/[a-f0-9-]+" url)))

(defun my/blog--immich-extract-asset-id (url)
  "Extract asset ID from Immich URL."
  (when (string-match "/photos/\\([a-f0-9-]+\\)" url)
    (match-string 1 url)))

(defun my/blog--immich-to-direct-url (url)
  "Convert Immich photo page URL to direct download API URL."
  (let ((asset-id (my/blog--immich-extract-asset-id url)))
    (when asset-id
      (format "%s/api/assets/%s/original" my/blog-immich-host asset-id))))

(defun my/blog--download-image (url target-file &optional headers)
  "Download image from URL to TARGET-FILE with optional HEADERS."
  (require 'url)
  (let ((url-request-method "GET"))
  (let ((url-request-method "GET")
        (url-request-extra-headers headers))
    (with-current-buffer (url-retrieve-synchronously url t)
      ;; Skip HTTP headers
      (goto-char (point-min))


@@ 1009,9 1711,10 @@ Creates a new org file in the blog posts directory with front matter."

(defun my/blog-insert-image ()
  "Insert an image link for blog post.
Supports both local files and web URLs.
Supports local files, web URLs, and Immich photo page URLs.
For local files: select and copy to post's asset folder.
For web URLs: download and save with a specified filename."
For web URLs: download and save with a specified filename.
For Immich URLs: auto-convert to API URL and download with auth."
  (interactive)
  (unless (buffer-file-name)
    (error "Buffer must be visiting a file"))


@@ 1020,6 1723,8 @@ For web URLs: download and save with a specified filename."
         ;; Get source: can be file path or URL
         (source (read-string "Image file or URL: "))
         (is-url (my/blog--url-p source))
         (source (if is-url source (expand-file-name source)))
         (is-immich (and is-url (my/blog--immich-url-p source)))
         ;; For URLs without extension, ask for filename; for local files, use original name
         (image-name (if is-url
                         (let ((default-name (file-name-nondirectory (url-filename (url-generic-parse-url source)))))


@@ 1031,24 1736,252 @@ For web URLs: download and save with a specified filename."
    ;; Validate filename for URLs
    (when (and is-url (string-empty-p image-name))
      (error "Filename is required for web images"))
    ;; Validate Immich config
    (when (and is-immich (not my/blog-immich-api-key))
      (error "Immich API key not configured. Set `my/blog-immich-api-key'"))
    ;; Create target directory if needed
    (unless (file-directory-p target-dir)
      (make-directory target-dir t)
      (message "Created directory: %s" target-dir))
    ;; Download or copy image
    (unless (file-exists-p target-file)
      (if is-url
          (progn
            (message "Downloading %s..." source)
            (my/blog--download-image source target-file)
            (message "Downloaded to %s" target-file))
      (cond
       ;; Immich URL: convert and download with API key
       (is-immich
        (let ((direct-url (my/blog--immich-to-direct-url source))
              (headers `(("x-api-key" . ,my/blog-immich-api-key))))
          (message "Downloading from Immich: %s..." (my/blog--immich-extract-asset-id source))
          (my/blog--download-image direct-url target-file headers)
          (message "Downloaded to %s" target-file)))
       ;; Regular URL
       (is-url
        (message "Downloading %s..." source)
        (my/blog--download-image source target-file)
        (message "Downloaded to %s" target-file))
       ;; Local file
       (t
        (copy-file source target-file)
        (message "Copied %s to %s" image-name target-dir)))
        (message "Copied %s to %s" image-name target-dir))))
    ;; Insert org link
    (if (string-empty-p alt-text)
        (insert (format "[[./%s]]" image-name))
      (insert (format "[[./%s][%s]]" image-name alt-text)))))

;;; Gemini Export

(defun my/blog--export-to-gemini (org-file)
  "Export ORG-FILE to gemtext (.gmi).
Dispatches to monthly or post exporter based on filename."
  (let* ((base-name (file-name-nondirectory org-file))
         (is-monthly (string-match-p "^[0-9]\\{4\\}-[0-9]\\{2\\}\\.org$" base-name)))
    (if is-monthly
        (my/blog--export-monthly-to-gmi org-file)
      (my/blog--export-post-to-gmi org-file))))

(defun my/blog--export-monthly-to-gmi (org-file)
  "Export monthly ORG-FILE to gemtext using collect-structure."
  (let* ((gemini-posts-dir (expand-file-name "posts" my/blog-gemini-dir))
         (base-name (file-name-base org-file))
         (gmi-file (expand-file-name (concat base-name ".gmi") gemini-posts-dir)))
    (unless (file-directory-p gemini-posts-dir)
      (make-directory gemini-posts-dir t))
    (with-temp-buffer
      (insert-file-contents org-file)
      (org-mode)
      (let* ((title (my/blog--get-org-property "TITLE"))
             (date (or (my/blog--get-org-property "DATE")
                       (format-time-string "%Y-%m-%d")))
             (structure (my/blog--collect-structure (current-buffer)))
             (intro (plist-get structure :intro))
             (sections (plist-get structure :sections))
             ;; Collect monthly posts for 我写的文章
             (date-parts (split-string date "-"))
             (export-year (string-to-number (nth 0 date-parts)))
             (export-month (string-to-number (nth 1 date-parts)))
             (monthly-posts (my/blog--collect-monthly-posts export-year export-month)))
        (with-temp-buffer
          ;; Title + date
          (insert (format "# %s\n%s\n\n" title date))
          ;; Intro
          (when (and intro (not (string-empty-p intro)))
            (let ((result (my/blog--convert-org-to-gmi intro)))
              (insert (car result) "\n")
              (dolist (link (cdr result))
                (insert (format "=> %s %s\n" (car link) (cdr link))))
              (insert "\n")))
          ;; Sections
          (let ((skippable-sections '("折腾博客" "编程历程")))
            (dolist (sec sections)
              (let ((level (plist-get sec :level))
                    (sec-title (plist-get sec :title))
                    (source (plist-get sec :source))
                    (content (plist-get sec :content))
                    (has-subsections nil))
                ;; Check if this L1 section has subsections with content
                (when (= level 1)
                  (let ((sec-idx (cl-position sec sections)))
                    (cl-loop for i from (1+ sec-idx) below (length sections)
                             for next-sec = (nth i sections)
                             while (> (plist-get next-sec :level) 1)
                             when (not (string-empty-p (or (plist-get next-sec :content) "")))
                             do (setq has-subsections t))))
                (cond
                 ;; 日记片段 → Mastodon link
                 ((and (= level 1) (string= sec-title "日记片段"))
                  (insert (format "## %s\n\n" sec-title))
                  (insert "日记片段发布在 Mastodon 上。\n\n")
                  (insert (format "=> %s 在 Mastodon 阅读日记片段\n\n" my/blog-mastodon-url)))
                 ;; 我写的文章 → .gmi links
                 ((and (= level 1) (string= sec-title "我写的文章"))
                  (when monthly-posts
                    (insert (format "## %s\n\n" sec-title))
                    (dolist (post monthly-posts)
                      (let ((slug (my/blog--slugify (car post))))
                        (insert (format "=> %s.gmi 《%s》\n" slug (car post)))))
                    (insert "\n")))
                 ;; Level 1 category heading
                 ((= level 1)
                  ;; Skip empty skippable sections
                  (unless (and (member sec-title skippable-sections)
                               (or (null content) (string-empty-p content))
                               (not has-subsections))
                    ;; Skip sections with no content and no subsections
                    (unless (and (or (null content) (string-empty-p content))
                                 (not has-subsections))
                      (insert (format "## %s\n\n" sec-title))
                      (when (and content (not (string-empty-p content)))
                        (let ((result (my/blog--convert-org-to-gmi content)))
                          (insert (car result) "\n")
                          (dolist (link (cdr result))
                            (insert (format "=> %s %s\n" (car link) (cdr link))))
                          (insert "\n"))))))
                 ;; Level 2 article heading
                 ((= level 2)
                  (insert (format "### %s\n\n" (my/blog--clean-title-for-gmi sec-title)))
                  (when (and content (not (string-empty-p content)))
                    (let ((result (my/blog--convert-org-to-gmi content)))
                      (insert (car result) "\n")
                      ;; Source link first
                      (when source
                        (insert (format "=> %s 原文\n" source)))
                      ;; Collected links
                      (dolist (link (cdr result))
                        (insert (format "=> %s %s\n" (car link) (cdr link))))
                      (insert "\n")))
                  (when (and (or (null content) (string-empty-p content)) source)
                    (insert (format "=> %s 原文\n\n" source))))))))
          (write-region (point-min) (point-max) gmi-file))))
    (message "Gemini: %s" gmi-file)
    gmi-file))

(defun my/blog--export-post-to-gmi (org-file)
  "Export standalone post ORG-FILE to gemtext."
  (let* ((gemini-posts-dir (expand-file-name "posts" my/blog-gemini-dir))
         (base-name (file-name-base org-file))
         (gmi-file (expand-file-name (concat base-name ".gmi") gemini-posts-dir)))
    (unless (file-directory-p gemini-posts-dir)
      (make-directory gemini-posts-dir t))
    (with-temp-buffer
      (insert-file-contents org-file)
      (org-mode)
      (let* ((title (my/blog--get-org-property "TITLE"))
             (date (or (my/blog--get-org-property "DATE")
                       (format-time-string "%Y-%m-%d")))
             (body (my/blog--get-post-body))
             (result (my/blog--convert-org-to-gmi body)))
        (with-temp-buffer
          ;; Title + date
          (insert (format "# %s\n%s\n\n" title date))
          ;; Body
          (insert (car result) "\n")
          ;; Links
          (when (cdr result)
            (insert "\n## 链接\n\n")
            (dolist (link (cdr result))
              (insert (format "=> %s %s\n" (car link) (cdr link)))))
          (write-region (point-min) (point-max) gmi-file))))
    (message "Gemini: %s" gmi-file)
    gmi-file))

(defun my/blog-export-post-gemini (&optional file)
  "Export current org file to gemtext (.gmi)."
  (interactive)
  (let ((org-file (or file
                      (when (and (buffer-file-name)
                                 (string-suffix-p ".org" (buffer-file-name)))
                        (buffer-file-name)))))
    (unless org-file (error "No org file specified"))
    (my/blog--export-to-gemini org-file)))

(defun my/blog-gemini-export-all ()
  "Export all blog org files to gemtext (.gmi) and generate index."
  (interactive)
  (let ((org-files (append
                    (directory-files (expand-file-name "posts" my/blog-org-dir) t "\\.org$")
                    (directory-files (expand-file-name my/blog-monthly-dir my/blog-org-dir) t "\\.org$")))
        (count 0))
    (dolist (file org-files)
      (condition-case err
          (progn
            (my/blog--export-to-gemini file)
            (setq count (1+ count)))
        (error (message "Failed to export %s: %s" (file-name-nondirectory file) err))))
    (my/blog-gemini-generate-index)
    (message "Exported %d files to Gemini and generated index." count)))

(defun my/blog-gemini-generate-index ()
  "Generate index.gmi for Gemini capsule."
  (interactive)
  (let* ((posts-dir (expand-file-name "posts" my/blog-gemini-dir))
         (gmi-files (directory-files posts-dir t "\\.gmi$"))
         (entries '()))
    ;; 提取每篇文章的标题和日期
    (dolist (file gmi-files)
      (unless (string= (file-name-nondirectory file) "index.gmi")
        (with-temp-buffer
          (insert-file-contents file)
          (goto-char (point-min))
          (when (re-search-forward "^# \\(.+\\)" nil t)
            (let ((title (match-string 1))
                  (date ""))
              ;; 日期在标题的下一行
              (forward-line 1)
              (when (looking-at "\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\)")
                (setq date (match-string 1)))
              (push (list title (file-name-nondirectory file) date) entries))))))
    ;; 按日期降序排列
    (setq entries (sort entries (lambda (a b) (string> (nth 2 a) (nth 2 b)))))
    ;; 生成 posts/index.gmi
    (with-temp-file (expand-file-name "index.gmi" posts-dir)
      (insert "# 文章列表\n\n")
      (dolist (entry entries)
        (insert (format "=> %s %s\n" (nth 1 entry) (nth 0 entry))))
      (insert "\n=> /index.gmi 返回大厅\n"))
    ;; 生成根 index.gmi
    (with-temp-file (expand-file-name "index.gmi" my/blog-gemini-dir)
      (insert "# Cytrogen 的胶囊旅馆\n\n")
      (insert "欢迎入住。这里是领地在 Gemini 空间的一间胶囊旅馆,供旅人歇息、阅读。\n\n")
      (insert "## 近期文章\n\n")
      (let ((recent (seq-take entries 10)))
        (dolist (entry recent)
          (insert (format "=> /posts/%s %s\n" (nth 1 entry) (nth 0 entry)))))
      (insert "\n=> /posts/index.gmi 所有文章\n\n")
      (insert "## 导航\n\n")
      (insert "=> /about.gmi 关于旅馆主人\n")
      (insert "=> /feed.gmi 订阅\n")
      (insert "=> https://cytrogen.icu 前往领地(Web)\n"))
    ;; 生成 feed.gmi
    (with-temp-file (expand-file-name "feed.gmi" my/blog-gemini-dir)
      (insert "# Cytrogen 的胶囊旅馆\n\n")
      (dolist (entry entries)
        (let ((date (nth 2 entry)))
          (when (not (string-empty-p date))
            (insert (format "=> /posts/%s %s %s\n" (nth 1 entry) date (nth 0 entry)))))))
    (message "Generated capsule index + feed (%d posts)" (length entries))))

;;; Webmention support
(require 'webmention (expand-file-name "webmention.el" user-emacs-directory))

;;; Provide

(provide 'pkg-blog)