feat: SQLite preview read straight from the file, and sorting of the file table by column, kept per folder

This commit is contained in:
redtea committed 2026-10-14 18:46:32 +03:00
1 parent 1a90af7800
commit c911143eb2
2 files changed
+191 -10

No files matched your search

+143 -6
View File
@@ -143,13 +143,13 @@
// ---------- tables: one grid per sheet ----------
const MAX_ROWS = 2000, MAX_COLS = 60;
function colName(i) { let s = ''; for (i++; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + (i - 1) % 26) + s; return s; }
function grid(rows) {
let cols = 0;
function grid(rows, names) {
let cols = names ? names.length : 0;
for (const r of rows) cols = Math.max(cols, r.length);
cols = Math.min(cols, MAX_COLS);
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > MAX_COLS);
let html = '<div class="dv-scroll dv-grid"><table class="dv-sheet"><thead><tr><th></th>';
for (let c = 0; c < cols; c++) html += '<th>' + colName(c) + '</th>';
for (let c = 0; c < cols; c++) html += '<th>' + esc(names && names[c] != null ? names[c] : colName(c)) + '</th>';
html += '</tr></thead><tbody>';
rows.slice(0, MAX_ROWS).forEach((r, i) => {
html += '<tr><th>' + (i + 1) + '</th>';
@@ -170,8 +170,8 @@
html += '<div class="dv-tabs">' + list.map((s, i) => '<button type="button" data-sheet="' + i + '"' + (i ? '' : ' class="on"') + '>' + esc(s.name) + '</button>').join('') + '</div>';
}
list.forEach((s, i) => {
const g = grid(s.rows);
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + g.html +
const g = grid(s.rows, s.cols);
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + (s.note ? '<p class="dv-note">' + esc(s.note) + '</p>' : '') + g.html +
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
});
return html;
@@ -387,5 +387,142 @@
text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '<p>' + esc(l) + '</p>').join('') + '</div>';
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, bindTabs};
// ---------- sqlite ----------
// Reads the database file itself: the b-tree of sqlite_master for the
// tables, then each table's b-tree for its first rows. No SQL engine.
function sqlite(buf) {
const b = new Uint8Array(buf);
if (new TextDecoder().decode(b.subarray(0, 15)) !== 'SQLite format 3') throw new Error('не база SQLite');
const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
let ps = dv.getUint16(16);
if (ps === 1) ps = 65536;
const usable = ps - b[20];
const enc = dv.getUint32(56);
const dec = new TextDecoder(enc === 2 ? 'utf-16le' : enc === 3 ? 'utf-16be' : 'utf-8');
const page = n => (n - 1) * ps;
function varint(o) {
let v = 0;
for (let i = 0; i < 9; i++) {
const c = b[o + i];
if (i === 8) return [v * 256 + c, o + 9];
v = v * 128 + (c & 0x7f);
if (!(c & 0x80)) return [v, o + i + 1];
}
}
// A cell's payload, gathered from its overflow pages when it spills.
function payload(o, size) {
const max = usable - 35;
if (size <= max) return b.subarray(o, o + size);
const min = Math.floor((usable - 12) * 32 / 255) - 23;
let local = min + (size - min) % (usable - 4);
if (local > max) local = min;
const out = new Uint8Array(size);
out.set(b.subarray(o, o + local));
let at = local, next = dv.getUint32(o + local);
for (let guard = 0; next && at < size && guard < 1e6; guard++) {
const po = page(next), len = Math.min(usable - 4, size - at);
out.set(b.subarray(po + 4, po + 4 + len), at);
at += len;
next = dv.getUint32(po);
}
return out;
}
function record(p) {
const pv = new DataView(p.buffer, p.byteOffset, p.byteLength);
let [hlen, o] = vi(p, 0);
const types = [];
while (o < hlen) { const r = vi(p, o); types.push(r[0]); o = r[1]; }
let d = hlen;
return types.map(t => {
let v = null;
if (t >= 1 && t <= 6) {
const n = [0, 1, 2, 3, 4, 6, 8][t];
let x = 0;
for (let i = 0; i < n; i++) x = x * 256 + p[d + i];
if (p[d] & 0x80) x -= Math.pow(2, 8 * n);
v = n === 8 && Math.abs(x) > Number.MAX_SAFE_INTEGER ? pv.getBigInt64(d).toString() : x;
d += n;
} else if (t === 7) { v = pv.getFloat64(d); d += 8; }
else if (t === 8) v = 0;
else if (t === 9) v = 1;
else if (t >= 12 && t % 2 === 0) { const n = (t - 12) / 2; v = '[blob ' + n + ' B]'; d += n; }
else if (t >= 13) { const n = (t - 13) / 2; v = dec.decode(p.subarray(d, d + n)); d += n; }
return v;
});
}
// varint over a payload buffer rather than the file
function vi(p, o) {
let v = 0;
for (let i = 0; i < 9; i++) {
const c = p[o + i];
if (i === 8) return [v * 256 + c, o + 9];
v = v * 128 + (c & 0x7f);
if (!(c & 0x80)) return [v, o + i + 1];
}
}
// Walks a table b-tree in rowid order, up to limit rows; also counts all.
function table(root, limit) {
const rows = [];
let count = 0;
const walk = (n, depth) => {
if (depth > 40) return;
const h = page(n) + (n === 1 ? 100 : 0);
const type = b[h], cells = dv.getUint16(h + 3);
const ptrs = h + (type === 0x05 ? 12 : 8);
if (type === 0x0d) {
count += cells;
for (let i = 0; i < cells && rows.length < limit; i++) {
let o = page(n) + dv.getUint16(ptrs + i * 2), size, rowid;
[size, o] = varint(o);
[rowid, o] = varint(o);
rows.push({rowid, values: record(payload(o, size))});
}
} else if (type === 0x05) {
for (let i = 0; i < cells; i++) walk(dv.getUint32(page(n) + dv.getUint16(ptrs + i * 2)), depth + 1);
walk(dv.getUint32(h + 8), depth + 1);
}
};
walk(root, 0);
return {rows, count};
}
// Column names out of CREATE TABLE; an INTEGER PRIMARY KEY is the rowid.
function columns(sql) {
const m = /\(([\s\S]*)\)/.exec(sql || '');
if (!m) return {names: [], rowidCol: -1};
const parts = [];
let depth = 0, cur = '';
for (const c of m[1]) {
if (c === '(') depth++;
if (c === ')') depth--;
if (c === ',' && !depth) { parts.push(cur); cur = ''; } else cur += c;
}
parts.push(cur);
const names = [];
let rowidCol = -1;
for (const p of parts) {
const t = p.trim();
if (!t || /^(constraint|primary\s+key|unique|check|foreign\s+key)\b/i.test(t)) continue;
const nm = /^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\]|\S+)/.exec(t)[1].replace(/^["`\[]|["`\]]$/g, '');
if (/^\S+\s+integer\s+primary\s+key\b/i.test(t.replace(/^("(?:[^"]|"")+"|`[^`]+`|\[[^\]]+\])/, 'x'))) rowidCol = names.length;
names.push(nm);
}
return {names, rowidCol};
}
const master = table(1, 100000).rows.map(r => ({type: r.values[0], name: r.values[1], root: r.values[3], sql: r.values[4] || ''}));
const list = [];
for (const t of master.filter(x => x.type === 'table' && x.root).sort((a, c) => String(a.name).localeCompare(String(c.name)))) {
if (/without\s+rowid/i.test(t.sql)) { list.push({name: t.name, rows: [], note: 'таблица WITHOUT ROWID - её строки здесь не показываются'}); continue; }
const {names, rowidCol} = columns(t.sql);
let data;
try { data = table(t.root, MAX_ROWS); } catch (e) { list.push({name: t.name, rows: [], note: 'не удалось прочитать: ' + e.message}); continue; }
const rows = data.rows.map(r => { const v = r.values.slice(); if (rowidCol >= 0 && v[rowidCol] == null) v[rowidCol] = r.rowid; return v; });
list.push({name: t.name, cols: names, rows, note: data.count + ' ' + (data.count % 10 === 1 && data.count % 100 !== 11 ? 'строка' : data.count % 10 >= 2 && data.count % 10 <= 4 && (data.count % 100 < 12 || data.count % 100 > 14) ? 'строки' : 'строк') + (data.count > rows.length ? ', показаны первые ' + rows.length : '')});
}
if (!list.length) return '<p class="dv-note">в базе нет таблиц</p>';
return sheets(list);
}
window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs};
})();
+48 -4
View File
@@ -45,6 +45,10 @@
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
.act { display:flex; gap:6px; justify-content:flex-end; }
/* Sortable headers: a click sorts by the column, a second one turns it round. */
table.fm th.srt { cursor:pointer; user-select:none; white-space:nowrap; }
table.fm th.srt:hover { color:var(--text); }
table.fm th.srt i { font-style:normal; display:inline-block; width:12px; margin-left:4px; color:var(--accent); font-size:10px; }
table.fm .c-sz { width:120px; }
table.fm .c-mt { width:160px; }
table.fm .c-act { width:160px; }
@@ -208,8 +212,8 @@
<table class="fm">
<thead><tr>
<th style="width:28px"><input type="checkbox" id="all"></th>
<th>Имя</th><th class="sz c-sz">Размер</th>
<th class="sz c-mt">Изменён</th><th class="c-act"></th>
<th class="srt" data-sort="name">Имя<i></i></th><th class="sz c-sz srt" data-sort="size">Размер<i></i></th>
<th class="sz c-mt srt" data-sort="mtime">Изменён<i></i></th><th class="c-act"></th>
</tr></thead>
<tbody id="list"></tbody>
</table>
@@ -326,10 +330,49 @@
$('path').value = cwd; render();
}
// Sorting: by name, size or time, folders always ahead of files. Kept in
// this browser for each folder of each machine.
const SORT_DEF = {by: 'name', dir: 1};
const sortKey = () => 'lpra.sort:' + agentID + ':' + cwd;
function getSort() {
try { const v = JSON.parse(localStorage.getItem(sortKey())); if (v && ['name', 'size', 'mtime'].includes(v.by)) return v; } catch (e) {}
return SORT_DEF;
}
function setSort(v) {
try {
if (v.by === SORT_DEF.by && v.dir === SORT_DEF.dir) localStorage.removeItem(sortKey());
else localStorage.setItem(sortKey(), JSON.stringify(v));
} catch (e) {}
}
const byName = (a, b) => a.name.localeCompare(b.name, undefined, {numeric: true, sensitivity: 'base'}) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
function sorted() {
const s = getSort();
const key = s.by === 'size' ? e => e.size || 0 : s.by === 'mtime' ? e => e.mtime || 0 : null;
return entries.slice().sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
const d = key ? key(a) - key(b) : 0;
return (d || byName(a, b)) * s.dir;
});
}
function showSort() {
const s = getSort();
document.querySelectorAll('table.fm th.srt').forEach(th => {
th.querySelector('i').textContent = th.dataset.sort === s.by ? (s.dir > 0 ? '▲' : '▼') : '';
});
}
document.querySelectorAll('table.fm th.srt').forEach(th => th.onclick = () => {
const s = getSort(), by = th.dataset.sort;
// A new column starts the way it is most often wanted: names A to Z,
// sizes and times biggest and newest first.
setSort(s.by === by ? {by, dir: -s.dir} : {by, dir: by === 'name' ? 1 : -1});
render();
});
function render() {
showSort();
const tb = $('list'); tb.innerHTML = '';
updateSel();
for (const e of entries) {
for (const e of sorted()) {
const tr = document.createElement('tr');
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
const dlHref = '/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/download?path=' + encodeURIComponent(join(cwd, e.name));
@@ -381,7 +424,7 @@
const IMG = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i, VID = /\.(mp4|m4v|webm|ogv|mov)$/i, AUD = /\.(mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
const HEX_CAP = 64 * 1024 * 1024; // what the hex editor takes in memory
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?)$/i;
const DOC = /\.(pdf|docx?|odt|xlsx|ods|csv|tsv|html?|db|sqlite3?|db3)$/i;
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
// Highlighting by the file's name, then its extension, then its #! line.
@@ -564,6 +607,7 @@
: ext === 'xlsx' ? await DocView.xlsx(buf)
: ext === 'ods' ? await DocView.ods(buf)
: ext === 'doc' ? DocView.doc(buf)
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
} catch (err) {
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';