* gnus-registry.el: added brief explanation of basics
[gnus] / lisp / gnus-registry.el
1 ;;; gnus-registry.el --- article registry for Gnus
2 ;; Copyright (C) 1996, 1997, 1998, 1999, 2000, 2001, 2002, 2003
3 ;;        Free Software Foundation, Inc.
4
5 ;; Author: Ted Zlatanov <tzz@lifelogs.com>
6 ;; Keywords: news
7
8 ;; This file is part of GNU Emacs.
9
10 ;; GNU Emacs 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 2, or (at your option)
13 ;; any later version.
14
15 ;; GNU Emacs 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 GNU Emacs; see the file COPYING.  If not, write to the
22 ;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
23 ;; Boston, MA 02111-1307, USA.
24
25 ;;; Commentary:
26
27 ;; This is the gnus-registry.el package, works with other backends
28 ;; besides nnmail.  The major issue is that it doesn't go across
29 ;; backends, so for instance if an article is in nnml:sys and you see
30 ;; a reference to it in nnimap splitting, the article will end up in
31 ;; nnimap:sys
32
33 ;; gnus-registry.el intercepts article respooling, moving, deleting,
34 ;; and copying for all backends.  If it doesn't work correctly for
35 ;; you, submit a bug report and I'll be glad to fix it.  It needs
36 ;; documentation in the manual (also on my to-do list).
37
38 ;; Put this in your startup file (~/.gnus.el for instance)
39
40 ;; (setq gnus-registry-install t
41 ;;  gnus-registry-max-entries 2500
42 ;;  gnus-registry-use-long-group-names t)
43
44 ;; (require 'gnus-registry)
45
46 ;; Then use this in your fancy-split:
47
48 ;; (: gnus-registry-split-fancy-with-parent)
49
50 ;; TODO:
51
52 ;; - get the correct group on spool actions
53
54 ;; - articles that are spooled to a different backend should be handled
55
56 ;;; Code:
57
58 (eval-when-compile (require 'cl))
59
60 (require 'gnus)
61 (require 'gnus-int)
62 (require 'gnus-sum)
63 (require 'nnmail)
64
65 (defvar gnus-registry-dirty t
66  "Boolean set to t when the registry is modified")
67
68 (defgroup gnus-registry nil
69   "The Gnus registry."
70   :group 'gnus)
71
72 (defvar gnus-registry-hashtb nil
73   "*The article registry by Message ID.")
74
75 (defcustom gnus-registry-unfollowed-groups '("delayed" "drafts" "queue")
76   "List of groups that gnus-registry-split-fancy-with-parent won't follow.
77 The group names are matched, they don't have to be fully qualified."
78   :group 'gnus-registry
79   :type '(repeat string))
80
81 (defcustom gnus-registry-install nil
82   "Whether the registry should be installed."
83   :group 'gnus-registry
84   :type 'boolean)
85
86 (defcustom gnus-registry-clean-empty t
87   "Whether the empty registry entries should be deleted.
88 Registry entries are considered empty when they have no groups."
89   :group 'gnus-registry
90   :type 'boolean)
91
92 (defcustom gnus-registry-use-long-group-names nil
93   "Whether the registry should use long group names (BUGGY)."
94   :group 'gnus-registry
95   :type 'boolean)
96
97 (defcustom gnus-registry-track-extra nil
98   "Whether the registry should track other things about a message.
99 The Subject header is currently the only thing that can be
100 tracked this way."
101   :group 'gnus-registry
102   :type 'boolean)
103
104 (defcustom gnus-registry-entry-caching t
105   "Whether the registry should cache extra information."
106   :group 'gnus-registry
107   :type 'boolean)
108
109 (defcustom gnus-registry-minimum-subject-length 5
110   "The minimum length of a subject before it's considered trackable."
111   :group 'gnus-registry
112   :type 'integer)
113
114 (defcustom gnus-registry-trim-articles-without-groups t
115   "Whether the registry should clean out message IDs without groups."
116   :group 'gnus-registry
117   :type 'boolean)
118
119 (defcustom gnus-registry-cache-file "~/.gnus.registry.eld"
120   "File where the Gnus registry will be stored."
121   :group 'gnus-registry
122   :type 'file)
123
124 (defcustom gnus-registry-max-entries nil
125   "Maximum number of entries in the registry, nil for unlimited."
126   :group 'gnus-registry
127   :type '(radio (const :format "Unlimited " nil)
128                 (integer :format "Maximum number: %v\n" :size 0)))
129
130 ;; Function(s) missing in Emacs 20
131 (when (memq nil (mapcar 'fboundp '(puthash)))
132   (require 'cl)
133   (unless (fboundp 'puthash)
134     ;; alias puthash is missing from Emacs 20 cl-extra.el
135     (defalias 'puthash 'cl-puthash)))
136
137 (defun gnus-registry-cache-read ()
138   "Read the registry cache file."
139   (interactive)
140   (let ((file gnus-registry-cache-file))
141     (when (file-exists-p file)
142       (gnus-message 5 "Reading %s..." file)
143       (gnus-load file)
144       (gnus-message 5 "Reading %s...done" file))))
145
146 (defun gnus-registry-cache-save ()
147   "Save the registry cache file."
148   (interactive)
149   (let ((file gnus-registry-cache-file))
150     (save-excursion
151       (set-buffer (gnus-get-buffer-create " *Gnus-registry-cache*"))
152       (make-local-variable 'version-control)
153     (setq version-control gnus-backup-startup-file)
154     (setq buffer-file-name file)
155     (setq default-directory (file-name-directory buffer-file-name))
156     (buffer-disable-undo)
157     (erase-buffer)
158     (gnus-message 5 "Saving %s..." file)
159     (if gnus-save-startup-file-via-temp-buffer
160         (let ((coding-system-for-write gnus-ding-file-coding-system)
161               (standard-output (current-buffer)))
162           (gnus-gnus-to-quick-newsrc-format t "gnus registry startup file" 'gnus-registry-alist)
163           (gnus-registry-cache-whitespace file)
164           (save-buffer))
165       (let ((coding-system-for-write gnus-ding-file-coding-system)
166             (version-control gnus-backup-startup-file)
167             (startup-file file)
168             (working-dir (file-name-directory file))
169             working-file
170             (i -1))
171         ;; Generate the name of a non-existent file.
172         (while (progn (setq working-file
173                             (format
174                              (if (and (eq system-type 'ms-dos)
175                                       (not (gnus-long-file-names)))
176                                  "%s#%d.tm#" ; MSDOS limits files to 8+3
177                                (if (memq system-type '(vax-vms axp-vms))
178                                    "%s$tmp$%d"
179                                  "%s#tmp#%d"))
180                              working-dir (setq i (1+ i))))
181                       (file-exists-p working-file)))
182         
183         (unwind-protect
184             (progn
185               (gnus-with-output-to-file working-file
186                 (gnus-gnus-to-quick-newsrc-format t "gnus registry startup file" 'gnus-registry-alist))
187               
188               ;; These bindings will mislead the current buffer
189               ;; into thinking that it is visiting the startup
190               ;; file.
191               (let ((buffer-backed-up nil)
192                     (buffer-file-name startup-file)
193                     (file-precious-flag t)
194                     (setmodes (file-modes startup-file)))
195                 ;; Backup the current version of the startup file.
196                 (backup-buffer)
197                 
198                 ;; Replace the existing startup file with the temp file.
199                 (rename-file working-file startup-file t)
200                 (set-file-modes startup-file setmodes)))
201           (condition-case nil
202               (delete-file working-file)
203             (file-error nil)))))
204     
205     (gnus-kill-buffer (current-buffer))
206     (gnus-message 5 "Saving %s...done" file))))
207
208 ;; Idea from Dan Christensen <jdc@chow.mat.jhu.edu>
209 ;; Save the gnus-registry file with extra line breaks.
210 (defun gnus-registry-cache-whitespace (filename)
211   (gnus-message 5 "Adding whitespace to %s" filename)
212   (save-excursion
213     (goto-char (point-min))
214     (while (re-search-forward "^(\\|(\\\"" nil t)
215       (replace-match "\n\\&" t))
216     (goto-char (point-min))
217     (while (re-search-forward " $" nil t)
218       (replace-match "" t t))))
219
220 (defun gnus-registry-save (&optional force)
221   (when (or gnus-registry-dirty force)
222     (let ((caching gnus-registry-entry-caching))
223       ;; turn off entry caching, so mtime doesn't get recorded
224       (setq gnus-registry-entry-caching nil)
225       ;; remove entry caches
226       (maphash
227        (lambda (key value)
228          (if (hash-table-p value)
229              (remhash key gnus-registry-hashtb)))
230        gnus-registry-hashtb)
231       ;; remove empty entries
232       (when gnus-registry-clean-empty 
233         (gnus-registry-clean-empty-function))
234       ;; now trim the registry appropriately
235       (setq gnus-registry-alist (gnus-registry-trim 
236                                  (hashtable-to-alist gnus-registry-hashtb)))
237       ;; really save
238       (gnus-registry-cache-save)
239       (setq gnus-registry-entry-caching entry-caching)
240       (setq gnus-registry-dirty nil))))
241
242 (defun gnus-registry-clean-empty-function ()
243   "Remove all empty entries from the registry.  Returns count thereof."
244   (let ((count 0))
245     (maphash
246      (lambda (key value)
247        (unless (gnus-registry-fetch-group key)
248          (incf count)
249          (remhash key gnus-registry-hashtb)))
250      gnus-registry-hashtb)
251     count))
252
253 (defun gnus-registry-read ()
254   (gnus-registry-cache-read)
255   (setq gnus-registry-hashtb (alist-to-hashtable gnus-registry-alist))
256   (setq gnus-registry-dirty nil))
257
258 (defun gnus-registry-trim (alist)
259   "Trim alist to size, using gnus-registry-max-entries."
260   (if (null gnus-registry-max-entries)
261       alist                             ; just return the alist
262     ;; else, when given max-entries, trim the alist
263     (let ((timehash (make-hash-table                        
264                      :size 4096
265                      :test 'equal)))
266       (maphash
267        (lambda (key value)
268          (puthash key (gnus-registry-fetch-extra key 'mtime) timehash))
269        gnus-registry-hashtb)
270
271       ;; we use the return value of this setq, which is the trimmed alist
272       (setq alist
273             (nthcdr
274              (- (length alist) gnus-registry-max-entries)
275              (sort alist 
276                    (lambda (a b)
277                      (time-less-p 
278                       (cdr (gethash (car a) timehash))
279                       (cdr (gethash (car b) timehash))))))))))
280
281 (defun alist-to-hashtable (alist)
282   "Build a hashtable from the values in ALIST."
283   (let ((ht (make-hash-table                        
284              :size 4096
285              :test 'equal)))
286     (mapc
287      (lambda (kv-pair)
288        (puthash (car kv-pair) (cdr kv-pair) ht))
289      alist)
290      ht))
291
292 (defun hashtable-to-alist (hash)
293   "Build an alist from the values in HASH."
294   (let ((list nil))
295     (maphash
296      (lambda (key value)
297        (setq list (cons (cons key value) list)))
298      hash)
299     list))
300
301 (defun gnus-registry-action (action data-header from &optional to method)
302   (let* ((id (mail-header-id data-header))
303          (subject (gnus-registry-simplify-subject 
304                    (mail-header-subject data-header)))
305         (from (gnus-group-guess-full-name from))
306         (to (if to (gnus-group-guess-full-name to) nil))
307         (to-name (if to to "the Bit Bucket"))
308         (old-entry (gethash id gnus-registry-hashtb)))
309     (gnus-message 5 "Registry: article %s %s from %s to %s"
310                   id
311                   (if method "respooling" "going")
312                   from
313                   to)
314
315     ;; All except copy will need a delete
316     (gnus-registry-delete-group id from)
317
318     (when (equal 'copy action) 
319       (gnus-registry-add-group id from subject)) ; undo the delete
320
321     (gnus-registry-add-group id to subject)))
322
323 (defun gnus-registry-spool-action (id group &optional subject)
324   ;; do not process the draft IDs
325 ;  (unless (string-match "totally-fudged-out-message-id" id)
326 ;    (let ((group (gnus-group-guess-full-name group)))
327   (when (string-match "\r$" id)
328     (setq id (substring id 0 -1)))
329   (gnus-message 5 "Registry: article %s spooled to %s"
330                 id
331                 group)
332   (gnus-registry-add-group id group subject))
333 ;)
334
335 ;; Function for nn{mail|imap}-split-fancy: look up all references in
336 ;; the cache and if a match is found, return that group.
337 (defun gnus-registry-split-fancy-with-parent ()
338   "Split this message into the same group as its parent.  The parent
339 is obtained from the registry.  This function can be used as an entry
340 in `nnmail-split-fancy' or `nnimap-split-fancy', for example like
341 this: (: gnus-registry-split-fancy-with-parent) 
342
343 For a message to be split, it looks for the parent message in the
344 References or In-Reply-To header and then looks in the registry to
345 see which group that message was put in.  This group is returned.
346
347 See the Info node `(gnus)Fancy Mail Splitting' for more details."
348   (let ((refstr (or (message-fetch-field "references")
349                     (message-fetch-field "in-reply-to")))
350         (nnmail-split-fancy-with-parent-ignore-groups
351          (if (listp nnmail-split-fancy-with-parent-ignore-groups)
352              nnmail-split-fancy-with-parent-ignore-groups
353            (list nnmail-split-fancy-with-parent-ignore-groups)))
354         references res)
355     (if refstr
356         (progn
357           (setq references (nreverse (gnus-split-references refstr)))
358           (mapcar (lambda (x)
359                     (setq res (or (gnus-registry-fetch-group x) res))
360                     (when (or (gnus-registry-grep-in-list 
361                                res
362                                gnus-registry-unfollowed-groups)
363                               (gnus-registry-grep-in-list 
364                                res 
365                                nnmail-split-fancy-with-parent-ignore-groups))
366                       (setq res nil)))
367                   references))
368       ;; there were no references, now try the extra tracking
369       (when gnus-registry-track-extra
370         (let ((subject (gnus-registry-simplify-subject 
371                         (message-fetch-field "subject"))))
372           (when (and subject
373                      (< gnus-registry-minimum-subject-length (length subject)))
374             (maphash
375              (lambda (key value)
376                (let ((this-subject (cdr 
377                                     (gnus-registry-fetch-extra key 'subject))))
378                  (when (and this-subject
379                             (equal subject this-subject))
380                    (setq res (gnus-registry-fetch-group key))
381                    (gnus-message
382                     ;; raise level of messaging if gnus-registry-track-extra
383                     (if gnus-registry-track-extra 5 9) 
384                     "%s (extra tracking) traced subject %s to group %s"
385                     "gnus-registry-split-fancy-with-parent"
386                     subject
387                     (if res res "nil")))))
388              gnus-registry-hashtb))))
389
390       (gnus-message
391        5 
392        "gnus-registry-split-fancy-with-parent traced %s to group %s"
393        refstr (if res res "nil"))
394       res)))
395
396 (defun gnus-registry-register-message-ids ()
397   "Register the Message-ID of every article in the group"
398   (unless (gnus-parameter-registry-ignore gnus-newsgroup-name)
399     (dolist (article gnus-newsgroup-articles)
400       (let ((id (gnus-registry-fetch-message-id-fast article)))
401         (unless (gnus-registry-fetch-group id)
402           (gnus-message 9 "Registry: Registering article %d with group %s" 
403                         article gnus-newsgroup-name)
404           (gnus-registry-add-group 
405            (gnus-registry-fetch-message-id-fast article)
406            gnus-newsgroup-name
407            (gnus-registry-fetch-simplified-message-subject-fast article)))))))
408
409 (defun gnus-registry-fetch-message-id-fast (article)
410   "Fetch the Message-ID quickly, using the internal gnus-data-list function"
411   (if (and (numberp article)
412            (assoc article (gnus-data-list nil)))
413       (mail-header-id (gnus-data-header (assoc article (gnus-data-list nil))))
414     nil))
415
416 (defun gnus-registry-simplify-subject (subject)
417   (if (null subject)
418       nil
419     (gnus-simplify-subject subject)))
420
421 (defun gnus-registry-fetch-simplified-message-subject-fast (article)
422   "Fetch the Subject quickly, using the internal gnus-data-list function"
423   (if (and (numberp article)
424            (assoc article (gnus-data-list nil)))
425       (gnus-registry-simplify-subject
426        (mail-header-subject (gnus-data-header
427                              (assoc article (gnus-data-list nil)))))
428     nil))
429
430 (defun gnus-registry-grep-in-list (word list)
431   (when word
432     (memq nil
433           (mapcar 'not
434                   (mapcar 
435                    (lambda (x)
436                      (string-match x word))
437                    list)))))
438
439 (defun gnus-registry-fetch-extra (id &optional entry)
440   "Get the extra data of a message, based on the message ID.
441 Returns the first place where the trail finds a nonstring."
442   (let ((entry-cache (gethash entry gnus-registry-hashtb)))
443     (if (and entry
444              (hash-table-p entry-cache)
445              (gethash id entry-cache))
446         (gethash id entry-cache)
447       ;; else, if there is no caching possible...
448       (let ((trail (gethash id gnus-registry-hashtb)))
449         (dolist (crumb trail)
450           (unless (stringp crumb)
451             (return (gnus-registry-fetch-extra-entry crumb entry id))))))))
452
453 (defun gnus-registry-fetch-extra-entry (alist &optional entry id)
454   "Get the extra data of a message, or a specific entry in it.
455 Update the entry cache if needed."
456   (if (and entry id)
457       (let ((entry-cache (gethash entry gnus-registry-hashtb))
458             entree)
459         (when gnus-registry-entry-caching
460           ;; create the hash table
461           (unless (hash-table-p entry-cache)
462             (setq entry-cache (make-hash-table
463                                :size 4096
464                                :test 'equal))
465             (puthash entry entry-cache gnus-registry-hashtb))
466
467           ;; get the entree from the hash table or from the alist
468           (setq entree (gethash id entry-cache)))
469         
470         (unless entree
471           (setq entree (assq entry alist))
472           (puthash id entree entry-cache))
473         entree)
474     alist))
475
476 (defun gnus-registry-store-extra (id extra)
477   "Store the extra data of a message, based on the message ID.
478 The message must have at least one group name."
479   (when (gnus-registry-group-count id)
480     ;; we now know the trail has at least 1 group name, so it's not empty
481     (let ((trail (gethash id gnus-registry-hashtb))
482           (old-extra (gnus-registry-fetch-extra id)))
483       (puthash id (cons extra (delete old-extra trail))
484                gnus-registry-hashtb)
485       (setq gnus-registry-dirty t))))
486
487 (defun gnus-registry-store-extra-entry (id key value)
488   "Put a specific entry in the extras field of the registry entry for id."
489   (let* ((extra (gnus-registry-fetch-extra id))
490          (alist (cons (cons key value)
491                  (gnus-assq-delete-all key (gnus-registry-fetch-extra id)))))
492     (gnus-registry-store-extra id alist)))
493
494 (defun gnus-registry-fetch-group (id)
495   "Get the group of a message, based on the message ID.
496 Returns the first place where the trail finds a group name."
497   (when (gnus-registry-group-count id)
498     ;; we now know the trail has at least 1 group name
499     (let ((trail (gethash id gnus-registry-hashtb)))
500       (dolist (crumb trail)
501         (when (stringp crumb)
502           (return (gnus-group-short-name crumb)))))))
503
504 (defun gnus-registry-group-count (id)
505   "Get the number of groups of a message, based on the message ID."
506   (let ((trail (gethash id gnus-registry-hashtb)))
507     (if (and trail (listp trail))
508         (apply '+ (mapcar (lambda (x) (if (stringp x) 1 0)) trail))
509       0)))
510
511 (defun gnus-registry-delete-group (id group)
512   "Delete a group for a message, based on the message ID."
513   (when group
514     (when id
515       (let ((trail (gethash id gnus-registry-hashtb))
516             (group (gnus-group-short-name group)))
517         (puthash id (if trail
518                         (delete group trail)
519                       nil)
520                  gnus-registry-hashtb))
521       ;; now, clear the entry if there are no more groups
522       (when gnus-registry-trim-articles-without-groups
523         (unless (gnus-registry-group-count id)
524           (remhash id gnus-registry-hashtb)))
525       (gnus-registry-store-extra-entry id 'mtime (current-time)))))
526
527 (defun gnus-registry-add-group (id group &optional subject)
528   "Add a group for a message, based on the message ID."
529   ;; make sure there are no duplicate entries
530   (when group
531     (when (and id
532                (not (string-match "totally-fudged-out-message-id" id)))
533       (let ((full-group group)
534             (group (if gnus-registry-use-long-group-names 
535                        group 
536                      (gnus-group-short-name group))))
537         (gnus-registry-delete-group id group)
538         (unless gnus-registry-use-long-group-names 
539           (gnus-registry-delete-group id full-group))
540         (let ((trail (gethash id gnus-registry-hashtb)))
541           (puthash id (if trail
542                           (cons group trail)
543                         (list group))
544                    gnus-registry-hashtb)
545
546           (when gnus-registry-track-extra 
547             (gnus-registry-store-extra-entry 
548              id 
549              'subject 
550              (gnus-registry-simplify-subject subject)))
551           
552           (gnus-registry-store-extra-entry id 'mtime (current-time)))))))
553
554 (defun gnus-registry-clear ()
555   "Clear the Gnus registry."
556   (interactive)
557   (setq gnus-registry-alist nil)
558   (setq gnus-registry-hashtb (alist-to-hashtable gnus-registry-alist))
559   (setq gnus-registry-dirty t))
560
561 (defun gnus-registry-install-hooks ()
562   "Install the registry hooks."
563   (interactive)
564   (add-hook 'gnus-summary-article-move-hook 'gnus-registry-action) 
565   (add-hook 'gnus-summary-article-delete-hook 'gnus-registry-action)
566   (add-hook 'gnus-summary-article-expire-hook 'gnus-registry-action)
567   (add-hook 'nnmail-spool-hook 'gnus-registry-spool-action)
568   
569   (add-hook 'gnus-save-newsrc-hook 'gnus-registry-save)
570   (add-hook 'gnus-read-newsrc-el-hook 'gnus-registry-read)
571
572   (add-hook 'gnus-summary-prepare-hook 'gnus-registry-register-message-ids))
573
574 (defun gnus-registry-unload-hook ()
575   "Uninstall the registry hooks."
576   (interactive)
577   (remove-hook 'gnus-summary-article-move-hook 'gnus-registry-action) 
578   (remove-hook 'gnus-summary-article-delete-hook 'gnus-registry-action)
579   (remove-hook 'gnus-summary-article-expire-hook 'gnus-registry-action)
580   (remove-hook 'nnmail-spool-hook 'gnus-registry-spool-action)
581   
582   (remove-hook 'gnus-save-newsrc-hook 'gnus-registry-save)
583   (remove-hook 'gnus-read-newsrc-el-hook 'gnus-registry-read)
584
585   (remove-hook 'gnus-summary-prepare-hook 'gnus-registry-register-message-ids))
586
587 (when gnus-registry-install
588   (gnus-registry-install-hooks)
589   (gnus-registry-read))
590
591 ;; TODO: a lot of things
592
593 (provide 'gnus-registry)
594
595 ;;; gnus-registry.el ends here