toc-org.el (20204B)
1 ;;; toc-org.el --- add table of contents to org-mode files (formerly, org-toc) 2 3 ;; Copyright (C) 2014 Sergei Nosov 4 5 ;; Author: Sergei Nosov <sergei.nosov [at] gmail.com> 6 ;; Version: 1.1 7 ;; Keywords: org-mode org-toc toc-org org toc table of contents 8 ;; URL: https://github.com/snosov1/toc-org 9 10 ;; This program is free software; you can redistribute it and/or modify 11 ;; it under the terms of the GNU General Public License as published by 12 ;; the Free Software Foundation, either version 3 of the License, or 13 ;; (at your option) any later version. 14 15 ;; This program is distributed in the hope that it will be useful, 16 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of 17 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 ;; GNU General Public License for more details. 19 20 ;; You should have received a copy of the GNU General Public License 21 ;; along with this program. If not, see <https://www.gnu.org/licenses/>. 22 23 ;;; Commentary: 24 25 ;; toc-org helps you to have an up-to-date table of contents in org or markdown 26 ;; files without exporting (useful primarily for readme files on GitHub). 27 28 ;; NOTE: Previous name of the package is org-toc. It was changed because of a 29 ;; name conflict with one of the org contrib modules. 30 31 ;; After installation put into your .emacs file something like 32 33 ;; (if (require 'toc-org nil t) 34 ;; (progn 35 ;; (add-hook 'org-mode-hook 'toc-org-mode) 36 ;; 37 ;; ;; enable in markdown, too 38 ;; (add-hook 'markdown-mode-hook 'toc-org-mode) 39 ;; (define-key markdown-mode-map (kbd "\C-c\C-o") 'toc-org-markdown-follow-thing-at-point)) 40 ;; (warn "toc-org not found")) 41 42 ;; And every time you'll be saving an org file, the first headline with a :TOC: 43 ;; tag will be updated with the current table of contents. 44 45 ;; For details, see https://github.com/snosov1/toc-org 46 47 ;;; Code: 48 49 (require 'org) 50 (require 'thingatpt) 51 52 (defgroup toc-org nil 53 "toc-org is a utility to have an up-to-date table of contents 54 in the org files without exporting (useful primarily for readme 55 files on GitHub)" 56 :group 'org) 57 58 ;; just in case, simple regexp "^*.*:toc:\\($\\|[^ ]*:$\\)" 59 (defconst toc-org-toc-org-regexp ".*?\\(<--\s+\\)?:toc\\([@_][0-9]\\|\\([@_][0-9][@_][a-zA-Z]+\\)\\)?:\\(\\(\s+-->\\)?$\\|[^ ]*?:\\(\s+-->\\)?$\\)" 60 "Regexp to find the heading with the :toc: tag. 61 It misses the heading symbol which must be added depending on the 62 markup style (org vs markdown).") 63 (defconst toc-org-quote-tag-regexp ":quote:\\(\\(\s+-->\\)?$\\|[^ ]*?:\\(\s+-->\\)?$\\)" 64 "Regexp to find the heading with the :quote: tag") 65 (defconst toc-org-noexport-regexp "\\(^*+\\)\s+.*:noexport\\([@_][0-9]\\)?:\\($\\|[^ ]*?:$\\)" 66 "Regexp to find the extended version of :noexport: tag") 67 (defconst toc-org-tags-regexp "\s*:[[:word:]:@_]*:\s*$" 68 "Regexp to find tags on the line") 69 (defconst toc-org-todo-custom-keywords-regexp "^#\\+\\(TODO\\|SEQ_TODO\\|TYP_TODO\\):\\(.*\\)$" 70 "Regexp to find custom TODO keywords") 71 (defconst toc-org-COMMENT-regexp "\\(^*+\\)\s+\\(COMMENT\s+\\)" 72 "Regexp to find COMMENT headlines") 73 (defconst toc-org-priorities-regexp "^*+\s+\\(\\[#.\\]\s+\\)" 74 "Regexp to find states on the line") 75 (defconst toc-org-links-regexp "\\[\\[\\(.*?\\)\\]\\[\\(.*?\\)\\]\\]" 76 "Regexp to find states on the line") 77 (defconst toc-org-special-chars-regexp "[^[:alnum:]_-]" 78 "Regexp with the special characters (which are omitted in hrefs 79 by GitHub)") 80 (defconst toc-org-statistics-cookies-regexp "\s*\\[[0-9]*\\(%\\|/[0-9]*\\)\\]\s*" 81 "Regexp to find statistics cookies on the line") 82 (defconst toc-org-leave-todo-regexp "^#\\+OPTIONS:.*\stodo:t[\s\n]" 83 "Regexp to find the todo export setting") 84 (defconst toc-org-drawer-regexp "^[ ]*:\\(\\(?:\\w\\|[-_]\\)+\\):[ ]*$" 85 "Regexp to match org drawers. Note: generally, it should be 86 equal to `org-drawer-regexp'. However, some older versions of 87 org (notably, 8.2.10) restrict the values that can be placed 88 between the colons. So, the value here is set explicitly.") 89 (defconst toc-org-markdown-link-regexp ;; copy-paste from markdown-mode 90 "\\(!\\)?\\(\\[\\)\\([^]^][^]]*\\|\\)\\(\\]\\)\\((\\)\\([^)]*?\\)\\(?:\\s-+\\(\"[^\"]*\"\\)\\)?\\()\\)" 91 "Regular expression for a [text](file) or an image link ![text](file). 92 Group 1 matches the leading exclamation point (optional). 93 Group 2 matches the opening square bracket. 94 Group 3 matches the text inside the square brackets. 95 Group 4 matches the closing square bracket. 96 Group 5 matches the opening parenthesis. 97 Group 6 matches the URL. 98 Group 7 matches the title (optional). 99 Group 8 matches the closing parenthesis.") 100 101 (defcustom toc-org-max-depth 2 102 "Maximum depth of the headings to use in the table of 103 contents. The default of 2 uses only the highest level headings 104 and their subheadings (one and two stars)." 105 :type 'integer 106 :group 'toc-org) 107 108 (defcustom toc-org-hrefify-default "gh" 109 "Default hrefify function to use." 110 :type 'string 111 :group 'toc-org) 112 113 (defcustom toc-org-enable-links-opening t 114 "With this option, org-open-at-point (C-c C-o) should work on 115 the TOC links (even if the style is different from org)." 116 :type 'boolean 117 :group 'toc-org) 118 119 (defvar-local toc-org-hrefify-hash nil 120 "Buffer local hash-table that is used to enable links 121 opening. The keys are hrefified headings, the values are original 122 headings.") 123 124 (defun toc-org-raw-toc (markdown-syntax-p) 125 "Return the \"raw\" table of contents of the current file, 126 i.e. simply flush everything that's not a heading and strip 127 auxiliary text." 128 (let ((content (buffer-substring-no-properties 129 (point-min) (point-max))) 130 (leave-states-p nil) 131 (custom-keywords nil) 132 (toc-org-states-regexp "")) 133 (with-temp-buffer 134 (insert content) 135 136 ;; preprocess markdown-style headings 137 (when markdown-syntax-p 138 (save-excursion 139 (let ((case-fold-search t)) 140 (goto-char (point-min)) 141 (while (re-search-forward "^#+ " nil t) 142 (replace-match (concat 143 (make-string (1- (length (match-string 0))) ?*) 144 " ") nil nil)) 145 (goto-char (point-min)) 146 (while (re-search-forward "\s+#+$" nil t) 147 (replace-match "" nil nil)) 148 (goto-char (point-min)) 149 (while (re-search-forward "\\(^*.*\\)<-- \\(:toc[^ ]*:\\) -->\\($\\)" nil t) 150 (replace-match (concat (match-string 1) (match-string 2) (match-string 3)) nil nil))))) 151 152 ;; set leave-states-p variable 153 (goto-char (point-min)) 154 (when (re-search-forward toc-org-leave-todo-regexp nil t) 155 (setq leave-states-p t)) 156 157 ;; set toc-org-states-regexp (after collecting custom keywords) 158 (goto-char (point-min)) 159 (while (re-search-forward toc-org-todo-custom-keywords-regexp nil t) 160 (setq custom-keywords (append custom-keywords (split-string (match-string 2) "[ \f\t\n\r\v|]+" t)))) 161 (if custom-keywords 162 (setq toc-org-states-regexp 163 (concat "^*+\s+\\(" 164 (mapconcat (lambda (x) (replace-regexp-in-string "(.+?)" "" x)) 165 custom-keywords "\s+\\|") 166 "\s+\\)")) 167 (setq toc-org-states-regexp "^*+\s+\\(TODO\s+\\|DONE\s+\\)")) 168 169 ;; keep only lines starting with *s 170 (goto-char (point-min)) 171 (keep-lines "^\*+[ ]") 172 173 ;; don't include the TOC itself 174 (goto-char (point-min)) 175 (re-search-forward (concat "^\\*" toc-org-toc-org-regexp) nil t) 176 (beginning-of-line) 177 (delete-region (point) (progn (forward-line 1) (point))) 178 179 ;; strip states 180 (unless leave-states-p 181 (goto-char (point-min)) 182 (while (re-search-forward toc-org-states-regexp nil t) 183 (replace-match "" nil nil nil 1))) 184 185 ;; strip COMMENT headlines 186 (goto-char (point-min)) 187 (let ((case-fold-search nil)) 188 (while (re-search-forward toc-org-COMMENT-regexp nil t) 189 (let ((skip-depth (concat (match-string 1) "*"))) 190 (while (progn 191 (beginning-of-line) 192 (delete-region (point) (min (1+ (line-end-position)) (point-max))) 193 (string-prefix-p skip-depth (or (current-word) ""))))))) 194 195 ;; strip headings with :noexport: tag 196 (goto-char (point-min)) 197 (while (re-search-forward toc-org-noexport-regexp nil t) 198 (save-excursion 199 (let* ((tag (match-string 2)) 200 (depth (if tag (string-to-number (substring tag 1)) 0)) 201 (subheading-depth (concat (match-string 1) "*")) 202 (skip-depth (concat subheading-depth (make-string (max (1- depth) 0) ?*)))) 203 (if (> depth 0) 204 (forward-line) 205 (beginning-of-line) 206 (delete-region (point) (min (1+ (line-end-position)) (point-max)))) 207 (while (string-prefix-p subheading-depth (or (current-word) "")) 208 (if (string-prefix-p skip-depth (or (current-word) "")) 209 (progn 210 (beginning-of-line) 211 (delete-region (point) (min (1+ (line-end-position)) (point-max)))) 212 (forward-line)))))) 213 214 ;; strip priorities 215 (goto-char (point-min)) 216 (while (re-search-forward toc-org-priorities-regexp nil t) 217 (replace-match "" nil nil nil 1)) 218 219 ;; strip tags 220 (goto-char (point-min)) 221 (while (re-search-forward toc-org-tags-regexp nil t) 222 (replace-match "" nil nil)) 223 224 ;; flatten links 225 (goto-char (point-min)) 226 (while (re-search-forward toc-org-links-regexp nil t) 227 (replace-match "\\2" nil nil)) 228 229 (buffer-substring-no-properties 230 (point-min) (point-max))))) 231 232 (defun toc-org-hrefify-gh (str &optional hash) 233 "Given a heading, transform it into a href using the GitHub 234 rules." 235 (let* ((spc-fix (replace-regexp-in-string " " "-" str)) 236 (upcase-fix (downcase spc-fix)) 237 (special-chars-fix (replace-regexp-in-string toc-org-special-chars-regexp "" upcase-fix t)) 238 (hrefified-base (concat "#" special-chars-fix)) 239 (hrefified hrefified-base) 240 (idx 0)) 241 ;; try appending -1, -2, -3, etc. until unique href is found 242 (when hash 243 (while (gethash hrefified hash) 244 (setq hrefified 245 (concat hrefified-base "-" (number-to-string (setq idx (1+ idx))))))) 246 hrefified)) 247 248 (defun toc-org-format-visible-link (str) 249 "Formats the visible text of the link." 250 (with-temp-buffer 251 (insert str) 252 253 ;; strip statistics cookies 254 (goto-char (point-min)) 255 (while (re-search-forward toc-org-statistics-cookies-regexp nil t) 256 (replace-match "" nil nil)) 257 (buffer-substring-no-properties 258 (point-min) (point-max)))) 259 260 (defun toc-org-hrefify-org (str &optional hash) 261 "Given a heading, transform it into a href using the org-mode 262 rules." 263 (toc-org-format-visible-link str)) 264 265 (defun toc-org-unhrefify (type path) 266 "Looks for a value in toc-org-hrefify-hash using path as a key." 267 (let ((ret-type type) 268 (ret-path path) 269 (original-path (and (not (eq toc-org-hrefify-hash nil)) 270 (gethash 271 (concat 272 ;; Org 8.3 and above provides type as "custom-id" 273 ;; and strips the leading hash symbol 274 (if (equal type "custom-id") "#" "") 275 (substring-no-properties path)) 276 toc-org-hrefify-hash 277 nil)))) 278 (when toc-org-enable-links-opening 279 (when original-path 280 ;; Org 8.2 and below provides type as "thisfile" 281 (when (equal type "thisfile") 282 (setq ret-path original-path)) 283 (when (equal type "custom-id") 284 (setq ret-type "fuzzy") 285 (setq ret-path original-path)))) 286 (cons ret-type ret-path))) 287 288 (defun toc-org-hrefify-toc (toc hrefify markdown-syntax-p &optional hash) 289 "Format the raw `toc' using the `hrefify' function to transform 290 each heading into a link." 291 (with-temp-buffer 292 (insert toc) 293 (goto-char (point-min)) 294 (while 295 (progn 296 (when (looking-at "\\*") 297 (delete-char 1) 298 299 (while (looking-at "\\*") 300 (delete-char 1) 301 (insert (make-string 302 (+ 2 (or (bound-and-true-p org-list-indent-offset) 0)) 303 ?\s))) 304 305 (insert "-") 306 (skip-chars-forward " ") 307 308 (save-excursion 309 (delete-trailing-whitespace (point) (line-end-position))) 310 311 (let* ((beg (point)) 312 (end (line-end-position)) 313 (heading (buffer-substring-no-properties 314 beg end)) 315 (hrefified (funcall hrefify heading hash)) 316 (visible-link (toc-org-format-visible-link heading))) 317 318 (if markdown-syntax-p 319 (progn 320 (insert "[") 321 (insert visible-link) 322 (delete-region (point) (line-end-position)) 323 (insert "]") 324 (insert "(") 325 (insert hrefified) 326 (insert ")")) 327 (insert "[[") 328 (insert hrefified) 329 (insert "][") 330 (insert visible-link) 331 (delete-region (point) (line-end-position)) 332 (insert "]]")) 333 334 ;; maintain the hash table, if provided 335 (when hash 336 (puthash hrefified visible-link hash))) 337 (= 0 (forward-line 1))))) 338 339 (buffer-substring-no-properties 340 (point-min) (point-max)))) 341 342 (defun toc-org-flush-subheadings (toc max-depth) 343 "Flush subheadings of the raw `toc' deeper than `max-depth'." 344 (with-temp-buffer 345 (insert toc) 346 (goto-char (point-min)) 347 348 (let ((re "^")) 349 (dotimes (i (1+ max-depth)) 350 (setq re (concat re "\\*"))) 351 (flush-lines re)) 352 353 (buffer-substring-no-properties 354 (point-min) (point-max)))) 355 356 (defun toc-org-insert-toc (&optional dry-run) 357 "Update table of contents in heading tagged :TOC:. 358 359 When DRY-RUN is non-nil, the buffer is not modified, only the 360 internal hash-table is updated to enable `org-open-at-point' for 361 TOC links. 362 363 The table of contents heading may also be set with these tags: 364 365 - :TOC_#: Sets the maximum depth of the headlines in the table of 366 contents to the number given, e.g. :TOC_3: for 367 3 (default for plain :TOC: tag is 2). 368 369 - :TOC_#_gh: Sets the maximum depth as above and also uses 370 GitHub-style anchors in the table of contents (the 371 default). The other supported style is :TOC_#_org:, 372 which is the default org style. 373 374 Headings may be excluded from the TOC with these tags: 375 376 - :noexport: Exclude this heading. 377 378 - :noexport_#: Exclude this heading's children with relative 379 level greater than number given (e.g. :noexport_1: 380 causes all child headings to be excluded). 381 382 Note that :noexport: is also used by Org-mode's exporter, but 383 not :noexport_#:." 384 385 (interactive) 386 (save-excursion 387 (goto-char (point-min)) 388 (let* ((case-fold-search t) 389 (markdown-syntax-p (derived-mode-p 'markdown-mode)) 390 (heading-symbol-regexp (if markdown-syntax-p "^#" "^\\*"))) 391 ;; find the first heading with the :TOC: tag 392 (when (re-search-forward (concat heading-symbol-regexp toc-org-toc-org-regexp) (point-max) t) 393 (let* ((tag (match-string 2)) 394 (depth (if tag 395 (- (aref tag 1) ?0) ;; is there a better way to convert char to number? 396 toc-org-max-depth)) 397 (hrefify-tag (if (and tag (>= (length tag) 4)) 398 (downcase (substring tag 3)) 399 toc-org-hrefify-default)) 400 (hrefify-string (concat "toc-org-hrefify-" hrefify-tag)) 401 (hrefify (intern-soft hrefify-string)) 402 (put-quote (save-match-data (string-match toc-org-quote-tag-regexp (match-string 0)))) 403 (toc-prefix (if put-quote (if markdown-syntax-p "```\n" "#+BEGIN_QUOTE\n") "")) 404 (toc-suffix (if put-quote (if markdown-syntax-p "```\n" "#+END_QUOTE\n") ""))) 405 (if hrefify 406 (let ((new-toc 407 (concat toc-prefix 408 (toc-org-hrefify-toc 409 (toc-org-flush-subheadings (toc-org-raw-toc markdown-syntax-p) depth) 410 hrefify 411 markdown-syntax-p 412 (when toc-org-hrefify-hash 413 (clrhash toc-org-hrefify-hash))) 414 toc-suffix))) 415 (unless dry-run 416 (newline (forward-line 1)) 417 418 ;; skip drawers 419 (let ((end 420 (save-excursion ;; limit to next heading 421 (search-forward-regexp heading-symbol-regexp (point-max) 'skip)))) 422 (while (re-search-forward toc-org-drawer-regexp end t) 423 (skip-chars-forward "[:space:]"))) 424 (beginning-of-line) 425 426 ;; insert newline if TOC is currently empty 427 (when (looking-at heading-symbol-regexp) 428 (open-line 1)) 429 430 ;; find TOC boundaries 431 (let ((beg (point)) 432 (end 433 (save-excursion 434 (when (search-forward-regexp heading-symbol-regexp (point-max) 'skip) 435 (forward-line -1)) 436 (end-of-line) 437 (point)))) 438 ;; update the TOC, but only if it's actually different 439 ;; from the current one 440 (unless (equal 441 (buffer-substring-no-properties beg end) 442 new-toc) 443 (delete-region beg end) 444 (insert new-toc))))) 445 (message (concat "Hrefify function " hrefify-string " is not found")))))))) 446 447 (defun toc-org-follow-markdown-link () 448 "Follow the markdown link (mimics `org-open-at-point')" 449 (interactive) 450 (when (thing-at-point-looking-at toc-org-markdown-link-regexp) 451 (let ((pos (point))) 452 (goto-char (point-min)) 453 (if (re-search-forward (concat "^#+\s+" (match-string-no-properties 3)) (point-max) t) 454 (beginning-of-line) 455 (goto-char pos))))) 456 457 (defun toc-org-markdown-follow-thing-at-point (arg) 458 "Try to follow the link with `toc-org-follow-markdown-link', 459 fallback to `markdown-follow-thing-at-point' on failure" 460 (interactive "P") 461 (let ((pos (point))) 462 (toc-org-follow-markdown-link) 463 (when (and (equal pos (point)) (fboundp 'markdown-follow-thing-at-point)) 464 (markdown-follow-thing-at-point arg)))) 465 466 ;;;###autoload 467 (defun toc-org-enable () 468 "Enable toc-org in this buffer." 469 (add-hook 'before-save-hook 'toc-org-insert-toc nil t) 470 471 ;; conservatively set org-link-translation-function 472 (when (and (equal toc-org-enable-links-opening t) 473 (or 474 (not (fboundp org-link-translation-function)) 475 (equal org-link-translation-function 'toc-org-unhrefify))) 476 (setq toc-org-hrefify-hash (make-hash-table :test 'equal)) 477 (setq org-link-translation-function 'toc-org-unhrefify) 478 (toc-org-insert-toc t))) 479 480 ;;;###autoload 481 (define-minor-mode toc-org-mode 482 "Toggle `toc-org' in this buffer." 483 :group toc-org 484 (if toc-org-mode 485 (toc-org-enable) 486 (remove-hook 'before-save-hook 'toc-org-insert-toc t) 487 ;; we would've set `org-link-translation-function' only if it's been nil 488 (when (equal org-link-translation-function 'toc-org-unhrefify) 489 (setq org-link-translation-function nil)))) 490 491 ;; Local Variables: 492 ;; compile-command: "emacs -batch -l ert -l toc-org.el -l toc-org-test.el -f ert-run-tests-batch-and-exit && emacs -batch -f batch-byte-compile toc-org.el 2>&1 | sed -n '/Warning\|Error/p' | xargs -r ls" 493 ;; End: 494 495 (provide 'toc-org) 496 ;;; toc-org.el ends here