;;; 4g.el --- Browse 4chan in Org-Mode -*- lexical-binding: t; -*- (require 'url) (require 'json) (require 'subr-x) (require 'seq) (require 'map) (require 'rx) (require 'button) (require 'dom nil 'noerror) (defgroup 4g nil "View 4chan in Org-Mode." :group 'applications :prefix "4g-") ;;;###autoload (defcustom 4g-display-thumbnails nil "When non-nil, display thumbnail images in posts. For thumbnails to render, Org must be configured to display remote images via https. See https://emacs.stackexchange.com/a/73431 If this variable is nil/falsey, thumbnails are not displayed." :type 'boolean :group '4g :safe #'booleanp) ;;;###autoload (defcustom 4g-greentext-rendering :verbatim "How 4chan greentext (quotes) is rendered in Org. Values: - :verbatim — Enclose the text in Org =verbatim= markup. - :quote — Wrap the text in a QUOTE block (# +begin_quote ... # +end_quote). - :as-is — Insert the text as-is, without additional Org markup." :type '(choice (const :tag "Org =verbatim=" :verbatim) (const :tag "Org QUOTE block" :quote) (const :tag "Plain text (as-is)" :as-is)) :group '4g :safe (lambda (v) (memq v '(:verbatim :quote :as-is)))) ;;;###autoload (defcustom 4g-scroll-lock nil "When non-nil, start 4g-thread buffers with `scroll-lock-mode' enabled." :type 'boolean :group '4g :safe #'booleanp) ;;;###autoload (defcustom 4g--timestamp-format " [%F %T]" "Format string used with `format-time-string' to stamp each post. Examples: - \" [%F %T]\" → \" [2025-12-24 12:34:56]\" (an inactive Org timestamp) - \" [%F %T %Z]\" includes the timezone. - \" %c\" your locale's date and time format." :type 'string :group '4g :safe #'stringp) ;;;###autoload (defcustom 4g-lang-guess-function #'4g--guess-code-language "Function used to guess the Org src language from a CODE string. This function is called as: (funcall 4g-lang-guess-function CODE) It must return either a language name string suitable for `#+BEGIN_SRC` (e.g., \"clojure\", \"cpp\", \"emacs-lisp\") or nil if it cannot determine the language. When this function returns nil, the block's language defaults to elisp." :type '(choice (const :tag "Built-in detector (4g--guess-code-language)" 4g--guess-code-language) (function :tag "Custom function: (CODE) → language-or-nil")) :group '4g :safe (lambda (v) (functionp v))) (defvar-local 4g--boardname nil "Buffer-local variable for the 4chan board of the current buffer") (defvar-local 4g--threadno nil "Buffer-local variable for the 4chan thread number of the current buffer.") (defvar 4g--boards nil "Cache for parsed data from 4chan's boards.json API endpoint.") (defvar 4g--catalogs (make-hash-table :test 'eq) "Cache for parsed data from 4chan's catalog.json API endpoints.") (defconst 4g--msg-thumbs-disabled "You can enable thumbnails by setting ~4g-display-thumbnails~ to a non-nil value. Just move your cursor into the src block below and run ~M-x org-babel-execute-src-block~ #+begin_src elisp (customize-set-value '4g-display-thumbnails t) #+end_src To make this change permanent: #+begin_src elisp (customize-save-variable '4g-display-thumbnails t) #+end_src /(Additional steps may be required)/\n") (defconst 4g--msg-thumbs-enabled "If thumbnails don't show up, see https://emacs.stackexchange.com/a/73431\n") (defconst 4g--msg-security-headsup "*NOTE*: 4chan users can theoretically include links with malicious elisp code in their posts. Do not click on links before reading and understanding what they do.") ;;; --- Helpers ----------------------------------------------------------------- (defun 4g--string->keyword (s) (intern (concat ":" s))) (defun 4g--keyword-name (kw) "Return KW's name without the leading colon, or signal if not a keyword." (declare (pure t) (side-effect-free t)) (unless (keywordp kw) (signal 'wrong-type-argument (list 'keywordp kw))) (substring (symbol-name kw) 1)) ;;; --- Networking and Cache --------------------------------------------------- (defun 4g--fetch-json (url) "Retrieve URL and parse JSON. Returns PLIST objects and arrays as LISTs. Errors on failure." (message "Fetching %s..." url) (let ((buf (url-retrieve-synchronously url t t 30))) (unless buf (error "Failed to fetch %s" url)) (unwind-protect (with-current-buffer buf ;; Skip HTTP headers (goto-char (point-min)) (re-search-forward "\n\n" nil 'move) (json-parse-buffer :object-type 'plist :array-type 'array :null-object nil :false-object nil)) (kill-buffer buf)))) (defun 4g--fetch-boards (&optional url) (when-let* ((url (or url "https://a.4cdn.org/boards.json")) (res (4g--fetch-json url)) (boards (map-elt res :boards))) (setq 4g--boards boards))) ;;; --- UI --------------------------------------------------------------------- (defun 4g--setup-keybindings () (keymap-local-set "C-c 4 4" #'4g-refresh) (keymap-local-set "C-c 4 b" #'4g-board-list) (keymap-local-set "C-c 4 c" #'4g-catalog) (keymap-local-set "C-c 4 r" #'4g-refresh) (keymap-local-set "C-c 4 t" #'4g-thread) (keymap-local-set "C-c 4 v" #'4g-view-in-browser)) (defun 4g--picker (prompt candidates) (let* ((cands (map-keys candidates)) (lookup (lambda (x) (map-elt candidates x))) (affix (lambda (xs) (seq-map lookup xs))) (completion-extra-properties `(:affixation-function ,affix))) (completing-read prompt cands))) (defun 4g--prompt-board () (thread-last 4g--boards (seq-mapcat (lambda (b) (map-let (:board :title) b (list board (list (format "%-7s - %s" (format "/%s/" board) title) "" ""))))) (map-pairs) (4g--picker "board: "))) (defun 4g--insert-refresh-button () (insert-text-button "Refresh" 'help-echo "Refresh this buffer" 'follow-link t 'action (lambda (_) (4g-refresh)))) (defun 4g--insert-boardlist-button () (insert-text-button "Board List" 'help-echo "Open 4chan's board list" 'follow-link t 'action (lambda (_) (4g-board-list)))) (defun 4g--insert-catalog-button (board) (insert-text-button "Catalog" 'help-echo (format "Open /%s/'s catalog" board) 'follow-link t 'action (lambda (_) (4g-catalog board)))) (defun 4g--insert-thread-button (board threadno) (insert-text-button "View Thread" 'help-echo (format "Open thread %s on /%s/" threadno board) 'follow-link t 'action (lambda (_) (4g-thread board threadno)))) ;;; --- Document-internal -------------------------------------------------------- (defun 4g--crosslink (board thread no) (4g-thread board thread) (org-link-open-from-string (format "[[%s]]" no))) ;;; --- Text Processing (Regex-based) -------------------------------------------- (defun 4g--emojify-country-code (country) "Return the flag emoji for COUNTRY. Example: \"US\" → \"🇺🇸\". Accepts a two-letter string (case-insensitive). Returns nil if CODE is not 2 characters long or not between A and Z. Implementation detail: each A–Z maps to Regional Indicator Symbols U+1F1E6..U+1F1FF, which, when paired, render as a flag emoji." (declare (pure t) (side-effect-free t)) (unless (and country (stringp country)) (signal 'wrong-type-argument (list 'stringp country))) (let ((code (upcase (string-trim country)))) (when (= (length code) 2) (let* ((a (aref code 0)) (b (aref code 1)) (base 127462)) ;; U+1F1E6 (when (and (>= a ?A) (<= a ?Z) (>= b ?A) (<= b ?Z)) (string (+ base (- a ?A)) (+ base (- b ?A)))))))) (cl-defun 4g--find-all-matches (string regexp &key (group 0) (start 0)) "Find all matches of REGEXP in STRING and return them as a list." (let ((position start) matches) ;; REVIEW add safety check for zero-width matches? (while (string-match regexp string position) (push (match-string group string) matches) (setq position (match-end 0))) (nreverse matches))) ;; Reverse the list to preserve the order of matches (defconst 4g--html-linebreaks '(("
" . "\n") ("" . ""))) (defconst 4g--html-literals (map-pairs '(""" "\"" "'" "'" "'" "'" " " " " "<" "<" ">" ">" ;; IMPORTANT: & last to avoid double-unescaping "&" "&")) "HTML entities and their replacements, in the order in which they get replaced. These are all I've seen on 4chan. Open a PR for any others you come across.") (defconst 4g--simple-tags (map-pairs '("[eqn]" "$$" "[/eqn]" "$$" "" "\n:SPOILER:\n" "" "\n:END:\n"))) (defconst 4g--rx-code-with-lang (rx "" (or (+ (not alphabetic)) "rem" "REM") (* whitespace) "lang:" (* whitespace) (group (+ alphabetic) "
") (group (*? printing)) "") "Match a code block, capturing the body.") (defconst 4g--rx-code (rx "" (group (*? printing)) "") "Match a code block, capturing the body.") (defconst 4g--rx-quotelink-inpage (rx "") "Match an in-page quote link like capturing the id.") (defconst 4g--rx-other-link (rx ""))) (defconst 4g--rx-quotelink-crossthread (rx "") "Match a cross-thread quote link and capture the board, threadno and postno.") (defconst 4g--rx-greentext (rx "" (group (*? print)) "") "Match greentext spans and capture the contents.") (defconst 4g--rx-adjacent-quote-block (rx "#+end_quote" (+ (or whitespace "
")) "#+begin_quote")) (defun 4g--replace-regexes (text pairs) (seq-reduce (lambda (s p) (replace-regexp-in-string (car p) (cdr p) s t)) pairs text)) (defun 4g--replace-literals (text pairs) (seq-reduce (lambda (s p) (string-replace (car p) (cdr p) s)) pairs text)) (defun 4g--orgify-com-regex (com) "Convert board-style HTML comment COM into Org markup." (unless (and com (stringp com)) (signal 'wrong-type-argument (list 'stringp com))) ;; --- order matters! --- (thread-first com (4g--replace-regexes (map-pairs (list 4g--rx-code-with-lang "\n#+begin_src \\1\\2
#+end_src\n" 4g--rx-code "\n#+begin_src elisp
\\1
#+end_src\n" 4g--rx-quotelink-crossthread "[[elisp:(4g--crosslink \"\\1\" \\2 \\3)][>>\\3 (🔗 thread \\2)]]" 4g--rx-quotelink-inpage "[[\\1][>>\\1]]" 4g--rx-other-link "[[\\1]]" 4g--rx-greentext (pcase 4g-greentext-rendering (:quote "\n#+begin_quote\n\\1\n#+end_quote") (:as-is "\\1") (:verbatim "=\\1=")) ;; TODO factor this out to a different function to prevent unnecessary calls 4g--rx-adjacent-quote-block (if (eq :quote 4g-greentext-rendering) "" "\\0")))) (4g--replace-literals 4g--simple-tags) (4g--replace-literals 4g--html-linebreaks) (4g--replace-literals 4g--html-literals))) ;;; --- Text Processing (DOM-based) --------------------------------------------- (defconst 4g--rx-quotelink-crossthread-href (rx string-start "/" (group (+? (not "/"))) ; board (group 1) "/thread/" (group (+ digit)) "#p" ; thread number (group 2) (group (+ digit)) ; post no (group 3) string-end) "Match a cross-thread quote link and capture the board, threadno and postno.") (defconst 4g--rx-quotelink-crossboard-href (rx string-start "//boards.4chan.org/" (group (+? (not "/"))) ; board (group 1) "/thread/" (group (+ digit)) "#p" ; thread number (group 2) (group (+ digit)) ; post no (group 3) string-end) "Match a cross-board quote link and capture the board, threadno and postno.") (defconst 4g--rx-code-lang (rx string-start (or (+ (not alphabetic)) "rem" "REM") (* whitespace) "lang:" (* whitespace) (group (+ alphanumeric))) "Match a lang declaration in a code block, capturing the language.") (defconst 4g--lang-patterns ;; REVIEW: Using Treesitter would be another option to recognize languages, ;; but that relies on grammars being installed. ;; SLOP: Most of these are LLM-generated and untested. (map-pairs (list "(ns\\s-+\\w" :clojure "(defn\\s-+\\w" :clojure "#\\({\\|_\\)" :clojure "(->>?" :clojure "(defun\\s-+\\w" :emacs-lisp "(setq\\s-+\\w" :emacs-lisp "(use-package\\s-+\\w" :emacs-lisp "(interactive)" :emacs-lisp "(define\\s-*(\\w" :scheme "(lambda\\s-*\(" :scheme "\\bletrec\\b\\|\\bcond\\b" :scheme "(defmacro\\s-+\\w" :lisp "(defparameter\\s-+\\w" :lisp "(setf\\s-+\\w" :lisp "(let\\*?\\s-*\(" :lisp "(loop\\s+" :lisp ")))))" :emacs-lisp "\.unwrap()" :rust "public\s+class" :java "\\bstatic\\s+void\\s+main" :java "\\bSystem\\.out\\.println" :java "\\bimport\\s+java\\." :java "^#include" :cpp "^#define" :cpp "\\b(int|void)\\s+main\\s-*(" :cpp "\\bstd::" :cpp "using\\s+namespace\\s+std" :cpp "\\btemplate\\s-*<" :cpp "\\bprintf\\s-*\(" :cpp "^#!.*/\\(ba\\|z\\)?sh\\b" :shell "\\bfor\\s+\\w+\\s+in\\b.*;\\s*do" :shell "\\bthen\\b.*\\bfi\\b" :shell "^echo\\s+" :shell "^sudo\\s+" :shell "\\s--\\w+" :shell "\\s--?\\w+s*=.*$" :shell "\\bfunction\\s+\\w+\\s-*(" :javascript ")\\s*=>\\s*(" :javascript "\\bconsole\\.log(" :javascript "\\bimport\\s+.+\\s+from\\b" :javascript "\\bexport\\s+default\\b" :javascript "\\bdocument\\." :javascript "" :html "\\.[-_a-zA-Z0-9]+\\s-*{[^}]*:[^}]*}" :css "\\b[a-z-]+\\s-*:\\s*[^;]+;" :css "\\bpackage\\s+\\w+\\b" :go "\\bfunc\\s+\\w+\\s-*\(" :go "\\bfmt\\.[A-Za-z]+\\s-*\(" :go "\\bgo\\s+\\w+\\b" :go "\\busing\\s+System\\b" :csharp "\\bnamespace\\s+\\w+" :csharp "\\bpublic\\s+\\(class\\|struct\\)\\s+\\w+" :csharp "\\bstatic\\s+void\\s+Main\\s-*\(" :csharp "\\bConsole\\.WriteLine\\s-*\(" :csharp "\\bList<\\w+>" :csharp "\\bmodule\\s+\\w+\\s+where\\b" :haskell "\\bimport\\s+\\w+" :haskell "^[[:space:]]*\\w+\\s*::\\s*[^:]+$" :haskell "\\bwhere\\b" :haskell "\\bpub\\s+fn\\s+\\w+\\s-*" :zig "\\bconst\\s+\\w+\\s*=\\s*" :zig "@import\\s-*(\\s-*\"std\"\\s-*)" :zig "\\b\\(defer\\|errdefer\\|try\\)\\b" :zig "\\bfn\\s+\\w+\\s-*\(" :rust "\\blet\\s+mut\\b" :rust "\\bprintln!" :rust "\\buse\\s+\\w\\w*::" :rust "^struct\\b" :c "\\b\\(trait\\|impl\\)\\b" :rust)) "Mapping of regexps to lang names for use in `4g--guess-code-language'.") (defun 4g--lang-from-hashbang (code) (when (and (stringp code) (string-match (rx string-start "#!/usr/bin/env" (+ whitespace) (group (+ alnum))) (string-trim-left code))) (match-string 1 code))) (defun 4g--guess-code-language (code) "Guess the language of CODE. Returns nil if no language was recognized." (when-let ((s code) (case-fold-search t)) ;; be a bit forgiving with case (or (4g--lang-from-hashbang s) (map-some (lambda (regexp lang) (when (string-match-p regexp s) (4g--keyword-name lang))) 4g--lang-patterns)))) (defun 4g--com->nodes (html) "Convert a 4chan :com HTML fragment to dom nodes." (with-temp-buffer ;; REVIEW: replace with 'with-work-buffer' for Emacs 31+ ;; Wrap in a harmless container so fragments parse cleanly. (insert "
" (or html "") "
") (thread-first (libxml-parse-html-region (point-min) (point-max)) (dom-by-class "__frag") (dom-children)))) (defun 4g--orgify-com-dom (html) "Convert a 4chan :com HTML fragment to Org using libxml DOM." (mapconcat #'4g--node->org (4g--com->nodes html))) (defun 4g--node->org (node) "DOM node → Org string." (if (stringp node) node (let ((tag (dom-tag node)) (cls (dom-attr node 'class)) (children (mapconcat #'4g--node->org (dom-children node)))) (cond ((eq tag 'br) "\n") ;; is parsed as a node with children for some reason. ((eq tag 'wbr) children) ;; >greentext ;; TODO: merge adjacent #+quote blocks ((and (eq tag 'span) (string= cls "quote")) (format (pcase 4g-greentext-rendering (:verbatim "=%s=") (:quote "#+begin_quote\n%s\n#+end_quote") (:as-is "%s")) (string-trim-right children " "))) ;; [code] blocks ((and (eq tag 'pre) (string= cls "prettyprint") (let* ((lang (if (string-match 4g--rx-code-lang children) (match-string 1 children) (or (funcall 4g-lang-guess-function children) "elisp"))) (code (string-trim (replace-regexp-in-string 4g--rx-code-lang "" children)))) (format "\n#+begin_src %s\n%s\n#+end_src\n" lang code)))) ;; Quote links: >>123 or >>>/g/123 ((string= cls "quotelink") (let ((href (dom-attr node 'href))) (cond ;; in-page quotelinks like >>12346 ((string-prefix-p "#p" href) (format "[[%s][%s]]" (string-remove-prefix "#p" href) children)) ;; links to posts in other threads on the same board ((string-match 4g--rx-quotelink-crossthread-href href) (format "[[elisp:(4g--crosslink \"%s\" %s %s)][🔗%s (thread %s)]]" (match-string 1 href) (match-string 2 href) (match-string 3 href) children (match-string 2 href))) ;; cross-board links like >>>/g/123456 ((string-match 4g--rx-quotelink-crossboard-href href) (format "[[elisp:(4g--crosslink \"%s\" %s %s)][🔗%s]]" (match-string 1 href) (match-string 2 href) (match-string 3 href) children)) ;; catalog links like >>>/g/emacs ;; TODO: handle search terms with 'consult-org-heading' ((string-match (rx string-start ">>>/" (group (+? (not "/"))) "/") children) (format "[[elisp:(4g-catalog \"%s\")][🔗%s]]" (match-string 1 children) children))))) ((string= cls "deadlink") (concat "+" children "+")) ;; [spoiler] tags ((eq tag 's) (format "\n:SPOILER:\n%s\n:END:\n" children)) ;; TODO: catalog links, /sci/ and /qst/ stuff (t children))))) ;; libxml2 support is required for HTML parsing (fset '4g--orgify-com (if (fboundp 'libxml-parse-html-region) #'4g--orgify-com-dom #'4g--orgify-com-regex)) ;;; --- Data processing --------------------------------------------------------- (defun 4g--orgify-links (links) "Turn LINKS into a string of backlinks representing future replies to a post." (mapconcat (lambda (no) (format "[[%d][>>%d]]" no no)) links " ")) (defun 4g--find-backlinks (posts) (thread-last posts (seq-map (lambda (post) (when-let ((com (map-elt post :com)) (no (map-elt post :no))) (thread-last (4g--find-all-matches com 4g--rx-quotelink-inpage :group 1) (seq-map #'string-to-number) (seq-uniq) (seq-map (lambda (linkee) (cons linkee (list no)))))))) (apply #'map-merge-with 'hash-table #'append))) (defun 4g--add-backlinks (posts backlinks) (seq-map (lambda (post) (if-let ((links (map-elt backlinks (map-elt post :no)))) (map-insert post :backlinks links) post)) posts)) (defun 4g--expand-cooldown-info (board) (thread-last (map-elt board :cooldowns) (map-apply (lambda (k period) (cons (intern (concat (symbol-name k) "_cooldown")) period))) (map-merge 'alist board) (map-remove (lambda (k _) (eq k :cooldowns))))) ;;; --- Formatting pieces ------------------------------------------------------- (defun 4g--board-info->org (brd) (map-let (:board :title :meta_description) brd (concat (format "/%s/ - %s" board title) (format "\n[[elisp:(4g-catalog \"%s\")][Catalog]]\n" board) (4g--replace-literals meta_description 4g--html-literals) (thread-last brd (4g--expand-cooldown-info) (map-apply (lambda (k v) (unless (seq-contains-p [:board :title :meta_description :ws_board] k) (format "\n| %s | %s |" (string-replace "_" " " (4g--keyword-name k)) (pcase v (0 "no") (1 "yes") ((and n (pred numberp) (guard (> n 1000000))) (file-size-human-readable-iec n)) ((and s (pred stringp)) s) ((and s (pred plistp)) (map-length s)) ((and s (pred sequencep)) (length s)) (_ v)))))) (seq-filter #'stringp) (string-join))))) (defun 4g--image-link (post) "Return an Org link to the image attached in POST" (map-let (:tim :filename :ext :fsize) post (when (and tim filename ext) (let* ((filesize (file-size-human-readable-iec (or fsize 0))) (fname (format "%s%s" filename ext)) ;; REVIEW sanitization required? (cdn "https://i.4cdn.org/") (thumb-url (format "%s%s/%ss.jpg" cdn 4g--boardname tim)) (file-url (format "%s%s/%s%s" cdn 4g--boardname tim ext))) (if 4g-display-thumbnails (concat (format "[[eww:%s][%s (\U0001f4be %s)]]" file-url fname filesize) (format "\n[[%s]]" thumb-url)) ;; "\n" (format "[[%s][%s (💾 %s)]]" file-url fname filesize)))))) (defun 4g--reply->org (post) "Format one POST to Org text." (map-let (:name :id :time :no :com :country :trip :capcode :board_flag :filedeleted :backlinks (:ext file-attached)) post (string-trim (concat name (when trip (format "🪪%s" trip)) (when capcode (format "🍀%s" capcode)) (when country (4g--emojify-country-code country)) (when board_flag (format "(%s)" board_flag)) (when id (format " (🆔 ~%s~)" id)) (format-time-string 4g--timestamp-format time) (format " No.<<%s>>" no) (cond (filedeleted "\n[❌ *FILE DELETED*]") (file-attached (concat "\n" (or (4g--image-link post) "¯\\_(ツ)_/¯")))) (when com (concat "\n" (4g--orgify-com com))) (when backlinks (concat "\n\n💬 " (4g--orgify-links backlinks))))))) (defun 4g--thread-title (op) (thread-first op (map-elt :sub) (or (string-replace "-" " " (map-elt op :semantic_url ""))) (4g--replace-literals 4g--html-literals))) (defun 4g--op->org (thd) "Format the OP heading for a thread THD like: \"Title [R: x / I: y]\". shows `replies' and `images' as /italics/ when limits are set." (map-let (:replies :images :bumplimit :imagelimit :closed :sticky :archived) thd (let ((title (4g--replace-literals (4g--thread-title thd) 4g--html-literals)) (lock (if closed "🔒" "")) (pin (if sticky "📌" "")) (old (if archived "📦" "")) (r-str (if (eq 1 bumplimit) (format "/%s/" replies) (format "%s" replies))) (i-str (if (eq 1 imagelimit) (format "/%s/" images) (format "%s" images)))) ;; more intuitive but too attention-grabbing: "%s [💬 %s / 🖼️ %s]%s%s" (format "%s [R: %s / I: %s]%s%s%s" title r-str i-str pin lock old)))) (defun 4g--catalog-thread->org (thd) "Return a string for one thread THD with OP and its last_replies as ** items." (let* ((op-line (4g--op->org thd)) (op-reply (4g--reply->org thd)) (no (map-elt thd :no 0)) (replies (map-elt thd :last_replies)) (web-url (format "https://boards.4chan.org/%s/thread/%s" 4g--boardname no))) (concat "\n* " op-line (format "\n[[elisp:(4g-thread \"%s\" %s)][View Thread]]" 4g--boardname no) (format "\t[[%s][View in Web Browser]]" web-url) "\n** " op-reply (when replies (concat "\n** " (mapconcat #'4g--reply->org replies "\n\n** ")))))) ;;; --- Interactive Commands ---------------------------------------------------- ;;;###autoload (defun 4g-catalog (&optional board) "Prompt for a 4chan BOARD (like \"g\") and build an Org buffer from its catalog." (interactive) (let* ((board (or board (4g--prompt-board))) (url (format "https://a.4cdn.org/%s/catalog.json" board)) (pages (4g--fetch-json url)) (threads (seq-mapcat (lambda (page) (map-elt page :threads '())) pages)) (bufname (format "*4chan /%s/ catalog*" board)) (out (get-buffer-create bufname))) (with-current-buffer out (erase-buffer) (setq-local 4g--boardname board) (insert "#+TITLE: Catalog of 4chan /"board"/ as of " (format-time-string 4g--timestamp-format) "\n" (if 4g-display-thumbnails (concat "#+STARTUP: overview inlineimages\n" 4g--msg-thumbs-enabled) (concat "#+STARTUP: overview\n" 4g--msg-thumbs-disabled))) (4g--insert-refresh-button) (insert " ") (4g--insert-boardlist-button) (insert (format " [[https://boards.4chan.org/%s/catalog][Web]]" board) "\n" (mapconcat #'4g--catalog-thread->org threads)) (goto-char (point-min)) (when (require 'org nil 'noerror) (org-mode))) (switch-to-buffer out) (4g--setup-keybindings) ;; Needed for 4g-refresh: (setq-local 4g--boardname board) out)) ;;;###autoload (defun 4g-thread (&optional board no) "Prompt for a 4chan thread's BOARD and NO and build an Org buffer from it." (interactive) (when-let* ((board (or board (4g--prompt-board))) (no (or no (read-string "Thread number: "))) ;TODO offer completions (url (format "https://a.4cdn.org/%s/thread/%s.json" board no)) (web-url (format "https://boards.4chan.org/%s/thread/%s" board no)) (posts (map-elt (4g--fetch-json url) :posts)) (posts (4g--add-backlinks posts (4g--find-backlinks posts))) (op (seq-elt posts 0)) (title (4g--thread-title op)) (headline (4g--op->org op)) (replies (mapconcat #'4g--reply->org posts "\n\n** ")) (lweb (format "[[%s][Web]]" web-url)) (bufname (format "*4chan /%s/: %s [%s]*" board title no)) (out (get-buffer-create bufname))) (with-current-buffer out (erase-buffer) (setq-local 4g--boardname board) (insert (format "#+TITLE: 4chan /%s/%s: %s\n" board no title) (if 4g-display-thumbnails "#+STARTUP: inlineimages\n" "") 4g--msg-security-headsup "\n\n") (4g--insert-refresh-button) (insert " ") (4g--insert-catalog-button 4g--boardname) (insert " ") (4g--insert-boardlist-button) (insert " " lweb "\n\n* " headline "\n** " replies "\n\n") (4g--insert-refresh-button) (insert " ") (4g--insert-catalog-button 4g--boardname) (goto-char (point-min)) (when (require 'org nil 'noerror) (org-mode))) (switch-to-buffer out) (4g--setup-keybindings) (when 4g-scroll-lock (scroll-lock-mode 1)) ;; Needed for 4g-refresh: (setq-local 4g--boardname board 4g--threadno no) out)) ;;;###autoload (defun 4g-board-list () ;; TODO update 4g--boards "Fetch 4chan's board list and build an Org buffer from it." (interactive) (when-let* ((boards (or 4g--boards (4g--fetch-boards))) (wsafep (lambda (b) (eq 1 (map-elt b :ws_board)))) (wsafe (seq-filter wsafep boards)) (nsfw (seq-remove wsafep boards)) (bufname "*4chan Board List*") (out (get-buffer-create bufname))) (with-current-buffer out (erase-buffer) (insert "#+TITLE: 4chan Board List" "\n#+STARTUP: content" "\n\n* Safe-for-work boards" "\n** " (mapconcat #'4g--board-info->org wsafe "\n\n** ") "\n* NSFW boards" "\n** " (mapconcat #'4g--board-info->org nsfw "\n\n** ")) (when (require 'org nil 'noerror) (org-mode)) (when (require 'org-table nil 'noerror) (ignore-errors (org-table-map-tables #'org-table-align))) (goto-char (point-min))) (switch-to-buffer out) (4g--setup-keybindings) out)) ;; Credit to anonymous /g/ user 107218061 for figuring out how to make this work (defun 4g-refresh () "Fetch the data associated with the current 4g buffer and regenerate the latter." (interactive) (let* ((current-line (line-number-at-pos))) (cond (4g--threadno (4g-thread 4g--boardname 4g--threadno)) (4g--boardname (4g-catalog 4g--boardname)) ;; prompt because I figure if you ever refresh the BL it's because of a bug ((y-or-n-p "Load 4chan board list?") (4g-board-list))) (goto-line current-line) (recenter))) (defun 4g-view-in-browser () "Open web browser to the page corresponding to the current 4g buffer." (interactive) (cond (4g--threadno (browse-url (format "https://boards.4chan.org/%s/thread/%s" 4g--boardname 4g--threadno))) (4g--boardname (browse-url (format "https://boards.4chan.org/%s/" 4g--boardname))) ((y-or-n-p "No 4g thread or catalog buffer detected. Open 4chan's homepage?") (browse-url "https://www.4chan.org/")))) (provide '4g) ;;; --- Tests (ERT) --- (require 'ert) (ert-deftest 4g-country-emojification () (should (equal nil (4g--emojify-country-code "asdf"))) (should (equal "🇺🇸" (4g--emojify-country-code "US"))) (should (equal "🇬🇧" (4g--emojify-country-code "GB")))) (ert-deftest 4g--orgification-quote-links () (should (equal "[[107164637][>>107164637]]\nMay I ask why monke sad?" (4g--orgify-com "
>>107164637
May I ask why monke sad?")))) (ert-deftest 4g--orgification-code-blocks () (should (equal (concat "see you guys tomorrow\n" "#+begin_src c\n" "$ time ./a.out
$ time ./a.out <bigboy.txt
16666509048482

real 0m4.948s
"))))