;;; pkg-org.el --- Org-mode configuration and dynamic system -*- lexical-binding: t -*-
;; Copyright (C) 2024 Cytrogen
;; This file contains:
;; - Org directory setup
;; - Dynamic structure system for Capture and Refile
;; - Agenda and workflow configurations
;; - Org-mode customizations
;;; Commentary:
;; Complete Org-mode configuration including the custom dynamic structure
;; system that provides flexible capture templates and refile targets.
;;; Code:
;; Org Directory Configuration
;; Dynamic path setup with user interaction
(defvar my/org-path-config-file (concat user-emacs-directory ".org-path")
"File to store the user's Org directory path.")
(defun my/setup-org-directory ()
"Load Org directory from config file or prompt user to select one."
(let ((path nil))
;; 1. Try to read from file
(when (file-exists-p my/org-path-config-file)
(with-temp-buffer
(insert-file-contents my/org-path-config-file)
(setq path (string-trim (buffer-string)))))
;; 2. Validate path, if invalid/missing, prompt user
(unless (and path (file-directory-p path))
(setq path (read-directory-name "请选择您的 Org 笔记根目录 (Please select Org root): " "D:/"))
(unless (file-directory-p path)
(make-directory path t))
;; Save for next time
(with-temp-file my/org-path-config-file
(insert path)))
;; 3. Apply setting
(setq org-directory (file-name-as-directory path))
(setq org-default-notes-file (concat org-directory "inbox.org"))
(message "Org Directory loaded: %s" org-directory)))
;; Execute setup immediately
(my/setup-org-directory)
;; Set agenda files to include all org files in the directory
(setq org-agenda-files
(append (list org-directory)
(when (file-exists-p org-directory)
(directory-files org-directory t "\\.org$"))))
;; Ensure Org directories exist immediately to prevent crashes during config load
(let ((notes-dir (concat org-directory "notes/")))
(unless (file-exists-p notes-dir)
(make-directory notes-dir t)
(message "Created missing directory: %s" notes-dir)))
;; Dynamic Structure System
;; Flexible capture and refile system
(defvar my/org-structure-file (expand-file-name "org-structure.org" user-emacs-directory)
"Path to the Org file defining capture templates and refile targets.")
(defvar my/parsed-org-structure nil
"Cached structure parsed from my/org-structure-file.")
(defvar my/org-structure-loading nil
"Flag to prevent infinite loading loops.")
(defun my/parse-org-properties-at-point ()
"Parse properties for the current headline without using org-entry-properties."
(save-excursion
(let ((props '())
(case-fold-search t))
;; Look for properties drawer after the headline
(when (re-search-forward ":PROPERTIES:" (line-end-position 10) t)
(let ((props-start (point)))
(when (re-search-forward "^[ \t]*:END:" nil t)
(let ((props-end (match-beginning 0)))
(goto-char props-start)
(while (re-search-forward "^[ \t]*:\\([^:]+\\):[ \t]*\\(.*\\)$" props-end t)
(push (cons (match-string 1) (match-string 2)) props))))))
props)))
(defun my/load-org-structure ()
"Parse the structure file into a list of categories.
Format: ((key label file ((subkey sublabel headline template override-file) ...)) ...)"
(interactive)
(when my/org-structure-loading
(message "Warning: Structure loading already in progress, skipping...")
(cl-return-from my/load-org-structure nil))
(if (not (file-exists-p my/org-structure-file))
(message "Org structure file not found: %s" my/org-structure-file)
(setq my/org-structure-loading t)
(unwind-protect
(with-temp-buffer
(let ((coding-system-for-read 'utf-8))
(insert-file-contents my/org-structure-file))
(let ((result '())
(current-main nil))
(goto-char (point-min))
(while (re-search-forward "^\\(\\*+\\) \\(.*\\)" nil t)
(let* ((level (length (match-string 1)))
(title (match-string 2))
(props (my/parse-org-properties-at-point)))
(cond
((= level 1)
(let ((key (cdr (assoc "KEY" props)))
(file (cdr (assoc "FILE" props))))
(if (and key file (not (string-empty-p key)) (not (string-empty-p file)))
(progn
(setq current-main (list key title file '()))
(push current-main result))
;; Debug: 跳过无效的一级标题
(message "Skipping invalid level-1 heading: %s (missing KEY or FILE)" title))))
((and (= level 2) current-main)
(let* ((key (cdr (assoc "KEY" props)))
(headline (cdr (assoc "HEADLINE" props)))
(raw-template (cdr (assoc "TEMPLATE" props)))
;; Convert literal \n to actual newlines
(template (when raw-template
(replace-regexp-in-string (regexp-quote "\\n") "\n" raw-template)))
(file-override (cdr (assoc "FILE" props)))
(type-str (cdr (assoc "TYPE" props)))
(type (if (and type-str (not (string-empty-p type-str)))
(intern type-str)
'entry))
(sub-list (nth 3 current-main)))
(when key
(let ((entry (list key title headline template file-override type)))
(setf (nth 3 current-main) (append sub-list (list entry))))))))))
(setq my/parsed-org-structure (reverse result))
(message "Org structure loaded with %d main categories." (length result))))
(setq my/org-structure-loading nil))))
;; Org Workflow Configuration
;; Agenda, refile, and capture setup
(with-eval-after-load 'org
;; --- Custom Agenda Views ---
;; 将"想读"的书转为"正在读"(创建书籍笔记并删除原条目)
(defun my/reading-start-book ()
"Start reading a book: create a note entry and remove from wishlist."
(interactive)
(org-agenda-check-type t 'agenda 'tags)
(let* ((marker (or (org-get-at-bol 'org-marker)
(org-agenda-error)))
(buffer (marker-buffer marker))
(pos (marker-position marker))
book-name)
;; 获取书名
(with-current-buffer buffer
(goto-char pos)
(setq book-name (org-get-heading t t t t)))
;; 确认操作
(when (y-or-n-p (format "开始阅读《%s》?" book-name))
;; 删除原条目
(org-agenda-kill)
;; 创建书籍笔记
(let ((org-capture-templates
`(("b" "书籍笔记" entry
(file+headline ,(expand-file-name "reading.org" org-directory) "书籍笔记")
,(format "** 《%s》笔记 %%^{序号}: %%^{标题}\n%%U\n\n%%?" book-name)))))
(org-capture nil "b")))))
;; 将"正在读"的书标记为"读完"(创建读后感)
(defun my/reading-finish-book ()
"Finish reading a book: create a review entry."
(interactive)
(org-agenda-check-type t 'agenda 'tags)
(let* ((marker (or (org-get-at-bol 'org-marker)
(org-agenda-error)))
(buffer (marker-buffer marker))
(pos (marker-position marker))
book-name)
;; 从标题中提取书名(格式:《书名》笔记 X: 标题)
(with-current-buffer buffer
(goto-char pos)
(let ((heading (org-get-heading t t t t)))
(if (string-match "《\\([^》]+\\)》" heading)
(setq book-name (match-string 1 heading))
(setq book-name (read-string "书名: ")))))
;; 创建读后感
(let ((org-capture-templates
`(("d" "读后感" entry
(file+headline ,(expand-file-name "reading.org" org-directory) "读完")
,(format "** 《%s》读后感\n:PROPERTIES:\n:FINISHED: %%U\n:END:\n\n%%?" book-name)))))
(org-capture nil "d"))))
;; 辅助函数:跳过不在指定父标题下的条目
(defun my/org-agenda-skip-unless-parent (parent-headline)
"Skip entry unless its parent headline matches PARENT-HEADLINE."
(let ((dominated nil))
(save-excursion
(while (and (not dominated) (org-up-heading-safe))
(when (string= (org-get-heading t t t t) parent-headline)
(setq dominated t))))
(if dominated
nil ; 不跳过
(save-excursion (org-end-of-subtree t))))) ; 跳过
(setq org-agenda-custom-commands
'(;; Reading lists
("r" . "Reading (书单)")
("rw" "想读的书" tags "LEVEL=2"
((org-agenda-files (list (expand-file-name "reading.org" org-directory)))
(org-agenda-overriding-header "想读的书")
(org-agenda-prefix-format " ")
(org-agenda-skip-function '(my/org-agenda-skip-unless-parent "想读"))))
("rb" "正在读 (书籍笔记)" tags "LEVEL=2"
((org-agenda-files (list (expand-file-name "reading.org" org-directory)))
(org-agenda-overriding-header "正在读的书")
(org-agenda-prefix-format " ")
(org-agenda-skip-function '(my/org-agenda-skip-unless-parent "书籍笔记"))))
("rd" "读完的书" tags "LEVEL=2"
((org-agenda-files (list (expand-file-name "reading.org" org-directory)))
(org-agenda-overriding-header "读完的书")
(org-agenda-prefix-format " ")
(org-agenda-skip-function '(my/org-agenda-skip-unless-parent "读完"))))
;; Inbox items
("i" . "Inbox")
("is" "分享项目" tags "LEVEL=2"
((org-agenda-files (list (expand-file-name "inbox.org" org-directory)))
(org-agenda-overriding-header "分享的项目/工具")
(org-agenda-prefix-format " ")
(org-agenda-skip-function '(my/org-agenda-skip-unless-parent "分享"))))))
;; Agenda 快捷键:书单操作
(with-eval-after-load 'org-agenda
(define-key org-agenda-mode-map (kbd "S") #'my/reading-start-book)
(define-key org-agenda-mode-map (kbd "F") #'my/reading-finish-book))
;; --- Refile Configuration ---
;; 强化 Refile 功能,允许将条目移动到项目或分类的具体标题下
(setq org-refile-use-outline-path 'file)
(setq org-outline-path-complete-in-steps nil)
(setq org-refile-allow-creating-parent-nodes 'confirm)
;; --- Source Block Editing Configuration ---
;; 让 TAB 在源代码块中按语言原生方式工作
(setq org-src-tab-acts-natively t)
;; 保留源代码块的缩进,不自动调整
(setq org-src-preserve-indentation t)
;; 源代码块内容不额外缩进(相对于 #+BEGIN_SRC)
(setq org-edit-src-content-indentation 0)
;; 在当前窗口编辑源代码块(避免分割窗口)
(setq org-src-window-setup 'current-window)
;; 禁用返回编辑 buffer 时的确认提示
(setq org-src-ask-before-returning-to-edit-buffer nil))
;; Custom Org Functions
;; Helper functions for enhanced workflow
(defun my/open-org-file ()
"Open an org file from the org directory."
(interactive)
(let ((file (read-file-name "Open Org file: " org-directory nil t)))
(find-file file)))
(defun my/reload-org-structure ()
"Force reload the org structure file."
(interactive)
(setq my/parsed-org-structure nil)
(my/load-org-structure))
(defun my/apply-org-structure ()
"Generate capture templates from the parsed structure."
(interactive)
(unless my/parsed-org-structure
(my/load-org-structure))
(let ((templates '()))
(dolist (main my/parsed-org-structure)
(let ((main-key (nth 0 main))
(main-label (nth 1 main))
(main-file (nth 2 main))
(sub-cats (nth 3 main)))
;; 只添加有效的一级菜单项(有子分类的)
(when sub-cats
(push (list main-key main-label) templates))
;; 添加二级模板项(使用组合键)
(dolist (sub sub-cats)
(let* ((sub-key (nth 0 sub))
(combined-key (concat main-key sub-key))
(label (nth 1 sub))
(headline (nth 2 sub))
(template (nth 3 sub))
(file-ov (nth 4 sub))
(type (or (nth 5 sub) 'entry))
(target-file (expand-file-name (or file-ov main-file) org-directory))
(capture-target
(cond
((and headline (string= headline "datetree"))
(list 'file+olp+datetree target-file))
((and headline (not (string-empty-p headline)))
(list 'file+headline target-file headline))
(t
(list 'file target-file)))))
(unless template
(setq template (if (eq type 'item)
"- %?"
"* TODO %?\n %U")))
(push (list combined-key label type capture-target template) templates)))))
(setq org-capture-templates (reverse templates))
(message "Org capture templates updated.")))
(defun my/quick-refile ()
"Interactive refile using the dynamic org structure with improved interface.
Step 1: Choose Main Category (File) using completing-read.
Step 2: Choose Sub Category (Context) using completing-read.
- If Sub Category has a specific HEADLINE, move there.
- If HEADLINE is empty/nil, perform interactive org-refile in that file."
(interactive)
(unless my/parsed-org-structure
(my/load-org-structure))
;; Debug: 显示解析结果
(message "Parsed structure has %d categories" (length my/parsed-org-structure))
(if (= 0 (length my/parsed-org-structure))
(message "No categories found! Structure file might have parsing issues.")
(progn
(unless (org-region-active-p)
(org-back-to-heading t))
;; Step 1: Select Main Category using completing-read
(let* ((main-alist (mapcar (lambda (cat)
(cons (format "[%s] %s" (nth 0 cat) (nth 1 cat)) cat))
my/parsed-org-structure))
(main-selection (completing-read "Refile to category (↑↓ navigate, TAB complete): " main-alist nil t))
(selected-main (cdr (assoc main-selection main-alist))))
(if (not selected-main)
(message "No category selected.")
;; Step 2: Select Sub Category using completing-read
(let* ((sub-cats (nth 3 selected-main))
(sub-alist (mapcar (lambda (sub)
(cons (format "[%s] %s" (nth 0 sub) (nth 1 sub)) sub))
sub-cats))
(sub-selection (completing-read "Refile to target (↑↓ navigate, TAB complete): " sub-alist nil t))
(selected-sub (cdr (assoc sub-selection sub-alist))))
(if (not selected-sub)
(message "No target selected.")
;; Perform Refile Action
(let* ((main-file (nth 2 selected-main))
(headline (nth 2 selected-sub))
(file-ov (nth 4 selected-sub))
(target-file (expand-file-name (or file-ov main-file) org-directory)))
(unless (file-exists-p target-file)
(make-directory (file-name-directory target-file) t)
(with-temp-file target-file (insert "#+TITLE: " (file-name-base target-file) "\n")))
(if (and headline (not (string-empty-p headline)))
;; Case A: Direct Refile to specific headline
(if (string= headline "datetree")
(org-refile nil nil (list "Datetree" target-file nil nil))
;; Standard Headline Refile
(let ((pos (save-excursion
(with-current-buffer (find-file-noselect target-file)
(org-find-exact-headline-in-buffer headline)))))
(if pos
(progn
(org-refile nil nil (list headline target-file nil pos))
(message "Refiled to %s > %s" (file-name-base target-file) headline))
;; Headline missing: create it or fail? Let's fail gracefully.
(message "Heading '%s' not found in %s. Please create it first." headline (file-name-base target-file)))))
;; Case B: Interactive Refile in Target File (HEADLINE is empty)
(let ((org-refile-targets `((,target-file :maxlevel . 3))))
(call-interactively 'org-refile)))))))))))
;; Auto-load structure on startup
(with-eval-after-load 'org
(my/apply-org-structure))
(provide 'pkg-org)
;;; pkg-org.el ends here