diff options
| author | cashmere <cashmere@autistici.org> | 2026-08-24 02:17:03 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-08-24 02:17:03 +0200 |
| commit | 5aa60edd4386a4930d0c44b03a626f7d9263ac48 (patch) | |
| tree | 7faa3a9c568d329475f39edfec32faef973090b1 | |
| parent | 15def4cc1edbd5a80973fa9c5bba35d5eb32c340 (diff) | |
| download | emacs-5aa60edd4386a4930d0c44b03a626f7d9263ac48.tar.gz | |
include bbs.el
| -rw-r--r-- | config.org | 10 | ||||
| -rw-r--r-- | lisp/bbs-ui.el | 2770 |
2 files changed, 2780 insertions, 0 deletions
@@ -3545,6 +3545,16 @@ moved to local.el :after textui :commands (fossil-ui fossil-ui-status)) +(use-package bbs-ui + :ensure nil + :after textui + :commands (bbs bbs-open) + :custom + (bbs-ui-repository-file + (expand-file-name "bbs-sandbox.fossil" "/home/cashmere/bbs/")) + (bbs-ui-web-base-url "https://bbs.copland.systems") + (bbs-ui-pull-on-open nil)) + (use-package yggdrasil-ui :ensure nil :after textui diff --git a/lisp/bbs-ui.el b/lisp/bbs-ui.el new file mode 100644 index 0000000..b86f249 --- /dev/null +++ b/lisp/bbs-ui.el @@ -0,0 +1,2770 @@ +;;; bbs-ui.el --- A Fossil-native BBS for Emacs -*- lexical-binding: t; -*- + +;; Copyright (C) 2026 cashmere + +;; Author: cashmere +;; Version: 0.1.0 +;; Package-Requires: ((emacs "29.1") (textui "0.5.1") (markdown-mode "2.7")) +;; Keywords: applications, hypermedia, tools + +;;; Commentary: + +;; bbs-ui treats a Fossil repository as the durable local copy of a forum and +;; wiki. Reading is entirely local. Pull and push are deliberately separate: +;; opening the UI may pull, while publishing always requires an explicit, +;; confirmed command. Forum writes go through Fossil's own local web handler; +;; this package never writes Fossil's SQLite tables or constructs artifacts. + +;;; Code: + +(require 'browse-url) +(require 'cl-lib) +(require 'json) +(require 'markdown-mode) +(require 'seq) +(require 'subr-x) +(require 'textui) +(require 'textui-widgets) +(require 'url-parse) +(require 'url-util) +(require 'wid-edit) + +(declare-function evil-define-key* "evil-core" (state keymap key def &rest bindings)) +(declare-function evil-get-auxiliary-keymap "evil-core" + (map state &optional create noinherit)) +(declare-function evil-make-intercept-map "evil-core" (keymap &optional state aux)) +(declare-function evil-normalize-keymaps "evil-core" (&optional state)) +(declare-function evil-set-initial-state "evil-core" (mode state)) +(declare-function general-define-key "general" (&rest args)) +(declare-function general-local-map "general" ()) +(defvar general-override-local-mode-map) + +(defgroup bbs-ui nil + "A local-first TextUI client for Fossil forums and wikis." + :group 'applications + :prefix "bbs-ui-") + +(defcustom bbs-ui-repository-file nil + "Default Fossil repository opened by `bbs'. +When nil, `bbs' asks for a repository file." + :type '(choice (const :tag "Ask each time" nil) file) + :group 'bbs-ui) + +(defcustom bbs-ui-fossil-program "fossil" + "Fossil executable used by the BBS client." + :type 'string + :group 'bbs-ui) + +(defcustom bbs-ui-pull-on-open t + "Whether `bbs-open' starts a pull after rendering the local snapshot. +This option never runs sync or push." + :type 'boolean + :group 'bbs-ui) + +(defcustom bbs-ui-web-base-url nil + "Public browser URL for the BBS, independent of Fossil sync remotes. +This is used only after an explicit `bbs-ui-open-web' command and never by +opening, reading, refreshing, or composing in the Emacs client." + :type '(choice (const :tag "Use Fossil remote" nil) url) + :group 'bbs-ui) + +(defcustom bbs-ui-state-file nil + "JSON file used for read and saved markers. +Nil means bbs/state.json below `xdg-state-home'." + :type '(choice (const :tag "XDG state directory" nil) file) + :group 'bbs-ui) + +(defcustom bbs-ui-cache-directory nil + "Directory used for locally extracted avatars. +Nil means bbs below `xdg-cache-home'." + :type '(choice (const :tag "XDG cache directory" nil) directory) + :group 'bbs-ui) + +(defcustom bbs-ui-wide-layout-width 96 + "Minimum width for the BBS master/detail layout." + :type 'integer + :group 'bbs-ui) + +(defcustom bbs-ui-compact-layout-width 72 + "Width below which secondary BBS list columns are hidden." + :type 'integer + :group 'bbs-ui) + +(defcustom bbs-ui-board-limit 80 + "Maximum number of threads rendered in the board list." + :type 'integer + :group 'bbs-ui) + +(defface bbs-ui-logo + '((t :foreground "#ffaf5f" :weight bold)) + "The BBS wordmark." + :group 'bbs-ui) + +(defface bbs-ui-cyan + '((t :foreground "#7ee8fa")) + "Cyan structural accents." + :group 'bbs-ui) + +(defface bbs-ui-magenta + '((t :foreground "#c792ea" :weight bold)) + "Magenta labels and important accents." + :group 'bbs-ui) + +(defface bbs-ui-green + '((t :foreground "#c3e88d" :weight bold)) + "Connected and completed state." + :group 'bbs-ui) + +(defface bbs-ui-yellow + '((t :foreground "#ffc777" :weight bold)) + "Unread and queued state." + :group 'bbs-ui) + +(defface bbs-ui-blue + '((t :foreground "#82aaff")) + "Actionable BBS text." + :group 'bbs-ui) + +(defface bbs-ui-muted + '((t :foreground "#697098")) + "Secondary BBS text." + :group 'bbs-ui) + +(defface bbs-ui-selected + '((t :background "#193a73" :foreground "#f8f8f2" :weight bold)) + "The selected BBS row." + :group 'bbs-ui) + +(defface bbs-ui-deleted + '((t :inherit shadow :strike-through t)) + "Deleted forum posts." + :group 'bbs-ui) + +(defface bbs-ui-frame + '((t :foreground "#315d9c")) + "Outer rails and pane separators." + :group 'bbs-ui) + +(defface bbs-ui-bar + '((t :background "#001a38" :foreground "#7ee8fa" :weight bold)) + "Persistent BBS header and command bars." + :group 'bbs-ui) + +(defface bbs-ui-error + '((t :background "#3a1018" :foreground "#ff757f" :weight bold)) + "Recoverable BBS error notices." + :group 'bbs-ui) + +(defvar-local bbs-ui--process nil) +(defvar-local bbs-ui--window-height nil) +(defvar-local bbs-ui--compose-owner nil) +(defvar-local bbs-ui--compose-kind nil) +(defvar-local bbs-ui--compose-target nil) +(defvar-local bbs-ui--compose-title nil) +(defvar-local bbs-ui--compose-mimetype nil) +(defvar-local bbs-ui--compose-page nil) +(defvar-local bbs-ui--diagnostics nil) +(defvar-local bbs-ui--last-render-error nil) +(defvar-local bbs-ui--keys-active nil) + +(defun bbs-ui--set-notice (text) + "Display transient status TEXT in the persistent BBS footer." + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :error nil)) + (plist-put next :notice text))))) + +(defun bbs-ui--single-line-string (value) + "Return VALUE with every cursor-breaking character made horizontal." + (let ((text (format "%s" (or value "")))) + ;; `truncate-string-to-width' may append a Unicode ellipsis. Ensure its + ;; destination is never an unibyte string first. + (unless (multibyte-string-p text) + (setq text (string-to-multibyte text))) + (replace-regexp-in-string "[\n\r\t]+" " " text))) + +(defun bbs-ui--measure-row (widget) + "Return the visible value of BBS row WIDGET." + (let ((value (bbs-ui--single-line-string (widget-get widget :value)))) + (when (string-match-p "[\n\r]" value) + (error "BBS interactive rows must be exactly one line")) + value)) + +(defun bbs-ui--attach-row (widget from to) + "Attach WIDGET and its BBS identity between FROM and TO." + (textui-widgets-attach-button widget from to) + (add-text-properties + from to + (list 'bbs-ui-kind (widget-get widget :bbs-ui-kind) + 'bbs-ui-id (widget-get widget :bbs-ui-id)))) + +(define-widget 'bbs-ui-row 'push-button + "A flat, focusable BBS row." + :format "%v" + :button-face 'default + :textui-measure #'bbs-ui--measure-row + :textui-attach #'bbs-ui--attach-row) + +(define-widget 'bbs-ui-keycap 'push-button + "A compact BBS command button." + :format "%v" + :button-face 'default + :textui-measure #'bbs-ui--measure-row + :textui-attach #'textui-widgets-attach-button) + +(defun bbs-ui--state-path () + "Return the configured persistent state path." + (expand-file-name + (or bbs-ui-state-file + (expand-file-name "bbs/state.json" (xdg-state-home))))) + +(defun bbs-ui--cache-path () + "Return the configured cache directory." + (file-name-as-directory + (expand-file-name + (or bbs-ui-cache-directory + (expand-file-name "bbs" (xdg-cache-home)))))) + +(defun bbs-ui--call-raw (input &rest args) + "Run Fossil ARGS with optional INPUT and return (CODE OUTPUT). +OUTPUT is decoded as raw bytes so Fossil W-card byte lengths remain valid." + (with-temp-buffer + (when input + (set-buffer-multibyte nil) + (insert (encode-coding-string input 'utf-8))) + (let ((coding-system-for-read 'binary) + (coding-system-for-write 'binary)) + (list + (condition-case err + (if input + (apply #'call-process-region + (point-min) (point-max) bbs-ui-fossil-program + t (list t t) nil args) + (apply #'process-file bbs-ui-fossil-program nil (list t t) nil args)) + (file-missing + (erase-buffer) + (insert (error-message-string err)) + 127) + (error + (erase-buffer) + (insert (error-message-string err)) + 1)) + (buffer-string))))) + +(defun bbs-ui--call (&rest args) + "Run Fossil ARGS and return (CODE decoded-output)." + (pcase-let ((`(,code ,raw) (apply #'bbs-ui--call-raw nil args))) + (list code (string-trim-right (decode-coding-string raw 'utf-8))))) + +(defun bbs-ui--require (&rest args) + "Run Fossil ARGS and return output, or signal a user error." + (pcase-let ((`(,code ,output) (apply #'bbs-ui--call args))) + (if (zerop code) + output + (user-error "%s" (if (string-empty-p output) + (format "Fossil exited with status %d" code) + output))))) + +(defun bbs-ui--sql (repository query) + "Run read-only Fossil SQL QUERY against REPOSITORY." + (bbs-ui--require "sql" "-R" repository query)) + +(defun bbs-ui--hex-decode (hex) + "Decode UTF-8 text represented by HEX." + (unless (zerop (% (length hex) 2)) + (error "Odd hexadecimal payload length")) + (let ((bytes (make-string (/ (length hex) 2) 0))) + (dotimes (index (/ (length hex) 2)) + (aset bytes index + (string-to-number (substring hex (* 2 index) (+ 2 (* 2 index))) 16))) + (decode-coding-string (encode-coding-string bytes 'binary) 'utf-8))) + +(defun bbs-ui--hex-bytes (hex) + "Decode HEX to an unibyte string." + (let ((bytes (make-string (/ (length hex) 2) 0))) + (dotimes (index (/ (length hex) 2)) + (aset bytes index + (string-to-number (substring hex (* 2 index) (+ 2 (* 2 index))) 16))) + (encode-coding-string bytes 'binary))) + +(defun bbs-ui--fossil-unescape (value) + "Decode Fossil card escapes in VALUE." + (let ((index 0) + (limit (length value)) + pieces) + (while (< index limit) + (let ((char (aref value index))) + (if (and (= char ?\\) (< (1+ index) limit)) + (let ((escaped (aref value (1+ index)))) + (push (char-to-string + (pcase escaped + (?s ?\s) (?n ?\n) (?r ?\r) (?t ?\t) (?\\ ?\\) + (_ escaped))) + pieces) + (setq index (+ index 2))) + (push (char-to-string char) pieces) + (setq index (1+ index))))) + (apply #'concat (nreverse pieces)))) + +(defun bbs-ui--parse-artifact (raw) + "Parse the Fossil forum or wiki artifact RAW into a plist." + (let ((position 0) + (limit (length raw)) + cards body) + (while (< position limit) + (let ((newline (string-search "\n" raw position))) + (if (not newline) + (setq position limit) + (let* ((line-bytes (substring raw position newline)) + (line (decode-coding-string line-bytes 'utf-8))) + (setq position (1+ newline)) + (when (string-match "\\`\\([A-Z]\\)\\(?: \\(.*\\)\\)?\\'" line) + (let ((card (string-to-char (match-string 1 line))) + (value (or (match-string 2 line) ""))) + (if (= card ?W) + (let ((count (string-to-number value))) + (when (> (+ position count) limit) + (error "Truncated Fossil W card")) + (setq body (decode-coding-string + (substring raw position (+ position count)) 'utf-8) + position (+ position count)) + (when (and (< position limit) (= (aref raw position) ?\n)) + (setq position (1+ position)))) + (push (cons card (bbs-ui--fossil-unescape value)) cards)))))))) + (list :date (cdr (assq ?D cards)) + :title (cdr (assq ?H cards)) + :root (cdr (assq ?G cards)) + :irt (cdr (assq ?I cards)) + :mimetype (or (cdr (assq ?N cards)) "text/x-fossil-wiki") + :previous (cdr (assq ?P cards)) + :user (or (cdr (assq ?U cards)) "unknown") + :wiki-name (cdr (assq ?L cards)) + :body (or body "")))) + +(defun bbs-ui--parse-time (value) + "Convert Fossil ISO timestamp VALUE to seconds since the epoch." + (condition-case nil + (if (string-match + "\\`\\([0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]\\)\\(?:\\.\\([0-9]+\\)\\)?Z?\\'" + value) + (let ((base (match-string 1 value)) + (fraction (match-string 2 value))) + (+ (float-time (date-to-time (concat base "Z"))) + (if fraction + (/ (string-to-number fraction) + (float (expt 10 (length fraction)))) + 0.0))) + (float-time (date-to-time value))) + (error 0.0))) + +(defun bbs-ui--rows (output fields) + "Split SQL OUTPUT into rows containing exactly FIELDS columns." + (mapcar + (lambda (line) + (let ((parts (split-string line "|" nil))) + (unless (= (length parts) fields) + (error "Unexpected Fossil SQL row: %s" line)) + parts)) + (split-string output "\n" t))) + +(defconst bbs-ui--forum-query + (concat + "SELECT hex(b.uuid),p.fpid,p.froot,coalesce(p.fprev,0)," + "coalesce(p.firt,0),hex(content(b.uuid)) " + "FROM forumpost p JOIN blob b ON b.rid=p.fpid " + "ORDER BY p.fmtime,p.fpid;") + "Read-only query used to load complete forum artifacts.") + +(defun bbs-ui--load-forum-versions (repository) + "Load raw forum versions from REPOSITORY." + (if (string= "0" (bbs-ui--sql + repository + "SELECT count(*) FROM sqlite_schema WHERE name='forumpost';")) + nil + (mapcar + (lambda (columns) + (pcase-let ((`(,uuid-hex ,rid ,root ,prev ,irt ,artifact-hex) columns)) + (let* ((uuid (bbs-ui--hex-decode uuid-hex)) + (artifact (bbs-ui--parse-artifact + (bbs-ui--hex-bytes artifact-hex)))) + (append + (list :uuid uuid + :rid (string-to-number rid) + :root-rid (string-to-number root) + :previous-rid (string-to-number prev) + :irt-rid (string-to-number irt) + :time (bbs-ui--parse-time (plist-get artifact :date))) + artifact)))) + (bbs-ui--rows (bbs-ui--sql repository bbs-ui--forum-query) 6)))) + +(defun bbs-ui--collapse-posts (versions) + "Collapse forum VERSIONS into logical posts and threads." + (let ((by-rid (make-hash-table :test #'eql)) + (logical-cache (make-hash-table :test #'eql)) + (groups (make-hash-table :test #'eql)) + (aliases (make-hash-table :test #'equal)) + logical-posts) + (dolist (version versions) + (puthash (plist-get version :rid) version by-rid)) + (cl-labels + ((logical-rid + (rid &optional seen) + (or (gethash rid logical-cache) + (let* ((version (gethash rid by-rid)) + (previous (and version (plist-get version :previous-rid))) + (logical + (if (and previous (> previous 0) (not (memq rid seen))) + (logical-rid previous (cons rid seen)) + rid))) + (puthash rid logical logical-cache) + logical)))) + (dolist (version versions) + (let* ((logical (logical-rid (plist-get version :rid))) + (existing (gethash logical groups))) + (puthash logical (cons version existing) groups))) + (maphash + (lambda (logical history) + (setq history (sort history + (lambda (a b) + (if (= (plist-get a :time) (plist-get b :time)) + (< (plist-get a :rid) (plist-get b :rid)) + (< (plist-get a :time) (plist-get b :time)))))) + (let* ((base (or (gethash logical by-rid) (car history))) + (current (car (last history))) + (post (copy-sequence current))) + (setq post (plist-put post :logical-rid logical) + post (plist-put post :logical-id (plist-get base :uuid)) + post (plist-put post :history history) + post (plist-put post :edited (> (length history) 1)) + post (plist-put post :deleted + (string-empty-p (plist-get current :body)))) + (dolist (version history) + (puthash (plist-get version :uuid) (plist-get base :uuid) aliases)) + (push post logical-posts))) + groups)) + (dolist (post logical-posts) + (let* ((root-version (gethash (plist-get post :root-rid) by-rid)) + (root-id (and root-version + (gethash (plist-get root-version :uuid) aliases))) + (irt (plist-get post :irt))) + (setq post (plist-put post :thread-id + (or root-id (plist-get post :logical-id))) + post (plist-put post :reply-to (and irt (gethash irt aliases)))))) + (list logical-posts aliases))) + +(defun bbs-ui--jget (key object) + "Get KEY from JSON alist OBJECT regardless of key representation." + (or (alist-get key object) + (alist-get (if (symbolp key) (symbol-name key) (intern-soft key)) object nil nil + #'equal) + (alist-get (if (stringp key) key (symbol-name key)) object nil nil #'equal))) + +(defun bbs-ui--optional-json (repository name) + "Read unversioned JSON NAME locally from REPOSITORY, or return nil." + (pcase-let ((`(,code ,output) + (bbs-ui--call "uv" "cat" "-R" repository name))) + (when (zerop code) + (condition-case nil + (json-parse-string output :object-type 'alist :array-type 'list + :null-object nil :false-object nil) + (error nil))))) + +(defun bbs-ui--thread-metadata (root-id metadata) + "Return optional thread data for ROOT-ID from METADATA." + (and metadata + (bbs-ui--jget root-id (bbs-ui--jget 'threads metadata)))) + +(defun bbs-ui--rank-for (user ranks) + "Return USER's optional Copland rank from RANKS." + (or (and ranks (bbs-ui--jget user (bbs-ui--jget 'users ranks))) "node")) + +(defun bbs-ui--build-threads (posts forum-meta ranks) + "Build logical threads from POSTS and optional FORUM-META and RANKS." + (let ((groups (make-hash-table :test #'equal)) + threads) + (dolist (post posts) + (let ((root (plist-get post :thread-id))) + (puthash root (cons post (gethash root groups)) groups))) + (maphash + (lambda (root-id members) + (let* ((sorted (sort members + (lambda (a b) (< (plist-get a :time) + (plist-get b :time))))) + (root (or (cl-find root-id sorted + :key (lambda (post) (plist-get post :logical-id)) + :test #'equal) + (car sorted))) + (metadata (bbs-ui--thread-metadata root-id forum-meta)) + (updated (apply #'max (mapcar (lambda (post) (plist-get post :time)) + sorted)))) + (dolist (post sorted) + (setq post (plist-put post :rank + (bbs-ui--rank-for (plist-get post :user) ranks)))) + (push (list :id root-id + :title (or (plist-get root :title) "Untitled transmission") + :root root + :posts sorted + :updated updated + :author (plist-get root :user) + :rank (bbs-ui--rank-for (plist-get root :user) ranks) + :reply-count (max 0 (1- (length sorted))) + :pinned (and metadata (bbs-ui--jget 'pinned metadata)) + :labels (and metadata (bbs-ui--jget 'labels metadata))) + threads))) + groups) + (sort threads + (lambda (a b) + (if (eq (not (plist-get a :pinned)) (not (plist-get b :pinned))) + (> (plist-get a :updated) (plist-get b :updated)) + (plist-get a :pinned)))))) + +(defconst bbs-ui--wiki-query + (concat + "WITH versions AS (" + " SELECT substr(e.comment,2) AS page,e.comment,e.mtime,e.user,b.uuid," + " content(b.uuid) AS artifact," + " row_number() OVER (PARTITION BY substr(e.comment,2)" + " ORDER BY e.mtime DESC,e.objid DESC) AS n" + " FROM event e JOIN blob b ON b.rid=e.objid" + " WHERE e.type='w' AND substr(e.comment,1,1) IN('+',':','-')" + ") SELECT hex(page),hex(coalesce(user,'')),hex(uuid),hex(artifact)" + " FROM versions WHERE n=1 AND substr(comment,1,1) IN('+',':')" + " ORDER BY page COLLATE nocase;") + "Read-only query used to load current wiki pages.") + +(defun bbs-ui--load-wiki (repository) + "Load current wiki pages from REPOSITORY." + (mapcar + (lambda (columns) + (pcase-let ((`(,page-hex ,user-hex ,uuid-hex ,artifact-hex) columns)) + (let* ((artifact (bbs-ui--parse-artifact + (bbs-ui--hex-bytes artifact-hex))) + (date (plist-get artifact :date))) + (list :name (bbs-ui--hex-decode page-hex) + :user (bbs-ui--hex-decode user-hex) + :uuid (bbs-ui--hex-decode uuid-hex) + :mimetype (plist-get artifact :mimetype) + :body (plist-get artifact :body) + :time (bbs-ui--parse-time date))))) + (bbs-ui--rows (bbs-ui--sql repository bbs-ui--wiki-query) 4))) + +(defun bbs-ui--config-value (repository name) + "Return Fossil configuration NAME from REPOSITORY." + (let* ((quoted (replace-regexp-in-string "'" "''" name t t)) + (output (bbs-ui--sql + repository (format "SELECT hex(value) FROM config WHERE name='%s';" + quoted)))) + (unless (string-empty-p output) + (bbs-ui--hex-decode (car (split-string output "\n" t)))))) + +(defun bbs-ui--safe-remote (remote) + "Remove a password, if any, from REMOTE." + (if (and remote + (string-match "\\`\\(https?://[^:/@]+\\):[^@/]+@" remote)) + (replace-match "\\1@" t nil remote) + remote)) + +(defun bbs-ui--remote (repository) + "Return the configured sanitized remote for REPOSITORY." + (pcase-let ((`(,code ,output) (bbs-ui--call "remote" "-R" repository))) + (and (zerop code) (not (string-empty-p output)) (not (string= output "off")) + (bbs-ui--safe-remote output)))) + +(defun bbs-ui--user (repository) + "Return the default Fossil user for REPOSITORY." + (pcase-let ((`(,code ,output) + (bbs-ui--call "user" "default" "-R" repository))) + (if (and (zerop code) (not (string-empty-p output))) output "unknown"))) + +(defun bbs-ui--unsent (repository) + "Return all unpublished local artifacts in REPOSITORY." + (mapcar + (lambda (columns) + (pcase-let ((`(,uuid-hex ,type-hex ,comment-hex ,user-hex) columns)) + (list :uuid (bbs-ui--hex-decode uuid-hex) + :type (bbs-ui--hex-decode type-hex) + :comment (bbs-ui--hex-decode comment-hex) + :user (bbs-ui--hex-decode user-hex)))) + (bbs-ui--rows + (bbs-ui--sql + repository + (concat + "SELECT hex(b.uuid),hex(coalesce(e.type,''))," + "hex(coalesce(e.comment,'')),hex(coalesce(e.user,'')) " + "FROM unsent u JOIN blob b ON b.rid=u.rid " + "LEFT JOIN event e ON e.objid=u.rid ORDER BY u.rid;")) + 4))) + +(defun bbs-ui--read-json-file (file) + "Read FILE as JSON, returning nil for absent or malformed files." + (when (file-readable-p file) + (condition-case nil + (json-parse-string + (with-temp-buffer + (insert-file-contents file) + (buffer-string)) + :object-type 'alist :array-type 'array :null-object nil :false-object nil) + (error nil)))) + +(defun bbs-ui--project-local-state (project-code) + "Load read and saved state for PROJECT-CODE." + (let* ((data (bbs-ui--read-json-file (bbs-ui--state-path))) + (projects (bbs-ui--jget 'projects data)) + (project (cl-find project-code projects + :key (lambda (entry) (bbs-ui--jget 'code entry)) + :test #'equal))) + (list :read (mapcar + (lambda (entry) + (cons (bbs-ui--jget 'id entry) (bbs-ui--jget 'time entry))) + (or (bbs-ui--jget 'read project) nil)) + :saved (append (or (bbs-ui--jget 'saved project) nil) nil)))) + +(defun bbs-ui--write-json-atomically (file object) + "Write JSON OBJECT atomically to FILE." + (make-directory (file-name-directory file) t) + (let ((temporary (make-temp-file + (expand-file-name ".bbs-state-" (file-name-directory file))))) + (unwind-protect + (progn + (with-temp-file temporary + (insert (json-serialize object :null-object nil :false-object :false)) + (insert "\n")) + (set-file-modes temporary #o600) + (rename-file temporary file t)) + (when (file-exists-p temporary) + (delete-file temporary))))) + +(defun bbs-ui--save-project-state (project-code local-state) + "Persist LOCAL-STATE for PROJECT-CODE." + (let* ((file (bbs-ui--state-path)) + (data (or (bbs-ui--read-json-file file) + '((version . 1) (projects . [])))) + (projects (append (bbs-ui--jget 'projects data) nil)) + (entry `((code . ,project-code) + (read . ,(vconcat + (mapcar + (lambda (cell) + `((id . ,(car cell)) (time . ,(cdr cell)))) + (or (plist-get local-state :read) nil)))) + (saved . ,(vconcat (or (plist-get local-state :saved) nil))))) + (found nil) + next) + (dolist (project projects) + (if (equal (bbs-ui--jget 'code project) project-code) + (progn (push entry next) (setq found t)) + (push project next))) + (unless found (push entry next)) + (bbs-ui--write-json-atomically + file `((version . 1) (projects . ,(vconcat (nreverse next))))))) + +(defun bbs-ui--avatar-files (repository) + "Return an alist of username to local avatar cache from REPOSITORY." + (pcase-let ((`(,code ,output) + (bbs-ui--call "uv" "list" "-R" repository "--glob" "avatars/*"))) + (let (result) + (when (zerop code) + (dolist (line (split-string output "\n" t)) + (when (string-match + "\\`\\([[:xdigit:]]+\\).*?[[:space:]]\\(avatars/[^[:space:]]+\\)\\'" + line) + (let* ((hash (match-string 1 line)) + (uv-name (match-string 2 line)) + (base (file-name-nondirectory uv-name)) + (user (file-name-base base)) + (directory (expand-file-name "avatars" (bbs-ui--cache-path))) + (target (expand-file-name + (format "%s-%s" (substring hash 0 (min 12 (length hash))) + base) + directory))) + (unless (file-readable-p target) + (make-directory directory t) + (pcase-let ((`(,export-code ,_) + (bbs-ui--call "uv" "export" "-R" repository + uv-name target))) + (unless (zerop export-code) + (setq target nil)))) + (when target (push (cons user target) result))))) + result)))) + +(defun bbs-ui--profiles (posts wiki ranks avatars) + "Build profiles from POSTS, WIKI, RANKS and AVATARS." + (let ((names (make-hash-table :test #'equal)) + result) + (dolist (post posts) (puthash (plist-get post :user) t names)) + (dolist (page wiki) + (when (string-match "\\`users/\\(.+\\)\\'" (plist-get page :name)) + (puthash (match-string 1 (plist-get page :name)) t names))) + (when ranks + (dolist (entry (bbs-ui--jget 'users ranks)) + (puthash (if (symbolp (car entry)) (symbol-name (car entry)) (car entry)) + t names))) + (maphash + (lambda (name _) + (let ((page (cl-find (concat "users/" name) wiki + :key (lambda (entry) (plist-get entry :name)) + :test #'equal))) + (push (list :name name + :rank (bbs-ui--rank-for name ranks) + :bio (and page (plist-get page :body)) + :avatar (cdr (assoc-string name avatars t)) + :posts (cl-count name posts + :key (lambda (post) (plist-get post :user)) + :test #'equal)) + result))) + names) + (sort result (lambda (a b) (string-lessp (downcase (plist-get a :name)) + (downcase (plist-get b :name))))))) + +(defun bbs-ui--snapshot (repository &optional previous-state) + "Build a complete local snapshot of REPOSITORY. +PREVIOUS-STATE contributes current navigation and transient status." + (setq repository (expand-file-name repository)) + (unless (file-readable-p repository) + (user-error "Unreadable Fossil repository: %s" repository)) + (unless (executable-find bbs-ui-fossil-program) + (user-error "Cannot find Fossil executable: %s" bbs-ui-fossil-program)) + (let* ((project (or (bbs-ui--config-value repository "project-name") + (file-name-base repository))) + (project-code (or (bbs-ui--config-value repository "project-code") + (secure-hash 'sha256 repository))) + (versions (bbs-ui--load-forum-versions repository)) + (collapsed (bbs-ui--collapse-posts versions)) + (posts (car collapsed)) + (forum-meta (bbs-ui--optional-json repository "bbs/forum-meta.json")) + (ranks (bbs-ui--optional-json repository "bbs/ranks.json")) + (wiki (bbs-ui--load-wiki repository)) + (avatars (bbs-ui--avatar-files repository)) + (threads (bbs-ui--build-threads posts forum-meta ranks)) + (profiles (bbs-ui--profiles posts wiki ranks avatars)) + (local-state (or (and previous-state (plist-get previous-state :local-state)) + (bbs-ui--project-local-state project-code))) + (view (or (and previous-state (plist-get previous-state :view)) 'board)) + (selected (or (and previous-state (plist-get previous-state :selected-root)) + (and threads (plist-get (car threads) :id))))) + (list :repository repository + :project project + :project-code project-code + :remote (bbs-ui--remote repository) + :user (bbs-ui--user repository) + :versions versions + :posts posts + :threads threads + :wiki wiki + :profiles profiles + :forum-meta forum-meta + :ranks ranks + :local-state local-state + :view view + :view-id (and previous-state (plist-get previous-state :view-id)) + :selected-root selected + :selected-post + (or (and previous-state (plist-get previous-state :selected-post)) + (and threads + (plist-get (plist-get (car threads) :root) :logical-id))) + :selected-wiki + (or (and previous-state (plist-get previous-state :selected-wiki)) + (and wiki (plist-get (car wiki) :name))) + :selected-profile + (or (and previous-state (plist-get previous-state :selected-profile)) + (and profiles (plist-get (car profiles) :name))) + :selected-saved (and previous-state (plist-get previous-state :selected-saved)) + :selected-search (and previous-state (plist-get previous-state :selected-search)) + :active-pane (or (and previous-state (plist-get previous-state :active-pane)) + 'list) + :board-offset (or (and previous-state (plist-get previous-state :board-offset)) 0) + :thread-offset (or (and previous-state (plist-get previous-state :thread-offset)) 0) + :wiki-offset (or (and previous-state (plist-get previous-state :wiki-offset)) 0) + :users-offset (or (and previous-state (plist-get previous-state :users-offset)) 0) + :saved-offset (or (and previous-state (plist-get previous-state :saved-offset)) 0) + :search-offset (or (and previous-state (plist-get previous-state :search-offset)) 0) + :body-offset (or (and previous-state (plist-get previous-state :body-offset)) 0) + :query (and previous-state (plist-get previous-state :query)) + :unsent (bbs-ui--unsent repository) + :busy (and previous-state (plist-get previous-state :busy)) + :connection (or (and previous-state (plist-get previous-state :connection)) + 'local) + :error nil + :notice (and previous-state (plist-get previous-state :notice)) + :diagnostic-id (and previous-state (plist-get previous-state :diagnostic-id)) + :height (or (and previous-state (plist-get previous-state :height)) 32)))) + +(defun bbs-ui--find-thread (id) + "Find thread ID in current TextUI state." + (cl-find id (plist-get textui-state :threads) + :key (lambda (thread) (plist-get thread :id)) :test #'equal)) + +(defun bbs-ui--find-post (id) + "Find logical post ID in current TextUI state." + (cl-find id (plist-get textui-state :posts) + :key (lambda (post) (plist-get post :logical-id)) :test #'equal)) + +(defun bbs-ui--find-wiki (name) + "Find wiki page NAME in current TextUI state." + (cl-find name (plist-get textui-state :wiki) + :key (lambda (page) (plist-get page :name)) :test #'equal)) + +(defun bbs-ui--find-profile (name) + "Find profile NAME in current TextUI state." + (cl-find name (plist-get textui-state :profiles) + :key (lambda (profile) (plist-get profile :name)) :test #'equal)) + +(defun bbs-ui--read-time (thread-id) + "Return last read timestamp for THREAD-ID." + (let ((read (plist-get (plist-get textui-state :local-state) :read))) + (cdr (assoc-string thread-id read t)))) + +(defun bbs-ui--unread-p (thread) + "Return non-nil when THREAD is newer than its local read marker." + (< (or (bbs-ui--read-time (plist-get thread :id)) 0) + (plist-get thread :updated))) + +(defun bbs-ui--persist-local-state (local-state) + "Persist LOCAL-STATE and install it in the current BBS buffer." + (bbs-ui--save-project-state (plist-get textui-state :project-code) local-state) + (textui-set-state (current-buffer) :local-state local-state)) + +(defun bbs-ui--mark-thread-read (thread) + "Mark THREAD read through its latest local update." + (let* ((local (copy-tree (plist-get textui-state :local-state))) + (read (copy-tree (plist-get local :read))) + (id (plist-get thread :id)) + (cell (assoc-string id read t))) + (if cell + (setcdr cell (plist-get thread :updated)) + (push (cons id (plist-get thread :updated)) read)) + (setq local (plist-put local :read read)) + (bbs-ui--persist-local-state local))) + +(defun bbs-ui--saved-p (id) + "Return non-nil when logical post ID is saved." + (member id (plist-get (plist-get textui-state :local-state) :saved))) + +(defun bbs-ui-toggle-save () + "Toggle the post or thread at point in Saved." + (interactive) + (let* ((target (or (bbs-ui--semantic-target) + (cons (bbs-ui--kind-at-point) (bbs-ui--id-at-point)))) + (kind (car target)) + (id (cdr target)) + (post-id + (pcase kind + ('post id) + ('thread (and-let* ((thread (bbs-ui--find-thread id))) + (plist-get (plist-get thread :root) :logical-id))) + (_ nil)))) + (unless post-id (user-error "No post at point")) + (let* ((local (copy-tree (plist-get textui-state :local-state))) + (saved (copy-sequence (plist-get local :saved)))) + (setq saved (if (member post-id saved) (delete post-id saved) + (cons post-id saved)) + local (plist-put local :saved saved)) + (bbs-ui--persist-local-state local) + (bbs-ui--set-notice + (format "%s TRANSMISSION %s" + (if (member post-id saved) "ARCHIVED" "REMOVED FROM ARCHIVE") + (substring post-id 0 (min 10 (length post-id)))))))) + +(defun bbs-ui--ago (timestamp) + "Format TIMESTAMP as compact relative time." + (let ((seconds (max 0 (- (float-time) (or timestamp 0))))) + (cond ((< seconds 60) "now") + ((< seconds 3600) (format "%dm" (/ seconds 60))) + ((< seconds 86400) (format "%dh" (/ seconds 3600))) + ((< seconds (* 86400 30)) (format "%dd" (/ seconds 86400))) + (t (format-time-string "%Y-%m-%d" (seconds-to-time timestamp)))))) + +(defun bbs-ui--fit (text width &optional face right) + "Make TEXT exactly WIDTH cells wide and optionally apply FACE. +When RIGHT is non-nil, right-align the value. Newlines are always flattened." + (let* ((width (max 0 width)) + (text (bbs-ui--single-line-string text)) + (value (truncate-string-to-width text width nil nil "…")) + (space (make-string (max 0 (- width (string-width value))) ?\s)) + (value (if right (concat space value) (concat value space)))) + (if face (propertize value 'face face) value))) + +(defun bbs-ui--line (text width &optional face) + "Return one fixed-width TextUI line containing TEXT." + (list :type :text :value (bbs-ui--fit text width face) + :layout (list :width width :min-width (min width 8) :grow 0))) + +(defun bbs-ui--column (children width) + "Return a gapless TextUI column of CHILDREN at WIDTH." + (list :type :flex :direction :column :gap 0 :children children + :layout (list :width width :min-width (min width 8) :grow 0))) + +(defun bbs-ui--row (text kind id action width &optional selected) + "Return a one-line BBS button for TEXT, KIND, ID and ACTION at WIDTH." + (let ((value (bbs-ui--fit text width))) + (list :type 'bbs-ui-row + :value value + :button-face (if selected 'bbs-ui-selected 'default) + :bbs-ui-kind kind :bbs-ui-id id + :layout (list :width width :focus-id (list kind id)) + :action (lambda (&rest _) (funcall action kind id))))) + +(defun bbs-ui--rank-chip (rank) + "Format RANK as a compact Copland access chip." + (propertize (format "<%s>" (upcase (or rank "node"))) + 'face (pcase (downcase (or rank "node")) + ("wired" 'bbs-ui-magenta) + ("sysop" 'bbs-ui-yellow) + ("operator" 'bbs-ui-green) + (_ 'bbs-ui-cyan)))) + +(defun bbs-ui--label-text (labels) + "Format thread LABELS as compact uppercase tags." + (if labels + (mapconcat (lambda (label) + (propertize (format "[%s]" (upcase (format "%s" label))) + 'face 'bbs-ui-magenta)) + labels " ") + "")) + +(defun bbs-ui--markup-text (body mimetype) + "Fontify BODY according to MIMETYPE without changing its source form." + (setq body (or body "")) + (cond + ((string= mimetype "text/x-markdown") + (with-temp-buffer + (insert body) + (delay-mode-hooks (markdown-mode)) + (font-lock-ensure) + (buffer-substring (point-min) (point-max)))) + ((string= mimetype "text/x-fossil-wiki") + (let ((copy (copy-sequence body))) + (dolist (regexp '("^==+ .* ==+$" "^[-*] " "\\[https?://[^]]+\\]")) + (let ((start 0)) + (while (string-match regexp copy start) + (add-face-text-property (match-beginning 0) (match-end 0) + 'bbs-ui-cyan nil copy) + (setq start (match-end 0))))) + copy)) + (t body))) + +(defun bbs-ui--wrapped-lines (text width) + "Wrap TEXT into display lines no wider than WIDTH." + (let ((width (max 8 width)) result) + (dolist (source (split-string (or text "") "\n" nil)) + (if (string-empty-p source) + (push "" result) + (with-temp-buffer + (insert source) + (let ((fill-column width) + (sentence-end-double-space nil)) + (fill-region (point-min) (point-max))) + (dolist (line (split-string (buffer-string) "\n" nil)) + (push line result))))) + (or (nreverse result) (list "")))) + +(defun bbs-ui--body-window (text width height &optional offset-key) + "Return a HEIGHT-line window over TEXT wrapped to WIDTH. +OFFSET-KEY names the state entry used for scrolling." + (let* ((height (max 0 height)) + (lines (bbs-ui--wrapped-lines text width)) + (total (length lines)) + (maximum (max 0 (- total height))) + (offset (min maximum (max 0 (or (and offset-key + (plist-get textui-state offset-key)) + 0)))) + (end (min total (+ offset height))) + (visible (cl-subseq lines offset end))) + (when offset-key + (setq textui-state (plist-put (copy-sequence textui-state) + offset-key offset))) + (list :lines (append visible (make-list (max 0 (- height (length visible))) "")) + :start offset :end end :total total))) + +(defun bbs-ui--viewport (items id-function selection-key offset-key capacity) + "Return a bounded slice of ITEMS and keep its selection visible." + (let* ((capacity (max 1 capacity)) + (total (length items)) + (selected (plist-get textui-state selection-key)) + (index (and selected + (cl-position selected items :key id-function :test #'equal)))) + (when (and items (null index)) + (setq index 0 selected (funcall id-function (car items))) + (setq textui-state (plist-put (copy-sequence textui-state) + selection-key selected))) + (let* ((maximum (max 0 (- total capacity))) + (offset (min maximum (max 0 (or (plist-get textui-state offset-key) 0))))) + (when index + (cond ((< index offset) (setq offset index)) + ((>= index (+ offset capacity)) + (setq offset (- (1+ index) capacity))))) + (setq offset (min maximum (max 0 offset))) + (setq textui-state (plist-put (copy-sequence textui-state) offset-key offset)) + (list :items (cl-subseq items offset (min total (+ offset capacity))) + :selected selected :start offset + :end (min total (+ offset capacity)) :total total)))) + +(defun bbs-ui--bar-line (left text fill right width &optional face) + "Build a fixed WIDTH BBS rail using LEFT, TEXT, FILL and RIGHT." + (let* ((inner (max 0 (- width (string-width left) (string-width right)))) + (text (truncate-string-to-width (bbs-ui--single-line-string text) + inner nil nil "…")) + (fill-count (max 0 (- inner (string-width text)))) + (value (concat left text (make-string fill-count fill) right))) + (bbs-ui--line value width (or face 'bbs-ui-frame)))) + +(defun bbs-ui--view-name () + "Return the current screen's BBS-style name." + (pcase (plist-get textui-state :view) + ('board "BOARD / MESSAGE BASE") + ('thread "THREAD / MESSAGE READER") + ('wiki "WIKI / FILE INDEX") + ('wiki-page "WIKI / DOCUMENT READER") + ('users "USERS / NODE DIRECTORY") + ('profile "USER / PROFILE") + ('saved "ARCHIVE / SAVED TRANSMISSIONS") + ('search "SEARCH / LOCAL INDEX") + (_ "BOARD / MESSAGE BASE"))) + +(defun bbs-ui--status-chip () + "Return the current link status as a short string." + (pcase (plist-get textui-state :connection) + ('pulling "PULLING") ('online "ONLINE") ('offline "OFFLINE") (_ "LOCAL"))) + +(defun bbs-ui--header (width) + "Return the persistent three-line BBS header for WIDTH." + (let* ((unsent (length (plist-get textui-state :unsent))) + (identity (format " FEED://BBS %-22s NODE @%-14s %-7s%s " + (or (plist-get textui-state :project) "unknown") + (or (plist-get textui-state :user) "anonymous") + (bbs-ui--status-chip) + (if (> unsent 0) (format " QUEUE:%d" unsent) "")))) + (list + (bbs-ui--bar-line "╔═" " COPLAND.SYSTEMS // PRIVATE PACKET NETWORK " ?═ "═╗" + width 'bbs-ui-logo) + (bbs-ui--bar-line "║" identity ?\s "║" width 'bbs-ui-bar) + (bbs-ui--bar-line "╠═" (concat " " (bbs-ui--view-name) " ") ?═ "═╣" + width 'bbs-ui-cyan)))) + +(defun bbs-ui--identicon-lines (name rows width) + "Return a deterministic mirrored block identicon for NAME." + (let* ((hash (secure-hash 'sha1 (downcase (or name "?")))) + (half (max 1 (/ width 2))) + result) + (cl-loop for scanline below rows do + (let (left) + (dotimes (column half) + (let* ((index (% (+ (* scanline half) column) (length hash))) + (on (>= (string-to-number (substring hash index (1+ index)) 16) 8))) + (push (if on "█" " ") left))) + (setq left (nreverse left)) + (let* ((mirror (reverse (if (cl-oddp width) (butlast left) left))) + (line (apply #'concat (append left mirror)))) + (push (propertize (bbs-ui--fit line width) 'face 'bbs-ui-magenta) + result)))) + (nreverse result))) + +(defun bbs-ui--avatar-element (profile rows width) + "Return PROFILE's avatar in ROWS by WIDTH cells with an identicon fallback." + (let ((file (and profile (plist-get profile :avatar))) + (name (or (and profile (plist-get profile :name)) "unknown"))) + (if (and (display-graphic-p) file (file-readable-p file)) + (list :type :image :file file :rows rows + ;; TextUI inserts :alt into an unibyte image-slice placeholder. + ;; Its final image width may be narrower than WIDTH, so even an + ;; already fitted label can be truncated a second time with a + ;; Unicode ellipsis. One ASCII cell is safe at every possible + ;; slice width; the complete name is rendered beside the image. + :alt "@" + :layout (list :width width :min-width width :grow 0)) + (list :type :text + :value (mapconcat #'identity + (bbs-ui--identicon-lines name rows width) "\n") + :layout (list :width width :min-width width :grow 0))))) + +(defun bbs-ui--identity-block (profile details width rows avatar-width) + "Render PROFILE avatar and DETAILS in a fixed-size identity block." + (let* ((avatar-width (min avatar-width (max 5 (/ width 3)))) + (detail-width (max 1 (- width avatar-width 1))) + (details (append (seq-take details rows) + (make-list (max 0 (- rows (length details))) "")))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width :grow 0) + :children + (list (bbs-ui--avatar-element profile rows avatar-width) + (bbs-ui--column + (mapcar (lambda (line) (bbs-ui--line line detail-width)) details) + detail-width))))) + +(defun bbs-ui--position-line (slice width noun) + "Render SLICE position at WIDTH using NOUN." + (let ((total (plist-get slice :total)) + (start (plist-get slice :start)) + (end (plist-get slice :end))) + (bbs-ui--line + (if (> total 0) + (format " ── %s %d–%d / %d " noun (1+ start) end total) + (format " ── NO %s " noun)) + width 'bbs-ui-muted))) + +(defun bbs-ui--thread-subject (thread) + "Return a useful one-line subject for THREAD, including untitled posts." + (let ((title (string-trim (or (plist-get thread :title) "")))) + (if (not (string-empty-p title)) + title + (let* ((root (plist-get thread :root)) + (body (string-trim (bbs-ui--single-line-string + (and root (plist-get root :body))))) + (body (replace-regexp-in-string + "\\`[[:space:]#>*_-]+" "" body))) + (if (string-empty-p body) "(no subject)" body))))) + +(defun bbs-ui--thread-header (width) + "Build the dense board column header for WIDTH." + (let* ((wide (>= width 56)) + (author-width (if wide 12 0)) + (fixed (+ 2 1 author-width (if wide 1 0) 1 2 1 6)) + (title-width (max 6 (- width fixed)))) + (concat (bbs-ui--fit "ST" 2 'bbs-ui-cyan) " " + (bbs-ui--fit "SUBJECT" title-width 'bbs-ui-cyan) + (if wide + (concat " " (bbs-ui--fit "FROM" author-width 'bbs-ui-cyan)) + "") + " " (bbs-ui--fit "RE" 2 'bbs-ui-cyan t) + " " (bbs-ui--fit "AGE" 6 'bbs-ui-cyan t)))) + +(defun bbs-ui--thread-line (thread width) + "Build one compact board line for THREAD fitting WIDTH." + (let* ((unread (bbs-ui--unread-p thread)) + (flag (concat (if (plist-get thread :pinned) "◆" " ") + (if unread "●" "·"))) + (replies (format "%2d" (plist-get thread :reply-count))) + (age (format "%6s" (bbs-ui--ago (plist-get thread :updated)))) + (author (if (>= width 56) + (format "%-12s" (truncate-string-to-width + (plist-get thread :author) 12 nil nil "…")) + "")) + (fixed (+ 2 1 (string-width author) (if (string-empty-p author) 0 1) + 1 2 1 6)) + (title-width (max 6 (- width fixed))) + (title (bbs-ui--fit (bbs-ui--thread-subject thread) title-width))) + (concat (propertize flag 'face (if unread 'bbs-ui-yellow 'bbs-ui-muted)) + " " title + (unless (string-empty-p author) + (concat " " (propertize author 'face 'bbs-ui-blue))) + " " (propertize replies 'face 'bbs-ui-magenta) + " " (propertize age 'face 'bbs-ui-muted)))) + +(defun bbs-ui--board-list-pane (width height) + "Render the bounded board lightbar at WIDTH by HEIGHT." + (let* ((threads (seq-take (plist-get textui-state :threads) bbs-ui-board-limit)) + (capacity (max 1 (- height 2))) + (slice (bbs-ui--viewport threads + (lambda (thread) (plist-get thread :id)) + :selected-root :board-offset capacity)) + (selected (plist-get slice :selected)) + (rows + (mapcar + (lambda (thread) + (let ((id (plist-get thread :id))) + (bbs-ui--row (bbs-ui--thread-line thread width) + 'thread id #'bbs-ui--activate-row width + (equal id selected)))) + (plist-get slice :items)))) + (bbs-ui--column + (append + (list (bbs-ui--line + (bbs-ui--thread-header width) width)) + rows + (make-list (max 0 (- capacity (length rows))) (bbs-ui--line "" width)) + (list (bbs-ui--position-line slice width "TRANSMISSIONS"))) + width))) + +(defun bbs-ui--post-details (post thread) + "Return five identity lines describing POST in THREAD." + (let* ((posts (and thread (plist-get thread :posts))) + (index (and post (cl-position post posts :test #'eq)))) + (list + (format "%s %s" (bbs-ui--rank-chip (plist-get post :rank)) + (propertize (or (plist-get post :user) "unknown") 'face 'bbs-ui-blue)) + (format "MESSAGE %d / %d" (if index (1+ index) 1) (max 1 (length posts))) + (format "DATE %s" (bbs-ui--ago (plist-get post :time))) + (format "STATE %s%s" + (if (plist-get post :edited) + (format "EDITED ×%d" (max 1 (1- (length (plist-get post :history))))) + "ORIGINAL") + (if (bbs-ui--saved-p (plist-get post :logical-id)) " ★ SAVED" "")) + (format "ID %s" (substring (or (plist-get post :logical-id) "----------") + 0 (min 10 (length (or (plist-get post :logical-id) + "----------")))))))) + +(defun bbs-ui--message-pane (post thread width height &optional heading) + "Render POST from THREAD as a fixed BBS reader pane." + (if (not post) + (bbs-ui--column + (cons (bbs-ui--line " NO MESSAGE SELECTED" width 'bbs-ui-muted) + (make-list (max 0 (1- height)) (bbs-ui--line "" width))) width) + (let* ((profile (bbs-ui--find-profile (plist-get post :user))) + (body (if (plist-get post :deleted) + (propertize "[transmission deleted]" 'face 'bbs-ui-deleted) + (bbs-ui--markup-text (plist-get post :body) + (plist-get post :mimetype)))) + (body-height (max 0 (- height 7))) + (window (bbs-ui--body-window body width body-height :body-offset)) + (range (format " BODY · LINES %d–%d / %d " + (if (> (plist-get window :total) 0) + (1+ (plist-get window :start)) 0) + (plist-get window :end) (plist-get window :total)))) + (bbs-ui--column + (append + (list (bbs-ui--line + (concat " " (or heading "MESSAGE") " // " + (if thread (bbs-ui--thread-subject thread) "UNTITLED")) + width 'bbs-ui-yellow) + (bbs-ui--identity-block profile (bbs-ui--post-details post thread) + width 5 11) + (bbs-ui--line range width 'bbs-ui-frame)) + (mapcar (lambda (line) (bbs-ui--line line width)) + (plist-get window :lines))) + width)))) + +(defun bbs-ui--board-view (width height) + "Render the Board at WIDTH by HEIGHT." + (let* ((threads (plist-get textui-state :threads)) + (selected (plist-get textui-state :selected-root)) + (thread (cl-find selected threads + :key (lambda (item) (plist-get item :id)) :test #'equal))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 40 (floor (* (1- width) 0.43)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children + (list (bbs-ui--board-list-pane left height) + (bbs-ui--message-pane (and thread (plist-get thread :root)) + thread right height "PREVIEW")))) + (bbs-ui--board-list-pane width height)))) + +(defun bbs-ui--post-depth (post posts) + "Return POST nesting depth among POSTS, bounded for display." + (let ((parent (plist-get post :reply-to)) (depth 0) seen) + (while (and parent (< depth 6) (not (member parent seen))) + (push parent seen) + (setq depth (1+ depth)) + (setq parent + (and-let* ((match (cl-find parent posts + :key (lambda (item) + (plist-get item :logical-id)) + :test #'equal))) + (plist-get match :reply-to)))) + depth)) + +(defun bbs-ui--post-tree-line (post posts width) + "Format POST as one compact reply-tree row." + (let* ((depth (bbs-ui--post-depth post posts)) + (prefix (if (zerop depth) "◆ " + (concat (make-string (* 2 (1- depth)) ?\s) "└─"))) + (age (format "%6s" (bbs-ui--ago (plist-get post :time)))) + (saved (if (bbs-ui--saved-p (plist-get post :logical-id)) "★" " ")) + (name-width (max 5 (- width (string-width prefix) 9))) + (name (bbs-ui--fit (plist-get post :user) name-width))) + (concat (propertize prefix 'face 'bbs-ui-frame) + (propertize saved 'face 'bbs-ui-yellow) " " + (propertize name 'face 'bbs-ui-blue) " " + (propertize age 'face 'bbs-ui-muted)))) + +(defun bbs-ui--thread-tree-pane (thread width height) + "Render THREAD's bounded reply tree." + (let* ((posts (plist-get thread :posts)) + (capacity (max 1 (- height 2))) + (slice (bbs-ui--viewport posts + (lambda (post) (plist-get post :logical-id)) + :selected-post :thread-offset capacity)) + (selected (plist-get slice :selected)) + (rows + (mapcar + (lambda (post) + (let ((id (plist-get post :logical-id))) + (bbs-ui--row (bbs-ui--post-tree-line post posts width) + 'post id #'bbs-ui--activate-row width + (equal id selected)))) + (plist-get slice :items)))) + (bbs-ui--column + (append (list (bbs-ui--line " REPLY TREE // AUTHOR AGE" + width 'bbs-ui-cyan)) + rows + (make-list (max 0 (- capacity (length rows))) + (bbs-ui--line "" width)) + (list (bbs-ui--position-line slice width "MESSAGES"))) + width))) + +(defun bbs-ui--thread-view (width height id) + "Render thread ID at WIDTH by HEIGHT." + (let ((thread (bbs-ui--find-thread id))) + (if (not thread) + (bbs-ui--column + (cons (bbs-ui--line " THREAD NOT FOUND" width 'bbs-ui-error) + (make-list (max 0 (1- height)) (bbs-ui--line "" width))) width) + (let* ((posts (plist-get thread :posts)) + (selected (or (bbs-ui--find-post (plist-get textui-state :selected-post)) + (plist-get thread :root)))) + (unless (member selected posts) + (setq selected (plist-get thread :root) + textui-state (plist-put (copy-sequence textui-state) + :selected-post + (plist-get selected :logical-id)))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 31 (floor (* (1- width) 0.34)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children + (list (bbs-ui--thread-tree-pane thread left height) + (bbs-ui--message-pane selected thread right height)))) + (bbs-ui--message-pane selected thread width height)))))) + +(defun bbs-ui--wiki-line (page width) + "Format wiki PAGE as a dense file-index line." + (let* ((name (plist-get page :name)) + (age (format "%10s" (bbs-ui--ago (plist-get page :time)))) + (author (if (>= width 58) + (format "%-12s" (truncate-string-to-width + (plist-get page :user) 12 nil nil "…")) + "")) + (name-width (max 8 (- width 3 10 (if (string-empty-p author) 0 13)))) + (depth (max 0 (1- (length (split-string name "/" t))))) + (prefix (if (zerop depth) "◆ " (concat (make-string (min 4 depth) ?\s) "└─")))) + (concat (propertize prefix 'face 'bbs-ui-frame) + (bbs-ui--fit name name-width 'bbs-ui-blue) + (unless (string-empty-p author) + (concat " " (propertize author 'face 'bbs-ui-cyan))) + " " (propertize age 'face 'bbs-ui-muted)))) + +(defun bbs-ui--document-pane (page width height &optional heading) + "Render wiki PAGE as a fixed document reader." + (if (not page) + (bbs-ui--column + (cons (bbs-ui--line " DOCUMENT NOT FOUND" width 'bbs-ui-error) + (make-list (max 0 (1- height)) (bbs-ui--line "" width))) width) + (let* ((body-height (max 0 (- height 3))) + (body (bbs-ui--markup-text (plist-get page :body) + (plist-get page :mimetype))) + (window (bbs-ui--body-window body width body-height :body-offset))) + (bbs-ui--column + (append + (list (bbs-ui--line (format " %s // %s" (or heading "WIKI") + (plist-get page :name)) width 'bbs-ui-yellow) + (bbs-ui--line + (format " AUTHOR %-16s UPDATED %-10s FORMAT %s" + (plist-get page :user) (bbs-ui--ago (plist-get page :time)) + (or (plist-get page :mimetype) "text/plain")) + width 'bbs-ui-muted) + (bbs-ui--line + (format " BODY · LINES %d–%d / %d " + (if (> (plist-get window :total) 0) + (1+ (plist-get window :start)) 0) + (plist-get window :end) (plist-get window :total)) + width 'bbs-ui-frame)) + (mapcar (lambda (line) (bbs-ui--line line width)) + (plist-get window :lines))) + width)))) + +(defun bbs-ui--wiki-list-pane (width height) + "Render the bounded wiki file index." + (let* ((pages (plist-get textui-state :wiki)) + (capacity (max 1 (- height 2))) + (slice (bbs-ui--viewport pages + (lambda (page) (plist-get page :name)) + :selected-wiki :wiki-offset capacity)) + (selected (plist-get slice :selected)) + (rows (mapcar + (lambda (page) + (let ((id (plist-get page :name))) + (bbs-ui--row (bbs-ui--wiki-line page width) + 'wiki-page id #'bbs-ui--activate-row width + (equal id selected)))) + (plist-get slice :items)))) + (bbs-ui--column + (append (list (bbs-ui--line " TYPE PAGE AUTHOR UPDATED" + width 'bbs-ui-cyan)) + rows + (make-list (max 0 (- capacity (length rows))) (bbs-ui--line "" width)) + (list (bbs-ui--position-line slice width "PAGES"))) width))) + +(defun bbs-ui--wiki-view (width height) + "Render the wiki index and optional document preview." + (let* ((pages (plist-get textui-state :wiki)) + (page (cl-find (plist-get textui-state :selected-wiki) pages + :key (lambda (item) (plist-get item :name)) :test #'equal))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 38 (floor (* (1- width) 0.42)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children (list (bbs-ui--wiki-list-pane left height) + (bbs-ui--document-pane page right height "PREVIEW")))) + (bbs-ui--wiki-list-pane width height)))) + +(defun bbs-ui--wiki-page-view (width height name) + "Render wiki page NAME at WIDTH by HEIGHT." + (bbs-ui--document-pane (bbs-ui--find-wiki name) width height)) + +(defun bbs-ui--profile-activity (profile) + "Return a compact local activity document for PROFILE." + (let* ((name (plist-get profile :name)) + (posts (sort (cl-remove-if-not + (lambda (post) (equal name (plist-get post :user))) + (copy-sequence (plist-get textui-state :posts))) + (lambda (a b) (> (plist-get a :time) (plist-get b :time))))) + (pages (sort (cl-remove-if-not + (lambda (page) (equal name (plist-get page :user))) + (copy-sequence (plist-get textui-state :wiki))) + (lambda (a b) (> (plist-get a :time) (plist-get b :time))))) + lines) + (push "" lines) + (push "RECENT ACTIVITY" lines) + (dolist (post (seq-take posts 5)) + (let ((thread (bbs-ui--find-thread (plist-get post :thread-id)))) + (push (format "F %-8s %s" (bbs-ui--ago (plist-get post :time)) + (if thread (bbs-ui--thread-subject thread) "untitled")) lines))) + (dolist (page (seq-take pages 3)) + (push (format "W %-8s %s" (bbs-ui--ago (plist-get page :time)) + (plist-get page :name)) lines)) + (when (= (length lines) 2) (push "No activity in this clone." lines)) + (mapconcat #'identity (nreverse lines) "\n"))) + +(defun bbs-ui--profile-document (profile) + "Return PROFILE bio followed by locally derived activity." + (concat (or (plist-get profile :bio) "No profile page in this clone.") + (bbs-ui--profile-activity profile))) + +(defun bbs-ui--profile-pane (profile width height &optional heading) + "Render PROFILE as a fixed profile reader." + (if (not profile) + (bbs-ui--column + (cons (bbs-ui--line " PROFILE NOT FOUND" width 'bbs-ui-error) + (make-list (max 0 (1- height)) (bbs-ui--line "" width))) width) + (let* ((rows (min 7 (max 3 (- height 4)))) + (body-height (max 0 (- height rows 2))) + (window (bbs-ui--body-window (bbs-ui--profile-document profile) + width body-height :body-offset)) + (details + (list + (format "%s" (bbs-ui--rank-chip (plist-get profile :rank))) + (propertize (format "@%s" (plist-get profile :name)) 'face 'bbs-ui-blue) + (format "FORUM POSTS %d" (plist-get profile :posts)) + (format "AVATAR %s" (if (plist-get profile :avatar) "LOCAL PNG" "IDENTICON")) + "SOURCE FOSSIL CLONE"))) + (bbs-ui--column + (append + (list (bbs-ui--line (format " %s // %s" (or heading "USER") + (plist-get profile :name)) width 'bbs-ui-yellow) + (bbs-ui--identity-block profile details width rows 15) + (bbs-ui--line + (format " PROFILE DATA · LINES %d–%d / %d " + (if (> (plist-get window :total) 0) + (1+ (plist-get window :start)) 0) + (plist-get window :end) (plist-get window :total)) + width 'bbs-ui-frame)) + (mapcar (lambda (line) (bbs-ui--line line width)) + (plist-get window :lines))) width)))) + +(defun bbs-ui--profile-entry (profile width selected) + "Render one three-line PROFILE directory entry with its avatar." + (let* ((avatar-width 7) + (info-width (max 8 (- width avatar-width 1))) + (name (plist-get profile :name)) + (bio (or (plist-get profile :bio) "No profile page in this clone."))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children + (list + (bbs-ui--avatar-element profile 3 avatar-width) + (bbs-ui--column + (list + (bbs-ui--row + (format "%s @%s" (bbs-ui--rank-chip (plist-get profile :rank)) name) + 'profile name #'bbs-ui--activate-row info-width selected) + (bbs-ui--line (format " %d POSTS · %s" + (plist-get profile :posts) + (if (plist-get profile :avatar) "AVATAR" "IDENTICON")) + info-width 'bbs-ui-muted) + (bbs-ui--line (concat " " (bbs-ui--single-line-string bio)) + info-width 'bbs-ui-cyan)) + info-width))))) + +(defun bbs-ui--users-list-pane (width height) + "Render the bounded avatar-rich node directory." + (let* ((profiles (plist-get textui-state :profiles)) + (capacity (max 1 (/ (max 1 (- height 2)) 4))) + (slice (bbs-ui--viewport profiles + (lambda (profile) (plist-get profile :name)) + :selected-profile :users-offset capacity)) + (selected (plist-get slice :selected)) + (children (list (bbs-ui--line " NODE DIRECTORY // LOCAL IDENTITIES" + width 'bbs-ui-cyan)))) + (dolist (profile (plist-get slice :items)) + (setq children + (append children + (list (bbs-ui--profile-entry + profile width (equal (plist-get profile :name) selected)) + (bbs-ui--line "" width))))) + (setq children (append children (list (bbs-ui--position-line slice width "USERS")))) + (setq children + (append children + (make-list (max 0 (- height + (+ 2 (* 4 (length (plist-get slice :items)))))) + (bbs-ui--line "" width)))) + (bbs-ui--column children width))) + +(defun bbs-ui--users-view (width height) + "Render the avatar directory and selected profile." + (let* ((profiles (plist-get textui-state :profiles)) + (profile (cl-find (plist-get textui-state :selected-profile) profiles + :key (lambda (item) (plist-get item :name)) :test #'equal))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 40 (floor (* (1- width) 0.43)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children (list (bbs-ui--users-list-pane left height) + (bbs-ui--profile-pane profile right height "PROFILE")))) + (bbs-ui--users-list-pane width height)))) + +(defun bbs-ui--profile-view (width height name) + "Render profile NAME at WIDTH by HEIGHT." + (bbs-ui--profile-pane (bbs-ui--find-profile name) width height)) + +(defun bbs-ui--saved-posts () + "Return locally saved logical posts in persistent order." + (delq nil + (mapcar #'bbs-ui--find-post + (plist-get (plist-get textui-state :local-state) :saved)))) + +(defun bbs-ui--saved-line (post width) + "Format saved POST at WIDTH." + (let* ((thread (bbs-ui--find-thread (plist-get post :thread-id))) + (age (format "%8s" (bbs-ui--ago (plist-get post :time)))) + (author (format "%-12s" (truncate-string-to-width + (plist-get post :user) 12 nil nil "…"))) + (title-width (max 8 (- width 24)))) + (concat (propertize "★ " 'face 'bbs-ui-yellow) + (bbs-ui--fit (if thread (bbs-ui--thread-subject thread) "untitled") + title-width) + " " (propertize author 'face 'bbs-ui-blue) + " " (propertize age 'face 'bbs-ui-muted)))) + +(defun bbs-ui--saved-list-pane (width height) + "Render the bounded saved-post index." + (let* ((posts (bbs-ui--saved-posts)) + (capacity (max 1 (- height 2))) + (slice (bbs-ui--viewport posts + (lambda (post) (plist-get post :logical-id)) + :selected-saved :saved-offset capacity)) + (selected (plist-get slice :selected)) + (rows (mapcar + (lambda (post) + (let ((id (plist-get post :logical-id))) + (bbs-ui--row (bbs-ui--saved-line post width) + 'post id #'bbs-ui--activate-row width + (equal id selected)))) + (plist-get slice :items)))) + (bbs-ui--column + (append (list (bbs-ui--line " SAVED // SUBJECT AUTHOR AGE" + width 'bbs-ui-cyan)) + rows + (make-list (max 0 (- capacity (length rows))) + (bbs-ui--line "" width)) + (list (bbs-ui--position-line slice width "SAVED POSTS"))) width))) + +(defun bbs-ui--saved-view (width height) + "Render locally saved posts with a reader preview." + (let* ((posts (bbs-ui--saved-posts)) + (post (bbs-ui--find-post (plist-get textui-state :selected-saved)))) + (unless (member post posts) + (setq post (car posts)) + (when post + (setq textui-state + (plist-put (copy-sequence textui-state) :selected-saved + (plist-get post :logical-id))))) + (let ((thread (and post (bbs-ui--find-thread (plist-get post :thread-id))))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 40 (floor (* (1- width) 0.43)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children (list (bbs-ui--saved-list-pane left height) + (bbs-ui--message-pane post thread right height "ARCHIVE")))) + (bbs-ui--saved-list-pane width height))))) + +(defun bbs-ui--search-results (query) + "Return local forum, wiki and profile results for QUERY." + (let ((needle (downcase (string-trim (or query "")))) result) + (unless (string-empty-p needle) + (dolist (thread (plist-get textui-state :threads)) + (when (string-match-p + (regexp-quote needle) + (downcase (concat (plist-get thread :title) "\n" + (mapconcat (lambda (post) (plist-get post :body)) + (plist-get thread :posts) "\n")))) + (push (list :kind 'thread :id (plist-get thread :id) + :key (cons 'thread (plist-get thread :id)) + :title (plist-get thread :title)) result))) + (dolist (page (plist-get textui-state :wiki)) + (when (string-match-p (regexp-quote needle) + (downcase (concat (plist-get page :name) "\n" + (plist-get page :body)))) + (push (list :kind 'wiki-page :id (plist-get page :name) + :key (cons 'wiki-page (plist-get page :name)) + :title (plist-get page :name)) result))) + (dolist (profile (plist-get textui-state :profiles)) + (when (string-match-p (regexp-quote needle) + (downcase (concat (plist-get profile :name) "\n" + (or (plist-get profile :bio) "")))) + (push (list :kind 'profile :id (plist-get profile :name) + :key (cons 'profile (plist-get profile :name)) + :title (plist-get profile :name)) result)))) + (nreverse result))) + +(defun bbs-ui--search-line (result width) + "Format search RESULT at WIDTH." + (let* ((kind (pcase (plist-get result :kind) + ('thread "FORUM") ('wiki-page "WIKI ") (_ "USER "))) + (title-width (max 8 (- width 9)))) + (concat (propertize (format " %s " kind) 'face 'bbs-ui-magenta) + (bbs-ui--fit (plist-get result :title) title-width 'bbs-ui-blue)))) + +(defun bbs-ui--search-list-pane (results width height) + "Render bounded RESULTS at WIDTH by HEIGHT." + (let* ((capacity (max 1 (- height 2))) + (slice (bbs-ui--viewport results + (lambda (result) (plist-get result :key)) + :selected-search :search-offset capacity)) + (selected (plist-get slice :selected)) + (rows (mapcar + (lambda (result) + (let ((key (plist-get result :key))) + (bbs-ui--row (bbs-ui--search-line result width) + (plist-get result :kind) (plist-get result :id) + #'bbs-ui--activate-row width (equal key selected)))) + (plist-get slice :items)))) + (bbs-ui--column + (append (list (bbs-ui--line + (format " LOCAL SEARCH // %s" (or (plist-get textui-state :query) "")) + width 'bbs-ui-cyan)) + rows + (make-list (max 0 (- capacity (length rows))) + (bbs-ui--line (if results "" " NO LOCAL MATCHES · / TO SEARCH AGAIN") + width 'bbs-ui-muted)) + (list (bbs-ui--position-line slice width "MATCHES"))) width))) + +(defun bbs-ui--search-preview (result width height) + "Render a detail preview for search RESULT." + (pcase (and result (plist-get result :kind)) + ('thread + (let ((thread (bbs-ui--find-thread (plist-get result :id)))) + (bbs-ui--message-pane (and thread (plist-get thread :root)) + thread width height "MATCH"))) + ('wiki-page + (bbs-ui--document-pane (bbs-ui--find-wiki (plist-get result :id)) + width height "MATCH")) + ('profile + (bbs-ui--profile-pane (bbs-ui--find-profile (plist-get result :id)) + width height "MATCH")) + (_ (bbs-ui--column + (cons (bbs-ui--line " SELECT A LOCAL MATCH" width 'bbs-ui-muted) + (make-list (max 0 (1- height)) (bbs-ui--line "" width))) width)))) + +(defun bbs-ui--search-view (width height) + "Render current local search at WIDTH by HEIGHT." + (let* ((results (bbs-ui--search-results (plist-get textui-state :query))) + (key (plist-get textui-state :selected-search)) + (result (cl-find key results :key (lambda (item) (plist-get item :key)) + :test #'equal))) + (if (>= width bbs-ui-wide-layout-width) + (let* ((left (max 38 (floor (* (1- width) 0.42)))) + (right (- width left 1))) + (list :type :flex :direction :row :gap 1 + :layout (list :width width :min-width width) + :children (list (bbs-ui--search-list-pane results left height) + (bbs-ui--search-preview result right height)))) + (bbs-ui--search-list-pane results width height)))) + +(defun bbs-ui--footer-status () + "Return a concise user-facing status string and face." + (let ((error-text (plist-get textui-state :error)) + (busy (plist-get textui-state :busy)) + (notice (plist-get textui-state :notice))) + (cond + (error-text + (cons (format " ERROR · %s · [E] DETAILS · [R] RETRY" + (bbs-ui--single-line-string error-text)) 'bbs-ui-error)) + (busy (cons (format " LINK ACTIVITY · %s… · LOCAL DATA REMAINS AVAILABLE" busy) + 'bbs-ui-yellow)) + (notice (cons (format " %s" (bbs-ui--single-line-string notice)) 'bbs-ui-green)) + (t (cons (format " %s ARCHIVE · %d THREADS · %d WIKI PAGES · %d USERS" + (bbs-ui--status-chip) + (length (plist-get textui-state :threads)) + (length (plist-get textui-state :wiki)) + (length (plist-get textui-state :profiles))) + 'bbs-ui-muted))))) + +(defun bbs-ui--command-line () + "Return the current view's compact command line." + (pcase (plist-get textui-state :view) + ('board " [RET] READ [N] NEW [/] FIND [g] PULL [P] PUBLISH [TAB] PREVIEW [?] HELP ") + ('thread " [←/→] MESSAGE [r] REPLY [e] EDIT [s] SAVE [h] HISTORY [TAB] READER [q] BACK ") + ('wiki " [RET] OPEN [W] NEW PAGE [/] FIND [TAB] PREVIEW [Q] BOARD ") + ('wiki-page " [e] EDIT [w] WEB [j/k] SCROLL [q] INDEX ") + ('users " [RET] PROFILE [j/k] NODE [TAB] PREVIEW [q] BOARD ") + ('profile " [j/k] SCROLL [q] DIRECTORY ") + ('saved " [RET] OPEN [s] UNSAVE [TAB] PREVIEW [q] BOARD ") + ('search " [RET] OPEN [/] NEW SEARCH [TAB] PREVIEW [q] BOARD ") + (_ " [B] BOARD [V] WIKI [U] USERS [S] SAVED [?] HELP "))) + +(defun bbs-ui--footer (width) + "Return the persistent two-line status and command footer." + (pcase-let ((`(,status . ,face) (bbs-ui--footer-status))) + (list (bbs-ui--bar-line "╟─" status ?─ "─╢" width face) + (bbs-ui--bar-line "╚═" (bbs-ui--command-line) ?═ "═╝" width 'bbs-ui-bar)))) + +(defun bbs-ui--screen-body (width height) + "Return the current view's fixed WIDTH by HEIGHT body." + (pcase (plist-get textui-state :view) + ('board (bbs-ui--board-view width height)) + ('thread (bbs-ui--thread-view width height (plist-get textui-state :view-id))) + ('wiki (bbs-ui--wiki-view width height)) + ('wiki-page (bbs-ui--wiki-page-view width height (plist-get textui-state :view-id))) + ('users (bbs-ui--users-view width height)) + ('profile (bbs-ui--profile-view width height (plist-get textui-state :view-id))) + ('saved (bbs-ui--saved-view width height)) + ('search (bbs-ui--search-view width height)) + (_ (bbs-ui--board-view width height)))) + +(defun bbs-ui--screen (width) + "Build the complete height-bound BBS screen at WIDTH." + (let* ((height (max 14 (or (plist-get textui-state :height) 32))) + (body-height (max 8 (- height 5)))) + (append (bbs-ui--header width) + (list (bbs-ui--screen-body width body-height)) + (bbs-ui--footer width)))) + +(defun bbs-ui--record-diagnostic (error-data width) + "Record ERROR-DATA raised while rendering WIDTH and return its ID." + (let* ((signature (error-message-string error-data)) + (duplicate (and (equal signature bbs-ui--last-render-error) + (car bbs-ui--diagnostics))) + (id (or (plist-get duplicate :id) + (format "%s-%03d" (format-time-string "%Y%m%d-%H%M%S") + (% (truncate (* 1000 (float-time))) 1000)))) + (entry (list :id id :time (current-time) :width width + :height (plist-get textui-state :height) + :view (plist-get textui-state :view) + :message signature + :backtrace (with-output-to-string (backtrace))))) + (unless duplicate + (push entry bbs-ui--diagnostics) + (setq bbs-ui--last-render-error signature)) + (setq textui-state + (plist-put (plist-put (copy-sequence textui-state) :diagnostic-id id) + :error (format "UI fault %s" id))) + id)) + +(defun bbs-ui-show-diagnostics () + "Show local UI and Fossil failures captured by this BBS buffer." + (interactive) + (let ((entries bbs-ui--diagnostics) + (source (current-buffer)) + (buffer (get-buffer-create "*BBS diagnostics*"))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (propertize "COPLAND.SYSTEMS // LOCAL DIAGNOSTICS\n" + 'face 'bbs-ui-logo) + (propertize + "This viewer transmits nothing and never modifies the Fossil archive.\n\n" + 'face 'bbs-ui-muted)) + (if (null entries) + (insert "No BBS faults have been captured in this session.\n") + (dolist (entry entries) + (insert (propertize + (format "[%s] %s VIEW:%s FRAME:%sx%s\n" + (plist-get entry :id) + (format-time-string "%Y-%m-%d %H:%M:%S" + (plist-get entry :time)) + (upcase (format "%s" (plist-get entry :view))) + (or (plist-get entry :width) "?") + (or (plist-get entry :height) "?")) + 'face 'bbs-ui-yellow) + (propertize (concat (plist-get entry :message) "\n") + 'face 'bbs-ui-error) + (or (plist-get entry :backtrace) "") "\n"))) + (goto-char (point-min)) + (special-mode) + (setq-local revert-buffer-function + (lambda (&rest _) + (when (buffer-live-p source) + (with-current-buffer source + (bbs-ui-show-diagnostics))))))) + (pop-to-buffer buffer))) + +(defun bbs-ui--fallback-frame (width error-data) + "Return a dependency-free BBS fallback frame for ERROR-DATA." + (let* ((height (max 14 (or (plist-get textui-state :height) 32))) + (message (error-message-string error-data)) + (body-height (- height 5)) + (body + (append + (list (bbs-ui--line "" width) + (bbs-ui--line " !!! LOCAL CONSOLE RECOVERY MODE !!!" width 'bbs-ui-error) + (bbs-ui--line (concat " " message) width 'bbs-ui-yellow) + (bbs-ui--line "" width) + (bbs-ui--line " The Fossil archive was not modified." width 'bbs-ui-green) + (bbs-ui--line " Press R to rebuild, E for diagnostics, q to leave." width + 'bbs-ui-cyan)) + (make-list (max 0 (- body-height 6)) (bbs-ui--line "" width))))) + (list + (list :type :flex :direction :column :gap 0 + :layout (list :width width :min-width width) + :children + (append (bbs-ui--header width) + (list (bbs-ui--column body width)) + (bbs-ui--footer width)))))) + +(defun bbs-ui--frame (width) + "Render the BBS safely at WIDTH, falling back without entering the debugger." + (let ((width (max 24 width))) + (condition-case error-data + (let ((frame + (list + (list :type :flex :direction :column :gap 0 + :layout (list :width width :min-width width) + :children (bbs-ui--screen width))))) + ;; TextUI performs measurement after this function returns. Preflight + ;; both phases here so BBS-owned layout faults become a recovery screen. + (textui--render-specs (textui--prepare-frame frame t) width) + (setq bbs-ui--last-render-error nil) + frame) + (error + (bbs-ui--record-diagnostic error-data width) + (bbs-ui--fallback-frame width error-data))))) + +(defun bbs-ui--kind-at-point () + "Return BBS row kind at point." + (or (get-text-property (point) 'bbs-ui-kind) + (get-text-property (line-beginning-position) 'bbs-ui-kind) + (car (bbs-ui--semantic-target)))) + +(defun bbs-ui--id-at-point () + "Return BBS row identity at point." + (or (get-text-property (point) 'bbs-ui-id) + (get-text-property (line-beginning-position) 'bbs-ui-id) + (cdr (bbs-ui--semantic-target)))) + +(defun bbs-ui--goto-id (id) + "Move to row ID when visible." + (goto-char (point-min)) + (when-let* ((match (text-property-search-forward 'bbs-ui-id id #'equal))) + (goto-char (prop-match-beginning match)))) + +(defun bbs-ui--set-view (view &optional id) + "Switch to VIEW and optional ID." + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :view view)) + (setq next (plist-put next :view-id id)) + (setq next (plist-put next :body-offset 0)) + (plist-put next :active-pane + (if (memq view '(wiki-page profile)) 'detail 'list)))))) + +(defun bbs-ui--semantic-target () + "Return the currently selected semantic (KIND . ID), if any." + (pcase (plist-get textui-state :view) + ('board (cons 'thread (plist-get textui-state :selected-root))) + ('thread (cons 'post (plist-get textui-state :selected-post))) + ('wiki (cons 'wiki-page (plist-get textui-state :selected-wiki))) + ('wiki-page (cons 'wiki-page (plist-get textui-state :view-id))) + ('users (cons 'profile (plist-get textui-state :selected-profile))) + ('profile (cons 'profile (plist-get textui-state :view-id))) + ('saved (cons 'post (plist-get textui-state :selected-saved))) + ('search + (when-let* ((key (plist-get textui-state :selected-search)) + (result (cl-find key + (bbs-ui--search-results + (plist-get textui-state :query)) + :key (lambda (item) (plist-get item :key)) + :test #'equal))) + (cons (plist-get result :kind) (plist-get result :id)))) + (_ nil))) + +(defun bbs-ui--activate-row (kind id) + "Open BBS row KIND and ID." + (pcase kind + ('thread + (when-let* ((thread (bbs-ui--find-thread id))) + (bbs-ui--mark-thread-read thread) + (bbs-ui--set-view 'thread id))) + ('post + (when-let* ((post (bbs-ui--find-post id)) + (thread (bbs-ui--find-thread (plist-get post :thread-id)))) + (bbs-ui--mark-thread-read thread) + (if (and (eq (plist-get textui-state :view) 'thread) + (equal (plist-get textui-state :view-id) + (plist-get thread :id))) + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :selected-post id)) + (setq next (plist-put next :body-offset 0)) + (plist-put next :active-pane 'detail)))) + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :view 'thread)) + (setq next (plist-put next :view-id (plist-get thread :id))) + (setq next (plist-put next :selected-post id)) + (setq next (plist-put next :body-offset 0)) + (plist-put next :active-pane 'detail))))))) + ('wiki-page (bbs-ui--set-view 'wiki-page id)) + ('profile (bbs-ui--set-view 'profile id)) + (_ (user-error "Nothing to open here")))) + +(defun bbs-ui-open-at-point () + "Open the BBS item at point." + (interactive) + (if (and (memq (plist-get textui-state :view) '(thread wiki-page profile)) + (or (< (or textui--last-width 0) bbs-ui-wide-layout-width) + (eq (plist-get textui-state :active-pane) 'detail))) + (bbs-ui-page-down) + (pcase-let ((`(,kind . ,id) (or (bbs-ui--semantic-target) + (cons (bbs-ui--kind-at-point) + (bbs-ui--id-at-point))))) + (if (and kind id) + (bbs-ui--activate-row kind id) + (user-error "No BBS item selected"))))) + +(defun bbs-ui--selection-model () + "Return (ITEMS SELECTION-KEY ID-FUNCTION) for the current list view." + (pcase (plist-get textui-state :view) + ('board (list (plist-get textui-state :threads) :selected-root + (lambda (thread) (plist-get thread :id)))) + ('thread + (when-let* ((thread (bbs-ui--find-thread (plist-get textui-state :view-id)))) + (list (plist-get thread :posts) :selected-post + (lambda (post) (plist-get post :logical-id))))) + ('wiki (list (plist-get textui-state :wiki) :selected-wiki + (lambda (page) (plist-get page :name)))) + ('users (list (plist-get textui-state :profiles) :selected-profile + (lambda (profile) (plist-get profile :name)))) + ('saved (list (bbs-ui--saved-posts) :selected-saved + (lambda (post) (plist-get post :logical-id)))) + ('search (list (bbs-ui--search-results (plist-get textui-state :query)) + :selected-search + (lambda (result) (plist-get result :key)))) + (_ nil))) + +(defun bbs-ui--detail-active-p () + "Return non-nil when movement should scroll a reader rather than a list." + (or (memq (plist-get textui-state :view) '(wiki-page profile)) + (and (eq (plist-get textui-state :view) 'thread) + (< (or textui--last-width 0) bbs-ui-wide-layout-width)) + (and (>= (or textui--last-width 0) bbs-ui-wide-layout-width) + (eq (plist-get textui-state :active-pane) 'detail)))) + +(defun bbs-ui--scroll-body (amount) + "Scroll the current preview or reader by AMOUNT wrapped lines." + (textui-set-state (current-buffer) :body-offset + (lambda (offset) (max 0 (+ (or offset 0) amount))))) + +(defun bbs-ui--move-selection (amount) + "Move the semantic list selection by AMOUNT entries." + (if-let* ((model (bbs-ui--selection-model)) + (items (nth 0 model))) + (let* ((selection-key (nth 1 model)) + (id-function (nth 2 model)) + (selected (plist-get textui-state selection-key)) + (index (or (cl-position selected items :key id-function :test #'equal) 0)) + (next-index (min (1- (length items)) (max 0 (+ index amount)))) + (next-id (funcall id-function (nth next-index items))) + (buffer (current-buffer))) + (if (equal next-id selected) + (message "%s" (if (> amount 0) "End of list" "Top of list")) + (textui-update + buffer + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next selection-key next-id)) + (setq next (plist-put next :body-offset 0)) + next))) + (run-at-time 0 nil + (lambda (owner id) + (when (buffer-live-p owner) + (with-current-buffer owner (bbs-ui--goto-id id)))) + buffer + (if (consp next-id) (cdr next-id) next-id)))) + (message "No selectable BBS rows"))) + +(defun bbs-ui--move-row (direction) + "Move one logical row in DIRECTION or scroll the active reader." + (if (bbs-ui--detail-active-p) + (bbs-ui--scroll-body direction) + (bbs-ui--move-selection direction))) + +(defun bbs-ui-next-row () (interactive) (bbs-ui--move-row 1)) +(defun bbs-ui-previous-row () (interactive) (bbs-ui--move-row -1)) + +(defun bbs-ui-page-down () + "Move one BBS viewport page down." + (interactive) + (let ((amount (max 1 (- (or (plist-get textui-state :height) 32) 10)))) + (if (bbs-ui--detail-active-p) + (bbs-ui--scroll-body amount) + (bbs-ui--move-selection amount)))) + +(defun bbs-ui-page-up () + "Move one BBS viewport page up." + (interactive) + (let ((amount (max 1 (- (or (plist-get textui-state :height) 32) 10)))) + (if (bbs-ui--detail-active-p) + (bbs-ui--scroll-body (- amount)) + (bbs-ui--move-selection (- amount))))) + +(defun bbs-ui-toggle-pane () + "Toggle between a wide screen's index and detail pane." + (interactive) + (if (< (or textui--last-width 0) bbs-ui-wide-layout-width) + (message "This window has one BBS pane") + (textui-set-state (current-buffer) :active-pane + (lambda (pane) (if (eq pane 'detail) 'list 'detail))))) + +(defun bbs-ui-next-message () + "Select the next message in the current thread." + (interactive) + (if (eq (plist-get textui-state :view) 'thread) + (bbs-ui--move-selection 1) + (message "Not in a message thread"))) + +(defun bbs-ui-previous-message () + "Select the previous message in the current thread." + (interactive) + (if (eq (plist-get textui-state :view) 'thread) + (bbs-ui--move-selection -1) + (message "Not in a message thread"))) + +(defun bbs-ui-board () "Open the board." (interactive) (bbs-ui--set-view 'board)) +(defun bbs-ui-wiki () "Open the wiki index." (interactive) (bbs-ui--set-view 'wiki)) +(defun bbs-ui-users () "Open the user index." (interactive) (bbs-ui--set-view 'users)) +(defun bbs-ui-saved () "Open locally saved posts." (interactive) (bbs-ui--set-view 'saved)) + +(defun bbs-ui-search (query) + "Search the complete local BBS for literal QUERY." + (interactive (list (read-string "Search local BBS: " (plist-get textui-state :query)))) + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :query query)) + (setq next (plist-put next :view 'search)) + (setq next (plist-put next :view-id nil)) + (setq next (plist-put next :selected-search nil)) + (setq next (plist-put next :search-offset 0)) + (setq next (plist-put next :body-offset 0)) + (plist-put next :active-pane 'list))))) + +(defun bbs-ui-back () + "Go back one semantic level, or close the BBS from its board." + (interactive) + (pcase (plist-get textui-state :view) + ('board (quit-window)) + ((or 'thread 'saved 'search) (bbs-ui-board)) + ((or 'wiki-page) (bbs-ui-wiki)) + ((or 'profile) (bbs-ui-users)) + (_ (bbs-ui-board)))) + +(defun bbs-ui--reload (&optional clear-error) + "Reload the local repository, optionally CLEAR-ERROR." + (let ((position-id (bbs-ui--id-at-point)) + (old textui-state)) + (condition-case err + (let ((next (bbs-ui--snapshot (plist-get old :repository) old))) + (unless clear-error + (setq next (plist-put next :error (plist-get old :error)) + next (plist-put next :connection (plist-get old :connection)))) + (textui-update (current-buffer) (lambda (_) next)) + (when position-id (bbs-ui--goto-id position-id))) + (error + (bbs-ui--record-diagnostic err (or textui--last-width 0)) + (textui-update + (current-buffer) + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :notice nil)) + (plist-put next :error (error-message-string err))))))))) + +(defun bbs-ui-refresh () + "Refresh from the local repository without network access." + (interactive) + (bbs-ui--reload t)) + +(defun bbs-ui--finish-process (owner label code output success) + "Finish asynchronous LABEL for OWNER using CODE, OUTPUT and SUCCESS." + (when (buffer-live-p owner) + (with-current-buffer owner + (setq bbs-ui--process nil) + (condition-case err + (if (zerop code) + (progn + (textui-update + owner + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :busy nil)) + (setq next (plist-put next :connection 'online)) + (setq next (plist-put next :error nil)) + (plist-put next :notice + (format "%s COMPLETE" (upcase label)))))) + (bbs-ui--reload t) + (bbs-ui--set-notice + (format "%s COMPLETE%s" (upcase label) + (if (string-empty-p output) "" + (format " · %s" + (bbs-ui--single-line-string output))))) + (when success (funcall success output))) + (let ((failure (if (string-empty-p output) + (format "Fossil %s exited %d" label code) + output))) + (bbs-ui--record-diagnostic + (list 'error failure) (or textui--last-width 0)) + (textui-update + owner + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :busy nil)) + (setq next (plist-put next :notice nil)) + (setq next (plist-put next :connection 'offline)) + (plist-put next :error failure)))))) + (error + (bbs-ui--record-diagnostic err (or textui--last-width 0)) + (textui-update + owner + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :busy nil)) + (setq next (plist-put next :connection 'offline)) + (setq next (plist-put next :notice nil)) + (plist-put next :error (error-message-string err)))))))))) + +(defun bbs-ui--async-fossil (label args success) + "Run Fossil ARGS asynchronously as LABEL, then call SUCCESS." + (when (or (plist-get textui-state :busy) + (and bbs-ui--process (process-live-p bbs-ui--process))) + (user-error "BBS is busy: %s" (plist-get textui-state :busy))) + (let* ((owner (current-buffer)) + (output-buffer (generate-new-buffer (format " *bbs-ui:%s*" label))) + (default-directory (file-name-directory + (plist-get textui-state :repository)))) + (textui-update + owner + (lambda (state) + (let ((next (copy-sequence state))) + (setq next (plist-put next :busy label) + next (plist-put next :error nil)) + (plist-put next :connection (if (equal label "pull") 'pulling + (plist-get state :connection)))))) + (setq bbs-ui--process + (make-process + :name (format "bbs-ui-%s" label) + :buffer output-buffer + :command (cons bbs-ui-fossil-program args) + :noquery t + :sentinel + (lambda (process _event) + (when (memq (process-status process) '(exit signal)) + (let ((code (process-exit-status process)) + (output (with-current-buffer (process-buffer process) + (string-trim (buffer-string))))) + (when (buffer-live-p (process-buffer process)) + (kill-buffer (process-buffer process))) + (bbs-ui--finish-process owner label code output success)))))))) + +(defun bbs-ui-pull () + "Pull durable artifacts from the configured remote. +This command cannot upload local artifacts." + (interactive) + (if (not (plist-get textui-state :remote)) + (bbs-ui--set-notice + "LINK DISABLED · SANDBOX REMAINS LOCAL · NO NETWORK REQUEST SENT") + (bbs-ui--async-fossil + "pull" (list "pull" "-R" (plist-get textui-state :repository)) nil))) + +(defun bbs-ui--unsent-description (entries) + "Return a compact description of unsent ENTRIES." + (mapconcat + (lambda (entry) + (format "%s %-2s %s" + (substring (plist-get entry :uuid) 0 10) + (or (plist-get entry :type) "") + (or (plist-get entry :comment) ""))) + entries "\n")) + +(defun bbs-ui-publish-pending () + "Explicitly publish all currently unsent Fossil artifacts." + (interactive) + (let ((remote (plist-get textui-state :remote)) + (entries (bbs-ui--unsent (plist-get textui-state :repository)))) + (unless remote (user-error "This repository has no remote")) + (unless entries (user-error "There are no local artifacts to publish")) + (unless + (yes-or-no-p + (format + "Publish ALL %d queued artifact(s) as %s to %s?\n%s\n" + (length entries) (plist-get textui-state :user) remote + (bbs-ui--unsent-description entries))) + (user-error "Publish cancelled")) + ;; This is the only push call in bbs-ui. It is intentionally behind the + ;; complete-scope confirmation above. + (bbs-ui--async-fossil + "publish" (list "push" "-R" (plist-get textui-state :repository)) + (lambda (_) (message "Queued BBS artifacts published"))))) + +(defun bbs-ui--form-encode (fields) + "Encode alist FIELDS for an HTML form." + (mapconcat + (lambda (field) + (concat (url-hexify-string (format "%s" (car field))) "=" + (url-hexify-string (or (cdr field) "")))) + fields "&")) + +(defun bbs-ui--local-http (repository method path &optional fields) + "Run METHOD PATH with FIELDS through REPOSITORY's local Fossil handler." + (let* ((body (and fields (bbs-ui--form-encode fields))) + (host "localhost") + (request + (concat method " " path " HTTP/1.0\r\n" + "Host: " host "\r\n" + "Origin: http://" host "\r\n" + "Referer: http://" host path "\r\n" + (when body + (concat "Content-Type: application/x-www-form-urlencoded\r\n" + "Content-Length: " + (number-to-string (string-bytes + (encode-coding-string body 'utf-8))) + "\r\n")) + "\r\n" (or body "")))) + (pcase-let ((`(,code ,raw) + (bbs-ui--call-raw request "test-http" "--test" repository))) + (let ((response (decode-coding-string raw 'utf-8))) + (unless (zerop code) + (user-error "Fossil local forum handler failed: %s" response)) + response)))) + +(defun bbs-ui--http-status (response) + "Return numeric HTTP status from RESPONSE." + (and (string-match "\\`HTTP/[0-9.]+ \\([0-9]+\\)" response) + (string-to-number (match-string 1 response)))) + +(defun bbs-ui--html-field (name response) + "Return HTML input NAME's value from RESPONSE." + (when (string-match + (format "name=[\"']%s[\"'][^>]*value=[\"']\\([^\"']+\\)" + (regexp-quote name)) + response) + (match-string 1 response))) + +(defun bbs-ui--html-actor (response) + "Return the locally authenticated forum actor from RESPONSE." + (when (string-match "From:[[:space:]]*\\([^<\n]+\\)<br>" response) + (string-trim (match-string 1 response)))) + +(defun bbs-ui--forum-write (repository kind target title mimetype content) + "Create a local forum artifact in REPOSITORY. +KIND is `thread', `reply' or `edit'; TARGET is a full artifact ID." + (let* ((path (pcase kind + ('thread "/forume1") + ('reply (format "/forume2?fpid=%s" target)) + ('edit (format "/forume2?fpid=%s&edit=1" target)) + (_ (error "Unknown forum write kind: %S" kind)))) + (form (bbs-ui--local-http repository "GET" path)) + (status (bbs-ui--http-status form)) + (csrf (bbs-ui--html-field "csrf" form)) + (actor (bbs-ui--html-actor form)) + (expected-user (bbs-ui--user repository)) + (fpid (and target (or (bbs-ui--html-field "fpid" form) target))) + fields response) + (unless (= status 200) + (user-error "Fossil forum form returned HTTP %s" status)) + (unless csrf + (user-error "Fossil forum form contract changed: no CSRF field")) + (unless (and actor (equal actor expected-user)) + (user-error "Local Fossil handler would write as %s, expected %s" + (or actor "an unknown user") expected-user)) + (setq fields + (append + (when title `(("title" . ,title))) + (when fpid `(("fpid" . ,fpid))) + (pcase kind ('reply '(("reply" . "1"))) ('edit '(("edit" . "1")))) + `(("mimetype" . ,mimetype) + ("content" . ,content) + ("submit" . "Submit") + ("csrf" . ,csrf)))) + (setq response + (bbs-ui--local-http repository "POST" + (if (eq kind 'thread) "/forume1" "/forume2") fields)) + (unless (memq (bbs-ui--http-status response) '(302 303)) + (user-error "Fossil rejected the local forum post (HTTP %s)" + (bbs-ui--http-status response))) + response)) + +(defun bbs-ui--wiki-write (repository page mimetype content) + "Create or update PAGE with CONTENT locally in REPOSITORY." + (let ((exists (member page + (mapcar (lambda (entry) (plist-get entry :name)) + (plist-get textui-state :wiki))))) + (pcase-let ((`(,code ,raw) + (bbs-ui--call-raw + content "wiki" (if exists "commit" "create") page "-" + "-M" mimetype "-R" repository))) + (let ((output (string-trim (decode-coding-string raw 'utf-8)))) + (unless (zerop code) + (user-error "%s" output)) + output)))) + +(defun bbs-ui--compose-body () + "Return current composer body." + (string-trim-right + (buffer-substring-no-properties (point-min) (point-max)))) + +(defun bbs-ui-compose-submit (&optional publish) + "Save the current composer locally; with PUBLISH, explicitly publish it." + (interactive) + (let ((body (bbs-ui--compose-body)) + (owner bbs-ui--compose-owner) + (kind bbs-ui--compose-kind) + (target bbs-ui--compose-target) + (title bbs-ui--compose-title) + (mimetype bbs-ui--compose-mimetype) + (page bbs-ui--compose-page) + (buffer (current-buffer))) + (unless (buffer-live-p owner) (user-error "The BBS buffer was closed")) + (when (string-empty-p (string-trim body)) + (user-error "Transmission body cannot be empty")) + (when (and title (or (string-empty-p (string-trim title)) (> (length title) 125))) + (user-error "Thread title must contain 1–125 characters")) + (with-current-buffer owner + (let ((repository (plist-get textui-state :repository))) + (if (eq kind 'wiki) + (bbs-ui--wiki-write repository page mimetype body) + (bbs-ui--forum-write repository kind target title mimetype body)))) + (set-buffer-modified-p nil) + (kill-buffer buffer) + (pop-to-buffer owner) + (with-current-buffer owner + (bbs-ui--reload t) + (bbs-ui--set-notice + (if publish + "SAVED LOCALLY · PUBLISH SCOPE REVIEW FOLLOWS" + "SAVED LOCALLY · NOTHING TRANSMITTED")) + (when publish + ;; The second confirmation is intentional: it shows the actual + ;; repository-wide unsent set after the new artifact exists. + (call-interactively #'bbs-ui-publish-pending))))) + +(defun bbs-ui-compose-save () + "Save the current composer locally without network activity." + (interactive) + (bbs-ui-compose-submit nil)) + +(defun bbs-ui-compose-publish () + "Save locally, then request an explicit repository-wide publish." + (interactive) + (bbs-ui-compose-submit t)) + +(defun bbs-ui-compose-cancel () + "Cancel the current composer." + (interactive) + (let ((owner bbs-ui--compose-owner)) + (when (or (not (buffer-modified-p)) + (yes-or-no-p "Discard this local draft? ")) + (set-buffer-modified-p nil) + (kill-buffer (current-buffer)) + (when (buffer-live-p owner) (pop-to-buffer owner))))) + +(defun bbs-ui-compose-preview () + "Show a source-faithful preview of the current composer." + (interactive) + (let ((buffer (get-buffer-create "*BBS preview*")) + (body (bbs-ui--compose-body)) + (mimetype bbs-ui--compose-mimetype)) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (bbs-ui--markup-text body mimetype)) + (goto-char (point-min)) + (special-mode))) + (display-buffer buffer))) + +(defvar bbs-ui-compose-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map markdown-mode-map) + (define-key map (kbd "C-c C-c") #'bbs-ui-compose-save) + (define-key map (kbd "C-c C-p") #'bbs-ui-compose-publish) + (define-key map (kbd "C-c C-v") #'bbs-ui-compose-preview) + (define-key map (kbd "C-c C-k") #'bbs-ui-compose-cancel) + map) + "Keymap for `bbs-ui-compose-mode'.") + +(defun bbs-ui-compose--header-line () + "Return the transmission editor's context header." + (let ((subject (or bbs-ui--compose-title bbs-ui--compose-page + (upcase (format "%s" bbs-ui--compose-kind))))) + (concat + (propertize " COPLAND.SYSTEMS // TRANSMISSION EDITOR " + 'face 'bbs-ui-logo) + (propertize (format " %-40s " + (truncate-string-to-width subject 40 nil nil "…")) + 'face 'bbs-ui-bar) + (propertize " LOCAL DRAFT · OFFLINE " 'face 'bbs-ui-green)))) + +(defun bbs-ui-compose--mode-line () + "Return the transmission editor's persistent command bar." + (propertize + " C-c C-c SAVE LOCAL C-c C-v PREVIEW C-c C-p REVIEW + PUBLISH C-c C-k ABORT " + 'face 'bbs-ui-bar)) + +(define-derived-mode bbs-ui-compose-mode markdown-mode "BBS-Compose" + "Major mode for composing local Fossil forum and wiki artifacts." + (setq-local header-line-format '(:eval (bbs-ui-compose--header-line))) + (setq-local mode-line-format '(:eval (bbs-ui-compose--mode-line))) + (setq-local fill-column 78) + (setq-local cursor-type 'bar)) + +(defun bbs-ui--open-composer (kind &optional target title mimetype body page) + "Open a composer for KIND with TARGET, TITLE, MIMETYPE, BODY and PAGE." + (let* ((owner (current-buffer)) + (name (format "*BBS compose: %s*" (or title page kind))) + (buffer (get-buffer-create name))) + (with-current-buffer buffer + (bbs-ui-compose-mode) + (setq-local bbs-ui--compose-owner owner + bbs-ui--compose-kind kind + bbs-ui--compose-target target + bbs-ui--compose-title title + bbs-ui--compose-mimetype (or mimetype "text/x-markdown") + bbs-ui--compose-page page) + (let ((inhibit-read-only t)) + (erase-buffer) + (insert (or body "")) + (goto-char (point-max))) + (set-buffer-modified-p nil)) + (pop-to-buffer buffer) + buffer)) + +(defun bbs-ui-new-thread (title) + "Compose a new forum thread with TITLE." + (interactive (list (read-string "Thread title: "))) + (when (or (string-empty-p (string-trim title)) (> (length title) 125)) + (user-error "Thread title must contain 1–125 characters")) + (bbs-ui--open-composer 'thread nil title "text/x-markdown")) + +(defun bbs-ui-reply () + "Reply to the post or selected thread at point." + (interactive) + (let* ((target (or (bbs-ui--semantic-target) + (cons (bbs-ui--kind-at-point) (bbs-ui--id-at-point)))) + (kind (car target)) + (id (cdr target)) + (post + (pcase kind + ('post (bbs-ui--find-post id)) + ('thread (plist-get (bbs-ui--find-thread id) :root)) + (_ (and (eq (plist-get textui-state :view) 'thread) + (plist-get (bbs-ui--find-thread (plist-get textui-state :view-id)) + :root)))))) + (unless post (user-error "No post to reply to")) + (bbs-ui--open-composer 'reply (plist-get post :uuid) nil + (plist-get post :mimetype)))) + +(defun bbs-ui-edit () + "Edit the post at point or the displayed wiki page." + (interactive) + (if (eq (plist-get textui-state :view) 'wiki-page) + (let ((page (bbs-ui--find-wiki (plist-get textui-state :view-id)))) + (unless page (user-error "Wiki page not found")) + (bbs-ui--open-composer 'wiki nil nil (plist-get page :mimetype) + (plist-get page :body) (plist-get page :name))) + (let* ((id (cdr (or (bbs-ui--semantic-target) + (cons nil (bbs-ui--id-at-point))))) + (post (and id (bbs-ui--find-post id)))) + (unless post (user-error "Move point to a post header before editing")) + (if (not (equal (plist-get post :user) (plist-get textui-state :user))) + (bbs-ui--set-notice + "READ-ONLY TRANSMISSION · MODERATION STAYS IN THE WEB UI") + (bbs-ui--open-composer 'edit (plist-get post :uuid) + (plist-get post :title) (plist-get post :mimetype) + (plist-get post :body)))))) + +(defun bbs-ui-new-wiki-page (name) + "Compose a new wiki page NAME." + (interactive (list (read-string "New wiki page: "))) + (when (string-empty-p (string-trim name)) + (user-error "Wiki page name cannot be empty")) + (when (bbs-ui--find-wiki name) + (user-error "Wiki page already exists; open it and press e")) + (bbs-ui--open-composer 'wiki nil nil "text/x-markdown" nil name)) + +(defun bbs-ui-history () + "Show immutable edit history for the post at point." + (interactive) + (let* ((id (cdr (or (bbs-ui--semantic-target) + (cons nil (bbs-ui--id-at-point))))) + (post (and id (bbs-ui--find-post id)))) + (unless post (user-error "No post at point")) + (let ((buffer (get-buffer-create "*BBS post history*"))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (dolist (version (reverse (plist-get post :history))) + (insert (propertize + (format "%s %s %s\n" + (substring (plist-get version :uuid) 0 12) + (plist-get version :user) + (format-time-string "%Y-%m-%d %H:%M:%S" + (seconds-to-time + (plist-get version :time)))) + 'face 'bbs-ui-cyan)) + (insert (bbs-ui--markup-text (plist-get version :body) + (plist-get version :mimetype)) "\n\n")) + (goto-char (point-min)) + (special-mode))) + (pop-to-buffer buffer)))) + +(defun bbs-ui-open-web () + "Open the current thread or wiki page on the configured remote." + (interactive) + (let ((remote (or bbs-ui-web-base-url + (plist-get textui-state :remote))) + path) + (if (not remote) + (bbs-ui--set-notice + "NO PUBLIC WEB ENDPOINT CONFIGURED · LOCAL ARCHIVE STILL AVAILABLE") + (setq remote (replace-regexp-in-string "/+\\'" "" remote)) + (setq path + (pcase (plist-get textui-state :view) + ('thread (concat "/forumpost/" (plist-get textui-state :view-id))) + ('wiki-page (concat "/wiki?name=" + (url-hexify-string + (plist-get textui-state :view-id)))) + (_ "/forum"))) + (browse-url (concat remote path))))) + +(defun bbs-ui-help () + "Show the BBS command reference and safety model." + (interactive) + (let ((buffer (get-buffer-create "*bbs-ui help*"))) + (with-current-buffer buffer + (let ((inhibit-read-only t)) + (erase-buffer) + (insert + "FEED://BBS\n\n" + "The Fossil repository is the offline archive. Opening and refreshing\n" + "read local data. Pull receives artifacts only. Nothing publishes until\n" + "you invoke Publish and confirm the complete repository-wide queue.\n\n" + "This client never exposes moderator or admin operations. Foreign posts\n" + "are read-only; use the web UI deliberately for administration.\n\n" + "b/v/u/s Board / Wiki / Users / Saved\n" + "/ local full-text search N new thread\n" + "j/k, ↑/↓ next / previous row RET open\n" + "PgUp/PgDn page list or reader TAB index / preview\n" + "←/→ previous / next reply in a thread\n" + "r/e reply / edit s toggle saved\n" + "g pull only P publish queue\n" + "h/w post history / open web E diagnostics\n" + "q back or close\n" + "W new wiki page R local refresh\n\n" + "Composer: C-c C-c Save Local, C-c C-p Publish, C-c C-v Preview,\n" + "C-c C-k Cancel.\n") + (goto-char (point-min)) + (special-mode))) + (pop-to-buffer buffer))) + +(defun bbs-ui--visible-height () + "Return the smallest body height displaying the current BBS." + (when-let* ((windows (get-buffer-window-list (current-buffer) nil t))) + (apply #'min (mapcar #'window-body-height windows)))) + +(defun bbs-ui--window-size-changed (_window) + "Track the BBS window height for responsive rendering." + (when (and (derived-mode-p 'bbs-ui-mode) + (not (bound-and-true-p textui--refreshing))) + (when-let* ((height (bbs-ui--visible-height))) + (unless (equal height bbs-ui--window-height) + (setq bbs-ui--window-height height) + (textui-set-state (current-buffer) :height height))))) + +(defun bbs-ui--pin-window-start () + "Keep every visible BBS window pinned to its full-screen frame." + (when (derived-mode-p 'bbs-ui-mode) + (dolist (window (get-buffer-window-list (current-buffer) nil t)) + (set-window-start window (point-min) t)))) + +(defvar bbs-ui-mode-map + (let ((map (make-sparse-keymap))) + (set-keymap-parent map textui-mode-map) + (define-key map (kbd "b") #'bbs-ui-board) + (define-key map (kbd "v") #'bbs-ui-wiki) + (define-key map (kbd "u") #'bbs-ui-users) + (define-key map (kbd "s") #'bbs-ui-toggle-save) + (define-key map (kbd "S") #'bbs-ui-saved) + (define-key map (kbd "/") #'bbs-ui-search) + (define-key map (kbd "N") #'bbs-ui-new-thread) + (define-key map (kbd "W") #'bbs-ui-new-wiki-page) + (define-key map (kbd "g") #'bbs-ui-pull) + (define-key map (kbd "R") #'bbs-ui-refresh) + (define-key map (kbd "P") #'bbs-ui-publish-pending) + (define-key map (kbd "RET") #'bbs-ui-open-at-point) + (define-key map (kbd "<return>") #'bbs-ui-open-at-point) + (define-key map (kbd "TAB") #'bbs-ui-toggle-pane) + (define-key map (kbd "<tab>") #'bbs-ui-toggle-pane) + (define-key map (kbd "<down>") #'bbs-ui-next-row) + (define-key map (kbd "<up>") #'bbs-ui-previous-row) + (define-key map (kbd "<right>") #'bbs-ui-next-message) + (define-key map (kbd "<left>") #'bbs-ui-previous-message) + (define-key map (kbd "C-v") #'bbs-ui-page-down) + (define-key map (kbd "M-v") #'bbs-ui-page-up) + (define-key map (kbd "<next>") #'bbs-ui-page-down) + (define-key map (kbd "<prior>") #'bbs-ui-page-up) + (define-key map (kbd "r") #'bbs-ui-reply) + (define-key map (kbd "e") #'bbs-ui-edit) + (define-key map (kbd "h") #'bbs-ui-history) + (define-key map (kbd "w") #'bbs-ui-open-web) + (define-key map (kbd "j") #'bbs-ui-next-row) + (define-key map (kbd "n") #'bbs-ui-next-row) + (define-key map (kbd "k") #'bbs-ui-previous-row) + (define-key map (kbd "p") #'bbs-ui-previous-row) + (define-key map (kbd "?") #'bbs-ui-help) + (define-key map (kbd "E") #'bbs-ui-show-diagnostics) + (define-key map (kbd "q") #'bbs-ui-back) + map) + "Keymap for `bbs-ui-mode'.") + +(defconst bbs-ui--evil-bindings + '(("b" . bbs-ui-board) ("v" . bbs-ui-wiki) ("u" . bbs-ui-users) + ("s" . bbs-ui-toggle-save) ("S" . bbs-ui-saved) ("/" . bbs-ui-search) + ("N" . bbs-ui-new-thread) ("W" . bbs-ui-new-wiki-page) + ("g" . bbs-ui-pull) ("R" . bbs-ui-refresh) ("P" . bbs-ui-publish-pending) + ("RET" . bbs-ui-open-at-point) ("<return>" . bbs-ui-open-at-point) + ("TAB" . bbs-ui-toggle-pane) ("<tab>" . bbs-ui-toggle-pane) + ("<down>" . bbs-ui-next-row) ("<up>" . bbs-ui-previous-row) + ("<right>" . bbs-ui-next-message) ("<left>" . bbs-ui-previous-message) + ("C-v" . bbs-ui-page-down) ("M-v" . bbs-ui-page-up) + ("<next>" . bbs-ui-page-down) ("<prior>" . bbs-ui-page-up) + ("r" . bbs-ui-reply) ("e" . bbs-ui-edit) ("h" . bbs-ui-history) + ("w" . bbs-ui-open-web) ("j" . bbs-ui-next-row) ("n" . bbs-ui-next-row) + ("k" . bbs-ui-previous-row) ("p" . bbs-ui-previous-row) + ("?" . bbs-ui-help) ("E" . bbs-ui-show-diagnostics) + ("q" . bbs-ui-back)) + "Bindings owned by BBS buffers in modal states.") + +(defun bbs-ui--refresh-command-map () + "Install BBS bindings idempotently, including after a live source reload." + (set-keymap-parent bbs-ui-mode-map textui-mode-map) + (dolist (binding bbs-ui--evil-bindings) + (define-key bbs-ui-mode-map (kbd (car binding)) (cdr binding)))) + +(bbs-ui--refresh-command-map) + +(defvar bbs-ui--emulation-mode-map-alist + `((bbs-ui--keys-active . ,bbs-ui-mode-map)) + "Highest-priority BBS bindings used while a BBS buffer is active.") + +(add-to-list 'emulation-mode-map-alists 'bbs-ui--emulation-mode-map-alist) + +(defun bbs-ui--install-general-bindings () + "Install buffer-local General overrides for BBS." + (general-local-map) + (dolist (binding bbs-ui--evil-bindings) + (general-define-key :states '(normal motion) + :keymaps 'general-override-local-mode-map + (car binding) (cdr binding))) + (when (fboundp 'evil-get-auxiliary-keymap) + (dolist (state '(normal motion)) + (evil-make-intercept-map + (evil-get-auxiliary-keymap general-override-local-mode-map state t t) + state)))) + +(define-derived-mode bbs-ui-mode textui-mode "BBS" + "Major mode for the Fossil-native BBS." + (setq-local truncate-lines t cursor-type nil mode-line-format "") + (setq-local bbs-ui--keys-active t) + (add-hook 'window-size-change-functions #'bbs-ui--window-size-changed nil t) + (add-hook 'post-command-hook #'bbs-ui--pin-window-start nil t) + (when (fboundp 'general-define-key) (bbs-ui--install-general-bindings)) + (when (fboundp 'evil-normalize-keymaps) (evil-normalize-keymaps))) + +;; `load-file' does not re-run the major mode of an already open BBS buffer. +;; Upgrade those buffers in place so no Evil/global command can leak through. +(dolist (buffer (buffer-list)) + (with-current-buffer buffer + (when (derived-mode-p 'bbs-ui-mode) + (setq-local bbs-ui--keys-active t)))) + +(defun bbs-ui--install-evil-bindings () + "Make BBS bindings precede global modal bindings." + (evil-set-initial-state 'bbs-ui-mode 'normal) + (evil-make-intercept-map bbs-ui-mode-map) + (dolist (binding bbs-ui--evil-bindings) + (evil-define-key* '(normal motion) bbs-ui-mode-map + (kbd (car binding)) (cdr binding)))) + +(with-eval-after-load 'evil + (bbs-ui--install-evil-bindings)) + +(with-eval-after-load 'general + (dolist (buffer (buffer-list)) + (with-current-buffer buffer + (when (derived-mode-p 'bbs-ui-mode) + (bbs-ui--install-general-bindings))))) + +;;;###autoload +(defun bbs-open (repository) + "Open a local-first BBS backed by Fossil REPOSITORY." + (interactive + (list (read-file-name "Fossil BBS repository: " nil bbs-ui-repository-file t + nil (lambda (file) (or (file-directory-p file) + (string-suffix-p ".fossil" file)))))) + (setq repository (expand-file-name repository)) + (let* ((snapshot (bbs-ui--snapshot repository)) + (project (plist-get snapshot :project)) + (name (format "*BBS: %s*" project)) + (existing (get-buffer name)) + buffer) + (when (and existing + (with-current-buffer existing (not (derived-mode-p 'bbs-ui-mode)))) + (user-error "A non-BBS buffer already uses %s" name)) + (setq buffer (or existing (get-buffer-create name))) + (with-current-buffer buffer + (unless (derived-mode-p 'bbs-ui-mode) (bbs-ui-mode)) + (setq-local default-directory (file-name-directory repository))) + (textui-open name #'bbs-ui--frame snapshot) + (with-current-buffer buffer + (when-let* ((height (bbs-ui--visible-height))) + (setq bbs-ui--window-height height) + (textui-set-state buffer :height height)) + (when-let* ((target (bbs-ui--semantic-target))) + (bbs-ui--goto-id (cdr target))) + (when (and bbs-ui-pull-on-open (plist-get textui-state :remote)) + (bbs-ui-pull))) + buffer)) + +;;;###autoload +(defun bbs (&optional repository) + "Open the configured Fossil BBS REPOSITORY." + (interactive) + (bbs-open + (or repository bbs-ui-repository-file + (read-file-name "Fossil BBS repository: " nil nil t nil + (lambda (file) (or (file-directory-p file) + (string-suffix-p ".fossil" file))))))) + +(provide 'bbs-ui) +;;; bbs-ui.el ends here |
