summary refs log tree commit diff stats
path: root/lisp/org-word-count.el
diff options
context:
space:
mode:
Diffstat (limited to 'lisp/org-word-count.el')
-rw-r--r--lisp/org-word-count.el297
1 files changed, 297 insertions, 0 deletions
diff --git a/lisp/org-word-count.el b/lisp/org-word-count.el new file mode 100644 index 0000000..d6d2598 --- /dev/null +++ b/lisp/org-word-count.el
@@ -0,0 +1,297 @@
1;;; org-word-count.el --- org-word-count in the modeline -*- lexical-binding: t; -*-
2
3;;; Commentary:
4
5;;; Code:
6
7(require 'org)
8(require 'cl-lib)
9
10(defgroup org-word-count nil
11 "Extra fast word-counting in `org-mode'."
12 :group 'org)
13
14(defvar-local org-word-count-word-count nil
15 "Running total of words in this buffer.")
16
17(defvar-local org-word-count-string nil
18 "String for the modeline.")
19
20(defcustom org-word-count-format "%sw "
21 "Format for org word count in modeline."
22 :type 'string)
23
24(defcustom org-word-count-huge-string "huge"
25 "String to display with a huge buffer."
26 :type 'string)
27
28(defcustom org-word-count-update-after-funcs '(org-narrow-to-subtree
29 org-narrow-to-block
30 org-narrow-to-element
31 org-capture-narrow)
32 "Functions after which to update the word count."
33 :type '(repeat function))
34
35(defcustom org-word-count-deletion-idle-timer 0.25
36 "Length of time, in seconds, to wait before updating word-count."
37 :type 'number)
38
39(defcustom org-word-count-huge-change 5000
40 "Number of characters that constitute a \"huge\" insertion."
41 :type 'number)
42
43(defcustom org-word-count-huge-buffer 10000
44 "Number of words past which we're not going to try to count."
45 :type 'number)
46
47(defvar org-word-count-correction -5
48 "Number to add to `org-word-count-word-count', for some reason?
49`org-word-count-word-count' seems to consistently be off by 5. Thus
50this correction. (At some point I should correct the underlying
51code... probably).")
52
53(defvar-local org-word-count-update-timer nil)
54
55;;; Variables from org-wc
56
57(defun org-word-count-list-of-strings-p (arg)
58 (cl-every #'stringp arg))
59
60(defun org-word-count--downcase-list-of-strings-set-default (var val)
61 (set-default var (mapcar #'downcase val)))
62
63(defcustom org-word-count-ignored-tags '("nowc" "noexport" "ARCHIVE")
64 "List of tags for which subtrees will be ignored in word counts"
65 :type '(repeat string)
66 :safe #'org-word-count-list-of-strings-p)
67
68(defcustom org-word-count-ignore-commented-trees t
69 "Ignore trees with COMMENT-prefix if non-nil."
70 :type 'boolean
71 :safe #'booleanp)
72
73(defcustom org-word-count-default-link-count 'description-or-path
74 "Default way of counting words in links.
75This is applied to any link type not specified in any of
76‘org-word-count-ignored-link-types’,‘org-word-count-one-word-link-types’, or
77‘org-word-count-only-description-link-types’ "
78 :type '(choice
79 (const :tag "Count words in description or else path part of links" description-or-path)
80 (const :tag "Count words only in description part of links" description)
81 (const :tag "Count links as 0 words" ignore)
82 (const :tag "Count links as 1 word" oneword)
83 (const :tag "Count words only in path part of links" path))
84 :safe 'symbolp)
85
86(defcustom org-word-count-ignored-link-types nil
87 "Link types which won't be counted as a word"
88 :type '(repeat string)
89 :safe #'org-word-count-list-of-strings-p)
90
91(defcustom org-word-count-one-word-link-types '("zotero")
92 "Link types which will be counted as one word"
93 :type '(repeat string)
94 :safe #'org-word-count-list-of-strings-p)
95
96(defcustom org-word-count-description-or-path-link-types '()
97 "Link types for which the description or the path should be counted"
98 :type '(repeat string)
99 :safe #'org-word-count-list-of-strings-p)
100
101(defcustom org-word-count-only-description-link-types '("note")
102 "Link types for which only the description should be counted"
103 :type '(repeat string)
104 :safe #'org-word-count-list-of-strings-p)
105
106(defcustom org-word-count-only-path-link-types '()
107 "Link types for which only the path should be counted"
108 :type '(repeat string)
109 :safe #'org-word-count-list-of-strings-p)
110
111(defcustom org-word-count-blocks-to-count '("quote" "verse")
112 "List of blocks which should be included in word count.
113
114Use lower case block names"
115 :type '(repeat string)
116 :safe #'org-word-count-list-of-strings-p
117 :set #'org-word-count--downcase-list-of-strings-set-default)
118
119(defun org-word-count-delayed-update (&rest _)
120 (if org-word-count-update-timer
121 (setq org-word-count-update-timer nil)
122 (setq org-word-count-update-timer
123 (run-with-idle-timer org-word-count-deletion-idle-timer nil
124 #'org-word-count-update))))
125
126(defun org-word-count-force-update ()
127 (interactive)
128 (message "Counting words...")
129 (when (timerp org-word-count-update-timer)
130 (cancel-timer org-word-count-update-timer))
131 (org-word-count-update)
132 (message "Counting words...done"))
133
134(defun org-word-count-update (&rest _) ; Needs variadic parameters, since it's advice
135 (dlet ((org-word-count-counting t))
136 (org-word-count-buffer)
137 (org-word-count-modeline)
138 (setq org-word-count-update-timer nil)))
139
140(defun org-word-count-changed (start end length)
141 (org-word-count-delayed-update))
142
143(defun org-word-count-buffer ()
144 "Count the words in the buffer."
145 (when (and (derived-mode-p 'org-mode)
146 (not (eq org-word-count-word-count 'huge)))
147 (setq org-word-count-word-count
148 (cond
149 ((> (count-words (point-min) (point-max))
150 org-word-count-huge-buffer)
151 'huge)
152 (t (org-word-count-aux (point-min) (point-max)))))))
153
154;;; From org-wc.el:
155;; https://github.com/tesujimath/org-wc/
156(defun org-word-count-aux (beg end)
157 "Return the number of words between BEG and END."
158 (let ((wc 0)
159 subtreecount
160 (latex-macro-regexp "\\\\[A-Za-z]+\\(\\[[^]]*\\]\\|\\){\\([^}]*\\)}"))
161 (save-excursion
162 (goto-char beg)
163 ;; Handle the case where we start in a drawer
164 (when (org-at-drawer-p)
165 (org-end-of-meta-data t))
166 (while (< (point) end)
167 (cond
168 ;; Handle headlines and subtrees
169 ((org-at-heading-p)
170 (cond
171 ;; Ignore commented and org-wc-ignored-tags trees
172 ((or (and org-word-count-ignore-commented-trees (org-in-commented-heading-p))
173 (cl-intersection org-word-count-ignored-tags (org-get-tags) :test #'string=))
174 (org-end-of-subtree t t))
175 ;; Re-use count for subtrees already counted
176 ((setq subtreecount (get-text-property (point) :org-wc))
177 (cl-incf wc subtreecount)
178 (org-end-of-subtree t t))
179 ;; Skip counting words in headline
180 (t (org-word-count--goto-char (point-at-eol) end))))
181 ;; Ignore most blocks.
182 ((when (save-excursion
183 (beginning-of-line 1)
184 (looking-at org-block-regexp))
185 (if (member (downcase (match-string 1)) org-word-count-blocks-to-count)
186 (progn ;; go inside block and subtract count of end line
187 (org-word-count--goto-char (match-beginning 4) end)
188 (cl-decf wc))
189 (org-word-count--goto-char (match-end 0) end))))
190 ;; Ignore comments.
191 ((org-at-comment-p)
192 (org-word-count--goto-char (point-at-eol) end))
193 ;; Ignore drawers.
194 ((org-at-drawer-p)
195 (org-end-of-meta-data t))
196 ;; Ignore all other #+ lines
197 ((looking-at "#+")
198 (org-word-count--goto-char (point-at-eol) end))
199 ;; Handle links
200 ((save-excursion
201 (when (< (1+ (point-min)) (point)) (backward-char 2))
202 (looking-at org-link-bracket-re))
203 (let* ((type (car (save-match-data (split-string (match-string 1) ":"))))
204 (pathstart (+ 1 (length type) (match-beginning 1))))
205 (cl-case (cond ((member type org-word-count-ignored-link-types) 'ignore)
206 ((member type org-word-count-one-word-link-types) 'oneword)
207 ((member type org-word-count-only-description-link-types)
208 'description)
209 ((member type org-word-count-only-path-link-types) 'path)
210 ((member type org-word-count-description-or-path-link-types)
211 'description-or-path)
212 (t org-word-count-default-link-count))
213 (ignore (org-word-count--goto-char (match-end 0) end))
214 (oneword (org-word-count--goto-char (match-end 0) end)
215 (cl-incf wc))
216 (description (if (match-beginning 2)
217 (goto-char (match-beginning 2))
218 (org-word-count--goto-char
219 (match-end 0) end)))
220 (path (cl-incf wc (count-words-region pathstart
221 (match-end 1)))
222 (org-word-count--goto-char (match-end 0) end))
223 (description-or-path
224 (if (match-beginning 2)
225 (goto-char (match-beginning 2))
226 (cl-incf wc (count-words-region pathstart
227 (match-end 1)))
228 (org-word-count--goto-char (match-end 0) end)))
229 (t (user-error "Error in org-word-count link configuration")))))
230 ;; Count latex macros as 1 word, ignoring their arguments.
231 ((save-excursion
232 (when (< (point-min) (point)) (backward-char))
233 (looking-at latex-macro-regexp))
234 (org-word-count--goto-char (match-end 0) end)
235 (cl-incf wc))
236 (t
237 (and (re-search-forward "\\w+\\W*" end 'skip)
238 (cl-incf wc))))))
239 wc))
240
241(defun org-word-count--goto-char (char end)
242 "Moves point to CHAR and from there passes 0+ non-word characters.
243Searchers to end as a maximum.
244
245This ensures that we are in an expected state (at the first word
246character after some non-word characters) after moving beyond
247headlines, links etc."
248 (goto-char char)
249 (re-search-forward "\\W*" end 'skip))
250
251(defvar org-word-count-counting nil
252 "Are we currently counting?")
253
254(defun org-word-count-recount-widen (&rest _)
255 (when (and (not org-word-count-counting))
256 (org-word-count-update)))
257
258(defun org-word-count-modeline ()
259 (setq org-word-count-string
260 (cond
261 ((eq org-word-count-word-count 'huge)
262 org-word-count-huge-string)
263 (org-word-count-word-count
264 (format org-word-count-format
265 (max 0 (+ org-word-count-word-count
266 org-word-count-correction))))))
267 (force-mode-line-update))
268
269(define-minor-mode org-word-count-mode
270 "Count words in `org-mode' buffers in the mode-line."
271 :lighter ""
272 :keymap (let ((map (make-sparse-keymap)))
273 (define-key map (kbd "C-c C-.") #'org-word-count-force-update)
274 map)
275 (cond (org-word-count-mode
276 (org-word-count-buffer)
277 (add-hook 'after-change-functions
278 #'org-word-count-delayed-update nil t)
279 (unless (member '(org-word-count-mode org-word-count-string)
280 mode-line-misc-info)
281 (add-to-list 'mode-line-misc-info
282 '(org-word-count-mode org-word-count-string)
283 nil
284 #'equal))
285 (dolist (fn org-word-count-update-after-funcs)
286 (advice-add fn :after #'org-word-count-update)))
287 (:else
288 (remove-hook 'after-change-functions
289 #'org-word-count-delayed-update t)
290 (setf mode-line-misc-info
291 (delete '(org-word-count-mode org-word-count-string)
292 mode-line-misc-info))
293 (dolist (fn org-word-count-update-after-funcs)
294 (advice-remove fn #'org-word-count-update)))))
295
296(provide 'org-word-count)
297;;; org-word-count.el ends here