diff --git a/.gitignore b/.gitignore
index 55be276..d8b95ac 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,154 +1,170 @@
-# ---> Python
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
+# Common Lisp Stuff
+*.fasl
+*.dx32fsl
+*.dx64fsl
+*.lx32fsl
+*.lx64fsl
+*.x86f
+*~
+.#*
+
+# Python Stuff
+-# Byte-compiled / optimized / DLL files
+-__pycache__/
+-*.py[cod]
+-*$py.class
+-
+-# C extensions
+-*.so
+-
+-# Distribution / packaging
+-.Python
+-build/
+-develop-eggs/
+-dist/
+-downloads/
+-eggs/
+-.eggs/
+-lib/
+-lib64/
+-parts/
+-sdist/
+-var/
+-wheels/
+-share/python-wheels/
+-*.egg-info/
+-.installed.cfg
+-*.egg
+-MANIFEST
+-
+-# PyInstaller
+-# Usually these files are written by a python script from a template
+-# before PyInstaller builds the exe, so as to inject date/other infos into it.
+-*.manifest
+-*.spec
+-
+-# Installer logs
+-pip-log.txt
+-pip-delete-this-directory.txt
+-
+-# Unit test / coverage reports
+-htmlcov/
+-.tox/
+-.nox/
+-.coverage
+-.coverage.*
+-.cache
+-nosetests.xml
+-coverage.xml
+-*.cover
+-*.py,cover
+-.hypothesis/
+-.pytest_cache/
+-cover/
+-
+-# Translations
+-*.mo
+-*.pot
+-
+-# Django stuff:
+-*.log
+-local_settings.py
+-db.sqlite3
+-db.sqlite3-journal
+-
+-# Flask stuff:
+-instance/
+-.webassets-cache
+-
+-# Scrapy stuff:
+-.scrapy
+-
+-# Sphinx documentation
+-docs/_build/
+-
+-# PyBuilder
+-.pybuilder/
+-target/
+-
+-# Jupyter Notebook
+-.ipynb_checkpoints
+-
+-# IPython
+-profile_default/
+-ipython_config.py
+-
+-# pyenv
+-# For a library or package, you might want to ignore these files since the code is
+-# intended to run in multiple environments; otherwise, check them in:
+-# .python-version
+-
+-# pipenv
+-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+-# However, in case of collaboration, if having platform-specific dependencies or dependencies
+-# having no cross-platform support, pipenv may install dependencies that don't work, or not
+-# install all needed dependencies.
+-#Pipfile.lock
+-
+-# poetry
+-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+-# This is especially recommended for binary packages to ensure reproducibility, and is more
+-# commonly ignored for libraries.
+-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+-#poetry.lock
+-
+-# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+-__pypackages__/
+-
+-# Celery stuff
+-celerybeat-schedule
+-celerybeat.pid
+-
+-# SageMath parsed files
+-*.sage.py
+-
+-# Environments
+-.env
+-.venv
+-env/
+-venv/
+-ENV/
+-env.bak/
+-venv.bak/
+hot-line-python/venv/
+-
+-# Spyder project settings
+-.spyderproject
+-.spyproject
+-
+-# Rope project settings
+-.ropeproject
+-
+-# mkdocs documentation
+-/site
+-
+-# mypy
+-.mypy_cache/
+-.dmypy.json
+-dmypy.json
+-
+-# Pyre type checker
+-.pyre/
+-
+-# pytype static type analyzer
+-.pytype/
+-
+-# Cython debug symbols
+-cython_debug/
+-
+-# PyCharm
+-# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
+-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+-# and can be added to the global gitignore or merged into this file. For a more nuclear
+-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+-#.idea/
+
+# Craig's Custom Stuff
+# hot-line is a sym-link to the non-dev project folder.
+/hot-line
+/storage/*
+/db/*.db
\ No newline at end of file
diff --git a/README.markdown b/README.markdown
new file mode 100644
index 0000000..39f2366
--- /dev/null
+++ b/README.markdown
@@ -0,0 +1,16 @@
+# hot-line
+
+A proof-of-concept website which generates interactive charts whilst integrating with Python and its Bokeh library.
+
+## Usage
+
+## Installation
+
+## Author
+
+* Craig Oates (craig@craigoates.net)
+
+## Copyright
+
+Copyright (c) 2022 Craig Oates (craig@craigoates.net)
+
diff --git a/app.lisp b/app.lisp
new file mode 100644
index 0000000..9886106
--- /dev/null
+++ b/app.lisp
@@ -0,0 +1,41 @@
+(ql:quickload :hot-line)
+
+(defpackage hot-line.app
+ (:use :cl)
+ (:import-from :lack.builder
+ :builder)
+ (:import-from :ppcre
+ :scan
+ :regex-replace)
+ (:import-from :hot-line.web
+ :*web*)
+ (:import-from :hot-line.config
+ :config
+ :productionp
+ :*static-directory*))
+(in-package :hot-line.app)
+
+(funcall clack-errors:*clack-error-middleware*
+ (builder
+ (:static
+ :path (lambda (path)
+ (if (ppcre:scan "^(?:/images/|/css/|/js/|/robot\\.txt$|/favicon\\.ico$)" path)
+ path
+ nil))
+ :root *static-directory*)
+ (if (productionp)
+ nil
+ :accesslog)
+ (if (getf (config) :error-log)
+ `(:backtrace
+ :output ,(getf (config) :error-log))
+ nil)
+ :session
+ (if (productionp)
+ nil
+ (lambda (app)
+ (lambda (env)
+ (let ((datafly:*trace-sql* t))
+ (funcall app env)))))
+ *web*)
+ :debug t)
diff --git a/db/schema.sql b/db/schema.sql
new file mode 100644
index 0000000..e69de29
diff --git a/README.md b/hot-line-python/README.md
similarity index 100%
rename from README.md
rename to hot-line-python/README.md
diff --git a/hot-line-python/app/hot-line-python.py b/hot-line-python/app/hot-line-python.py
new file mode 100755
index 0000000..89eca2d
--- /dev/null
+++ b/hot-line-python/app/hot-line-python.py
@@ -0,0 +1,251 @@
+#!/usr/bin/env python
+from bokeh.plotting import figure, output_file, save, show
+from bokeh.models import Legend, FactorRange
+from bokeh.palettes import Spectral4
+import pandas as pd
+import numpy as np
+import os.path
+from sys import stdin
+import argparse
+import logging
+from pathlib import Path
+from rich import print
+from rich.console import Console
+from rich.markdown import Markdown
+from rich.logging import RichHandler
+from rich.traceback import install
+import random
+
+# Global Variables (Making things easy for myself)
+# ====================================================================
+__version__ = "1.0.0"
+logging.basicConfig(level="NOTSET", format="%(message)s",
+ datefmt="[%X] ", handlers=[RichHandler()])
+log = logging.getLogger("rich")
+console = Console()
+# ====================================================================
+
+def get_extension(path):
+ return os.path.splitext(path)[1]
+
+def parse_arguments():
+ parser = argparse.ArgumentParser("hot-line-python")
+ parser.add_argument("-v", "--version", action="version",
+ version='%(prog)s: {version}'.format(version=__version__))
+ parser.add_argument("-V", "--verbose",
+ help="Provide info. on the current state of the chart creation.",
+ action="store_true")
+ parser.add_argument("input",
+ help="The path of the data file which hot-line will use to make the chart..")
+ parser.add_argument("output",
+ help="The location hot-line will write the newly created chart to.")
+ parser.add_argument("-t", "--title",
+ help="The title displayed on the chart.")
+ parser.add_argument("-x", "--xaxis",
+ help="The label used on the chart's x-axis.")
+ parser.add_argument("-y", "--yaxis",
+ help="The label used on the chart's y-axis.")
+
+ return parser.parse_args()
+
+def get_y_values(data):
+ r = (data.loc[0])
+ return r[1::1]
+
+def random_colour():
+ r = random.randint(0,255)
+ g = random.randint(0,255)
+ b = random.randint(0,255)
+ rgb = [r,g,b]
+ return rgb
+
+# I could reduce the duplicated code by extracting the 'verbose' code out but I
+# don't plan on touching this much after it's able to generate charts. So, I'm
+# at a point where I don't care. If this script needs to expand, then I will
+# worry about refactoring and making the code pretty then.
+def build_chart(arguments):
+ column_data = []
+ column_headers = []
+ x_values = []
+ legend_items = []
+ if arguments.verbose is True:
+ tasks = ["Loading file: Complete",
+ "Parsing data: Complete",
+ "Building chart outline: Complete",
+ "Populating chart: Complete",
+ "All Done"]
+ tasks_total = len(tasks)
+ with console.status("[bold green]Performing magic...") as status:
+ try:
+ # 1. Load file...
+ extension = get_extension(arguments.input)
+ if extension == ".tsv":
+ data = pd.read_csv(arguments.input, sep="\t", header=None,
+ index_col=False, dtype='unicode')
+ elif extension == ".csv":
+ data = pd.read_csv(arguments.input, sep=",", header=None,
+ index_col=False, dtype='unicode')
+ else:
+ log.critical("File is neither a .csv or .tsv file. Unable to process.")
+ return
+ y_labels = get_y_values(data)
+ data = data[1:]
+ task = tasks.pop(0)
+ console.log(f"1/{tasks_total}. {task}")
+
+ # 2. Parse data...
+ for x in data.columns:
+ values = data.iloc[:, x]
+ column_data.append(values)
+ x_values = column_data[0]
+ column_data.pop(0)
+ task = tasks.pop(0)
+ console.log(f"2/{tasks_total}. {task}")
+
+ # If x_values contains duplicated values, they will
+ # need an 'ID' appended to each value. This part of
+ # the code checks for them so the code below will know
+ # to append it or not. If there are no duplicated
+ # entries, the appendaged is not needed.
+ dupes = False
+ for item in x_values.duplicated():
+ if item is False:
+ dupes = True
+ break
+
+ # Add an 'ID' tag to the x-axis values. This is done
+ # to stop duplicated values which causes errors in
+ # Bokeh.
+ x_range = []
+ if dupes == True:
+ counter = 0
+ for row_value in x_values:
+ x_range.append(f"{counter}: {row_value}")
+ counter = counter + 1
+ else:
+ x_range = x_values
+
+ # 3. Build chart outline...
+ p = figure(title=arguments.title,
+ x_range=FactorRange(* x_range),
+ x_axis_label=arguments.xaxis,
+ y_axis_label=arguments.yaxis,
+ sizing_mode="stretch_both")
+ p.axis.major_label_text_font_size = "12px"
+ p.axis.major_label_standoff = -10
+ task = tasks.pop(0)
+ console.log(f"3/{tasks_total}. {task}")
+
+ # 4. Populate chart...
+ # The reason for the '+ 1' and 'y - 1' when specifying
+ # an index value is to avoid O.B.O.B's. Program throws an
+ # error when y_label[0] is used, can only access from
+ # '1' or above. column_data starts at 0. So, the index
+ # (y) needs to drop down 1 from the current 'y' value
+ # -- which indicates where about in the loop the
+ # program is regarding the y_labels list.
+ for y in range (1, (len(y_labels) + 1)):
+ col = random_colour()
+ p.line(x_range,
+ column_data[y - 1],
+ legend_label=f"{y_labels[y]}",
+ line_color=random_colour(),
+ line_width=2)
+ task = tasks.pop(0)
+ console.log(f"4/{tasks_total}. {task}")
+
+ # 5. Write chart to disk...
+ output_file(arguments.output)
+ save(p)
+ task = tasks.pop(0)
+ console.log(f"5/{tasks_total}. {task}")
+ except IOError:
+ log.critical("File cannot be found.")
+ except Exception as e:
+ log.critical("Unrecoverable system error.")
+ console.print_exception()
+ else:
+ try:
+ # 1. Load file...
+ extension = get_extension(arguments.input)
+ if extension == ".tsv":
+ data = pd.read_csv(arguments.input, sep="\t", header=None,
+ index_col=False, dtype='unicode')
+ elif extension == ".csv":
+ data = pd.read_csv(arguments.input, sep=",", header=None,
+ index_col=False, dtype='unicode')
+ else:
+ log.critical("File is neither a .csv or .tsv file. Unable to process.")
+ return
+ y_labels = get_y_values(data)
+ data = data[1:]
+
+ # 2. Parse data...
+ for x in data.columns:
+ values = data.iloc[:, x]
+ column_data.append(values)
+ x_values = column_data[0]
+ column_data.pop(0)
+ # If x_values contains duplicated values, they will
+ # need an 'ID' appended to each value. This part of
+ # the code checks for them so the code below will know
+ # to append it or not. If there are no duplicated
+ # entries, the appendaged is not needed.
+ dupes = False
+ for item in x_values.duplicated():
+ if item is False:
+ dupes = True
+ break
+
+ # Add an 'ID' tag to the x-axis values. This is done
+ # to stop duplicated values which causes errors in
+ # Bokeh.
+ x_range = []
+ if dupes == True:
+ counter = 0
+ for row_value in x_values:
+ x_range.append(f"{counter}: {row_value}")
+ counter = counter + 1
+ else:
+ x_range = x_values
+
+ # 3. Build chart outline...
+ p = figure(title=arguments.title,
+ x_range=FactorRange(* x_range),
+ x_axis_label=arguments.xaxis,
+ y_axis_label=arguments.yaxis,
+ sizing_mode="stretch_both")
+ p.axis.major_label_text_font_size = "12px"
+ p.axis.major_label_standoff = 10
+
+ # 4. Populate chart...
+ # The reason for the '+ 1' and 'y - 1' when specifying
+ # an index value is to avoid O.B.O.B's. Program throws an
+ # error when y_label[0] is used, can only access from
+ # '1' or above. column_data starts at 0. So, the index
+ # (y) needs to drop down 1 from the current 'y' value
+ # -- which indicates where about in the loop the
+ # program is regarding the y_labels list.
+ for y in range (1, (len(y_labels) + 1)):
+ col = random_colour()
+ p.line(x_range,
+ column_data[y - 1],
+ legend_label=f"{y_labels[y]}",
+ line_color=random_colour(),
+ line_width=2)
+
+ # 5. Write chart to disk...
+ output_file(arguments.output)
+ save(p)
+ except IOError:
+ log.critical("File cannot be found.")
+ except Exception as e:
+ log.critical("Unrecoverable system error.")
+ console.print_exception()
+
+def main():
+ args = parse_arguments()
+ build_chart(args)
+
+if __name__ == "__main__":
+ main()
diff --git a/hot-line-python/requirements.txt b/hot-line-python/requirements.txt
new file mode 100644
index 0000000..758eb2c
--- /dev/null
+++ b/hot-line-python/requirements.txt
@@ -0,0 +1,18 @@
+bokeh==2.4.3
+commonmark==0.9.1
+Jinja2==3.1.2
+MarkupSafe==2.1.1
+numpy==1.23.1
+packaging==21.3
+pandas==1.4.3
+pathlib==1.0.1
+Pillow==9.2.0
+Pygments==2.12.0
+pyparsing==3.0.9
+python-dateutil==2.8.2
+pytz==2022.1
+PyYAML==6.0
+rich==12.5.1
+six==1.16.0
+tornado==6.2
+typing-extensions==4.3.0
diff --git a/hot-line.asd b/hot-line.asd
new file mode 100644
index 0000000..bb3bbbf
--- /dev/null
+++ b/hot-line.asd
@@ -0,0 +1,67 @@
+(defsystem "hot-line"
+ :version "0.1.0"
+ :author "Craig Oates"
+ :license "MIT"
+ :depends-on ("clack"
+ "lack"
+ "caveman2"
+ "envy"
+ "cl-ppcre"
+ "uiop"
+
+ ;; for @route annotation
+ "cl-syntax-annot"
+
+ ;; HTML Template
+ "djula"
+
+ ;; for DB
+ "datafly"
+ "sxql"
+
+ ;; Additional Packages
+ #:clack-errors ; Error report (HTML/template views)
+ #:mito ; Database ORM
+ #:osicat ; Environment variables (dev/prod.)
+ #:ratify ; Utilites
+ #:sqlite ; Sqlite database ORM
+ #:hermetic ; Authentication
+ #:cl-fad ; Files and directories
+ #:serapeum ; Pagination
+ )
+ :pathname "src/"
+ :components (;; Caveman Files
+ (:file "config")
+ (:file "main")
+ (:file "db")
+ (:file "view")
+ ;; hot-line Specific Files
+ (:file "models/app-constants")
+ (:file "models/user")
+ (:file "models/file")
+ (:file "services/storage")
+ (:file "services/authentication")
+ (:file "services/validation")
+ (:file "services/convert")
+ (:file "services/user-management")
+ (:file "services/db-management")
+ (:file "services/storage-management")
+ (:file "services/pagination")
+ (:file "services/routing")
+ ;; Caveman Files
+ (:file "web"))
+ :description "A proof-of-concept website testing Python and its Bokeh library."
+ :build-operation "program-op" ;; leave as is
+ :build-pathname "hot-line-nellis"
+ :entry-point "hot-line:main"
+ :in-order-to ((test-op (test-op "hot-line/test"))))
+
+(defsystem #:hot-line/tests
+ :author "Craig Oates"
+ :license "MIT"
+ :depends-on (#:hot-line
+ #:parachute)
+ :pathname "tests/"
+ :components ((:file "tests"))
+ :description "Test system for hot-line"
+ :perform (test-op (op s) (symbol-call :parachute :test :tests)))
diff --git a/src/config.lisp b/src/config.lisp
new file mode 100644
index 0000000..8ed7377
--- /dev/null
+++ b/src/config.lisp
@@ -0,0 +1,45 @@
+;; (in-package :cl-user)
+(defpackage hot-line.config
+ (:use :cl)
+ (:import-from :envy
+ :config-env-var
+ :defconfig)
+ (:export :config
+ :*application-root*
+ :*static-directory*
+ :*template-directory*
+ :appenv
+ :developmentp
+ :productionp))
+(in-package :hot-line.config)
+
+(setf (config-env-var) "APP_ENV")
+
+(defparameter *application-root* (asdf:system-source-directory :hot-line))
+(defparameter *static-directory* (merge-pathnames #P"static/" *application-root*))
+(defparameter *template-directory* (merge-pathnames #P"templates/" *application-root*))
+
+(defconfig :common
+ `(:application-root ,(asdf:component-pathname (asdf:find-system :hot-line))
+ :databases ((:maindb :sqlite3 :database-name ,(merge-pathnames #P"db/hot-line.db")))))
+
+(defconfig |development|
+ '())
+
+(defconfig |production|
+ '())
+
+(defconfig |test|
+ '())
+
+(defun config (&optional key)
+ (envy:config #.(package-name *package*) key))
+
+(defun appenv ()
+ (uiop:getenv (config-env-var #.(package-name *package*))))
+
+(defun developmentp ()
+ (string= (appenv) "development"))
+
+(defun productionp ()
+ (string= (appenv) "production"))
diff --git a/src/db.lisp b/src/db.lisp
new file mode 100644
index 0000000..3ed557e
--- /dev/null
+++ b/src/db.lisp
@@ -0,0 +1,26 @@
+;; (in-package :cl-user)
+(defpackage hot-line.db
+ (:use :cl)
+ (:import-from :hot-line.config
+ :config)
+ (:import-from :datafly
+ :*connection*)
+ (:import-from :cl-dbi
+ :connect-cached)
+ (:export :connection-settings
+ :db
+ :with-connection))
+(in-package :hot-line.db)
+
+(defun connection-settings (&optional (db :maindb))
+ (cdr (assoc db (config :databases))))
+
+(defun db (&optional (db :maindb))
+ (apply #'connect-cached (connection-settings db)))
+
+(defmacro with-connection (conn &body body)
+ ;; Added 'mito.connection:' to 'let' binding. To return to default,
+ ;; just remove it so it just says '*connection*'. mito replaces
+ ;; datafly.
+ `(let ((mito.connection:*connection* ,conn))
+ ,@body))
diff --git a/src/main.lisp b/src/main.lisp
new file mode 100644
index 0000000..6a14c74
--- /dev/null
+++ b/src/main.lisp
@@ -0,0 +1,56 @@
+;; (in-package :cl-user)
+(defpackage hot-line
+ (:use :cl)
+ (:import-from :hot-line.config
+ :config)
+ (:import-from :clack
+ :clackup)
+ (:export :start
+ :stop
+ #:main))
+(in-package :hot-line)
+
+(defvar *appfile-path*
+ (asdf:system-relative-pathname :hot-line #P"app.lisp"))
+
+(defvar *handler* nil)
+
+(defun start (&rest args &key server port debug &allow-other-keys)
+ (declare (ignore server port debug))
+ (when *handler*
+ (restart-case (error "Server is already running.")
+ (restart-server ()
+ :report "Restart the server"
+ (stop))))
+ (setf *handler*
+ (apply #'clackup *appfile-path* args)))
+
+(defun stop ()
+ (prog1
+ (clack:stop *handler*)
+ (setf *handler* nil)))
+
+#| 'main' Function Used For Starting Server From Script (I.E. Live Deployment)
+================================================================================
+https://lisp-journey.gitlab.io/web-dev/#building
+The code below was taken from the URL above (with slight modifications). It's
+main use is to make it easier to start the server via a script.
+|#
+(defun main ()
+ (start :server :hunchentoot :port 3002 :debug nil)
+ ;; with bordeaux-threads
+ (handler-case (bt:join-thread
+ (find-if (lambda (th)
+ (search "hunchentoot" (bt:thread-name th)))
+ (bt:all-threads)))
+ (#+sbcl sb-sys:interactive-interrupt
+ #+ccl ccl:interrupt-signal-condition
+ #+clisp system::simple-interrupt-condition
+ #+ecl ext:interactive-interrupt
+ #+allegro excl:interrupt-signal
+ () (progn
+ (format *error-output* "Aborting.~&")
+ (clack:stop *handler*)
+ (uiop:quit 1)) ;; portable exit, included in ASDF, already loaded.
+ ;; for others, unhandled errors (we might want to do the same).
+ (error (c) (format t "Woops, an unknown error occured:~&~a~&" c)))))
diff --git a/src/models/app-constants.lisp b/src/models/app-constants.lisp
new file mode 100644
index 0000000..23ab1f7
--- /dev/null
+++ b/src/models/app-constants.lisp
@@ -0,0 +1,256 @@
+(defpackage #:app-constants
+ (:use #:cl)
+ (:export #:+false+
+ #:+true+
+
+ ;; OTHER-ITEMS LIST SIZES
+ #:+default-other-items-size+
+ #:+single-other-items-size+
+ #:+small-other-items-size+
+ #:+large-other-items-size+
+ #:+extra-large-other-items-size+
+
+ ;; PAGINATION SIZES
+ #:+default-page-size+
+ #:+extra-small-page-size+
+ #:+small-page-size+
+ #:+single-page-size+
+ #:+extra-large-page-size+
+ #:+large-page-size+
+
+ ;; DIRECTORIES
+ #:+media-directory+
+ #:+uploads-directory+
+
+ ;; GENERAL/GENERIC MESSAGES
+ #:+generic-fail+
+ #:+generic-success+
+ #:+nil-or-empty-string-used+
+ #:+undetermined-file-type+
+
+ ;; SESSION MANAGEMENT
+ #:+incorrect-login-details+
+ #:+user-not-found+
+
+ ;; STORAGE SECTION
+ #:+storage-directory-not-found+
+ #:+storage-directory-deleted+
+ #:+storage-file-already-exists+
+ #:+storage-file-deleted+
+ #:+storage-file-not-found+
+ #:+storage-file-successful-upload+
+ #:+storage-file-successfully-updated+
+
+ ;; USER MANAGEMENT
+ #:+username-already-taken+
+ #:+new-user-added+
+ #:+user-deleted+
+ #:+user-not-authorised+
+ #:+user-role-updated+
+ #:+display-name-updated+
+ #:+password-updated+
+ #:+old-password-incorrect+))
+(in-package #:app-constants)
+
+#| Switched to `DEFINE-CONSTANT' from `DEFCONSTANT'.
+================================================================================
+Because this website uses Steel Bank Common Lisp (SBCL), I need to go through a
+cycle of confirming changes to the constant values even though they have not
+changed. This behaviour is explained in the SBCL Manual 2.1.3 2021-03 (Section
+2.3.4 Defining Constants, page 5 (printed) page 13 (PDF)). The key part of the
+section is,
+
+'ANSI says that doing `DEFCONSTANT' of the same symbol more than once is
+undefined unless the new value is eql to the old value.'
+
+http://www.sbcl.org/manual/#Defining-Constants (this URL should provide the
+latest information of the subject).
+
+A workaround, provided by the SBCL Manual is to use the `DEFINE-CONSTANT' macro
+instead of `DEFCONST'. By doing this, I can use Quickload to reload the code
+(after a big change for example) and not have to repeat the cycle of 'updating'
+the constants when they have not changed.
+|#
+
+(defmacro define-constant (name value &optional doc)
+ `(defconstant ,name (if (boundp ',name) (symbol-value ',name) ,value)
+ ,@(when doc (list doc))))
+
+#| SQLite does not have Boolean value types.
+================================================================================
+At the time of writing (February 2022), the website uses SQLite as its
+database. So, I have made these constants to reduce hard-coded `1'
+and/or `0' values when `TRUE' and `NIL'/`FALSE' values are want is
+meant (in the code-base).
+|#
+
+(define-constant +false+ 0
+ "An integer representing 'false' (for SQLite mostly).")
+
+(define-constant +true+ 1
+ "An integer representing 'true' (for SQLite mostly.")
+
+;; These refer to the size of the 'Other X' lists displayed in the
+;; 'Other X' sections of the content.html templates. Examples of the
+;; 'Other' lists are 'Other Software Projects' and 'Other Artworks'.
+(define-constant +default-other-items-size+ 10
+ "The default number of items to show in the 'other X' section of
+ content.html templates.")
+
+(define-constant +single-other-items-size+ 1
+ "Display a single item in the 'other X' section of content.html
+ templates -- use for testing.")
+
+(define-constant +small-other-items-size+ 5
+ "The number of items to show in the 'other X' section of
+ content.html templates.")
+
+(define-constant +large-other-items-size+ 20
+ "The number of items to show in the 'other X' section of
+ content.html templates.")
+
+(define-constant +extra-large-other-items-size+ 30
+ "The number of items to show in the 'other X' section of
+ content.html templates.")
+
+;; The pagination code has a default value of 200 built-in. That is
+;; why the amounts seems odd.
+(define-constant +default-page-size+ 100
+ "The default value for the amount of items to display on a paginated
+ page.")
+
+(define-constant +single-page-size+ 1
+ "Display only one item per paginated page -- use for testing.")
+
+(define-constant +extra-small-page-size+ 25
+ "The value for a smaller than default amount of items on a paginated
+ page.")
+
+(define-constant +small-page-size+ 50
+ "The value for a smaller than default amount of items on a paginated
+ page.")
+
+(define-constant +large-page-size+ 150
+ "The value for a larger than default amount of items on a paginated
+ page.")
+
+(define-constant +extra-large-page-size+ 250
+ "The value for a larger than default amount of items on a paginated
+ page.")
+
+#| Alert Messages (add '| safe' to djula filter in HTML templates)
+================================================================================
+How the alerts are normally rendered in the HTML templates:
+
+{% if alert %}{{alert | safe}}{% endif %}
+
+The constants below are to be used with the `:ALERT' values when rendering an
+HTML template as part of a 'defroute' in 'web.lisp'. The reason for these
+string values being stored here instead of in-lining them when the :alert is
+formatted is two-fold:
+
+1. These messages can be multiple lines long which can make the code the
+defroute a little messy.
+2. Reduce repetitive code. The amount of repetitive code is not that high. But,
+this will help.
+
+The '| safe' filter (djula) is needed because the HTML tags are removed without
+it. Refer to the djula manual for more information:
+
+https://mmontone.github.io/djula/djula/Filters.html#format
+|#
+
+(define-constant +generic-success+
+ "
Task completed. Great success!
"
+ "Alert message. Intended as a basic or placeholder message.")
+
+(define-constant +media-directory+ "media"
+ "The /media directory holds the user's generic file uploads.")
+
+(define-constant +uploads-directory+ "uploads"
+ "The directory which holds the user's uploaded .csv and .tsv files.")
+
+(define-constant +generic-fail+
+ "
Task failed.
"
+ "Alert message. Intended as a basic or placeholder message.")
+
+(define-constant +nil-or-empty-string-used+
+ "
An 'empty-string' was provided by the user (most likely), or the
+website's code came across a 'nil' value instead of a string.
"
+ "Alert message. Intended as an error message for all user-input sections.")
+
+(define-constant +undetermined-file-type+
+ "
Cannot determine if file is a .csv or .tsv file.
"
+ "Alert message. Intended as an error message for all user-input sections.")
+
+(define-constant +incorrect-login-details+
+ "
Name and password do not match.
"
+ "Use as an alert message, intented for the login routes.")
+
+(define-constant +user-not-found+
+ "
No account found with those details.
"
+ "Use as an alert message, intented for the loging routes.")
+
+(define-constant +storage-directory-deleted+
+ "
Directory deleted from /storage.
"
+ "Use as an alert message, intented for the /storage routes.
+This is used for relaying a directory within the /storage directory is
+deleted. It does not mean the /storage directory is deleted.")
+
+(define-constant +storage-directory-not-found+
+ "
Directory not found in /storage.
"
+ "Use as an alert message, intented for the /storage routes.
+Used to indicated a directory within /storage is not found and not the
+/storage directory itself.")
+
+(define-constant +storage-file-already-exists+
+ "
A file with that name already exists.
"
+ "Use as an alert message, intended for the /storage routes.")
+
+(define-constant +storage-file-deleted+
+ "
File deleted.
"
+ "User as an alert message. Intended for the /storage routes.")
+
+(define-constant +storage-file-not-found+
+ "
File(s) could not be found.
"
+ "Use as an alert message. Intended for the /storage routes.")
+
+(define-constant +storage-file-successful-upload+
+ "
File uploaded. Great success!
"
+ "Use as an alert message. Intended for the /storage routes.")
+
+(define-constant +storage-file-successfully-updated+
+ "
File updated!
"
+ "Use as an alert message. Intended for the /storage routes.")
+
+(define-constant +username-already-taken+
+ "
Username already taken.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +new-user-added+
+ "
New user added.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +user-deleted+
+ "
User deleted.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +user-not-authorised+
+ "
User is not authorised to make this change.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +user-role-updated+
+ "
Role updated.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +display-name-updated+
+ "
Display name changed.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +password-updated+
+ "
Password updated.
"
+ "Use as an alert message, intented for the /user routes.")
+
+(define-constant +old-password-incorrect+
+ "
Old password is incorrect.
"
+ "Use as an alert message, intented for the /user routes.")
diff --git a/src/models/file.lisp b/src/models/file.lisp
new file mode 100644
index 0000000..9fe67da
--- /dev/null
+++ b/src/models/file.lisp
@@ -0,0 +1,63 @@
+(defpackage #:file
+ (:use #:cl
+ #:user)
+ (:export #:file))
+(in-package #:file)
+
+(defclass file ()
+ (
+ ;; ((id
+ ;; :documentation "The row Id. of the file stored in the database."
+ ;; :col-type :integer
+ ;; :initarg :id
+ ;; :accessor id-of)
+ (filename
+ :documentation "The name of the file."
+ :col-type (:varchar 128)
+ :initarg :filename
+ :accessor filename-of)
+ (username :col-type user)
+ (slug
+ :documentation "The dev. friendly way to refer to the file. It
+ mostly is about giving options to shorten the URL needed to get a
+ file if the `NAME' is long and makes it cumbersome to refer to it
+ in the back-end/admin. controls or URL."
+ :col-type (:varchar 128)
+ :initarg :slug
+ :accessor slug-of)
+ (content-type
+ :documentation "The type of the file -- not the file
+ extension. `CONTENT-TYPE' refers to the file being more of a 'text'
+ file or 'image' file than it being a '.md' file or '.png' file. The
+ main reason for this property is so I can represent the text files
+ with a graphic when managing the uploaded files to /storage."
+ :col-type (:varchar 128)
+ :initarg :content-type
+ :accessor content-type-of)
+ ;; (created_at
+ ;; :documentation "The date this meta-data was added to the
+ ;; database. This does not automatically mean it is the same time the
+ ;; data-file was uploaded to the /storage directory (within the
+ ;; website)."
+ ;; :col-type (:varchar 64)
+ ;; :initform (local-time:now)
+ ;; :initarg :created_at
+ ;; :accessor created_at)
+ ;; (updated_at
+ ;; :documentation "The last time the file was modified in the
+ ;; database. This does not automatically align with the actual
+ ;; data-file associated with this meta-data file. So, be careful when
+ ;; making assumption about this property."
+ ;; :col-type (:varchar 64)
+ ;; :initarg :updated_at
+ ;; :accessor updated_at))
+ )
+ (:documentation "The model used to describe `FILE' table in the
+ database. Essentially, this is the meta-data for the files uploaded
+ to the website and stored in the /storage directory. This class is
+ for keeping track of the files in /storage via the website's
+ database. It does not represent the actual file it is paired
+ with. The intended usage for this class is to provide ways to manage
+ the files in /storage from the website's (admin.) back-end.")
+ (:metaclass mito:dao-table-class))
+
diff --git a/src/models/user.lisp b/src/models/user.lisp
new file mode 100644
index 0000000..9261741
--- /dev/null
+++ b/src/models/user.lisp
@@ -0,0 +1,59 @@
+(defpackage #:user
+ (:use #:cl
+ #:hot-line.db
+ #:mito
+ #:app-constants)
+ ;;(:import-from)
+ (:export #:user
+ #:seeds))
+(in-package #:user)
+
+(defclass user ()
+ (
+ ;; ((id :col-type (:varchar 36)
+ ;; :primary-key t)
+ (username
+ :documentation "The name the user uses to log into the website."
+ :col-type (:varchar 64)
+ :initarg :username
+ :accessor username-of)
+ (display-name
+ :documentation "The name used in the website GUI (the pretty name)."
+ :col-type (or (:varchar 128) :null)
+ :initarg :display-name
+ :accessor display-name-of)
+ (administrator
+ :documentation "States if user has admin. priveledges. At the time
+ of writing (21/01/2022), SQLite is the current database and it
+ does not have a Boolean datatype so '0' represents 'false' and '1'
+ represents 'true'. You will not come across '0' or '1' in the code
+ because of how mito maps the code to the database. But, you will
+ see it in the database if you view it directly."
+ :col-type :integer
+ :initarg :administrator
+ :initform app-constants:+true+ ; SQLite: 0 -> false 1 -> true.
+ :accessor is-administrator-p)
+ (password
+ :documentation "The user's password. It is hashed using
+ cl-pass. The password is 'deflated' into the database. This means
+ the password is storted as a hash and returned as the value the
+ user provided."
+ :col-type :text
+ :initarg :password
+ :accessor password-of
+ :inflate #'cl-pass:hash
+ ;; :deflate #'cl-pass:hash
+ )
+ ;; (created_at
+ ;; :documentation "The date the user's account was created."
+ ;; :col-type (:varchar 64)
+ ;; :initarg :created_at
+ ;; :accessor created_at)
+ ;; (updated_at
+ ;; :documentation "The last time the user logged into the website."
+ ;; :col-type (:varchar 64)
+ ;; :initarg :last-log-in
+ ;; :accessor updated_at)
+ )
+ (:documentation "The model used to describe the `USER' table in the database")
+ (:metaclass mito:dao-table-class))
diff --git a/src/services/authentication.lisp b/src/services/authentication.lisp
new file mode 100644
index 0000000..50e9847
--- /dev/null
+++ b/src/services/authentication.lisp
@@ -0,0 +1,85 @@
+(defpackage #:authentication
+ (:use #:cl
+ #:hermetic
+ #:sxql
+ ;; #:datafly
+ #:ningle
+ #:mito
+ #:user)
+ (:import-from #:hot-line.db
+ #:connection-settings
+ #:db
+ #:with-connection)
+ (:export #:csrf-token
+ #:get-user-roles
+ #:request-params
+ #:get-current-user
+ #:get-user-id
+ #:flash-gethash))
+(in-package #:authentication)
+
+(defun csrf-token ()
+ "Cross-Site Request Forgery (CSRF) token."
+ (cdr (assoc "lack.session"
+ (lack.request:request-cookies ningle:*request*)
+ :test #'string=)))
+
+(hermetic:setup
+ ;; #' is needed. (hermetic:roles) generates infinite-loop when called
+ ;; otherwise -- 'roles' called in other parts of code-base.
+
+ ;; #' is shorthand for the 'function' operator (returns the function
+ ;; object associated with the name of the function which is supplied
+ ;; as an argument. Keep forgetting that.
+ :user-p #'(lambda (username)
+ (with-connection (db)
+ (mito:find-dao 'user::user :username username)))
+ :user-pass #'(lambda (username)
+ (user::password-of
+ (with-connection (db)
+ (mito:find-dao 'user::user :username username))))
+ :user-roles #'(lambda (username)
+ (cons :logged-in
+ (let ((user (with-connection (db)
+ (mito:find-dao
+ 'user::user :username username))))
+ (and user
+ (= (user::is-administrator-p user) app-constants:+true+)
+ '(:administrator)))))
+ :Session ningle:*session*
+ :denied (constantly '(400 (:content-type "text/plain") ("Authentication denied"))))
+
+(defun get-current-user()
+ "Returns the currently logged in user from the browser session."
+ (with-connection (db)
+ (mito:find-dao 'user::user
+ :id (gethash :id ningle:*session*))))
+
+(defun get-user-id (username)
+ "Returns the Id. number of the specified `USERNAME' in the database."
+ (with-connection (db)
+ (mito:object-id
+ (mito:find-dao 'user::user :username username))))
+
+(defun request-params (request)
+ "Loops through the HTTP `REQUEST' and creates a key-value pairing."
+ (loop :for (key . value) :in request
+ :collect (let ((*package* (find-package :keyword)))
+ (read-from-string key))
+ :collect value))
+
+(defun get-user-roles()
+ "Returns a list of roles the current user has assigned to them.
+This is mostly to check if the user is logged-in or has administration
+privileges. You can then create if-blocks in the HTML templates and
+control what the user can and cannot see or do."
+ (loop :for role :in (hermetic:roles)
+ :collect role
+ :collect t))
+
+;; Copied over from rails-to-caveman project. I don't know what it
+;; does exactly.
+(defun flash-gethash (key table)
+ (let ((value (gethash key table)))
+ (remhash key table)
+ value))
diff --git a/src/services/convert.lisp b/src/services/convert.lisp
new file mode 100644
index 0000000..ea82d9c
--- /dev/null
+++ b/src/services/convert.lisp
@@ -0,0 +1,52 @@
+(defpackage #:convert
+ (:use #:cl
+ #:app-constants)
+ (:export #:bool-to-checkbox
+ #:checkbox-to-bool
+ #:dimension-to-clean-string
+ #:universal-time-to-prefix))
+(in-package #:convert)
+
+(defun bool-to-checkbox (value)
+ "Converts `VALUE' so it can populate an HTML checkbox.
+It is assumed you are converting a SQLite version of a Boolean so
+either 1 (true) or 0 (false). If you need a traditional Boolean value,
+DO NOT USE THIS FUNCTION."
+ (cond ((= value 0) "off")
+ ((null value) "off")
+ (t "on")))
+
+(defun checkbox-to-bool (value)
+ "Converts a HTML Checkbox `VALUE' to a Boolean.
+The `VALUE' will either be 'on' or 'off'. 'Boolean' in this instance
+is assuming you are using SQLite and need to convert `VALUE' to an
+integer/number. If you are needing a traditional Boolean value, DO NOT USE
+THIS FUNCTION."
+ (cond ((string= "on" value) +true+)
+ ((string= "off" value) +false+)
+ ((null value) +false+)))
+
+(defun dimension-to-clean-string (dimension)
+ "Takes a string like '148.0d0' and changes to '148.00'.
+This funciton is needed because of how the dimensions in the /art
+section are rendered in the /art/edit.html template. The djula
+template renders the dimensions as strings but the database stores
+them as numbers. This means they are capable of possessing letters in
+them -- like '148.0d0' -- which breaks when these values come into
+contact with mito."
+ (if (not (null dimension))
+ (format nil "~,2F" dimension)
+ (format nil "")))
+
+(defun universal-time-to-prefix ()
+ "Converts an encoded universal time to something a human can make sense of."
+ (multiple-value-bind
+ (second minute hour day month year)
+ (get-decoded-time)
+ (format nil "~d~2,'0d~d_~2,'0d~2,'0d~2,'0d"
+ year
+ month
+ day
+ hour
+ minute
+ second)))
diff --git a/src/services/db-management.lisp b/src/services/db-management.lisp
new file mode 100644
index 0000000..1020e69
--- /dev/null
+++ b/src/services/db-management.lisp
@@ -0,0 +1,60 @@
+(defpackage #:db-management
+ (:use #:cl
+ #:mito
+ #:local-time
+ #:storage
+ #:user
+ #:file)
+ (:import-from #:hot-line.db
+ #:connection-settings
+ #:db
+ #:with-connection)
+ (:export #:get-distinct-column-totals
+ #:get-random-selection
+ #:seed-database))
+(in-package #:db-management)
+
+(defun seed-database()
+ "A quick way to reset the database."
+ (hot-line.db:with-connection (db)
+ (mito:ensure-table-exists 'user)
+ (mito:create-dao 'user:user
+ :username "admin"
+ :display-name "Dave from Accounting"
+ :administrator 1
+ :password "password")
+ (mito:ensure-table-exists 'file)
+ (mito:create-dao 'file:file
+ :filename "Testing.png"
+ :username (mito:find-dao 'user:user :username "admin")
+ :slug "testing"
+ :content-type "image/png")))
+
+(defun get-distinct-column-totals (table column)
+ "Creates a list of distinct values and their totals in `COLUMN' in DB `TABLE'."
+ (with-connection (db)
+ (mito:retrieve-by-sql
+ (format nil
+ "SELECT ~S, COUNT(~S) AS col_totals FROM ~S GROUP BY ~S;"
+ column column table column))))
+
+(defun get-random-selection (table amount exclude-id)
+ "Returns a random selection of rows from the `TABLE' in the database.
+`AMOUNT' specifies how many rows to return and the `EXCLUDE-ID' is the row which
+should NOT be returned. The intented use for this function is to generate a list
+of 'other articles/artworks' Etc. when viewing a 'content.html' page. The need
+for 'raw SQL' is because I couldn't find a way to apply the 'RANDOM()' function
+using mito and sxql.
+
+Please note, this function does not map the results to their classes like mito
+usually does, especially when used with sxql. Because this functions main reason
+to be is to provide a list for populating the 'Other X' columns in the
+content.html templates, you will need to use the 'raw-date' djula filter (custom
+made and in view.lisp) to render the date-time stamp."
+ (with-connection (db)
+ (mito:retrieve-by-sql
+ (format nil
+ "SELECT * FROM ~a WHERE ID != ~a ORDER BY RANDOM() LIMIT ~a"
+ table
+ exclude-id
+ amount))))
diff --git a/src/services/pagination.lisp b/src/services/pagination.lisp
new file mode 100644
index 0000000..0277f1c
--- /dev/null
+++ b/src/services/pagination.lisp
@@ -0,0 +1,76 @@
+(defpackage #:pagination
+ (:use #:cl)
+ (:export #:make-pagination))
+(in-package #:pagination)
+
+#| Found This Code on The Internet
+================================================================================
+Original Blog Post:
+https://lisp-journey.gitlab.io/blog/lisp-for-the-web-pagination-and-cleaning-up-html/
+
+Pagination Source Code:
+https://gitlab.com/vindarel/abstock/-/blob/master/src/pagination.lisp
+
+Djula HTML Template Code:
+https://gitlab.com/vindarel/abstock/-/blob/master/src/templates/includes/pagination.html
+
+I've modified the code a little bit to fit my needs but it's, for the most part,
+as I found it (`BUTTON-RANGE' is the main thing I've added).
+|#
+
+(defun make-pagination (&key (page 1) (nb-elements 0) (page-size 200)
+ (max-nb-buttons 5))
+ "From a current page number, a total number of elements, a page size,
+ return a dict with all of that, and the total number of pages.
+
+ Example:
+
+(get-pagination :nb-elements 1001)
+;; =>
+ (dict
+ :PAGE 1
+ :NB-ELEMENTS 1001
+ :PAGE-SIZE 200
+ :NB-PAGES 6
+ :TEXT-LABEL \"Page 1 / 6\"
+ )
+"
+ (let* ((nb-pages (get-nb-pages nb-elements page-size))
+ (max-nb-buttons (min nb-pages max-nb-buttons))
+ (button-list (build-pagination-list page max-nb-buttons)))
+ (serapeum:dict :page page
+ :nb-elements nb-elements
+ :page-size page-size
+ :nb-pages nb-pages
+ :button-range button-list
+ :max-nb-buttons max-nb-buttons
+ :text-label (format nil
+ "Page ~a / ~a" page nb-pages))))
+
+(defun get-nb-pages (length page-size)
+ "Given a total number of elements and a page size, compute how many pages fit in there.
+ (if there's a remainder, add 1 page)"
+ (multiple-value-bind (nb-pages remainder)
+ (floor length page-size)
+ (if (plusp remainder)
+ (1+ nb-pages)
+ nb-pages)))
+
+(defun build-pagination-list (current-page max-nb-buttons)
+ "Creates a list of numbers with values before and after `CURRENT-PAGE'.
+This is a bit hacky because the list needs to have checks in the HTML
+template (djula) to make sure it doesn't render negative numbers or
+display page number beyond the maximun number of pages available,
+`MAX-NB-BUTTONS'. With that said, it allows you to create a 'floating
+list' of page numbers which updates itself based on what page the
+viewer is on."
+ (let* ((start (- current-page max-nb-buttons))
+ (end (+ current-page max-nb-buttons)))
+ (range end :min start :step 1)))
+
+(defun range (max &key (min 0) (step 1))
+ "Creates a list of number starting from `MIN' upto `MAX'.
+Use `STEP' to specify the increment value for each entry. This is
+basically the range function from Python."
+ (loop for n from min below max by step
+ collect n))
diff --git a/src/services/routing.lisp b/src/services/routing.lisp
new file mode 100644
index 0000000..35126cd
--- /dev/null
+++ b/src/services/routing.lisp
@@ -0,0 +1,562 @@
+(defpackage #:routing
+ (:use #:cl
+ #:caveman2
+ #:hot-line.config
+ #:hot-line.view
+ #:hot-line.db
+ #:app-constants
+ #:datafly
+ #:sxql
+ #:local-time
+ #:sqlite
+ #:cl-pass
+ #:storage
+ #:validation
+ #:authentication
+ #:user-management
+ #:storage-management
+ #:convert
+ #:hermetic
+ #:pagination)
+ (:export
+ ;; CHARTING SECTION
+ #:create-chart
+ #:create-chart1
+
+ ;; STORAGE SECTION
+ #:add-storage-file
+ #:delete-storage-file
+ #:update-storage-file
+
+ ;; SESSION MANAGEMENT
+ #:attempt-login
+ #:log-out
+
+ ;; USER MANAGEMENT
+ #:add-user
+ #:delete-user
+ #:sign-up-user
+ #:update-role
+ #:update-display-name
+ #:update-password))
+(in-package #:routing)
+
+#| Routing.lisp (The overflow file for web.lisp)
+================================================================================
+In here is essentially the overflow of code from the web.lisp file. When the
+code in one of 'defroute' macros needs breaking out into its own
+function (defined with a 'defun'), I add that function into this file. The
+reason why is so I can keep the website routes free from clutter (as much as I
+can help it). By using something like the origami package in Emacs, I can reduce
+the routes to their first line so I an quickly scan through the file and
+navigate to the function I need to work next.
+
+You will find, the functions in here tend to deal the HTTP POST requests almost
+exclusively. At the time of writing (August 2022), I have not had any need to
+include a function which deals with a HTTP GET request. But, I intend to add
+them to this file unless this file becomes too unwieldy.
+|#
+
+;; CHARTING SECTION
+
+(defun create-chart (request)
+ "Builds the chart by calling out to Python and returns the file created by it."
+ (destructuring-bind
+ (&key title content-file x-axis y-axis &allow-other-keys)
+ (authentication:request-params request)
+ (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user))
+ (alert-message nil)
+ (checks-failed? nil)
+ (status-code nil)
+ (python-output nil)
+ (file-type (car (last content-file)))
+ (data-bag `(:token ,(authentication:csrf-token)
+ :user ,current-user
+ :roles ,(authentication:get-user-roles))))
+ (cond ((null content-file)
+ (setf alert-message +storage-file-not-found+)
+ (setf checks-failed? t))
+ ((and (not (string-equal "text/csv" file-type))
+ (not (string-equal "text/tsv" file-type))
+ (not (string-equal "text/tab-separated-values" file-type)))
+ (setf alert-message +undetermined-file-type+)
+ (setf checks-failed? t)
+ (setf status-code 301))
+ ((or (null (directory-exists-p username +uploads-directory+))
+ (null (directory-exists-p username "")))
+ (setf alert-message +storage-directory-not-found+)
+ (setf checks-failed? t)
+ (setf status-code 301))
+ (t (setf checks-failed? nil)
+ (setf status-code 201)))
+ (if (not checks-failed?)
+ (progn
+ ;; File is stored on server so it available to hot-line-python.
+ ;; hot-line-python is called from this website but is run in
+ ;; seperate process to this website.
+ (let* ((sanitised-filename (clean-filename (second content-file)))
+ (sanitised-filename-root (pathname-name sanitised-filename))
+ (prefixed-filename (format nil "line_~a--~a.html"
+ (convert:universal-time-to-prefix)
+ sanitised-filename-root))
+ (temp-file-path ; For input .csv/.tsv file uploaded to server.
+ (make-path username
+ +uploads-directory+
+ sanitised-filename))
+ (output-path
+ (make-path username "" prefixed-filename)))
+ (storage:store-file username
+ +uploads-directory+
+ (format nil sanitised-filename)
+ content-file)
+ (setf python-output (uiop:run-program
+ (list (merge-pathnames
+ "hot-line-python/venv/bin/python"
+ hot-line.config::*application-root*)
+ (format nil "~a"
+ (merge-pathnames
+ "hot-line-python/app/hot-line-python.py"
+ hot-line.config::*application-root*))
+ ;; "-V" ; For verbose output.
+ (format nil "-t ~a" title)
+ (format nil "-x ~a" x-axis)
+ (format nil "-y ~a" y-axis)
+ (format nil "~a" temp-file-path)
+ (format nil "~a" output-path))
+ :output :string
+ :error-output :string))
+ (storage:remove-file username +uploads-directory+
+ sanitised-filename))))
+ (if (and (not (search "CRITICAL" python-output))
+ (null alert-message))
+ (setf alert-message +generic-success+)
+ (setf alert-message +generic-fail+))
+ (format t "~@s" python-output)
+ `(,status-code () (,(hot-line.view:render
+ "user/dashboard.html"
+ (append data-bag
+ `(:alert ,alert-message
+ :storage-files
+ ,(reverse
+ (storage:get-file-names
+ (storage:get-files-in-directory
+ username "")))
+ :python-output ,python-output))))))))
+
+;; Storage Section
+
+(defun add-storage-file (request)
+ "Stores the meta-data and data-file of an uploaded file (to the website).
+The CSRF-Token and Logged-in checks should already be completed before calling
+this function."
+ (destructuring-bind
+ (&key filename slug content-type content-file &allow-other-keys)
+ (authentication:request-params request)
+ (cond ((or (storage-management:get-file-from-db :slug slug)
+ (storage-management:get-file-from-db :filename filename))
+ `(303 () (,(hot-line.view:render
+ "storage/add.html"
+ `(:alert
+ ,app-constants:+storage-file-already-exists+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :roles ,(authentication:get-user-roles))))))
+ (t (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user))
+ (file-count (storage-management:get-file-count username)))
+ (storage-management:add-file-to-db filename username ; content-type
+ (caddr content-file)
+ slug)
+ (storage:store-file username app-constants:+media-directory+
+ filename content-file)
+ `(201 () (,(hot-line.view:render
+ "storage/manage.html"
+ `(:alert
+ ,app-constants:+storage-file-successful-upload+
+ :token ,(authentication:csrf-token)
+ :root-url ,(format nil "storage/manage/~a" username)
+ :user ,current-user
+ :roles ,(authentication:get-user-roles)
+ :storage-count
+ ,(storage-management:get-file-count username)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "file" "content_type")
+ :storage-files
+ ,(storage-management:get-paginated-files
+ username 1 +default-page-size+ :filename)
+ :pagination ,(pagination:make-pagination
+ :page 1
+ :page-size +default-page-size+
+ :nb-elements file-count))))))))))
+
+(defun delete-storage-file (request)
+ "Deletes the data-file in the logged-in user's /storage directory."
+ (destructuring-bind
+ (&key filename &allow-other-keys)
+ (authentication:request-params request)
+ (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user))
+ (token (authentication:csrf-token))
+ (roles (authentication:get-user-roles))
+ (alert-message nil))
+ (if (null (storage:file-exists-p username "" filename))
+ (setf alert-message +storage-file-not-found+)
+ (progn
+ (setf alert-message +storage-file-deleted+)
+ (storage:remove-file username "" filename)))
+ `(303 () (,(hot-line.view:render
+ "user/dashboard.html"
+ `(:alert ,alert-message
+ :token ,token
+ :user ,current-user
+ :roles ,roles
+ :storage-files ,(reverse
+ (storage:get-file-names
+ (storage:get-files-in-directory
+ username ""))))))))))
+
+(defun update-storage-file (request)
+ "Updates the meta-data of a data-file stored in the /storage directory.
+The function does not alter the actual data-file. The thinking is if you want to
+edit the actual data-file, you should just delete the file stored on the
+server (in /storage) and upload a new one. This function is for changing the
+`FILENAME', `CONTENT-TYPE' and `SLUG' (I.E. the things you use to reference the
+files in /storage when logged into the site)."
+ (destructuring-bind
+ (&key id filename content-type slug &allow-other-keys)
+ (authentication:request-params request)
+ (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user))
+ (original-db-file-name (file::filename-of
+ (storage-management:get-file-from-db :id id))))
+ (cond ((null (storage-management:get-file-from-db :id id))
+ `(303 () (,(hot-line.view:render "storage/manage.html"
+ `(:alert ,app-constants:+storage-file-not-found+
+ :token ,(authentication:csrf-token)
+ :user ,current-user
+ :root-url ,(format nil "storage/manage/~a" username)
+ :roles ,(authentication:get-user-roles)
+ :storage-count
+ ,(storage-management:get-file-count username)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "file" "content_type")
+ :storage-files
+ ,(storage-management:get-all-files-from-db
+ username :filename)
+ :pagination ,(make-pagination
+ :page 1
+ :page-size +default-page-size+
+ :nb-elements
+ (storage-management:get-file-count
+ username)))))))
+ (t ;; Read as 'ignore inner code-block of UNLESS the file exists'.
+ (unless (storage:file-exists-p username app-constants:+media-directory+ filename)
+ (storage:rename-content-file username app-constants:+media-directory+
+ original-db-file-name filename))
+ (storage-management:edit-file-in-db id filename content-type slug)
+ `(201 () (,(hot-line.view:render "storage/manage.html"
+ `(:alert
+ ,app-constants:+storage-file-successfully-updated+
+ :token ,(authentication:csrf-token)
+ :user ,current-user
+ :root-url ,(format nil "storage/manage/~a" username)
+ :roles ,(authentication:get-user-roles)
+ :storage-count
+ ,(storage-management:get-file-count username)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "file" "content_type")
+ :storage-files
+ ,(storage-management:get-all-files-from-db
+ username :filename)
+ :pagination ,(make-pagination
+ :page 1
+ :page-size +default-page-size+
+ :nb-elements
+ (storage-management:get-file-count
+ username)))))))))))
+
+;; Session Management
+
+(defun attempt-login (request)
+ "Attempts to log the user into the website.
+Redirects the user depending on how successful the log-in attempt
+is. Updates the session id and password if log-in is
+successful. `REQUEST' consists of the body parameters of the log-in
+attempt Derived from the log-in form comprised in /session and
+/login."
+ (destructuring-bind
+ (&key username password authenticity-token &allow-other-keys)
+ (authentication:request-params request)
+ (if (not (string= authenticity-token (authentication:csrf-token)))
+ `(403 (:content-type "text/plain") ("Denied"))
+ (let ((params (list :|username| username :|password| password)))
+ (hermetic:login params
+ ;; Successful log-in attempt.
+ (progn
+ (setf
+ ;; Set session Id. to the logged in user.
+ (gethash :id ningle:*session*)
+ (authentication:get-user-id username)
+ ;; Set the users password (for session)
+ (gethash :password ningle:*session*) password)
+ `(303 (:location "/dashboard")))
+ ;; Failed log-in attempt.
+ (hot-line.view:render "user/log-in.html"
+ `(:alert ,+incorrect-login-details+
+ :token ,(authentication:csrf-token)))
+ ;; No user found.
+ (hot-line.view:render "user/log-in.html"
+ `(:alert ,+user-not-found+
+ :token ,(authentication:csrf-token))))))))
+
+(defun log-out (request)
+ "Logs the current user out of the browsing session."
+ (destructuring-bind
+ (&key username authenticity-token &allow-other-keys)
+ (authentication:request-params request)
+ (if (not (string= authenticity-token (authentication:csrf-token)))
+ `(403 (:content-type "text/plain") ("Denied"))
+ (hermetic::logout
+ ;; Successful log-out.
+ (progn (authentication::flash-gethash :id ningle:*session*)
+ '(303 (:location "/")))
+ ;; Failed log-out
+ '(303 (:location "/"))))))
+
+;; User Management
+(defun sign-up-user (request)
+ "Creates a new user via the sign-up section of site."
+ (destructuring-bind
+ (&key username display-name password &allow-other-keys)
+ (authentication:request-params request)
+ (let* ((alert-message nil)
+ (can-create-user? nil))
+ (cond ((user-management:user-in-db-p :username username)
+ (setf alert-message +username-already-taken+))
+ ((find t (mapcar #'validation:string-is-nil-or-empty-p
+ `(,username ,display-name ,password)))
+ (setf alert-message +nil-or-empty-string-used+))
+ (t (setf alert-message +new-user-added+)
+ (setf can-create-user? t)))
+ (if (equal can-create-user? t)
+ (progn
+ (user-management:add-user-to-db
+ username display-name +false+
+ password)
+ (storage:ensure-directory-exists username "uploads")
+ (attempt-login request))
+ `(303 () (, (hot-line.view:render
+ "sign-up.html"
+ `(:alert ,+username-already-taken+
+ :username ,username
+ :display-name ,display-name
+ :token ,(authentication:csrf-token)))))))))
+
+(defun add-user (request)
+ "Adds a new user to the database by a currently logged in (I.E. admin.) user.
+`REQUEST' contains the data provided by the HTML form used to specify
+the new user's data."
+ (destructuring-bind
+ (&key authenticity-token username display-name administrator
+ password &allow-other-keys)
+ (authentication:request-params request)
+ (cond ((if (user-management:user-in-db-p :username username)
+ `(303 () (,(hot-line.view:render
+ "user/add.html"
+ `(:alert ,+username-already-taken+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :roles ,(authentication:get-user-roles)))))))
+ ;; TODO: Add validation for add-user arguments.
+ (t (progn
+ (user-management:add-user-to-db
+ username display-name
+ (convert:checkbox-to-bool administrator) password)
+ (storage:ensure-directory-exists username "uploads")
+ `(201 () (,(hot-line.view:render
+ "user/index.html"
+ `(:alert ,+new-user-added+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))))))
+
+(defun delete-user (request)
+ "Deletes a user from the database.
+`REQUEST' contains the data which specifies which user should be
+deleted. It typically is provided by a HTML form."
+ (destructuring-bind
+ (&key username &allow-other-keys)
+ (authentication:request-params request)
+ (cond ((not (user-management:user-in-db-p :username username))
+ `(303 () (,(hot-line.view:render
+ "user/index.html"
+ `(:alert ,+user-not-found+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))
+ ((or (and (equal +true+ (user::is-administrator-p (authentication:get-current-user)))
+ (user-management:user-in-db-p :username username))
+ (string= username (user::username-of (authentication:get-current-user))))
+ (let ((username-being-deleted (user::username-of (authentication:get-current-user))))
+ (user-management:delete-user-from-db :username username)
+ (storage:remove-directory username "")
+ (if (string= username username-being-deleted)
+ (log-out request)
+ `(201 () (,(hot-line.view:render
+ "user/index.html"
+ `(:alert ,app-constants:+user-deleted+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))))
+ (t (format nil "Well then, it looks like you managed to the
+ website into a right old pickle. For you to have got here,
+ you must have been snooping around. Fair play, you broke
+ the website.")))))
+
+(defun update-role (request)
+ "Give or remove admin. privileges to user specified in `REQUEST'."
+ (destructuring-bind (&key username administrator &allow-other-keys)
+ (authentication:request-params request)
+ (cond ((null (user-management:user-in-db-p :username username))
+ `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,+user-not-found+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles))))))
+ ((not (= (user::is-administrator-p (authentication:get-current-user))
+ +true+))
+ `(303 () ,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,+user-not-authorised+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator")))))
+ (t (user-management:update-user-administration-role
+ username (convert:checkbox-to-bool administrator))
+ `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,+user-role-updated+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator")))))))))
+
+(defun update-display-name (request)
+ "Change the username of the user specified in `REQUEST'."
+ (destructuring-bind (&key username display-name &allow-other-keys)
+ (authentication:request-params request)
+ (format t "[INFO] SESSION: ~A" (gethash :password ningle:*session*))
+ (cond ((equal nil (user-management:user-in-db-p :username username))
+ `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,app-constants:+user-not-found+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))
+ (t (user-management:update-user-display-name username display-name)
+ `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,+display-name-updated+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator")))))))))
+
+(defun update-password (request)
+ "Change the password of the user specified in `REQUEST'."
+ (destructuring-bind (&key username old-password new-password &allow-other-keys)
+ (authentication:request-params request)
+ (cond ((equal nil (user-management:user-in-db-p :username username))
+ `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,app-constants:+user-not-found+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))
+ ((if (and (not old-password) (hermetic:role-p :administrator))
+ (progn
+ (user-management:update-user-password username new-password)
+ `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,app-constants:+password-updated+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))))
+ ((equal nil
+ (cl-pass:check-password old-password
+ (user::password-of (user-management:user-in-db-p
+ :username username))))
+ `(303 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,app-constants:+old-password-incorrect+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator"))))))
+ (t (user-management:update-user-password username new-password)
+ `(201 () (,(hot-line.view:render (user-management:get-crud-redirect-url)
+ `(:alert ,app-constants:+password-updated+
+ :token ,(authentication:csrf-token)
+ :user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :roles ,(authentication:get-user-roles)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator")))))))))
diff --git a/src/services/storage-management.lisp b/src/services/storage-management.lisp
new file mode 100644
index 0000000..c27fc10
--- /dev/null
+++ b/src/services/storage-management.lisp
@@ -0,0 +1,127 @@
+(defpackage #:storage-management
+ (:use #:cl
+ #:file
+ #:mito
+ #:local-time
+ #:storage)
+ (:import-from #:hot-line.db
+ #:connection-settings
+ #:db
+ #:with-connection)
+ (:export #:add-file-to-db
+ #:delete-file-from-db
+ #:edit-file-in-db
+ #:get-all-files-from-db
+ #:get-file-count
+ #:get-files-from-db
+ #:get-file-from-db
+ #:get-paginated-files))
+(in-package #:storage-management)
+
+(defun add-file-to-db (filename owner content-type slug)
+ "Adds an entry to the 'file' table in the database.
+`CONTENT-TYPE' is more like a file extension (png, jpg, pdf, md Etc.) than
+anything else. I've added to so I can differentiate the different types of files
+in the website's GUI -- when managing the files in the Admin./backend parts.
+
+NOTE: This only stores the meta-data of the uploaded file. You must use the
+'storage' package to deal with the data-file associated with the database
+entry."
+ (with-connection (db)
+ (mito:create-dao 'file
+ :filename filename
+ :owner owner
+ :content-type content-type
+ :slug slug)))
+
+(defun delete-file-from-db (&key (id nil) (filename nil) (slug nil))
+ "Deletes the specified entry in the 'file' database.
+It does not delete the actual data-file. You will need to use the 'storage'
+package to do that. The database entry is the meta-data used by the
+Admin./backend of the website to manage the functionality for the /storage
+section."
+ (with-connection (db)
+ (cond ((and (not filename) (not slug))
+ (mito:delete-dao
+ (mito:find-dao 'file:file :id id)))
+ ((and (not id) (not slug))
+ (mito:delete-dao
+ (mito:find-dao 'file:file :filename filename)))
+ ((and (not id) (not filename))
+ (mito:delete-dao
+ (mito:find-dao 'file:file :slug slug))))))
+
+(defun edit-file-in-db (id filename content-type slug)
+ "Updates the specified entry in the 'file' table in the database.
+The entry is identified via the `ID'. That is the only argument which will not
+change."
+ (with-connection (db)
+ (let ((file-to-update
+ (mito:find-dao 'file:file :id id)))
+ (setf (file::filename-of file-to-update) filename
+ (file::content-type-of file-to-update) content-type
+ (file::slug-of file-to-update) slug)
+ (mito:save-dao file-to-update))))
+
+(defun get-file-count (&optional (owner nil))
+ "Returns the total number of entries in the 'storage' database table."
+ (with-connection (db)
+ (if (null owner)
+ (mito:count-dao 'file:file)
+ (mito:count-dao 'file:file :owner owner))))
+
+
+(defun get-all-files-from-db (username order)
+ "Retrieves all the entries in the 'file' database for `USERNAME'.
+It does not retrieve the data-files associated with the entries in the
+database. You will need to use the 'storage' package alongside this to do
+that. This function/package only deals with the meta-data for the files stored
+in the /storage directory.
+
+NOTE: `USERNAME' is used here (column name in the DB) but it refers to 'owner'
+in the actual DB table I have used `USERNAME' here because it easier to think of
+it that way from a programming point-of-view From the database's point-of-view,
+'owner' can refer to more than just `USERNAME' I've just settled on that from a
+programming perspective."
+ (with-connection (db)
+ (mito:select-dao 'file:file
+ (sxql:where (:= :owner username))
+ (sxql:order-by order))))
+
+(defun get-files-from-db (username order amount &key reverse)
+ "Returns all the entries in the 'file' table in the database.
+These files are the meta-data of the files stored in the /storage
+directory. They are not the actual file. You would use this when you want to
+manage the files in the /storage directory (admin./backend of website)."
+ (with-connection (db)
+ (mito:select-dao 'file:file
+ (sxql:where (:= :owner username))
+ (if reverse
+ (sxql:order-by (:desc order))
+ (sxql:order-by order))
+ (sxql:limit amount))))
+
+(defun get-file-from-db (&key (id nil) (filename nil) (slug nil))
+ "Retrieves the specified entry from the 'file' table in the database.
+It only returns the meta-data for the file stored in the /storage directory. Use
+the 'storage' package alongside this one to work with the data-file and the
+meta-data of the data-file."
+ (with-connection (db)
+ (cond ((and (not filename) (not slug))
+ (mito:find-dao 'file:file :id id))
+ ((and (not id) (not slug))
+ (mito:find-dao 'file:file :filename filename))
+ ((and (not id) (not filename))
+ (mito:find-dao 'file:file :slug slug))
+ (t nil))))
+
+(defun get-paginated-files (username page page-amount order)
+ "Retrieves a list of rows from the 'file' database with an offset.
+The offset is determined by watch page the viewer is on and `PAGE' and
+`PAGE-AMOUNT'."
+ (with-connection (db)
+ (mito:select-dao 'file:file
+ (sxql:order-by order)
+ (sxql:where (:= :owner username))
+ (sxql:offset (* (- page 1) page-amount))
+ (sxql:limit page-amount))))
diff --git a/src/services/storage.lisp b/src/services/storage.lisp
new file mode 100644
index 0000000..dca2d97
--- /dev/null
+++ b/src/services/storage.lisp
@@ -0,0 +1,167 @@
+(defpackage #:storage
+ (:use #:cl)
+ (:export #:directory-exists-p
+ #:ensure-directory-exists
+ #:file-exists-p
+ #:get-files-in-directory
+ #:get-file-names
+ #:get-latest-file-type
+ #:make-path
+ #:open-file
+ #:open-binary-file
+ #:open-text-file
+ #:remove-directory
+ #:remove-file
+ #:rename-content-file
+ #:rename-directory
+ #:store-file))
+(in-package #:storage)
+
+(defun directory-exists-p (username directory)
+ "Checks to see if the specified diretory exists.
+The directories path is returned if it does exist and `NIL' is
+returned if the directory cannot be found."
+ (cl:probe-file (make-path username directory "")))
+
+(defun ensure-directory-exists (username directory)
+ "The project's standardised way to call `ENSURE-DIRECTORY-EXISTS'.
+If the directory exists, the full (absolute) path is
+returned (equating to `T', otherwiser `NIL' it returned."
+
+ ;; The empty string for `SLUG' (3rd arg.) is used because
+ ;; `MAKE-PATH' can form paths for files. In this instance, only the
+ ;; directory needs to be formed. The empty string kinda acts like
+ ;; `NIL' but it is a bit of a hack, I will admit.
+ (ensure-directories-exist (make-path username directory "")))
+
+
+(defun file-exists-p (username subdirectory slug)
+ "This project's standardised way to call `CL:PROBE-FILE'.
+If the file exists, the full (absolute) path is returned (equates to
+`T', otherwise `NIL' is returned."
+ (cl:probe-file (make-path username subdirectory slug)))
+
+(defun get-files-in-directory (username directory)
+ ""
+ (uiop:directory-files (make-path username directory "")))
+
+(defun get-file-names (filenames)
+ ""
+ (mapcar #'(lambda (x) (file-namestring x)) filenames))
+
+(defun get-latest-file-type (old-file new-file)
+ "Gets the file type of the specified `FILE', return `NIL' if no file found"
+ (if (string= "" (cadr new-file))
+ (file::content-type-of old-file)
+ (caddr new-file)))
+
+(defun make-path (username subdirectory slug)
+ "Forms the path used to save a file.
+Storage path:
+`*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/`SLUG'
+
+Each user has their own directory in /storage. This is so I can build a media
+manager at a later date -- I had not got around to writing it at the time I
+implemented this function/feature. I decided to go with `USERNAME' and
+not (user-)`ID' is because I wanted to easily identify the directories in
+/storage."
+ (merge-pathnames (format nil "storage/~A/~A/~A"
+ username subdirectory slug)
+ hot-line.config::*application-root*))
+
+(defun open-binary-file (username subdirectory slug)
+ "Reads the file stored in the /storage directory."
+ (with-open-file (stream
+ (make-path username subdirectory slug)
+ :element-type '(unsigned-byte 8))
+ (let* ((length (file-length stream))
+ (buffer (make-array length
+ :element-type '(unsigned-byte 8))))
+ (read-sequence buffer stream)
+ (values buffer length))))
+
+(defun open-text-file (username subdirectory slug)
+ "Reads the text (.md) file stored in the /storage directory."
+ (with-open-file (stream (make-path username subdirectory slug))
+ (let ((data (make-string (file-length stream))))
+ (read-sequence data stream)
+ data)))
+
+(defun remove-directory (username subdirectory)
+ "Deletes an entire sketchbook directory in /storage (not database).
+Path template: `*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/'
+
+- https://edicl.github.io/cl-fad/#delete-directory-and-files
+- https://stackoverflow.com/questions/24350183/how-do-i-delete-a-directory-in-common-lisp
+
+'cl-fad' (files and directories) is a wrapper package over the various
+Common Lisp implementions to aid in keeping your Common Lisp code
+portable. At the time of writing (February 2022), the website is using
+Steel Bank Common Lisp (SBCL) but this allow you to use something else
+if you want or need to switch."
+ (cl-fad:delete-directory-and-files (make-path username subdirectory "")))
+
+(defun rename-directory (username original-directory new-directory)
+ "Renames a sub-directory in the /storage directory.
+`USERNAME' is the directory holding the one which is to be
+changed. `ORIGINAL-DIRECTORY' is the 'source' (in the usual Linux/Bash
+CLI sense). `NEW-DIRECTORY' is the name the `ORIGINAL-DIRECTORY' will
+be changed to. There are various examples of the path
+structure/template in other comments in this file. Have a look
+around (don't want to repeat myself)."
+ (rename-file (make-path username original-directory "")
+ (make-path username new-directory "")))
+
+(defun remove-file (username subdirectory slug)
+ "Deletes the specified file, stored in the /storage directory.
+Before calling this function, make sure the file exists. You should have
+'file-exists-p' available to you -- within this (storage) package."
+ (delete-file (make-path username subdirectory slug)))
+
+(defun rename-content-file (username subdirectory old-slug new-slug)
+ "This project's standardised way to call `RENAME-FILE'."
+ (rename-file (make-path username subdirectory old-slug)
+ (make-path username subdirectory new-slug)))
+
+(defun store-file (username subdirectory filename data)
+ "Stores the uploaded file to the /storage directory.
+Storage path: `*APPLICATION-ROOT*'/storage/`USERNAME'/`SUBDIRECTORY'/`FILENAME'
+`DATA' is the actual contents which will be written to the said path."
+ (let ((path (ensure-directories-exist
+ (make-path username subdirectory filename))))
+ (cond ((or (string= (caddr data) "application/gzip")
+ (string= (caddr data) "application/zip")
+ (string= (caddr data) "application/epub+zip"))
+ (uiop:copy-file (slot-value (car data) 'pathname) path))
+ (t
+ (with-open-file (stream
+ path
+ :direction :output
+ :if-does-not-exist :create
+ :element-type '(unsigned-byte 8)
+ :if-exists :supersede)
+ (write-sequence (slot-value (car data) 'vector) stream))))))
+
+;;; PORTED FROM RAILS-TO-CAVEMAN PROJECT (expect it to be deleted)
+;;; =============================================================================
+
+;; (defun prin1-to-base64-string (object)
+;; (cl-base64:string-to-base64-string (prin1-to-string object)))
+
+;; (defun read-from-base64-string(string)
+;; (values (read-from-string
+;; (cl-base64:base64-string-to-string string))))
+
+;;; This function requires ImageMagick so you will need to install it
+;;; with 'sudo apt install imagemagick' (assuming you are on a
+;;; Debian-based system).
+(defun convert (id subdirectory original-file converted-file)
+ (let ((command (format nil "convert -geometry ~A ~A ~A"
+ (file-size converted-file)
+ (make-storage-pathname id subdirectory original-file)
+ (make-storage-pathname id subdirectory converted-file))))
+ (let ((message (nth-value 1
+ (uiop:run-program command
+ :ignore-error-status t
+ :error-output :string))))
+ (when message (error message)))))
diff --git a/src/services/user-management.lisp b/src/services/user-management.lisp
new file mode 100644
index 0000000..f55805c
--- /dev/null
+++ b/src/services/user-management.lisp
@@ -0,0 +1,111 @@
+(defpackage #:user-management
+ (:use #:cl
+ #:user
+ #:mito
+ #:local-time)
+ (:import-from #:hot-line.db
+ #:connection-settings
+ #:db
+ #:with-connection)
+ (:export #:add-user-to-db
+ #:user-in-db-p
+ #:validate-user
+ #:get-all-users
+ #:get-crud-redirect-url
+ #:update-user-display-name
+ #:update-user-password
+ #:delete-user-from-db
+ #:get-total-user-count
+ #:update-user-administration-role))
+(in-package #:user-management)
+
+(defun user-in-db-p (&key (id nil) username)
+ "Looks for the user with either the specified `ID' or `USERNAME'.
+Make sure you specify which argument you want to use with either :id or
+:username. The `USER' information will be returned if found. `NIL' will be
+returned if no entry with either the specified `ID' or `USERNAME' is found."
+ (if (not id)
+ (with-connection (db)
+ (mito:find-dao 'user:user :username username))
+ (with-connection (db)
+ (mito:find-dao 'user:user :id id))))
+
+(defun add-user-to-db (username display-name administrator password)
+ "Add a new user to the database.
+Make sure you have validated the data before calling this function."
+ (with-connection (db)
+ (mito:create-dao 'user
+ :username username
+ :display-name display-name
+ :administrator administrator
+ :password password
+ ;; :created_at (local-time:now)
+ ;; :updated_at (local-time:now)
+ )))
+
+(defun get-all-users ()
+ "Returns a list of all registered users in the database."
+ (with-connection (db)
+ (mito:select-dao 'user:user
+ (sxql:order-by (:asc :display-name)))))
+
+(defun update-user-display-name (username display-name)
+ "Updates the specified user's display name.
+`USERNAME' is used to specify which user to update in the
+database. `DISPLAY-NAME' is used to replace the old one. The old 'display-name'
+is not needed here as no validation check is done. This function simply
+overwrites the old 'display-name' with the new `DISPLAY-NAME'."
+ (with-connection (db)
+ (mito:execute-sql
+ (format nil "UPDATE user SET display_name = ~S WHERE username = ~S;"
+ display-name username))))
+
+(defun update-user-administration-role (username is-administrator)
+ "Adds or removes the specified user's administrator privileges.
+You can specify the user via `USERNAME'. At the time of writing, the
+website uses SQLite as it database so the `IS-ADMINISTRATOR' value is
+determined via a '0' or '1'. You should use the 'APP-CONSTANTS'
+package (E.G. 'app-constants:+true+') to specify (or help convert)
+when specifying this 'make-shift' Boolean value."
+ (with-connection (db)
+ (mito:execute-sql
+ (format nil "UPDATE user SET administrator = ~D WHERE username = ~S;"
+ is-administrator username))))
+
+(defun update-user-password (username new-password)
+ "Updates the password of the specified user.
+Use `USERNAME' to specify which user your want from the database. `NEW-PASSWORD'
+will overwrite the old password. Make sure you have validated the user and they
+have entered the old password as a validation check before you call this
+function."
+ (with-connection (db)
+ (let ((user-to-update
+ (mito:find-dao 'user:user :username username)))
+ (setf (user::password-of user-to-update) new-password)
+ (mito:save-dao user-to-update))))
+
+(defun delete-user-from-db (&key (id nil) username)
+ "Deletes the user from the database with the specified `ID' or `USERNAME'.
+Make sure your specify which argument you want to user when calling the function
+with either :id or :username."
+ (if (not id)
+ (with-connection (db)
+ (mito:delete-by-values 'user:user :username username))
+ (with-connection (db)
+ (mito:delete-by-values 'user:user :id id))))
+
+(defun get-crud-redirect-url ()
+ "Returns the URL the user is redirected to after a CRUD-based HTTP POST.
+The `USERNAME' is the username of the currently logged in user. The URL is
+formed based on the type of roles the user has asigned to him/her. If the user
+is an administrator, the URL returns 'user/index.html' and 'user/dashboard.html'
+otherwise."
+ (let ((user (authentication:get-current-user)))
+ (if (= (user::is-administrator-p user) app-constants:+true+)
+ "user/index.html" ; Admin.
+ "user/dashboard.html"))) ; Regular User
+
+(defun get-total-user-count ()
+ "Returns the total number of registered users in the database."
+ (with-connection (db)
+ (mito:count-dao 'user:user)))
diff --git a/src/services/validation.lisp b/src/services/validation.lisp
new file mode 100644
index 0000000..2a3013a
--- /dev/null
+++ b/src/services/validation.lisp
@@ -0,0 +1,45 @@
+(defpackage #:validation
+ (:use #:cl)
+ (:export
+ #:clean-filename
+ #:string-is-nil-or-empty-p
+ #:separate-files-in-web-request))
+(in-package #:validation)
+
+(defun validation-test ()
+ (format t "~&[INFO] Validation package reached."))
+
+(defun request-params (request)
+ (loop :for (key . value) :in request
+ :collect (let ((*package* (find-package :keyword)))
+ (read-from-string key))
+ :collect value))
+
+(defun string-is-nil-or-empty-p (string-to-test)
+ "Tests to see if `STRING-TO-TEST' is empty of just whitespace.
+This is essentially the 'IsNullOrWhiteSpace' function I use in C#. It
+expands the 'empty string' check to include a check to see if there is
+string with just a '(white) space' in it."
+ (if (or (string= string-to-test " ")
+ (zerop (length string-to-test))
+ (null string-to-test))
+ t
+ nil))
+
+(defun separate-files-in-web-request (request &optional request-value)
+ "Creates a new list of 'upload' files from a web `REQUEST'.
+You will mostly use this for processing a multi-file upload (HTML)
+form. The standard value for the 'name' attribute in (file) input tag
+in the HTML form is `CONTENT-FILES' but you can use a different
+name. Just specify it in this function's `REQUEST-VALUE' argument."
+ (loop :for item :in request
+ if (or (string= "CONTENT-FILES" (car item))
+ (string= request-value (car item)))
+ collect item))
+
+(defun clean-filename (filename)
+ "Basically, slugifies it, removes whitespace, trims it Etc."
+ (let* ((lowercased (string-downcase filename))
+ (trimmed (string-trim '(#\Space #\Tab) lowercased))
+ (whitespaced (substitute #\- #\Space trimmed)))
+ whitespaced))
diff --git a/src/view.lisp b/src/view.lisp
new file mode 100644
index 0000000..9de6b9e
--- /dev/null
+++ b/src/view.lisp
@@ -0,0 +1,73 @@
+;; (in-package :cl-user)
+(defpackage hot-line.view
+ (:use :cl)
+ (:import-from :hot-line.config
+ :*template-directory*)
+ (:import-from :caveman2
+ :*response*
+ :response-headers)
+ (:import-from :djula
+ :add-template-directory
+ :compile-template*
+ :render-template*
+ :*djula-execute-package*)
+ (:import-from :datafly
+ :encode-json)
+ (:export :render
+ :render-json))
+(in-package :hot-line.view)
+
+(djula:add-template-directory *template-directory*)
+
+(defparameter *template-registry* (make-hash-table :test 'equal))
+
+(defun render (template-path &optional env)
+ (let ((template (gethash template-path *template-registry*)))
+ (unless template
+ (setf template (djula:compile-template* (princ-to-string template-path)))
+ (setf (gethash template-path *template-registry*) template))
+ (apply #'djula:render-template*
+ template nil
+ env)))
+
+(defun render-json (object)
+ (setf (getf (response-headers *response*) :content-type) "application/json")
+ (encode-json object))
+
+
+;;
+;; Execute package definition
+
+(defpackage hot-line.djula
+ (:use :cl)
+ (:import-from :hot-line.config
+ :config
+ :appenv
+ :developmentp
+ :productionp)
+ (:import-from :caveman2
+ :url-for))
+
+;;; '(in-package' line added after default Caveman set-up. Needed for
+;;; custom filters and functions. Not part of default Caveman set-up.
+(in-package #:hot-line.djula)
+
+(setf djula:*djula-execute-package* (find-package :hot-line.djula))
+
+
+(defun insert-umami-script ()
+ "Outputs the script for my Umami instance (stats.abbether.net).
+It provides either the dev. or prod. tracker depending on the
+environment this website is running in."
+ (cond ((equal t (hot-line.config:productionp))
+ (format
+ nil
+ ""))
+ (t (format nil ""))))
+
+(djula::def-filter :chart-icon (filename)
+ (if (null filename)
+ (format nil "/images/file.png")
+ (let ((chart-type
+ (subseq filename 0 (search "_" filename))))
+ (format nil "/images/~a.png" chart-type))))
diff --git a/src/web.lisp b/src/web.lisp
new file mode 100644
index 0000000..ce4f2a3
--- /dev/null
+++ b/src/web.lisp
@@ -0,0 +1,256 @@
+;; (in-package :cl-user)
+(defpackage hot-line.web
+ (:use #:cl
+ #:caveman2
+ #:hot-line.config
+ #:hot-line.view
+ #:hot-line.db
+ #:datafly
+ #:sxql
+ #:app-constants
+ #:local-time
+ #:sqlite
+ #:cl-pass
+ ;; #:validation
+ #:authentication
+ #:user-management
+ #:hermetic
+ #:storage-management
+ #:convert
+ #:pagination
+ #:routing
+ #:storage)
+ (:export :*web*))
+(in-package :hot-line.web)
+
+;; for @route annotation
+(syntax:use-syntax :annot)
+
+;;
+;; Application
+
+(defclass () ())
+(defvar *web* (make-instance '))
+(clear-routing-rules *web*)
+
+;;
+;; Routing rules
+
+(defroute "/" ()
+ (cond ((not (hermetic:logged-in-p))
+ (render "/index.html"
+ `(:token ,(authentication:csrf-token))))
+ (t (render "/index.html"
+ `(:user ,(authentication:get-current-user)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))))
+
+(defroute "/about" ()
+ (cond ((not (hermetic:logged-in-p))
+ (render "about.html"
+ `(:token ,(authentication:csrf-token))))
+ (t (render "about.html"
+ `(:user ,(authentication:get-current-user)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))))
+
+(defroute "/privacy" ()
+ (cond ((not (hermetic:logged-in-p))
+ (render "privacy.html"
+ `(:token ,(authentication:csrf-token))))
+ (t (render "privacy.html"
+ `(:user ,(authentication:get-current-user)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))))
+
+(defroute "/navigation" ()
+ (cond ((not (hermetic:logged-in-p))
+ (render "/nav-menu.html"
+ `(:token ,(authentication:csrf-token))))
+ (t (render "/nav-menu.html"
+ `(:user ,(authentication:get-current-user)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))))
+
+(defroute ("/sign-up" :method :get) ()
+ (if (hermetic:logged-in-p)
+ `(301 (:location "/dashboard"))
+ (render "sign-up.html" `(:token ,(authentication:csrf-token)))))
+
+(defroute ("/sign-up" :method :post) (&key method)
+ (destructuring-bind (&key authenticity-token &allow-other-keys)
+ (authentication:request-params
+ (lack.request:request-body-parameters ningle:*request*))
+ (cond ((not (string= authenticity-token (authentication:csrf-token)))
+ '(403 (:content-type "text/plain") ("Denied")))
+ ((hermetic:logged-in-p)
+ '(301 (:location "/dashboard")))
+ ((string= "sign-up-user" method)
+ (routing:sign-up-user
+ (lack.request:request-body-parameters ningle:*request*)))
+ (t `(400 (:content-type "text/plain")
+ (,(format nil "Unknown method ~S" method)))))))
+
+;; Admin/User Section
+(defroute "/login" ()
+ (if (hermetic:logged-in-p)
+ `(301 (:location "/dashboard"))
+ (render "user/log-in.html"
+ `(:token ,(authentication:csrf-token)))))
+
+(defroute ("/login" :method :post) (&key method)
+ (cond ((string= "login" method)
+ (routing:attempt-login (lack.request:request-body-parameters ningle:*request*)))
+ (t `(400 (:content-type "text/plain")
+ (,(format nil "Unknown method ~S" method))))))
+
+(defroute ("/logout" :method :post) (&key method)
+ (cond ((string= "logout" method)
+ (routing:log-out (lack.request:request-body-parameters ningle:*request*)))
+ (t `(400 (:content-type "text/plain")
+ (,(format nil "Unknown method ~S" method))))))
+
+(defroute ("/users") ()
+ (cond ((not (hermetic:logged-in-p))
+ (on-exception *web* 404))
+ ((equal +true+ (user::is-administrator-p
+ (authentication:get-current-user)))
+ (render "user/index.html"
+ `(:user ,(authentication:get-current-user)
+ :users ,(user-management:get-all-users)
+ :user-count
+ ,(user-management:get-total-user-count)
+ :categories
+ ,(db-management:get-distinct-column-totals
+ "user" "administrator")
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))
+ (t (on-exception *web* 404))))
+
+
+
+(defroute ("/user/add") ()
+ (cond ((not (hermetic:logged-in-p))
+ (on-exception *web* 404))
+ ((equal +true+ (user::is-administrator-p
+ (authentication:get-current-user)))
+ (render "user/add.html"
+ `(:user ,(authentication:get-current-user)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles))))
+ (t (on-exception *web* 404))))
+
+(defroute ("/user/edit/:username") (&key username)
+ (cond ((or (not (hermetic:logged-in-p))
+ (null (user-management:user-in-db-p :username username)))
+ (on-exception *web* 404))
+ ((or (and (not (null (user-management:user-in-db-p
+ :username username)))
+ (equal +true+ (user::is-administrator-p
+ (authentication:get-current-user))))
+ (string= username (user::username-of
+ (authentication:get-current-user))))
+ `(200 () (, (render "user/edit.html"
+ `(:user-to-edit ,(user-management:user-in-db-p
+ :username username)
+ :user ,(authentication:get-current-user)
+ :roles ,(authentication:get-user-roles)
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles)
+ :session ,ningle:*session*)))))
+ (t (on-exception *web* 404))))
+
+(defroute ("/user" :method :post) (&key method)
+ (destructuring-bind
+ (&key authenticity-token &allow-other-keys)
+ (authentication:request-params
+ (lack.request:request-body-parameters ningle:*request*))
+ (cond ((not (string= authenticity-token (authentication:csrf-token)))
+ '(403 (:content-type "text/plain") ("Denied")))
+ ((not (hermetic:logged-in-p))
+ '(303 (:location "/login")))
+ ((string= "add" method)
+ (routing:add-user (lack.request:request-body-parameters ningle:*request*)))
+ ((string= "update-role" method)
+ (routing:update-role (lack.request:request-body-parameters ningle:*request*)))
+ ((string= "update-display-name" method)
+ (routing:update-display-name (lack.request:request-body-parameters ningle:*request*)))
+ ((string= "update-password" method)
+ (routing:update-password (lack.request:request-body-parameters ningle:*request*)))
+ ((string= "delete-user" method)
+ (routing:delete-user (lack.request:request-body-parameters ningle:*request*)))
+ (t `(400 (:content-type "text/plain")
+ (,(format nil "Unknown method ~S" method)))))))
+
+(defroute "/dashboard" ()
+ (if (not (hermetic:logged-in-p))
+ '(303 (:location "/login"))
+ (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user)))
+ (render "user/dashboard.html"
+ `(:user ,current-user
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles)
+ :storage-files ,(reverse
+ (storage:get-file-names
+ (storage:get-files-in-directory
+ username ""))))))))
+
+(defroute "/storage/download/:username/:filename" (&key username filename)
+ (if (and (hermetic:logged-in-p)
+ (string= username (user::username-of (authentication:get-current-user))))
+ (if (storage:file-exists-p username "" filename)
+ `(200 (:content-type "octet/stream")
+ ,(storage:open-binary-file username "" filename)))
+ (on-exception *web* 404)))
+
+(defroute ("/storage" :method :POST) (&key method)
+ (destructuring-bind
+ (&key filename authenticity-token &allow-other-keys)
+ (authentication:request-params
+ (lack.request:request-body-parameters ningle:*request*))
+ (cond ((not (string= authenticity-token (authentication:csrf-token)))
+ '(403 (:content-type "text/plain") ("Denied")))
+ ((validation:string-is-nil-or-empty-p filename)
+ (hot-line.web::on-exception hot-line.web:*web* 404))
+ ((not (hermetic:logged-in-p))
+ '(303 (:location "/login")))
+ ((string= "delete-storage-file" method)
+ (routing:delete-storage-file
+ (lack.request:request-body-parameters ningle:*request*)))
+ (t (on-exception *web* 404)))))
+
+;; This is where the chart stuff starts...
+
+(defroute "/chart/add" ()
+ (if (not (hermetic:logged-in-p))
+ '(303 (:location "/login"))
+ (progn
+ (let* ((current-user (authentication:get-current-user))
+ (username (user::username-of current-user)))
+ (render "chart/add.html"
+ `(:user ,current-user
+ :token ,(authentication:csrf-token)
+ :roles ,(authentication:get-user-roles)))))))
+
+(defroute ("/chartify" :method :post) (&key method)
+ (destructuring-bind
+ (&key authenticity-token &allow-other-keys)
+ (authentication:request-params
+ (lack.request:request-body-parameters ningle:*request*))
+ (cond ((not (string= authenticity-token (authentication:csrf-token)))
+ '(403 (:content-type "text/plain") ("Denied")))
+ ((not (hermetic:logged-in-p))
+ '(303 (:location "/login")))
+ ((string= "create-chart" method)
+ (routing:create-chart
+ (lack.request:request-body-parameters ningle:*request*)))
+ (t `(400 (:content-type "text/plain")
+ (,(format nil "Unknown method ~S" method)))))))
+;;
+;; Error pages
+
+(defmethod on-exception ((app ) (code (eql 404)))
+ (declare (ignore app))
+ (merge-pathnames #P"_errors/404.html"
+ *template-directory*))
diff --git a/static/css/avenir-book.otf b/static/css/avenir-book.otf
new file mode 100644
index 0000000..52ab53e
Binary files /dev/null and b/static/css/avenir-book.otf differ
diff --git a/static/css/avenir.otf b/static/css/avenir.otf
new file mode 100755
index 0000000..15e47a6
Binary files /dev/null and b/static/css/avenir.otf differ
diff --git a/static/css/firacode-retina.ttf b/static/css/firacode-retina.ttf
new file mode 100644
index 0000000..5bbb74b
Binary files /dev/null and b/static/css/firacode-retina.ttf differ
diff --git a/static/css/full-search.css b/static/css/full-search.css
new file mode 100644
index 0000000..23a8d3d
--- /dev/null
+++ b/static/css/full-search.css
@@ -0,0 +1,418 @@
+.search-dashboard {
+ margin: 0px;
+ padding: 0px;
+ display: flex;
+ flex-direction: column;
+}
+
+.refinements-panel h2,
+.refinements-panel p {
+ padding: 0px;
+ margin: 0px;
+}
+.refinements-panel p {
+ color : silver;
+ font-size: 12px;
+}
+
+#clear-refinements {
+ margin-bottom: 20px;
+}
+
+.ais-ClearRefinements-button {
+ font-size: 16px;
+ height: 40px;
+ width: 100%;
+ margin: 0px;
+ font-family: 'main', sans-serif;
+ color: white;
+ background-color: #0094ff;
+ box-shadow: 2px 2px 1px black;
+ border-radius: 4px;
+ padding: 10px;
+ cursor: pointer;
+ text-transform: uppercase;
+ border: none;
+}
+
+.ais-ClearRefinements-button:hover {
+ background: lightblue;
+ color: #0094ff;
+ text-decoration: none;
+ box-shadow: none;
+}
+
+.ais-ClearRefinements-button--disabled {
+ text-align: center;
+ padding: 10px;
+ border: 2px solid silver;
+ border-radius: 4px;
+ text-transform: uppercase;
+ color: silver;
+ background: transparent;
+ box-shadow: none;
+}
+
+.ais-ClearRefinements-button--disabled:hover {
+ background: transparent;
+ color: silver;
+}
+
+.search-refinement-list {
+ margin: 20px 0px;
+ border-bottom: 2px solid black;
+}
+
+#searchbox {
+ width: 100%;
+ margin-top: 20px;
+ margin-bottom: 20px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.ais-SearchBox,
+.ais-ClearRefinements-button {
+ max-width: 600px;
+}
+
+.search-results-container {
+ display: flex;
+ flex-direction: column;
+}
+
+.ais-SearchBox-form {
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+}
+
+.ais-SearchBox-input {
+ width: 100%;
+ height: 40px;
+ padding: 10px;
+ border: 2px solid #0094ff;
+ border-radius: 4px;
+ font-family: 'main', sans-serif;
+ font-size: 16px;
+}
+
+.ais-SearchBox-input::placeholder {
+ font-family: 'main', sans-serif;
+ font-size: 16px;
+}
+
+.ais-SearchBox-form input:focus-visible {
+ outline: none;
+}
+
+.ais-SearchBox-submitIcon,
+.ais-SearchBox-submit,
+.ais-SearchBox-reset {
+ display: none;
+}
+
+.ais-RefinementList-list {
+ list-style: none;
+ padding: 0px;
+ margin: 0px 0px 40px 0px;
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ overflow: scroll;
+}
+
+.ais-RefinementList-label {
+ display: flex;
+ align-items: center ;
+ width: max-content;
+ margin-right: 6px;
+}
+
+.ais-RefinementList-label > span {
+ padding: 0px 2px;
+}
+
+.ais-RefinementList-checkbox {
+ width: 30px;
+ height: 30px;
+ margin: 0px;
+}
+
+.search-hits-panel {
+ width: -webkit-fill-available;
+}
+
+#hits {
+ width: 100%;
+ min-width: 350px;
+ display: flex;
+ flex-direction: column;
+}
+
+.ais-Hits-list {
+ padding: 0px;
+ margin: 0px;
+ display: flex;
+ flex-direction: column;
+ list-style: none;
+}
+
+.ais-Hits-item {
+ margin: 12px 0px 12px 0px;
+ max-height: 400px;
+ border: 2px #0094ff solid;
+ border-radius: 4px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 3px 3px 3px black;
+}
+
+.ais-Hits-item:hover {
+ border-color: lightblue;
+ box-shadow: none;
+}
+
+#pagination {
+ display: block;
+ margin: 40px 0px 40px 0px;
+ overflow: auto;
+ width: 100%;
+}
+
+.ais-Pagination-list {
+ list-style: none;
+ padding: 0px;
+ margin: 0px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.ais-Pagination-item {
+ margin: 0px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ max-width: 64px;
+ height: 50px;
+}
+
+/* Hack to change content of pagination links
+================================================================================
+https://stackoverflow.com/questions/48907242/how-can-i-remove-and-replace-content-in-a-html-tag-using-css
+
+Because Meilsearch adds the pagination stuff via JavaScript, I can't set the
+contents of the various HTML elements via the djula templates. This means I have
+to update the content after it's loaded. I didn't want to do this via JavaScript
+because I want to keep the JavaScript to a minimum. So, to fix the problem, I
+came across a hack (see Stack Overflow URL above). You need to change the
+visibility (to hidden) of the element(s) you want to change and then use the
+'content' property to set the new text value. When that is done, you adjust the
+element's visibility back to 'visible'.
+
+I ACKNOWLEDGE THIS IS JACKY BUT IT WORKS.
+*/
+
+.ais-Pagination-item--firstPage a,
+.ais-Pagination-item--previousPage a,
+.ais-Pagination-item--nextPage a {
+ visibility: hidden;
+}
+
+/* Meilisearch not designed for extended pagination use (disabled 'Last' link)
+================================================================================
+https://github.com/meilisearch/documentation/issues/561
+Meilisearch adds a 'Last Page' link with its built-in pagination features. With
+that said, the people developing the project acknowledge Meilisearch's
+pagination features are limited. They recommend on not using or relying on it
+too much. See the URL above for more information.
+
+Whilst developing this part of the site, I found the 'Last Page' link was
+behaving inconsistently. You would click the link and it would jump ahead
+(I.E. more than one page) but it took several clicks to get to the 'final/last
+page'. Because of that, I've just turned it off by hiding it with the
+style-rule below. Again, see URL above for more info. on this behaviour.
+*/
+
+.ais-Pagination-item.ais-Pagination-item--lastPage {
+ display: none;
+}
+
+.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span,
+.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span,
+.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span {
+ visibility: hidden;
+}
+
+/* The new text is set here: See 'Hack to change content of pagination links'
+ note above for more information. */
+.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span:before {
+ content: "First";
+ visibility: visible;
+}
+
+.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span:before {
+ content: "Prev.";
+ visibility: visible;
+}
+.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span:before {
+ content: "Next";
+ visibility: visible;
+ margin-left: 8px;
+}
+
+.ais-Pagination-item--firstPage.ais-Pagination-item--disabled span:before,
+.ais-Pagination-item--previousPage.ais-Pagination-item--disabled span:before,
+.ais-Pagination-item--nextPage.ais-Pagination-item--disabled span:before {
+ width: 70px;
+ height: 39px;
+ text-transform: uppercase;
+ border: 2px solid silver;
+ border-radius: 4px;
+ text-align: center;
+ padding: 8px;
+ color: silver;
+}
+
+.ais-Pagination-item--firstPage a:before {
+ content: "First";
+ visibility: visible;
+}
+
+.ais-Pagination-item--previousPage a:before {
+ content: "Prev.";
+ visibility: visible;
+}
+
+.ais-Pagination-item--nextPage a:before {
+ content: "Next";
+ visibility: visible;
+ margin-left: 8px;
+}
+
+.ais-Pagination-item--firstPage a:link::before,
+.ais-Pagination-item--previousPage a:link::before,
+.ais-Pagination-item--nextPage a:link::before {
+ width: 100%;
+ height: 39px;
+ text-transform: uppercase;
+ background: #0094ff;
+ color: white;
+ border: 2px solid #0094ff;
+ border-radius: 4px;
+ text-align: center;
+ padding: 8px;
+ text-decoration: none;
+ box-shadow: 2px 2px 3px black;
+}
+
+.ais-Pagination-item--page a:link {
+ background: #0094ff;
+ color: white;
+ padding: 10px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ text-decoration: none;
+ margin: 0px 2px;
+ box-shadow: 2px 2px 3px black;
+}
+
+.ais-Pagination-item--selected a:link,
+.ais-Pagination-item--selected a:hover {
+ background: silver !important;
+ color: white;
+ box-shadow: none;
+}
+
+.ais-Pagination-item--page a:hover {
+ background: lightblue;
+ text-decoration: none;
+ box-shadow: none;
+}
+
+.ais-Pagination-item--firstPage a:hover::before,
+.ais-Pagination-item--previousPage a:hover::before,
+.ais-Pagination-item--nextPage a:hover::before {
+ background: lightblue;
+ border: 2px solid lightblue;
+ border-radius: 4px;
+ text-align: center;
+ color: #0094ff;
+ box-shadow: none;
+}
+
+.ais-Pagination-item--firstPage a:hover,
+.ais-Pagination-item--previousPage a:hover,
+.ais-Pagination-item--nextPage a:hover {
+ text-decoration: none !important;
+}
+
+@media (min-width:600px) {
+ .ais-ClearRefinements-button {
+ max-width: 200px;
+ }
+}
+
+@media (min-width:961px) {
+ .search-results-container {
+ flex-direction: row;
+ }
+
+ .refinements-panel {
+ max-width: 300px;
+ width: 100%;
+ margin-right: 20px;
+ }
+
+ .ais-RefinementList-list {
+ font-size: 12px;
+ flex-direction: column;
+ overflow: auto;
+ }
+
+ .ais-RefinementList-item {
+ margin: 2px 0px;
+ }
+
+ .ais-RefinementList-checkbox {
+ width: 20px;
+ margin-right: 4px;
+ }
+
+ #hits {
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .ais-Hits-list {
+ padding: 0px;
+ margin: 0px;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ list-style: none;
+ }
+
+ .ais-Hits-item {
+ width: 200px;
+ height: 280px;
+ margin: 12px;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .ais-Hits-item .ui-link-card .ui-card-text {
+ height: 75px;
+ /* white-space: nowrap; */
+ text-overflow: ellipsis;
+ }
+
+ .ais-Hits-item .ui-link-card .ui-card-text .ui-card-secondary {
+ text-overflow: ellipsis;
+ }
+
+}
diff --git a/static/css/iAWriterQuattroV.ttf b/static/css/iAWriterQuattroV.ttf
new file mode 100644
index 0000000..6d50c14
Binary files /dev/null and b/static/css/iAWriterQuattroV.ttf differ
diff --git a/static/css/main.css b/static/css/main.css
new file mode 100644
index 0000000..f175719
--- /dev/null
+++ b/static/css/main.css
@@ -0,0 +1,1087 @@
+@charset "UTF-8";
+
+/* FONTS STORED IN THE /CSS DIRECTORY.
+================================================================================
+Fonts are stored in the /css directory. This is because of how Caveman controls
+the /static directory. It limits the routes from /static to /css, /images, /js,
+robot.txt and favicon.ico by default. Apparently to can change this in app.lisp
+but I couldn't work out how to get a /fonts directory to work. Because of this,
+I have reverted to putting the fonts directly in the /css so Caveman knows
+where/how to find them.
+*/
+
+@font-face {
+ font-family: "main";
+ src: url("avenir.otf") format("opentype");
+}
+
+@font-face {
+ font-family: "main-content";
+ src: url("avenir-book.ttf");
+}
+
+@font-face {
+ font-family: "code";
+ src: url("firacode-retina.ttf");
+}
+
+/* COLOUR PALLET AND GENERALISED VALUES
+================================================================================
+The values below are notes of commonly used values. I've put them here to make
+it easier to find/refer to them when this file gets to out of hand.
+
+Main: #0094ff
+Secondary: lightblue
+Warning: darkorange
+Warning Secondary: orangered
+Confirm/Add: lightseagreen
+Confirm/Add Sec: lightgreen
+Admin: darkviolet
+Admin Secondary: violet
+Error: salmon
+Danger: crimson
+*/
+
+/* smartphones, iPhone, portrait 480x320 phones */
+html {
+ background: white;
+}
+
+body {
+ font-family: 'main', sans-serif;
+}
+
+header {
+ border-bottom: 2px solid black;
+ display: flex;
+}
+
+nav form {
+ display: inline-block;
+}
+
+nav {
+ display: block;
+ width: 100%;
+ padding-top: 0px;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 9px;
+}
+
+nav div {
+ display: initial;
+}
+
+nav .nav-header {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 4px;
+ font-size: 1.5em;
+}
+
+nav .nav-header img {
+ display: initial;
+ height: 45px;
+ margin: 0px 4px 0px 0px;
+}
+
+nav .nav-header span {
+ font-size: 1.5em;
+}
+
+nav a {
+ margin: 0px 3px;
+}
+
+main {
+ margin: 0px;
+ padding: 0px;
+}
+
+article {
+ max-width: 800px;
+}
+
+code {
+ white-space: pre;
+ margin: auto;
+ max-width: 300px;
+ width: 100%;
+ display: block;
+ white-space: pre;
+ -webkit-overflow-scrolling: touch;
+ overflow-x: scroll;
+ padding: 12px;
+ font-family: 'code', monospace;
+ font-weight: bold;
+}
+
+a:link {
+ color: #0094ff;
+}
+
+a:visited {
+ color: purple;
+}
+
+a:hover {
+ background: lightblue;
+ color: #0094ff;
+ text-decoration-thickness:4px;
+ border-radius: 4px;
+}
+
+table {
+ width: 100%;
+ border-spacing: 0;
+}
+
+th {
+ text-align: left;
+ border-bottom: 2px solid black;
+ padding: 12px;
+ margin: 0px;
+}
+
+td {
+ text-align: left;
+ padding-left: 4px;
+}
+
+td img {
+ margin: 0px 10px 0px 10px;
+}
+
+tbody td {
+ border-bottom: 1px solid silver;
+ padding: 4px;
+}
+
+tbody tr:last-child td {
+ border-bottom: 2px solid black;
+}
+
+tbody tr:hover {
+ background-color: silver;
+}
+
+legend {
+ text-transform: uppercase;
+}
+
+.ui-form-add fieldset {
+ border: 2px solid lightseagreen;
+}
+
+.ui-form-edit fieldset {
+ border: 2px solid darkorange;
+}
+
+footer {
+ border-top: 2px solid black;
+}
+
+footer p {
+ margin-top: 20px;
+ font-size: 9px;
+}
+
+input[type=button], input[type=submit], input[type=reset] {
+ font-family: 'main', sans-serif;
+ color: white;
+ background-color: transparent;
+ border-radius: 4px;
+ padding: 10px;
+ cursor: pointer;
+ text-transform: uppercase;
+ border: none;
+ font-size: 9px;
+}
+
+.ui-h2 {
+ padding: 60px 0px 0px 0px;
+ border-top: 2px solid black;
+}
+
+.ui-message-success {
+ font-size: 1.5em;
+ color: black;
+ padding: 6px;
+ border-radius: 4px;
+ background-size: 400% 100%;
+ background-image: linear-gradient(270deg, lightgreen, white);
+ animation: alert-message-animation 20s infinite linear;
+}
+
+.ui-message-warning {
+ font-size: 1.5em;
+ color: black;
+ padding: 6px;
+ border-radius: 4px;
+ background-size: 400% 100%;
+ background-image: linear-gradient(270deg, darkorange, white);
+ animation: alert-message-animation 20s infinite linear;
+}
+
+.ui-message-error {
+ font-size: 1.5em;
+ color: black;
+ padding: 6px;
+ border-radius: 4px;
+ background-size: 400% 100%;
+ background-image: linear-gradient(270deg, salmon, white);
+ animation: alert-message-animation 20s infinite linear;
+}
+
+.ui-message-danger {
+ font-size: 1.5em;
+ color: black;
+ padding: 6px;
+ border-radius: 4px;
+ background-size: 400% 100%;
+ background-image: linear-gradient(270deg, crimson, white);
+ animation: alert-message-animation 20s infinite linear;
+}
+
+.ui-link:link {
+ box-shadow: 2px 2px 1px black;
+ background: #0094ff;
+ color: white;
+ padding: 10px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ text-decoration: none;
+}
+
+.pagination {
+ font-size: 1em;
+}
+
+.ui-link:visited {
+ color: white;
+}
+
+.ui-link:hover {
+ box-shadow: none;
+ background: lightblue;
+ color: #0094ff;
+ text-decoration: none;
+}
+
+.ui-link-add {
+ background: lightseagreen;
+ color: white;
+ box-shadow: 2px 2px 1px black;
+ padding: 10px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ text-decoration: none;
+}
+
+.ui-link-add:link {
+ color: white;
+}
+
+.ui-link-add:visited {
+ color: white;
+}
+
+.ui-link-add:hover {
+ background: lightgreen;
+ color: lightseagreen;
+ box-shadow: none;
+ text-decoration: none;
+}
+
+.ui-link-admin,
+.ui-button-admin[type=submit] {
+ background: darkviolet;
+ color: white;
+ box-shadow: 2px 2px 1px black;
+ padding: 10px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ text-decoration: none;
+}
+
+.ui-link-admin:link {
+ color: white;
+}
+
+.ui-link-admin:visited {
+ color: white;
+}
+
+.ui-link-admin:hover,
+.ui-button-admin[type=submit]:hover{
+ background: violet;
+ box-shadow: none;
+ color: white;
+ text-decoration: none;
+}
+
+.ui-link-edit:link {
+ background: darkorange;
+ color: white;
+ box-shadow: 2px 2px 1px black;
+ padding: 10px;
+ border-radius: 4px;
+ text-transform: uppercase;
+ text-decoration: none;
+}
+
+.ui-link-edit:visited {
+ color: white;
+}
+
+.ui-link-edit:hover {
+ background: orangered;
+ border-top: none;
+ text-decoration: none;
+}
+
+.ui-button-add[type=submit] {
+ background: lightseagreen;
+ box-shadow: 2px 2px 1px black;
+ color: white;
+}
+
+.ui-button-add[type=submit]:visited {
+ color:white;
+}
+
+.ui-button-add[type=submit]:hover {
+ background: lightgreen;
+ color: white;
+ box-shadow: none;
+}
+
+.ui-button-edit[type=submit] {
+ background: darkorange;
+ box-shadow: 2px 2px 1px black;
+}
+
+.ui-button-edit[type=submit]:visited {
+ color:white;
+}
+
+.ui-button-edit[type=submit]:hover {
+ background: orangered;
+ color: white;
+ box-shadow: none;
+}
+
+.ui-button-danger[type=submit] {
+ background: crimson;
+ box-shadow: 2px 2px 1px black;
+}
+
+.ui-button-danger[type=submit]:visited {
+ color:white;
+}
+
+.ui-button-danger[type=submit]:hover {
+ background: salmon;
+ color: crimson;
+ box-shadow: none;
+}
+
+.ui-form,
+.ui-form-wrap {
+ padding: 60px 0 0 0;
+ margin: 60px 0 40px 0;
+ display: flex;
+ border-top: 2px solid black;
+}
+
+.ui-form {
+ justify-content: center;
+}
+
+.ui-form-wrap {
+ justify-content: center;
+ flex-direction: column;
+}
+
+.ui-form h1,
+.ui-form-wrap h1 {
+
+ padding-bottom: 60px;
+}
+
+.ui-form form,
+.ui-form-wrap form {
+ max-width: 800px;
+ width: 100%;
+ margin: 0px;
+}
+
+.ui-form form fieldset,
+.ui-form-wrap form fieldset {
+ border-radius: 4px;
+ margin-bottom: 12px;
+}
+
+.ui-form label,
+.ui-form-wrap label {
+ display: block;
+ text-transform: uppercase;
+}
+
+.ui-form label em {
+ text-transform: none;
+}
+
+.ui-form legend,
+.ui-form-wrap legend{
+ margin-bottom: 20px;
+ font-size: 1.5em;
+}
+
+.ui-form form input[type=text],
+.ui-form form input[type=file],
+.ui-form form input[type=date],
+.ui-form form input[type=number],
+.ui-form form input[type=password],
+.ui-form form select,
+.ui-form-wrap form input[type=text],
+.ui-form-wrap form input[type=file],
+.ui-form-wrap form input[type=date],
+.ui-form-wrap form input[type=number],
+.ui-form-admin input[type=password],
+.ui-form-wrap form select {
+ width: 98%;
+ margin: 0px 0px 40px 0px;
+}
+
+.ui-form-add input[type=text],
+.ui-form-add input[type=date],
+.ui-form-add input[type=number],
+.ui-form-add input[type=password],
+.ui-form-add select {
+ height: 40px;
+ font-size: 16px;
+ padding: 0px 0px 0px 4px;
+ display: block;
+ border-radius: 4px;
+ border: 2px solid lightseagreen;
+}
+
+.ui-form-add input[type=file] {
+ padding: 0px;
+ border-bottom: 2px solid lightseagreen;
+}
+
+.ui-form-admin input[type=text],
+.ui-form-admin input[type=date],
+.ui-form-admin input[type=number],
+.ui-form-admin input[type=password],
+.ui-form-admin select {
+ height: 40px;
+ font-size: 16px;
+ padmining: 0px 0px 0px 4px;
+ display: block;
+ border-radius: 4px;
+ border: 2px solid darkviolet;
+}
+
+.ui-muted-text,
+td .ui-muted-text {
+ color: silver;
+}
+
+.ui-form-checkbox-fieldset div {
+ display: flex;
+ align-items: center;
+}
+
+.ui-form-checkbox-fieldset label {
+ display: inline;
+ margin-left: 12px;
+ text-transform: none;
+}
+
+.ui-form-checkbox-fieldset input[type=checkbox],
+.ui-form-add input[type=checkbox] {
+ width: 40px;
+ height: 40px;
+}
+
+
+.ui-form-edit input[type=text],
+.ui-form-edit input[type=date],
+.ui-form-edit input[type=number],
+.ui-form-edit input[type=password],
+.ui-form-edit select {
+ height: 40px;
+ font-size: 1em;
+ padding: 0px 0px 0px 4px;
+ display: block;
+ border-radius: 4px;
+ border: 2px solid darkorange;
+}
+
+.ui-form-edit input[type=file] {
+ padding: 0px;
+ border-bottom: 2px solid darkorange;
+}
+
+.ui-form-edit img,
+.ui-form-edit video {
+ max-height: 200px;
+}
+
+.ui-form-edit video {
+ max-width: 300px;
+}
+
+.ui-form-edit fieldset {
+ display: block;
+}
+
+nav div {
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+}
+
+nav div form {
+ color: silver;
+ font-size: 10px;
+ text-align: right;
+ padding: 0px;
+ margin: 0px 4px 0px 0px;
+ float:right;
+}
+
+nav div form input[type=submit] {
+ border: 2px solid darkorange;
+ color: black;
+ box-shadow: 2px 2px 1px black;
+ display: inline-block;
+ background-color: white;
+}
+
+nav div form input[type=submit]:hover {
+ background: darkorange;
+ color: white;
+ box-shadow: none;
+}
+
+.flex-center {
+ display: flex;
+ justify-content: center;
+}
+
+.flex-column {
+ display: flex;
+ flex-direction: column;
+}
+
+.flex-end {
+ display: flex;
+ justify-content: end;
+ align-items: center;
+}
+
+.logged-in-info {
+ padding: 0px;
+ margin: 0px;
+ text-align: end;
+}
+
+.logged-in-info span {
+ color: silver;
+}
+
+.logged-in-options {
+ padding: 20px 0px 20px 0px;
+}
+
+.ui-mobile-menu {
+ text-transform: uppercase;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ padding-bottom: 10px;
+}
+
+.ui-mobile-menu div {
+ display: flex;
+ align-items: center;
+ font-size: 1.5em;
+ text-transform: initial;
+}
+
+.ui-mobile-menu img {
+ height: 40px;
+ margin-right: 4px;
+}
+
+.ui-card,
+.ui-card-other {
+ box-shadow: 3px 3px 3px black;
+ width: 350px;
+ max-height: 100px;
+ margin: 12px;
+ border: 2px #0094ff solid;
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.ui-card-other {
+ width: 100%;
+ margin: 12px 0px 12px 0px;
+}
+
+.ui-link-card:link,
+.ui-link-card-other:link {
+ text-decoration: none;
+}
+
+.ui-link-card:link div,
+.ui-link-card-other:link div {
+ background: #0094ff;
+ color: white;
+ display: flex;
+ justify-content: flex-start;
+}
+
+.ui-link-card:visited div,
+.ui-link-card:hover,
+.ui-link-card-other:visited div,
+.ui-link-card-other:hover {
+ text-decoration: none;
+ box-shadow: none;
+}
+
+.ui-card:hover,
+.ui-card-other:hover {
+ border: 2px solid lightblue;
+ box-shadow: none;
+}
+
+.ui-link-card:hover div,
+.ui-link-card-other:hover div {
+ background: lightblue;
+ color: #0094ff;
+ box-shadow: none;
+}
+
+.ui-link-card:visited,
+.ui-link-card-other:visited {
+ color: white;
+}
+
+.ui-link-card:link img,
+.ui-link-card-other:link img {
+ width: 100px;
+ display: inline-block;
+}
+
+.ui-card-text,
+.ui-card-text-other {
+ display: flex;
+ flex-direction: column;
+ padding: 6px;
+}
+
+.ui-card-title,
+.ui-card-secondary {
+ display: block;
+}
+
+.ui-card-title {
+ margin-bottom: 6px;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 2;
+ overflow: hidden;
+}
+
+.ui-card-secondary,
+.ui-card-keywords {
+ font-size: 9px;
+ display: inline-block;
+ overflow-wrap: break-word;
+}
+
+.ui-card-audio,
+.ui-card-audio audio {
+ width: 100%;
+}
+
+.ui-file-edit-table {
+ margin-bottom: 120px;
+}
+
+.ui-file-edit-table th:nth-child(2),
+.ui-file-edit-table tr td:nth-child(2) {
+ display: none;
+}
+
+.ui-file-edit-table .ui-form-edit {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-direction: row;
+}
+
+.ui-file-edit-table .ui-form-edit input[type=text] {
+ margin-right: 4px;
+ width: 100%;
+}
+
+.ui-table-column-small {
+ width: 50px;
+}
+
+.ui-table-column-small img {
+ max-width: 50px;
+ max-height: 50px;
+}
+
+.mobile-nav {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ text-align: center;
+ margin: 0px 0px 40px 0px;
+ padding-top: 40px;
+}
+
+.mobile-nav .ui-link {
+ margin: 4px 0px 4px 0px;
+}
+
+.dashboard section {
+ margin: 0px 0px 0px 0px;
+ padding-bottom: 6em;
+}
+
+/* This should be the Account Settings. Try to keep the links/buttons away from
+ other GUI elements to avoid clumsy button clicks.*/
+.dashboard section:last-child {
+ margin-bottom: 50px;
+}
+
+.dashboard section,
+.dashboard section input[type=submit]{
+ font-size: 9px;
+}
+
+.dashboard section div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 10px 0;
+}
+
+.dashboard section div a {
+ margin: 0px 4px 0px 0px;
+}
+
+.dashboard-header {
+ border-bottom: 2px solid black;
+ /* padding-bottom: 120px; */
+}
+
+.dashboard h1,
+.dashboard h2 {
+ text-align: left;
+}
+
+.dashboard h1 {
+ font-size: 4em;
+ margin-bottom: 0px;
+ padding-top: 20px;
+}
+
+.dashboard h2 {
+ font-size: 2em;
+ margin: 0px 0px 8px 0px;
+ color: silver;
+}
+
+.dashboard h3 {
+ font-size: 2.2em;
+ margin: 0;
+ padding-right: 1em;
+}
+
+.dashboard-header ul {
+ list-style: none;
+ padding: 0px;
+ margin: 0px;
+}
+
+.dashboard h1 span,
+.dashboard h3 span{
+ color: steelblue;
+}
+
+.dashboard img {
+ width: 40px;
+}
+
+.storage-dashboard-table {
+ border-top: 2px solid black;
+}
+
+.storage-dashboard-table thead tr th:nth-child(1) {
+ width: 60px;
+}
+
+.storage-dashboard-table thead tr th:nth-child(3) {
+ width: 100px;
+ text-align: center;
+}
+
+.storage-dashboard-table tbody tr td:nth-child(3) {
+ min-height: 45px;
+ width: auto;
+}
+
+.storage-dashboard-table tbody tr td:nth-child(3) form {
+ margin-left: 12px;
+}
+
+.storage-dashboard-table form {
+ display: inline;
+}
+
+.storage-dashboard-table th {
+ font-size: 15px;
+}
+
+
+
+.content-header,
+.content-image-header {
+ border-bottom: 2px solid black;
+ padding: 30px 0px 40px 0px;
+}
+
+.content-image-header {
+ display: flex;
+ align-items: center;
+}
+
+.content-image-header img {
+ display: inline-block;
+ width: 75px;
+ border-radius: 36px;
+}
+
+.content-image-header div {
+ display: flex;
+ flex-direction: column;
+ margin: 0px 0px 0px 12px;
+}
+
+.content-header h1,
+.content-image-header h1 {
+ margin-bottom: 4px;
+}
+
+.content-header p,
+.content-image-header p {
+ color: silver;
+ margin: 0;
+ padding: 0;
+ font-size: 12px;
+}
+
+.article-stage article,
+.content-stage .sketchbook-content {
+ margin-bottom: 60px;
+ padding-top: 10px;
+}
+
+.article-stage figure {
+ display: table;
+ text-align: center;
+ width: 100%;
+ padding: 0px;
+ margin: 0px;
+ font-style: italic;
+}
+
+.article-stage figure img,
+.article-stage figure video{
+ width: 100%;
+ max-width: 600px;
+}
+
+.article-stage div h2 {
+ border-bottom: 2px solid black;
+ padding: 60px 0px 60px 0px;
+ margin: 0;
+}
+
+.article-stage div {
+ /* border-top: 2px solid black; */
+}
+
+.article-stage article {
+ font-family: "main", sans-serif;
+}
+
+.article-stage div p {
+ width: 100%;
+}
+
+.content-options {
+ margin-top: 20px;
+}
+
+.article-stage div a:link {
+ margin: 12px 0px 12px 0px;
+ display: block;
+ text-transform: initial;
+}
+
+.article-stage div .ui-link .ui-link-secondary {
+ padding: 0;
+ margin: 0;
+ font-size:10px;
+}
+
+.article-stage div .ui-link h2 {
+ margin: 0;
+ padding: 0;
+ border: none;
+}
+
+.content-stage {
+ display: flex;
+ justify-content: center;
+ flex-direction: row;
+ flex-wrap: wrap;
+ margin-top: 40px;
+ width: 100%;
+}
+
+.content-other-stage {
+ width: 100%;
+}
+
+.content-other-stage h2 {
+ padding: 70px 0px 70px 0px;
+ border-bottom: 2px solid black;
+ border-top: 2px solid black;
+}
+
+.index-dashboard {
+ width: 100%;
+ display: flex;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+ padding: 0px;
+ margin: 0px;
+}
+
+.index-header {
+ border-bottom: 2px solid black;
+ padding: 40px 0px 60px 0px;
+}
+
+.ui-muted-text,
+td .ui-muted-text {
+ color: silver;
+}
+
+.console-output {
+ white-space: pre-wrap;
+ color: salmon;
+}
+
+.pagination {
+ display: block;
+ margin-bottom: 20px;
+}
+
+.pagination-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.pagination-list div {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ margin: 10px 0px 10px 0px;
+}
+
+.pagination-list div a {
+ margin: 4px;
+}
+
+.ui-disabled {
+ color: silver;
+ text-align: center;
+ padding: 8px 10px;
+ margin: 4px;
+ border: 2px solid silver;
+ border-radius: 4px;
+ text-transform: uppercase;
+}
+
+.pagination-list div div:nth-child(2){
+ display: none;
+}
+
+.ui-is-current,
+.ui-is-current:hover {
+ background: silver;
+ box-shadow: none !important;
+ color: white;
+}
+
+@keyframes alert-message-animation {
+ 0% { background-position: 400% 0%}
+ 100% { background-position: 0% 0}
+}
+
+/* portrait e-readers (Nook/Kindle), smaller tablets @ 600 or @ 640 wide.*/
+@media (min-width:481px) {
+}
+
+/* portrait tablets, portrait iPad, landscape e-readers, landscape 800x480 or
+ 854x480 phones */
+@media (min-width:641px) {
+}
+
+/* tablet, landscape iPad, lo-res laptops ands desktops */
+@media (min-width:961px) {
+
+}
+
+/* big landscape tablets, laptops, and desktops */
+@media (min-width:1025px) {
+
+}
+
+/* hi-res laptops and desktops */
+@media (min-width:1281px) {
+
+ nav a,
+ nav .nav-header {
+ font-size: 1em;
+ }
+
+ nav,
+ nav input[type=submit],
+ footer p,
+ .dashboard section,
+ .dashboard section input[type=submit] {
+ font-size: 1em;
+ }
+}
+@media (min-width:2100px) {
+
+
+}
diff --git a/static/css/search.css b/static/css/search.css
new file mode 100644
index 0000000..ba2ebde
--- /dev/null
+++ b/static/css/search.css
@@ -0,0 +1,121 @@
+.ui-search-container {
+ margin: 0px;
+ display: flex;
+ position: abosolute;
+ flex-direction: column;
+ align-items: center;
+ height: 120px;
+ border-bottom: 2px solid black;
+}
+
+#searchbox {
+ position: relative;
+ top: 40px;
+ width: 100%;
+ max-width: 600px;
+}
+
+.ais-SearchBox-form {
+ display: flex;
+ flex-wrap: nowrap;
+ justify-content: center;
+ align-items: center;
+}
+
+.ais-SearchBox-input {
+ width: 100%;
+ height: 40px;
+ padding: 10px;
+ border: 2px solid #0094ff;
+ border-radius: 4px 4px 0px 0px;
+ font-family: 'main', sans-serif;
+ font-size: 16px;
+}
+
+.ais-SearchBox-input::placeholder {
+ font-family: 'main', sans-serif;
+ font-size: 16px;
+}
+
+.ais-SearchBox-form input:focus-visible {
+ outline: none;
+}
+
+.ais-SearchBox-submit {
+ font-size: 16px;
+ height: 40px;
+ margin: 0px;
+ font-family: 'main', sans-serif;
+ color: white;
+ background-color: #0094ff;
+ border-radius: 4px;
+ padding: 10px;
+ cursor: pointer;
+ text-transform: uppercase;
+ border: none;
+}
+
+.ais-SearchBox-submitIcon,
+.ais-SearchBox-submit,
+.ais-SearchBox-reset,
+#hits {
+ display: none;
+}
+
+#hits {
+ position: relative;
+ top:40px;
+ background: white;
+ width: calc(100% - 4px);
+ max-width: calc(600px - 4px);
+ border-right: 2px solid #0094ff;
+ border-bottom: 2px solid #0094ff;
+ border-left: 2px solid #0094ff;
+ border-radius: 0px 0px 4px 4px;
+}
+
+.ais-Hits-list {
+ list-style: none;
+ padding: 0px 2px;
+ margin: 0px;
+}
+
+.ais-Hits-list:first-child {
+ display: flex;
+ flex-wrap: nowrap;
+ flex-direction: column;
+ justify-content: flex-start;
+}
+
+
+.ais-Hits-item {
+ margin: 4px 0px 0px 0px;
+}
+
+.ui-link-search-card {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ width: 100%;
+}
+
+.ui-link-search-card:link {
+ text-decoration: none;
+ float: left;
+ border-radius: 0px;
+ padding: 4px 0px;
+}
+
+.ui-link-search-card img {
+ width: 30px;
+ height: 30px;
+ display: inline;
+ float: left;
+ padding: 0px 6px;
+}
+
+.ui-link-search-card span {
+ vertical-align: middle;
+}
+
+
diff --git a/static/images/audio-file.png b/static/images/audio-file.png
new file mode 100644
index 0000000..592251b
Binary files /dev/null and b/static/images/audio-file.png differ
diff --git a/static/images/bar-line.png b/static/images/bar-line.png
new file mode 100644
index 0000000..dc648b5
Binary files /dev/null and b/static/images/bar-line.png differ
diff --git a/static/images/bar.png b/static/images/bar.png
new file mode 100644
index 0000000..eba7d33
Binary files /dev/null and b/static/images/bar.png differ
diff --git a/static/images/bokeh-logo.png b/static/images/bokeh-logo.png
new file mode 100644
index 0000000..067ae80
Binary files /dev/null and b/static/images/bokeh-logo.png differ
diff --git a/static/images/bokeh-simple.png b/static/images/bokeh-simple.png
new file mode 100644
index 0000000..c9fdf09
Binary files /dev/null and b/static/images/bokeh-simple.png differ
diff --git a/static/images/email.png b/static/images/email.png
new file mode 100644
index 0000000..b9eb698
Binary files /dev/null and b/static/images/email.png differ
diff --git a/static/images/favicon.png b/static/images/favicon.png
new file mode 100644
index 0000000..deb47c4
Binary files /dev/null and b/static/images/favicon.png differ
diff --git a/static/images/file.png b/static/images/file.png
new file mode 100644
index 0000000..1cab6ff
Binary files /dev/null and b/static/images/file.png differ
diff --git a/static/images/git.png b/static/images/git.png
new file mode 100644
index 0000000..51f4ae5
Binary files /dev/null and b/static/images/git.png differ
diff --git a/static/images/gravatar.png b/static/images/gravatar.png
new file mode 100644
index 0000000..a8a96a4
Binary files /dev/null and b/static/images/gravatar.png differ
diff --git a/static/images/hero.png b/static/images/hero.png
new file mode 100644
index 0000000..3ffb05a
Binary files /dev/null and b/static/images/hero.png differ
diff --git a/static/images/instagram.png b/static/images/instagram.png
new file mode 100644
index 0000000..c4b1540
Binary files /dev/null and b/static/images/instagram.png differ
diff --git a/static/images/line-chart-overview.png b/static/images/line-chart-overview.png
new file mode 100644
index 0000000..1a1bea9
Binary files /dev/null and b/static/images/line-chart-overview.png differ
diff --git a/static/images/line.png b/static/images/line.png
new file mode 100644
index 0000000..632b64e
Binary files /dev/null and b/static/images/line.png differ
diff --git a/static/images/logo b/static/images/logo
new file mode 100644
index 0000000..417e030
Binary files /dev/null and b/static/images/logo differ
diff --git a/static/images/network-effect.png b/static/images/network-effect.png
new file mode 100644
index 0000000..b9ef782
Binary files /dev/null and b/static/images/network-effect.png differ
diff --git a/static/images/nuget.png b/static/images/nuget.png
new file mode 100644
index 0000000..a76ab93
Binary files /dev/null and b/static/images/nuget.png differ
diff --git a/static/images/screen-video-1.mp4 b/static/images/screen-video-1.mp4
new file mode 100644
index 0000000..bce63ea
Binary files /dev/null and b/static/images/screen-video-1.mp4 differ
diff --git a/static/images/screenshot-1.png b/static/images/screenshot-1.png
new file mode 100644
index 0000000..0e3e8bb
Binary files /dev/null and b/static/images/screenshot-1.png differ
diff --git a/static/images/screenshot-2.1.png b/static/images/screenshot-2.1.png
new file mode 100644
index 0000000..c793ce0
Binary files /dev/null and b/static/images/screenshot-2.1.png differ
diff --git a/static/images/screenshot-2.png b/static/images/screenshot-2.png
new file mode 100644
index 0000000..8f565d5
Binary files /dev/null and b/static/images/screenshot-2.png differ
diff --git a/static/images/site-logo.png b/static/images/site-logo.png
new file mode 100644
index 0000000..417e030
Binary files /dev/null and b/static/images/site-logo.png differ
diff --git a/static/images/tiktok.png b/static/images/tiktok.png
new file mode 100644
index 0000000..ef6ae15
Binary files /dev/null and b/static/images/tiktok.png differ
diff --git a/templates/_errors/404.html b/templates/_errors/404.html
new file mode 100644
index 0000000..5d0d4ae
--- /dev/null
+++ b/templates/_errors/404.html
@@ -0,0 +1,47 @@
+
+
+
+
+ 404 NOT FOUND
+
+
+
+
+ This website's aim is to help people take their spreadsheets and generate
+ interactive charts with them.
+
+ The charts are created using a plotting/visualisation library written in
+ Python called Bokeh.
+
+
+
+ About Bokeh
+
+
+ The following it taken from Bokeh's website,
+
+
+ Bokeh is a Python library for creating interactive visualizations for modern
+ web browsers. It helps you build beautiful graphics, ranging from simple
+ plots to complex dashboards with streaming datasets. With Bokeh, you can
+ create JavaScript-powered visualizations without writing any JavaScript
+ yourself.
+
+
+ If you would like to know more about Bokeh, please use the following links:
+
+ The main group of people I had in mind when I developed this website were
+ people with little to no programming experience but worked with
+ spreadsheets. Because of this, the type of charts a user can create
+ utilises only a sub-set of Bokeh's functionality. This trade-off, though,
+ allows non-programmers to make interactive charts quickly and easily. From
+ there, they can explore and share said charts like it was another file on
+ your computer.
+
+
Accepted File Types
+
+ So far, this website only accepts the following file types:
+
+
+
Comma-Seperate values (.csv)
+
Tab-Seperated values (.tsv)
+
+
+ These types of files quite common and can be viewed in standard
+ spreadsheet software such as:
+
+ Note:
+ Because .csv and .tsv are 'plain text' files, you should be able to open them
+ using Notepad (Windows) or TextEdit (Mac OS).
+
+
About Craig
+
+ The short version is on my personal website you will find artworks I've
+ made, blog posts I've written, 3D models I've concocted, graphic design
+ projects I've put together and software I've developed. For the longer
+ version,
+ visit craigoates.net/about.
+
+
+
+
+{% endblock %}
diff --git a/templates/chart/add.html b/templates/chart/add.html
new file mode 100644
index 0000000..ebf7681
--- /dev/null
+++ b/templates/chart/add.html
@@ -0,0 +1,62 @@
+{% extends "layouts/base.html" %}
+{% block title %}{{user.display-name}}: Create a new chart{% endblock %}
+
+{% block content %}
+
+
+
Create Chart
+
+
+
+
Preparing Your Data
+
+
+
+
+ For this website to build interactive charts for you, without you
+ needing writing any code, it needs to make some assumptions about
+ how the data is organised. Below is an example of how your
+ spreadsheet should look.
+
+
+
+
+
+ The 'Title', 'X-Axis' and 'Y-Axis' sections in the form are what
+ the chart uses to label the chart. As the form says, the are
+ optional. The only thing you need to submit is the actual
+ spreadsheet.
+
+
+ If your files are stores in Microsoft Excel's native format (.xlsx
+ or .xls), you will need to export them to either a Comma-Separated
+ Value (.csv) or Tab-Separated Value (.tsv) file. This website is
+ unable to process 'Excel files' at this moment in time.
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..d6b17ca
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,83 @@
+{% extends "layouts/base.html" %}
+{% block title %}
+Turn your spreadsheets into interactive charts, without writing a single line of code.
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
Turn your spreadsheets into interactive charts, without writing a single line of code.
+
+ Basically, this website aims to keep as little of your personal
+ information as possible. It does not contain any advertisements with
+ tracking cookies or any social media trackers either.
+
+
What Data this Website Stores
+
+ This website stores your Username, Display Name, Password and the HTML
+ files (I.E. the charts) generated by the files you upload (.csv and .tsv
+ files) to the server. You are not required to provide any personal
+ information as part of your Username or Display Name. For the sake
+ completeness, the same applies for the Password.
+
+
What is Not Stored on this Website
+
+ This website temporarily stores any files (I.E. spreadsheets) you upload
+ so it can generate the charts (stored as HTML files). When the server has
+ finished generating the chart and stored it as an HTML file, the uploaded
+ file is deleted. None of your personally uploaded files are stored
+ long-term.
+
+
What is Viewable to the Public
+
+ Nothing is viewable to the general public. The only thing logged in users
+ can access is their own Dashboard and the charts they have created.
+
+
What is Deleted when a User Deletes their Account
+
+ The website removes the account's details from the database (Username,
+ Display Name and Password) and all the files, attached to that account, on
+ the server at that moment in time.
+
+ When a user deletes a HTML file (via the Dashboard), that file is
+ permanently deleted. It is not reversible. The same applies when deleting
+ an account. When a file or account is deleted, that is it. There is no way
+ to retrieve/restore it.
+
+
What is Shared Between this Website and craigoates.net
+
+ In terms of data, nothing is shared. This website shares the
+ 'craigoates.net' domain name but this website uses a seperate database and
+ stores all its files in a different location.
+
+
+{% endblock %}
diff --git a/templates/user/log-in.html b/templates/user/log-in.html
new file mode 100644
index 0000000..85b91b3
--- /dev/null
+++ b/templates/user/log-in.html
@@ -0,0 +1,20 @@
+{% extends "layouts/base.html" %}
+{% block title%}
+Log-in to charts.craigoates.net and start turning your spreadsheets into interactive charts.
+{%endblock%}
+
+{% block content %}
+
Login
+
+
+
+
+{% endblock %}
diff --git a/tests/tests.lisp b/tests/tests.lisp
new file mode 100644
index 0000000..c5e7411
--- /dev/null
+++ b/tests/tests.lisp
@@ -0,0 +1,21 @@
+(in-package :cl-user)
+(defpackage #:tests
+ (:use #:cl
+ #:parachute))
+(in-package #:tests)
+
+#| parachute: https://shinmera.github.io/parachute/
+================================================================================
+Use the URL to access the documentation for parachute.
+|#
+
+
+;; This was an example taken from the doc's for parachute. I'm going to keep it
+;; here as a reference until I get comfortable with parachute.
+(define-test reference-tests
+ (of-type integer 5)
+ (true (numberp 2/3))
+ (false (numberp :keyword))
+ (is-values (values 0 "1")
+ (= 0)
+ (equal "1")))