diff options
author | Juan J. Martinez <jjm@usebox.net> | 2024-03-23 19:01:29 +0000 |
---|---|---|
committer | Juan J. Martinez <jjm@usebox.net> | 2024-03-23 19:10:43 +0000 |
commit | 0a5471217b9f562b92f32802de4260390f639880 (patch) | |
tree | daf0655fe2753351d7fac9010e3b50c499194bb2 | |
download | personal-wiki-pybottle-0a5471217b9f562b92f32802de4260390f639880.tar.gz personal-wiki-pybottle-0a5471217b9f562b92f32802de4260390f639880.zip |
Initial import
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | README.md | 31 | ||||
-rw-r--r-- | bottle_sqlite.py | 174 | ||||
-rw-r--r-- | logger.json | 27 | ||||
-rw-r--r-- | model.py | 112 | ||||
-rw-r--r-- | requirements.txt | 2 | ||||
-rw-r--r-- | schema.sql | 10 | ||||
-rw-r--r-- | static/robots.txt | 4 | ||||
-rw-r--r-- | static/water.min.css | 1 | ||||
-rw-r--r-- | static/wiki.css | 17 | ||||
-rw-r--r-- | views/edit.tpl | 10 | ||||
-rw-r--r-- | views/footer.tpl | 3 | ||||
-rw-r--r-- | views/header.tpl | 14 | ||||
-rw-r--r-- | views/history.tpl | 17 | ||||
-rw-r--r-- | views/page.tpl | 21 | ||||
-rw-r--r-- | wiki.conf | 7 | ||||
-rw-r--r-- | wiki.py | 89 |
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 @@ -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) |