Skip to content

Instantly share code, notes, and snippets.

@WetHat
Last active April 11, 2023 04:35
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save WetHat/a49e6f2140b401a190d45d31e052af8f to your computer and use it in GitHub Desktop.
Save WetHat/a49e6f2140b401a190d45d31e052af8f to your computer and use it in GitHub Desktop.
Pretty Print Table Data in Common Lisp
Display the source blob
Display the rendered blob
Raw
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Pretty Printing Table Data in Common Lisp\n",
"\n",
"Common Lisp has a powerfull but _byzantine_ string formatting language which can do all sorts\n",
"of magic. Here we are using the power of\n",
"[format](http://www.lispworks.com/documentation/HyperSpec/Body/22_c.htm)\n",
"to produce [ASCII art](https://en.wikipedia.org/wiki/ASCII_art)\n",
"tables from tabular data."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Objective\n",
"\n",
"Table output should:\n",
"* be general purpose (i.e. not specialized to numbers)\n",
"* be _pretty_ in its ASCII form\n",
"* support simple column level text alignment\n",
"* should be usable in Markdown text."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## About this Jupyter Notebook\n",
"\n",
"This _Gist_ was created using:\n",
"* the [Jupyter Lab](https://jupyter.org/) computational notebook.\n",
"* the [common-lisp-jupyter](https://yitzchak.github.io/common-lisp-jupyter/) kernel by Frederic Peschanski.\n",
"* [Steel Bank Common Lisp](http://www.sbcl.org/) (SBCL)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Coding Style\n",
"\n",
"For the most part the [Google Common Lisp Style Guide](https://google.github.io/styleguide/lispguide.xml) is used."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Table Formatting\n",
"\n",
"The format of choice for table output format is [Markdown](https://www.markdownguide.org/getting-started)\n",
"_[pipe tables](https://www.markdownguide.org/extended-syntax)_ which:\n",
"* Provides basic cell alignment instructions (left, right, center)\n",
"* Is pretty in ASCII form.\n",
"\n",
"**Example:**\n",
"\n",
"The Markdown pipe table:\n",
"\n",
"~~~\n",
"| Coord | Value |\n",
"| :----: | ----: |\n",
"| X | 12.5 |\n",
"| Y | 3.1 |\n",
"| Z | 19.0 |\n",
"~~~\n",
"\n",
"Renders as (Note: At the time of writing GitHub flavored Markdown did not support column alignment):\n",
"\n",
"| Coord | Value |\n",
"| :----: | ----: |\n",
"| X | 12.5 |\n",
"| Y | 3.1 |\n",
"| Z | 19.0 |\n",
"\n",
"\n",
"We want to do the heavy lifting of formatting key-value data only once, therefore we first define a generic table data model which can be easily generated from a variety of data structures.\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Table Cell Formatting\n",
"\n",
"To properly format table cells we need to find the corresponding formatting instructions using:\n",
"* [Tilde Less-Than-Sign: Justification](http://www.lispworks.com/documentation/HyperSpec/Body/22_cfb.htm)"
]
},
{
"cell_type": "code",
"execution_count": 2,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"\"|Test |\""
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"text/plain": [
"\"| Test |\""
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
},
{
"data": {
"text/plain": [
"\"| Test|\""
]
},
"execution_count": 2,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(format nil \"|~vA|\" 10 \"Test\") ; Left align text in a table cell\n",
"(format nil \"|~v:@<~A~>|\" 10 \"Test\") ; Center text in a table\n",
"(format nil \"|~v@A|\" 10 \"Test\") ; Right align text in a table cell"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Table Data Model \n",
"\n",
"To represent table date we use a simple and intuitive list-of-lists model which looks like:\n",
"\n",
"~~~ Lisp\n",
" '(( <obj1.1> ... <obj1.N>) ; row 1\n",
" ...\n",
" ( <objN.1> ... <objN.N>)) ; row N\n",
"~~~"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Table Cell Alignment Format Specification\n",
"\n",
"With the preparation from the previous chapter the format function can now be implemented.\n",
"We start with defining a property list which maps alignment keywords to cell format strings."
]
},
{
"cell_type": "code",
"execution_count": 3,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"+CELL-FORMATS+"
]
},
"execution_count": 3,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(defconstant +CELL-FORMATS+ '(:left \"~vA\"\n",
" :center \"~v:@<~A~>\"\n",
" :right \"~v@A\"))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## format-table [Function]\n",
"\n",
"Format table data as Markdown pipe table:\n",
"\n",
"~~~ Lisp\n",
"(format-table stream data [:column-label {string list}]\n",
" [:colun-align {keyword list}])\n",
"~~~\n",
"\n",
"#### Arguments\n",
"\n",
"stream {`output-stream`}\n",
"> The stream to write the table to. The symbol `T` can be used as\n",
"> an alias for `*standard-output*`\n",
"\n",
"data {`list of lists`}\n",
"> Table data of the form:\n",
">\n",
"> ~~~ Lisp\n",
"> '((<obj1.1> ... <obj1.N>) ; row 1\n",
"> ...\n",
"> (<objN.1> ... <objN.N>)) ; row N\n",
"> ~~~\n",
"\n",
"column-label {`string list`} default `'(\"COL1\" ... \"COLn\")`\n",
"> Optional list of column labels. If omitted the default labels\n",
"> `\"COL1\" ... \"COLn\"` are used. The\n",
"> length of the colum header list given in this parameter must be\n",
"> identical to the number of columns in the table.\n",
"\n",
"column-align {`keyword list`} default `'(:left ... :left)}`\n",
"> Optional list of column alignments. A column alignment is\n",
"> one of the keywords `:left`, `:center`, `:right`. The\n",
"> length of the keyword list given in this parameter must be\n",
"> identical to the number of columns in the table."
]
},
{
"cell_type": "code",
"execution_count": 4,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"FORMAT-TABLE"
]
},
"execution_count": 4,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(defun format-table (stream data &key (column-label (loop for i from 1 to (length (car data))\n",
" collect (format nil \"COL~D\" i)))\n",
" (column-align (loop for i from 1 to (length (car data))\n",
" collect :left)))\n",
" (let* ((col-count (length column-label))\n",
" (strtable (cons column-label ; table header\n",
" (loop for row in data ; table body with all cells as strings\n",
" collect (loop for cell in row\n",
" collect (if (stringp cell)\n",
" cell\n",
" ;else\n",
" (format nil \"~A\" cell))))))\n",
" (col-widths (loop with widths = (make-array col-count :initial-element 0)\n",
" for row in strtable\n",
" do (loop for cell in row\n",
" for i from 0\n",
" do (setf (aref widths i)\n",
" (max (aref widths i) (length cell))))\n",
" finally (return widths))))\n",
" ;------------------------------------------------------------------------------------\n",
" ; splice in the header separator\n",
" (setq strtable\n",
" (nconc (list (car strtable) ; table header\n",
" (loop for align in column-align ; generate separator\n",
" for width across col-widths\n",
" collect (case align\n",
" (:left (format nil \":~v@{~A~:*~}\"\n",
" (1- width) \"-\"))\n",
" (:right (format nil \"~v@{~A~:*~}:\"\n",
" (1- width) \"-\"))\n",
" (:center (format nil \":~v@{~A~:*~}:\"\n",
" (- width 2) \"-\")))))\n",
" (cdr strtable))) ; table body\n",
" ;------------------------------------------------------------------------------------\n",
" ; Generate the formatted table\n",
" (let ((row-fmt (format nil \"| ~{~A~^ | ~} |~~%\" ; compile the row format\n",
" (loop for align in column-align\n",
" collect (getf +CELL-FORMATS+ align))))\n",
" (widths (loop for w across col-widths collect w)))\n",
" ; write each line to the given stream\n",
" (dolist (row strtable)\n",
" (apply #'format stream row-fmt (mapcan #'list widths row)))))\n",
")"
]
},
{
"cell_type": "markdown",
"metadata": {
"toc-hr-collapsed": false
},
"source": [
"# Examples\n",
"\n",
"The table data sets used in the examples below were shamelessly stolen from the\n",
"[Northwind Database](https://github.com/graphql-compose/graphql-compose-examples/tree/master/examples/northwind/data/csv)."
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Customer Table"
]
},
{
"cell_type": "code",
"execution_count": 5,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"+CUSTOMER-DATA+"
]
},
"execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(defconstant +CUSTOMER-DATA+\n",
" '((:ALFKI\t\"Alfreds Futterkiste\" \"Maria Anders\")\n",
" (:ANATR\t\"Ana Trujillo Emparedados y helados\" \"Ana Trujillo\")\n",
" (:ANTON\t\"Antonio Moreno Taquería\" \"Antonio Moreno\")\n",
" (:AROUT\t\"Around the Horn\" \"Thomas Hardy\")\n",
" (:BERGS\t\"Berglunds snabbköp\" \"Christina Berglund\")\n",
" (:BLAUS\t\"Blauer See Delikatessen\" \"Hanna Moos\")\n",
" (:BLONP\t\"Blondesddsl père et fils\" \"Frédérique Citeaux\")\n",
" (:BOLID\t\"Bólido Comidas preparadas\" \"Martín Sommer\")\n",
" (:BONAP\t\"Bon app'\" \"Laurence Lebihan\")\n",
" (:BOTTM\t\"Bottom-Dollar Markets\" \"Elizabeth Lincoln\")\n",
" (:BSBEV\t\"B's Beverages\" \"Victoria Ashworth\")\n",
" (:CACTU\t\"Cactus Comidas para llevar\" \"Patricio Simpson\")\n",
" (:CENTC\t\"Centro comercial Moctezuma\" \"Francisco Chang\")\n",
" (:CHOPS\t\"Chop-suey Chinese\" \"Yang Wang\")\n",
" (:COMMI\t\"Comércio Mineiro\" \"Pedro Afonso\")\n",
" (:CONSH\t\"Consolidated Holdings\" \"Elizabeth Brown\")\n",
" (:DRACD\t\"Drachenblut Delikatessen\" \"Sven Ottlieb\")))"
]
},
{
"cell_type": "code",
"execution_count": 6,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"NIL"
]
},
"execution_count": 6,
"metadata": {},
"output_type": "execute_result"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"| ID | Customer | Contact |\n",
"| ----: | ---------------------------------: | :----------------: |\n",
"| ALFKI | Alfreds Futterkiste | Maria Anders |\n",
"| ANATR | Ana Trujillo Emparedados y helados | Ana Trujillo |\n",
"| ANTON | Antonio Moreno Taquería | Antonio Moreno |\n",
"| AROUT | Around the Horn | Thomas Hardy |\n",
"| BERGS | Berglunds snabbköp | Christina Berglund |\n",
"| BLAUS | Blauer See Delikatessen | Hanna Moos |\n",
"| BLONP | Blondesddsl père et fils | Frédérique Citeaux |\n",
"| BOLID | Bólido Comidas preparadas | Martín Sommer |\n",
"| BONAP | Bon app' | Laurence Lebihan |\n",
"| BOTTM | Bottom-Dollar Markets | Elizabeth Lincoln |\n",
"| BSBEV | B's Beverages | Victoria Ashworth |\n",
"| CACTU | Cactus Comidas para llevar | Patricio Simpson |\n",
"| CENTC | Centro comercial Moctezuma | Francisco Chang |\n",
"| CHOPS | Chop-suey Chinese | Yang Wang |\n",
"| COMMI | Comércio Mineiro | Pedro Afonso |\n",
"| CONSH | Consolidated Holdings | Elizabeth Brown |\n",
"| DRACD | Drachenblut Delikatessen | Sven Ottlieb |\n",
"\n",
"Evaluation took:\n",
" 0.000 seconds of real time\n",
" 0.000000 seconds of total run time (0.000000 user, 0.000000 system)\n",
" 100.00% CPU\n",
" 1,054,994 processor cycles\n",
" 65,456 bytes consed\n",
" \n"
]
}
],
"source": [
"; DEMO: Create a formatted table from data with 3 columns\n",
"(time (format-table t +CUSTOMER-DATA+\n",
" :column-label '(\"ID\" \"Customer\" \"Contact\")\n",
" :column-align '(:right :right :center)))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"When rendered with a Markdown viewer the table looks like (Note: At the time of writing GitHub flavored Markdown did not support column alignment): \n",
"\n",
" ID | Customer | Contact \n",
"----: | ---------------------------------: | :----------------:\n",
"ALFKI | Alfreds Futterkiste | Maria Anders \n",
"ANATR | Ana Trujillo Emparedados y helados | Ana Trujillo \n",
"ANTON | Antonio Moreno Taquería | Antonio Moreno \n",
"AROUT | Around the Horn | Thomas Hardy \n",
"BERGS | Berglunds snabbköp | Christina Berglund\n",
"BLAUS | Blauer See Delikatessen | Hanna Moos \n",
"BLONP | Blondesddsl père et fils | Frédérique Citeaux\n",
"BOLID | Bólido Comidas preparadas | Martín Sommer \n",
"BONAP | Bon app' | Laurence Lebihan \n",
"BOTTM | Bottom-Dollar Markets | Elizabeth Lincoln \n",
"BSBEV | B's Beverages | Victoria Ashworth \n",
"CACTU | Cactus Comidas para llevar | Patricio Simpson \n",
"CENTC | Centro comercial Moctezuma | Francisco Chang \n",
"CHOPS | Chop-suey Chinese | Yang Wang \n",
"COMMI | Comércio Mineiro | Pedro Afonso \n",
"CONSH | Consolidated Holdings | Elizabeth Brown \n",
"DRACD | Drachenblut Delikatessen | Sven Ottlieb "
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Single Column Data"
]
},
{
"cell_type": "code",
"execution_count": 7,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"+SINGLE-COLUMN-DATA+"
]
},
"execution_count": 7,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(defconstant +SINGLE-COLUMN-DATA+\n",
" '((\"Alfreds Futterkiste\")\n",
" (\"Ana Trujillo Emparedados y helados\")\n",
" (\"Antonio Moreno Taquería\")\n",
" (\"Around the Horn\")\n",
" (\"Berglunds snabbköp\")\n",
" (\"Blauer See Delikatessen\")\n",
" (\"Blondesddsl père et fils\")\n",
" (\"Bólido Comidas preparadas\")\n",
" (\"Bon app'\")\n",
" (\"Bottom-Dollar Markets\")\n",
" (\"B's Beverages\")\n",
" (\"Cactus Comidas para llevar\")\n",
" (\"Centro comercial Msoctezuma\")\n",
" (\"Chop-suey Chinese\")\n",
" (\"Comércio Mineiro\")\n",
" (\"Consolidated Holdings\")\n",
" (\"Drachenblut Delikatessen\")))"
]
},
{
"cell_type": "code",
"execution_count": 8,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"NIL"
]
},
"execution_count": 8,
"metadata": {},
"output_type": "execute_result"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"| Customer |\n",
"| :--------------------------------: |\n",
"| Alfreds Futterkiste |\n",
"| Ana Trujillo Emparedados y helados |\n",
"| Antonio Moreno Taquería |\n",
"| Around the Horn |\n",
"| Berglunds snabbköp |\n",
"| Blauer See Delikatessen |\n",
"| Blondesddsl père et fils |\n",
"| Bólido Comidas preparadas |\n",
"| Bon app' |\n",
"| Bottom-Dollar Markets |\n",
"| B's Beverages |\n",
"| Cactus Comidas para llevar |\n",
"| Centro comercial Msoctezuma |\n",
"| Chop-suey Chinese |\n",
"| Comércio Mineiro |\n",
"| Consolidated Holdings |\n",
"| Drachenblut Delikatessen |\n",
"\n",
"Evaluation took:\n",
" 0.000 seconds of real time\n",
" 0.000000 seconds of total run time (0.000000 user, 0.000000 system)\n",
" 100.00% CPU\n",
" 320,908 processor cycles\n",
" 0 bytes consed\n",
" \n"
]
}
],
"source": [
"; DEMO: Create a formatted table from data with 1 column\n",
"(time (format-table t +SINGLE-COLUMN-DATA+\n",
" :column-label '(\"Customer\")\n",
" :column-align '(:center)))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Tables with Numbers"
]
},
{
"cell_type": "code",
"execution_count": 9,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"+NUMERIC-DATA+"
]
},
"execution_count": 9,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"(defconstant +NUMERIC-DATA+\n",
" '((10248 11 14.00 12 0)\n",
" (10248 42 9.80 10 0)\n",
" (10248 72 34.80 5 0)\n",
" (10249 14 18.60 9 0)\n",
" (10249 51 42.40 40 0)\n",
" (10250 41 7.70 10 0)\n",
" (10250 51 42.40 35 0.15)\n",
" (10250 65 16.80 15 0.15)\n",
" (10251 22 16.80 6 0.05)\n",
" (10251 57 15.60 15 0.05)\n",
" (10251 65 16.80 20 0)))"
]
},
{
"cell_type": "code",
"execution_count": 10,
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"NIL"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"| Order# | Product# | Price | Quantity | Discount |\n",
"| :----: | :------: | ----: | -------: | :------- |\n",
"| 10248 | 11 | 14.0 | 12 | 0 |\n",
"| 10248 | 42 | 9.8 | 10 | 0 |\n",
"| 10248 | 72 | 34.8 | 5 | 0 |\n",
"| 10249 | 14 | 18.6 | 9 | 0 |\n",
"| 10249 | 51 | 42.4 | 40 | 0 |\n",
"| 10250 | 41 | 7.7 | 10 | 0 |\n",
"| 10250 | 51 | 42.4 | 35 | 0.15 |\n",
"| 10250 | 65 | 16.8 | 15 | 0.15 |\n",
"| 10251 | 22 | 16.8 | 6 | 0.05 |\n",
"| 10251 | 57 | 15.6 | 15 | 0.05 |\n",
"| 10251 | 65 | 16.8 | 20 | 0 |\n",
"\n",
"Evaluation took:\n",
" 0.000 seconds of real time\n",
" 0.000000 seconds of total run time (0.000000 user, 0.000000 system)\n",
" 100.00% CPU\n",
" 666,304 processor cycles\n",
" 65,376 bytes consed\n",
" \n"
]
}
],
"source": [
"; DEMO: Create a formatted table from data with 3 columns\n",
"(time (format-table t +NUMERIC-DATA+\n",
" :column-label '(\"Order#\" \"Product#\" \"Price\" \"Quantity\" \"Discount\")\n",
" :column-align '(:center :center :right :right :left)))"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Reference"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Formatting Directives\n",
"\n",
"These format commands were used in the implementation of the pretty print methods:\n",
"\n",
"* [Tilde Tilde](http://www.lispworks.com/documentation/HyperSpec/Body/22_cae.htm)\n",
"* [Tilde Left-Brace: Iteration](http://www.lispworks.com/documentation/HyperSpec/Body/22_cgd.htm)\n",
"* [Tilde Asterisk: Go-To](http://www.lispworks.com/documentation/HyperSpec/Body/22_cga.htm)\n",
"* [Tilde Less-Than-Sign: Justification](http://www.lispworks.com/documentation/HyperSpec/Body/22_cfb.htm)"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## See also\n",
"* [18. A Few FORMAT Recipes](http://www.gigamonkeys.com/book/a-few-format-recipes.html) from [Practical Common Lisp](http://www.gigamonkeys.com/book/)\n",
"* [22.3 Formatted Output](http://www.lispworks.com/documentation/HyperSpec/Body/22_c.htm) from [Common Lisp Hyperspec](http://www.lispworks.com/documentation/HyperSpec/Front/index.htm)\n",
"* [Google Common Lisp Style Guide](https://google.github.io/styleguide/lispguide.xml)\n",
"* [pipe tables](https://www.markdownguide.org/extended-syntax) from [Markdown Guide](https://www.markdownguide.org)\n",
"* [What is Markdown](https://www.markdownguide.org/getting-started) from [Markdown Guide](https://www.markdownguide.org)\n",
"* [A simple data dump from SQL's Northwind database to CSV](https://github.com/graphql-compose/graphql-compose-examples/tree/master/examples/northwind/data/csv)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Common Lisp",
"language": "common-lisp",
"name": "common-lisp"
},
"language_info": {
"codemirror_mode": "text/x-common-lisp",
"file_extension": ".lisp",
"mimetype": "text/x-common-lisp",
"name": "common-lisp",
"pygments_lexer": "common-lisp",
"version": "2.0.0"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": true,
"toc_position": {
"height": "calc(100% - 180px)",
"left": "10px",
"top": "150px",
"width": "193.865px"
},
"toc_section_display": true,
"toc_window_display": true
},
"toc-showtags": false
},
"nbformat": 4,
"nbformat_minor": 4
}
@lispm
Copy link

lispm commented Feb 22, 2020

You can get rid of APPLY:

CL-USER 70 > (apply #'nconc (mapcar (lambda (w s) (list w s)) '(1 2 3 4) '(a b c d)))
(1 A 2 B 3 C 4 D)

CL-USER 71 > (apply #'nconc (mapcar #'list '(1 2 3 4) '(a b c d)))
(1 A 2 B 3 C 4 D)

CL-USER 72 > (mapcan #'list '(1 2 3 4) '(a b c d))
(1 A 2 B 3 C 4 D)

@WetHat
Copy link
Author

WetHat commented Feb 22, 2020

Very good catch lispm! Your input is very much appreciated!

@Symbolics
Copy link

This is great. I am about finished with table formatting functions for use with the pretty printer and thought about a markdown/restructured text version. Glad I found this. Perhaps these two could be combined into a project, say, "formatted-tables" ? My stuff is under the MS-PL. What's the license for this?

@WetHat
Copy link
Author

WetHat commented Apr 8, 2021

@Symbolics, the code has no usage restrictions. Use it as you see fit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment