;;; pkg-org.el --- Org-mode configuration and dynamic system -*- lexical-binding: t -*- ;; Copyright (C) 2026 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)) ;; --- 课程笔记 Capture --- (defvar my/class-notes-date nil "临时存储日期,供模板使用") (defun my/get-class-subjects () "获取 reading.org 中 课程笔记 下的所有科目。" (let ((reading-file (expand-file-name "reading.org" org-directory)) (subjects '())) (when (file-exists-p reading-file) (with-temp-buffer (insert-file-contents reading-file) (goto-char (point-min)) (when (re-search-forward "^\\* 课程笔记" nil t) (let ((end (save-excursion (or (re-search-forward "^\\* " nil t) (point-max))))) (while (re-search-forward "^\\*\\* \\(.+\\)" end t) (push (match-string-no-properties 1) subjects)))))) (nreverse subjects))) (defun my/class-notes-goto-subject () "提示选择科目和日期,定位到科目标题下。" (let* ((subjects (my/get-class-subjects)) (subject (completing-read "科目: " subjects nil nil)) (date (read-string "日期: " (format-time-string "%Y-%m-%d")))) (setq my/class-notes-date date) (goto-char (point-min)) (if (re-search-forward "^\\* 课程笔记" nil t) (let ((subtree-end (save-excursion (org-end-of-subtree t) (point)))) (if (re-search-forward (format "^\\*\\* %s$" (regexp-quote subject)) subtree-end t) (beginning-of-line) ;; 科目不存在,在子树末尾创建 (goto-char subtree-end) (insert "\n** " subject) (beginning-of-line))) ;; 课程笔记也不存在 (goto-char (point-max)) (insert "\n* 课程笔记\n** " subject) (beginning-of-line)))) ;; --- 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) ;; --- 数学公式显示配置 --- ;; 启用 pretty entities 显示上标/下标 (setq org-pretty-entities t) ;; 可选:只在需要时显示(光标不在实体上时) (setq org-pretty-entities-include-sub-superscripts t) ;; --- 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) ;; --- Link Configuration --- ;; 按 RET 时直接打开链接 (setq org-return-follows-link t) ;; --- Org-Babel Configuration --- ;; 启用语言支持,实现类似 Jupyter Notebook 的体验 (org-babel-do-load-languages 'org-babel-load-languages '((shell . t) ; 用于编译和运行命令 (emacs-lisp . t))) ; 默认支持 ;; 执行代码块时不询问确认(方便快速执行) (setq org-confirm-babel-evaluate 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)) ;; 替换课程笔记模板为使用 file+function (setq org-capture-templates (mapcar (lambda (tpl) (if (equal (car tpl) "Rc") `("Rc" "课程笔记" entry (file+function ,(expand-file-name "reading.org" org-directory) my/class-notes-goto-subject) "*** %(symbol-value 'my/class-notes-date)\n%?" :empty-lines 1) tpl)) org-capture-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)) ;; 中文行内标记支持 ;; 允许中文字符紧贴 org 强调标记符(*粗体*、/斜体/ 等),无需额外空格 (with-eval-after-load 'org (setcar org-emphasis-regexp-components "-[:space:]('\"{\x200B[:nonascii:]") (setcar (nthcdr 1 org-emphasis-regexp-components) "-[:space:].,:!?;'\")}\\[\x200B[:nonascii:]") (org-set-emph-re 'org-emphasis-regexp-components org-emphasis-regexp-components) ;; 使用 _{xxx}_ 语法时,防止下划线被解释为下标 (setq org-use-sub-superscripts '{}) (setq org-export-with-sub-superscripts '{})) ;;; ---- 下划线语法迁移 ---- (defun my/migrate-underline-syntax (&optional dry-run) "Migrate _xxx_ to _{xxx}_ in all org files under `org-directory'. With prefix argument (C-u), perform a DRY-RUN showing changes without modifying files." (interactive "P") (let ((files (directory-files-recursively org-directory "\\.org$")) (total-replacements 0) (modified-files '()) ;; Regex matching Org emphasis _xxx_ pattern ;; Matches _ + non-space/non-underscore + optional middle + non-space/non-underscore + _ ;; but NOT already in _{xxx}_ form (underline-re "_\\([^ _\n]\\(?:[^_\n]*[^ _\n]\\)?\\)_")) (dolist (file files) (with-temp-buffer (insert-file-contents file) (let ((file-replacements 0) (regions-to-skip '())) ;; Collect regions to skip: src blocks, example blocks, property drawers (goto-char (point-min)) (while (re-search-forward "^[ \t]*#\\+BEGIN_\\(SRC\\|EXAMPLE\\)\\b.*\n" nil t) (let ((beg (match-beginning 0))) (when (re-search-forward (format "^[ \t]*#\\+END_%s" (match-string 1)) nil t) (push (cons beg (line-end-position)) regions-to-skip)))) (goto-char (point-min)) (while (re-search-forward "^[ \t]*:PROPERTIES:" nil t) (let ((beg (match-beginning 0))) (when (re-search-forward "^[ \t]*:END:" nil t) (push (cons beg (line-end-position)) regions-to-skip)))) ;; Collect inline code =...= and ~...~ (goto-char (point-min)) (while (re-search-forward "\\(?:=\\([^=\n]+\\)=\\)\\|\\(?:~\\([^~\n]+\\)~\\)" nil t) (push (cons (match-beginning 0) (match-end 0)) regions-to-skip)) ;; Collect links [[...]] (goto-char (point-min)) (while (re-search-forward "\\[\\[\\([^]]*\\)\\]\\(?:\\[\\([^]]*\\)\\]\\)?\\]" nil t) (push (cons (match-beginning 0) (match-end 0)) regions-to-skip)) ;; Collect #+KEYWORD: lines (goto-char (point-min)) (while (re-search-forward "^[ \t]*#\\+[A-Z_]+:.*$" nil t) (push (cons (match-beginning 0) (match-end 0)) regions-to-skip)) ;; Now do replacements, skipping protected regions (goto-char (point-min)) (while (re-search-forward underline-re nil t) (let ((pos (match-beginning 0)) (skip nil)) ;; Check if inside a skip region (dolist (region regions-to-skip) (when (and (>= pos (car region)) (<= pos (cdr region))) (setq skip t))) ;; Skip if already in _{...}_ form (preceded by _ and { ) (when (and (not skip) (> pos 0) (eq (char-before (match-beginning 0)) ?{)) (setq skip t)) (unless skip (cl-incf file-replacements) (unless dry-run (replace-match "_{\\1}_"))))) (when (> file-replacements 0) (cl-incf total-replacements file-replacements) (push (cons file file-replacements) modified-files) (unless dry-run (write-region (point-min) (point-max) file)))))) ;; Report results (if (= total-replacements 0) (message "No _xxx_ patterns found to migrate.") (with-current-buffer (get-buffer-create "*underline-migration*") (erase-buffer) (insert (format "%s — %d replacements in %d files:\n\n" (if dry-run "DRY RUN" "MIGRATED") total-replacements (length modified-files))) (dolist (entry (nreverse modified-files)) (insert (format " %s: %d\n" (car entry) (cdr entry)))) (display-buffer (current-buffer)))))) (provide 'pkg-org) ;;; pkg-org.el ends here