From ef72b74ec34e95a38890d542cda298dd6565d5ad Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Fri, 2 May 2025 16:37:29 +0300 Subject: Start replacing browser with a chat demo --- ARCHITECTURE.md | 8 +- browser/app.py | 242 ----------------------------- browser/requirements.txt | 11 -- browser/static/InclusiveSans-Regular.ttf | Bin 57124 -> 0 bytes browser/static/htmp.js | 38 ----- browser/static/styles.css | 140 ----------------- browser/templates/base.html.j2 | 19 --- browser/templates/frag_aside.html.j2 | 27 ---- browser/templates/frag_aside_link.html.j2 | 9 -- browser/templates/frag_error.html.j2 | 16 -- browser/templates/frag_form_delete.html.j2 | 7 - browser/templates/frag_form_find.html.j2 | 7 - browser/templates/frag_form_range.html.j2 | 21 --- browser/templates/frag_form_upsert.html.j2 | 5 - browser/templates/frag_results.html.j2 | 18 --- browser/templates/page_error.html.j2 | 5 - browser/templates/page_main.html.j2 | 11 -- demo/app.py | 242 +++++++++++++++++++++++++++++ demo/requirements.txt | 11 ++ demo/static/InclusiveSans-Regular.ttf | Bin 0 -> 57124 bytes demo/static/htmp.js | 38 +++++ demo/static/styles.css | 140 +++++++++++++++++ demo/templates/base.html.j2 | 19 +++ demo/templates/frag_aside.html.j2 | 27 ++++ demo/templates/frag_aside_link.html.j2 | 9 ++ demo/templates/frag_error.html.j2 | 16 ++ demo/templates/frag_form_delete.html.j2 | 7 + demo/templates/frag_form_find.html.j2 | 7 + demo/templates/frag_form_range.html.j2 | 21 +++ demo/templates/frag_form_upsert.html.j2 | 5 + demo/templates/frag_results.html.j2 | 18 +++ demo/templates/page_error.html.j2 | 5 + demo/templates/page_main.html.j2 | 11 ++ 33 files changed, 577 insertions(+), 583 deletions(-) delete mode 100644 browser/app.py delete mode 100644 browser/requirements.txt delete mode 100644 browser/static/InclusiveSans-Regular.ttf delete mode 100644 browser/static/htmp.js delete mode 100644 browser/static/styles.css delete mode 100644 browser/templates/base.html.j2 delete mode 100644 browser/templates/frag_aside.html.j2 delete mode 100644 browser/templates/frag_aside_link.html.j2 delete mode 100644 browser/templates/frag_error.html.j2 delete mode 100644 browser/templates/frag_form_delete.html.j2 delete mode 100644 browser/templates/frag_form_find.html.j2 delete mode 100644 browser/templates/frag_form_range.html.j2 delete mode 100644 browser/templates/frag_form_upsert.html.j2 delete mode 100644 browser/templates/frag_results.html.j2 delete mode 100644 browser/templates/page_error.html.j2 delete mode 100644 browser/templates/page_main.html.j2 create mode 100644 demo/app.py create mode 100644 demo/requirements.txt create mode 100644 demo/static/InclusiveSans-Regular.ttf create mode 100644 demo/static/htmp.js create mode 100644 demo/static/styles.css create mode 100644 demo/templates/base.html.j2 create mode 100644 demo/templates/frag_aside.html.j2 create mode 100644 demo/templates/frag_aside_link.html.j2 create mode 100644 demo/templates/frag_error.html.j2 create mode 100644 demo/templates/frag_form_delete.html.j2 create mode 100644 demo/templates/frag_form_find.html.j2 create mode 100644 demo/templates/frag_form_range.html.j2 create mode 100644 demo/templates/frag_form_upsert.html.j2 create mode 100644 demo/templates/frag_results.html.j2 create mode 100644 demo/templates/page_error.html.j2 create mode 100644 demo/templates/page_main.html.j2 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a34330a..bf622e7 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -381,10 +381,4 @@ When the active segment changes, the process should reopen the files between the The schema handling up to now has been in a weird spot: the DB requires a schema, i.e. a mapping from fields (types with bound `F: Eq`) to database types (`Type`), but does not persist this information anywhere. Every initialization of the DB might have a different view of the schema. This is fine if the readers are all instances of a single piece software, but if the DB is accessed by multiple different applications, the schema must be somehow communicated between them out-of-band. This is not ideal, of course. -I tried removing the schema completely. All validation is outsourced to the user. This works, but has even more quirks, mostly stemming from the fact that now the database has no idea what the data is supposed to look like. - -I started implementing a database browser application for demoing purposes, and realized that the schema must be persisted in some way, since otherwise the browser app would have to either 1) infer the schema from the data, or 2) require the user to input the schema somehow. Both of these are kind of unsuitable for the feel I'm going for with this project. - -My current idea is to store the schema in a file in the data directory upon first initialization. The schema file contains a mapping from field names to types. The schema file is read upon DB initialization and used to validate the data. - -The user can change the schema only by adding new nullable fields. This is the only schema evolution action that is both backwards and forwards compatible, which is a nice property to have. Adding nullability to an existing field is backwards compatible, but not forwards compatible. The compatibility mode (BACKWARDS, FULL) might be a good thing to expose to the user. +I removed the schema completely. All validation is outsourced to the user. This works, but has some quirks, mostly stemming from the fact that now the database has no idea what the data is supposed to look like. diff --git a/browser/app.py b/browser/app.py deleted file mode 100644 index be79432..0000000 --- a/browser/app.py +++ /dev/null @@ -1,242 +0,0 @@ -from dotenv import load_dotenv -load_dotenv() - -import os -import sys -import traceback -import tempfile -from typing import cast -from flask import Flask, render_template, request -from flask_compress import Compress # type: ignore - -import log_db -from log_db import DB, Bound, Value - -app = Flask(__name__) -Compress(app) - -BASE_URL = os.environ["BASE_URL"] -DB_DIR = os.environ["DB_DIR"] if "DB_DIR" in os.environ else tempfile.TemporaryDirectory().name - -print("""\n""" - f"""BASE_URL: {BASE_URL}\n""" - f"""DB_DIR: {DB_DIR}\n""") - -db_fields = ["id", "name"] -db_types = ["int", "string"] - -db = DB \ - .configure() \ - .data_dir(DB_DIR) \ - .fields(db_fields) \ - .primary_key("id") \ - .secondary_keys(["name"]) \ - .initialize() - -def error(e: str, code: int): - error_text = f"HTTP {code}: {e}" - #return render_template("page_error.html.j2", error = error_text) - - htmp_target = request.form.get("htmp") or request.args.get("htmp") - if htmp_target: - return render_template("frag_error.html.j2", error = error_text, container = htmp_target) - else: - return render_template("page_error.html.j2", error = error_text) - -@app.errorhandler(Exception) -def error_handler(e: Exception): - if hasattr(e, "code") and 400 >= getattr(e, "code") < 500: - return error(str(e), getattr(e, "code")) - else: - traceback.print_exception(e, file=sys.stderr) - return error("Internal Server Error", 500) - -@app.get("/") -def index_default(): - return index("find") - -@app.get("/") -def index(op: str): - if op not in ["find", "range", "upsert", "delete"]: - return error("Invalid operation", 400) - - rows = db.range_by("id", Bound.unbounded(), Bound.unbounded(), limit=100) - rows = [[value_to_str(v) for v in row] for row in rows] - - htmp_target = request.form.get("htmp") or request.args.get("htmp") - if htmp_target: - return render_template(f"frag_form_{op}.html.j2") - else: - return render_template('page_main.html.j2', - selected_form = f"frag_form_{op}.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - -@app.post("/find") -def query_find(): - field = request.form.get("field") - if not field: raise ValueError("Field is required") - - values = request.form.get("values") - if not values: raise ValueError("Values are required") - - field_index = db_fields.index(field) - if field_index == -1: raise ValueError(f"Field '{field}' not found") - - try: - values = [cast_to_value(field, v) for v in values.split("\n")] - values = [v for v in values if v is not None] - except ValueError as e: - return error(str(e), 400) - - tagged_rows = db.batch_find_by(field, values, limit=100) - rows = [[value_to_str(v) for v in row] for (_, row) in tagged_rows] - - htmp_target = request.form.get("htmp") or request.args.get("htmp") - if htmp_target: - return render_template("frag_results.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - else: - return render_template('page_main.html.j2', - selected_form = "frag_form_find.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - -@app.post("/range") -def query_range(): - field = request.form.get("field") - if not field: raise ValueError("Field is required") - - from_type = request.form.get("from_type") - if not from_type: raise ValueError("From type is required") - - to_type = request.form.get("to_type") - if not to_type: raise ValueError("To type is required") - - field_index = db_fields.index(field) - if field_index == -1: raise ValueError(f"Field '{field}' not found") - - match from_type: - case "unbounded": - bound_lower = Bound.unbounded() - case "included": - from_value = request.form.get("from_value") - try: - if not from_value: raise ValueError("From value is required") - from_value = cast_to_value(field, from_value) - except ValueError as e: - return error(str(e), 400) - bound_lower = Bound.included(cast(Value, from_value)) - case "excluded": - from_value = request.form.get("from_value") - try: - if not from_value: raise ValueError("From value is required") - from_value = cast_to_value(field, from_value) - except ValueError as e: - return error(str(e), 400) - bound_lower = Bound.excluded(cast(Value, from_value)) - case _: - return error("Invalid from type", 400) - - match to_type: - case "unbounded": - bound_upper = Bound.unbounded() - case "included": - to_value = request.form.get("to_value") - try: - if not to_value: raise ValueError("To value is required") - to_value = cast_to_value(field, to_value) - except ValueError as e: - return error(str(e), 400) - bound_upper = Bound.included(cast(Value, to_value)) - case "excluded": - to_value = request.form.get("to_value") - try: - if not to_value: raise ValueError("To value is required") - to_value = cast_to_value(field, to_value) - except ValueError as e: - return error(str(e), 400) - bound_upper = Bound.excluded(cast(Value, to_value)) - case _: - return error("Invalid to type", 400) - - tagged_rows = db.range_by(field, bound_lower, bound_upper, limit=100) - rows = [[value_to_str(v) for v in row] for row in tagged_rows] - - htmp_target = request.form.get("htmp") or request.args.get("htmp") - if htmp_target: - return render_template("frag_results.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - else: - return render_template('page_main.html.j2', - selected_form = "frag_form_range.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - -@app.post("/delete") -def query_delete(): - field = request.form.get("field") - if not field: raise ValueError("Field is required") - - value = request.form.get("value") - if not value: raise ValueError("Value is required") - - field_index = db_fields.index(field) - if field_index == -1: raise ValueError(f"Field '{field}' not found") - - try: - value = cast_to_value(field, value) - except ValueError as e: - return error(str(e), 400) - - rows = db.delete_by(field, cast(Value, value)) - rows = [[value_to_str(v) for v in row] for row in rows] - - htmp_target = request.form.get("htmp") or request.args.get("htmp") - if htmp_target: - return render_template("frag_results.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - else: - return render_template('page_main.html.j2', - selected_form = "frag_form_delete.html.j2", - field_names = ["id", "name"], - rows = rows, - ) - - -# Utils - -def cast_to_value(field: str, str_value: str) -> Value | None: - str_value = str_value.strip() - if str_value == "": return None - if str_value[0] == "\"": - if str_value[-1] != "\"": raise ValueError(f"Invalid string: {str_value}") - str_value = str_value[1:-1] - - field_index = db_fields.index(field) - if field_index == -1: raise ValueError(f"Field '{field}' not found") - - type = db_types[field_index] - - match type: - case "int": return Value.int(int(str_value)) - case "string": return Value.string(str_value) - case _: raise ValueError(f"Unsupported type: {type}") - -def value_to_str(value: Value) -> str: - match value.kind(): - case log_db.VALUE_INT: return f"{str(value.as_int())} (int)" - case log_db.VALUE_STRING: return f"\"{value.as_string()}\" (string)" - case log_db.VALUE_DECIMAL: return f"{value.as_decimal()} (decimal)" - case log_db.VALUE_BYTES: return f"{value.as_bytes()} (bytes)" - case log_db.VALUE_NULL: return "null" - case _: raise ValueError(f"Unsupported value kind: {value.kind()}") diff --git a/browser/requirements.txt b/browser/requirements.txt deleted file mode 100644 index b6b9bff..0000000 --- a/browser/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -blinker==1.9.0 -Brotli==1.1.0 -click==8.1.8 -Flask==3.1.0 -Flask-Compress==1.17 -itsdangerous==2.2.0 -Jinja2==3.1.5 -MarkupSafe==3.0.2 -python-dotenv==1.0.1 -Werkzeug==3.1.3 -zstandard==0.23.0 diff --git a/browser/static/InclusiveSans-Regular.ttf b/browser/static/InclusiveSans-Regular.ttf deleted file mode 100644 index 3a30e79..0000000 Binary files a/browser/static/InclusiveSans-Regular.ttf and /dev/null differ diff --git a/browser/static/htmp.js b/browser/static/htmp.js deleted file mode 100644 index 27d963b..0000000 --- a/browser/static/htmp.js +++ /dev/null @@ -1,38 +0,0 @@ -const iframe = document.createElement("iframe"); -iframe.name = "htmp"; -iframe.hidden = true; -iframe.addEventListener("load", function () { - setTimeout(() => { - const cd = this.contentDocument; - const cw = this.contentWindow; - // If the server responds with an entire HTML document, replace the current document with it. - // To check whether the response is an entire page we check the presence of this very snippet. - if (cd.querySelector("iframe[name='htmp']")) { - document.documentElement.replaceWith(cd.documentElement); - // If the server responds with a fragment, replace the target element with it. - } else { - document - .querySelector(cw.location.hash || null) - ?.replaceWith(...cd.body.childNodes); - const url = new URL(cw.location.href); - url.searchParams.delete("htmp"); - url.hash = ""; - history.replaceState({}, "", url.toString()); - } - }); -}); -document.body.appendChild(iframe); - -document.querySelectorAll("form[htmp]").forEach(function (el) { - const r = el.attributes.replace.value; - const input = document.createElement("input"); - input.type = "hidden"; - input.name = "htmp"; - input.value = r; - el.appendChild(input); - - const action = new URL(el.action); - action.hash = r; - el.action = action.toString(); - el.target = "htmp"; -}); diff --git a/browser/static/styles.css b/browser/static/styles.css deleted file mode 100644 index 82bed74..0000000 --- a/browser/static/styles.css +++ /dev/null @@ -1,140 +0,0 @@ -:root { - --blue: rgb(30, 99, 248); - --red: #d42f2f; - --yellow: #ff9800; - --black: rgb(0, 0, 0); - --white: rgb(255, 255, 255); - --gray: #666; - --light-gray-1: #f2f2f2; - --light-gray-2: #f8f6ff; - --gold-trans: #ffcd0455; - - --font-family: "Inclusive Sans", sans-serif; -} - -@font-face { - font-family: "Inclusive Sans"; - font-style: normal; - font-weight: 400; - font-display: swap; - src: url(/static/InclusiveSans-Regular.ttf) format("woff2"); -} - -html { - font-family: var(--font-family) !important; - font-size: 16px; -} - -body { - padding: 10px; - margin: 0 auto; - line-height: 24px; -} - -#container { - display: flex; - flex-flow: row wrap; - justify-content: space-between; - align-items: flex-start; -} - -a, -.contrast { - color: var(--blue); -} - -h1 { - margin-bottom: 40px; -} - -h1 a { - color: var(--black); - text-decoration: none; -} - -h1:hover a { - border-bottom: 2px dotted var(--blue); - text-decoration: none; -} - -textarea, -input { - font-family: var(--font-family); - font-size: 16px; -} - -button, -input[type="submit"] { - cursor: pointer; - font-size: 16px; - border-radius: 4px; - text-shadow: none; - box-shadow: none; - padding: 5px 8px; - border: 0; - background-color: var(--blue); - color: var(--white); - - margin-top: 20px; -} - -button.link, -input[type="submit"].link { - background: none; - color: var(--blue); - padding: 0; - margin: 0; - text-decoration: underline; - font-family: "Inclusive Sans"; -} - -button.red, -input[type="submit"].red { - background-color: var(--red); -} - -button.blue, -input[type="submit"].blue { - background-color: var(--blue); -} - -button.yellow, -input[type="submit"].yellow { - background-color: var(--yellow); -} - -input[type="text"], -input[type*="date"] { - font-size: 16px; - color: var(--black); -} - -input[type="text"]:disabled, -input[type*="date"]:disabled { - color: var(--gray); -} - -aside { - width: 250px; -} - -main { - flex: 1; - overflow-y: scroll; - max-height: 100%; -} - -table { - width: 100%; -} - -th, -td { - min-width: 100px; - text-align: left; - padding: 7px 5px; -} - -tr:nth-of-type(odd) td { - background-color: var(--light-gray-2); -} diff --git a/browser/templates/base.html.j2 b/browser/templates/base.html.j2 deleted file mode 100644 index 47b3f6c..0000000 --- a/browser/templates/base.html.j2 +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - {% block head_meta %} - Database browser - - {% endblock %} - - -

Database browser

- {% block content %}{% endblock %} - - - - diff --git a/browser/templates/frag_aside.html.j2 b/browser/templates/frag_aside.html.j2 deleted file mode 100644 index 6c5658f..0000000 --- a/browser/templates/frag_aside.html.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{# -Params - selected_form Form fragment to include -#} - - diff --git a/browser/templates/frag_aside_link.html.j2 b/browser/templates/frag_aside_link.html.j2 deleted file mode 100644 index 9cd6041..0000000 --- a/browser/templates/frag_aside_link.html.j2 +++ /dev/null @@ -1,9 +0,0 @@ -{# -Params - text Text to render in the link - href URL to link to -#} - -
- -
diff --git a/browser/templates/frag_error.html.j2 b/browser/templates/frag_error.html.j2 deleted file mode 100644 index c5034cd..0000000 --- a/browser/templates/frag_error.html.j2 +++ /dev/null @@ -1,16 +0,0 @@ -{# -Params - error Error message to display - container (optional) id for a div to wrap the error message in -#} - -{% if container %} -
-{% endif %} - -

Error

-{{ error }} - -{% if container %} -
-{% endif %} diff --git a/browser/templates/frag_form_delete.html.j2 b/browser/templates/frag_form_delete.html.j2 deleted file mode 100644 index 22306c1..0000000 --- a/browser/templates/frag_form_delete.html.j2 +++ /dev/null @@ -1,7 +0,0 @@ -
- - - - - -
diff --git a/browser/templates/frag_form_find.html.j2 b/browser/templates/frag_form_find.html.j2 deleted file mode 100644 index cadd44b..0000000 --- a/browser/templates/frag_form_find.html.j2 +++ /dev/null @@ -1,7 +0,0 @@ -
- - - - - -
diff --git a/browser/templates/frag_form_range.html.j2 b/browser/templates/frag_form_range.html.j2 deleted file mode 100644 index 84b8024..0000000 --- a/browser/templates/frag_form_range.html.j2 +++ /dev/null @@ -1,21 +0,0 @@ -
- - - - - - - - - - - -
diff --git a/browser/templates/frag_form_upsert.html.j2 b/browser/templates/frag_form_upsert.html.j2 deleted file mode 100644 index 68820f5..0000000 --- a/browser/templates/frag_form_upsert.html.j2 +++ /dev/null @@ -1,5 +0,0 @@ -
- - - -
diff --git a/browser/templates/frag_results.html.j2 b/browser/templates/frag_results.html.j2 deleted file mode 100644 index bfb54e9..0000000 --- a/browser/templates/frag_results.html.j2 +++ /dev/null @@ -1,18 +0,0 @@ - - - - {% for field_name in field_names %} - - {% endfor %} - - - - {% for row in rows %} - - {% for field in row %} - - {% endfor %} - - {% endfor %} - -
{{ field_name }}
{{ field }}
diff --git a/browser/templates/page_error.html.j2 b/browser/templates/page_error.html.j2 deleted file mode 100644 index 3233283..0000000 --- a/browser/templates/page_error.html.j2 +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "base.html.j2" %} - -{% block content %} -{% include "frag_error.html.j2" %} -{% endblock %} diff --git a/browser/templates/page_main.html.j2 b/browser/templates/page_main.html.j2 deleted file mode 100644 index 907275f..0000000 --- a/browser/templates/page_main.html.j2 +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "base.html.j2" %} - -{% block content %} -
- {% include "frag_aside.html.j2" %} - -
- {% include "frag_results.html.j2" %} -
-
-{% endblock %} diff --git a/demo/app.py b/demo/app.py new file mode 100644 index 0000000..be79432 --- /dev/null +++ b/demo/app.py @@ -0,0 +1,242 @@ +from dotenv import load_dotenv +load_dotenv() + +import os +import sys +import traceback +import tempfile +from typing import cast +from flask import Flask, render_template, request +from flask_compress import Compress # type: ignore + +import log_db +from log_db import DB, Bound, Value + +app = Flask(__name__) +Compress(app) + +BASE_URL = os.environ["BASE_URL"] +DB_DIR = os.environ["DB_DIR"] if "DB_DIR" in os.environ else tempfile.TemporaryDirectory().name + +print("""\n""" + f"""BASE_URL: {BASE_URL}\n""" + f"""DB_DIR: {DB_DIR}\n""") + +db_fields = ["id", "name"] +db_types = ["int", "string"] + +db = DB \ + .configure() \ + .data_dir(DB_DIR) \ + .fields(db_fields) \ + .primary_key("id") \ + .secondary_keys(["name"]) \ + .initialize() + +def error(e: str, code: int): + error_text = f"HTTP {code}: {e}" + #return render_template("page_error.html.j2", error = error_text) + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_error.html.j2", error = error_text, container = htmp_target) + else: + return render_template("page_error.html.j2", error = error_text) + +@app.errorhandler(Exception) +def error_handler(e: Exception): + if hasattr(e, "code") and 400 >= getattr(e, "code") < 500: + return error(str(e), getattr(e, "code")) + else: + traceback.print_exception(e, file=sys.stderr) + return error("Internal Server Error", 500) + +@app.get("/") +def index_default(): + return index("find") + +@app.get("/") +def index(op: str): + if op not in ["find", "range", "upsert", "delete"]: + return error("Invalid operation", 400) + + rows = db.range_by("id", Bound.unbounded(), Bound.unbounded(), limit=100) + rows = [[value_to_str(v) for v in row] for row in rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template(f"frag_form_{op}.html.j2") + else: + return render_template('page_main.html.j2', + selected_form = f"frag_form_{op}.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/find") +def query_find(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + values = request.form.get("values") + if not values: raise ValueError("Values are required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + try: + values = [cast_to_value(field, v) for v in values.split("\n")] + values = [v for v in values if v is not None] + except ValueError as e: + return error(str(e), 400) + + tagged_rows = db.batch_find_by(field, values, limit=100) + rows = [[value_to_str(v) for v in row] for (_, row) in tagged_rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_find.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/range") +def query_range(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + from_type = request.form.get("from_type") + if not from_type: raise ValueError("From type is required") + + to_type = request.form.get("to_type") + if not to_type: raise ValueError("To type is required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + match from_type: + case "unbounded": + bound_lower = Bound.unbounded() + case "included": + from_value = request.form.get("from_value") + try: + if not from_value: raise ValueError("From value is required") + from_value = cast_to_value(field, from_value) + except ValueError as e: + return error(str(e), 400) + bound_lower = Bound.included(cast(Value, from_value)) + case "excluded": + from_value = request.form.get("from_value") + try: + if not from_value: raise ValueError("From value is required") + from_value = cast_to_value(field, from_value) + except ValueError as e: + return error(str(e), 400) + bound_lower = Bound.excluded(cast(Value, from_value)) + case _: + return error("Invalid from type", 400) + + match to_type: + case "unbounded": + bound_upper = Bound.unbounded() + case "included": + to_value = request.form.get("to_value") + try: + if not to_value: raise ValueError("To value is required") + to_value = cast_to_value(field, to_value) + except ValueError as e: + return error(str(e), 400) + bound_upper = Bound.included(cast(Value, to_value)) + case "excluded": + to_value = request.form.get("to_value") + try: + if not to_value: raise ValueError("To value is required") + to_value = cast_to_value(field, to_value) + except ValueError as e: + return error(str(e), 400) + bound_upper = Bound.excluded(cast(Value, to_value)) + case _: + return error("Invalid to type", 400) + + tagged_rows = db.range_by(field, bound_lower, bound_upper, limit=100) + rows = [[value_to_str(v) for v in row] for row in tagged_rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_range.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + +@app.post("/delete") +def query_delete(): + field = request.form.get("field") + if not field: raise ValueError("Field is required") + + value = request.form.get("value") + if not value: raise ValueError("Value is required") + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + try: + value = cast_to_value(field, value) + except ValueError as e: + return error(str(e), 400) + + rows = db.delete_by(field, cast(Value, value)) + rows = [[value_to_str(v) for v in row] for row in rows] + + htmp_target = request.form.get("htmp") or request.args.get("htmp") + if htmp_target: + return render_template("frag_results.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + else: + return render_template('page_main.html.j2', + selected_form = "frag_form_delete.html.j2", + field_names = ["id", "name"], + rows = rows, + ) + + +# Utils + +def cast_to_value(field: str, str_value: str) -> Value | None: + str_value = str_value.strip() + if str_value == "": return None + if str_value[0] == "\"": + if str_value[-1] != "\"": raise ValueError(f"Invalid string: {str_value}") + str_value = str_value[1:-1] + + field_index = db_fields.index(field) + if field_index == -1: raise ValueError(f"Field '{field}' not found") + + type = db_types[field_index] + + match type: + case "int": return Value.int(int(str_value)) + case "string": return Value.string(str_value) + case _: raise ValueError(f"Unsupported type: {type}") + +def value_to_str(value: Value) -> str: + match value.kind(): + case log_db.VALUE_INT: return f"{str(value.as_int())} (int)" + case log_db.VALUE_STRING: return f"\"{value.as_string()}\" (string)" + case log_db.VALUE_DECIMAL: return f"{value.as_decimal()} (decimal)" + case log_db.VALUE_BYTES: return f"{value.as_bytes()} (bytes)" + case log_db.VALUE_NULL: return "null" + case _: raise ValueError(f"Unsupported value kind: {value.kind()}") diff --git a/demo/requirements.txt b/demo/requirements.txt new file mode 100644 index 0000000..b6b9bff --- /dev/null +++ b/demo/requirements.txt @@ -0,0 +1,11 @@ +blinker==1.9.0 +Brotli==1.1.0 +click==8.1.8 +Flask==3.1.0 +Flask-Compress==1.17 +itsdangerous==2.2.0 +Jinja2==3.1.5 +MarkupSafe==3.0.2 +python-dotenv==1.0.1 +Werkzeug==3.1.3 +zstandard==0.23.0 diff --git a/demo/static/InclusiveSans-Regular.ttf b/demo/static/InclusiveSans-Regular.ttf new file mode 100644 index 0000000..3a30e79 Binary files /dev/null and b/demo/static/InclusiveSans-Regular.ttf differ diff --git a/demo/static/htmp.js b/demo/static/htmp.js new file mode 100644 index 0000000..27d963b --- /dev/null +++ b/demo/static/htmp.js @@ -0,0 +1,38 @@ +const iframe = document.createElement("iframe"); +iframe.name = "htmp"; +iframe.hidden = true; +iframe.addEventListener("load", function () { + setTimeout(() => { + const cd = this.contentDocument; + const cw = this.contentWindow; + // If the server responds with an entire HTML document, replace the current document with it. + // To check whether the response is an entire page we check the presence of this very snippet. + if (cd.querySelector("iframe[name='htmp']")) { + document.documentElement.replaceWith(cd.documentElement); + // If the server responds with a fragment, replace the target element with it. + } else { + document + .querySelector(cw.location.hash || null) + ?.replaceWith(...cd.body.childNodes); + const url = new URL(cw.location.href); + url.searchParams.delete("htmp"); + url.hash = ""; + history.replaceState({}, "", url.toString()); + } + }); +}); +document.body.appendChild(iframe); + +document.querySelectorAll("form[htmp]").forEach(function (el) { + const r = el.attributes.replace.value; + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "htmp"; + input.value = r; + el.appendChild(input); + + const action = new URL(el.action); + action.hash = r; + el.action = action.toString(); + el.target = "htmp"; +}); diff --git a/demo/static/styles.css b/demo/static/styles.css new file mode 100644 index 0000000..82bed74 --- /dev/null +++ b/demo/static/styles.css @@ -0,0 +1,140 @@ +:root { + --blue: rgb(30, 99, 248); + --red: #d42f2f; + --yellow: #ff9800; + --black: rgb(0, 0, 0); + --white: rgb(255, 255, 255); + --gray: #666; + --light-gray-1: #f2f2f2; + --light-gray-2: #f8f6ff; + --gold-trans: #ffcd0455; + + --font-family: "Inclusive Sans", sans-serif; +} + +@font-face { + font-family: "Inclusive Sans"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/static/InclusiveSans-Regular.ttf) format("woff2"); +} + +html { + font-family: var(--font-family) !important; + font-size: 16px; +} + +body { + padding: 10px; + margin: 0 auto; + line-height: 24px; +} + +#container { + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: flex-start; +} + +a, +.contrast { + color: var(--blue); +} + +h1 { + margin-bottom: 40px; +} + +h1 a { + color: var(--black); + text-decoration: none; +} + +h1:hover a { + border-bottom: 2px dotted var(--blue); + text-decoration: none; +} + +textarea, +input { + font-family: var(--font-family); + font-size: 16px; +} + +button, +input[type="submit"] { + cursor: pointer; + font-size: 16px; + border-radius: 4px; + text-shadow: none; + box-shadow: none; + padding: 5px 8px; + border: 0; + background-color: var(--blue); + color: var(--white); + + margin-top: 20px; +} + +button.link, +input[type="submit"].link { + background: none; + color: var(--blue); + padding: 0; + margin: 0; + text-decoration: underline; + font-family: "Inclusive Sans"; +} + +button.red, +input[type="submit"].red { + background-color: var(--red); +} + +button.blue, +input[type="submit"].blue { + background-color: var(--blue); +} + +button.yellow, +input[type="submit"].yellow { + background-color: var(--yellow); +} + +input[type="text"], +input[type*="date"] { + font-size: 16px; + color: var(--black); +} + +input[type="text"]:disabled, +input[type*="date"]:disabled { + color: var(--gray); +} + +aside { + width: 250px; +} + +main { + flex: 1; + overflow-y: scroll; + max-height: 100%; +} + +table { + width: 100%; +} + +th, +td { + min-width: 100px; + text-align: left; + padding: 7px 5px; +} + +tr:nth-of-type(odd) td { + background-color: var(--light-gray-2); +} diff --git a/demo/templates/base.html.j2 b/demo/templates/base.html.j2 new file mode 100644 index 0000000..47b3f6c --- /dev/null +++ b/demo/templates/base.html.j2 @@ -0,0 +1,19 @@ + + + + + + + + {% block head_meta %} + Database browser + + {% endblock %} + + +

Database browser

+ {% block content %}{% endblock %} + + + + diff --git a/demo/templates/frag_aside.html.j2 b/demo/templates/frag_aside.html.j2 new file mode 100644 index 0000000..6c5658f --- /dev/null +++ b/demo/templates/frag_aside.html.j2 @@ -0,0 +1,27 @@ +{# +Params + selected_form Form fragment to include +#} + + diff --git a/demo/templates/frag_aside_link.html.j2 b/demo/templates/frag_aside_link.html.j2 new file mode 100644 index 0000000..9cd6041 --- /dev/null +++ b/demo/templates/frag_aside_link.html.j2 @@ -0,0 +1,9 @@ +{# +Params + text Text to render in the link + href URL to link to +#} + +
+ +
diff --git a/demo/templates/frag_error.html.j2 b/demo/templates/frag_error.html.j2 new file mode 100644 index 0000000..c5034cd --- /dev/null +++ b/demo/templates/frag_error.html.j2 @@ -0,0 +1,16 @@ +{# +Params + error Error message to display + container (optional) id for a div to wrap the error message in +#} + +{% if container %} +
+{% endif %} + +

Error

+{{ error }} + +{% if container %} +
+{% endif %} diff --git a/demo/templates/frag_form_delete.html.j2 b/demo/templates/frag_form_delete.html.j2 new file mode 100644 index 0000000..22306c1 --- /dev/null +++ b/demo/templates/frag_form_delete.html.j2 @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/demo/templates/frag_form_find.html.j2 b/demo/templates/frag_form_find.html.j2 new file mode 100644 index 0000000..cadd44b --- /dev/null +++ b/demo/templates/frag_form_find.html.j2 @@ -0,0 +1,7 @@ +
+ + + + + +
diff --git a/demo/templates/frag_form_range.html.j2 b/demo/templates/frag_form_range.html.j2 new file mode 100644 index 0000000..84b8024 --- /dev/null +++ b/demo/templates/frag_form_range.html.j2 @@ -0,0 +1,21 @@ +
+ + + + + + + + + + + +
diff --git a/demo/templates/frag_form_upsert.html.j2 b/demo/templates/frag_form_upsert.html.j2 new file mode 100644 index 0000000..68820f5 --- /dev/null +++ b/demo/templates/frag_form_upsert.html.j2 @@ -0,0 +1,5 @@ +
+ + + +
diff --git a/demo/templates/frag_results.html.j2 b/demo/templates/frag_results.html.j2 new file mode 100644 index 0000000..bfb54e9 --- /dev/null +++ b/demo/templates/frag_results.html.j2 @@ -0,0 +1,18 @@ + + + + {% for field_name in field_names %} + + {% endfor %} + + + + {% for row in rows %} + + {% for field in row %} + + {% endfor %} + + {% endfor %} + +
{{ field_name }}
{{ field }}
diff --git a/demo/templates/page_error.html.j2 b/demo/templates/page_error.html.j2 new file mode 100644 index 0000000..3233283 --- /dev/null +++ b/demo/templates/page_error.html.j2 @@ -0,0 +1,5 @@ +{% extends "base.html.j2" %} + +{% block content %} +{% include "frag_error.html.j2" %} +{% endblock %} diff --git a/demo/templates/page_main.html.j2 b/demo/templates/page_main.html.j2 new file mode 100644 index 0000000..907275f --- /dev/null +++ b/demo/templates/page_main.html.j2 @@ -0,0 +1,11 @@ +{% extends "base.html.j2" %} + +{% block content %} +
+ {% include "frag_aside.html.j2" %} + +
+ {% include "frag_results.html.j2" %} +
+
+{% endblock %} -- cgit v1.3