(defun l29-get-last-message-id ()
"Return last message id in archive."
(let ((buffer-name "level-29-bbs.org"))
(unless (string= (buffer-name) buffer-name)
(error "Buffer is not '%s'" buffer-name))
(save-mark-and-excursion
(save-match-data
(goto-char (point-min))
(unless (re-search-forward "^[ \t]*:CUSTOM_ID: messages$" nil :noerror)
(error "Messages section not found in buffer '%s'!" buffer-name))
(goto-char (point-max))
(if (re-search-backward "^[ \t]*:CUSTOM_ID: message-\\([0-9]+\\)$" nil :noerror)
(string-to-number (match-string 1))
0)))))
(defun l29-send-string (process string &optional with-cr)
"Send STRING to `comint' PROCESS in a way that works with telnet.
If WITH-CR is non-nil, then a carrage return will be added to the end."
(process-send-string process (concat string (if with-cr "\r" ""))))
(defun l29-parse-message (archive-buffer)
"Get message in current buffer and add it to ARCHIVE-BUFFER.
POINT should be at the end of the message.
If the message is a reply, then it will be inserted as a
subheading under the original message.
Message header should look like:
Message #NUM
Subject: SUBJECT
SUBJECT CAN WRAP [optional]
From: NAME
Date: YYYY-MM-DD HH:MM:SS
Connection: DESCRIPTION [optional]
Replying to: NAME, message #NUM [optional]
MESSAGE
Select: ..."
(save-mark-and-excursion
(let ((end (progn
(forward-line -1)
(point))))
(when (re-search-backward "^==========*\n\nMessage" nil :noerror)
(forward-line 2)
(when (looking-at "^Message #\\([0-9]+\\)$")
(let ((message-id (string-to-number (match-string-no-properties 1)))
(start (point))
properties)
(forward-line 1)
(while (re-search-forward "^\\(.*?\\):[ \t]*\\(.*\\)$" (point-at-eol) :noerror)
(let ((property (upcase (replace-regexp-in-string " " "_" (match-string-no-properties 1))))
(value (match-string-no-properties 2)))
(forward-line 1)
(while (looking-at "^[\t ]+\\(.*\\)$")
(setq value (concat value " " (match-string-no-properties 1)))
(forward-line 1))
(push (cons property value) properties)))
(forward-line 1)
(let* ((message (buffer-substring-no-properties start end))
(subject (remove-if-not (lambda (x) (string= (car x) "SUBJECT")) properties))
(reply (remove-if-not (lambda (x) (string= (car x) "REPLYING_TO")) properties))
(reply-id (and reply
(string-to-number
(replace-regexp-in-string "^.* message #" "" (cdar reply))))))
(with-current-buffer archive-buffer
(goto-char (point-max))
(let* ((reply-indent
(if reply-id
(progn
(re-search-backward (format ":CUSTOM_ID: message-%d$" reply-id))
(org-show-entry)
(- (+ (point) 2) (point-at-bol)))
4))
(header (make-string (1- reply-indent) ?*))
(spacer (make-string reply-indent ? )))
(when reply-id
(let ((pos (point)))
(org-forward-heading-same-level 1 :invisible-ok)
(while (and (< (point) pos)
(> (org-outline-level) 1))
(outline-up-heading 1 :invisible-ok)
(org-forward-heading-same-level 1 :invisible-ok)))
(if (= (org-outline-level) 1)
(goto-char (point-max))
(forward-line -1)))
(newline)
(insert (format "%s %d%s\n" header message-id (if subject (concat " - " (cdar subject)) "")))
(insert (format "%s:PROPERTIES:\n" spacer))
(insert (format "%s:CUSTOM_ID: message-%d\n" spacer message-id))
(mapcar (lambda (x) (insert (format "%s:%s: %s\n" spacer (car x) (cdr x))))
(nreverse properties))
(insert (format "%s:END:\n" spacer))
(forward-line -1)
(org-indent-drawer)
(forward-line 1)
(newline)
(insert (replace-regexp-in-string "^\*" "," message)) (let ((point (point)))
(while (invisible-p point)
(goto-char point)
(org-show-entry))))))
(setq l29-last-message-id message-id)))))))
(defun l29-output-filter (process string)
"Output incoming data to process buffer."
(with-current-buffer (process-buffer process)
(let* ((last-insertion (marker-position (process-mark process)))
(delta (- (point) last-insertion))
(input-end (and comint-last-input-end
(marker-position comint-last-input-end)))
(window (get-buffer-window (current-buffer)))
(window-start (and window (window-start window))))
(goto-char last-insertion)
(insert string)
(set-marker comint-last-output-start last-insertion)
(set-marker (process-mark process) (point))
(when window-start
(set-window-start window window-start :noforce))
(when input-end
(set-marker comint-last-input-end input-end))
(while (progn (skip-chars-backward "^\C-m" last-insertion)
(> (point) last-insertion))
(delete-region (1- (point)) (point)))
(goto-char (process-mark process))
(when (> delta 0)
(goto-char (+ (process-mark process) delta))))))
(defun l29-state-filter (process string)
"State based filter.
Possible states:
:login - Perform initial login
:init - Start initial message fetching
:init-next - Continue fetching initial messages
:new - Handle new message fetching
:new-next - Continuefetching new messages
:logout - Perform final logout
BUFFER is used internally for recursive calls."
(let* ((env (expand-file-name "~/.level-29-bbs-login"))
(username (shell-command-to-string (concat "sed -n 's/username=//p' " env)))
(password (shell-command-to-string (concat "sed -n 's/password=//p' " env)))
(case-fold-search t))
(with-current-buffer (process-buffer process)
(l29-output-filter process string)
(let ((point (point)))
(goto-char (point-at-bol))
(case l29-state
(:login
(cond
((looking-at "^User: $")
(goto-char point)
(l29-send-string process username))
((looking-at "^Password: $")
(goto-char point)
(l29-send-string process password)
(clear-this-command-keys))
((looking-at "^Terminal size .*: $")
(goto-char point)
(l29-send-string process "80x100" :with-cr))
((looking-at "^Terminal type .*: $")
(goto-char point)
(l29-send-string process "dumb" :with-cr))
((looking-at "^Character set .*: $")
(goto-char point)
(l29-send-string process "ASCII" :with-cr))
((looking-at "^Select: \\[.*\\] $")
(goto-char point)
(setq l29-state l29-mode)
(l29-state-filter process ""))))
(:init
(cond
((looking-at "^Select: \\[.*#.*\\] $")
(goto-char point)
(l29-send-string process "#"))
((looking-at "^Go to message number: $")
(goto-char point)
(l29-send-string
process
(number-to-string (if (< l29-last-message-id 2) 2 l29-last-message-id))
:with-cr))
((looking-at "^Select: \\[\\(.*\\)\\] $")
(let ((options (match-string-no-properties 1)))
(when (< l29-last-message-id 2)
(l29-parse-message l29-archive-buffer))
(goto-char point)
(if (string-match "e" options)
(progn
(setq l29-state :init-next)
(l29-send-string process "e"))
(progn
(setq l29-state :new)
(l29-send-string process "q")))))))
(:init-next
(cond
((looking-at "^Select: \\[\\(.*\\)\\] $")
(let ((options (match-string-no-properties 1)))
(l29-parse-message l29-archive-buffer)
(goto-char point)
(if (string-match "e" options)
(l29-send-string process "e")
(progn
(setq l29-state :new)
(l29-send-string process "q")))))))
(:new
(cond
((looking-at "^Select: \\[\\(.*\\)\\] $")
(let ((options (match-string-no-properties 1))
(messages (not (re-search-backward "There are 0 unread messages" nil :no-error))))
(goto-char point)
(if (and (string-match "n" options)
messages)
(progn
(setq l29-state :new-next)
(l29-send-string process "n"))
(progn
(setq l29-state :logout)
(if (string-match "q" options)
(l29-send-string process "q")
(l29-state-filter process ""))))))))
(:new-next
(cond
((looking-at "^Select: \\[\\(.*\\)\\] $")
(let ((options (match-string-no-properties 1)))
(l29-parse-message l29-archive-buffer)
(goto-char point)
(if (string-match "n" options)
(l29-send-string process "n")
(progn
(setq l29-state :logout)
(if (string-match "q" options)
(l29-send-string process "q")
(l29-state-filter process ""))))))))
(:manual
(cond
((looking-at "^Select: \\[\\([^#]*\\)\\] $")
(let ((options (match-string-no-properties 1)))
(l29-parse-message l29-archive-buffer)
(goto-char point)))
(t
(goto-char point))))
(:logout
(cond
((looking-at "^Select: \\[\\(.*\\)\\] $")
(goto-char point)
(l29-send-string process "o"))
((looking-at "^Really log off\\? ")
(goto-char point)
(set-process-filter process #'l29-output-filter)
(l29-send-string process "y"))))
(otherwise
(goto-char point)))))))
(defun l29-fetch-messages (mode)
"Telnet into <<host>>, fetch new messages, and add them to this archive.
Where MODE is one of:
:init Initialize archive by pulling down all linked messages
:new Pull down new messages
:manual Login then fetch any messages manually viewed by the user"
(interactive)
(let* ((host "<<host>>")
(port <<port>>)
(buffer-name "<<archive-buffer-name>>")
(buffer (current-buffer))
(comint-program "<<program>>")
(comint-name "<<process-name>>")
(comint-buffer "<<process-buffer-name>>")
(last-message-id (l29-get-last-message-id)))
(unless (string= (buffer-name) buffer-name)
(error "Buffer is not '%s'" buffer-name))
(when (get-process comint-name)
(delete-process (get-process comint-name)))
(make-comint-in-buffer comint-name comint-buffer comint-program)
(switch-to-buffer-other-window comint-buffer)
(let ((comint-process (get-buffer-process comint-buffer)))
(with-current-buffer comint-buffer
(set (make-local-variable 'l29-archive-buffer) buffer-name)
(set (make-local-variable 'l29-last-message-id) last-message-id)
(set (make-local-variable 'l29-mode) mode)
(set (make-local-variable 'l29-state) :login)
(set-process-filter comint-process #'l29-state-filter)
(accept-process-output comint-process)
(erase-buffer)
(comint-simple-send comint-process (concat "open " host " " (number-to-string port)))))))
(defun l29-fetch-messages-new ()
"Call `l29-fetch-messages' in init mode to pull down all linked
messages. This should only be run once."
(interactive)
(l29-fetch-messages :init))
(defun l29-fetch-messages-new ()
"Call `l29-fetch-messages' in new mode to pull down new
messages."
(interactive)
(l29-fetch-messages :new))
(defun l29-fetch-messages-manual ()
"Call `l29-fetch-messages' in manual mode to login then fetch
any messages manually viewed by the user."
(interactive)
(l29-fetch-messages :manual))
(defun l29-stop-process ()
"Stop the `l29-fetch-messages' process."
(interactive)
(set-process-filter (get-buffer-process "<<process-buffer-name>>") #'l29-output-filter))
(defun l29-find-missing-messages ()
"Find missing messages."
(interactive)
(let ((messages (make-hash-table :test 'eq))
max
missing
(ignore '(6012 6014)))
(save-mark-and-excursion
(goto-char (point-min))
(re-search-forward "^[ \t]*:CUSTOM_ID: messages$")
(while (re-search-forward "^[ \t]*:CUSTOM_ID: message-\\([0-9]+\\)$" nil :noerror)
(let ((id (string-to-number (match-string-no-properties 1))))
(puthash id id messages)
(setq max id)))
(do ((id 1 (1+ id)))
((> id max))
(unless (or (gethash id messages)
(< id 4000) (member id ignore))
(push id missing))))
(let ((buffer "*Level 29 Missing Messages*"))
(get-buffer-create buffer)
(set-buffer buffer)
(setq buffer-read-only nil)
(erase-buffer)
(insert "Level 29 Missing Messages")
(newline)
(newline)
(insert "|----------|")
(newline)
(insert "| Messages |")
(newline)
(insert "|----------|")
(newline)
(seq-do
(lambda (id)
(insert (format "| %s |" id))
(newline))
(nreverse missing))
(insert "|----------|")
(newline)
(org-mode)
(forward-line -2)
(org-table-align)
(setq buffer-read-only t)
(switch-to-buffer buffer)
(goto-char (point-min)))))
(defun l29-post-stats ()
"Output statistics on posting frequency."
(interactive)
(let ((post-frequency (make-hash-table :test 'equal)))
(save-mark-and-excursion
(goto-char (point-min))
(re-search-forward "^[ \t]*:CUSTOM_ID: messages$")
(while (re-search-forward "^[ \t]*:FROM: +\\(.*\\)$" nil :noerror)
(let* ((user (match-string 1))
(posts (or (gethash user post-frequency) 0)))
(puthash user (1+ posts) post-frequency))))
(let ((buffer "*Level 29 Post Statistics*"))
(get-buffer-create buffer)
(set-buffer buffer)
(setq buffer-read-only nil)
(erase-buffer)
(insert "Level 29 Post Statistics")
(newline)
(newline)
(insert "|------|-------|")
(newline)
(insert "| User | Posts |")
(newline)
(insert "|------|-------|")
(newline)
(maphash
(lambda (key value)
(insert (format "| %s | %s |" key value))
(newline))
post-frequency)
(insert "|------|-------|")
(newline)
(org-mode)
(forward-line -2)
(org-table-align)
(org-table-next-field)
(org-table-next-field)
(org-table-sort-lines nil ?N)
(setq buffer-read-only t)
(switch-to-buffer buffer)
(goto-char (point-min)))))
(provide 'bbs-fetch)