├── .dockerignore ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── auth.py ├── frontend │ ├── base.html │ ├── create_dashboard.html │ ├── dashboard.html │ ├── db_details.html │ ├── index.html │ ├── js │ │ ├── code_editor.js │ │ ├── commit_links.js │ │ ├── dashboard_creation.js │ │ ├── table.js │ │ ├── viz.js │ │ └── viz_control.js │ ├── partial_dashboard_entry.html │ ├── partial_error.html │ ├── partial_html_table.html │ ├── partial_html_table_interactive.html │ ├── partial_query_links.html │ ├── partial_resources.html │ ├── partial_result.html │ ├── query.html │ ├── report.html │ ├── report.txt │ └── static │ │ ├── css │ │ ├── font.css │ │ ├── gitbi.css │ │ ├── github.min.css │ │ ├── grids-responsive.min.css │ │ └── pure.min.css │ │ ├── favicon.ico │ │ └── js │ │ ├── codejar.min.js │ │ ├── echarts.min.js │ │ ├── highlight │ │ ├── core.min.js │ │ └── sql.min.js │ │ ├── htmx-response-targets.min.js │ │ ├── htmx.min.js │ │ └── simple-datatables.min.js ├── mailer.py ├── main.py ├── query.py ├── repo.py ├── routes_dashboard.py ├── routes_execute.py ├── routes_listing.py ├── routes_query.py └── utils.py ├── pytest.ini ├── requirements.txt ├── screenshots.md ├── start_app.sh └── tests ├── __init__.py ├── conftest.py ├── test_app.py ├── test_query.py └── test_repo.py /.dockerignore: -------------------------------------------------------------------------------- 1 | gitbivenv 2 | gitbi-example 3 | **/__pycache__ 4 | **/.pytest_cache 5 | .vscode 6 | tests 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gitbivenv 2 | gitbi-example 3 | **/__pycache__ 4 | **/.pytest_cache 5 | .vscode 6 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/gitbi-testing"] 2 | path = tests/gitbi-testing 3 | url = https://github.com/ppatrzyk/gitbi-testing.git 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.7-slim 2 | RUN apt-get update \ 3 | && apt-get install -y \ 4 | git \ 5 | --no-install-recommends \ 6 | && apt-get clean \ 7 | && rm -rf /var/lib/apt/lists/* 8 | COPY . ./gitbi 9 | RUN pip3 install -r gitbi/requirements.txt 10 | 11 | RUN useradd -U gitbiuser \ 12 | && chown -R gitbiuser:gitbiuser ./gitbi 13 | USER gitbiuser 14 | WORKDIR /gitbi 15 | EXPOSE 8000 16 | CMD [ "./start_app.sh" ] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Piotr Patrzyk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gitbi 2 | 3 | _Gitbi_ is a lightweight BI web app that uses git repository as a source of saved queries, visualizations and dashboards. Everything is stored as text files which can be easily accessed and edited outside of the app (in addition to using web interface). 4 | 5 | Features: 6 | 7 | - You can write queries using either SQL or [PRQL](https://github.com/PRQL/prql) 8 | - Interactive visualizations with [ECharts](https://github.com/apache/echarts) 9 | - Mailing reports and alerts 10 | - Currently supported DBs: clickhouse, duckdb (query csv files), postgresql, sqlite 11 | 12 | Test it now with sample dbs and config: 13 | 14 | ``` 15 | docker run -p 8000:8000 pieca/gitbi:latest 16 | ``` 17 | 18 | Or view [screenshots](screenshots.md). 19 | 20 | See full deployment example: [ppatrzyk/gitbi-example](https://github.com/ppatrzyk/gitbi-example). 21 | 22 | ## Configuration 23 | 24 | _Gitbi_ requires the following to run: 25 | 26 | ### Config repository 27 | 28 | Repository needs to have the following structure: 29 | - directories in repo root refer to databases 30 | - files in each directory are queries/visualizations to be run against respective database 31 | - files with `.sql` or `.prql` extension are queries 32 | - (optional) files with `.json` extension are saved visualizations 33 | - (optional) special directory `_dashboards` that contains dashboard specifications (`.json` format) 34 | - (optional) README.md file content will be displayed on _Gitbi_ main page 35 | 36 | ### Environment variables 37 | 38 | Name | Description 39 | --- | --- 40 | GITBI\_REPO\_DIR | Path to the repository 41 | GITBI\_\_CONN | Connection string 42 | GITBI\_\_TYPE | Database type (see below for permissible values) 43 | GITBI\_AUTH | (Optional) List of users (`"user1:password1, user2:password2"`), if set, Basic HTTP Auth (RFC 7617) required for all calls 44 | GITBI\_SMTP\_USER | (Optional) SMTP user 45 | GITBI\_SMTP\_PASS | (Optional) SMTP password 46 | GITBI\_SMTP\_URL | (Optional) SMTP server (`"smtp.example.com:587"`) 47 | GITBI\_SMTP\_EMAIL | (Optional) SMTP email to send from 48 | 49 | Following database types are supported: 50 | 51 | Type (value of GITBI\_\_TYPE) | Connection string format (GITBI\_\_CONN) 52 | --- | --- 53 | clickhouse | `clickhouse://[login]:[password]@[host]:[port]/[database]` 54 | duckdb | path to db file (or `:memory:`) 55 | postgres | `postgresql://[userspec@][hostspec][/dbname][?paramspec]` 56 | sqlite | path to db file (or `:memory:`) 57 | 58 | ### Example 59 | 60 | Assume you have repository with the following structure: 61 | 62 | ``` 63 | repo 64 | ├── _dashboards 65 | │ └── my_dashboard.json 66 | ├── db1 67 | │ ├── query1.sql 68 | │ ├── query2.sql 69 | │ └── query2.sql.json 70 | ├── db2 71 | │ ├── query3.sql 72 | │ ├── query3.sql.json 73 | │ ├── query4.sql 74 | │ └── query5.sql 75 | └── README.md 76 | ``` 77 | 78 | There are 2 databases named _db1_ and _db2_. _db1_ has 2 queries, one of them has also visualization; _db2_ has 3 queries, 1 with added visualization. There is also one dashboard called _my_dashboard.json_. 79 | 80 | For configuration you'd need to set the following environment variables: 81 | 82 | ``` 83 | GITBI_REPO_DIR= 84 | GITBI_DB1_CONN= 85 | GITBI_DB1_TYPE= 86 | GITBI_DB2_CONN= 87 | GITBI_DB2_TYPE= 88 | ``` 89 | 90 | ## Config formatting 91 | 92 | ### Visualization 93 | 94 | Visualization is a JSON file with the following format: 95 | 96 | ``` 97 | { 98 | "type": "scatter|line|bar|heatmap", 99 | "xaxis": , 100 | "yaxis": , 101 | "zaxis": , 102 | "group": 103 | } 104 | ``` 105 | 106 | ### Dashboard 107 | 108 | Dashboard is a JSON file with the following format, list can have any number of entries: 109 | 110 | ``` 111 | [ 112 | [ 113 | "", 114 | "" 115 | ], 116 | [ 117 | "", 118 | "" 119 | ], 120 | ... 121 | ] 122 | ``` 123 | 124 | ### Reporting 125 | 126 | For every query, you have a _report_ endpoint that provides query results in html and text formats, as well as data only csv and json. This endpoint accepts two optional query parameters: 127 | 128 | - `mail`: if this is not empty, gitbi will send an email with the result to specified address 129 | - `alert`: if this is not empty, gitbi will send results via email only if there are some rows returned. Write your alert queries in a way that they usually do not return anything, but you want to be notified when they do. This parameter makes sense only together with email. 130 | 131 | Scheduling is possible via any external service, most commonly CRON. Example: 132 | 133 | ``` 134 | # Report sent at 6am 135 | 0 6 * * * curl -s -u : ?mail= 136 | 137 | # Alert checked every minute 138 | * * * * * curl -s -u : ?alert=true&mail= 139 | ``` 140 | 141 | If you don't want to setup email credentials in _Gitbi_, you can still use CRON to to send reports with other tools. Examples: 142 | 143 | ``` 144 | # HTML report via sendmail 145 | * * * * * echo -e "Subject: Gitbi report\nContent-Type: text/html\n\n$(curl -s -u : )" | /usr/sbin/sendmail -f 146 | 147 | # HTML report via mailgun api 148 | * * * * * curl -X POST --user "api:" --data-urlencode from= --data-urlencode to= --data-urlencode subject="Gitbi report" --data-urlencode html="$(curl -s -u : )" https://api.eu.mailgun.net/v3/SENDER_DOMAIN/messages 149 | ``` 150 | 151 | You can copy `report_url` from every query page. 152 | 153 | ## Repo setup 154 | 155 | The easiest way to run _Gitbi_ is to set up a repository at the same server the app is running, and then sync changes into your local repo via ssh. This requires setting proper permissions for everything to work smoothly. Example setup: 156 | 157 | ``` 158 | # initialize as shared repo 159 | # the command below allows any user in group to push into repo, for other options see https://git-scm.com/docs/git-init 160 | git init --shared=group 161 | chgrp -R 162 | chmod g+rwxs 163 | # enable pushing to checked out branch 164 | git config receive.denyCurrentBranch updateInstead 165 | ``` 166 | 167 | ## Development 168 | 169 | ``` 170 | # install dependencies 171 | pip3 install -r requirements.txt 172 | 173 | # run with sample repo 174 | ./start_app.sh 175 | 176 | # run with testing repo 177 | GITBI_REPO_DIR="./tests/gitbi-testing" GITBI_SQLITE_CONN="$(realpath ./tests/gitbi-testing/db.sqlite)" GITBI_SQLITE_TYPE=sqlite ./start_app.sh 178 | 179 | # build image 180 | docker build -t pieca/gitbi: . 181 | ``` 182 | 183 | ## Some alternatives 184 | 185 | - generate static html reports using Python: [merkury](https://github.com/ppatrzyk/merkury) 186 | - create custom dashboards using SQL and markdown: [evidence](https://github.com/evidence-dev/evidence) 187 | - analyze single sqlite db: [datasette](https://github.com/simonw/datasette) 188 | - run SQL queries from your browser: [sqlpad](https://github.com/sqlpad/sqlpad) 189 | - full-blown BI solution: [metabase](https://github.com/metabase/metabase) 190 | 191 | ## Acknowledgements 192 | 193 | Backend: 194 | - [clickhouse-driver](https://github.com/mymarilyn/clickhouse-driver) 195 | - [duckdb](https://github.com/duckdb/duckdb/tree/master/tools/pythonpkg) 196 | - [jinja](https://github.com/pallets/jinja/) 197 | - [markdown](https://github.com/Python-Markdown/markdown) 198 | - [prettytable](https://github.com/jazzband/prettytable) 199 | - [prql](https://github.com/PRQL/prql/tree/main/bindings/prql-python) 200 | - [psycopg](https://github.com/psycopg/psycopg) 201 | - [pygit2](https://github.com/libgit2/pygit2) 202 | - [sqlparse](https://github.com/andialbrecht/sqlparse) 203 | - [starlette](https://github.com/encode/starlette) 204 | - [uvicorn](https://github.com/encode/uvicorn) 205 | 206 | Frontend: 207 | - [codejar](https://github.com/antonmedv/codejar) 208 | - [ECharts](https://github.com/apache/echarts) 209 | - [Font Awesome](https://iconscout.com/contributors/font-awesome) 210 | - [highlight](https://github.com/highlightjs/highlight.js) 211 | - [htmx](https://github.com/bigskysoftware/htmx) 212 | - [pure.css](https://github.com/pure-css/pure) 213 | - [simple-datatables](https://github.com/fiduswriter/simple-datatables) 214 | - [ubuntu font](https://ubuntu.com/legal/font-licence) 215 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ppatrzyk/gitbi/8f620634d516907f4ed040eea40e52c17ae73ba6/app/__init__.py -------------------------------------------------------------------------------- /app/auth.py: -------------------------------------------------------------------------------- 1 | """ 2 | Basic Auth for app 3 | https://www.starlette.io/authentication/ 4 | """ 5 | from starlette.authentication import ( 6 | AuthCredentials, AuthenticationBackend, AuthenticationError, SimpleUser 7 | ) 8 | from starlette.middleware import Middleware 9 | from starlette.middleware.authentication import AuthenticationMiddleware 10 | from starlette.responses import PlainTextResponse 11 | import base64 12 | import logging 13 | import repo 14 | 15 | class BasicAuthBackend(AuthenticationBackend): 16 | def __init__(self, users): 17 | self.users = users 18 | async def authenticate(self, conn): 19 | try: 20 | auth = conn.headers.get("Authorization") 21 | assert auth is not None, "Auth not provided" 22 | scheme, credentials = auth.split() 23 | assert scheme.lower() == "basic", "Basic auth required" 24 | decoded = base64.b64decode(credentials).decode("ascii") 25 | user, _pass = decoded.split(":") 26 | assert decoded in self.users, "Bad user" 27 | except Exception as e: 28 | raise AuthenticationError(f"Auth error: {str(e)}") 29 | else: 30 | return AuthCredentials(["authenticated"]), SimpleUser(user) 31 | 32 | def _auth_challenge(request, exception): 33 | """ 34 | Ask for Basic auth password 35 | """ 36 | headers = {"WWW-Authenticate": "Basic realm=Gitbi access"} 37 | return PlainTextResponse(status_code=401, headers=headers, content=str(exception)) 38 | 39 | try: 40 | users = repo.get_auth() 41 | parsed = (entry.split(":") for entry in users) 42 | user_names = [] 43 | for parsed_entry, raw_entry in zip(parsed, users): 44 | assert len(parsed_entry) == 2, f"Malformed entry: {raw_entry}" 45 | user_names.append(parsed_entry[0]) 46 | AUTH = Middleware(AuthenticationMiddleware, backend=BasicAuthBackend(users), on_error=_auth_challenge) 47 | USERS = parsed 48 | logging.info(f"{len(user_names)} users: {', '.join(user_names)}") 49 | except Exception as e: 50 | AUTH = None 51 | USERS = tuple() 52 | logging.warning(f"Auth not defined: {str(e)}") 53 | -------------------------------------------------------------------------------- /app/frontend/base.html: -------------------------------------------------------------------------------- 1 | {# https://jinja.palletsprojects.com/en/3.0.x/templates/#base-template #} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 22 | {% block extendhead %}{% endblock %} 23 | Gitbi | {% block title %}{% endblock %} 24 | 25 | 26 |
27 | 61 |
62 |
63 |
64 |
65 |
66 |
67 | {% block content %}{% endblock %} 68 |
69 |
70 |
71 |
72 | 73 | 74 | -------------------------------------------------------------------------------- /app/frontend/create_dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}New Dashboard{% endblock %} 3 | {% block content %} 4 |
5 |

New dashboard

6 |
7 |
8 | {% for db, queries in databases.items() %} 9 | {% for query in queries %} 10 | 14 | {% endfor %} 15 | {% endfor %} 16 |
17 |
18 | 19 | 20 | Dashboard name must have .json extension 21 |
22 |
30 | Save dashboard 31 |
32 |
33 |
34 | {% endblock %} 35 | 36 | 37 |
38 | 45 | 46 |
-------------------------------------------------------------------------------- /app/frontend/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Dashboard | {{ file }}{% endblock %} 3 | {% block extendhead %} 4 | 5 | 8 | {% endblock %} 9 | {% block content %} 10 |

{{ file }}

11 |
18 | Delete this dashboard 19 |
20 |
21 | {% for db, query, id in dashboard_conf %} 22 | 32 |
33 | {% endfor %} 34 | {% endblock %} -------------------------------------------------------------------------------- /app/frontend/db_details.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Home{% endblock %} 3 | {% block content %} 4 |

Data docs: {{ db }}

5 |
6 | 7 | 8 |
9 | {{ data_docs }} 10 |
11 | 12 | 13 | 14 |
15 |
    16 | {% for table in tables %} 17 |
  • {{ table }}
  • 18 | {% endfor %} 19 |
20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /app/frontend/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %}Home{% endblock %} 3 | {% block content %} 4 |
5 | 6 | 7 |
8 | {% if readme is not none %} 9 |
{{ readme }}
10 | {% else %} 11 |

Gitbi Home

12 |

[Readme file is missing]

13 | {% endif %} 14 |
15 | 16 | 17 | 18 |
19 |

Commits

20 | {{ commits_table }} 21 |
22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /app/frontend/js/code_editor.js: -------------------------------------------------------------------------------- 1 | import {CodeJar} from '{{ request.app.url_path_for("static", path="/js/codejar.min.js") }}' 2 | import hljs from '{{ request.app.url_path_for("static", path="/js/highlight/core.min.js") }}' 3 | import sql from '{{ request.app.url_path_for("static", path="/js/highlight/sql.min.js") }}' 4 | hljs.configure({ignoreUnescapedHTML: true}); 5 | hljs.registerLanguage('sql', sql); 6 | var query_editor = document.getElementById("query-editor"); 7 | window.query_jar = CodeJar(query_editor, hljs.highlightElement); 8 | function query_format() { 9 | var file_name = document.getElementById("file-name").value.trim(); 10 | var format = document.getElementById("result-format").value.trim(); 11 | var viz = JSON.stringify(window.get_chart_options()); 12 | var data = {query: query_jar.toString(), viz: viz, file: file_name, format: format, echart_id: '{{ echart_id }}'}; 13 | return JSON.stringify(data) 14 | } 15 | window.query_format = query_format; 16 | function generate_link() { 17 | var data = JSON.parse(query_format()); 18 | delete data.echart_id; 19 | var path = window.location.pathname.split("/").slice(0, 3).join("/"); 20 | var query = "?"; 21 | for (let [key, value] of Object.entries(data)) { 22 | query += `${encodeURIComponent(key)}=${encodeURIComponent(value)}&`; 23 | } 24 | var url = `${window.location.origin}${path}${query}`; 25 | alert(url); 26 | } 27 | window.generate_link = generate_link; -------------------------------------------------------------------------------- /app/frontend/js/commit_links.js: -------------------------------------------------------------------------------- 1 | var table = document.querySelector("#commits-table tbody"); 2 | for (let row of table.getElementsByTagName("tr")) { 3 | try { 4 | var commit_cell = row.getElementsByTagName("td")[0]; 5 | var hash = commit_cell.innerText 6 | commit_cell.innerHTML = `${hash}` 7 | } catch (error) { 8 | console.error("Failed to make links in commits table") 9 | console.error(error) 10 | } 11 | } -------------------------------------------------------------------------------- /app/frontend/js/dashboard_creation.js: -------------------------------------------------------------------------------- 1 | function dashboard_format() { 2 | var file_name = document.getElementById("dashboard-file-name").value.trim(); 3 | var fieldset = document.getElementById('dashboard-choices'); 4 | var queries = Array.from(fieldset.getElementsByTagName('input')).filter(e => e.checked).map(e => e.id); 5 | var data = {queries: queries, file: file_name}; 6 | return JSON.stringify(data) 7 | } -------------------------------------------------------------------------------- /app/frontend/js/table.js: -------------------------------------------------------------------------------- 1 | function html_escape(str) { 2 | return new Option(str).innerHTML; 3 | } 4 | function create_table(table_id, data) { 5 | var table = document.getElementById(table_id); 6 | var perPageSelect = (data.data.length <= 25) ? false :[10, 25, 50, 100]; 7 | data.headings = data.headings.map(e => html_escape(e)) 8 | data.data = data.data.map(row => row.map(e => html_escape(e))) 9 | var data_table = new simpleDatatables.DataTable(table, { 10 | data: data, 11 | perPage: 25, 12 | perPageSelect: perPageSelect, 13 | classes: { 14 | top: "pure-form", 15 | bottom: "datatable-container", 16 | table: "pure-table pure-table-striped", 17 | search: "datatable-search bottom-margin", 18 | dropdown: "datatable-dropdown bottom-margin" 19 | }, 20 | type: "html", 21 | layout: { 22 | top: "{search}", 23 | bottom: "{select}{info}{pager}" 24 | }, 25 | }); 26 | } -------------------------------------------------------------------------------- /app/frontend/js/viz.js: -------------------------------------------------------------------------------- 1 | function get_val(row, index, dtypes) { 2 | return (dtypes[index] === 'time') ? new Date(row[index]) : row[index]; 3 | } 4 | function format_row(row, chart_options, current_data) { 5 | var x = get_val(row, chart_options.x_index, current_data.dtypes); 6 | var y = get_val(row, chart_options.y_index, current_data.dtypes); 7 | var z = get_val(row, chart_options.z_index, current_data.dtypes); 8 | return [x, y, z] 9 | } 10 | function series_single(current_data, chart_options) { 11 | var data = current_data.data.map(row => format_row(row, chart_options, current_data)); 12 | var series = [{data: data, type: chart_options.type, name: chart_options.yaxis}]; 13 | return series 14 | } 15 | function series_multi(current_data, chart_options) { 16 | var series = {}; 17 | current_data.data.forEach(row => { 18 | if (series[row[chart_options.group_index]] === undefined) { 19 | series[row[chart_options.group_index]] = {data: [format_row(row, chart_options, current_data), ], type: chart_options.type, name: chart_options.group}; 20 | } else { 21 | series[row[chart_options.group_index]].data.push(format_row(row, chart_options, current_data)); 22 | } 23 | }); 24 | series = Object.keys(series).map(k => {return Object.assign({}, series[k], {name: k})}); 25 | return series 26 | } 27 | function create_viz(current_data, chart_options, chart_el) { 28 | var axis_opts = {nameLocation: 'middle', nameTextStyle: {padding: 20}, splitArea: {show: true}, } 29 | if (['line', 'scatter'].includes(chart_options.type)) { 30 | axis_opts.min = function (value) {return value.min - ((value.max-value.min)*0.1);}; 31 | axis_opts.max = function (value) {return value.max + ((value.max-value.min)*0.1);}; 32 | } 33 | chart_options.x_index = current_data.headings.indexOf(chart_options.xaxis); 34 | chart_options.y_index = current_data.headings.indexOf(chart_options.yaxis); 35 | chart_options.z_index = current_data.headings.indexOf(chart_options.zaxis); 36 | chart_options.group_index = current_data.headings.indexOf(chart_options.group); 37 | chart_el.replaceChildren(); 38 | chart_el.removeAttribute('_echarts_instance_') 39 | chart_el.style.width = `${chart_el.offsetWidth}px`; 40 | chart_el.style.height = `${Math.floor(chart_el.offsetWidth * 0.5)}px`; 41 | var title = `${chart_options.xaxis} x ${chart_options.yaxis}`; 42 | if (chart_options.group === '_NONE') { 43 | var series = series_single(current_data, chart_options); 44 | } else { 45 | if (chart_options.type === 'heatmap') { 46 | throw new Error('group does not make sense for this chart type'); 47 | } 48 | var series = series_multi(current_data, chart_options); 49 | } 50 | var echarts_conf = { 51 | legend: {show: true, top: 20, }, 52 | toolbox: {show: true, feature: {saveAsImage: {show: true}}}, 53 | tooltip: {show: true, triggerOn: "mousemove", }, 54 | title: {show: true, text: title}, 55 | textStyle: {fontFamily: 'Ubuntu', fontSize: 16}, 56 | xAxis: Object.assign({}, {type: current_data.dtypes[chart_options.x_index], name: chart_options.xaxis}, axis_opts), 57 | yAxis: Object.assign({}, {type: current_data.dtypes[chart_options.y_index], name: chart_options.yaxis}, axis_opts), 58 | series: series, 59 | }; 60 | if (chart_options.zaxis !== '_NONE') { 61 | var in_range; 62 | if (chart_options.type === 'heatmap') { 63 | echarts_conf.series.forEach(el => el['label'] = {show: true}); 64 | in_range = {color: ['rgb(252, 255, 164)', 'rgb(249, 142, 9)', 'rgb(188, 55, 84)', 'rgb(87, 16, 110)', 'rgb(0, 0, 4)']}; 65 | } else if (['line', 'scatter'].includes(chart_options.type)) { 66 | in_range = {symbolSize: [10, 60]} 67 | } else { 68 | throw new Error('z axis does not make sense for this chart type'); 69 | } 70 | var z_data = series.map(el => el.data).flat().map(el => el[2]) 71 | echarts_conf['visualMap'] = {min: Math.min(...z_data), max: Math.max(...z_data) , type: 'continuous', dimension: 2, inRange: in_range}; 72 | } 73 | var chart = echarts.init(chart_el); 74 | chart.setOption(echarts_conf); 75 | } -------------------------------------------------------------------------------- /app/frontend/js/viz_control.js: -------------------------------------------------------------------------------- 1 | var chart_id = '{{ echart_id }}' 2 | var current_data = null; 3 | var initial_viz = true; 4 | var saved_viz = {{ viz }}; 5 | 6 | function array_ident(arr1, arr2) { 7 | // https://stackoverflow.com/a/19746771 8 | return (arr1.length === arr2.length && arr1.every((value, index) => value === arr2[index])) 9 | } 10 | function get_chart_options() { 11 | var chart_options = { 12 | type: document.getElementById('echart-options-type').value, 13 | xaxis: document.getElementById('echart-options-xaxis').value, 14 | yaxis: document.getElementById('echart-options-yaxis').value, 15 | zaxis: document.getElementById('echart-options-zaxis').value, 16 | group: document.getElementById('echart-options-group').value, 17 | } 18 | return chart_options 19 | } 20 | window.get_chart_options = get_chart_options; 21 | function update_chart_options() { 22 | var select_ids = ['echart-options-xaxis', 'echart-options-yaxis', 'echart-options-zaxis','echart-options-group', ]; 23 | var headings = Array.from(document.getElementById(select_ids[0]).getElementsByTagName('option')).map((node) => node.value) 24 | var new_headings = ['_NONE', ].concat(current_data.headings); 25 | if (!array_ident(headings, new_headings)) { 26 | select_ids.forEach(id => { 27 | var columns = new_headings.map((name) => { 28 | var entry = document.createElement("option"); 29 | entry.setAttribute('value', name); 30 | entry.innerText = name; 31 | return entry 32 | }) 33 | document.getElementById(id).replaceChildren(...columns); 34 | }); 35 | if (initial_viz) { 36 | initial_viz = false; 37 | if (saved_viz !== null) { 38 | document.getElementById('echart-options-type').value = saved_viz.type; 39 | document.getElementById('echart-options-xaxis').value = saved_viz.xaxis; 40 | document.getElementById('echart-options-yaxis').value = saved_viz.yaxis; 41 | document.getElementById('echart-options-zaxis').value = saved_viz.zaxis; 42 | document.getElementById('echart-options-group').value = saved_viz.group; 43 | } 44 | } else { 45 | select_ids.forEach(id => { 46 | document.getElementById(id).value = "_NONE"; 47 | }) 48 | document.getElementById('echart-options-type').value = "scatter" 49 | } 50 | } 51 | } 52 | function make_viz() { 53 | // this function wraps viz creation for query page 54 | try { 55 | var chart_el = document.getElementById(chart_id); 56 | var chart_options = get_chart_options(); 57 | if (current_data === null || current_data.data.length === 0) { 58 | document.getElementById('echart-note').classList.remove("hidden"); 59 | document.getElementById('echart-chart').classList.add("hidden"); 60 | throw new Error('no data available'); 61 | } else { 62 | document.getElementById('echart-note').classList.add("hidden"); 63 | document.getElementById('echart-chart').classList.remove("hidden"); 64 | create_viz(current_data, chart_options, chart_el); 65 | } 66 | } catch (error) { 67 | console.error(`Failed to draw chart`); 68 | console.error(error); 69 | alert(error) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/frontend/partial_dashboard_entry.html: -------------------------------------------------------------------------------- 1 | {# partial html - this is used by htmx inserting one query into dashboard #} 2 |

{{ db|e }} | {{ file|e }}

3 |

4 | Returned {{ no_rows|e }} rows in {{ duration|e }}ms
5 | Executed on {{ time|e }} 6 |

7 | {% include 'partial_query_links.html' %} 8 |
9 | 10 | 11 |
12 |
13 | 19 |
20 | 21 | 22 | 23 |
24 | {{ table }} 25 |
26 |
-------------------------------------------------------------------------------- /app/frontend/partial_error.html: -------------------------------------------------------------------------------- 1 | {# partial html - used as is by htmx or inserted into "full" error pages #} 2 | -------------------------------------------------------------------------------- /app/frontend/partial_html_table.html: -------------------------------------------------------------------------------- 1 | {# partial html - this is used to generate html table #} 2 | 3 | 4 | {% for col in col_names %}{% endfor %} 5 | 6 | 7 | {% for row in data %} 8 | {% for entry in row %}{% endfor %} 9 | {% endfor %} 10 | 11 |
{{ col|e }}
{{ entry|e }}
-------------------------------------------------------------------------------- /app/frontend/partial_html_table_interactive.html: -------------------------------------------------------------------------------- 1 | {# partial html - this is used to generate simpledatatables table #} 2 |
3 | -------------------------------------------------------------------------------- /app/frontend/partial_query_links.html: -------------------------------------------------------------------------------- 1 | {# partial html - listing query links #} 2 |
3 | Query: {{ file|e }} 4 |
5 | Report: 6 | html | 7 | text | 8 | csv | 9 | json 10 |
-------------------------------------------------------------------------------- /app/frontend/partial_resources.html: -------------------------------------------------------------------------------- 1 |
2 |

Dashboards

3 | new dashboard 4 | 9 | {% for db, queries in databases.items() %} 10 |
11 |

{{ db|e }}

12 |
13 | new query 14 | docs 15 |
16 |
    17 | {% for query in queries %} 18 |
  • {{ query|e }}
  • 19 | {% endfor %} 20 |
21 |
22 | {% endfor %} 23 |
-------------------------------------------------------------------------------- /app/frontend/partial_result.html: -------------------------------------------------------------------------------- 1 | {# partial html - this is used by htmx passing result #} 2 |

3 | Returned {{ no_rows|e }} rows in {{ duration|e }}ms
4 | Executed on {{ time|e }} 5 |

6 | {# table also contains js script that replaces current data in document #} 7 | {{ table }} 8 | -------------------------------------------------------------------------------- /app/frontend/query.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block extendhead %} 3 | 4 | 5 | 8 | 12 | {% endblock %} 13 | {% block title %}Query | {{ db|e }}{% if file != "__empty__" %}| {{ file }}{% endif %}{% endblock %} 14 | {% block content %} 15 |

{{ db|e }}{% if file != "__empty__" %} | {{ file }}{% endif %}

16 | {% if file != "__empty__" %}{% include 'partial_query_links.html' %}{% endif %} 17 |
18 | 19 |
{{ query }}
20 |
21 |
22 |
23 | 24 | 25 | Query name must have .sql or .prql extension 26 |
27 |
28 | 29 | 36 |
37 |
38 |
39 |
48 | Run 49 |
50 |
58 | Save query 59 |
60 |
64 | Generate share link 65 |
66 | {% if file != "__empty__" %} 67 |
74 | Delete query 75 |
76 | {% endif %} 77 |
78 |
79 |

Result

80 |
81 |

No data available

82 |

Running query...

83 |
84 |

Visualization

85 |
No data available
86 | 114 |
115 | {% endblock %} 116 | -------------------------------------------------------------------------------- /app/frontend/report.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Gitbi Report 8 | 9 | 10 |
11 |

Gitbi Report

12 |

{{ db|e }} | {{ file|e }} | {{ state|e }}

13 |

14 | Returned {{ no_rows|e }} rows in {{ duration|e }}ms
15 | Executed on {{ time|e }}
16 |

17 | {% include 'partial_query_links.html' %} 18 |

Query

19 |
{{ query_str }}
20 |

Result

21 | {{ table }} 22 |
23 | 24 | -------------------------------------------------------------------------------- /app/frontend/report.txt: -------------------------------------------------------------------------------- 1 | {{ db|e }} | {{ file|e }} | {{ state|e }} 2 | 3 | Returned {{ no_rows|e }} rows in {{ duration|e }}ms 4 | Executed on {{ time|e }} 5 | 6 | Query 7 | 8 | {{ query_str }} 9 | 10 | Result 11 | 12 | {{ table }} -------------------------------------------------------------------------------- /app/frontend/static/css/gitbi.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* https://meodai.github.io/poline/ */ 3 | --blue0: #f0f4f8; 4 | --blue1: #cfd8e6; 5 | --blue2: #a6b4ce; 6 | --blue3: #657594; 7 | --black: #101820; 8 | --font-size: 18px; 9 | --font-family: "Ubuntu"; 10 | } 11 | body { 12 | color: var(--black); 13 | font-size: var(--font-size); 14 | font-family: var(--font-family); 15 | } 16 | h1 { 17 | font-size: 2em; 18 | } 19 | h2 { 20 | font-size: 1.5em; 21 | } 22 | h3 { 23 | font-size: 1em; 24 | } 25 | a { 26 | color: var(--blue3); 27 | text-decoration: none; 28 | } 29 | a:hover { 30 | color: var(--black); 31 | } 32 | hr { 33 | color: var(--blue1); 34 | } 35 | .pure-button { 36 | background-color: var(--blue1); 37 | } 38 | .sidebar { 39 | background-color: var(--blue0); 40 | } 41 | .mylist li::marker { 42 | content: '\25b8 '; 43 | } 44 | .mylist li { 45 | padding-left: 0.5em; 46 | margin-bottom: 0.5em; 47 | } 48 | .mylist { 49 | padding-left: 1em; 50 | margin-bottom: 2em; 51 | } 52 | .pure-table { 53 | margin-bottom: 1em; 54 | } 55 | .pure-table thead { 56 | background-color: var(--blue1); 57 | } 58 | .pure-table-striped tr:nth-child(2n-1) td { 59 | background-color: var(--blue0); 60 | } 61 | .container { 62 | width: 100%; 63 | margin: 2em; 64 | } 65 | .bottom-margin { 66 | margin-bottom: 1em; 67 | } 68 | .icon { 69 | height: 1em; 70 | width: 1em; 71 | vertical-align: middle; 72 | border: 0; 73 | margin-right: 0.25em; 74 | } 75 | .icon-dashboard { 76 | content: url(data:image/svg+xml;utf8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNzkyIiBoZWlnaHQ9IjE3OTIiPjxwYXRoIGQ9Ik0xNzI4IDk5MlYxNjBxMC0xMy05LjUtMjIuNVQxNjk2IDEyOEg5NnEtMTMgMC0yMi41IDkuNVQ2NCAxNjB2ODMycTAgMTMgOS41IDIyLjVUOTYgMTAyNGgxNjAwcTEzIDAgMjIuNS05LjV0OS41LTIyLjV6bTEyOC04MzJ2MTA4OHEwIDY2LTQ3IDExM3QtMTEzIDQ3aC01NDRxMCAzNyAxNiA3Ny41dDMyIDcxIDE2IDQzLjVxMCAyNi0xOSA0NXQtNDUgMTlINjQwcS0yNiAwLTQ1LTE5dC0xOS00NXEwLTE0IDE2LTQ0dDMyLTcwIDE2LTc4SDk2cS02NiAwLTExMy00N3QtNDctMTEzVjE2MHEwLTY2IDQ3LTExM1Q5NiAwaDE2MDBxNjYgMCAxMTMgNDd0NDcgMTEzeiIvPjwvc3ZnPg==); 77 | } 78 | .icon-db { 79 | content: url(data:image/svg+xml;utf8;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNzkyIiBoZWlnaHQ9IjE3OTIiPjxwYXRoIGQ9Ik04OTYgNzY4cTIzNyAwIDQ0My00M3QzMjUtMTI3djE3MHEwIDY5LTEwMyAxMjh0LTI4MCA5My41LTM4NSAzNC41LTM4NS0zNC41VDIzMSA4OTYgMTI4IDc2OFY1OThxMTE5IDg0IDMyNSAxMjd0NDQzIDQzem0wIDc2OHEyMzcgMCA0NDMtNDN0MzI1LTEyN3YxNzBxMCA2OS0xMDMgMTI4dC0yODAgOTMuNS0zODUgMzQuNS0zODUtMzQuNS0yODAtOTMuNS0xMDMtMTI4di0xNzBxMTE5IDg0IDMyNSAxMjd0NDQzIDQzem0wLTM4NHEyMzcgMCA0NDMtNDN0MzI1LTEyN3YxNzBxMCA2OS0xMDMgMTI4dC0yODAgOTMuNS0zODUgMzQuNS0zODUtMzQuNS0yODAtOTMuNS0xMDMtMTI4Vjk4MnExMTkgODQgMzI1IDEyN3Q0NDMgNDN6TTg5NiAwcTIwOCAwIDM4NSAzNC41dDI4MCA5My41IDEwMyAxMjh2MTI4cTAgNjktMTAzIDEyOHQtMjgwIDkzLjVUODk2IDY0MHQtMzg1LTM0LjVUMjMxIDUxMiAxMjggMzg0VjI1NnEwLTY5IDEwMy0xMjh0MjgwLTkzLjVUODk2IDB6Ii8+PC9zdmc+); 80 | } 81 | .hidden { 82 | display: none; 83 | } 84 | .footer { 85 | text-align: center; 86 | margin-top: 4rem; 87 | } 88 | .code-editor { 89 | /* https://github.com/antonmedv/codejar#getting-started */ 90 | font-family: 'Source Code Pro', monospace; 91 | font-weight: 400; 92 | min-height: 100px; 93 | min-width: 300px; 94 | letter-spacing: normal; 95 | line-height: 20px; 96 | tab-size: 4; 97 | } 98 | .text-result { 99 | width: fit-content; 100 | background-color: var(--blue0); 101 | padding: 1em; 102 | border-style: solid; 103 | border-width: 1px; 104 | border-radius: 5px; 105 | border-color: var(--blue1); 106 | box-shadow: inset 0 1px 3px #ddd; 107 | } 108 | .datatable-container button { 109 | background-color: transparent; 110 | border: none; 111 | } 112 | .datatable-sorter { 113 | font-weight: bold; 114 | color: var(--blue3); 115 | } 116 | .datatable-sorter:hover { 117 | color: var(--black); 118 | } 119 | .datatable-sorter:after { 120 | content: ' \21c5'; 121 | } 122 | .datatable-search { 123 | margin-bottom: 1em; 124 | } 125 | .datatable-pagination-list { 126 | padding: 0; 127 | } 128 | .datatable-pagination-list-item { 129 | display: inline; 130 | padding: 0.5em; 131 | } 132 | .datatable-pagination-list-item:hover { 133 | background-color: var(--blue0); 134 | } 135 | .datatable-pagination-list-item.datatable-active { 136 | background-color: var(--blue1); 137 | } 138 | /* https://codepen.io/alvarotrigo/pen/RwLzvQz */ 139 | .tabs { 140 | display: flex; 141 | flex-wrap: wrap; 142 | } 143 | .tabs-label { 144 | order: 1; 145 | display: block; 146 | font-size: 1.5em; 147 | padding: 1rem 2rem; 148 | margin-right: 0.2rem; 149 | cursor: pointer; 150 | font-weight: bold; 151 | transition: background ease 0.2s; 152 | } 153 | .tabs .tab { 154 | order: 99; 155 | flex-grow: 1; 156 | width: 100%; 157 | display: none; 158 | padding: 1rem; 159 | } 160 | .tabs input[type="radio"] { 161 | display: none; 162 | } 163 | .tabs input[type="radio"] + label { 164 | border-style: none none solid none; 165 | border-width: 2px; 166 | border-color: var(--blue1); 167 | color: var(--blue1); 168 | } 169 | .tabs input[type="radio"]:checked + label { 170 | color: var(--black); 171 | border-color: var(--black); 172 | } 173 | .tabs input[type="radio"]:checked + label + .tab { 174 | display: block; 175 | } 176 | @media (max-width: 45em) { 177 | .tabs .tab, 178 | .tabs label { 179 | order: initial; 180 | } 181 | .tabs label { 182 | width: 100%; 183 | margin-right: 0; 184 | margin-top: 0.2rem; 185 | } 186 | } -------------------------------------------------------------------------------- /app/frontend/static/css/github.min.css: -------------------------------------------------------------------------------- 1 | pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}/*! 2 | Theme: GitHub 3 | Description: Light theme as seen on github.com 4 | Author: github.com 5 | Maintainer: @Hirse 6 | Updated: 2021-05-15 7 | 8 | Outdated base version: https://github.com/primer/github-syntax-light 9 | Current colors taken from GitHub's CSS 10 | */.hljs{color:#24292e;background:#fff}.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-template-tag,.hljs-template-variable,.hljs-type,.hljs-variable.language_{color:#d73a49}.hljs-title,.hljs-title.class_,.hljs-title.class_.inherited__,.hljs-title.function_{color:#6f42c1}.hljs-attr,.hljs-attribute,.hljs-literal,.hljs-meta,.hljs-number,.hljs-operator,.hljs-selector-attr,.hljs-selector-class,.hljs-selector-id,.hljs-variable{color:#005cc5}.hljs-meta .hljs-string,.hljs-regexp,.hljs-string{color:#032f62}.hljs-built_in,.hljs-symbol{color:#e36209}.hljs-code,.hljs-comment,.hljs-formula{color:#6a737d}.hljs-name,.hljs-quote,.hljs-selector-pseudo,.hljs-selector-tag{color:#22863a}.hljs-subst{color:#24292e}.hljs-section{color:#005cc5;font-weight:700}.hljs-bullet{color:#735c0f}.hljs-emphasis{color:#24292e;font-style:italic}.hljs-strong{color:#24292e;font-weight:700}.hljs-addition{color:#22863a;background-color:#f0fff4}.hljs-deletion{color:#b31d28;background-color:#ffeef0} -------------------------------------------------------------------------------- /app/frontend/static/css/grids-responsive.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v3.0.0 3 | Copyright 2013 Yahoo! 4 | Licensed under the BSD License. 5 | https://github.com/pure-css/pure/blob/master/LICENSE 6 | */ 7 | @media screen and (min-width:35.5em){.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-1-12,.pure-u-sm-1-2,.pure-u-sm-1-24,.pure-u-sm-1-3,.pure-u-sm-1-4,.pure-u-sm-1-5,.pure-u-sm-1-6,.pure-u-sm-1-8,.pure-u-sm-10-24,.pure-u-sm-11-12,.pure-u-sm-11-24,.pure-u-sm-12-24,.pure-u-sm-13-24,.pure-u-sm-14-24,.pure-u-sm-15-24,.pure-u-sm-16-24,.pure-u-sm-17-24,.pure-u-sm-18-24,.pure-u-sm-19-24,.pure-u-sm-2-24,.pure-u-sm-2-3,.pure-u-sm-2-5,.pure-u-sm-20-24,.pure-u-sm-21-24,.pure-u-sm-22-24,.pure-u-sm-23-24,.pure-u-sm-24-24,.pure-u-sm-3-24,.pure-u-sm-3-4,.pure-u-sm-3-5,.pure-u-sm-3-8,.pure-u-sm-4-24,.pure-u-sm-4-5,.pure-u-sm-5-12,.pure-u-sm-5-24,.pure-u-sm-5-5,.pure-u-sm-5-6,.pure-u-sm-5-8,.pure-u-sm-6-24,.pure-u-sm-7-12,.pure-u-sm-7-24,.pure-u-sm-7-8,.pure-u-sm-8-24,.pure-u-sm-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-sm-1-24{width:4.1667%}.pure-u-sm-1-12,.pure-u-sm-2-24{width:8.3333%}.pure-u-sm-1-8,.pure-u-sm-3-24{width:12.5%}.pure-u-sm-1-6,.pure-u-sm-4-24{width:16.6667%}.pure-u-sm-1-5{width:20%}.pure-u-sm-5-24{width:20.8333%}.pure-u-sm-1-4,.pure-u-sm-6-24{width:25%}.pure-u-sm-7-24{width:29.1667%}.pure-u-sm-1-3,.pure-u-sm-8-24{width:33.3333%}.pure-u-sm-3-8,.pure-u-sm-9-24{width:37.5%}.pure-u-sm-2-5{width:40%}.pure-u-sm-10-24,.pure-u-sm-5-12{width:41.6667%}.pure-u-sm-11-24{width:45.8333%}.pure-u-sm-1-2,.pure-u-sm-12-24{width:50%}.pure-u-sm-13-24{width:54.1667%}.pure-u-sm-14-24,.pure-u-sm-7-12{width:58.3333%}.pure-u-sm-3-5{width:60%}.pure-u-sm-15-24,.pure-u-sm-5-8{width:62.5%}.pure-u-sm-16-24,.pure-u-sm-2-3{width:66.6667%}.pure-u-sm-17-24{width:70.8333%}.pure-u-sm-18-24,.pure-u-sm-3-4{width:75%}.pure-u-sm-19-24{width:79.1667%}.pure-u-sm-4-5{width:80%}.pure-u-sm-20-24,.pure-u-sm-5-6{width:83.3333%}.pure-u-sm-21-24,.pure-u-sm-7-8{width:87.5%}.pure-u-sm-11-12,.pure-u-sm-22-24{width:91.6667%}.pure-u-sm-23-24{width:95.8333%}.pure-u-sm-1,.pure-u-sm-1-1,.pure-u-sm-24-24,.pure-u-sm-5-5{width:100%}}@media screen and (min-width:48em){.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-1-12,.pure-u-md-1-2,.pure-u-md-1-24,.pure-u-md-1-3,.pure-u-md-1-4,.pure-u-md-1-5,.pure-u-md-1-6,.pure-u-md-1-8,.pure-u-md-10-24,.pure-u-md-11-12,.pure-u-md-11-24,.pure-u-md-12-24,.pure-u-md-13-24,.pure-u-md-14-24,.pure-u-md-15-24,.pure-u-md-16-24,.pure-u-md-17-24,.pure-u-md-18-24,.pure-u-md-19-24,.pure-u-md-2-24,.pure-u-md-2-3,.pure-u-md-2-5,.pure-u-md-20-24,.pure-u-md-21-24,.pure-u-md-22-24,.pure-u-md-23-24,.pure-u-md-24-24,.pure-u-md-3-24,.pure-u-md-3-4,.pure-u-md-3-5,.pure-u-md-3-8,.pure-u-md-4-24,.pure-u-md-4-5,.pure-u-md-5-12,.pure-u-md-5-24,.pure-u-md-5-5,.pure-u-md-5-6,.pure-u-md-5-8,.pure-u-md-6-24,.pure-u-md-7-12,.pure-u-md-7-24,.pure-u-md-7-8,.pure-u-md-8-24,.pure-u-md-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-md-1-24{width:4.1667%}.pure-u-md-1-12,.pure-u-md-2-24{width:8.3333%}.pure-u-md-1-8,.pure-u-md-3-24{width:12.5%}.pure-u-md-1-6,.pure-u-md-4-24{width:16.6667%}.pure-u-md-1-5{width:20%}.pure-u-md-5-24{width:20.8333%}.pure-u-md-1-4,.pure-u-md-6-24{width:25%}.pure-u-md-7-24{width:29.1667%}.pure-u-md-1-3,.pure-u-md-8-24{width:33.3333%}.pure-u-md-3-8,.pure-u-md-9-24{width:37.5%}.pure-u-md-2-5{width:40%}.pure-u-md-10-24,.pure-u-md-5-12{width:41.6667%}.pure-u-md-11-24{width:45.8333%}.pure-u-md-1-2,.pure-u-md-12-24{width:50%}.pure-u-md-13-24{width:54.1667%}.pure-u-md-14-24,.pure-u-md-7-12{width:58.3333%}.pure-u-md-3-5{width:60%}.pure-u-md-15-24,.pure-u-md-5-8{width:62.5%}.pure-u-md-16-24,.pure-u-md-2-3{width:66.6667%}.pure-u-md-17-24{width:70.8333%}.pure-u-md-18-24,.pure-u-md-3-4{width:75%}.pure-u-md-19-24{width:79.1667%}.pure-u-md-4-5{width:80%}.pure-u-md-20-24,.pure-u-md-5-6{width:83.3333%}.pure-u-md-21-24,.pure-u-md-7-8{width:87.5%}.pure-u-md-11-12,.pure-u-md-22-24{width:91.6667%}.pure-u-md-23-24{width:95.8333%}.pure-u-md-1,.pure-u-md-1-1,.pure-u-md-24-24,.pure-u-md-5-5{width:100%}}@media screen and (min-width:64em){.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-1-12,.pure-u-lg-1-2,.pure-u-lg-1-24,.pure-u-lg-1-3,.pure-u-lg-1-4,.pure-u-lg-1-5,.pure-u-lg-1-6,.pure-u-lg-1-8,.pure-u-lg-10-24,.pure-u-lg-11-12,.pure-u-lg-11-24,.pure-u-lg-12-24,.pure-u-lg-13-24,.pure-u-lg-14-24,.pure-u-lg-15-24,.pure-u-lg-16-24,.pure-u-lg-17-24,.pure-u-lg-18-24,.pure-u-lg-19-24,.pure-u-lg-2-24,.pure-u-lg-2-3,.pure-u-lg-2-5,.pure-u-lg-20-24,.pure-u-lg-21-24,.pure-u-lg-22-24,.pure-u-lg-23-24,.pure-u-lg-24-24,.pure-u-lg-3-24,.pure-u-lg-3-4,.pure-u-lg-3-5,.pure-u-lg-3-8,.pure-u-lg-4-24,.pure-u-lg-4-5,.pure-u-lg-5-12,.pure-u-lg-5-24,.pure-u-lg-5-5,.pure-u-lg-5-6,.pure-u-lg-5-8,.pure-u-lg-6-24,.pure-u-lg-7-12,.pure-u-lg-7-24,.pure-u-lg-7-8,.pure-u-lg-8-24,.pure-u-lg-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-lg-1-24{width:4.1667%}.pure-u-lg-1-12,.pure-u-lg-2-24{width:8.3333%}.pure-u-lg-1-8,.pure-u-lg-3-24{width:12.5%}.pure-u-lg-1-6,.pure-u-lg-4-24{width:16.6667%}.pure-u-lg-1-5{width:20%}.pure-u-lg-5-24{width:20.8333%}.pure-u-lg-1-4,.pure-u-lg-6-24{width:25%}.pure-u-lg-7-24{width:29.1667%}.pure-u-lg-1-3,.pure-u-lg-8-24{width:33.3333%}.pure-u-lg-3-8,.pure-u-lg-9-24{width:37.5%}.pure-u-lg-2-5{width:40%}.pure-u-lg-10-24,.pure-u-lg-5-12{width:41.6667%}.pure-u-lg-11-24{width:45.8333%}.pure-u-lg-1-2,.pure-u-lg-12-24{width:50%}.pure-u-lg-13-24{width:54.1667%}.pure-u-lg-14-24,.pure-u-lg-7-12{width:58.3333%}.pure-u-lg-3-5{width:60%}.pure-u-lg-15-24,.pure-u-lg-5-8{width:62.5%}.pure-u-lg-16-24,.pure-u-lg-2-3{width:66.6667%}.pure-u-lg-17-24{width:70.8333%}.pure-u-lg-18-24,.pure-u-lg-3-4{width:75%}.pure-u-lg-19-24{width:79.1667%}.pure-u-lg-4-5{width:80%}.pure-u-lg-20-24,.pure-u-lg-5-6{width:83.3333%}.pure-u-lg-21-24,.pure-u-lg-7-8{width:87.5%}.pure-u-lg-11-12,.pure-u-lg-22-24{width:91.6667%}.pure-u-lg-23-24{width:95.8333%}.pure-u-lg-1,.pure-u-lg-1-1,.pure-u-lg-24-24,.pure-u-lg-5-5{width:100%}}@media screen and (min-width:80em){.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-1-12,.pure-u-xl-1-2,.pure-u-xl-1-24,.pure-u-xl-1-3,.pure-u-xl-1-4,.pure-u-xl-1-5,.pure-u-xl-1-6,.pure-u-xl-1-8,.pure-u-xl-10-24,.pure-u-xl-11-12,.pure-u-xl-11-24,.pure-u-xl-12-24,.pure-u-xl-13-24,.pure-u-xl-14-24,.pure-u-xl-15-24,.pure-u-xl-16-24,.pure-u-xl-17-24,.pure-u-xl-18-24,.pure-u-xl-19-24,.pure-u-xl-2-24,.pure-u-xl-2-3,.pure-u-xl-2-5,.pure-u-xl-20-24,.pure-u-xl-21-24,.pure-u-xl-22-24,.pure-u-xl-23-24,.pure-u-xl-24-24,.pure-u-xl-3-24,.pure-u-xl-3-4,.pure-u-xl-3-5,.pure-u-xl-3-8,.pure-u-xl-4-24,.pure-u-xl-4-5,.pure-u-xl-5-12,.pure-u-xl-5-24,.pure-u-xl-5-5,.pure-u-xl-5-6,.pure-u-xl-5-8,.pure-u-xl-6-24,.pure-u-xl-7-12,.pure-u-xl-7-24,.pure-u-xl-7-8,.pure-u-xl-8-24,.pure-u-xl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xl-1-24{width:4.1667%}.pure-u-xl-1-12,.pure-u-xl-2-24{width:8.3333%}.pure-u-xl-1-8,.pure-u-xl-3-24{width:12.5%}.pure-u-xl-1-6,.pure-u-xl-4-24{width:16.6667%}.pure-u-xl-1-5{width:20%}.pure-u-xl-5-24{width:20.8333%}.pure-u-xl-1-4,.pure-u-xl-6-24{width:25%}.pure-u-xl-7-24{width:29.1667%}.pure-u-xl-1-3,.pure-u-xl-8-24{width:33.3333%}.pure-u-xl-3-8,.pure-u-xl-9-24{width:37.5%}.pure-u-xl-2-5{width:40%}.pure-u-xl-10-24,.pure-u-xl-5-12{width:41.6667%}.pure-u-xl-11-24{width:45.8333%}.pure-u-xl-1-2,.pure-u-xl-12-24{width:50%}.pure-u-xl-13-24{width:54.1667%}.pure-u-xl-14-24,.pure-u-xl-7-12{width:58.3333%}.pure-u-xl-3-5{width:60%}.pure-u-xl-15-24,.pure-u-xl-5-8{width:62.5%}.pure-u-xl-16-24,.pure-u-xl-2-3{width:66.6667%}.pure-u-xl-17-24{width:70.8333%}.pure-u-xl-18-24,.pure-u-xl-3-4{width:75%}.pure-u-xl-19-24{width:79.1667%}.pure-u-xl-4-5{width:80%}.pure-u-xl-20-24,.pure-u-xl-5-6{width:83.3333%}.pure-u-xl-21-24,.pure-u-xl-7-8{width:87.5%}.pure-u-xl-11-12,.pure-u-xl-22-24{width:91.6667%}.pure-u-xl-23-24{width:95.8333%}.pure-u-xl-1,.pure-u-xl-1-1,.pure-u-xl-24-24,.pure-u-xl-5-5{width:100%}}@media screen and (min-width:120em){.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-1-12,.pure-u-xxl-1-2,.pure-u-xxl-1-24,.pure-u-xxl-1-3,.pure-u-xxl-1-4,.pure-u-xxl-1-5,.pure-u-xxl-1-6,.pure-u-xxl-1-8,.pure-u-xxl-10-24,.pure-u-xxl-11-12,.pure-u-xxl-11-24,.pure-u-xxl-12-24,.pure-u-xxl-13-24,.pure-u-xxl-14-24,.pure-u-xxl-15-24,.pure-u-xxl-16-24,.pure-u-xxl-17-24,.pure-u-xxl-18-24,.pure-u-xxl-19-24,.pure-u-xxl-2-24,.pure-u-xxl-2-3,.pure-u-xxl-2-5,.pure-u-xxl-20-24,.pure-u-xxl-21-24,.pure-u-xxl-22-24,.pure-u-xxl-23-24,.pure-u-xxl-24-24,.pure-u-xxl-3-24,.pure-u-xxl-3-4,.pure-u-xxl-3-5,.pure-u-xxl-3-8,.pure-u-xxl-4-24,.pure-u-xxl-4-5,.pure-u-xxl-5-12,.pure-u-xxl-5-24,.pure-u-xxl-5-5,.pure-u-xxl-5-6,.pure-u-xxl-5-8,.pure-u-xxl-6-24,.pure-u-xxl-7-12,.pure-u-xxl-7-24,.pure-u-xxl-7-8,.pure-u-xxl-8-24,.pure-u-xxl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xxl-1-24{width:4.1667%}.pure-u-xxl-1-12,.pure-u-xxl-2-24{width:8.3333%}.pure-u-xxl-1-8,.pure-u-xxl-3-24{width:12.5%}.pure-u-xxl-1-6,.pure-u-xxl-4-24{width:16.6667%}.pure-u-xxl-1-5{width:20%}.pure-u-xxl-5-24{width:20.8333%}.pure-u-xxl-1-4,.pure-u-xxl-6-24{width:25%}.pure-u-xxl-7-24{width:29.1667%}.pure-u-xxl-1-3,.pure-u-xxl-8-24{width:33.3333%}.pure-u-xxl-3-8,.pure-u-xxl-9-24{width:37.5%}.pure-u-xxl-2-5{width:40%}.pure-u-xxl-10-24,.pure-u-xxl-5-12{width:41.6667%}.pure-u-xxl-11-24{width:45.8333%}.pure-u-xxl-1-2,.pure-u-xxl-12-24{width:50%}.pure-u-xxl-13-24{width:54.1667%}.pure-u-xxl-14-24,.pure-u-xxl-7-12{width:58.3333%}.pure-u-xxl-3-5{width:60%}.pure-u-xxl-15-24,.pure-u-xxl-5-8{width:62.5%}.pure-u-xxl-16-24,.pure-u-xxl-2-3{width:66.6667%}.pure-u-xxl-17-24{width:70.8333%}.pure-u-xxl-18-24,.pure-u-xxl-3-4{width:75%}.pure-u-xxl-19-24{width:79.1667%}.pure-u-xxl-4-5{width:80%}.pure-u-xxl-20-24,.pure-u-xxl-5-6{width:83.3333%}.pure-u-xxl-21-24,.pure-u-xxl-7-8{width:87.5%}.pure-u-xxl-11-12,.pure-u-xxl-22-24{width:91.6667%}.pure-u-xxl-23-24{width:95.8333%}.pure-u-xxl-1,.pure-u-xxl-1-1,.pure-u-xxl-24-24,.pure-u-xxl-5-5{width:100%}}@media screen and (min-width:160em){.pure-u-xxxl-1,.pure-u-xxxl-1-1,.pure-u-xxxl-1-12,.pure-u-xxxl-1-2,.pure-u-xxxl-1-24,.pure-u-xxxl-1-3,.pure-u-xxxl-1-4,.pure-u-xxxl-1-5,.pure-u-xxxl-1-6,.pure-u-xxxl-1-8,.pure-u-xxxl-10-24,.pure-u-xxxl-11-12,.pure-u-xxxl-11-24,.pure-u-xxxl-12-24,.pure-u-xxxl-13-24,.pure-u-xxxl-14-24,.pure-u-xxxl-15-24,.pure-u-xxxl-16-24,.pure-u-xxxl-17-24,.pure-u-xxxl-18-24,.pure-u-xxxl-19-24,.pure-u-xxxl-2-24,.pure-u-xxxl-2-3,.pure-u-xxxl-2-5,.pure-u-xxxl-20-24,.pure-u-xxxl-21-24,.pure-u-xxxl-22-24,.pure-u-xxxl-23-24,.pure-u-xxxl-24-24,.pure-u-xxxl-3-24,.pure-u-xxxl-3-4,.pure-u-xxxl-3-5,.pure-u-xxxl-3-8,.pure-u-xxxl-4-24,.pure-u-xxxl-4-5,.pure-u-xxxl-5-12,.pure-u-xxxl-5-24,.pure-u-xxxl-5-5,.pure-u-xxxl-5-6,.pure-u-xxxl-5-8,.pure-u-xxxl-6-24,.pure-u-xxxl-7-12,.pure-u-xxxl-7-24,.pure-u-xxxl-7-8,.pure-u-xxxl-8-24,.pure-u-xxxl-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-xxxl-1-24{width:4.1667%}.pure-u-xxxl-1-12,.pure-u-xxxl-2-24{width:8.3333%}.pure-u-xxxl-1-8,.pure-u-xxxl-3-24{width:12.5%}.pure-u-xxxl-1-6,.pure-u-xxxl-4-24{width:16.6667%}.pure-u-xxxl-1-5{width:20%}.pure-u-xxxl-5-24{width:20.8333%}.pure-u-xxxl-1-4,.pure-u-xxxl-6-24{width:25%}.pure-u-xxxl-7-24{width:29.1667%}.pure-u-xxxl-1-3,.pure-u-xxxl-8-24{width:33.3333%}.pure-u-xxxl-3-8,.pure-u-xxxl-9-24{width:37.5%}.pure-u-xxxl-2-5{width:40%}.pure-u-xxxl-10-24,.pure-u-xxxl-5-12{width:41.6667%}.pure-u-xxxl-11-24{width:45.8333%}.pure-u-xxxl-1-2,.pure-u-xxxl-12-24{width:50%}.pure-u-xxxl-13-24{width:54.1667%}.pure-u-xxxl-14-24,.pure-u-xxxl-7-12{width:58.3333%}.pure-u-xxxl-3-5{width:60%}.pure-u-xxxl-15-24,.pure-u-xxxl-5-8{width:62.5%}.pure-u-xxxl-16-24,.pure-u-xxxl-2-3{width:66.6667%}.pure-u-xxxl-17-24{width:70.8333%}.pure-u-xxxl-18-24,.pure-u-xxxl-3-4{width:75%}.pure-u-xxxl-19-24{width:79.1667%}.pure-u-xxxl-4-5{width:80%}.pure-u-xxxl-20-24,.pure-u-xxxl-5-6{width:83.3333%}.pure-u-xxxl-21-24,.pure-u-xxxl-7-8{width:87.5%}.pure-u-xxxl-11-12,.pure-u-xxxl-22-24{width:91.6667%}.pure-u-xxxl-23-24{width:95.8333%}.pure-u-xxxl-1,.pure-u-xxxl-1-1,.pure-u-xxxl-24-24,.pure-u-xxxl-5-5{width:100%}}@media screen and (min-width:240em){.pure-u-x4k-1,.pure-u-x4k-1-1,.pure-u-x4k-1-12,.pure-u-x4k-1-2,.pure-u-x4k-1-24,.pure-u-x4k-1-3,.pure-u-x4k-1-4,.pure-u-x4k-1-5,.pure-u-x4k-1-6,.pure-u-x4k-1-8,.pure-u-x4k-10-24,.pure-u-x4k-11-12,.pure-u-x4k-11-24,.pure-u-x4k-12-24,.pure-u-x4k-13-24,.pure-u-x4k-14-24,.pure-u-x4k-15-24,.pure-u-x4k-16-24,.pure-u-x4k-17-24,.pure-u-x4k-18-24,.pure-u-x4k-19-24,.pure-u-x4k-2-24,.pure-u-x4k-2-3,.pure-u-x4k-2-5,.pure-u-x4k-20-24,.pure-u-x4k-21-24,.pure-u-x4k-22-24,.pure-u-x4k-23-24,.pure-u-x4k-24-24,.pure-u-x4k-3-24,.pure-u-x4k-3-4,.pure-u-x4k-3-5,.pure-u-x4k-3-8,.pure-u-x4k-4-24,.pure-u-x4k-4-5,.pure-u-x4k-5-12,.pure-u-x4k-5-24,.pure-u-x4k-5-5,.pure-u-x4k-5-6,.pure-u-x4k-5-8,.pure-u-x4k-6-24,.pure-u-x4k-7-12,.pure-u-x4k-7-24,.pure-u-x4k-7-8,.pure-u-x4k-8-24,.pure-u-x4k-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-x4k-1-24{width:4.1667%}.pure-u-x4k-1-12,.pure-u-x4k-2-24{width:8.3333%}.pure-u-x4k-1-8,.pure-u-x4k-3-24{width:12.5%}.pure-u-x4k-1-6,.pure-u-x4k-4-24{width:16.6667%}.pure-u-x4k-1-5{width:20%}.pure-u-x4k-5-24{width:20.8333%}.pure-u-x4k-1-4,.pure-u-x4k-6-24{width:25%}.pure-u-x4k-7-24{width:29.1667%}.pure-u-x4k-1-3,.pure-u-x4k-8-24{width:33.3333%}.pure-u-x4k-3-8,.pure-u-x4k-9-24{width:37.5%}.pure-u-x4k-2-5{width:40%}.pure-u-x4k-10-24,.pure-u-x4k-5-12{width:41.6667%}.pure-u-x4k-11-24{width:45.8333%}.pure-u-x4k-1-2,.pure-u-x4k-12-24{width:50%}.pure-u-x4k-13-24{width:54.1667%}.pure-u-x4k-14-24,.pure-u-x4k-7-12{width:58.3333%}.pure-u-x4k-3-5{width:60%}.pure-u-x4k-15-24,.pure-u-x4k-5-8{width:62.5%}.pure-u-x4k-16-24,.pure-u-x4k-2-3{width:66.6667%}.pure-u-x4k-17-24{width:70.8333%}.pure-u-x4k-18-24,.pure-u-x4k-3-4{width:75%}.pure-u-x4k-19-24{width:79.1667%}.pure-u-x4k-4-5{width:80%}.pure-u-x4k-20-24,.pure-u-x4k-5-6{width:83.3333%}.pure-u-x4k-21-24,.pure-u-x4k-7-8{width:87.5%}.pure-u-x4k-11-12,.pure-u-x4k-22-24{width:91.6667%}.pure-u-x4k-23-24{width:95.8333%}.pure-u-x4k-1,.pure-u-x4k-1-1,.pure-u-x4k-24-24,.pure-u-x4k-5-5{width:100%}} -------------------------------------------------------------------------------- /app/frontend/static/css/pure.min.css: -------------------------------------------------------------------------------- 1 | /*! 2 | Pure v3.0.0 3 | Copyright 2013 Yahoo! 4 | Licensed under the BSD License. 5 | https://github.com/pure-css/pure/blob/master/LICENSE 6 | */ 7 | /*! 8 | normalize.css v | MIT License | https://necolas.github.io/normalize.css/ 9 | Copyright (c) Nicolas Gallagher and Jonathan Neal 10 | */ 11 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}template{display:none}[hidden]{display:none}html{font-family:sans-serif}.hidden,[hidden]{display:none!important}.pure-img{max-width:100%;height:auto;display:block}.pure-g{display:flex;flex-flow:row wrap;align-content:flex-start}.pure-u{display:inline-block;vertical-align:top}.pure-u-1,.pure-u-1-1,.pure-u-1-12,.pure-u-1-2,.pure-u-1-24,.pure-u-1-3,.pure-u-1-4,.pure-u-1-5,.pure-u-1-6,.pure-u-1-8,.pure-u-10-24,.pure-u-11-12,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-2-24,.pure-u-2-3,.pure-u-2-5,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24,.pure-u-3-24,.pure-u-3-4,.pure-u-3-5,.pure-u-3-8,.pure-u-4-24,.pure-u-4-5,.pure-u-5-12,.pure-u-5-24,.pure-u-5-5,.pure-u-5-6,.pure-u-5-8,.pure-u-6-24,.pure-u-7-12,.pure-u-7-24,.pure-u-7-8,.pure-u-8-24,.pure-u-9-24{display:inline-block;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-10-24,.pure-u-5-12{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-14-24,.pure-u-7-12{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-15-24,.pure-u-5-8{width:62.5%}.pure-u-16-24,.pure-u-2-3{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-18-24,.pure-u-3-4{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-20-24,.pure-u-5-6{width:83.3333%}.pure-u-21-24,.pure-u-7-8{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-24-24,.pure-u-5-5{width:100%}.pure-button{display:inline-block;line-height:normal;white-space:nowrap;vertical-align:middle;text-align:center;cursor:pointer;-webkit-user-drag:none;-webkit-user-select:none;user-select:none;box-sizing:border-box}.pure-button::-moz-focus-inner{padding:0;border:0}.pure-button-group{letter-spacing:-.31em;text-rendering:optimizespeed}.opera-only :-o-prefocus,.pure-button-group{word-spacing:-0.43em}.pure-button-group .pure-button{letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-button{font-family:inherit;font-size:100%;padding:.5em 1em;color:rgba(0,0,0,.8);border:none transparent;background-color:#e6e6e6;text-decoration:none;border-radius:2px}.pure-button-hover,.pure-button:focus,.pure-button:hover{background-image:linear-gradient(transparent,rgba(0,0,0,.05) 40%,rgba(0,0,0,.1))}.pure-button:focus{outline:0}.pure-button-active,.pure-button:active{box-shadow:0 0 0 1px rgba(0,0,0,.15) inset,0 0 6px rgba(0,0,0,.2) inset;border-color:#000}.pure-button-disabled,.pure-button-disabled:active,.pure-button-disabled:focus,.pure-button-disabled:hover,.pure-button[disabled]{border:none;background-image:none;opacity:.4;cursor:not-allowed;box-shadow:none;pointer-events:none}.pure-button-hidden{display:none}.pure-button-primary,.pure-button-selected,a.pure-button-primary,a.pure-button-selected{background-color:#0078e7;color:#fff}.pure-button-group .pure-button{margin:0;border-radius:0;border-right:1px solid rgba(0,0,0,.2)}.pure-button-group .pure-button:first-child{border-top-left-radius:2px;border-bottom-left-radius:2px}.pure-button-group .pure-button:last-child{border-top-right-radius:2px;border-bottom-right-radius:2px;border-right:none}.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form select,.pure-form textarea{padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;vertical-align:middle;box-sizing:border-box}.pure-form input:not([type]){padding:.5em .6em;display:inline-block;border:1px solid #ccc;box-shadow:inset 0 1px 3px #ddd;border-radius:4px;box-sizing:border-box}.pure-form input[type=color]{padding:.2em .5em}.pure-form input[type=color]:focus,.pure-form input[type=date]:focus,.pure-form input[type=datetime-local]:focus,.pure-form input[type=datetime]:focus,.pure-form input[type=email]:focus,.pure-form input[type=month]:focus,.pure-form input[type=number]:focus,.pure-form input[type=password]:focus,.pure-form input[type=search]:focus,.pure-form input[type=tel]:focus,.pure-form input[type=text]:focus,.pure-form input[type=time]:focus,.pure-form input[type=url]:focus,.pure-form input[type=week]:focus,.pure-form select:focus,.pure-form textarea:focus{outline:0;border-color:#129fea}.pure-form input:not([type]):focus{outline:0;border-color:#129fea}.pure-form input[type=checkbox]:focus,.pure-form input[type=file]:focus,.pure-form input[type=radio]:focus{outline:thin solid #129FEA;outline:1px auto #129FEA}.pure-form .pure-checkbox,.pure-form .pure-radio{margin:.5em 0;display:block}.pure-form input[type=color][disabled],.pure-form input[type=date][disabled],.pure-form input[type=datetime-local][disabled],.pure-form input[type=datetime][disabled],.pure-form input[type=email][disabled],.pure-form input[type=month][disabled],.pure-form input[type=number][disabled],.pure-form input[type=password][disabled],.pure-form input[type=search][disabled],.pure-form input[type=tel][disabled],.pure-form input[type=text][disabled],.pure-form input[type=time][disabled],.pure-form input[type=url][disabled],.pure-form input[type=week][disabled],.pure-form select[disabled],.pure-form textarea[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input:not([type])[disabled]{cursor:not-allowed;background-color:#eaeded;color:#cad2d3}.pure-form input[readonly],.pure-form select[readonly],.pure-form textarea[readonly]{background-color:#eee;color:#777;border-color:#ccc}.pure-form input:focus:invalid,.pure-form select:focus:invalid,.pure-form textarea:focus:invalid{color:#b94a48;border-color:#e9322d}.pure-form input[type=checkbox]:focus:invalid:focus,.pure-form input[type=file]:focus:invalid:focus,.pure-form input[type=radio]:focus:invalid:focus{outline-color:#e9322d}.pure-form select{height:2.25em;border:1px solid #ccc;background-color:#fff}.pure-form select[multiple]{height:auto}.pure-form label{margin:.5em 0 .2em}.pure-form fieldset{margin:0;padding:.35em 0 .75em;border:0}.pure-form legend{display:block;width:100%;padding:.3em 0;margin-bottom:.3em;color:#333;border-bottom:1px solid #e5e5e5}.pure-form-stacked input[type=color],.pure-form-stacked input[type=date],.pure-form-stacked input[type=datetime-local],.pure-form-stacked input[type=datetime],.pure-form-stacked input[type=email],.pure-form-stacked input[type=file],.pure-form-stacked input[type=month],.pure-form-stacked input[type=number],.pure-form-stacked input[type=password],.pure-form-stacked input[type=search],.pure-form-stacked input[type=tel],.pure-form-stacked input[type=text],.pure-form-stacked input[type=time],.pure-form-stacked input[type=url],.pure-form-stacked input[type=week],.pure-form-stacked label,.pure-form-stacked select,.pure-form-stacked textarea{display:block;margin:.25em 0}.pure-form-stacked input:not([type]){display:block;margin:.25em 0}.pure-form-aligned input,.pure-form-aligned select,.pure-form-aligned textarea,.pure-form-message-inline{display:inline-block;vertical-align:middle}.pure-form-aligned textarea{vertical-align:top}.pure-form-aligned .pure-control-group{margin-bottom:.5em}.pure-form-aligned .pure-control-group label{text-align:right;display:inline-block;vertical-align:middle;width:10em;margin:0 1em 0 0}.pure-form-aligned .pure-controls{margin:1.5em 0 0 11em}.pure-form .pure-input-rounded,.pure-form input.pure-input-rounded{border-radius:2em;padding:.5em 1em}.pure-form .pure-group fieldset{margin-bottom:10px}.pure-form .pure-group input,.pure-form .pure-group textarea{display:block;padding:10px;margin:0 0 -1px;border-radius:0;position:relative;top:-1px}.pure-form .pure-group input:focus,.pure-form .pure-group textarea:focus{z-index:3}.pure-form .pure-group input:first-child,.pure-form .pure-group textarea:first-child{top:1px;border-radius:4px 4px 0 0;margin:0}.pure-form .pure-group input:first-child:last-child,.pure-form .pure-group textarea:first-child:last-child{top:1px;border-radius:4px;margin:0}.pure-form .pure-group input:last-child,.pure-form .pure-group textarea:last-child{top:-2px;border-radius:0 0 4px 4px;margin:0}.pure-form .pure-group button{margin:.35em 0}.pure-form .pure-input-1{width:100%}.pure-form .pure-input-3-4{width:75%}.pure-form .pure-input-2-3{width:66%}.pure-form .pure-input-1-2{width:50%}.pure-form .pure-input-1-3{width:33%}.pure-form .pure-input-1-4{width:25%}.pure-form-message-inline{display:inline-block;padding-left:.3em;color:#666;vertical-align:middle;font-size:.875em}.pure-form-message{display:block;color:#666;font-size:.875em}@media only screen and (max-width :480px){.pure-form button[type=submit]{margin:.7em 0 0}.pure-form input:not([type]),.pure-form input[type=color],.pure-form input[type=date],.pure-form input[type=datetime-local],.pure-form input[type=datetime],.pure-form input[type=email],.pure-form input[type=month],.pure-form input[type=number],.pure-form input[type=password],.pure-form input[type=search],.pure-form input[type=tel],.pure-form input[type=text],.pure-form input[type=time],.pure-form input[type=url],.pure-form input[type=week],.pure-form label{margin-bottom:.3em;display:block}.pure-group input:not([type]),.pure-group input[type=color],.pure-group input[type=date],.pure-group input[type=datetime-local],.pure-group input[type=datetime],.pure-group input[type=email],.pure-group input[type=month],.pure-group input[type=number],.pure-group input[type=password],.pure-group input[type=search],.pure-group input[type=tel],.pure-group input[type=text],.pure-group input[type=time],.pure-group input[type=url],.pure-group input[type=week]{margin-bottom:0}.pure-form-aligned .pure-control-group label{margin-bottom:.3em;text-align:left;display:block;width:100%}.pure-form-aligned .pure-controls{margin:1.5em 0 0 0}.pure-form-message,.pure-form-message-inline{display:block;font-size:.75em;padding:.2em 0 .8em}}.pure-menu{box-sizing:border-box}.pure-menu-fixed{position:fixed;left:0;top:0;z-index:3}.pure-menu-item,.pure-menu-list{position:relative}.pure-menu-list{list-style:none;margin:0;padding:0}.pure-menu-item{padding:0;margin:0;height:100%}.pure-menu-heading,.pure-menu-link{display:block;text-decoration:none;white-space:nowrap}.pure-menu-horizontal{width:100%;white-space:nowrap}.pure-menu-horizontal .pure-menu-list{display:inline-block}.pure-menu-horizontal .pure-menu-heading,.pure-menu-horizontal .pure-menu-item,.pure-menu-horizontal .pure-menu-separator{display:inline-block;vertical-align:middle}.pure-menu-item .pure-menu-item{display:block}.pure-menu-children{display:none;position:absolute;left:100%;top:0;margin:0;padding:0;z-index:3}.pure-menu-horizontal .pure-menu-children{left:0;top:auto;width:inherit}.pure-menu-active>.pure-menu-children,.pure-menu-allow-hover:hover>.pure-menu-children{display:block;position:absolute}.pure-menu-has-children>.pure-menu-link:after{padding-left:.5em;content:"\25B8";font-size:small}.pure-menu-horizontal .pure-menu-has-children>.pure-menu-link:after{content:"\25BE"}.pure-menu-scrollable{overflow-y:scroll;overflow-x:hidden}.pure-menu-scrollable .pure-menu-list{display:block}.pure-menu-horizontal.pure-menu-scrollable .pure-menu-list{display:inline-block}.pure-menu-horizontal.pure-menu-scrollable{white-space:nowrap;overflow-y:hidden;overflow-x:auto;padding:.5em 0}.pure-menu-horizontal .pure-menu-children .pure-menu-separator,.pure-menu-separator{background-color:#ccc;height:1px;margin:.3em 0}.pure-menu-horizontal .pure-menu-separator{width:1px;height:1.3em;margin:0 .3em}.pure-menu-horizontal .pure-menu-children .pure-menu-separator{display:block;width:auto}.pure-menu-heading{text-transform:uppercase;color:#565d64}.pure-menu-link{color:#777}.pure-menu-children{background-color:#fff}.pure-menu-heading,.pure-menu-link{padding:.5em 1em}.pure-menu-disabled{opacity:.5}.pure-menu-disabled .pure-menu-link:hover{background-color:transparent;cursor:default}.pure-menu-active>.pure-menu-link,.pure-menu-link:focus,.pure-menu-link:hover{background-color:#eee}.pure-menu-selected>.pure-menu-link,.pure-menu-selected>.pure-menu-link:visited{color:#000}.pure-table{border-collapse:collapse;border-spacing:0;empty-cells:show;border:1px solid #cbcbcb}.pure-table caption{color:#000;font:italic 85%/1 arial,sans-serif;padding:1em 0;text-align:center}.pure-table td,.pure-table th{border-left:1px solid #cbcbcb;border-width:0 0 0 1px;font-size:inherit;margin:0;overflow:visible;padding:.5em 1em}.pure-table thead{background-color:#e0e0e0;color:#000;text-align:left;vertical-align:bottom}.pure-table td{background-color:transparent}.pure-table-odd td{background-color:#f2f2f2}.pure-table-striped tr:nth-child(2n-1) td{background-color:#f2f2f2}.pure-table-bordered td{border-bottom:1px solid #cbcbcb}.pure-table-bordered tbody>tr:last-child>td{border-bottom-width:0}.pure-table-horizontal td,.pure-table-horizontal th{border-width:0 0 1px 0;border-bottom:1px solid #cbcbcb}.pure-table-horizontal tbody>tr:last-child>td{border-bottom-width:0} -------------------------------------------------------------------------------- /app/frontend/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ppatrzyk/gitbi/8f620634d516907f4ed040eea40e52c17ae73ba6/app/frontend/static/favicon.ico -------------------------------------------------------------------------------- /app/frontend/static/js/codejar.min.js: -------------------------------------------------------------------------------- 1 | const globalWindow=window;export function CodeJar(editor,highlight,opt={}){const options=Object.assign({tab:'\t',indentOn:/[({\[]$/,moveToNewLine:/^[)}\]]/,spellcheck:false,catchTab:true,preserveIdent:true,addClosing:true,history:true,window:globalWindow},opt);const window=options.window;const document=window.document;let listeners=[];let history=[];let at=-1;let focus=false;let callback;let prev;editor.setAttribute('contenteditable','plaintext-only');editor.setAttribute('spellcheck',options.spellcheck?'true':'false');editor.style.outline='none';editor.style.overflowWrap='break-word';editor.style.overflowY='auto';editor.style.whiteSpace='pre-wrap';let isLegacy=false;highlight(editor);if(editor.contentEditable!=='plaintext-only'){isLegacy=true}if(isLegacy){editor.setAttribute('contenteditable','true')}const debounceHighlight=debounce(()=>{const pos=save();highlight(editor,pos);restore(pos)},30);let recording=false;const shouldRecord=(event)=>{return!isUndo(event)&&!isRedo(event)&&event.key!=='Meta'&&event.key!=='Control'&&event.key!=='Alt'&&!event.key.startsWith('Arrow')};const debounceRecordHistory=debounce((event)=>{if(shouldRecord(event)){recordHistory();recording=false}},300);const on=(type,fn)=>{listeners.push([type,fn]);editor.addEventListener(type,fn)};on('keydown',event=>{if(event.defaultPrevented){return}prev=toString();if(options.preserveIdent){handleNewLine(event)}else{legacyNewLineFix(event)}if(options.catchTab){handleTabCharacters(event)}if(options.addClosing){handleSelfClosingCharacters(event)}if(options.history){handleUndoRedo(event);if(shouldRecord(event)&&!recording){recordHistory();recording=true}}if(isLegacy&&!isCopy(event)){restore(save())}});on('keyup',event=>{if(event.defaultPrevented){return}if(event.isComposing){return}if(prev!==toString()){debounceHighlight()}debounceRecordHistory(event);if(callback){callback(toString())}});on('focus',_event=>{focus=true});on('blur',_event=>{focus=false});on('paste',event=>{recordHistory();handlePaste(event);recordHistory();if(callback){callback(toString())}});function save(){const s=getSelection();const pos={start:0,end:0,dir:undefined};let{anchorNode,anchorOffset,focusNode,focusOffset}=s;if(!anchorNode||!focusNode){throw 'error1'}if(anchorNode===editor&&focusNode===editor){pos.start=(anchorOffset>0&&editor.textContent)?editor.textContent.length:0;pos.end=(focusOffset>0&&editor.textContent)?editor.textContent.length:0;pos.dir=(focusOffset>=anchorOffset)?'->':'<-';return pos}if(anchorNode.nodeType===Node.ELEMENT_NODE){const node=document.createTextNode('');anchorNode.insertBefore(node,anchorNode.childNodes[anchorOffset]);anchorNode=node;anchorOffset=0}if(focusNode.nodeType===Node.ELEMENT_NODE){const node=document.createTextNode('');focusNode.insertBefore(node,focusNode.childNodes[focusOffset]);focusNode=node;focusOffset=0}visit(editor,el=>{if(el===anchorNode&&el===focusNode){pos.start+=anchorOffset;pos.end+=focusOffset;pos.dir=anchorOffset<=focusOffset?'->':'<-';return 'stop'}if(el===anchorNode){pos.start+=anchorOffset;if(!pos.dir){pos.dir='->'}else{return 'stop'}}else if(el===focusNode){pos.end+=focusOffset;if(!pos.dir){pos.dir='<-'}else{return 'stop'}}if(el.nodeType===Node.TEXT_NODE){if(pos.dir!='->'){pos.start+=el.nodeValue.length}if(pos.dir!='<-'){pos.end+=el.nodeValue.length}}});editor.normalize();return pos}function restore(pos){const s=getSelection();let startNode,startOffset=0;let endNode,endOffset=0;if(!pos.dir){pos.dir='->'}if(pos.start<0){pos.start=0}if(pos.end<0){pos.end=0}if(pos.dir=='<-'){const{start,end}=pos;pos.start=end;pos.end=start}let current=0;visit(editor,el=>{if(el.nodeType!==Node.TEXT_NODE){return}const len=(el.nodeValue||'').length;if(current+len>pos.start){if(!startNode){startNode=el;startOffset=pos.start-current}if(current+len>pos.end){endNode=el;endOffset=pos.end-current;return 'stop'}}current+=len});if(!startNode){startNode=editor,startOffset=editor.childNodes.length}if(!endNode){endNode=editor,endOffset=editor.childNodes.length}if(pos.dir=='<-'){[startNode,startOffset,endNode,endOffset]=[endNode,endOffset,startNode,startOffset]}s.setBaseAndExtent(startNode,startOffset,endNode,endOffset)}function beforeCursor(){const s=getSelection();const r0=s.getRangeAt(0);const r=document.createRange();r.selectNodeContents(editor);r.setEnd(r0.startContainer,r0.startOffset);return r.toString()}function afterCursor(){const s=getSelection();const r0=s.getRangeAt(0);const r=document.createRange();r.selectNodeContents(editor);r.setStart(r0.endContainer,r0.endOffset);return r.toString()}function handleNewLine(event){if(event.key==='Enter'){const before=beforeCursor();const after=afterCursor();let[padding]=findPadding(before);let newLinePadding=padding;if(options.indentOn.test(before)){newLinePadding+=options.tab}if(newLinePadding.length>0){preventDefault(event);event.stopPropagation();insert('\n'+newLinePadding)}else{legacyNewLineFix(event)}if(newLinePadding!==padding&&options.moveToNewLine.test(after)){const pos=save();insert('\n'+padding);restore(pos)}}}function legacyNewLineFix(event){if(isLegacy&&event.key==='Enter'){preventDefault(event);event.stopPropagation();if(afterCursor()==''){insert('\n ');const pos=save();pos.start= --pos.end;restore(pos)}else{insert('\n')}}}function handleSelfClosingCharacters(event){const open=`([{'"`;const close=`)]}'"`;const codeAfter=afterCursor();const codeBefore=beforeCursor();const escapeCharacter=codeBefore.substr(codeBefore.length-1)==='\\';const charAfter=codeAfter.substr(0,1);if(close.includes(event.key)&&!escapeCharacter&&charAfter===event.key){const pos=save();preventDefault(event);pos.start= ++pos.end;restore(pos)}else if(open.includes(event.key)&&!escapeCharacter&&(`"'`.includes(event.key)||['',' ','\n'].includes(charAfter))){preventDefault(event);const pos=save();const wrapText=pos.start==pos.end?'':getSelection().toString();const text=event.key+wrapText+close[open.indexOf(event.key)];insert(text);pos.start+=1;pos.end+=1;restore(pos)}}function handleTabCharacters(event){if(event.key==='Tab'){preventDefault(event);if(event.shiftKey){const before=beforeCursor();let[padding,start]=findPadding(before);if(padding.length>0){const pos=save();const len=Math.min(options.tab.length,padding.length);restore({start,end:start+len});document.execCommand('delete');pos.start-=len;pos.end-=len;restore(pos)}}else{insert(options.tab)}}}function handleUndoRedo(event){if(isUndo(event)){preventDefault(event);at-=1;const record=history[at];if(record){editor.innerHTML=record.html;restore(record.pos)}if(at<0){at=0}}if(isRedo(event)){preventDefault(event);at+=1;const record=history[at];if(record){editor.innerHTML=record.html;restore(record.pos)}if(at>=history.length){at-=1}}}function recordHistory(){if(!focus){return}const html=editor.innerHTML;const pos=save();const lastRecord=history[at];if(lastRecord){if(lastRecord.html===html&&lastRecord.pos.start===pos.start&&lastRecord.pos.end===pos.end){return}}at+=1;history[at]={html,pos};history.splice(at+1);const maxHistory=300;if(at>maxHistory){at=maxHistory;history.splice(0,1)}}function handlePaste(event){preventDefault(event);const text=(event.originalEvent||event).clipboardData.getData('text/plain').replace(/\r/g,'');const pos=save();insert(text);highlight(editor);restore({start:Math.min(pos.start,pos.end)+text.length,end:Math.min(pos.start,pos.end)+text.length,dir:'<-'})}function visit(editor,visitor){const queue=[];if(editor.firstChild){queue.push(editor.firstChild)}let el=queue.pop();while(el){if(visitor(el)==='stop'){break}if(el.nextSibling){queue.push(el.nextSibling)}if(el.firstChild){queue.push(el.firstChild)}el=queue.pop()}}function isCtrl(event){return event.metaKey||event.ctrlKey}function isUndo(event){return isCtrl(event)&&!event.shiftKey&&getKeyCode(event)==='Z'}function isRedo(event){return isCtrl(event)&&event.shiftKey&&getKeyCode(event)==='Z'}function isCopy(event){return isCtrl(event)&&getKeyCode(event)==='C'}function getKeyCode(event){let key=event.key||event.keyCode||event.which;if(!key){return undefined}return(typeof key==='string'?key:String.fromCharCode(key)).toUpperCase()}function insert(text){text=text.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');document.execCommand('insertHTML',false,text)}function debounce(cb,wait){let timeout=0;return(...args)=>{clearTimeout(timeout);timeout=window.setTimeout(()=>cb(...args),wait)}}function findPadding(text){let i=text.length-1;while(i>=0&&text[i]!=='\n'){i-=1}i+=1;let j=i;while(j{throw Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=()=>{throw Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach((n=>{var i=e[n];"object"!=typeof i||Object.isFrozen(i)||t(i)})),e}e.exports=t,e.exports.default=t;class n{constructor(e){void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function i(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function r(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t];return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const s=e=>!!e.scope||e.sublanguage&&e.language;class o{constructor(e,t){this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){this.buffer+=i(e)}openNode(e){if(!s(e))return;let t="";t=e.sublanguage?"language-"+e.language:((e,{prefix:t})=>{if(e.includes(".")){const n=e.split(".");return[`${t}${n.shift()}`,...n.map(((e,t)=>`${e}${"_".repeat(t+1)}`))].join(" ")}return`${t}${e}`})(e.scope,{prefix:this.classPrefix}),this.span(t)}closeNode(e){s(e)&&(this.buffer+="")}value(){return this.buffer}span(e){this.buffer+=``}}const a=(e={})=>{const t={children:[]};return Object.assign(t,e),t};class c{constructor(){this.rootNode=a(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){this.top.children.push(e)}openNode(e){const t=a({scope:e});this.add(t),this.stack.push(t)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t),t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){"string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{c._collapse(e)})))}}class l extends c{constructor(e){super(),this.options=e}addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())}addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root;n.sublanguage=!0,n.language=t,this.add(n)}toHTML(){return new o(this,this.options).value()}finalize(){return!0}}function g(e){return e?"string"==typeof e?e:e.source:null}function d(e){return p("(?=",e,")")}function u(e){return p("(?:",e,")*")}function h(e){return p("(?:",e,")?")}function p(...e){return e.map((e=>g(e))).join("")}function f(...e){const t=(e=>{const t=e[e.length-1];return"object"==typeof t&&t.constructor===Object?(e.splice(e.length-1,1),t):{}})(e);return"("+(t.capture?"":"?:")+e.map((e=>g(e))).join("|")+")"}function b(e){return RegExp(e.toString()+"|").exec("").length-1}const m=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function E(e,{joinWith:t}){let n=0;return e.map((e=>{n+=1;const t=n;let i=g(e),r="";for(;i.length>0;){const e=m.exec(i);if(!e){r+=i;break}r+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),"\\"===e[0][0]&&e[1]?r+="\\"+(Number(e[1])+t):(r+=e[0],"("===e[0]&&n++)}return r})).map((e=>`(${e})`)).join(t)}const x="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",w={begin:"\\\\[\\s\\S]",relevance:0},y={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[w]},_={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[w]},O=(e,t,n={})=>{const i=r({scope:"comment",begin:e,end:t,contains:[]},n);i.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});const s=f("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return i.contains.push({begin:p(/[ ]+/,"(",s,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),i},v=O("//","$"),N=O("/\\*","\\*/"),k=O("#","$");var M=Object.freeze({__proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:"[a-zA-Z]\\w*",UNDERSCORE_IDENT_RE:"[a-zA-Z_]\\w*",NUMBER_RE:"\\b\\d+(\\.\\d+)?",C_NUMBER_RE:x,BINARY_NUMBER_RE:"\\b(0b[01]+)",RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",SHEBANG:(e={})=>{const t=/^#![ ]*\//;return e.binary&&(e.begin=p(t,/.*\b/,e.binary,/\b.*/)),r({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:w,APOS_STRING_MODE:y,QUOTE_STRING_MODE:_,PHRASAL_WORDS_MODE:{begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},COMMENT:O,C_LINE_COMMENT_MODE:v,C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:k,NUMBER_MODE:{scope:"number",begin:"\\b\\d+(\\.\\d+)?",relevance:0},C_NUMBER_MODE:{scope:"number",begin:x,relevance:0},BINARY_NUMBER_MODE:{scope:"number",begin:"\\b(0b[01]+)",relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{scope:"regexp",begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[w,{begin:/\[/,end:/\]/,relevance:0,contains:[w]}]}]},TITLE_MODE:{scope:"title",begin:"[a-zA-Z]\\w*",relevance:0},UNDERSCORE_TITLE_MODE:{scope:"title",begin:"[a-zA-Z_]\\w*",relevance:0},METHOD_GUARD:{begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{"on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function S(e,t){"."===e.input[e.index-1]&&t.ignoreMatch()}function R(e,t){void 0!==e.className&&(e.scope=e.className,delete e.className)}function A(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=S,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,void 0===e.relevance&&(e.relevance=0))}function j(e,t){Array.isArray(e.illegal)&&(e.illegal=f(...e.illegal))}function I(e,t){if(e.match){if(e.begin||e.end)throw Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function T(e,t){void 0===e.relevance&&(e.relevance=1)}const L=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw Error("beforeMatch cannot be used with starts");const n=Object.assign({},e);Object.keys(e).forEach((t=>{delete e[t]})),e.keywords=n.keywords,e.begin=p(n.beforeMatch,d(n.begin)),e.starts={relevance:0,contains:[Object.assign(n,{endsParent:!0})]},e.relevance=0,delete n.beforeMatch},B=["of","and","for","in","not","or","if","then","parent","list","value"];function D(e,t,n="keyword"){const i=Object.create(null);return"string"==typeof e?r(n,e.split(" ")):Array.isArray(e)?r(n,e):Object.keys(e).forEach((n=>{Object.assign(i,D(e[n],t,n))})),i;function r(e,n){t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|");i[n[0]]=[e,H(n[0],n[1])]}))}}function H(e,t){return t?Number(t):(e=>B.includes(e.toLowerCase()))(e)?0:1}const P={},C=e=>{console.error(e)},$=(e,...t)=>{console.log("WARN: "+e,...t)},U=(e,t)=>{P[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),P[`${e}/${t}`]=!0)},z=Error();function K(e,t,{key:n}){let i=0;const r=e[n],s={},o={};for(let e=1;e<=t.length;e++)o[e+i]=r[e],s[e+i]=!0,i+=b(t[e-1]);e[n]=o,e[n]._emit=s,e[n]._multi=!0}function W(e){(e=>{e.scope&&"object"==typeof e.scope&&null!==e.scope&&(e.beginScope=e.scope,delete e.scope)})(e),"string"==typeof e.beginScope&&(e.beginScope={_wrap:e.beginScope}),"string"==typeof e.endScope&&(e.endScope={_wrap:e.endScope}),(e=>{if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw C("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),z;if("object"!=typeof e.beginScope||null===e.beginScope)throw C("beginScope must be object"),z;K(e,e.begin,{key:"beginScope"}),e.begin=E(e.begin,{joinWith:""})}})(e),(e=>{if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw C("skip, excludeEnd, returnEnd not compatible with endScope: {}"),z;if("object"!=typeof e.endScope||null===e.endScope)throw C("endScope must be object"),z;K(e,e.end,{key:"endScope"}),e.end=E(e.end,{joinWith:""})}})(e)}function X(e){function t(t,n){return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(n?"g":""))}class n{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(e,t){t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),this.matchAt+=b(e)+1}compile(){0===this.regexes.length&&(this.exec=()=>null);const e=this.regexes.map((e=>e[1]));this.matcherRe=t(E(e,{joinWith:"|"}),!0),this.lastIndex=0}exec(e){this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e);if(!t)return null;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n];return t.splice(0,n),Object.assign(t,i)}}class i{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){if(this.multiRegexes[e])return this.multiRegexes[e];const t=new n;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex;let n=t.exec(e);if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}return n&&(this.regexIndex+=n.position+1,this.regexIndex===this.count&&this.considerAll()),n}}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.");return e.classNameAliases=r(e.classNameAliases||{}),function n(s,o){const a=s;if(s.isCompiled)return a;[R,I,W,L].forEach((e=>e(s,o))),e.compilerExtensions.forEach((e=>e(s,o))),s.__beforeBegin=null,[A,j,T].forEach((e=>e(s,o))),s.isCompiled=!0;let c=null;return"object"==typeof s.keywords&&s.keywords.$pattern&&(s.keywords=Object.assign({},s.keywords),c=s.keywords.$pattern,delete s.keywords.$pattern),c=c||/\w+/,s.keywords&&(s.keywords=D(s.keywords,e.case_insensitive)),a.keywordPatternRe=t(c,!0),o&&(s.begin||(s.begin=/\B|\b/),a.beginRe=t(a.begin),s.end||s.endsWithParent||(s.end=/\B|\b/),s.end&&(a.endRe=t(a.end)),a.terminatorEnd=g(a.end)||"",s.endsWithParent&&o.terminatorEnd&&(a.terminatorEnd+=(s.end?"|":"")+o.terminatorEnd)),s.illegal&&(a.illegalRe=t(s.illegal)),s.contains||(s.contains=[]),s.contains=[].concat(...s.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>r(e,{variants:null},t)))),e.cachedVariants?e.cachedVariants:Z(e)?r(e,{starts:e.starts?r(e.starts):null}):Object.isFrozen(e)?r(e):e))("self"===e?s:e)))),s.contains.forEach((e=>{n(e,a)})),s.starts&&n(s.starts,o),a.matcher=(e=>{const t=new i;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin"}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end"}),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(a),a}(e)}function Z(e){return!!e&&(e.endsWithParent||Z(e.starts))}class G extends Error{constructor(e,t){super(e),this.name="HTMLInjectionError",this.html=t}}const F=i,V=r,q=Symbol("nomatch");var J=(t=>{const i=Object.create(null),r=Object.create(null),s=[];let o=!0;const a="Could not find the language '{}', did you forget to load/include a language module?",c={disableAutodetect:!0,name:"Plain text",contains:[]};let g={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:l};function b(e){return g.noHighlightRe.test(e)}function m(e,t,n){let i="",r="";"object"==typeof t?(i=e,n=t.ignoreIllegals,r=t.language):(U("10.7.0","highlight(lang, code, ...args) has been deprecated."),U("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"),r=e,i=t),void 0===n&&(n=!0);const s={code:i,language:r};k("before:highlight",s);const o=s.result?s.result:E(s.language,s.code,n);return o.code=s.code,k("after:highlight",o),o}function E(e,t,r,s){const c=Object.create(null);function l(){if(!N.keywords)return void M.addText(S);let e=0;N.keywordPatternRe.lastIndex=0;let t=N.keywordPatternRe.exec(S),n="";for(;t;){n+=S.substring(e,t.index);const r=y.case_insensitive?t[0].toLowerCase():t[0],s=(i=r,N.keywords[i]);if(s){const[e,i]=s;if(M.addText(n),n="",c[r]=(c[r]||0)+1,c[r]<=7&&(R+=i),e.startsWith("_"))n+=t[0];else{const n=y.classNameAliases[e]||e;M.addKeyword(t[0],n)}}else n+=t[0];e=N.keywordPatternRe.lastIndex,t=N.keywordPatternRe.exec(S)}var i;n+=S.substring(e),M.addText(n)}function d(){null!=N.subLanguage?(()=>{if(""===S)return;let e=null;if("string"==typeof N.subLanguage){if(!i[N.subLanguage])return void M.addText(S);e=E(N.subLanguage,S,!0,k[N.subLanguage]),k[N.subLanguage]=e._top}else e=x(S,N.subLanguage.length?N.subLanguage:null);N.relevance>0&&(R+=e.relevance),M.addSublanguage(e._emitter,e.language)})():l(),S=""}function u(e,t){let n=1;const i=t.length-1;for(;n<=i;){if(!e._emit[n]){n++;continue}const i=y.classNameAliases[e[n]]||e[n],r=t[n];i?M.addKeyword(r,i):(S=r,l(),S=""),n++}}function h(e,t){return e.scope&&"string"==typeof e.scope&&M.openNode(y.classNameAliases[e.scope]||e.scope),e.beginScope&&(e.beginScope._wrap?(M.addKeyword(S,y.classNameAliases[e.beginScope._wrap]||e.beginScope._wrap),S=""):e.beginScope._multi&&(u(e.beginScope,t),S="")),N=Object.create(e,{parent:{value:N}}),N}function p(e,t,i){let r=((e,t)=>{const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,i);if(r){if(e["on:end"]){const i=new n(e);e["on:end"](t,i),i.isMatchIgnored&&(r=!1)}if(r){for(;e.endsParent&&e.parent;)e=e.parent;return e}}if(e.endsWithParent)return p(e.parent,t,i)}function f(e){return 0===N.matcher.regexIndex?(S+=e[0],1):(I=!0,0)}function b(e){const n=e[0],i=t.substring(e.index),r=p(N,e,i);if(!r)return q;const s=N;N.endScope&&N.endScope._wrap?(d(),M.addKeyword(n,N.endScope._wrap)):N.endScope&&N.endScope._multi?(d(),u(N.endScope,e)):s.skip?S+=n:(s.returnEnd||s.excludeEnd||(S+=n),d(),s.excludeEnd&&(S=n));do{N.scope&&M.closeNode(),N.skip||N.subLanguage||(R+=N.relevance),N=N.parent}while(N!==r.parent);return r.starts&&h(r.starts,e),s.returnEnd?0:n.length}let m={};function w(i,s){const a=s&&s[0];if(S+=i,null==a)return d(),0;if("begin"===m.type&&"end"===s.type&&m.index===s.index&&""===a){if(S+=t.slice(s.index,s.index+1),!o){const t=Error(`0 width match regex (${e})`);throw t.languageName=e,t.badRule=m.rule,t}return 1}if(m=s,"begin"===s.type)return(e=>{const t=e[0],i=e.rule,r=new n(i),s=[i.__beforeBegin,i["on:begin"]];for(const n of s)if(n&&(n(e,r),r.isMatchIgnored))return f(t);return i.skip?S+=t:(i.excludeBegin&&(S+=t),d(),i.returnBegin||i.excludeBegin||(S=t)),h(i,e),i.returnBegin?0:t.length})(s);if("illegal"===s.type&&!r){const e=Error('Illegal lexeme "'+a+'" for mode "'+(N.scope||"")+'"');throw e.mode=N,e}if("end"===s.type){const e=b(s);if(e!==q)return e}if("illegal"===s.type&&""===a)return 1;if(j>1e5&&j>3*s.index)throw Error("potential infinite loop, way more iterations than matches");return S+=a,a.length}const y=O(e);if(!y)throw C(a.replace("{}",e)),Error('Unknown language: "'+e+'"');const _=X(y);let v="",N=s||_;const k={},M=new g.__emitter(g);(()=>{const e=[];for(let t=N;t!==y;t=t.parent)t.scope&&e.unshift(t.scope);e.forEach((e=>M.openNode(e)))})();let S="",R=0,A=0,j=0,I=!1;try{for(N.matcher.considerAll();;){j++,I?I=!1:N.matcher.considerAll(),N.matcher.lastIndex=A;const e=N.matcher.exec(t);if(!e)break;const n=w(t.substring(A,e.index),e);A=e.index+n}return w(t.substring(A)),M.closeAllNodes(),M.finalize(),v=M.toHTML(),{language:e,value:v,relevance:R,illegal:!1,_emitter:M,_top:N}}catch(n){if(n.message&&n.message.includes("Illegal"))return{language:e,value:F(t),illegal:!0,relevance:0,_illegalBy:{message:n.message,index:A,context:t.slice(A-100,A+100),mode:n.mode,resultSoFar:v},_emitter:M};if(o)return{language:e,value:F(t),illegal:!1,relevance:0,errorRaised:n,_emitter:M,_top:N};throw n}}function x(e,t){t=t||g.languages||Object.keys(i);const n=(e=>{const t={value:F(e),illegal:!1,relevance:0,_top:c,_emitter:new g.__emitter(g)};return t._emitter.addText(e),t})(e),r=t.filter(O).filter(N).map((t=>E(t,e,!1)));r.unshift(n);const s=r.sort(((e,t)=>{if(e.relevance!==t.relevance)return t.relevance-e.relevance;if(e.language&&t.language){if(O(e.language).supersetOf===t.language)return 1;if(O(t.language).supersetOf===e.language)return-1}return 0})),[o,a]=s,l=o;return l.secondBest=a,l}function w(e){let t=null;const n=(e=>{let t=e.className+" ";t+=e.parentNode?e.parentNode.className:"";const n=g.languageDetectRe.exec(t);if(n){const t=O(n[1]);return t||($(a.replace("{}",n[1])),$("Falling back to no-highlight mode for this block.",e)),t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>b(e)||O(e)))})(e);if(b(n))return;if(k("before:highlightElement",{el:e,language:n}),e.children.length>0&&(g.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(e)),g.throwUnescapedHTML))throw new G("One of your code blocks includes unescaped HTML.",e.innerHTML);t=e;const i=t.textContent,s=n?m(i,{language:n,ignoreIllegals:!0}):x(i);e.innerHTML=s.value,((e,t,n)=>{const i=t&&r[t]||n;e.classList.add("hljs"),e.classList.add("language-"+i)})(e,n,s.language),e.result={language:s.language,re:s.relevance,relevance:s.relevance},s.secondBest&&(e.secondBest={language:s.secondBest.language,relevance:s.secondBest.relevance}),k("after:highlightElement",{el:e,result:s,text:i})}let y=!1;function _(){"loading"!==document.readyState?document.querySelectorAll(g.cssSelector).forEach(w):y=!0}function O(e){return e=(e||"").toLowerCase(),i[e]||i[r[e]]}function v(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{r[e.toLowerCase()]=t}))}function N(e){const t=O(e);return t&&!t.disableAutodetect}function k(e,t){const n=e;s.forEach((e=>{e[n]&&e[n](t)}))}"undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{y&&_()}),!1),Object.assign(t,{highlight:m,highlightAuto:x,highlightAll:_,highlightElement:w,highlightBlock:e=>(U("10.7.0","highlightBlock will be removed entirely in v12.0"),U("10.7.0","Please use highlightElement now."),w(e)),configure:e=>{g=V(g,e)},initHighlighting:()=>{_(),U("10.6.0","initHighlighting() deprecated. Use highlightAll() now.")},initHighlightingOnLoad:()=>{_(),U("10.6.0","initHighlightingOnLoad() deprecated. Use highlightAll() now.")},registerLanguage:(e,n)=>{let r=null;try{r=n(t)}catch(t){if(C("Language definition for '{}' could not be registered.".replace("{}",e)),!o)throw t;C(t),r=c}r.name||(r.name=e),i[e]=r,r.rawDefinition=n.bind(null,t),r.aliases&&v(r.aliases,{languageName:e})},unregisterLanguage:e=>{delete i[e];for(const t of Object.keys(r))r[t]===e&&delete r[t]},listLanguages:()=>Object.keys(i),getLanguage:O,registerAliases:v,autoDetection:N,inherit:V,addPlugin:e=>{(e=>{e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{e["before:highlightBlock"](Object.assign({block:t.el},t))}),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),s.push(e)}}),t.debugMode=()=>{o=!1},t.safeMode=()=>{o=!0},t.versionString="11.7.0",t.regex={concat:p,lookahead:d,either:f,optional:h,anyNumberOfTimes:u};for(const t in M)"object"==typeof M[t]&&e.exports(M[t]);return Object.assign(t,M),t})({});export{J as default}; -------------------------------------------------------------------------------- /app/frontend/static/js/highlight/sql.min.js: -------------------------------------------------------------------------------- 1 | /*! `sql` grammar compiled for Highlight.js 11.7.0 */var hljsGrammar=(()=>{"use strict";return e=>{const r=e.regex,t=e.COMMENT("--","$"),a=["true","false","unknown"],n=["bigint","binary","blob","boolean","char","character","clob","date","dec","decfloat","decimal","float","int","integer","interval","nchar","nclob","national","numeric","real","row","smallint","time","timestamp","varchar","varying","varbinary"],i=["abs","acos","array_agg","asin","atan","avg","cast","ceil","ceiling","coalesce","corr","cos","cosh","count","covar_pop","covar_samp","cume_dist","dense_rank","deref","element","exp","extract","first_value","floor","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","last_value","lead","listagg","ln","log","log10","lower","max","min","mod","nth_value","ntile","nullif","percent_rank","percentile_cont","percentile_disc","position","position_regex","power","rank","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","row_number","sin","sinh","sqrt","stddev_pop","stddev_samp","substring","substring_regex","sum","tan","tanh","translate","translate_regex","treat","trim","trim_array","unnest","upper","value_of","var_pop","var_samp","width_bucket"],s=["create table","insert into","primary key","foreign key","not null","alter table","add constraint","grouping sets","on overflow","character set","respect nulls","ignore nulls","nulls first","nulls last","depth first","breadth first"],o=i,c=["abs","acos","all","allocate","alter","and","any","are","array","array_agg","array_max_cardinality","as","asensitive","asin","asymmetric","at","atan","atomic","authorization","avg","begin","begin_frame","begin_partition","between","bigint","binary","blob","boolean","both","by","call","called","cardinality","cascaded","case","cast","ceil","ceiling","char","char_length","character","character_length","check","classifier","clob","close","coalesce","collate","collect","column","commit","condition","connect","constraint","contains","convert","copy","corr","corresponding","cos","cosh","count","covar_pop","covar_samp","create","cross","cube","cume_dist","current","current_catalog","current_date","current_default_transform_group","current_path","current_role","current_row","current_schema","current_time","current_timestamp","current_path","current_role","current_transform_group_for_type","current_user","cursor","cycle","date","day","deallocate","dec","decimal","decfloat","declare","default","define","delete","dense_rank","deref","describe","deterministic","disconnect","distinct","double","drop","dynamic","each","element","else","empty","end","end_frame","end_partition","end-exec","equals","escape","every","except","exec","execute","exists","exp","external","extract","false","fetch","filter","first_value","float","floor","for","foreign","frame_row","free","from","full","function","fusion","get","global","grant","group","grouping","groups","having","hold","hour","identity","in","indicator","initial","inner","inout","insensitive","insert","int","integer","intersect","intersection","interval","into","is","join","json_array","json_arrayagg","json_exists","json_object","json_objectagg","json_query","json_table","json_table_primitive","json_value","lag","language","large","last_value","lateral","lead","leading","left","like","like_regex","listagg","ln","local","localtime","localtimestamp","log","log10","lower","match","match_number","match_recognize","matches","max","member","merge","method","min","minute","mod","modifies","module","month","multiset","national","natural","nchar","nclob","new","no","none","normalize","not","nth_value","ntile","null","nullif","numeric","octet_length","occurrences_regex","of","offset","old","omit","on","one","only","open","or","order","out","outer","over","overlaps","overlay","parameter","partition","pattern","per","percent","percent_rank","percentile_cont","percentile_disc","period","portion","position","position_regex","power","precedes","precision","prepare","primary","procedure","ptf","range","rank","reads","real","recursive","ref","references","referencing","regr_avgx","regr_avgy","regr_count","regr_intercept","regr_r2","regr_slope","regr_sxx","regr_sxy","regr_syy","release","result","return","returns","revoke","right","rollback","rollup","row","row_number","rows","running","savepoint","scope","scroll","search","second","seek","select","sensitive","session_user","set","show","similar","sin","sinh","skip","smallint","some","specific","specifictype","sql","sqlexception","sqlstate","sqlwarning","sqrt","start","static","stddev_pop","stddev_samp","submultiset","subset","substring","substring_regex","succeeds","sum","symmetric","system","system_time","system_user","table","tablesample","tan","tanh","then","time","timestamp","timezone_hour","timezone_minute","to","trailing","translate","translate_regex","translation","treat","trigger","trim","trim_array","true","truncate","uescape","union","unique","unknown","unnest","update","upper","user","using","value","values","value_of","var_pop","var_samp","varbinary","varchar","varying","versioning","when","whenever","where","width_bucket","window","with","within","without","year","add","asc","collation","desc","final","first","last","view"].filter((e=>!i.includes(e))),l={begin:r.concat(/\b/,r.either(...o),/\s*\(/),relevance:0,keywords:{built_in:o}};return{name:"SQL",case_insensitive:!0,illegal:/[{}]|<\//,keywords:{$pattern:/\b[\w\.]+/,keyword:((e,{exceptions:r,when:t}={})=>{const a=t;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:a(e)?e+"|0":e))})(c,{when:e=>e.length<3}),literal:a,type:n,built_in:["current_catalog","current_date","current_default_transform_group","current_path","current_role","current_schema","current_transform_group_for_type","current_user","session_user","system_time","system_user","current_time","localtime","current_timestamp","localtimestamp"]},contains:[{begin:r.either(...s),relevance:0,keywords:{$pattern:/[\w\.]+/,keyword:c.concat(s),literal:a,type:n}},{className:"type",begin:r.either("double precision","large object","with timezone","without timezone")},l,{className:"variable",begin:/@[a-z0-9]+/},{className:"string",variants:[{begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,t,{className:"operator",begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})();export default hljsGrammar; -------------------------------------------------------------------------------- /app/frontend/static/js/htmx-response-targets.min.js: -------------------------------------------------------------------------------- 1 | // 1.9.7 2 | (function(){var api;var attrPrefix='hx-target-';function startsWith(str,prefix){return str.substring(0,prefix.length)===prefix}function getRespCodeTarget(elt,respCodeNumber){if(!elt||!respCodeNumber){return null}var respCode=respCodeNumber.toString();var attrPossibilities=[respCode,respCode.substr(0,2)+'*',respCode.substr(0,2)+'x',respCode.substr(0,1)+'*',respCode.substr(0,1)+'x',respCode.substr(0,1)+'**',respCode.substr(0,1)+'xx','*','x','***','xxx'];if(startsWith(respCode,'4')||startsWith(respCode,'5')){attrPossibilities.push('error')}for(var i=0;i=0)){return"unset"}else{return n}}function re(t,r){var n=null;c(t,function(e){return n=R(t,e,r)});if(n!=="unset"){return n}}function h(e,t){var r=e.matches||e.matchesSelector||e.msMatchesSelector||e.mozMatchesSelector||e.webkitMatchesSelector||e.oMatchesSelector;return r&&r.call(e,t)}function q(e){var t=/<([a-z][^\/\0>\x20\t\r\n\f]*)/i;var r=t.exec(e);if(r){return r[1].toLowerCase()}else{return""}}function i(e,t){var r=new DOMParser;var n=r.parseFromString(e,"text/html");var i=n.body;while(t>0){t--;i=i.firstChild}if(i==null){i=te().createDocumentFragment()}return i}function H(e){return e.match(/",0);return r.querySelector("template").content}else{var n=q(e);switch(n){case"thead":case"tbody":case"tfoot":case"colgroup":case"caption":return i(""+e+"
",1);case"col":return i(""+e+"
",2);case"tr":return i(""+e+"
",2);case"td":case"th":return i(""+e+"
",3);case"script":case"style":return i("
"+e+"
",1);default:return i(e,0)}}}function ne(e){if(e){e()}}function L(e,t){return Object.prototype.toString.call(e)==="[object "+t+"]"}function A(e){return L(e,"Function")}function N(e){return L(e,"Object")}function ie(e){var t="htmx-internal-data";var r=e[t];if(!r){r=e[t]={}}return r}function I(e){var t=[];if(e){for(var r=0;r=0}function oe(e){if(e.getRootNode&&e.getRootNode()instanceof window.ShadowRoot){return te().body.contains(e.getRootNode().host)}else{return te().body.contains(e)}}function P(e){return e.trim().split(/\s+/)}function se(e,t){for(var r in t){if(t.hasOwnProperty(r)){e[r]=t[r]}}return e}function S(e){try{return JSON.parse(e)}catch(e){y(e);return null}}function M(){var e="htmx:localStorageTest";try{localStorage.setItem(e,e);localStorage.removeItem(e);return true}catch(e){return false}}function D(t){try{var e=new URL(t);if(e){t=e.pathname+e.search}if(!t.match("^/$")){t=t.replace(/\/+$/,"")}return t}catch(e){return t}}function e(e){return xr(te().body,function(){return eval(e)})}function t(t){var e=Y.on("htmx:load",function(e){t(e.detail.elt)});return e}function X(){Y.logger=function(e,t,r){if(console){console.log(t,e,r)}}}function U(){Y.logger=null}function E(e,t){if(t){return e.querySelector(t)}else{return E(te(),e)}}function f(e,t){if(t){return e.querySelectorAll(t)}else{return f(te(),e)}}function B(e,t){e=s(e);if(t){setTimeout(function(){B(e);e=null},t)}else{e.parentElement.removeChild(e)}}function F(e,t,r){e=s(e);if(r){setTimeout(function(){F(e,t);e=null},r)}else{e.classList&&e.classList.add(t)}}function n(e,t,r){e=s(e);if(r){setTimeout(function(){n(e,t);e=null},r)}else{if(e.classList){e.classList.remove(t);if(e.classList.length===0){e.removeAttribute("class")}}}}function V(e,t){e=s(e);e.classList.toggle(t)}function j(e,t){e=s(e);ae(e.parentElement.children,function(e){n(e,t)});F(e,t)}function d(e,t){e=s(e);if(e.closest){return e.closest(t)}else{do{if(e==null||h(e,t)){return e}}while(e=e&&u(e));return null}}function g(e,t){return e.substring(0,t.length)===t}function _(e,t){return e.substring(e.length-t.length)===t}function z(e){var t=e.trim();if(g(t,"<")&&_(t,"/>")){return t.substring(1,t.length-2)}else{return t}}function W(e,t){if(t.indexOf("closest ")===0){return[d(e,z(t.substr(8)))]}else if(t.indexOf("find ")===0){return[E(e,z(t.substr(5)))]}else if(t==="next"){return[e.nextElementSibling]}else if(t.indexOf("next ")===0){return[$(e,z(t.substr(5)))]}else if(t==="previous"){return[e.previousElementSibling]}else if(t.indexOf("previous ")===0){return[G(e,z(t.substr(9)))]}else if(t==="document"){return[document]}else if(t==="window"){return[window]}else if(t==="body"){return[document.body]}else{return te().querySelectorAll(z(t))}}var $=function(e,t){var r=te().querySelectorAll(t);for(var n=0;n=0;n--){var i=r[n];if(i.compareDocumentPosition(e)===Node.DOCUMENT_POSITION_FOLLOWING){return i}}};function le(e,t){if(t){return W(e,t)[0]}else{return W(te().body,e)[0]}}function s(e){if(L(e,"String")){return E(e)}else{return e}}function J(e,t,r){if(A(t)){return{target:te().body,event:e,listener:t}}else{return{target:s(e),event:t,listener:r}}}function Z(t,r,n){Pr(function(){var e=J(t,r,n);e.target.addEventListener(e.event,e.listener)});var e=A(r);return e?r:n}function K(t,r,n){Pr(function(){var e=J(t,r,n);e.target.removeEventListener(e.event,e.listener)});return A(r)?r:n}var he=te().createElement("output");function de(e,t){var r=re(e,t);if(r){if(r==="this"){return[ve(e,t)]}else{var n=W(e,r);if(n.length===0){y('The selector "'+r+'" on '+t+" returned no matches!");return[he]}else{return n}}}}function ve(e,t){return c(e,function(e){return ee(e,t)!=null})}function ge(e){var t=re(e,"hx-target");if(t){if(t==="this"){return ve(e,"hx-target")}else{return le(e,t)}}else{var r=ie(e);if(r.boosted){return te().body}else{return e}}}function me(e){var t=Y.config.attributesToSettle;for(var r=0;r0){o=e.substr(0,e.indexOf(":"));t=e.substr(e.indexOf(":")+1,e.length)}else{o=e}var r=te().querySelectorAll(t);if(r){ae(r,function(e){var t;var r=i.cloneNode(true);t=te().createDocumentFragment();t.appendChild(r);if(!xe(o,e)){t=r}var n={shouldSwap:true,target:e,fragment:t};if(!fe(e,"htmx:oobBeforeSwap",n))return;e=n.target;if(n["shouldSwap"]){De(o,e,e,t,a)}ae(a.elts,function(e){fe(e,"htmx:oobAfterSwap",n)})});i.parentNode.removeChild(i)}else{i.parentNode.removeChild(i);ue(te().body,"htmx:oobErrorNoTarget",{content:i})}return e}function be(e,t,r){var n=re(e,"hx-select-oob");if(n){var i=n.split(",");for(let e=0;e0){var r=t.replace("'","\\'");var n=e.tagName.replace(":","\\:");var i=o.querySelector(n+"[id='"+r+"']");if(i&&i!==o){var a=e.cloneNode();pe(e,i);s.tasks.push(function(){pe(e,a)})}}})}function Ee(e){return function(){n(e,Y.config.addedClass);Dt(e);Ct(e);Ce(e);fe(e,"htmx:load")}}function Ce(e){var t="[autofocus]";var r=h(e,t)?e:e.querySelector(t);if(r!=null){r.focus()}}function a(e,t,r,n){Se(e,r,n);while(r.childNodes.length>0){var i=r.firstChild;F(i,Y.config.addedClass);e.insertBefore(i,t);if(i.nodeType!==Node.TEXT_NODE&&i.nodeType!==Node.COMMENT_NODE){n.tasks.push(Ee(i))}}}function Te(e,t){var r=0;while(r-1){var t=e.replace(/]*>|>)([\s\S]*?)<\/svg>/gim,"");var r=t.match(/]*>|>)([\s\S]*?)<\/title>/im);if(r){return r[2]}}}function Ue(e,t,r,n,i,a){i.title=Xe(n);var o=l(n);if(o){be(r,o,i);o=Me(r,o,a);we(o);return De(e,r,t,o,i)}}function Be(e,t,r){var n=e.getResponseHeader(t);if(n.indexOf("{")===0){var i=S(n);for(var a in i){if(i.hasOwnProperty(a)){var o=i[a];if(!N(o)){o={value:o}}fe(r,a,o)}}}else{var s=n.split(",");for(var l=0;l0){var o=t[0];if(o==="]"){n--;if(n===0){if(a===null){i=i+"true"}t.shift();i+=")})";try{var s=xr(e,function(){return Function(i)()},function(){return true});s.source=i;return s}catch(e){ue(te().body,"htmx:syntax:error",{error:e,source:i});return null}}}else if(o==="["){n++}if($e(o,a,r)){i+="(("+r+"."+o+") ? ("+r+"."+o+") : (window."+o+"))"}else{i=i+o}a=t.shift()}}}function x(e,t){var r="";while(e.length>0&&!e[0].match(t)){r+=e.shift()}return r}var Je="input, textarea, select";function Ze(e){var t=ee(e,"hx-trigger");var r=[];if(t){var n=We(t);do{x(n,ze);var i=n.length;var a=x(n,/[,\[\s]/);if(a!==""){if(a==="every"){var o={trigger:"every"};x(n,ze);o.pollInterval=v(x(n,/[,\[\s]/));x(n,ze);var s=Ge(e,n,"event");if(s){o.eventFilter=s}r.push(o)}else if(a.indexOf("sse:")===0){r.push({trigger:"sse",sseEvent:a.substr(4)})}else{var l={trigger:a};var s=Ge(e,n,"event");if(s){l.eventFilter=s}while(n.length>0&&n[0]!==","){x(n,ze);var u=n.shift();if(u==="changed"){l.changed=true}else if(u==="once"){l.once=true}else if(u==="consume"){l.consume=true}else if(u==="delay"&&n[0]===":"){n.shift();l.delay=v(x(n,p))}else if(u==="from"&&n[0]===":"){n.shift();var f=x(n,p);if(f==="closest"||f==="find"||f==="next"||f==="previous"){n.shift();var c=x(n,p);if(c.length>0){f+=" "+c}}l.from=f}else if(u==="target"&&n[0]===":"){n.shift();l.target=x(n,p)}else if(u==="throttle"&&n[0]===":"){n.shift();l.throttle=v(x(n,p))}else if(u==="queue"&&n[0]===":"){n.shift();l.queue=x(n,p)}else if((u==="root"||u==="threshold")&&n[0]===":"){n.shift();l[u]=x(n,p)}else{ue(e,"htmx:syntax:error",{token:n.shift()})}}r.push(l)}}if(n.length===i){ue(e,"htmx:syntax:error",{token:n.shift()})}x(n,ze)}while(n[0]===","&&n.shift())}if(r.length>0){return r}else if(h(e,"form")){return[{trigger:"submit"}]}else if(h(e,'input[type="button"], input[type="submit"]')){return[{trigger:"click"}]}else if(h(e,Je)){return[{trigger:"change"}]}else{return[{trigger:"click"}]}}function Ke(e){ie(e).cancelled=true}function Ye(e,t,r){var n=ie(e);n.timeout=setTimeout(function(){if(oe(e)&&n.cancelled!==true){if(!nt(r,e,Ut("hx:poll:trigger",{triggerSpec:r,target:e}))){t(e)}Ye(e,t,r)}},r.pollInterval)}function Qe(e){return location.hostname===e.hostname&&Q(e,"href")&&Q(e,"href").indexOf("#")!==0}function et(t,r,e){if(t.tagName==="A"&&Qe(t)&&(t.target===""||t.target==="_self")||t.tagName==="FORM"){r.boosted=true;var n,i;if(t.tagName==="A"){n="get";i=Q(t,"href")}else{var a=Q(t,"method");n=a?a.toLowerCase():"get";if(n==="get"){}i=Q(t,"action")}e.forEach(function(e){it(t,function(e,t){if(d(e,Y.config.disableSelector)){m(e);return}ce(n,i,e,t)},r,e,true)})}}function tt(e,t){if(e.type==="submit"||e.type==="click"){if(t.tagName==="FORM"){return true}if(h(t,'input[type="submit"], button')&&d(t,"form")!==null){return true}if(t.tagName==="A"&&t.href&&(t.getAttribute("href")==="#"||t.getAttribute("href").indexOf("#")!==0)){return true}}return false}function rt(e,t){return ie(e).boosted&&e.tagName==="A"&&t.type==="click"&&(t.ctrlKey||t.metaKey)}function nt(e,t,r){var n=e.eventFilter;if(n){try{return n.call(t,r)!==true}catch(e){ue(te().body,"htmx:eventFilter:error",{error:e,source:n.source});return true}}return false}function it(a,o,e,s,l){var u=ie(a);var t;if(s.from){t=W(a,s.from)}else{t=[a]}if(s.changed){t.forEach(function(e){var t=ie(e);t.lastValue=e.value})}ae(t,function(n){var i=function(e){if(!oe(a)){n.removeEventListener(s.trigger,i);return}if(rt(a,e)){return}if(l||tt(e,a)){e.preventDefault()}if(nt(s,a,e)){return}var t=ie(e);t.triggerSpec=s;if(t.handledFor==null){t.handledFor=[]}if(t.handledFor.indexOf(a)<0){t.handledFor.push(a);if(s.consume){e.stopPropagation()}if(s.target&&e.target){if(!h(e.target,s.target)){return}}if(s.once){if(u.triggeredOnce){return}else{u.triggeredOnce=true}}if(s.changed){var r=ie(n);if(r.lastValue===n.value){return}r.lastValue=n.value}if(u.delayed){clearTimeout(u.delayed)}if(u.throttle){return}if(s.throttle){if(!u.throttle){o(a,e);u.throttle=setTimeout(function(){u.throttle=null},s.throttle)}}else if(s.delay){u.delayed=setTimeout(function(){o(a,e)},s.delay)}else{fe(a,"htmx:trigger");o(a,e)}}};if(e.listenerInfos==null){e.listenerInfos=[]}e.listenerInfos.push({trigger:s.trigger,listener:i,on:n});n.addEventListener(s.trigger,i)})}var at=false;var ot=null;function st(){if(!ot){ot=function(){at=true};window.addEventListener("scroll",ot);setInterval(function(){if(at){at=false;ae(te().querySelectorAll("[hx-trigger='revealed'],[data-hx-trigger='revealed']"),function(e){lt(e)})}},200)}}function lt(t){if(!o(t,"data-hx-revealed")&&k(t)){t.setAttribute("data-hx-revealed","true");var e=ie(t);if(e.initHash){fe(t,"revealed")}else{t.addEventListener("htmx:afterProcessNode",function(e){fe(t,"revealed")},{once:true})}}}function ut(e,t,r){var n=P(r);for(var i=0;i=0){var t=dt(n);setTimeout(function(){ft(s,r,n+1)},t)}};t.onopen=function(e){n=0};ie(s).webSocket=t;t.addEventListener("message",function(e){if(ct(s)){return}var t=e.data;C(s,function(e){t=e.transformResponse(t,null,s)});var r=T(s);var n=l(t);var i=I(n.children);for(var a=0;a0){fe(u,"htmx:validation:halted",i);return}t.send(JSON.stringify(l));if(tt(e,u)){e.preventDefault()}})}else{ue(u,"htmx:noWebSocketSourceError")}}function dt(e){var t=Y.config.wsReconnectDelay;if(typeof t==="function"){return t(e)}if(t==="full-jitter"){var r=Math.min(e,6);var n=1e3*Math.pow(2,r);return n*Math.random()}y('htmx.config.wsReconnectDelay must either be a function or the string "full-jitter"')}function vt(e,t,r){var n=P(r);for(var i=0;i0){var o=n.shift();var s=o.match(/^\s*([a-zA-Z:\-\.]+:)(.*)/);if(a===0&&s){o.split(":");i=s[1].slice(0,-1);r[i]=s[2]}else{r[i]+=o}a+=Nt(o)}for(var l in r){It(e,l,r[l])}}}function Pt(t){Re(t);for(var e=0;eY.config.historyCacheSize){i.shift()}while(i.length>0){try{localStorage.setItem("htmx-history-cache",JSON.stringify(i));break}catch(e){ue(te().body,"htmx:historyCacheError",{cause:e,cache:i});i.shift()}}}function _t(e){if(!M()){return null}e=D(e);var t=S(localStorage.getItem("htmx-history-cache"))||[];for(var r=0;r=200&&this.status<400){fe(te().body,"htmx:historyCacheMissLoad",o);var e=l(this.response);e=e.querySelector("[hx-history-elt],[data-hx-history-elt]")||e;var t=Vt();var r=T(t);var n=Xe(this.response);if(n){var i=E("title");if(i){i.innerHTML=n}else{window.document.title=n}}Pe(t,e,r);Jt(r.tasks);Ft=a;fe(te().body,"htmx:historyRestore",{path:a,cacheMiss:true,serverResponse:this.response})}else{ue(te().body,"htmx:historyCacheMissLoadError",o)}};e.send()}function Kt(e){Wt();e=e||location.pathname+location.search;var t=_t(e);if(t){var r=l(t.content);var n=Vt();var i=T(n);Pe(n,r,i);Jt(i.tasks);document.title=t.title;setTimeout(function(){window.scrollTo(0,t.scroll)},0);Ft=e;fe(te().body,"htmx:historyRestore",{path:e,item:t})}else{if(Y.config.refreshOnHistoryMiss){window.location.reload(true)}else{Zt(e)}}}function Yt(e){var t=de(e,"hx-indicator");if(t==null){t=[e]}ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)+1;e.classList["add"].call(e.classList,Y.config.requestClass)});return t}function Qt(e){var t=de(e,"hx-disabled-elt");if(t==null){t=[]}ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)+1;e.setAttribute("disabled","")});return t}function er(e,t){ae(e,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.classList["remove"].call(e.classList,Y.config.requestClass)}});ae(t,function(e){var t=ie(e);t.requestCount=(t.requestCount||0)-1;if(t.requestCount===0){e.removeAttribute("disabled")}})}function tr(e,t){for(var r=0;r=0}function dr(e,t){var r=t?t:re(e,"hx-swap");var n={swapStyle:ie(e).boosted?"innerHTML":Y.config.defaultSwapStyle,swapDelay:Y.config.defaultSwapDelay,settleDelay:Y.config.defaultSettleDelay};if(Y.config.scrollIntoViewOnBoost&&ie(e).boosted&&!hr(e)){n["show"]="top"}if(r){var i=P(r);if(i.length>0){for(var a=0;a0?l.join(":"):null;n["scroll"]=u;n["scrollTarget"]=f}else if(o.indexOf("show:")===0){var c=o.substr(5);var l=c.split(":");var h=l.pop();var f=l.length>0?l.join(":"):null;n["show"]=h;n["showTarget"]=f}else if(o.indexOf("focus-scroll:")===0){var d=o.substr("focus-scroll:".length);n["focusScroll"]=d=="true"}else if(a==0){n["swapStyle"]=o}else{y("Unknown modifier in hx-swap: "+o)}}}}return n}function vr(e){return re(e,"hx-encoding")==="multipart/form-data"||h(e,"form")&&Q(e,"enctype")==="multipart/form-data"}function gr(t,r,n){var i=null;C(r,function(e){if(i==null){i=e.encodeParameters(t,n,r)}});if(i!=null){return i}else{if(vr(r)){return ur(n)}else{return lr(n)}}}function T(e){return{tasks:[],elts:[e]}}function mr(e,t){var r=e[0];var n=e[e.length-1];if(t.scroll){var i=null;if(t.scrollTarget){i=le(r,t.scrollTarget)}if(t.scroll==="top"&&(r||i)){i=i||r;i.scrollTop=0}if(t.scroll==="bottom"&&(n||i)){i=i||n;i.scrollTop=i.scrollHeight}}if(t.show){var i=null;if(t.showTarget){var a=t.showTarget;if(t.showTarget==="window"){a="body"}i=le(r,a)}if(t.show==="top"&&(r||i)){i=i||r;i.scrollIntoView({block:"start",behavior:Y.config.scrollBehavior})}if(t.show==="bottom"&&(n||i)){i=i||n;i.scrollIntoView({block:"end",behavior:Y.config.scrollBehavior})}}}function pr(e,t,r,n){if(n==null){n={}}if(e==null){return n}var i=ee(e,t);if(i){var a=i.trim();var o=r;if(a==="unset"){return null}if(a.indexOf("javascript:")===0){a=a.substr(11);o=true}else if(a.indexOf("js:")===0){a=a.substr(3);o=true}if(a.indexOf("{")!==0){a="{"+a+"}"}var s;if(o){s=xr(e,function(){return Function("return ("+a+")")()},{})}else{s=S(a)}for(var l in s){if(s.hasOwnProperty(l)){if(n[l]==null){n[l]=s[l]}}}}return pr(u(e),t,r,n)}function xr(e,t,r){if(Y.config.allowEval){return t()}else{ue(e,"htmx:evalDisallowedError");return r}}function yr(e,t){return pr(e,"hx-vars",true,t)}function br(e,t){return pr(e,"hx-vals",false,t)}function wr(e){return se(yr(e),br(e))}function Sr(t,r,n){if(n!==null){try{t.setRequestHeader(r,n)}catch(e){t.setRequestHeader(r,encodeURIComponent(n));t.setRequestHeader(r+"-URI-AutoEncoded","true")}}}function Er(t){if(t.responseURL&&typeof URL!=="undefined"){try{var e=new URL(t.responseURL);return e.pathname+e.search}catch(e){ue(te().body,"htmx:badResponseUrl",{url:t.responseURL})}}}function O(e,t){return e.getAllResponseHeaders().match(t)}function Cr(e,t,r){e=e.toLowerCase();if(r){if(r instanceof Element||L(r,"String")){return ce(e,t,null,null,{targetOverride:s(r),returnPromise:true})}else{return ce(e,t,s(r.source),r.event,{handler:r.handler,headers:r.headers,values:r.values,targetOverride:s(r.target),swapOverride:r.swap,returnPromise:true})}}else{return ce(e,t,null,null,{returnPromise:true})}}function Tr(e){var t=[];while(e){t.push(e);e=e.parentElement}return t}function Or(e,t,r){var n;var i;if(typeof URL==="function"){i=new URL(t,document.location.href);var a=document.location.origin;n=a===i.origin}else{i=t;n=g(t,document.location.origin)}if(Y.config.selfRequestsOnly){if(!n){return false}}return fe(e,"htmx:validateUrl",se({url:i,sameHost:n},r))}function ce(t,r,n,i,a,e){var o=null;var s=null;a=a!=null?a:{};if(a.returnPromise&&typeof Promise!=="undefined"){var l=new Promise(function(e,t){o=e;s=t})}if(n==null){n=te().body}var M=a.handler||qr;if(!oe(n)){ne(o);return l}var u=a.targetOverride||ge(n);if(u==null||u==he){ue(n,"htmx:targetError",{target:ee(n,"hx-target")});ne(s);return l}var f=ie(n);var c=f.lastButtonClicked;if(c){var h=Q(c,"formaction");if(h!=null){r=h}var d=Q(c,"formmethod");if(d!=null){if(d.toLowerCase()!=="dialog"){t=d}}}var v=re(n,"hx-confirm");if(e===undefined){var D=function(e){return ce(t,r,n,i,a,!!e)};var X={target:u,elt:n,path:r,verb:t,triggeringEvent:i,etc:a,issueRequest:D,question:v};if(fe(n,"htmx:confirm",X)===false){ne(o);return l}}var g=n;var m=re(n,"hx-sync");var p=null;var x=false;if(m){var U=m.split(":");var B=U[0].trim();if(B==="this"){g=ve(n,"hx-sync")}else{g=le(n,B)}m=(U[1]||"drop").trim();f=ie(g);if(m==="drop"&&f.xhr&&f.abortable!==true){ne(o);return l}else if(m==="abort"){if(f.xhr){ne(o);return l}else{x=true}}else if(m==="replace"){fe(g,"htmx:abort")}else if(m.indexOf("queue")===0){var F=m.split(" ");p=(F[1]||"last").trim()}}if(f.xhr){if(f.abortable){fe(g,"htmx:abort")}else{if(p==null){if(i){var y=ie(i);if(y&&y.triggerSpec&&y.triggerSpec.queue){p=y.triggerSpec.queue}}if(p==null){p="last"}}if(f.queuedRequests==null){f.queuedRequests=[]}if(p==="first"&&f.queuedRequests.length===0){f.queuedRequests.push(function(){ce(t,r,n,i,a)})}else if(p==="all"){f.queuedRequests.push(function(){ce(t,r,n,i,a)})}else if(p==="last"){f.queuedRequests=[];f.queuedRequests.push(function(){ce(t,r,n,i,a)})}ne(o);return l}}var b=new XMLHttpRequest;f.xhr=b;f.abortable=x;var w=function(){f.xhr=null;f.abortable=false;if(f.queuedRequests!=null&&f.queuedRequests.length>0){var e=f.queuedRequests.shift();e()}};var V=re(n,"hx-prompt");if(V){var S=prompt(V);if(S===null||!fe(n,"htmx:prompt",{prompt:S,target:u})){ne(o);w();return l}}if(v&&!e){if(!confirm(v)){ne(o);w();return l}}var E=fr(n,u,S);if(a.headers){E=se(E,a.headers)}var j=or(n,t);var C=j.errors;var T=j.values;if(a.values){T=se(T,a.values)}var _=wr(n);var z=se(T,_);var O=cr(z,n);if(t!=="get"&&!vr(n)){E["Content-Type"]="application/x-www-form-urlencoded"}if(Y.config.getCacheBusterParam&&t==="get"){O["org.htmx.cache-buster"]=Q(u,"id")||"true"}if(r==null||r===""){r=te().location.href}var R=pr(n,"hx-request");var W=ie(n).boosted;var q=Y.config.methodsThatUseUrlParams.indexOf(t)>=0;var H={boosted:W,useUrlParams:q,parameters:O,unfilteredParameters:z,headers:E,target:u,verb:t,errors:C,withCredentials:a.credentials||R.credentials||Y.config.withCredentials,timeout:a.timeout||R.timeout||Y.config.timeout,path:r,triggeringEvent:i};if(!fe(n,"htmx:configRequest",H)){ne(o);w();return l}r=H.path;t=H.verb;E=H.headers;O=H.parameters;C=H.errors;q=H.useUrlParams;if(C&&C.length>0){fe(n,"htmx:validation:halted",H);ne(o);w();return l}var $=r.split("#");var G=$[0];var L=$[1];var A=r;if(q){A=G;var J=Object.keys(O).length!==0;if(J){if(A.indexOf("?")<0){A+="?"}else{A+="&"}A+=lr(O);if(L){A+="#"+L}}}if(!Or(n,A,H)){ue(n,"htmx:invalidPath",H);ne(s);return l}b.open(t.toUpperCase(),A,true);b.overrideMimeType("text/html");b.withCredentials=H.withCredentials;b.timeout=H.timeout;if(R.noHeaders){}else{for(var N in E){if(E.hasOwnProperty(N)){var Z=E[N];Sr(b,N,Z)}}}var I={xhr:b,target:u,requestConfig:H,etc:a,boosted:W,pathInfo:{requestPath:r,finalRequestPath:A,anchor:L}};b.onload=function(){try{var e=Tr(n);I.pathInfo.responsePath=Er(b);M(n,I);er(k,P);fe(n,"htmx:afterRequest",I);fe(n,"htmx:afterOnLoad",I);if(!oe(n)){var t=null;while(e.length>0&&t==null){var r=e.shift();if(oe(r)){t=r}}if(t){fe(t,"htmx:afterRequest",I);fe(t,"htmx:afterOnLoad",I)}}ne(o);w()}catch(e){ue(n,"htmx:onLoadError",se({error:e},I));throw e}};b.onerror=function(){er(k,P);ue(n,"htmx:afterRequest",I);ue(n,"htmx:sendError",I);ne(s);w()};b.onabort=function(){er(k,P);ue(n,"htmx:afterRequest",I);ue(n,"htmx:sendAbort",I);ne(s);w()};b.ontimeout=function(){er(k,P);ue(n,"htmx:afterRequest",I);ue(n,"htmx:timeout",I);ne(s);w()};if(!fe(n,"htmx:beforeRequest",I)){ne(o);w();return l}var k=Yt(n);var P=Qt(n);ae(["loadstart","loadend","progress","abort"],function(t){ae([b,b.upload],function(e){e.addEventListener(t,function(e){fe(n,"htmx:xhr:"+t,{lengthComputable:e.lengthComputable,loaded:e.loaded,total:e.total})})})});fe(n,"htmx:beforeSend",I);var K=q?null:gr(b,n,O);b.send(K);return l}function Rr(e,t){var r=t.xhr;var n=null;var i=null;if(O(r,/HX-Push:/i)){n=r.getResponseHeader("HX-Push");i="push"}else if(O(r,/HX-Push-Url:/i)){n=r.getResponseHeader("HX-Push-Url");i="push"}else if(O(r,/HX-Replace-Url:/i)){n=r.getResponseHeader("HX-Replace-Url");i="replace"}if(n){if(n==="false"){return{}}else{return{type:i,path:n}}}var a=t.pathInfo.finalRequestPath;var o=t.pathInfo.responsePath;var s=re(e,"hx-push-url");var l=re(e,"hx-replace-url");var u=ie(e).boosted;var f=null;var c=null;if(s){f="push";c=s}else if(l){f="replace";c=l}else if(u){f="push";c=o||a}if(c){if(c==="false"){return{}}if(c==="true"){c=o||a}if(t.pathInfo.anchor&&c.indexOf("#")===-1){c=c+"#"+t.pathInfo.anchor}return{type:f,path:c}}else{return{}}}function qr(l,u){var f=u.xhr;var c=u.target;var e=u.etc;var t=u.requestConfig;if(!fe(l,"htmx:beforeOnLoad",u))return;if(O(f,/HX-Trigger:/i)){Be(f,"HX-Trigger",l)}if(O(f,/HX-Location:/i)){Wt();var r=f.getResponseHeader("HX-Location");var h;if(r.indexOf("{")===0){h=S(r);r=h["path"];delete h["path"]}Cr("GET",r,h).then(function(){$t(r)});return}var n=O(f,/HX-Refresh:/i)&&"true"===f.getResponseHeader("HX-Refresh");if(O(f,/HX-Redirect:/i)){location.href=f.getResponseHeader("HX-Redirect");n&&location.reload();return}if(n){location.reload();return}if(O(f,/HX-Retarget:/i)){u.target=te().querySelector(f.getResponseHeader("HX-Retarget"))}var d=Rr(l,u);var i=f.status>=200&&f.status<400&&f.status!==204;var v=f.response;var a=f.status>=400;var g=Y.config.ignoreTitle;var o=se({shouldSwap:i,serverResponse:v,isError:a,ignoreTitle:g},u);if(!fe(c,"htmx:beforeSwap",o))return;c=o.target;v=o.serverResponse;a=o.isError;g=o.ignoreTitle;u.target=c;u.failed=a;u.successful=!a;if(o.shouldSwap){if(f.status===286){Ke(l)}C(l,function(e){v=e.transformResponse(v,f,l)});if(d.type){Wt()}var s=e.swapOverride;if(O(f,/HX-Reswap:/i)){s=f.getResponseHeader("HX-Reswap")}var h=dr(l,s);if(h.hasOwnProperty("ignoreTitle")){g=h.ignoreTitle}c.classList.add(Y.config.swappingClass);var m=null;var p=null;var x=function(){try{var e=document.activeElement;var t={};try{t={elt:e,start:e?e.selectionStart:null,end:e?e.selectionEnd:null}}catch(e){}var r;if(O(f,/HX-Reselect:/i)){r=f.getResponseHeader("HX-Reselect")}var n=T(c);Ue(h.swapStyle,c,l,v,n,r);if(t.elt&&!oe(t.elt)&&Q(t.elt,"id")){var i=document.getElementById(Q(t.elt,"id"));var a={preventScroll:h.focusScroll!==undefined?!h.focusScroll:!Y.config.defaultFocusScroll};if(i){if(t.start&&i.setSelectionRange){try{i.setSelectionRange(t.start,t.end)}catch(e){}}i.focus(a)}}c.classList.remove(Y.config.swappingClass);ae(n.elts,function(e){if(e.classList){e.classList.add(Y.config.settlingClass)}fe(e,"htmx:afterSwap",u)});if(O(f,/HX-Trigger-After-Swap:/i)){var o=l;if(!oe(l)){o=te().body}Be(f,"HX-Trigger-After-Swap",o)}var s=function(){ae(n.tasks,function(e){e.call()});ae(n.elts,function(e){if(e.classList){e.classList.remove(Y.config.settlingClass)}fe(e,"htmx:afterSettle",u)});if(d.type){fe(te().body,"htmx:beforeHistoryUpdate",se({history:d},u));if(d.type==="push"){$t(d.path);fe(te().body,"htmx:pushedIntoHistory",{path:d.path})}else{Gt(d.path);fe(te().body,"htmx:replacedInHistory",{path:d.path})}}if(u.pathInfo.anchor){var e=te().getElementById(u.pathInfo.anchor);if(e){e.scrollIntoView({block:"start",behavior:"auto"})}}if(n.title&&!g){var t=E("title");if(t){t.innerHTML=n.title}else{window.document.title=n.title}}mr(n.elts,h);if(O(f,/HX-Trigger-After-Settle:/i)){var r=l;if(!oe(l)){r=te().body}Be(f,"HX-Trigger-After-Settle",r)}ne(m)};if(h.settleDelay>0){setTimeout(s,h.settleDelay)}else{s()}}catch(e){ue(l,"htmx:swapError",u);ne(p);throw e}};var y=Y.config.globalViewTransitions;if(h.hasOwnProperty("transition")){y=h.transition}if(y&&fe(l,"htmx:beforeTransition",u)&&typeof Promise!=="undefined"&&document.startViewTransition){var b=new Promise(function(e,t){m=e;p=t});var w=x;x=function(){document.startViewTransition(function(){w();return b})}}if(h.swapDelay>0){setTimeout(x,h.swapDelay)}else{x()}}if(a){ue(l,"htmx:responseError",se({error:"Response Status Error Code "+f.status+" from "+u.pathInfo.requestPath},u))}}var Hr={};function Lr(){return{init:function(e){return null},onEvent:function(e,t){return true},transformResponse:function(e,t,r){return e},isInlineSwap:function(e){return false},handleSwap:function(e,t,r,n){return false},encodeParameters:function(e,t,r){return null}}}function Ar(e,t){if(t.init){t.init(r)}Hr[e]=se(Lr(),t)}function Nr(e){delete Hr[e]}function Ir(e,r,n){if(e==undefined){return r}if(r==undefined){r=[]}if(n==undefined){n=[]}var t=ee(e,"hx-ext");if(t){ae(t.split(","),function(e){e=e.replace(/ /g,"");if(e.slice(0,7)=="ignore:"){n.push(e.slice(7));return}if(n.indexOf(e)<0){var t=Hr[e];if(t&&r.indexOf(t)<0){r.push(t)}}})}return Ir(u(e),r,n)}var kr=false;te().addEventListener("DOMContentLoaded",function(){kr=true});function Pr(e){if(kr||te().readyState==="complete"){e()}else{te().addEventListener("DOMContentLoaded",e)}}function Mr(){if(Y.config.includeIndicatorStyles!==false){te().head.insertAdjacentHTML("beforeend","")}}function Dr(){var e=te().querySelector('meta[name="htmx-config"]');if(e){return S(e.content)}else{return null}}function Xr(){var e=Dr();if(e){Y.config=se(Y.config,e)}}Pr(function(){Xr();Mr();var e=te().body;Dt(e);var t=te().querySelectorAll("[hx-trigger='restored'],[data-hx-trigger='restored']");e.addEventListener("htmx:abort",function(e){var t=e.target;var r=ie(t);if(r&&r.xhr){r.xhr.abort()}});var r=window.onpopstate;window.onpopstate=function(e){if(e.state&&e.state.htmx){Kt();ae(t,function(e){fe(e,"htmx:restored",{document:te(),triggerEvent:fe})})}else{if(r){r(e)}}};setTimeout(function(){fe(e,"htmx:load",{});e=null},0)});return Y}()}); -------------------------------------------------------------------------------- /app/mailer.py: -------------------------------------------------------------------------------- 1 | """ 2 | Format and send email 3 | """ 4 | from email import encoders 5 | from email.mime.multipart import MIMEMultipart 6 | from email.mime.base import MIMEBase 7 | from email.mime.text import MIMEText 8 | import repo 9 | import smtplib 10 | 11 | def send(content, format, to, file_name): 12 | """ 13 | Main function for sending alerts/reports 14 | """ 15 | assert to is not None, "no email provided" 16 | smtp_user, smtp_pass, smtp_url, smtp_email = repo.get_email_params() 17 | url, port = smtp_url.split(":") 18 | msg = MIMEMultipart('alternative') 19 | msg['Subject'] = f"Gitbi report: {file_name}" 20 | msg['From'] = smtp_email 21 | msg['To'] = to 22 | match format: 23 | case "html": 24 | msg.attach(MIMEText("Requires html", 'plain')) 25 | msg.attach(MIMEText(content, "html")) 26 | case "text": 27 | msg.attach(MIMEText(content, 'plain')) 28 | case "csv" | "json": 29 | msg.attach(MIMEText(file_name, 'plain')) 30 | result = MIMEBase('application', "octet-stream") 31 | result.set_payload(content) 32 | encoders.encode_base64(result) 33 | result.add_header("Content-Disposition", f"attachment; filename={file_name}.{format}") 34 | msg.attach(result) 35 | with smtplib.SMTP(url, port) as server: 36 | server.login(smtp_user, smtp_pass) 37 | server.sendmail(smtp_email, to, msg.as_string()) 38 | return True 39 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main app file 3 | """ 4 | from starlette.applications import Starlette 5 | from starlette.exceptions import HTTPException 6 | from starlette.routing import Mount, Route 7 | from starlette.staticfiles import StaticFiles 8 | import auth 9 | import routes_dashboard, routes_execute, routes_listing, routes_query 10 | import utils 11 | 12 | # Error types 13 | # 404 RuntimeError file not accessible 14 | # 500 NameError variables not set 15 | # 500 ValueError bad query 16 | 17 | async def server_error(request, exc): 18 | data = { 19 | "request": request, 20 | "path": dict(request).get("path"), 21 | "code": exc.status_code, 22 | "message": exc.detail 23 | } 24 | return utils.TEMPLATES.TemplateResponse(name='partial_error.html', context=data, status_code=exc.status_code) 25 | 26 | routes = [ 27 | # routes execute 28 | Route('/execute/{db:str}', endpoint=routes_execute.execute_route, methods=("POST", ), name="execute_route"), 29 | Route('/report/{db:str}/{file:str}/{state:str}/{format:str}', endpoint=routes_execute.report_route, name="report_route"), 30 | # routes_listing 31 | Route("/", endpoint=routes_listing.home_default_route, name="home_default_route"), 32 | Route("/home/{state:str}", endpoint=routes_listing.home_route, name="home_route"), 33 | Route("/resources/{state:str}", endpoint=routes_listing.resources_route, name="resources_route"), 34 | Route("/dbdetails/{db:str}", endpoint=routes_listing.db_details_route, name="db_details_route"), 35 | # routes query 36 | Route('/query/delete/{db:str}/{file:str}', endpoint=routes_query.delete_route, name="query_delete_route"), 37 | Route('/query/save/{db:str}', endpoint=routes_query.save_route, methods=("POST", ), name="query_save_route"), 38 | Route('/query/{db:str}', endpoint=routes_query.query_route, name="query_route"), 39 | Route('/query/{db:str}/{file:str}/{state:str}', endpoint=routes_query.saved_query_route, name="saved_query_route"), 40 | # routes dashboard 41 | Route('/dashboard/delete/{file:str}', endpoint=routes_dashboard.delete_route, name="dashboard_delete_route"), 42 | Route('/dashboard/save', endpoint=routes_dashboard.save_route, methods=("POST", ), name="dashboard_save_route"), 43 | Route('/dashboard/{file:str}/{state:str}', endpoint=routes_dashboard.dashboard_route, name="dashboard_route"), 44 | Route('/dashboard/new', endpoint=routes_dashboard.new_route, name="dashboard_new_route"), 45 | # static 46 | Mount('/static', app=StaticFiles(directory=utils.STATIC_DIR), name="static"), 47 | ] 48 | 49 | exception_handlers = { 50 | HTTPException: server_error, 51 | } 52 | 53 | middleware = [] 54 | if auth.AUTH is not None: 55 | middleware.append(auth.AUTH) 56 | 57 | app = Starlette( 58 | debug=True, 59 | routes=routes, 60 | exception_handlers=exception_handlers, 61 | middleware=middleware 62 | ) 63 | -------------------------------------------------------------------------------- /app/query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to process SQL queries 3 | """ 4 | from clickhouse_driver import dbapi as clickhouse 5 | from time import time 6 | import duckdb 7 | import os 8 | import prql_python as prql 9 | import psycopg 10 | import repo 11 | import sqlite3 12 | import sqlparse 13 | import utils 14 | 15 | DATABASES = { 16 | "sqlite": sqlite3, 17 | "postgres": psycopg, 18 | "clickhouse": clickhouse, 19 | "duckdb": duckdb, 20 | } 21 | 22 | def list_tables(db): 23 | """ 24 | List tables in db 25 | """ 26 | db_type, _conn_str = repo.get_db_params(db) 27 | match db_type: 28 | case "sqlite" | "duckdb": 29 | query = "SELECT tbl_name FROM sqlite_master where type='table';" 30 | case "postgres": 31 | query = "SELECT concat(schemaname, '.', tablename) FROM pg_catalog.pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema');" 32 | case "clickhouse": 33 | query = "SELECT name FROM system.tables where database == currentDatabase();" 34 | case other_db: 35 | raise ValueError(f"Bad DB: {other_db}") 36 | _col_names, rows, _duration_ms = execute(db, query, "sql") 37 | tables = sorted(el[0] for el in rows) 38 | return tables 39 | 40 | def list_table_data_types(db, tables): 41 | """ 42 | List columns and their data types for given tables in DB 43 | """ 44 | db_type, _conn_str = repo.get_db_params(db) 45 | if tables: 46 | match db_type: 47 | # Every query must return table with 3 cols: table_name, column_name, data_type 48 | case "sqlite" | "duckdb": 49 | query = " union all ".join(f"select '{table}', name, type from pragma_table_info('{table}')" for table in tables) 50 | case "postgres": 51 | tables_joined = ', '.join(f"\'{table}\'" for table in tables) 52 | query = f""" 53 | select * from 54 | (select concat(table_schema, '.', table_name) as table, column_name, data_type from information_schema.columns) as tables 55 | where tables.table in ({tables_joined}); 56 | """ 57 | case "clickhouse": 58 | tables_joined = ', '.join(f"\'{table}\'" for table in tables) 59 | query = f""" 60 | SELECT table, name, type FROM system.columns 61 | where database == currentDatabase() and table in ({tables_joined}); 62 | """ 63 | case other_db: 64 | raise ValueError(f"Bad DB: {other_db}") 65 | _col_names, rows, _duration_ms = execute(db, query, "sql") 66 | else: 67 | rows = tuple() 68 | col_names = ("table", "column", "type", ) 69 | table = utils.format_htmltable(utils.random_id(), col_names, rows, True) 70 | return table 71 | 72 | def execute(db, query, lang): 73 | """ 74 | Executes query and returns formatted table 75 | """ 76 | db_type, conn_str = repo.get_db_params(db) 77 | driver = DATABASES[db_type] 78 | start = time() 79 | if lang == "prql": 80 | query = prql.compile(query) 81 | col_names, rows = _execute_query(driver, conn_str, query) 82 | duration_ms = round(1000*(time()-start)) 83 | return col_names, rows, duration_ms 84 | 85 | def execute_saved(db, file, state): 86 | """ 87 | Executes from saved query 88 | """ 89 | query, lang = repo.get_query(db=db, file=file, state=state) 90 | return execute(db, query, lang) 91 | 92 | def _execute_query(driver, conn_str, query): 93 | """ 94 | Executes SQL query against DB using suitable driver 95 | """ 96 | try: 97 | if driver in (sqlite3, duckdb, ) and conn_str != ":memory:": 98 | assert os.path.exists(conn_str), f"No DB file at path: {conn_str}" 99 | conn = driver.connect(conn_str) 100 | cursor = conn.cursor() 101 | statements = (sqlparse.format(s, strip_comments=True).strip() for s in sqlparse.split(query)) 102 | statements = tuple(el for el in statements if el) 103 | assert statements, f"No valid SQL statements in: {query}" 104 | for statement in statements: 105 | cursor.execute(statement) 106 | col_names = tuple(el[0] for el in cursor.description) 107 | rows = cursor.fetchall() 108 | except Exception as e: 109 | raise ValueError(f"Error executing query: {str(e)}") 110 | finally: 111 | try: 112 | conn.close() 113 | except: 114 | pass 115 | return col_names, rows 116 | -------------------------------------------------------------------------------- /app/repo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions to interact with config repository 3 | """ 4 | from collections import OrderedDict 5 | from datetime import datetime 6 | import json 7 | import logging 8 | from markdown import markdown 9 | import os 10 | from pathlib import Path 11 | from pygit2 import Repository, Signature 12 | import utils 13 | 14 | DIR = os.environ["GITBI_REPO_DIR"] 15 | REPO = Repository(DIR) 16 | VALID_DB_TYPES = ("sqlite", "postgres", "clickhouse", "duckdb", ) 17 | VALID_QUERY_EXTENSIONS = (".sql", ".prql", ) 18 | VALID_DASHBOARD_EXTENSIONS = (".json", ) 19 | DASHBOARDS_DIR = "_dashboards" 20 | DASHBOARDS_FULL_PATH = os.path.join(DIR, DASHBOARDS_DIR) 21 | if not os.path.exists(DASHBOARDS_FULL_PATH): 22 | os.makedirs(DASHBOARDS_FULL_PATH) 23 | QUERIES_EXCLUDE = (".git", DASHBOARDS_DIR, ) 24 | SCHEDULE_KEYS = ("cron", "db", "file", "type", "format", "to", ) 25 | 26 | def get_db_params(db): 27 | """ 28 | Reads database configuration from environment variables 29 | """ 30 | db_type_key = f"GITBI_{db.upper()}_TYPE" 31 | conn_str_key = f"GITBI_{db.upper()}_CONN" 32 | db_type = _read_env_var(db_type_key) 33 | conn_str = _read_env_var(conn_str_key) 34 | if db_type not in VALID_DB_TYPES: 35 | raise ValueError(f"DB type {db_type} not supported") 36 | return db_type, conn_str 37 | 38 | def get_email_params(): 39 | """ 40 | Reads environment variables for email 41 | """ 42 | smtp_user = _read_env_var("GITBI_SMTP_USER") 43 | smtp_pass = _read_env_var("GITBI_SMTP_PASS") 44 | smtp_url = _read_env_var("GITBI_SMTP_URL") 45 | smtp_email = _read_env_var("GITBI_SMTP_EMAIL") 46 | return smtp_user, smtp_pass, smtp_url, smtp_email 47 | 48 | def get_auth(): 49 | """ 50 | Get authentication configuration 51 | """ 52 | users_raw = _read_env_var("GITBI_AUTH") 53 | users = tuple(entry.strip() for entry in users_raw.split(",")) 54 | assert users, "Empty user list" 55 | return users 56 | 57 | def get_query(state, db, file): 58 | """ 59 | Gets query content from the repo 60 | """ 61 | assert Path(file).suffix in VALID_QUERY_EXTENSIONS, "Bad query extension" 62 | lang = utils.get_lang(file) 63 | query_path = os.path.join(db, file) 64 | query_str = _get_file_content(state, query_path) 65 | return query_str, lang 66 | 67 | def get_query_viz(state, db, file): 68 | """ 69 | Gets saved viz for given query 70 | """ 71 | try: 72 | viz_path = os.path.join(db, f"{file}.json") 73 | viz_str = _get_file_content(state, viz_path) 74 | except: 75 | viz_str = "null" # this is read by JS 76 | return viz_str 77 | 78 | def get_dashboard(state, file): 79 | """ 80 | Get dashboard content from repo 81 | """ 82 | assert Path(file).suffix in VALID_DASHBOARD_EXTENSIONS, "Bad dashboard extension" 83 | path = os.path.join(DASHBOARDS_DIR, file) 84 | raw_dashboard = _get_file_content(state, path) 85 | return json.loads(raw_dashboard) 86 | 87 | def get_readme(state): 88 | """ 89 | Gets readme content from the repo 90 | """ 91 | try: 92 | readme = _get_file_content(state, "README.md") 93 | readme = markdown(readme) 94 | except Exception as e: 95 | # It is OK for README to be missing, fallback present in template 96 | logging.warning(f"Readme not specified: {str(e)}") 97 | readme = None 98 | return readme 99 | 100 | def list_sources(state): 101 | """ 102 | Lists all available sources (db + queries) 103 | """ 104 | try: 105 | match state: 106 | case 'file': 107 | db_dirs = {db_dir for db_dir in os.scandir(DIR) if db_dir.is_dir() and db_dir.name not in QUERIES_EXCLUDE} 108 | sources = {db_dir.name: _list_file_names(db_dir) for db_dir in db_dirs} 109 | case hash: 110 | commit = REPO.revparse_single(hash) 111 | sources = dict() 112 | for path in (Path(el) for el in _get_tree_objects_generator(commit.tree)): 113 | if len(path.parts) == 2: #files in 1st-level folder, all other ignored 114 | db = str(path.parent) 115 | query = str(path.name) 116 | if db not in QUERIES_EXCLUDE: 117 | try: 118 | sources[db].add(query) 119 | except: 120 | sources[db] = set((query, )) 121 | sources = OrderedDict((db, _filter_extension(queries, VALID_QUERY_EXTENSIONS)) for db, queries in sorted(sources.items())) 122 | except Exception as e: 123 | raise RuntimeError(f"Sources at state {state} cannot be listed: {str(e)}") 124 | else: 125 | return sources 126 | 127 | def list_dashboards(state): 128 | """ 129 | List dasboards in repo 130 | """ 131 | try: 132 | match state: 133 | case 'file': 134 | dashboards = _list_file_names(DASHBOARDS_FULL_PATH) 135 | case hash: 136 | commit = REPO.revparse_single(hash) 137 | repo_paths = (Path(el) for el in _get_tree_objects_generator(commit.tree)) 138 | dashboards = tuple(path.name for path in repo_paths if (len(path.parts) == 2 and str(path.parent) == DASHBOARDS_DIR)) 139 | dashboards = _filter_extension(dashboards, VALID_DASHBOARD_EXTENSIONS) 140 | except Exception as e: 141 | raise RuntimeError(f"Dashboards at state {state} cannot be listed: {str(e)}") 142 | else: 143 | return dashboards 144 | 145 | def list_commits(): 146 | """ 147 | Function lists all commits present in current branch of the repo 148 | """ 149 | headers = ("commit_hash", "author", "date", "message") 150 | commits = [("file", "N/A", "now", "N/A", ), ] 151 | for entry in REPO.walk(REPO.head.target): 152 | commit = _format_commit(entry) 153 | commits.append(commit) 154 | return headers, commits 155 | 156 | def _format_commit(entry): 157 | """ 158 | Format commit tuple 159 | """ 160 | commit = ( 161 | str(entry.id), 162 | str(entry.author), 163 | datetime.fromtimestamp(entry.commit_time).astimezone().strftime("%Y-%m-%d %H:%M:%S %Z"), 164 | entry.message.replace("\n", ""), 165 | ) 166 | return commit 167 | 168 | def save_dashboard(user, file, queries): 169 | """ 170 | Save dashboard config into repo 171 | """ 172 | path_obj = Path(file) 173 | assert file == path_obj.name, "Path passed" 174 | assert (path_obj.suffix in VALID_DASHBOARD_EXTENSIONS), f"Extension not in {str(VALID_DASHBOARD_EXTENSIONS)}" 175 | path = f"{DASHBOARDS_DIR}/{file}" 176 | assert _write_file_content(path, queries), "Writing query content failed" 177 | _commit(user, "save", (path, )) 178 | return True 179 | 180 | def delete_dashboard(user, file): 181 | """ 182 | Delete dashboard config 183 | """ 184 | path = f"{DASHBOARDS_DIR}/{file}" 185 | assert _remove_file(path), f"Cannot remove {path}" 186 | _commit(user, "delete", (path, )) 187 | return True 188 | 189 | def save_query(user, db, file, query, viz): 190 | """ 191 | Save query into repo 192 | file refers to query file name 193 | """ 194 | path_obj = Path(file) 195 | assert file == path_obj.name, "Path passed" 196 | assert (path_obj.suffix in VALID_QUERY_EXTENSIONS), f"Extension not in {str(VALID_QUERY_EXTENSIONS)}" 197 | query_path = f"{db}/{file}" 198 | viz_path = f"{query_path}.json" 199 | to_commit = [query_path, viz_path, ] 200 | assert _write_file_content(query_path, query), "Writing query content failed" 201 | assert _write_file_content(viz_path, viz), "Writing viz content failed" 202 | _commit(user, "save", to_commit) 203 | return True 204 | 205 | def delete_query(user, db, file): 206 | """ 207 | Delete query from the repo 208 | """ 209 | query_path = f"{db}/{file}" 210 | viz_path = f"{query_path}.json" 211 | to_commit = [query_path, ] 212 | assert _remove_file(query_path), f"Cannot remove {query_path}" 213 | try: 214 | _remove_file(viz_path) 215 | except: 216 | pass 217 | else: 218 | to_commit.append(viz_path) 219 | #TODO: if fails error not caught, not recoverable in Gitbi, one needs to checkout manually 220 | _commit(user, "delete", to_commit) 221 | return True 222 | 223 | def _list_file_names(dir): 224 | """ 225 | List file names in given directory 226 | """ 227 | return tuple(el.name for el in os.scandir(dir) if el.is_file()) 228 | 229 | def _filter_extension(files, valid_ext): 230 | """ 231 | Filter file list based on extension 232 | """ 233 | return tuple(sorted(f for f in files if Path(f).suffix in valid_ext)) 234 | 235 | def _write_file_content(path, content): 236 | """ 237 | Change file contents on disk 238 | """ 239 | full_path = os.path.join(DIR, path) 240 | with open(full_path, "w") as f: 241 | f.write(content) 242 | return True 243 | 244 | def _remove_file(path): 245 | """ 246 | Remove file from disk 247 | """ 248 | full_path = os.path.join(DIR, path) 249 | assert os.path.exists(full_path), f"no file {path}" 250 | os.remove(full_path) 251 | return True 252 | 253 | def _get_file_content(state, path): 254 | """ 255 | Read file content from git or filesystem 256 | """ 257 | try: 258 | match state: 259 | case 'file': 260 | with open(os.path.join(DIR, path), "r") as f: 261 | content = f.read() 262 | case hash: 263 | commit = REPO.revparse_single(hash) 264 | blob = commit.tree / path 265 | content = blob.data.decode("UTF-8") 266 | except Exception as e: 267 | # Common error for atual no file, permission, repo error etc. 268 | raise RuntimeError(f"File {path} at state {state} cannot be accessed: {str(e)}") 269 | else: 270 | return content 271 | 272 | def _get_tree_objects_generator(tree, prefix=""): 273 | """ 274 | List files from given state of the repo 275 | """ 276 | for obj in tree: 277 | if obj.type_str == "blob": 278 | yield os.path.join(prefix, obj.name) 279 | elif obj.type_str == "tree": 280 | new_prefix = os.path.join(prefix, obj.name) 281 | for entry in _get_tree_objects_generator(obj, new_prefix): 282 | yield entry 283 | 284 | def _commit(user, operation, files): 285 | """ 286 | Commit given file to repo 287 | """ 288 | assert files, "empty list passed" 289 | files_msg = ", ".join(files) 290 | index = REPO.index 291 | for file in files: 292 | match operation: 293 | case "save": 294 | index.add(file) 295 | case "delete": 296 | index.remove(file) 297 | case operation: 298 | raise ValueError(f"Bad operation: {operation}") 299 | index.write() 300 | author = Signature(name=(user or "Gitbi (no auth)"), email="gitbi@gitbi.gitbi") 301 | REPO.create_commit( 302 | REPO.head.name, # reference_name 303 | author, # author 304 | author, # committer 305 | f"[gitbi] {operation} {files_msg}", # message 306 | index.write_tree(), # tree 307 | [REPO.head.target, ] # parents 308 | ) 309 | return True 310 | 311 | def _read_env_var(key): 312 | """ 313 | Read variable, also attempt to take from file 314 | """ 315 | try: 316 | value = os.environ[key] 317 | except: 318 | try: 319 | file_key = f"{key}_FILE" 320 | value_file = os.environ[file_key] 321 | value = _get_file_content('file', value_file) 322 | except Exception as e: 323 | raise NameError(f"Neither {key} nor valid {file_key} was set: {str(e)}") 324 | return value 325 | -------------------------------------------------------------------------------- /app/routes_dashboard.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routes for displaying and saving dashboards 3 | """ 4 | from starlette.exceptions import HTTPException 5 | from starlette.responses import PlainTextResponse 6 | import repo 7 | import utils 8 | 9 | async def dashboard_route(request): 10 | """ 11 | Show dashboard 12 | """ 13 | try: 14 | dashboard_conf = repo.get_dashboard(**request.path_params) 15 | dashboard_conf = tuple(el + [utils.random_id(), ] for el in dashboard_conf) 16 | data = { 17 | **utils.common_context_args(request), 18 | **request.path_params, 19 | "dashboard_conf": dashboard_conf, 20 | } 21 | response = utils.TEMPLATES.TemplateResponse(name='dashboard.html', context=data) 22 | except Exception as e: 23 | status_code = 404 if isinstance(e, RuntimeError) else 500 24 | raise HTTPException(status_code=status_code, detail=str(e)) 25 | else: 26 | return response 27 | 28 | async def new_route(request): 29 | """ 30 | Endpoint for new dashboard 31 | """ 32 | try: 33 | data = { 34 | **utils.common_context_args(request), 35 | "databases": repo.list_sources("HEAD"), 36 | } 37 | response = utils.TEMPLATES.TemplateResponse(name='create_dashboard.html', context=data) 38 | except Exception as e: 39 | raise HTTPException(status_code=500, detail=str(e)) 40 | else: 41 | return response 42 | 43 | async def delete_route(request): 44 | """ 45 | Delete query from repository 46 | """ 47 | try: 48 | user = utils.common_context_args(request).get("user") 49 | repo.delete_dashboard(user=user, **request.path_params) 50 | redirect_url = request.app.url_path_for("home_route", state="HEAD") 51 | headers = {"HX-Redirect": redirect_url} 52 | response = PlainTextResponse(content="OK", headers=headers, status_code=200) 53 | except Exception as e: 54 | status_code = 404 if isinstance(e, RuntimeError) else 500 55 | raise HTTPException(status_code=status_code, detail=str(e)) 56 | else: 57 | return response 58 | 59 | async def save_route(request): 60 | """ 61 | Save query to repository 62 | """ 63 | try: 64 | form = await request.form() 65 | data = utils.parse_dashboard_data(request, form) 66 | repo.save_dashboard(**request.path_params, **data) 67 | redirect_url = request.app.url_path_for( 68 | "dashboard_route", 69 | file=data['file'], 70 | state="HEAD" 71 | ) 72 | headers = {"HX-Redirect": redirect_url} 73 | response = PlainTextResponse(content="OK", headers=headers, status_code=200) 74 | except Exception as e: 75 | status_code = 404 if isinstance(e, RuntimeError) else 500 76 | raise HTTPException(status_code=status_code, detail=str(e)) 77 | else: 78 | return response 79 | -------------------------------------------------------------------------------- /app/routes_execute.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routes for executing queries 3 | """ 4 | from starlette.background import BackgroundTask 5 | from starlette.exceptions import HTTPException 6 | from starlette.responses import PlainTextResponse 7 | import logging 8 | import mailer 9 | import query 10 | import repo 11 | import utils 12 | 13 | async def execute_route(request): 14 | """ 15 | Endpoint for getting query result 16 | Executes query POSTed from app, used by htmx 17 | """ 18 | try: 19 | form = await request.form() 20 | query_data = utils.parse_query_data(request, form) 21 | col_names, rows, duration_ms = query.execute( 22 | db=request.path_params.get("db"), 23 | query=query_data.get("query"), 24 | lang=utils.get_lang(query_data.get("file")) 25 | ) 26 | format = query_data.get("format") 27 | no_rows = len(rows) 28 | data_json = utils.get_data_json(col_names, rows) 29 | data = { 30 | **utils.common_context_args(request), 31 | "time": utils.get_time(), 32 | "no_rows": no_rows, 33 | "duration": duration_ms, 34 | "data_json": data_json, 35 | "echart_id": query_data["echart_id"], 36 | } 37 | match format: 38 | case "interactive-table" | "simple-table": 39 | interactive = (format == "interactive-table") 40 | table_id = f"results-table-{utils.random_id()}" 41 | table = utils.format_htmltable(table_id, col_names, rows, interactive) 42 | case "text" | "csv" | "json": 43 | match format: 44 | case "text": 45 | table = utils.format_asciitable(col_names, rows) 46 | case "csv": 47 | table = utils.format_csvtable(col_names, rows) 48 | case "json": 49 | table = data_json 50 | table = f'
{table}
' 51 | case other: 52 | raise ValueError(f"Bad format: {other}") 53 | data = {**data, "table": table} 54 | headers = {"Gitbi-Row-Count": str(no_rows)} 55 | response = utils.TEMPLATES.TemplateResponse(name='partial_result.html', headers=headers, context=data) 56 | except Exception as e: 57 | status_code = 404 if isinstance(e, RuntimeError) else 500 58 | raise HTTPException(status_code=status_code, detail=str(e)) 59 | else: 60 | return response 61 | 62 | async def report_route(request): 63 | """ 64 | Common function for reports and alerts generation 65 | Executes saved query and passes ready result in given format 66 | """ 67 | try: 68 | format = request.path_params.get("format") 69 | query_args = {k: request.path_params[k] for k in ("db", "file", "state")} 70 | query_str, _lang = repo.get_query(**query_args) 71 | col_names, rows, duration_ms = query.execute_saved(**query_args) 72 | no_rows = len(rows) 73 | headers = {"Gitbi-Row-Count": str(no_rows), "Gitbi-Duration-Ms": str(duration_ms)} 74 | table_id = f"results-table-{utils.random_id()}" 75 | common_data = { 76 | **utils.common_context_args(request), 77 | **request.path_params, 78 | "time": utils.get_time(), 79 | "duration": duration_ms, 80 | "no_rows": no_rows, 81 | "query_str": query_str, 82 | } 83 | match format: 84 | case "html": 85 | table = utils.format_htmltable(table_id, col_names, rows, False) 86 | data = { 87 | **common_data, 88 | "table": table, 89 | } 90 | response = utils.TEMPLATES.TemplateResponse(name='report.html', headers=headers, context=data) 91 | case "dashboard": 92 | table = utils.format_htmltable(table_id, col_names, rows, True) 93 | data_json = utils.get_data_json(col_names, rows) 94 | viz_str = repo.get_query_viz(**query_args) 95 | data = { 96 | **common_data, 97 | "table": table, 98 | "viz": viz_str, 99 | "echart_id": utils.random_id(), 100 | "tab_id": utils.random_id(), 101 | "data_json": data_json, 102 | } 103 | response = utils.TEMPLATES.TemplateResponse(name='partial_dashboard_entry.html', context=data) 104 | case "text": 105 | table = utils.format_asciitable(col_names, rows) 106 | data = { 107 | **common_data, 108 | "table": table, 109 | } 110 | response = utils.TEMPLATES.TemplateResponse(name='report.txt', headers=headers, context=data, media_type="text/plain") 111 | case "json": 112 | response = PlainTextResponse(content=utils.get_data_json(col_names, rows), headers=headers, media_type="application/json") 113 | case "csv": 114 | table = utils.format_csvtable(col_names, rows) 115 | response = PlainTextResponse(content=table, headers=headers, media_type="text/csv") 116 | case other: 117 | raise ValueError(f"Bad format: {other}") 118 | alert = request.query_params.get("alert") 119 | mail = request.query_params.get("mail") 120 | if (mail is not None) and not ((alert is not None) and (no_rows == 0)): 121 | file_name = request.path_params.get("file") 122 | logging.info(f"Mailing {file_name} to {mail}") 123 | response.background = BackgroundTask( 124 | mailer.send, 125 | content=response.body.decode(), 126 | format=format, 127 | to=mail, 128 | file_name=file_name 129 | ) 130 | except Exception as e: 131 | status_code = 404 if isinstance(e, RuntimeError) else 500 132 | raise HTTPException(status_code=status_code, detail=str(e)) 133 | else: 134 | return response 135 | -------------------------------------------------------------------------------- /app/routes_listing.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routes for listing info 3 | """ 4 | from starlette.exceptions import HTTPException 5 | from starlette.responses import RedirectResponse 6 | import query 7 | import repo 8 | import utils 9 | 10 | async def home_route(request): 11 | """ 12 | Endpoint for home page 13 | """ 14 | try: 15 | state = request.path_params.get("state") 16 | # since readme can be empty, need to check for state validity 17 | commits_headers, commits = repo.list_commits() 18 | commit_hashes = ("HEAD", ) + tuple(entry[0] for entry in commits) 19 | assert state in commit_hashes, f"Unknown state: {state}" 20 | commits_table = utils.format_htmltable("commits-table", commits_headers, commits, False) 21 | data = { 22 | **utils.common_context_args(request), 23 | "readme": repo.get_readme(state), 24 | "commits_table": commits_table, 25 | } 26 | response = utils.TEMPLATES.TemplateResponse(name='index.html', context=data) 27 | except Exception as e: 28 | raise HTTPException(status_code=404, detail=str(e)) 29 | else: 30 | return response 31 | 32 | async def home_default_route(request): 33 | """ 34 | Default endpoint: redirect to HEAD state 35 | """ 36 | return RedirectResponse(url="/home/HEAD/") 37 | 38 | async def resources_route(request): 39 | """ 40 | Endpoint for resources menu 41 | """ 42 | try: 43 | state = request.path_params.get("state") 44 | data = { 45 | **utils.common_context_args(request), 46 | "databases": repo.list_sources(state), 47 | "dashboards": repo.list_dashboards(state), 48 | } 49 | response = utils.TEMPLATES.TemplateResponse(name='partial_resources.html', context=data) 50 | except Exception as e: 51 | raise HTTPException(status_code=500, detail=str(e)) 52 | else: 53 | return response 54 | 55 | async def db_details_route(request): 56 | """ 57 | Endpoint for getting database docs 58 | """ 59 | try: 60 | db = request.path_params.get("db") 61 | tables = query.list_tables(db) 62 | data_docs = query.list_table_data_types(db, tables) 63 | data = { 64 | **utils.common_context_args(request), 65 | "data_docs": data_docs, 66 | "tables": tables, 67 | **request.path_params, 68 | } 69 | response = utils.TEMPLATES.TemplateResponse(name='db_details.html', context=data) 70 | except Exception as e: 71 | status_code = 404 if isinstance(e, RuntimeError) else 500 72 | raise HTTPException(status_code=status_code, detail=str(e)) 73 | else: 74 | return response 75 | -------------------------------------------------------------------------------- /app/routes_query.py: -------------------------------------------------------------------------------- 1 | """ 2 | Routes for displaying and saving queries 3 | """ 4 | from starlette.exceptions import HTTPException 5 | from starlette.responses import PlainTextResponse 6 | import repo 7 | import utils 8 | 9 | async def delete_route(request): 10 | """ 11 | Delete query from repository 12 | """ 13 | try: 14 | user = utils.common_context_args(request).get("user") 15 | repo.delete_query(user=user, **request.path_params) 16 | redirect_url = request.app.url_path_for("home_route", state="HEAD") 17 | headers = {"HX-Redirect": redirect_url} 18 | response = PlainTextResponse(content="OK", headers=headers, status_code=200) 19 | except Exception as e: 20 | status_code = 404 if isinstance(e, RuntimeError) else 500 21 | raise HTTPException(status_code=status_code, detail=str(e)) 22 | else: 23 | return response 24 | 25 | async def save_route(request): 26 | """ 27 | Save query to repository 28 | """ 29 | try: 30 | form = await request.form() 31 | data = utils.parse_query_data(request, form) 32 | repo.save_query( 33 | user=utils.common_context_args(request).get("user"), 34 | db=request.path_params["db"], 35 | file=data["file"], 36 | query=data["query"], 37 | viz=data["viz"], 38 | ) 39 | redirect_url = request.app.url_path_for( 40 | "saved_query_route", 41 | db=request.path_params['db'], 42 | file=data['file'], 43 | state="HEAD" 44 | ) 45 | headers = {"HX-Redirect": redirect_url} 46 | response = PlainTextResponse(content="OK", headers=headers, status_code=200) 47 | except Exception as e: 48 | status_code = 404 if isinstance(e, RuntimeError) else 500 49 | raise HTTPException(status_code=status_code, detail=str(e)) 50 | else: 51 | return response 52 | 53 | async def query_route(request): 54 | """ 55 | Endpoint for empty query 56 | """ 57 | try: 58 | db = request.path_params.get("db") 59 | if db not in repo.list_sources("file").keys(): 60 | raise RuntimeError(f"db {db} not present in repo") 61 | request.state.query_data = { 62 | "query": request.query_params.get('query') or "", 63 | "viz": "null", 64 | "file": "__empty__", 65 | **request.path_params, # db 66 | } 67 | except RuntimeError as e: 68 | raise HTTPException(status_code=404, detail=str(e)) 69 | else: 70 | return await _query(request) 71 | 72 | async def saved_query_route(request): 73 | """ 74 | Endpoint for saved query 75 | """ 76 | try: 77 | query_str, _lang = repo.get_query(**request.path_params) 78 | viz_str = repo.get_query_viz(**request.path_params) 79 | request.state.query_data = { 80 | "query": query_str, 81 | "viz": viz_str, 82 | **request.path_params, # db, file, state 83 | } 84 | except RuntimeError as e: 85 | raise HTTPException(status_code=404, detail=str(e)) 86 | else: 87 | 88 | return await _query(request) 89 | 90 | async def _query(request): 91 | """ 92 | Common logic for query endpoint 93 | Called by: 94 | - query 95 | - saved query 96 | """ 97 | data = { 98 | **utils.common_context_args(request), 99 | **request.state.query_data, 100 | "echart_id": f"echart-{utils.random_id()}", 101 | } 102 | return utils.TEMPLATES.TemplateResponse(name='query.html', context=data) 103 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | common functions for all routes 3 | """ 4 | import csv 5 | import datetime 6 | import decimal 7 | import io 8 | import itertools 9 | import json 10 | import os 11 | import prettytable 12 | from pathlib import Path 13 | from starlette.templating import Jinja2Templates 14 | import uuid 15 | 16 | VERSION = "0.10" 17 | APP_DIR = os.path.abspath(os.path.dirname(__file__)) 18 | STATIC_DIR = os.path.join(APP_DIR, "frontend/static") 19 | TEMPLATE_DIR = os.path.join(APP_DIR, "frontend") 20 | TEMPLATES = Jinja2Templates(directory=TEMPLATE_DIR, autoescape=False) 21 | 22 | def parse_query_data(request, form): 23 | """ 24 | Parses and validates query data generated from query_format() 25 | app/frontend/js/code_editor.js 26 | query, user, viz: string 27 | """ 28 | data = json.loads(form["data"]) 29 | data["file"] = data["file"].strip() 30 | for key in ("query", "viz", "echart_id", "file", "format", ): 31 | assert key in data.keys(), f"No {key} in POST data" 32 | assert data[key] != "", f"Empty {key} string" 33 | data["user"] = common_context_args(request).get("user") 34 | return data 35 | 36 | def parse_dashboard_data(request, form): 37 | """ 38 | Parses and validates query data generated from dashboard_format() 39 | app/frontend/js/dashboard_creation.js 40 | """ 41 | data = json.loads(form["data"]) 42 | data["file"] = data["file"].strip() 43 | assert data["file"] != "", "Empty file name" 44 | assert data["queries"], "zero queries chosen" 45 | data["queries"] = json.dumps(tuple(el.split('/') for el in data["queries"])) 46 | data["user"] = common_context_args(request).get("user") 47 | return data 48 | 49 | def get_lang(file): 50 | """ 51 | Establish query language based on file extension 52 | """ 53 | suffix = Path(file).suffix 54 | return suffix[1:] 55 | 56 | def format_asciitable(headers, rows): 57 | """ 58 | Format data into a text table 59 | """ 60 | table = prettytable.PrettyTable() 61 | table.field_names = headers 62 | table.add_rows(rows) 63 | return table.get_string() 64 | 65 | def format_csvtable(headers, rows): 66 | """ 67 | Format data into a CSV table 68 | """ 69 | out = io.StringIO() 70 | writer = csv.writer(out) 71 | for entry in itertools.chain((headers, ), rows): 72 | writer.writerow(entry) 73 | return out.getvalue() 74 | 75 | def format_htmltable(table_id, col_names, rows, interactive): 76 | """ 77 | Format data into a html table 78 | """ 79 | data = {"request": None, "table_id": table_id, } 80 | if interactive: 81 | data = {**data, "data_json": get_data_json(col_names, rows)} 82 | response = TEMPLATES.TemplateResponse(name='partial_html_table_interactive.html', context=data) 83 | else: 84 | data = {**data, "col_names": col_names, "data": rows} 85 | response = TEMPLATES.TemplateResponse(name='partial_html_table.html', context=data) 86 | return response.body.decode() 87 | 88 | def random_id(): 89 | """ 90 | Random id for html element 91 | """ 92 | return f"id-{str(uuid.uuid4())}" 93 | 94 | def get_data_json(headers, rows): 95 | """ 96 | Convert data to json 97 | """ 98 | if rows: 99 | dtypes = tuple(_data_convert(el)[1] for el in rows[0]) 100 | else: 101 | dtypes = tuple(None for _ in headers) 102 | headers = tuple(_data_convert(el)[0] for el in headers) 103 | rows = tuple(tuple(_data_convert(el)[0] for el in row) for row in rows) 104 | return json.dumps({"headings": headers, "data": rows, "dtypes": dtypes}, default=str) 105 | 106 | def _data_convert(el): 107 | """ 108 | Convert complex types such that it can be passed to json.dumps 109 | Data type names determined for echarts 110 | https://echarts.apache.org/en/option.html#xAxis.type 111 | """ 112 | match el: 113 | case decimal.Decimal() | float(): 114 | el = float(el) 115 | dtype = "value" 116 | case int(): 117 | dtype = "value" 118 | case datetime.datetime(): 119 | el = datetime.datetime.isoformat(el) 120 | dtype = "time" 121 | case _: 122 | el = str(el) 123 | dtype = "category" 124 | return el, dtype 125 | 126 | def common_context_args(request): 127 | """ 128 | Return context args common for all endpoints 129 | """ 130 | data = { 131 | "request": request, 132 | "version": VERSION, 133 | "user": _get_user(request), 134 | "state": (request.path_params.get("state") or "HEAD"), 135 | } 136 | return data 137 | 138 | def _get_user(request): 139 | """ 140 | Get user name, if exists 141 | """ 142 | try: 143 | user = request.user.display_name 144 | except: 145 | user = None 146 | return user 147 | 148 | def get_time(): 149 | """ 150 | Returns current time formatted 151 | """ 152 | return datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %Z") 153 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | pythonpath = ./app 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | anyio==4.1.0 2 | cffi==1.16.0 3 | click==8.1.7 4 | clickhouse-driver==0.2.6 5 | duckdb==0.9.2 6 | exceptiongroup==1.2.0 7 | h11==0.14.0 8 | idna==3.6 9 | Jinja2==3.1.2 10 | Markdown==3.5.1 11 | MarkupSafe==2.1.3 12 | prettytable==3.9.0 13 | prql-python==0.10.1 14 | psycopg==3.1.14 15 | psycopg-binary==3.1.14 16 | pycparser==2.21 17 | pygit2==1.13.3 18 | python-multipart==0.0.6 19 | pytz==2023.3.post1 20 | sniffio==1.3.0 21 | sqlparse==0.4.4 22 | starlette==0.33.0 23 | typing_extensions==4.9.0 24 | tzlocal==5.2 25 | uvicorn==0.24.0.post1 26 | wcwidth==0.2.12 27 | -------------------------------------------------------------------------------- /screenshots.md: -------------------------------------------------------------------------------- 1 | # Gitbi Screenshots 2 | 3 | Go to: 4 | 5 | - [Main page](#main-page) 6 | - [Dashboard page](#dashboard-page) 7 | - [Query page](#query-page) 8 | - [HTML report page](#html-report-page) 9 | 10 | ## Main page 11 | 12 | ![screenshot](https://drive.google.com/uc?export=view&id=1bGYmg69HWiI65iPF1gVWAvyRP6LkrwG9) 13 | 14 | ## Dashboard page 15 | 16 | ![screenshot](https://drive.google.com/uc?export=view&id=1YovS0sPqDAm429VxYU-SHdPEVPrZ5vIN) 17 | 18 | ## Query page 19 | 20 | ![screenshot](https://drive.google.com/uc?export=view&id=1V0MnyI2otg_qQrpzuCaY4dUEbAR9GR9j) 21 | 22 | ## HTML report page 23 | 24 | ![screenshot](https://drive.google.com/uc?export=view&id=1lF1vabLj02baHWW_YJoBK-yVh1_YIW4B) 25 | -------------------------------------------------------------------------------- /start_app.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | app_dir="$(realpath ./app)"; 6 | if [ -n "$GITBI_REPO_DIR" ]; then 7 | echo "GITBI_REPO_DIR specified: $GITBI_REPO_DIR" 8 | else 9 | echo "GITBI_REPO_DIR not specified, running example configuration" 10 | if [ -d "./gitbi-example" ]; then 11 | echo "Example repo already exists, skipping clone" 12 | else 13 | git clone https://github.com/ppatrzyk/gitbi-example.git 14 | fi 15 | . ./gitbi-example/example_setup.sh 16 | fi 17 | export GITBI_REPO_DIR="$(realpath $GITBI_REPO_DIR)"; 18 | cd $GITBI_REPO_DIR; 19 | 20 | inside_git_repo="$(git rev-parse --is-inside-work-tree)"; 21 | toplevel="$(git rev-parse --show-toplevel)"; 22 | if [ $GITBI_REPO_DIR != $toplevel ]; then 23 | echo "Passed subdirectory of an existing git repo at $toplevel"; 24 | exit 1 25 | else 26 | past_commits=$(git log); 27 | fi; 28 | 29 | echo "Git repo OK, starting gitbi..."; 30 | cd $app_dir 31 | uvicorn \ 32 | --host=0.0.0.0 \ 33 | --port=8000 \ 34 | --log-level=debug \ 35 | --workers=1 \ 36 | --app-dir=$app_dir \ 37 | main:app 38 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | USERS = "testuser:testpassword" 2 | USER_HTTPX = tuple(USERS.split(":")) -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from . import USERS 3 | 4 | def pytest_configure(config): 5 | os.environ["GITBI_AUTH"] = USERS 6 | os.environ["GITBI_REPO_DIR"] = os.path.abspath("./tests/gitbi-testing") 7 | os.environ["GITBI_SQLITE_CONN"] = os.path.abspath("./tests/gitbi-testing/db.sqlite") 8 | os.environ["GITBI_SQLITE_TYPE"] = "sqlite" 9 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from starlette.testclient import TestClient 2 | from app.main import app 3 | import json 4 | from . import USER_HTTPX 5 | 6 | client = TestClient(app) 7 | 8 | def test_listing(): 9 | assert client.get("/home/HEAD/").status_code == 401 10 | assert client.get("/home/HEAD/", auth=("baduser", "badpass")).status_code == 401 11 | assert client.get("/home/HEAD/", auth=USER_HTTPX).status_code == 200 12 | assert client.get("/home/badstate", auth=USER_HTTPX).status_code == 404 13 | assert client.get("/badpath", auth=USER_HTTPX).status_code == 404 14 | assert client.get("/badpath").status_code == 401 15 | 16 | def test_query(): 17 | assert client.get("/query/postgres/query.sql/HEAD").status_code == 401 18 | assert client.get("/query/postgres/query.sql/HEAD", auth=USER_HTTPX).status_code == 200 19 | assert client.get("/query/postgres/query.sql/badstate", auth=USER_HTTPX).status_code == 404 20 | assert client.get("/query/postgres/incorrectfile.sql/HEAD", auth=USER_HTTPX).status_code == 404 21 | assert client.get("/query/sqlite/incorrectfile.prql/HEAD", auth=USER_HTTPX).status_code == 404 22 | assert client.get("/query/sqlite/myquery.sql/HEAD", auth=USER_HTTPX).status_code == 200 23 | assert client.get("/query/sqlite/myquery_bad.sql/HEAD", auth=USER_HTTPX).status_code == 200 24 | assert client.get("/query/sqlite/myquery_empty.sql/HEAD", auth=USER_HTTPX).status_code == 200 25 | assert client.get("/query/sqlite/myquery_multi.sql/HEAD", auth=USER_HTTPX).status_code == 200 26 | # empty query, validations on db and query done on execute 27 | assert client.get("/query/sqlite").status_code == 401 28 | assert client.get("/query/sqlite", auth=USER_HTTPX).status_code == 200 29 | assert client.get("/query/postgres", auth=USER_HTTPX).status_code == 200 30 | assert client.get("/query/baddb", auth=USER_HTTPX).status_code == 404 31 | # TODO? save not tested, would need to reinit repo every time 32 | 33 | def test_execute(): 34 | assert client.post("/execute/postgres/", data={}).status_code == 401 35 | assert client.post("/execute/postgres/", data={}, auth=USER_HTTPX).status_code == 500 36 | assert client.post("/execute/baddb/", data={}, auth=USER_HTTPX).status_code == 500 37 | assert client.post("/execute/sqlite/", data={}, auth=USER_HTTPX).status_code == 500 38 | assert client.post("/execute/sqlite/", data={"baddata": 666}, auth=USER_HTTPX).status_code == 500 39 | assert client.post("/execute/sqlite/", data={"data": json.dumps({"query": "select badfunc();"})}, auth=USER_HTTPX).status_code == 500 40 | assert client.post("/execute/sqlite/", data={"data": json.dumps({"query": "select badfunc();", "viz": "foobar", "echart_id": "foobar", "file": "file.sql", "format": "bad"})}, auth=USER_HTTPX).status_code == 500 41 | assert client.post("/execute/sqlite/", data={"data": json.dumps({"query": "select 1;", "viz": "", "echart_id": "", "file": "", "format": ""})}, auth=USER_HTTPX).status_code == 500 42 | assert client.post("/execute/sqlite/", data={"data": json.dumps({"query": "select 1;", "viz": "foobar", "echart_id": "foobar", "file": "file.sql", "format": "text"})}, auth=USER_HTTPX).status_code == 200 43 | assert client.get("/report/sqlite/incorrectfile/HEAD", auth=USER_HTTPX).status_code == 404 44 | assert client.get("/report/sqlite/incorrectfile.sql/HEAD", auth=USER_HTTPX).status_code == 404 45 | assert client.get("/report/sqlite/incorrectfile.prql/HEAD", auth=USER_HTTPX).status_code == 404 46 | assert client.get("/report/sqlite/myquery.sql/HEAD", auth=USER_HTTPX).status_code == 404 47 | assert client.get("/report/sqlite/myquery.sql/HEAD/html", auth=USER_HTTPX).status_code == 200 48 | assert client.get("/report/sqlite/myquery.sql/HEAD/text", auth=USER_HTTPX).status_code == 200 49 | assert client.get("/report/sqlite/myquery.sql/HEAD/json", auth=USER_HTTPX).status_code == 200 50 | assert client.get("/report/sqlite/myquery.sql/HEAD/invalid", auth=USER_HTTPX).status_code == 500 51 | 52 | def test_dashboard(): 53 | assert client.get("/dashboard/test_dashboard.json/HEAD").status_code == 401 54 | assert client.get("/dashboard/test_dashboard.json/HEAD", auth=USER_HTTPX).status_code == 200 55 | assert client.get("/dashboard/test_dashboard.json/file", auth=USER_HTTPX).status_code == 200 56 | assert client.get("/dashboard/test_dashboard.json/67aa8bd9b58e0ed496a440812bea4719a1361f10", auth=USER_HTTPX).status_code == 404 57 | assert client.get("/dashboard/bad_spec.json/HEAD", auth=USER_HTTPX).status_code == 500 58 | assert client.get("/dashboard/bad_name/HEAD", auth=USER_HTTPX).status_code == 500 59 | assert client.get("/dashboard/nonexistent.json/HEAD", auth=USER_HTTPX).status_code == 404 60 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from contextlib import nullcontext as does_not_raise 3 | from app.query import * 4 | 5 | def test_execute(): 6 | with pytest.raises(NameError): 7 | execute("baddb", "badquery", "sql") 8 | with pytest.raises(ValueError): 9 | execute("sqlite", "select badfunc();", "sql") 10 | with pytest.raises(Exception): 11 | execute("sqlite", "~", "prql") 12 | with does_not_raise(): 13 | execute("sqlite", "select 1;", "sql") 14 | 15 | def test_execute_saved(): 16 | with pytest.raises(Exception): 17 | execute_saved("sqlite", "myquery_bad.sql", "HEAD") 18 | with does_not_raise(): 19 | execute_saved("sqlite", "myquery.sql", "HEAD") 20 | 21 | def test_list_tables(): 22 | assert list_tables("sqlite") == ["mytable", ] 23 | with pytest.raises(NameError): 24 | list_tables("baddb") 25 | with pytest.raises(NameError): 26 | list_tables("postgres") 27 | -------------------------------------------------------------------------------- /tests/test_repo.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.repo import * 3 | 4 | def test_get_readme(): 5 | assert get_readme("incorrect_commit_hash") is None 6 | assert get_readme("f76d73c56b16bb3d74535e7f5b672066c11e17af") == "

gitbi-testing

" 7 | latest = "

gitbi-testing

\n

new content

" 8 | assert get_readme("81b3c23584459b4e3693d6428e3fc608aab7252a") == latest 9 | assert get_readme("5bb043654572d1d768df503e04c4dbdd3606d65f") == latest 10 | assert get_readme("836bd2dced3aad27abb0c1def6de4696e0722dfe") == latest 11 | assert get_readme("bdd50332c25777feaaee7b3f40a4a42ab173ae18") == latest 12 | assert get_readme("38ceabd502ad82f828f640e418fce0bd1d45a2bd") == latest 13 | assert get_readme("67aa8bd9b58e0ed496a440812bea4719a1361f10") == latest 14 | assert get_readme("47725449de77fc06fcae8d1f9bebbcacad4f5864") == latest 15 | assert get_readme("85fd294d99bd810e2450c4bef596f06800aab372") == latest 16 | assert get_readme("HEAD") == latest 17 | assert get_readme("file") == latest 18 | 19 | def test_get_query(): 20 | with pytest.raises(RuntimeError): 21 | get_query("incorrect_commit_hash", "postgres", "query.sql") 22 | with pytest.raises(RuntimeError): 23 | get_query("f76d73c56b16bb3d74535e7f5b672066c11e17af", "postgres", "query.sql") 24 | assert get_query("81b3c23584459b4e3693d6428e3fc608aab7252a", "postgres", "query.sql") == ("", "sql") 25 | assert get_query_viz("81b3c23584459b4e3693d6428e3fc608aab7252a", "postgres", "query.sql") == "null" 26 | query_sql_latest = ("script file content\n", "sql", ) 27 | query_sql_latest_viz = "null" 28 | assert get_query("5bb043654572d1d768df503e04c4dbdd3606d65f", "postgres", "query.sql") == query_sql_latest 29 | assert get_query_viz("5bb043654572d1d768df503e04c4dbdd3606d65f", "postgres", "query.sql") == query_sql_latest_viz 30 | assert get_query("836bd2dced3aad27abb0c1def6de4696e0722dfe", "postgres", "query.sql") == query_sql_latest 31 | assert get_query_viz("836bd2dced3aad27abb0c1def6de4696e0722dfe", "postgres", "query.sql") == query_sql_latest_viz 32 | assert get_query("bdd50332c25777feaaee7b3f40a4a42ab173ae18", "postgres", "query.sql") == query_sql_latest 33 | assert get_query_viz("bdd50332c25777feaaee7b3f40a4a42ab173ae18", "postgres", "query.sql") == query_sql_latest_viz 34 | assert get_query("38ceabd502ad82f828f640e418fce0bd1d45a2bd", "postgres", "query.sql") == query_sql_latest 35 | assert get_query_viz("38ceabd502ad82f828f640e418fce0bd1d45a2bd", "postgres", "query.sql") == query_sql_latest_viz 36 | assert get_query("67aa8bd9b58e0ed496a440812bea4719a1361f10", "postgres", "query.sql") == query_sql_latest 37 | assert get_query_viz("67aa8bd9b58e0ed496a440812bea4719a1361f10", "postgres", "query.sql") == query_sql_latest_viz 38 | assert get_query("47725449de77fc06fcae8d1f9bebbcacad4f5864", "postgres", "query.sql") == query_sql_latest 39 | assert get_query_viz("47725449de77fc06fcae8d1f9bebbcacad4f5864", "postgres", "query.sql") == query_sql_latest_viz 40 | assert get_query("HEAD", "postgres", "query.sql") == query_sql_latest 41 | assert get_query_viz("HEAD", "postgres", "query.sql") == query_sql_latest_viz 42 | assert get_query("file", "postgres", "query.sql") == query_sql_latest 43 | assert get_query_viz("file", "postgres", "query.sql") == query_sql_latest_viz 44 | with pytest.raises(RuntimeError): 45 | get_query("file", "postgres", "nonexistent.sql") 46 | myquery_sql_latest = ("select * from mytable;\n-- comment\n", "sql", ) 47 | myquery_sql_latest_viz = "" 48 | assert get_query("836bd2dced3aad27abb0c1def6de4696e0722dfe", "sqlite", "myquery.sql") == ("select * from mytable;\n-- comment\n", "sql", ) 49 | assert get_query_viz("836bd2dced3aad27abb0c1def6de4696e0722dfe", "sqlite", "myquery.sql") == "null" 50 | assert get_query("bdd50332c25777feaaee7b3f40a4a42ab173ae18", "sqlite", "myquery.sql") == ("select * from mytable;\n-- comment\n", "sql", ) 51 | assert get_query_viz("bdd50332c25777feaaee7b3f40a4a42ab173ae18", "sqlite", "myquery.sql") == "null" 52 | assert get_query("38ceabd502ad82f828f640e418fce0bd1d45a2bd", "sqlite", "myquery.sql") == ("select * from mytable;\n-- comment\n", "sql", ) 53 | assert get_query_viz("38ceabd502ad82f828f640e418fce0bd1d45a2bd", "sqlite", "myquery.sql") == "null" 54 | assert get_query("67aa8bd9b58e0ed496a440812bea4719a1361f10", "sqlite", "myquery.sql") == myquery_sql_latest 55 | assert get_query_viz("67aa8bd9b58e0ed496a440812bea4719a1361f10", "sqlite", "myquery.sql") == myquery_sql_latest_viz 56 | assert get_query("47725449de77fc06fcae8d1f9bebbcacad4f5864", "sqlite", "myquery.sql") == myquery_sql_latest 57 | assert get_query_viz("47725449de77fc06fcae8d1f9bebbcacad4f5864", "sqlite", "myquery.sql") == myquery_sql_latest_viz 58 | assert get_query("HEAD", "sqlite", "myquery.sql") == myquery_sql_latest 59 | assert get_query_viz("HEAD", "sqlite", "myquery.sql") == myquery_sql_latest_viz 60 | assert get_query("file", "sqlite", "myquery.sql") == myquery_sql_latest 61 | assert get_query_viz("file", "sqlite", "myquery.sql") == myquery_sql_latest_viz 62 | 63 | def test_get_dashboard(): 64 | latest = [["sqlite", "myquery.sql", ], ] 65 | assert get_dashboard("file", "test_dashboard.json") == latest 66 | assert get_dashboard("85fd294d99bd810e2450c4bef596f06800aab372", "test_dashboard.json") == latest 67 | assert get_dashboard("47725449de77fc06fcae8d1f9bebbcacad4f5864", "test_dashboard.json") == latest 68 | with pytest.raises(Exception): 69 | get_dashboard("67aa8bd9b58e0ed496a440812bea4719a1361f10", "test_dashboard.json") 70 | with pytest.raises(Exception): 71 | get_dashboard("47725449de77fc06fcae8d1f9bebbcacad4f5864", "bad_spec.json") 72 | with pytest.raises(Exception): 73 | get_dashboard("47725449de77fc06fcae8d1f9bebbcacad4f5864", "nonexistent.json") 74 | 75 | def test_list_sources(): 76 | with pytest.raises(RuntimeError): 77 | list_sources("incorrect_commit_hash") 78 | assert list_sources("f76d73c56b16bb3d74535e7f5b672066c11e17af") == {} 79 | assert list_sources("81b3c23584459b4e3693d6428e3fc608aab7252a") == {"postgres": ("query.sql", )} 80 | assert list_sources("5bb043654572d1d768df503e04c4dbdd3606d65f") == {"postgres": ("query.sql", )} 81 | assert list_sources("836bd2dced3aad27abb0c1def6de4696e0722dfe") == {"postgres": ("query.sql", ), "sqlite": ("myquery.sql", )} 82 | latest = {"postgres": ("query.sql", ), "sqlite": ("myquery.sql", "myquery_bad.sql", "myquery_empty.sql", "myquery_multi.sql")} 83 | assert list_sources("bdd50332c25777feaaee7b3f40a4a42ab173ae18") == latest 84 | assert list_sources("38ceabd502ad82f828f640e418fce0bd1d45a2bd") == latest 85 | assert list_sources("67aa8bd9b58e0ed496a440812bea4719a1361f10") == latest 86 | assert list_sources("47725449de77fc06fcae8d1f9bebbcacad4f5864") == latest 87 | assert list_sources("85fd294d99bd810e2450c4bef596f06800aab372") == latest 88 | assert list_sources("HEAD") == latest 89 | assert list_sources("file") == latest 90 | 91 | def test_list_commits(): 92 | _headers, commits = list_commits() 93 | assert commits[0][0] == "file" 94 | assert commits[1][0] == "85fd294d99bd810e2450c4bef596f06800aab372" 95 | assert commits[2][0] == "47725449de77fc06fcae8d1f9bebbcacad4f5864" 96 | assert commits[3][0] == "67aa8bd9b58e0ed496a440812bea4719a1361f10" 97 | assert commits[4][0] == "38ceabd502ad82f828f640e418fce0bd1d45a2bd" 98 | 99 | def test_list_dashboards(): 100 | latest = ("bad_spec.json", "test_dashboard.json", ) 101 | assert list_dashboards("file") == latest 102 | assert list_dashboards("85fd294d99bd810e2450c4bef596f06800aab372") == latest 103 | assert list_dashboards("47725449de77fc06fcae8d1f9bebbcacad4f5864") == latest 104 | assert list_dashboards("67aa8bd9b58e0ed496a440812bea4719a1361f10") == tuple() 105 | --------------------------------------------------------------------------------