~cytrogen/.emacs.d

.emacs.d/config/pkg-org.el -rw-r--r-- 24.1 KiB
e2b51cc0 — Cytrogen chore: 更新 gitignore 和数据文件 a month ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
;;; 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