aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJuan J. Martinez <jjm@usebox.net>2024-03-23 19:01:29 +0000
committerJuan J. Martinez <jjm@usebox.net>2024-03-23 19:10:43 +0000
commit0a5471217b9f562b92f32802de4260390f639880 (patch)
treedaf0655fe2753351d7fac9010e3b50c499194bb2
downloadpersonal-wiki-pybottle-0a5471217b9f562b92f32802de4260390f639880.tar.gz
personal-wiki-pybottle-0a5471217b9f562b92f32802de4260390f639880.zip
Initial import
-rw-r--r--.gitignore3
-rw-r--r--README.md31
-rw-r--r--bottle_sqlite.py174
-rw-r--r--logger.json27
-rw-r--r--model.py112
-rw-r--r--requirements.txt2
-rw-r--r--schema.sql10
-rw-r--r--static/robots.txt4
-rw-r--r--static/water.min.css1
-rw-r--r--static/wiki.css17
-rw-r--r--views/edit.tpl10
-rw-r--r--views/footer.tpl3
-rw-r--r--views/header.tpl14
-rw-r--r--views/history.tpl17
-rw-r--r--views/page.tpl21
-rw-r--r--wiki.conf7
-rw-r--r--wiki.py89
17 files changed, 542 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..36aff71
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+__pycache__/
+data/
+ENV/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0127ef
--- /dev/null
+++ b/README.md
@@ -0,0 +1,31 @@
+# A personal Wiki
+
+I like wikis and I find them fascinating. Every now and then I feel like I should do something about it.
+
+So this is an attempt to make a personal wiki in Python (with [bottle](https://bottlepy.org/)).
+
+This is *a work in progress* and is not finished (it may never be!).
+
+# How to use it
+
+Why would you want to use this? OK, go on.
+
+* Create a virtual enviromement
+* Install the dependencies using pip
+* run `wiki,py`
+
+For example:
+
+1. `python3 -m venv ENV`
+2. `source ENV/bin/activate`
+3. `pip install -r requirements.txt`
+4. `python3 wiki.py`
+
+The configuration should be in `wiki.conf` (not much at the moment), and the logger configuraion is in `logger.json`.
+
+# Licence
+
+My code is licensed [GPL 3.0](gpl-3.0.txt).
+
+`bottle_sqlite.py` is licensed MIT and it is included here because it has a fix regarding API changes in Python 3.x. Read the file for further details.
+
diff --git a/bottle_sqlite.py b/bottle_sqlite.py
new file mode 100644
index 0000000..9411f6b
--- /dev/null
+++ b/bottle_sqlite.py
@@ -0,0 +1,174 @@
+"""
+Bottle-sqlite is a plugin that integrates SQLite3 with your Bottle
+application. It automatically connects to a database at the beginning of a
+request, passes the database handle to the route callback and closes the
+connection afterwards.
+
+To automatically detect routes that need a database connection, the plugin
+searches for route callbacks that require a `db` keyword argument
+(configurable) and skips routes that do not. This removes any overhead for
+routes that don't need a database connection.
+
+Usage Example::
+
+ import bottle
+ from bottle.ext import sqlite
+
+ app = bottle.Bottle()
+ plugin = sqlite.Plugin(dbfile='/tmp/test.db')
+ app.install(plugin)
+
+ @app.route('/show/:item')
+ def show(item, db):
+ row = db.execute('SELECT * from items where name=?', item).fetchone()
+ if row:
+ return template('showitem', page=row)
+ return HTTPError(404, "Page not found")
+"""
+
+__author__ = "Marcel Hellkamp"
+__version__ = "0.2.0"
+__license__ = "MIT"
+
+### CUT HERE (see setup.py)
+
+import sqlite3
+import inspect
+import bottle
+
+# PluginError is defined to bottle >= 0.10
+if not hasattr(bottle, "PluginError"):
+
+ class PluginError(bottle.BottleException):
+ pass
+
+ bottle.PluginError = PluginError
+
+
+class SQLitePlugin(object):
+ """This plugin passes an sqlite3 database handle to route callbacks
+ that accept a `db` keyword argument. If a callback does not expect
+ such a parameter, no connection is made. You can override the database
+ settings on a per-route basis."""
+
+ name = "sqlite"
+ api = 2
+
+ """ python3 moves unicode to str """
+ try:
+ unicode
+ except NameError:
+ unicode = str
+
+ def __init__(
+ self,
+ dbfile=":memory:",
+ autocommit=True,
+ dictrows=True,
+ keyword="db",
+ text_factory=unicode,
+ functions=None,
+ aggregates=None,
+ collations=None,
+ extensions=None,
+ ):
+ self.dbfile = dbfile
+ self.autocommit = autocommit
+ self.dictrows = dictrows
+ self.keyword = keyword
+ self.text_factory = text_factory
+ self.functions = functions or {}
+ self.aggregates = aggregates or {}
+ self.collations = collations or {}
+ self.extensions = extensions or ()
+
+ def setup(self, app):
+ """Make sure that other installed plugins don't affect the same
+ keyword argument."""
+ for other in app.plugins:
+ if not isinstance(other, SQLitePlugin):
+ continue
+ if other.keyword == self.keyword:
+ raise PluginError(
+ "Found another sqlite plugin with "
+ "conflicting settings (non-unique keyword)."
+ )
+ elif other.name == self.name:
+ self.name += "_%s" % self.keyword
+
+ def apply(self, callback, route):
+ # hack to support bottle v0.9.x
+ if bottle.__version__.startswith("0.9"):
+ config = route["config"]
+ _callback = route["callback"]
+ else:
+ config = route.config
+ _callback = route.callback
+
+ # Override global configuration with route-specific values.
+ if "sqlite" in config:
+ # support for configuration before `ConfigDict` namespaces
+ g = lambda key, default: config.get("sqlite", {}).get(key, default)
+ else:
+ g = lambda key, default: config.get("sqlite." + key, default)
+
+ dbfile = g("dbfile", self.dbfile)
+ autocommit = g("autocommit", self.autocommit)
+ dictrows = g("dictrows", self.dictrows)
+ keyword = g("keyword", self.keyword)
+ text_factory = g("text_factory", self.text_factory)
+ functions = g("functions", self.functions)
+ aggregates = g("aggregates", self.aggregates)
+ collations = g("collations", self.collations)
+ extensions = g("extensions", self.extensions)
+
+ # Test if the original callback accepts a 'db' keyword.
+ # Ignore it if it does not need a database handle.
+ argspec = inspect.getfullargspec(_callback)
+ if keyword not in argspec.args:
+ return callback
+
+ def wrapper(*args, **kwargs):
+ # Connect to the database
+ db = sqlite3.connect(dbfile)
+ # set text factory
+ db.text_factory = text_factory
+ # This enables column access by name: row['column_name']
+ if dictrows:
+ db.row_factory = sqlite3.Row
+ # Create user functions, aggregates and collations
+ for name, value in functions.items():
+ db.create_function(name, *value)
+ for name, value in aggregates.items():
+ db.create_aggregate(name, *value)
+ for name, value in collations.items():
+ db.create_collation(name, value)
+ for name in extensions:
+ db.enable_load_extension(True)
+ db.execute("SELECT load_extension(?)", (name,))
+ db.enable_load_extension(False)
+ # Add the connection handle as a keyword argument.
+ kwargs[keyword] = db
+
+ try:
+ rv = callback(*args, **kwargs)
+ if autocommit:
+ db.commit()
+ except sqlite3.IntegrityError as e:
+ db.rollback()
+ raise bottle.HTTPError(500, "Database Error", e)
+ except bottle.HTTPError as e:
+ raise
+ except bottle.HTTPResponse as e:
+ if autocommit:
+ db.commit()
+ raise
+ finally:
+ db.close()
+ return rv
+
+ # Replace the route callback with the wrapped one.
+ return wrapper
+
+
+Plugin = SQLitePlugin
diff --git a/logger.json b/logger.json
new file mode 100644
index 0000000..d4a0357
--- /dev/null
+++ b/logger.json
@@ -0,0 +1,27 @@
+{
+ "version": 1,
+ "formatters": {
+ "default": {
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ }
+ },
+ "handlers": {
+ "console":{
+ "class": "logging.StreamHandler",
+ "level": "INFO",
+ "formatter": "default",
+ "stream": "ext://sys.stdout"
+ }
+ },
+ "loggers": {
+ "wiki": {
+ "level": "INFO",
+ "handlers": ["console"],
+ "propagate": false
+ }
+ },
+ "root": {
+ "level": "ERROR",
+ "handlers": ["console"]
+ }
+}
diff --git a/model.py b/model.py
new file mode 100644
index 0000000..3f52115
--- /dev/null
+++ b/model.py
@@ -0,0 +1,112 @@
+import sqlite3
+from datetime import datetime
+import re
+
+import markdown
+
+
+def bootstrap_db(db):
+ with open("schema.sql", "rt") as fd:
+ schema = fd.read()
+ con = sqlite3.connect(db)
+ cur = con.cursor()
+ cur.executescript(schema)
+ con.commit()
+ cur.close()
+ con.close()
+
+
+class Page(object):
+ def __init__(
+ self,
+ name="WikiHome",
+ version=None,
+ content="",
+ changelog=None,
+ updated_at=None,
+ history=False,
+ ):
+ now = datetime.utcnow()
+ if updated_at is None:
+ updated_at = now
+
+ self.name = name
+ self.version = version
+ self.content = content
+ self.changelog = changelog
+ self.updated_at = updated_at
+ self.history = history
+
+ def render(self):
+ return markdown.markdown(self.content)
+
+ @property
+ def title(self):
+ pretty = " ".join(re.findall(r"[A-Z](?:[a-z]+|[A-Z]*(?=[A-Z]|$))", self.name))
+ if self.history:
+ pretty = f"{pretty} (history)"
+ return pretty
+
+
+class History(object):
+ def __init__(self, version, changelog, updated_at):
+ self.version = version
+ self.changelog = changelog
+ self.updated_at = updated_at
+
+
+def get_page(db, name, version=None):
+ cur = db.cursor()
+ if version:
+ cur.execute(
+ "SELECT name, version, content, changelog, updated_at"
+ " FROM pages"
+ " WHERE name = :name AND version = :version",
+ dict(name=name, version=version),
+ )
+ else:
+ cur.execute(
+ "SELECT name, version, content, changelog, updated_at"
+ " FROM pages"
+ " WHERE name = :name ORDER BY updated_at DESC LIMIT 1",
+ dict(name=name),
+ )
+ row = cur.fetchone()
+ cur.close()
+
+ if row:
+ return Page(*row, history=version is not None)
+ else:
+ return Page(name=name)
+
+
+def save_page(db, page):
+ cur = db.cursor()
+ cur.execute(
+ "INSERT INTO pages"
+ " VALUES(:name, :version, :content, :changelog, :updated_at)",
+ dict(
+ name=page.name,
+ version=page.version,
+ content=page.content,
+ changelog=page.changelog,
+ updated_at=page.updated_at,
+ ),
+ )
+ db.commit()
+ cur.close()
+
+
+def get_page_history(db, name):
+ cur = db.cursor()
+ cur.execute(
+ "SELECT version, changelog, updated_at"
+ " FROM pages"
+ " WHERE name = :name ORDER BY updated_at DESC",
+ dict(name=name),
+ )
+ rows = cur.fetchall()
+ cur.close()
+
+ if rows:
+ return [History(*r) for r in rows]
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..2f2b486
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,2 @@
+bottle
+markdown
diff --git a/schema.sql b/schema.sql
new file mode 100644
index 0000000..0e0bfdb
--- /dev/null
+++ b/schema.sql
@@ -0,0 +1,10 @@
+BEGIN;
+CREATE TABLE IF NOT EXISTS pages(
+ name TEXT,
+ version TEXT,
+ content BLOB NOT NULL,
+ changelog TEXT,
+ updated_at TEXT,
+ PRIMARY KEY(name, version)
+);
+COMMIT;
diff --git a/static/robots.txt b/static/robots.txt
new file mode 100644
index 0000000..5175c44
--- /dev/null
+++ b/static/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /$
+Disallow: /
+
diff --git a/static/water.min.css b/static/water.min.css
new file mode 100644
index 0000000..6a9b7f7
--- /dev/null
+++ b/static/water.min.css
@@ -0,0 +1 @@
+:root{--background-body:#fff;--background:#efefef;--background-alt:#f7f7f7;--selection:#9e9e9e;--text-main:#363636;--text-bright:#000;--text-muted:#70777f;--links:#0076d1;--focus:rgba(0,150,191,0.67);--border:#dbdbdb;--code:#000;--animation-duration:0.1s;--button-base:#d0cfcf;--button-hover:#9b9b9b;--scrollbar-thumb:#aaa;--scrollbar-thumb-hover:var(--button-hover);--form-placeholder:#949494;--form-text:#1d1d1d;--variable:#39a33c;--highlight:#ff0;--select-arrow:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='63' width='117' fill='%23161f27'%3E%3Cpath d='M115 2c-1-2-4-2-5 0L59 53 7 2a4 4 0 00-5 5l54 54 2 2 3-2 54-54c2-1 2-4 0-5z'/%3E%3C/svg%3E")}@media (prefers-color-scheme:dark){:root{--background-body:#202b38;--background:#161f27;--background-alt:#1a242f;--selection:#1c76c5;--text-main:#dbdbdb;--text-bright:#fff;--text-muted:#a9b1ba;--links:#41adff;--focus:rgba(0,150,191,0.67);--border:#526980;--code:#ffbe85;--animation-duration:0.1s;--button-base:#0c151c;--button-hover:#040a0f;--scrollbar-thumb:var(--button-hover);--scrollbar-thumb-hover:#000;--form-placeholder:#a9a9a9;--form-text:#fff;--variable:#d941e2;--highlight:#efdb43;--select-arrow:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='63' width='117' fill='%23efefef'%3E%3Cpath d='M115 2c-1-2-4-2-5 0L59 53 7 2a4 4 0 00-5 5l54 54 2 2 3-2 54-54c2-1 2-4 0-5z'/%3E%3C/svg%3E")}}html{scrollbar-color:#aaa #fff;scrollbar-color:var(--scrollbar-thumb) var(--background-body);scrollbar-width:thin}@media (prefers-color-scheme:dark){html{scrollbar-color:#040a0f #202b38;scrollbar-color:var(--scrollbar-thumb) var(--background-body)}}body{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,Segoe UI Emoji,Apple Color Emoji,Noto Color Emoji,sans-serif;line-height:1.4;max-width:800px;margin:20px auto;padding:0 10px;word-wrap:break-word;color:#363636;color:var(--text-main);background:#fff;background:var(--background-body);text-rendering:optimizeLegibility}@media (prefers-color-scheme:dark){body{background:#202b38;background:var(--background-body);color:#dbdbdb;color:var(--text-main)}}button{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}@media (prefers-color-scheme:dark){button{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}}input{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}@media (prefers-color-scheme:dark){input{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}}textarea{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}@media (prefers-color-scheme:dark){textarea{transition:background-color .1s linear,border-color .1s linear,color .1s linear,box-shadow .1s linear,transform .1s ease;transition:background-color var(--animation-duration) linear,border-color var(--animation-duration) linear,color var(--animation-duration) linear,box-shadow var(--animation-duration) linear,transform var(--animation-duration) ease}}h1{font-size:2.2em;margin-top:0}h1,h2,h3,h4,h5,h6{margin-bottom:12px;margin-top:24px}h1{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h1{color:#fff;color:var(--text-bright)}}h2{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h2{color:#fff;color:var(--text-bright)}}h3{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h3{color:#fff;color:var(--text-bright)}}h4{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h4{color:#fff;color:var(--text-bright)}}h5{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h5{color:#fff;color:var(--text-bright)}}h6{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){h6{color:#fff;color:var(--text-bright)}}strong{color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){strong{color:#fff;color:var(--text-bright)}}b,h1,h2,h3,h4,h5,h6,strong,th{font-weight:600}q:after,q:before{content:none}blockquote{border-left:4px solid rgba(0,150,191,.67);border-left:4px solid var(--focus);margin:1.5em 0;padding:.5em 1em;font-style:italic}@media (prefers-color-scheme:dark){blockquote{border-left:4px solid rgba(0,150,191,.67);border-left:4px solid var(--focus)}}q{border-left:4px solid rgba(0,150,191,.67);border-left:4px solid var(--focus);margin:1.5em 0;padding:.5em 1em;font-style:italic}@media (prefers-color-scheme:dark){q{border-left:4px solid rgba(0,150,191,.67);border-left:4px solid var(--focus)}}blockquote>footer{font-style:normal;border:0}address,blockquote cite{font-style:normal}a[href^=mailto\:]:before{content:"📧 "}a[href^=tel\:]:before{content:"📞 "}a[href^=sms\:]:before{content:"💬 "}mark{background-color:#ff0;background-color:var(--highlight);border-radius:2px;padding:0 2px;color:#000}@media (prefers-color-scheme:dark){mark{background-color:#efdb43;background-color:var(--highlight)}}a>code,a>strong{color:inherit}button,input[type=button],input[type=checkbox],input[type=radio],input[type=range],input[type=reset],input[type=submit],select{cursor:pointer}input,select{display:block}[type=checkbox],[type=radio]{display:initial}input{color:#1d1d1d;color:var(--form-text);background-color:#efefef;background-color:var(--background);font-family:inherit;font-size:inherit;margin-right:6px;margin-bottom:6px;padding:10px;border:none;border-radius:6px;outline:none}@media (prefers-color-scheme:dark){input{background-color:#161f27;background-color:var(--background);color:#fff;color:var(--form-text)}}button{color:#1d1d1d;color:var(--form-text);background-color:#efefef;background-color:var(--background);font-family:inherit;font-size:inherit;margin-right:6px;margin-bottom:6px;padding:10px;border:none;border-radius:6px;outline:none}@media (prefers-color-scheme:dark){button{background-color:#161f27;background-color:var(--background);color:#fff;color:var(--form-text)}}textarea{color:#1d1d1d;color:var(--form-text);background-color:#efefef;background-color:var(--background);font-family:inherit;font-size:inherit;margin-right:6px;margin-bottom:6px;padding:10px;border:none;border-radius:6px;outline:none}@media (prefers-color-scheme:dark){textarea{background-color:#161f27;background-color:var(--background);color:#fff;color:var(--form-text)}}select{color:#1d1d1d;color:var(--form-text);background-color:#efefef;background-color:var(--background);font-family:inherit;font-size:inherit;margin-right:6px;margin-bottom:6px;padding:10px;border:none;border-radius:6px;outline:none}@media (prefers-color-scheme:dark){select{background-color:#161f27;background-color:var(--background);color:#fff;color:var(--form-text)}}button{background-color:#d0cfcf;background-color:var(--button-base);padding-right:30px;padding-left:30px}@media (prefers-color-scheme:dark){button{background-color:#0c151c;background-color:var(--button-base)}}input[type=submit]{background-color:#d0cfcf;background-color:var(--button-base);padding-right:30px;padding-left:30px}@media (prefers-color-scheme:dark){input[type=submit]{background-color:#0c151c;background-color:var(--button-base)}}input[type=reset]{background-color:#d0cfcf;background-color:var(--button-base);padding-right:30px;padding-left:30px}@media (prefers-color-scheme:dark){input[type=reset]{background-color:#0c151c;background-color:var(--button-base)}}input[type=button]{background-color:#d0cfcf;background-color:var(--button-base);padding-right:30px;padding-left:30px}@media (prefers-color-scheme:dark){input[type=button]{background-color:#0c151c;background-color:var(--button-base)}}button:hover{background:#9b9b9b;background:var(--button-hover)}@media (prefers-color-scheme:dark){button:hover{background:#040a0f;background:var(--button-hover)}}input[type=submit]:hover{background:#9b9b9b;background:var(--button-hover)}@media (prefers-color-scheme:dark){input[type=submit]:hover{background:#040a0f;background:var(--button-hover)}}input[type=reset]:hover{background:#9b9b9b;background:var(--button-hover)}@media (prefers-color-scheme:dark){input[type=reset]:hover{background:#040a0f;background:var(--button-hover)}}input[type=button]:hover{background:#9b9b9b;background:var(--button-hover)}@media (prefers-color-scheme:dark){input[type=button]:hover{background:#040a0f;background:var(--button-hover)}}input[type=color]{min-height:2rem;padding:8px;cursor:pointer}input[type=checkbox],input[type=radio]{height:1em;width:1em}input[type=radio]{border-radius:100%}input{vertical-align:top}label{vertical-align:middle;margin-bottom:4px;display:inline-block}button,input:not([type=checkbox]):not([type=radio]),input[type=range],select,textarea{-webkit-appearance:none}textarea{display:block;margin-right:0;box-sizing:border-box;resize:vertical}textarea:not([cols]){width:100%}textarea:not([rows]){min-height:40px;height:140px}select{background:#efefef url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='63' width='117' fill='%23161f27'%3E%3Cpath d='M115 2c-1-2-4-2-5 0L59 53 7 2a4 4 0 00-5 5l54 54 2 2 3-2 54-54c2-1 2-4 0-5z'/%3E%3C/svg%3E") calc(100% - 12px) 50%/12px no-repeat;background:var(--background) var(--select-arrow) calc(100% - 12px) 50%/12px no-repeat;padding-right:35px}@media (prefers-color-scheme:dark){select{background:#161f27 url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' height='63' width='117' fill='%23efefef'%3E%3Cpath d='M115 2c-1-2-4-2-5 0L59 53 7 2a4 4 0 00-5 5l54 54 2 2 3-2 54-54c2-1 2-4 0-5z'/%3E%3C/svg%3E") calc(100% - 12px) 50%/12px no-repeat;background:var(--background) var(--select-arrow) calc(100% - 12px) 50%/12px no-repeat}}select::-ms-expand{display:none}select[multiple]{padding-right:10px;background-image:none;overflow-y:auto}input:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}@media (prefers-color-scheme:dark){input:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}}select:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}@media (prefers-color-scheme:dark){select:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}}button:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}@media (prefers-color-scheme:dark){button:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}}textarea:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}@media (prefers-color-scheme:dark){textarea:focus{box-shadow:0 0 0 2px rgba(0,150,191,.67);box-shadow:0 0 0 2px var(--focus)}}button:active,input[type=button]:active,input[type=checkbox]:active,input[type=radio]:active,input[type=range]:active,input[type=reset]:active,input[type=submit]:active{transform:translateY(2px)}button:disabled,input:disabled,select:disabled,textarea:disabled{cursor:not-allowed;opacity:.5}::-moz-placeholder{color:#949494;color:var(--form-placeholder)}:-ms-input-placeholder{color:#949494;color:var(--form-placeholder)}::-ms-input-placeholder{color:#949494;color:var(--form-placeholder)}::placeholder{color:#949494;color:var(--form-placeholder)}@media (prefers-color-scheme:dark){::-moz-placeholder{color:#a9a9a9;color:var(--form-placeholder)}:-ms-input-placeholder{color:#a9a9a9;color:var(--form-placeholder)}::-ms-input-placeholder{color:#a9a9a9;color:var(--form-placeholder)}::placeholder{color:#a9a9a9;color:var(--form-placeholder)}}fieldset{border:1px solid rgba(0,150,191,.67);border:1px solid var(--focus);border-radius:6px;margin:0 0 12px;padding:10px}@media (prefers-color-scheme:dark){fieldset{border:1px solid rgba(0,150,191,.67);border:1px solid var(--focus)}}legend{font-size:.9em;font-weight:600}input[type=range]{margin:10px 0;padding:10px 0;background:transparent}input[type=range]:focus{outline:none}input[type=range]::-webkit-slider-runnable-track{width:100%;height:9.5px;-webkit-transition:.2s;transition:.2s;background:#efefef;background:var(--background);border-radius:3px}@media (prefers-color-scheme:dark){input[type=range]::-webkit-slider-runnable-track{background:#161f27;background:var(--background)}}input[type=range]::-webkit-slider-thumb{box-shadow:0 1px 1px #000,0 0 1px #0d0d0d;height:20px;width:20px;border-radius:50%;background:#dbdbdb;background:var(--border);-webkit-appearance:none;margin-top:-7px}@media (prefers-color-scheme:dark){input[type=range]::-webkit-slider-thumb{background:#526980;background:var(--border)}}input[type=range]:focus::-webkit-slider-runnable-track{background:#efefef;background:var(--background)}@media (prefers-color-scheme:dark){input[type=range]:focus::-webkit-slider-runnable-track{background:#161f27;background:var(--background)}}input[type=range]::-moz-range-track{width:100%;height:9.5px;-moz-transition:.2s;transition:.2s;background:#efefef;background:var(--background);border-radius:3px}@media (prefers-color-scheme:dark){input[type=range]::-moz-range-track{background:#161f27;background:var(--background)}}input[type=range]::-moz-range-thumb{box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;height:20px;width:20px;border-radius:50%;background:#dbdbdb;background:var(--border)}@media (prefers-color-scheme:dark){input[type=range]::-moz-range-thumb{background:#526980;background:var(--border)}}input[type=range]::-ms-track{width:100%;height:9.5px;background:transparent;border-color:transparent;border-width:16px 0;color:transparent}input[type=range]::-ms-fill-lower{background:#efefef;background:var(--background);border:.2px solid #010101;border-radius:3px;box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d}@media (prefers-color-scheme:dark){input[type=range]::-ms-fill-lower{background:#161f27;background:var(--background)}}input[type=range]::-ms-fill-upper{background:#efefef;background:var(--background);border:.2px solid #010101;border-radius:3px;box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d}@media (prefers-color-scheme:dark){input[type=range]::-ms-fill-upper{background:#161f27;background:var(--background)}}input[type=range]::-ms-thumb{box-shadow:1px 1px 1px #000,0 0 1px #0d0d0d;border:1px solid #000;height:20px;width:20px;border-radius:50%;background:#dbdbdb;background:var(--border)}@media (prefers-color-scheme:dark){input[type=range]::-ms-thumb{background:#526980;background:var(--border)}}input[type=range]:focus::-ms-fill-lower{background:#efefef;background:var(--background)}@media (prefers-color-scheme:dark){input[type=range]:focus::-ms-fill-lower{background:#161f27;background:var(--background)}}input[type=range]:focus::-ms-fill-upper{background:#efefef;background:var(--background)}@media (prefers-color-scheme:dark){input[type=range]:focus::-ms-fill-upper{background:#161f27;background:var(--background)}}a{text-decoration:none;color:#0076d1;color:var(--links)}@media (prefers-color-scheme:dark){a{color:#41adff;color:var(--links)}}a:hover{text-decoration:underline}code{background:#efefef;background:var(--background);color:#000;color:var(--code);padding:2.5px 5px;border-radius:6px;font-size:1em}@media (prefers-color-scheme:dark){code{color:#ffbe85;color:var(--code);background:#161f27;background:var(--background)}}samp{background:#efefef;background:var(--background);color:#000;color:var(--code);padding:2.5px 5px;border-radius:6px;font-size:1em}@media (prefers-color-scheme:dark){samp{color:#ffbe85;color:var(--code);background:#161f27;background:var(--background)}}time{background:#efefef;background:var(--background);color:#000;color:var(--code);padding:2.5px 5px;border-radius:6px;font-size:1em}@media (prefers-color-scheme:dark){time{color:#ffbe85;color:var(--code);background:#161f27;background:var(--background)}}pre>code{padding:10px;display:block;overflow-x:auto}var{color:#39a33c;color:var(--variable);font-style:normal;font-family:monospace}@media (prefers-color-scheme:dark){var{color:#d941e2;color:var(--variable)}}kbd{background:#efefef;background:var(--background);border:1px solid #dbdbdb;border:1px solid var(--border);border-radius:2px;color:#363636;color:var(--text-main);padding:2px 4px}@media (prefers-color-scheme:dark){kbd{color:#dbdbdb;color:var(--text-main);border:1px solid #526980;border:1px solid var(--border);background:#161f27;background:var(--background)}}img,video{max-width:100%;height:auto}hr{border:none;border-top:1px solid #dbdbdb;border-top:1px solid var(--border)}@media (prefers-color-scheme:dark){hr{border-top:1px solid #526980;border-top:1px solid var(--border)}}table{border-collapse:collapse;margin-bottom:10px;width:100%;table-layout:fixed}table caption,td,th{text-align:left}td,th{padding:6px;vertical-align:top;word-wrap:break-word}thead{border-bottom:1px solid #dbdbdb;border-bottom:1px solid var(--border)}@media (prefers-color-scheme:dark){thead{border-bottom:1px solid #526980;border-bottom:1px solid var(--border)}}tfoot{border-top:1px solid #dbdbdb;border-top:1px solid var(--border)}@media (prefers-color-scheme:dark){tfoot{border-top:1px solid #526980;border-top:1px solid var(--border)}}tbody tr:nth-child(2n){background-color:#efefef;background-color:var(--background)}@media (prefers-color-scheme:dark){tbody tr:nth-child(2n){background-color:#161f27;background-color:var(--background)}}tbody tr:nth-child(2n) button{background-color:#f7f7f7;background-color:var(--background-alt)}@media (prefers-color-scheme:dark){tbody tr:nth-child(2n) button{background-color:#1a242f;background-color:var(--background-alt)}}tbody tr:nth-child(2n) button:hover{background-color:#fff;background-color:var(--background-body)}@media (prefers-color-scheme:dark){tbody tr:nth-child(2n) button:hover{background-color:#202b38;background-color:var(--background-body)}}::-webkit-scrollbar{height:10px;width:10px}::-webkit-scrollbar-track{background:#efefef;background:var(--background);border-radius:6px}@media (prefers-color-scheme:dark){::-webkit-scrollbar-track{background:#161f27;background:var(--background)}}::-webkit-scrollbar-thumb{background:#aaa;background:var(--scrollbar-thumb);border-radius:6px}@media (prefers-color-scheme:dark){::-webkit-scrollbar-thumb{background:#040a0f;background:var(--scrollbar-thumb)}}::-webkit-scrollbar-thumb:hover{background:#9b9b9b;background:var(--scrollbar-thumb-hover)}@media (prefers-color-scheme:dark){::-webkit-scrollbar-thumb:hover{background:#000;background:var(--scrollbar-thumb-hover)}}::-moz-selection{background-color:#9e9e9e;background-color:var(--selection);color:#000;color:var(--text-bright)}::selection{background-color:#9e9e9e;background-color:var(--selection);color:#000;color:var(--text-bright)}@media (prefers-color-scheme:dark){::-moz-selection{color:#fff;color:var(--text-bright)}::selection{color:#fff;color:var(--text-bright)}}@media (prefers-color-scheme:dark){::-moz-selection{background-color:#1c76c5;background-color:var(--selection)}::selection{background-color:#1c76c5;background-color:var(--selection)}}details{display:flex;flex-direction:column;align-items:flex-start;background-color:#f7f7f7;background-color:var(--background-alt);padding:10px 10px 0;margin:1em 0;border-radius:6px;overflow:hidden}@media (prefers-color-scheme:dark){details{background-color:#1a242f;background-color:var(--background-alt)}}details[open]{padding:10px}details>:last-child{margin-bottom:0}details[open] summary{margin-bottom:10px}summary{display:list-item;background-color:#efefef;background-color:var(--background);padding:10px;margin:-10px -10px 0;cursor:pointer;outline:none}@media (prefers-color-scheme:dark){summary{background-color:#161f27;background-color:var(--background)}}summary:focus,summary:hover{text-decoration:underline}details>:not(summary){margin-top:0}summary::-webkit-details-marker{color:#363636;color:var(--text-main)}@media (prefers-color-scheme:dark){summary::-webkit-details-marker{color:#dbdbdb;color:var(--text-main)}}dialog{background-color:#f7f7f7;background-color:var(--background-alt);color:#363636;color:var(--text-main);border-radius:6px;border:#dbdbdb;border-color:var(--border);padding:10px 30px}@media (prefers-color-scheme:dark){dialog{border-color:#526980;border-color:var(--border);color:#dbdbdb;color:var(--text-main);background-color:#1a242f;background-color:var(--background-alt)}}dialog>header:first-child{background-color:#efefef;background-color:var(--background);border-radius:6px 6px 0 0;margin:-10px -30px 10px;padding:10px;text-align:center}@media (prefers-color-scheme:dark){dialog>header:first-child{background-color:#161f27;background-color:var(--background)}}dialog::-webkit-backdrop{background:rgba(0,0,0,.61);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}dialog::backdrop{background:rgba(0,0,0,.61);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}footer{border-top:1px solid #dbdbdb;border-top:1px solid var(--border);padding-top:10px;color:#70777f;color:var(--text-muted)}@media (prefers-color-scheme:dark){footer{color:#a9b1ba;color:var(--text-muted);border-top:1px solid #526980;border-top:1px solid var(--border)}}body>footer{margin-top:40px}@media print{body,button,code,details,input,pre,summary,textarea{background-color:#fff}button,input,textarea{border:1px solid #000}body,button,code,footer,h1,h2,h3,h4,h5,h6,input,pre,strong,summary,textarea{color:#000}summary::marker{color:#000}summary::-webkit-details-marker{color:#000}tbody tr:nth-child(2n){background-color:#f2f2f2}a{color:#00f;text-decoration:underline}} \ No newline at end of file
diff --git a/static/wiki.css b/static/wiki.css
new file mode 100644
index 0000000..cca58c8
--- /dev/null
+++ b/static/wiki.css
@@ -0,0 +1,17 @@
+body {
+ min-width: 1024px;
+}
+
+#content {
+ background-color: var(--background-alt);
+ padding: 1em;
+}
+
+input.changelog {
+ width: 30em;
+}
+
+label {
+ font-weight: bold;
+ margin-top: 1em;
+}
diff --git a/views/edit.tpl b/views/edit.tpl
new file mode 100644
index 0000000..5e0f35b
--- /dev/null
+++ b/views/edit.tpl
@@ -0,0 +1,10 @@
+% include('header.tpl', title=f"Edit {page.name}")
+
+ <form action="/edit/{{page.name}}" method="post">
+ <textarea name="content" rows="20" required>{{page.content}}</textarea>
+ <label for="changelog">Changelog (optional)</label> <input class="changelog" name="changelog" type="text" value="">
+ <input value="Save" type="submit">
+ <p>Or <a href="/{{page.name}}">Cancel</a></p>
+ </form>
+
+% include('footer.tpl')
diff --git a/views/footer.tpl b/views/footer.tpl
new file mode 100644
index 0000000..f5471aa
--- /dev/null
+++ b/views/footer.tpl
@@ -0,0 +1,3 @@
+ </div>
+</body>
+</html>
diff --git a/views/header.tpl b/views/header.tpl
new file mode 100644
index 0000000..83c2046
--- /dev/null
+++ b/views/header.tpl
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
+ <title>Wiki - {{page.title}}</title>
+ <link rel="stylesheet" href="/static/water.min.css">
+ <link rel="stylesheet" href="/static/wiki.css">
+</head>
+<body>
+ <header>
+ <h1><a href="/{{page.name}}">{{page.title}}</a></h1>
+ </header>
+ <div id="content">
diff --git a/views/history.tpl b/views/history.tpl
new file mode 100644
index 0000000..5e0d42a
--- /dev/null
+++ b/views/history.tpl
@@ -0,0 +1,17 @@
+% include('header.tpl', page=page)
+
+ <h2>History</h2>
+ <ul>
+ % for h in history:
+ <li><a href="/history/{{page.name}}/{{h.version}}">View</a> -
+ % if h.changelog:
+ <em>{{h.changelog}}</em>
+ % else:
+ (no changelog)
+ % end
+ <br>Updated on {{h.updated_at}}
+ </li>
+ % end
+ </ul>
+
+% include('footer.tpl')
diff --git a/views/page.tpl b/views/page.tpl
new file mode 100644
index 0000000..4a4189a
--- /dev/null
+++ b/views/page.tpl
@@ -0,0 +1,21 @@
+% include('header.tpl', page=page)
+
+% if page.version is None:
+ <p><a href="/{{page.name}}">{{page.name}}</a> doesn't exist yet, <a href="/edit/{{page.name}}">create it</a>!</p>
+% else:
+ {{!page.render()}}
+ <hr>
+ <p>
+ % if not page.history:
+ <a href="/edit/{{page.name}}">Edit</a> :
+ % end
+ <a href="/history/{{page.name}}">History</a> :
+ % if page.history:
+ <a href="/restore/{{page.name}}/{{page.version}}">Restore</a> :
+ <a href="/delete/{{page.name}}/{{page.version}}">Delete</a>
+ % end
+ - Last updated: {{page.updated_at}}
+ </p>
+% end
+
+% include('footer.tpl')
diff --git a/wiki.conf b/wiki.conf
new file mode 100644
index 0000000..4be2248
--- /dev/null
+++ b/wiki.conf
@@ -0,0 +1,7 @@
+[sqlite]
+# full path to the sqlite3 DB
+db = data/wiki.db
+
+[wiki]
+# if the wiki is running in development mode
+dev = yes
diff --git a/wiki.py b/wiki.py
new file mode 100644
index 0000000..617f841
--- /dev/null
+++ b/wiki.py
@@ -0,0 +1,89 @@
+import logging
+from datetime import datetime
+import logging.config
+import json
+from hashlib import md5
+
+from bottle import Bottle, static_file, run, template, redirect, request
+from bottle_sqlite import SQLitePlugin
+
+import model
+
+with open("logger.json", "rt") as fd:
+ logging.config.dictConfig(json.load(fd))
+log = logging.getLogger("wiki")
+
+app = Bottle()
+app.config.load_config("wiki.conf")
+
+model.bootstrap_db(app.config["sqlite.db"])
+
+app.install(SQLitePlugin(dbfile=app.config["sqlite.db"]))
+
+DEV = app.config["wiki.dev"]
+
+if DEV:
+ log.warning("Running in dev mode")
+
+ @app.route("/static/<filename:path>")
+ def send_static(filename):
+ return static_file(filename, root="static/")
+
+
+@app.route("/")
+def index():
+ redirect("/WikiHome")
+
+
+@app.route("/<name>")
+def page(db, name):
+ return template(
+ "page", dict(page=model.get_page(db, name))
+ )
+
+
+@app.get("/edit/<name>")
+def edit(db, name):
+ return template(
+ "edit", dict(page=model.get_page(db, name))
+ )
+
+
+@app.get("/history/<name>")
+def history(db, name):
+ return template(
+ "history",
+ dict(
+ page=model.Page(name=name),
+ history=model.get_page_history(db, name),
+ ),
+ )
+
+
+@app.get("/history/<name>/<version>")
+def history_view(db, name, version):
+ return template(
+ "page",
+ dict(
+ page=model.get_page(db, name, version),
+ ),
+ )
+
+
+@app.post("/edit/<name>")
+def edit_save(db, name):
+ page = model.get_page(db, name)
+
+ if page.version is not None:
+ page.updated_at = datetime.utcnow()
+ page.content = request.forms.get("content")
+ page.changelog = request.forms.get("changelog")
+ page.version = md5(str(page.updated_at).encode()).hexdigest()
+
+ model.save_page(db, page)
+
+ redirect(f"/{name}")
+
+
+if __name__ == "__main__":
+ run(app, reloader=DEV, host="localhost", port=8080)