Skip to content

Commit ae2bc16

Browse files
committed
Buncha changes:
* copy values / rows to clipboard * factor common template stuff into includes * row detail view a-la the delete view * download db option Also change how we tag our containers, so :latest is the latest stable and :master is the current master commit.
1 parent 427cb75 commit ae2bc16

20 files changed

Lines changed: 437 additions & 204 deletions

.github/workflows/build_latest_container.yml renamed to .github/workflows/build_master_container.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
name: Build latest Container
1+
name: Build master Container
22
on:
33
push:
44
branches:
@@ -24,6 +24,6 @@ jobs:
2424
docker buildx build \
2525
--platform linux/amd64,linux/arm64 \
2626
--file docker/Dockerfile \
27-
--tag ghcr.io/coleifer/sqlite-web:latest \
27+
--tag ghcr.io/coleifer/sqlite-web:master \
2828
--push \
2929
.

.github/workflows/build_release_container.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ jobs:
2525
--platform linux/amd64,linux/arm64 \
2626
--file docker/Dockerfile \
2727
--tag ghcr.io/coleifer/sqlite-web:${{ github.ref_name }} \
28+
--tag ghcr.io/coleifer/sqlite-web:latest \
2829
--push \
2930
.

sqlite_web/sqlite_web.py

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import optparse
1414
import os
1515
import re
16+
import shutil
1617
import sys
1718
import tempfile
1819
import threading
@@ -32,7 +33,7 @@
3233
try:
3334
from flask import (
3435
Flask, abort, flash, g, jsonify, make_response, redirect,
35-
render_template, request, session, url_for)
36+
render_template, request, send_file, session, url_for)
3637
except ImportError:
3738
raise RuntimeError('Unable to import flask module. Install by running '
3839
'pip install flask')
@@ -482,8 +483,8 @@ def _query_view(template, table=None):
482483
model_class = dataset[table].model_class
483484
pk = model_class._meta.primary_key
484485
is_composite_pk = isinstance(pk, CompositeKey)
485-
allow_edit = (not dataset.is_readonly and pk is not False and
486-
not dataset.cached_is_view(table))
486+
allow_detail = pk is not False and not dataset.cached_is_view(table)
487+
allow_edit = allow_detail and not dataset.is_readonly
487488
allow_bulk = allow_edit and not is_composite_pk
488489
fk_lookup = {fk.column: (fk.dest_table, fk.dest_column)
489490
for fk in dataset.cached_foreign_keys(table)}
@@ -492,7 +493,7 @@ def _query_view(template, table=None):
492493
model_class = dataset._base_model
493494
pk = None
494495
is_composite_pk = False
495-
allow_edit = allow_bulk = False
496+
allow_detail = allow_edit = allow_bulk = False
496497
fk_lookup = {}
497498

498499
if request.method == 'POST' and request.form.get('action') == 'bulk-delete':
@@ -544,7 +545,7 @@ def _query_view(template, table=None):
544545
else:
545546
results = run_script(dataset, statements, page_size=rpp)
546547

547-
if (result is not None and result.kind == 'rows' and allow_edit and
548+
if (result is not None and result.kind == 'rows' and allow_detail and
548549
not explain and not is_composite_pk and
549550
pk.column_name in result.columns):
550551
pk_index = result.columns.index(pk.column_name) # First one wins.
@@ -566,6 +567,7 @@ def _query_view(template, table=None):
566567
return render_template(
567568
template,
568569
allow_bulk=allow_bulk,
570+
allow_detail=allow_detail,
569571
allow_edit=allow_edit,
570572
default_sql=default_sql,
571573
error=error,
@@ -856,11 +858,11 @@ def table_content(table):
856858
model = ds_table.model_class
857859
is_composite_pk = isinstance(model._meta.primary_key, CompositeKey)
858860

859-
# Views get a synthetic all-column pk from introspection, but cannot be
860-
# edited in place, so treat them as read-only here.
861-
allow_edit = (not dataset.is_readonly and
862-
model._meta.primary_key is not False and
863-
not dataset.cached_is_view(table))
861+
# Views get a synthetic all-column pk from introspection, which is not
862+
# a usable row key. They get no row links and no editing.
863+
allow_detail = (model._meta.primary_key is not False and
864+
not dataset.cached_is_view(table))
865+
allow_edit = allow_detail and not dataset.is_readonly
864866
allow_bulk = allow_edit and not is_composite_pk
865867

866868
if request.method == 'POST':
@@ -922,10 +924,10 @@ def table_content(table):
922924
session['last_viewed'] = last_viewed[:10]
923925

924926
table_pk = model._meta.primary_key
925-
rows, keys = [], ([] if allow_edit else None)
927+
rows, keys = [], ([] if allow_detail else None)
926928
for row in query:
927929
rows.append([row[c] for c in columns])
928-
if allow_edit:
930+
if allow_detail:
929931
keys.append(encode_pk(row, table_pk))
930932
result = Result('rows', columns=columns, rows=rows, keys=keys)
931933

@@ -935,6 +937,7 @@ def table_content(table):
935937
return render_template(
936938
'table_content.html',
937939
allow_bulk=allow_bulk,
940+
allow_detail=allow_detail,
938941
allow_edit=allow_edit,
939942
fk_lookup=fk_lookup,
940943
next_page=next_page,
@@ -1201,6 +1204,38 @@ def table_delete(table, pk):
12011204
table=table,
12021205
table_pk=table_pk)
12031206

1207+
@app.route('/<table>/row/<b64:pk>/')
1208+
@require_table
1209+
def table_row_detail(table, pk):
1210+
dataset = get_dataset()
1211+
dataset.ensure_cache()
1212+
model = dataset[table].model_class
1213+
table_pk = model._meta.primary_key
1214+
if not table_pk or dataset.cached_is_view(table):
1215+
flash('Row detail requires a table with a primary key.', 'danger')
1216+
return redirect(url_for('table_content', table=table))
1217+
1218+
expr = decode_pk(model, pk)
1219+
try:
1220+
row = model.select().where(expr).dicts().get()
1221+
except model.DoesNotExist:
1222+
pk_repr = pk_display(table_pk, pk)
1223+
flash('Could not fetch row with primary-key %s.' % str(pk_repr),
1224+
'danger')
1225+
return redirect(url_for('table_content', table=table))
1226+
1227+
fk_lookup = {fk.column: (fk.dest_table, fk.dest_column)
1228+
for fk in dataset.cached_foreign_keys(table)}
1229+
return render_template(
1230+
'table_row.html',
1231+
allow_edit=not dataset.is_readonly,
1232+
fk_lookup=fk_lookup,
1233+
model=model,
1234+
pk=pk,
1235+
row=row,
1236+
table=table,
1237+
table_pk=table_pk)
1238+
12041239
@app.route('/<table>/query/', methods=['GET', 'POST'])
12051240
@require_table
12061241
def table_query(table):
@@ -1266,6 +1301,27 @@ def table_export(table):
12661301
columns=columns,
12671302
table=table)
12681303

1304+
@app.route('/download/')
1305+
def db_download():
1306+
dataset = get_dataset()
1307+
# The same filename sanitizer the row exports use.
1308+
filename = re.sub(r'[^\w\d\-\.]+', '', dataset.basename) or 'database.db'
1309+
tmp_dir = tempfile.mkdtemp()
1310+
dest = os.path.join(tmp_dir, filename)
1311+
try:
1312+
# VACUUM INTO produces a consistent snapshot even mid-write.
1313+
dataset.query('VACUUM INTO ?', (dest,))
1314+
except Exception as exc:
1315+
shutil.rmtree(tmp_dir, ignore_errors=True)
1316+
flash('Error creating database snapshot: %s' % exc, 'danger')
1317+
app.logger.exception('Error creating database snapshot.')
1318+
return redirect(url_for('index'))
1319+
1320+
response = send_file(dest, mimetype='application/octet-stream',
1321+
as_attachment=True)
1322+
response.call_on_close(lambda: shutil.rmtree(tmp_dir, ignore_errors=True))
1323+
return response
1324+
12691325
@app.route('/<table>/import/', methods=['GET', 'POST'])
12701326
@require_table
12711327
def table_import(table):

sqlite_web/static/css/sqlbrowse.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,43 @@ table.cell-content td.num {
3737
font-variant-numeric: tabular-nums;
3838
text-align: right;
3939
}
40+
table.cell-content tbody td {
41+
position: relative;
42+
}
43+
svg.icon {
44+
width: 14px;
45+
height: 14px;
46+
fill: currentColor;
47+
vertical-align: -2px;
48+
}
49+
td a.rowaction {
50+
padding: 0 2px;
51+
}
52+
button.copy-cell {
53+
position: absolute;
54+
top: 1px;
55+
right: 1px;
56+
padding: 1px 3px 2px;
57+
line-height: 1;
58+
color: #555555;
59+
background: #f8f9fa;
60+
border: 1px solid #aaaaaa;
61+
border-radius: 3px;
62+
cursor: pointer;
63+
}
64+
button.copy-cell:hover {
65+
background: #e2e6ea;
66+
}
67+
button.copy-cell svg.icon {
68+
width: 12px;
69+
height: 12px;
70+
vertical-align: 0;
71+
}
72+
/* Numbers are right-aligned, so the button docks left in numeric cells. */
73+
td.num button.copy-cell {
74+
left: 1px;
75+
right: auto;
76+
}
4077
ul.pagination .page-item .page-link {
4178
border-color: #aaaaaa !important;
4279
color: #888;

sqlite_web/static/js/app.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,91 @@ App = window.App || {};
109109
$('button.bulk-action').prop('disabled', ($('input.toggle-pk:checked').length == 0));
110110
});
111111

112+
/* Copy cell values and rows as JSON to the clipboard. */
113+
function copyText(text, done) {
114+
if (navigator.clipboard && window.isSecureContext) {
115+
navigator.clipboard.writeText(text).then(done);
116+
} else {
117+
var ta = $('<textarea style="position:fixed;opacity:0;"></textarea>')
118+
.val(text).appendTo('body');
119+
ta[0].select();
120+
document.execCommand('copy');
121+
ta.remove();
122+
done();
123+
}
124+
}
125+
126+
/* Returns [value, isNull] for a result cell. The full span holds the
127+
untruncated value and external links keep it in the href. */
128+
function cellData(td) {
129+
var el = td.clone();
130+
el.find('button.copy-cell').remove();
131+
if (el.children('code').length && el.text().trim() === 'NULL') {
132+
return [null, true];
133+
}
134+
var full = el.find('span.full');
135+
if (full.length) {
136+
return [full.text(), false];
137+
}
138+
var href = el.children('a').first().attr('href') || '';
139+
if (/^(https?:|mailto:)/.test(href)) {
140+
return [href, false];
141+
}
142+
return [el.text().trim(), false];
143+
}
144+
145+
/* One shared button that rides along inside the hovered cell. */
146+
var copyBtn = $('<button class="copy-cell" title="Copy value" type="button">' +
147+
'<svg class="icon"><use href="#i-copy"/></svg></button>');
148+
copyBtn.on('click', function(e) {
149+
e.preventDefault();
150+
var data = cellData(copyBtn.parent()),
151+
use = copyBtn.find('use');
152+
copyText(data[1] ? 'NULL' : String(data[0]), function() {
153+
use.attr('href', '#i-check');
154+
setTimeout(function() { use.attr('href', '#i-copy'); }, 800);
155+
});
156+
});
157+
$('table.cell-content').on('mouseenter', 'tbody td', function() {
158+
var td = $(this);
159+
if (td.find('input.toggle-pk').length || td.find('a.copy-row').length) {
160+
copyBtn.detach();
161+
} else {
162+
copyBtn.appendTo(td);
163+
}
164+
});
165+
$('table.cell-content').on('mouseleave', 'tbody tr', function() {
166+
copyBtn.detach();
167+
});
168+
169+
$('a.copy-row').on('click', function(e) {
170+
e.preventDefault();
171+
var elem = $(this),
172+
row = elem.parents('tr'),
173+
cols = row.parents('table').find('thead th[data-col]'),
174+
tds = row.children('td').filter(function() {
175+
var td = $(this);
176+
return !td.find('input.toggle-pk').length &&
177+
!td.find('a.copy-row').length;
178+
}),
179+
accum = {};
180+
cols.each(function(i) {
181+
var td = tds.eq(i);
182+
if (!td.length) return;
183+
var data = cellData(td),
184+
value = data[0];
185+
if (!data[1] && td.hasClass('num') && isFinite(Number(value))) {
186+
value = Number(value);
187+
}
188+
accum[$(this).data('col')] = value;
189+
});
190+
copyText(JSON.stringify(accum, null, 2), function() {
191+
var use = elem.find('use');
192+
use.attr('href', '#i-check');
193+
setTimeout(function() { use.attr('href', '#i-copy'); }, 800);
194+
});
195+
});
196+
112197
/* Initialize focus on SQL textarea. */
113198
var sqlTextarea = $('textarea#sql, textarea#table-sql');
114199
if (sqlTextarea.length > 0) {

sqlite_web/static/js/bootstrap.bundle.min.js

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sqlite_web/static/js/jquery-1.11.0.min.js

Lines changed: 0 additions & 4 deletions
This file was deleted.

sqlite_web/static/js/jquery-3.7.1.min.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sqlite_web/templates/base.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
}
1818
</style>
1919
{% block extra_head %}{% endblock %}
20-
<script src="{{ url_for('static', filename='js/jquery-1.11.0.min.js') }}" type="text/javascript"></script>
20+
<script src="{{ url_for('static', filename='js/jquery-3.7.1.min.js') }}" type="text/javascript"></script>
2121
<script src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
2222
<script src="{{ url_for('static', filename='js/app.js') }}" type="text/javascript"></script>
2323
<script type="text/javascript">
@@ -30,6 +30,7 @@
3030
</head>
3131

3232
<body class="{% block body_class %}{% endblock %}">
33+
{% include "icons.html" %}
3334
<nav class="navbar navbar-expand-lg navbar-dark bg-dark primary-nav">
3435
<a class="navbar-brand" href="{{ url_for('index') }}">sqlite-web {{ version }}</a>
3536
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">

sqlite_web/templates/icons.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{# Bootstrap Icons (MIT): copy, check2, eye, pencil, trash. Referenced as
2+
<svg class="icon"><use href="#i-name"/></svg>. Same-document symbols, so
3+
app.js can swap hrefs and fills follow the link color. #}
4+
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
5+
<symbol id="i-copy" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M4 2a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm2-1a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V2a1 1 0 0 0-1-1zM2 5a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1h1v1a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h1v1z"/></symbol>
6+
<symbol id="i-check" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0"/></symbol>
7+
<symbol id="i-eye" viewBox="0 0 16 16"><path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8M1.173 8a13 13 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5s3.879 1.168 5.168 2.457A13 13 0 0 1 14.828 8q-.086.13-.195.288c-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5s-3.879-1.168-5.168-2.457A13 13 0 0 1 1.172 8z"/><path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5M4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0"/></symbol>
8+
<symbol id="i-pencil" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/></symbol>
9+
<symbol id="i-trash" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/><path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/></symbol>
10+
</svg>

0 commit comments

Comments
 (0)