summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xbin/tissue70
-rw-r--r--tissue/document.scm38
-rw-r--r--tissue/issue.scm57
-rw-r--r--tissue/web/server.scm192
4 files changed, 357 insertions, 0 deletions
diff --git a/bin/tissue b/bin/tissue
index e29cf3c..d7d4424 100755
--- a/bin/tissue
+++ b/bin/tissue
@@ -32,6 +32,7 @@ exec guile --no-auto-compile -s "$0" "$@"
(ice-9 match)
(ice-9 popen)
(ice-9 regex)
+ (system repl server)
(term ansi-color)
(git)
(xapian wrap)
@@ -43,6 +44,7 @@ exec guile --no-auto-compile -s "$0" "$@"
(tissue search)
(tissue tissue)
(tissue utils)
+ (tissue web server)
(tissue web static))
(define %state-directory
@@ -190,6 +192,72 @@ Export the repository as a website to OUTPUT-DIRECTORY.
(tissue-configuration-web-css (load-config))
(tissue-configuration-web-files (load-config)))))))
+(define (address->socket-address address port)
+ "Convert ADDRESS and PORT to a socket address."
+ (cond
+ ;; IPv4
+ ((string-contains address ".")
+ (make-socket-address AF_INET
+ (inet-pton AF_INET address)
+ port))
+ ;; IPv6
+ ((string-contains address ":")
+ (make-socket-address AF_INET6
+ (inet-pton AF_INET6 address)
+ port))
+ ;; Unix socket
+ (else
+ (make-socket-address AF_UNIX address))))
+
+(define tissue-run-web
+ (match-lambda*
+ (("--help")
+ (format #t "Usage: ~a run-web
+Run a web search service for the current repository.
+
+ --address=IP run web server listening on IP address [default=127.0.0.1]
+ --port=PORT run web server listening on PORT [default=8080]
+ --listen-repl=P run REPL server listening on port or path P
+"
+ (command-line-program)))
+ (args
+ (let ((args (args-fold args
+ (list (option (list "address")
+ #t #f
+ (lambda (opt name arg result)
+ (acons 'address arg result)))
+ (option (list "port")
+ #t #f
+ (lambda (opt name arg result)
+ (acons 'port (string->number arg)
+ result)))
+ (option '("listen-repl")
+ #t #f
+ (lambda (opt name arg result)
+ (acons 'listen-repl arg result))))
+ invalid-option
+ invalid-operand
+ ;; Default address and port
+ '((address . "127.0.0.1")
+ (port . 8080)))))
+ (let ((listen-repl (assq-ref args 'listen-repl)))
+ (when listen-repl
+ (spawn-server (cond
+ ((string? listen-repl)
+ (format (current-error-port)
+ "REPL server listening on port ~a~%"
+ listen-repl)
+ (make-unix-domain-server-socket #:path listen-repl))
+ (else
+ (format (current-error-port)
+ "REPL server listening on ~a~%"
+ listen-repl)
+ (make-unix-domain-server-socket #:path listen-repl))))))
+ (start-web-server (address->socket-address (assq-ref args 'address)
+ (assq-ref args 'port))
+ %xapian-index
+ (tissue-configuration-web-css (load-config)))))))
+
(define (print-usage)
(format #t "Usage: ~a COMMAND [OPTIONS] [ARGS]
@@ -199,6 +267,7 @@ COMMAND must be one of the sub-commands listed below:
show show the text of an issue
repl run a Guile script in a tissue environment
web export repository as website
+ run-web run a web search service
To get usage information for one of these sub-commands, run
~a COMMAND --help
@@ -266,6 +335,7 @@ top-level of the git repository."
("show" tissue-show)
("repl" tissue-repl)
("web" tissue-web)
+ ("run-web" tissue-run-web)
(invalid-command
(format (current-error-port) "Invalid command `~a'~%~%"
invalid-command)
diff --git a/tissue/document.scm b/tissue/document.scm
index 86fa548..2806f3b 100644
--- a/tissue/document.scm
+++ b/tissue/document.scm
@@ -41,6 +41,8 @@
document-term-generator
document-snippet
print
+ document-sxml-snippet
+ document->sxml
<file-document>
file-document-path
read-gemtext-document))
@@ -206,6 +208,42 @@ MSet object representing a list of search results."
(newline)
(newline))))
+(define (document-sxml-snippet document mset)
+ "Return snippet in SXML form for DOCUMENT. MSET is the xapian MSet
+object representing a list of search results."
+ ;; mset-snippet returns serialized HTML. So, we reverse it with
+ ;; html->sxml.
+ (match (html->sxml (mset-snippet mset
+ (document-text document)
+ #:length 200
+ #:highlight-start "<b>"
+ #:highlight-end "</b>"
+ #:stemmer (make-stem "en")))
+ (('*TOP* children ...)
+ (append-map (lambda (child)
+ (cond
+ ;; Add (br) if end of line.
+ ((and (string? child)
+ (string-suffix? "\n" child))
+ (list (string-trim-right child #\newline)
+ '(br)))
+ ;; Else, return verbatim.
+ (else
+ (list child))))
+ children))))
+
+(define-method (document->sxml (document <file-document>) mset)
+ "Render DOCUMENT to SXML. MSET is the xapian MSet object representing
+a list of search results."
+ `(li (@ (class "search-result"))
+ (a (@ (href ,(document-web-uri document)))
+ ,(document-title document))
+ ,@(let ((snippet (document-sxml-snippet document mset)))
+ (if snippet
+ (list `(span (@ (class "search-result-snippet"))
+ ,@snippet))
+ (list)))))
+
(define (read-gemtext-document file)
"Reade gemtext document from FILE. Return a <file-document> object."
(make <file-document>
diff --git a/tissue/issue.scm b/tissue/issue.scm
index 9b2f277..0003506 100644
--- a/tissue/issue.scm
+++ b/tissue/issue.scm
@@ -28,6 +28,7 @@
#:use-module (oop goops)
#:use-module (term ansi-color)
#:use-module (git)
+ #:use-module (web uri)
#:use-module (xapian xapian)
#:use-module (tissue document)
#:use-module (tissue git)
@@ -179,6 +180,62 @@
(else c)))
(string-downcase str)))
+(define-method (document->sxml (issue <issue>) mset)
+ "Render ISSUE, an <issue> object, to SXML. MSET is the xapian MSet
+object representing a list of search results."
+ `(li (@ (class "search-result"))
+ (a (@ (href ,(document-web-uri issue)))
+ ,(document-title issue))
+ ,@(map (lambda (tag)
+ (let ((words (string-split tag (char-set #\- #\space))))
+ `(a (@ (href ,(string-append "/search?query="
+ (uri-encode (string-append "tag:" tag))))
+ (class ,(string-append "tag"
+ (string-append " tag-" (sanitize-string tag))
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "bug" "critical"))))
+ " tag-bug"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "progress"))))
+ " tag-progress"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "chore"))))
+ " tag-chore"
+ "")
+ (if (not (null? (lset-intersection
+ string=? words
+ (list "enhancement" "feature"))))
+ " tag-feature"
+ ""))))
+ ,tag)))
+ (issue-keywords issue))
+ (span (@ (class "search-result-metadata"))
+ ,(string-append
+ (format #f " opened ~a by ~a"
+ (human-date-string (issue-created-date issue))
+ (issue-creator issue))
+ (if (> (length (issue-posts issue))
+ 1)
+ (format #f ", last updated ~a by ~a"
+ (human-date-string (issue-last-updated-date issue))
+ (issue-last-updater issue))
+ "")
+ (if (zero? (issue-tasks issue))
+ ""
+ (format #f "; ~a of ~a tasks done"
+ (issue-completed-tasks issue)
+ (issue-tasks issue)))))
+ ,@(let ((snippet (document-sxml-snippet issue mset)))
+ (if snippet
+ (list `(span (@ (class "search-result-snippet"))
+ ,@snippet))
+ (list)))))
+
(define (hashtable-append! hashtable key new-values)
"Append NEW-VALUES to the list of values KEY is associated to in
HASHTABLE. Deduplicate the resulting list if necessary. If KEY is not
diff --git a/tissue/web/server.scm b/tissue/web/server.scm
new file mode 100644
index 0000000..c2b59e1
--- /dev/null
+++ b/tissue/web/server.scm
@@ -0,0 +1,192 @@
+;;; tissue --- Text based issue tracker
+;;; Copyright © 2022 Arun Isaac <arunisaac@systemreboot.net>
+;;;
+;;; This file is part of tissue.
+;;;
+;;; tissue is free software: you can redistribute it and/or modify it
+;;; under the terms of the GNU General Public License as published by
+;;; the Free Software Foundation, either version 3 of the License, or
+;;; (at your option) any later version.
+;;;
+;;; tissue is distributed in the hope that it will be useful, but
+;;; WITHOUT ANY WARRANTY; without even the implied warranty of
+;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+;;; General Public License for more details.
+;;;
+;;; You should have received a copy of the GNU General Public License
+;;; along with tissue. If not, see <https://www.gnu.org/licenses/>.
+
+(define-module (tissue web server)
+ #:use-module (rnrs io ports)
+ #:use-module (srfi srfi-1)
+ #:use-module (srfi srfi-26)
+ #:use-module (ice-9 match)
+ #:use-module (htmlprag)
+ #:use-module (sxml simple)
+ #:use-module ((system repl server) #:select (make-unix-domain-server-socket))
+ #:use-module (web request)
+ #:use-module (web response)
+ #:use-module (web server)
+ #:use-module (web uri)
+ #:use-module (xapian xapian)
+ #:use-module (tissue conditions)
+ #:use-module (tissue document)
+ #:use-module (tissue search)
+ #:export (start-web-server))
+
+(define %css
+ "
+body {
+ max-width: 1000px;
+ margin: 0 auto;
+}
+
+form {
+ text-align: center;
+}
+
+.search-result {
+ list-style-type: none;
+ padding: 0.5em;
+}
+
+.search-result a {
+ text-decoration: none;
+ font-size: larger;
+}
+
+.search-result-metadata {
+ color: dimgray;
+ display: block;
+ font-size: smaller;
+}
+
+.search-result-snippet {
+ font-size: smaller;
+}
+
+a.tag {
+ padding: 0.25em 0.4em;
+ color: white;
+ background-color: blue;
+ border-radius: 0.5em;
+ margin: auto 0.25em;
+ font-size: smaller;
+}
+
+a.tag-bug {
+ background-color: red;
+}
+
+a.tag-feature {
+ background-color: green;
+}
+
+a.tag-progress, a.tag-unassigned {
+ background-color: orange;
+ color: black;
+}
+
+a.tag-chore {
+ background-color: khaki;
+ color: black;
+}")
+
+(define (make-search-page results css)
+ "Return SXML for a page with search RESULTS. CSS is a URI to a
+stylesheet."
+ `(html
+ (head
+ (title "Tissue search")
+ (style ,%css)
+ ,@(if css
+ (list `(link (@ (href "/style.css")
+ (rel "stylesheet")
+ (type "text/css"))))
+ (list)))
+ (body
+ (form (@ (action "/search") (method "GET"))
+ (input (@ (type "search") (name "query") (placeholder "Enter search query")))
+ (input (@ (type "submit") (value "Search"))))
+ (ul ,@results))))
+
+(define (query-parameters query)
+ "Return an association list of query parameters in web QUERY string."
+ (if query
+ (map (lambda (parameter)
+ (match (string-split parameter #\=)
+ ((key value)
+ (cons (uri-decode key)
+ (uri-decode value)))))
+ (string-split query #\&))
+ '()))
+
+(define (handler request body xapian-index css)
+ "Handle web REQUEST with BODY and return two values---the response
+headers and body. XAPIAN-INDEX is the path to the xapian database
+relative to the top-level of the current git repository. CSS is a URI
+to a stylesheet."
+ (let ((path (uri-path (request-uri request)))
+ (parameters (query-parameters (uri-query (request-uri request)))))
+ (format #t "~a ~a\n"
+ (request-method request)
+ path)
+ (cond
+ ((string=? path "/")
+ (values '((content-type . (text/html)))
+ (sxml->html (make-search-page '() css))))
+ ((string=? "/search" path)
+ (values '((content-type . (text/html)))
+ (sxml->html
+ (make-search-page
+ (call-with-database xapian-index
+ (lambda (db)
+ (search-map document->sxml
+ db
+ (list (assoc-ref parameters "query")))))
+ css))))
+ (else
+ (values (build-response #:code 404)
+ (string-append "Resource not found: "
+ (uri->string (request-uri request))))))))
+
+(define (start-web-server socket-address xapian-index css)
+ "Start web server listening on SOCKET-ADDRESS. XAPIAN-INDEX is the
+path to the xapian database relative to the top-level of the current
+git repository. CSS is a URI to a stylesheet."
+ (format (current-error-port)
+ "Tissue web server listening on ~a~%"
+ (cond
+ ;; IPv4 address
+ ((= (sockaddr:fam socket-address) AF_INET)
+ (format #f "~a:~a"
+ (inet-ntop (sockaddr:fam socket-address)
+ (sockaddr:addr socket-address))
+ (sockaddr:port socket-address)))
+ ;; IPv6 address
+ ((= (sockaddr:fam socket-address) AF_INET6)
+ (format #f "[~a]:~a"
+ (inet-ntop (sockaddr:fam socket-address)
+ (sockaddr:addr socket-address))
+ (sockaddr:port socket-address)))
+ ;; Unix socket
+ ((= (sockaddr:fam socket-address) AF_UNIX)
+ (sockaddr:path socket-address))))
+ (run-server (lambda (request body)
+ ;; Explicitly dereference the module and handler
+ ;; variable each time so as to support live hacking.
+ ((module-ref (resolve-module '(tissue web server))
+ 'handler)
+ request body xapian-index css))
+ 'http
+ (cond
+ ;; IPv4 or IPv6 address
+ ((or (= (sockaddr:fam socket-address) AF_INET)
+ (= (sockaddr:fam socket-address) AF_INET6))
+ (list #:family (sockaddr:fam socket-address)
+ #:addr (sockaddr:addr socket-address)
+ #:port (sockaddr:port socket-address)))
+ ;; Unix socket
+ ((= (sockaddr:fam socket-address) AF_UNIX)
+ (list #:socket (make-unix-domain-server-socket
+ #:path (sockaddr:path socket-address)))))))