327 lines
16 KiB
JavaScript
327 lines
16 KiB
JavaScript
// SQLite editor of the file manager: the agent opens the database and runs
|
||
// the queries, so a database in use keeps its locks. Tables and views in
|
||
// tabs, rows a page at a time; a click on a value edits it (Enter saves,
|
||
// Esc cancels, Shift+Enter a new line, the ∅ button makes it NULL). Rows are
|
||
// added and deleted, and a tab runs any SQL.
|
||
(function () {
|
||
'use strict';
|
||
var PAGE = 200;
|
||
var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return {'&': '&', '<': '<', '>': '>', '"': '"'}[c]; }); };
|
||
|
||
function cellHTML(c) {
|
||
if (c.t === 'n') return '<i class="dbx-null">NULL</i>';
|
||
if (c.t === 'b') return '<i class="dbx-null">BLOB ' + esc(c.v) + ' Б</i>';
|
||
var v = c.v || '';
|
||
return esc(v.length > 300 ? v.slice(0, 300) + '…' : v);
|
||
}
|
||
// The type a typed value goes as: what the value was, or what the column
|
||
// is declared as, when the text fits it.
|
||
function typeFor(text, was, decl) {
|
||
var d = (decl || '').toUpperCase();
|
||
var int = /^\s*-?\d+\s*$/.test(text), num = text.trim() !== '' && isFinite(Number(text));
|
||
if (was === 'i' || (was !== 's' && /INT/.test(d))) return int ? 'i' : num ? 'r' : 's';
|
||
if (was === 'r' || (was !== 's' && /REAL|FLOA|DOUB|NUM|DEC/.test(d))) return num ? 'r' : 's';
|
||
return 's';
|
||
}
|
||
|
||
function open(box, path, call, toast) {
|
||
var S = {tables: [], cur: null, offset: 0, data: null, sel: new Set()};
|
||
box.innerHTML =
|
||
'<div class="dbx">' +
|
||
'<div class="dbx-tabs" role="tablist"></div>' +
|
||
'<div class="dbx-tools">' +
|
||
' <span class="muted dbx-info"></span>' +
|
||
' <span class="dbx-pager"><button type="button" class="btn ghost small icon dbx-prev" title="Назад" aria-label="Назад">‹</button>' +
|
||
' <span class="muted dbx-range"></span><button type="button" class="btn ghost small icon dbx-next" title="Дальше" aria-label="Дальше">›</button></span>' +
|
||
' <span class="dbx-sp"></span>' +
|
||
' <button type="button" class="btn ghost small dbx-add">+ Строка</button>' +
|
||
' <button type="button" class="btn danger small dbx-del" disabled>Удалить</button>' +
|
||
'</div>' +
|
||
'<div class="dbx-grid"></div>' +
|
||
'<div class="dbx-sql" hidden>' +
|
||
' <textarea class="dbx-q" spellcheck="false" placeholder="SELECT * FROM … - Ctrl+Enter выполнить"></textarea>' +
|
||
' <div class="dbx-sqlbar"><button type="button" class="dbx-run">Выполнить</button><span class="muted dbx-sqlmsg"></span></div>' +
|
||
' <div class="dbx-sqlres"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
var $ = function (s) { return box.querySelector(s); };
|
||
|
||
function busy(on) { box.querySelector('.dbx').classList.toggle('busy', on); }
|
||
function req(op, args) {
|
||
args.path = path;
|
||
busy(true);
|
||
return call(op, args).then(function (r) { busy(false); return r; }, function (e) { busy(false); throw e; });
|
||
}
|
||
|
||
function tabs() {
|
||
var t = $('.dbx-tabs');
|
||
t.innerHTML = '';
|
||
S.tables.forEach(function (x) {
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.textContent = x.name;
|
||
if (x.type === 'view') b.title = 'представление - только чтение';
|
||
b.className = x.name === S.cur ? 'on' : '';
|
||
b.onclick = function () { show(x.name, 0); };
|
||
t.appendChild(b);
|
||
});
|
||
var q = document.createElement('button');
|
||
q.type = 'button';
|
||
q.textContent = 'SQL';
|
||
q.className = 'dbx-sqltab' + (S.cur === null ? ' on' : '');
|
||
q.onclick = sqlTab;
|
||
t.appendChild(q);
|
||
}
|
||
|
||
function show(name, offset) {
|
||
S.cur = name; S.offset = offset; S.sel.clear();
|
||
tabs();
|
||
$('.dbx-sql').hidden = true;
|
||
$('.dbx-grid').hidden = false;
|
||
$('.dbx-tools').hidden = false;
|
||
return req('db.rows', {table: name, offset: offset, limit: PAGE}).then(function (r) {
|
||
if (!r.ok) { $('.dbx-grid').innerHTML = '<p class="dv-note">' + esc(r.error || 'ошибка') + '</p>'; return; }
|
||
S.data = r.data;
|
||
draw();
|
||
});
|
||
}
|
||
|
||
function draw() {
|
||
var d = S.data, t = d.table, editable = !!t.key;
|
||
// An INTEGER PRIMARY KEY column already shows the rowid.
|
||
var showRowid = t.key === 'rowid' && !t.alias;
|
||
$('.dbx-info').textContent = d.total + ' ' + plural(d.total) + (t.type === 'view' ? ' · представление' : !editable ? ' · только чтение' : '');
|
||
$('.dbx-range').textContent = d.rows.length ? (d.offset + 1) + '–' + (d.offset + d.rows.length) : '';
|
||
$('.dbx-prev').disabled = d.offset <= 0;
|
||
$('.dbx-next').disabled = d.offset + d.rows.length >= d.total;
|
||
$('.dbx-pager').hidden = d.total <= PAGE;
|
||
$('.dbx-add').hidden = t.type !== 'table';
|
||
$('.dbx-del').hidden = !editable;
|
||
selInfo();
|
||
var h = '<table class="dbx-t"><thead><tr>' + (editable ? '<th class="dbx-ck"><input type="checkbox" class="dbx-all" aria-label="Выбрать все"></th>' : '') +
|
||
(showRowid ? '<th class="dbx-k">rowid</th>' : '');
|
||
t.columns.forEach(function (c) {
|
||
h += '<th title="' + esc(c.type + (c.pk ? ' · ключ' : '') + (c.notnull ? ' · NOT NULL' : '')) + '">' + esc(c.name) +
|
||
(c.type ? '<small>' + esc(c.type.toLowerCase()) + '</small>' : '') + '</th>';
|
||
});
|
||
h += '</tr></thead><tbody>';
|
||
d.rows.forEach(function (row, i) {
|
||
h += '<tr data-i="' + i + '">' + (editable ? '<td class="dbx-ck"><input type="checkbox" aria-label="Выбрать строку"></td>' : '') +
|
||
(showRowid ? '<td class="dbx-k">' + esc(row.k) + '</td>' : '');
|
||
row.v.forEach(function (c, j) {
|
||
h += '<td data-j="' + j + '" class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + (editable && c.t !== 'b' ? ' ed' : '') + '">' + cellHTML(c) + '</td>';
|
||
});
|
||
h += '</tr>';
|
||
});
|
||
h += '</tbody></table>';
|
||
if (!d.rows.length) h += '<p class="dv-note">строк нет</p>';
|
||
$('.dbx-grid').innerHTML = h;
|
||
$('.dbx-grid').scrollTop = 0;
|
||
fixWidths($('.dbx-grid table'));
|
||
}
|
||
// The columns keep the widths they first came out at, so a value being
|
||
// edited does not move the table about.
|
||
function fixWidths(table) {
|
||
if (!table) return;
|
||
var ths = table.querySelectorAll('thead th'), ws = [], sum = 0;
|
||
ths.forEach(function (th) { var w = Math.ceil(th.getBoundingClientRect().width); ws.push(w); sum += w; });
|
||
ths.forEach(function (th, i) { th.style.width = ws[i] + 'px'; });
|
||
table.style.width = sum + 'px';
|
||
table.style.tableLayout = 'fixed';
|
||
}
|
||
function plural(n) {
|
||
var m10 = n % 10, m100 = n % 100;
|
||
return m10 === 1 && m100 !== 11 ? 'строка' : m10 >= 2 && m10 <= 4 && (m100 < 12 || m100 > 14) ? 'строки' : 'строк';
|
||
}
|
||
function selInfo() {
|
||
var b = $('.dbx-del');
|
||
b.disabled = !S.sel.size;
|
||
b.textContent = S.sel.size ? 'Удалить (' + S.sel.size + ')' : 'Удалить';
|
||
}
|
||
|
||
$('.dbx-prev').onclick = function () { show(S.cur, Math.max(0, S.offset - PAGE)); };
|
||
$('.dbx-next').onclick = function () { show(S.cur, S.offset + PAGE); };
|
||
|
||
// ---- selecting rows ----
|
||
$('.dbx-grid').addEventListener('change', function (ev) {
|
||
var el = ev.target;
|
||
if (el.classList.contains('dbx-all')) {
|
||
box.querySelectorAll('tbody .dbx-ck input').forEach(function (c, i) { c.checked = el.checked; el.checked ? S.sel.add(i) : S.sel.delete(i); });
|
||
} else if (el.closest('tbody .dbx-ck')) {
|
||
var i = +el.closest('tr').dataset.i;
|
||
el.checked ? S.sel.add(i) : S.sel.delete(i);
|
||
}
|
||
selInfo();
|
||
});
|
||
|
||
// ---- editing a value ----
|
||
var editing = null;
|
||
$('.dbx-grid').addEventListener('click', function (ev) {
|
||
var td = ev.target.closest('td.ed');
|
||
if (!td || td.querySelector('textarea') || ev.target.closest('.dbx-edit')) return;
|
||
edit(td);
|
||
});
|
||
function edit(td) {
|
||
if (editing) finish(editing, true);
|
||
var i = +td.parentNode.dataset.i, j = +td.dataset.j;
|
||
var row = S.data.rows[i], cell = row.v[j], col = S.data.table.columns[j];
|
||
var hgt = td.offsetHeight;
|
||
td.classList.add('editing');
|
||
td.innerHTML = '<div class="dbx-edit"><textarea spellcheck="false"></textarea><button type="button" class="dbx-nullb" title="Сделать NULL" aria-label="Сделать NULL">∅</button></div>';
|
||
var ta = td.querySelector('textarea');
|
||
ta.value = cell.t === 'n' ? '' : cell.v;
|
||
ta.placeholder = cell.t === 'n' ? 'NULL' : '';
|
||
ta.style.height = Math.max(hgt, 30) + 'px';
|
||
ta.focus();
|
||
ta.select();
|
||
editing = {td: td, i: i, j: j, ta: ta, cell: cell, col: col, nul: false};
|
||
ta.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter' && !ev.shiftKey) { ev.preventDefault(); finish(editing, true); }
|
||
else if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); finish(editing, false); }
|
||
else if (ev.key === 'Tab') {
|
||
ev.preventDefault();
|
||
var cur = editing, next = ev.shiftKey ? cur.td.previousElementSibling : cur.td.nextElementSibling;
|
||
finish(cur, true);
|
||
if (next && next.classList.contains('ed')) edit(next);
|
||
}
|
||
});
|
||
ta.addEventListener('blur', function () { setTimeout(function () { if (editing && editing.ta === ta && document.activeElement !== td.querySelector('.dbx-nullb')) finish(editing, true); }, 120); });
|
||
td.querySelector('.dbx-nullb').onmousedown = function (ev) { ev.preventDefault(); editing.nul = true; finish(editing, true); };
|
||
}
|
||
function finish(e, keep) {
|
||
if (!e || editing !== e) return;
|
||
editing = null;
|
||
var text = e.ta.value, cell = e.cell;
|
||
var value = e.nul ? {t: 'n'} : {t: typeFor(text, cell.t, e.col.type), v: text};
|
||
var same = e.nul ? cell.t === 'n' : (cell.t !== 'n' || text !== '') && text === (cell.v || '') && (cell.t === value.t || cell.t === 's' || value.t === 's');
|
||
if (cell.t === 'n' && !e.nul && text === '') same = true;
|
||
e.td.classList.remove('editing');
|
||
if (!keep || same) { e.td.innerHTML = cellHTML(cell); return; }
|
||
e.td.innerHTML = cellHTML(value);
|
||
e.td.classList.add('saving');
|
||
req('db.set', {table: S.cur, key: S.data.rows[e.i].k, column: e.col.name, value: value}).then(function (r) {
|
||
e.td.classList.remove('saving');
|
||
if (!r.ok) {
|
||
e.td.innerHTML = cellHTML(cell);
|
||
flash(e.td, 'bad');
|
||
toast(r.error || 'не сохранилось', true);
|
||
return;
|
||
}
|
||
var got = r.data.value;
|
||
S.data.rows[e.i].v[e.j] = got;
|
||
// A changed key column moves the row's key too.
|
||
if (r.data.key !== undefined) S.data.rows[e.i].k = r.data.key;
|
||
e.td.innerHTML = cellHTML(got);
|
||
e.td.className = (got.t === 'i' || got.t === 'r' ? 'num' : '') + ' ed';
|
||
flash(e.td, 'ok');
|
||
});
|
||
}
|
||
function flash(td, kind) {
|
||
td.classList.remove('flash-ok', 'flash-bad');
|
||
void td.offsetWidth;
|
||
td.classList.add('flash-' + kind);
|
||
}
|
||
|
||
// ---- adding and deleting rows ----
|
||
$('.dbx-add').onclick = function () {
|
||
if (box.querySelector('tr.dbx-new')) return;
|
||
var t = S.data.table, tbody = box.querySelector('.dbx-t tbody');
|
||
var tr = document.createElement('tr');
|
||
tr.className = 'dbx-new';
|
||
var h = (t.key ? '<td class="dbx-ck"></td>' : '') + (t.key === 'rowid' && !t.alias ? '<td class="dbx-k">новая</td>' : '');
|
||
t.columns.forEach(function (c) {
|
||
h += '<td><input type="text" data-c="' + esc(c.name) + '" placeholder="' + esc(c.default ? 'по умолч.: ' + c.default : c.notnull ? 'обязательно' : 'NULL') + '"></td>';
|
||
});
|
||
tr.innerHTML = h;
|
||
tbody.insertBefore(tr, tbody.firstChild);
|
||
var bar = document.createElement('tr');
|
||
bar.className = 'dbx-newbar';
|
||
bar.innerHTML = '<td colspan="' + (t.columns.length + 2) + '"><button type="button" class="small dbx-addok">Добавить</button> <button type="button" class="btn ghost small dbx-addno">Отмена</button> <span class="muted">пустое поле - значение по умолчанию</span></td>';
|
||
tbody.insertBefore(bar, tr.nextSibling);
|
||
$('.dbx-grid').scrollTop = 0;
|
||
tr.querySelector('input').focus();
|
||
var done = function () { tr.remove(); bar.remove(); };
|
||
bar.querySelector('.dbx-addno').onclick = done;
|
||
var add = function () {
|
||
var values = {};
|
||
tr.querySelectorAll('input').forEach(function (inp) {
|
||
if (inp.value === '') return;
|
||
var c = t.columns.filter(function (x) { return x.name === inp.dataset.c; })[0];
|
||
values[c.name] = {t: typeFor(inp.value, '', c.type), v: inp.value};
|
||
});
|
||
req('db.add', {table: S.cur, values: values}).then(function (r) {
|
||
if (!r.ok) { toast(r.error || 'не добавилось', true); return; }
|
||
toast('строка добавлена');
|
||
var total = S.data.total + 1;
|
||
show(S.cur, Math.floor((total - 1) / PAGE) * PAGE);
|
||
});
|
||
};
|
||
bar.querySelector('.dbx-addok').onclick = add;
|
||
tr.addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter') { ev.preventDefault(); add(); }
|
||
if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); }
|
||
});
|
||
};
|
||
$('.dbx-del').onclick = async function () {
|
||
var keys = [];
|
||
S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); });
|
||
if (!keys.length || !await UI.confirm('Удалить строк: ' + keys.length + '?', {ok: 'Удалить', danger: true})) return;
|
||
req('db.del', {table: S.cur, keys: keys}).then(function (r) {
|
||
if (!r.ok) { toast(r.error || 'не удалилось', true); return; }
|
||
toast('удалено строк: ' + r.data.deleted);
|
||
show(S.cur, S.offset);
|
||
});
|
||
};
|
||
|
||
// ---- SQL ----
|
||
function sqlTab() {
|
||
S.cur = null;
|
||
tabs();
|
||
$('.dbx-grid').hidden = true;
|
||
$('.dbx-tools').hidden = true;
|
||
$('.dbx-sql').hidden = false;
|
||
$('.dbx-q').focus();
|
||
}
|
||
function run() {
|
||
var q = $('.dbx-q').value;
|
||
if (!q.trim()) return;
|
||
$('.dbx-sqlmsg').textContent = '';
|
||
req('db.sql', {sql: q}).then(function (r) {
|
||
var out = $('.dbx-sqlres');
|
||
if (!r.ok) { out.innerHTML = ''; $('.dbx-sqlmsg').textContent = r.error || 'ошибка'; $('.dbx-sqlmsg').className = 'dbx-sqlmsg bad'; return; }
|
||
$('.dbx-sqlmsg').className = 'muted dbx-sqlmsg';
|
||
var d = r.data;
|
||
if (d.columns) {
|
||
$('.dbx-sqlmsg').textContent = 'строк: ' + d.rows.length + (d.more ? ' (показаны первые 500)' : '');
|
||
var h = '<table class="dbx-t"><thead><tr>' + d.columns.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>';
|
||
d.rows.forEach(function (row) {
|
||
h += '<tr>' + row.map(function (c) { return '<td class="' + (c.t === 'i' || c.t === 'r' ? 'num' : '') + '">' + cellHTML(c) + '</td>'; }).join('') + '</tr>';
|
||
});
|
||
out.innerHTML = h + '</tbody></table>';
|
||
} else {
|
||
out.innerHTML = '';
|
||
$('.dbx-sqlmsg').textContent = 'готово · изменено строк: ' + d.changed;
|
||
loadTables(true);
|
||
}
|
||
});
|
||
}
|
||
$('.dbx-run').onclick = run;
|
||
$('.dbx-q').addEventListener('keydown', function (ev) {
|
||
if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) { ev.preventDefault(); run(); }
|
||
});
|
||
|
||
function loadTables(keep) {
|
||
return req('db.tables', {}).then(function (r) {
|
||
if (!r.ok) return r;
|
||
S.tables = r.data.tables;
|
||
if (keep) { tabs(); return r; }
|
||
if (S.tables.length) show(S.tables[0].name, 0); else sqlTab();
|
||
return r;
|
||
});
|
||
}
|
||
return loadTables(false);
|
||
}
|
||
|
||
window.DBEdit = {open: open};
|
||
})();
|