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