feat: SQLite preview read straight from the file, and sorting of the file table by column, kept per folder
This commit is contained in:
1 parent
1a90af7800
commit
c911143eb2
2 files changed
+191
-10
No files matched your search
@@ -143,13 +143,13 @@
|
|||||||
// ---------- tables: one grid per sheet ----------
|
// ---------- tables: one grid per sheet ----------
|
||||||
const MAX_ROWS = 2000, MAX_COLS = 60;
|
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 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) {
|
function grid(rows, names) {
|
||||||
let cols = 0;
|
let cols = names ? names.length : 0;
|
||||||
for (const r of rows) cols = Math.max(cols, r.length);
|
for (const r of rows) cols = Math.max(cols, r.length);
|
||||||
cols = Math.min(cols, MAX_COLS);
|
cols = Math.min(cols, MAX_COLS);
|
||||||
const cut = rows.length > MAX_ROWS || rows.some(r => r.length > 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>';
|
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>';
|
html += '</tr></thead><tbody>';
|
||||||
rows.slice(0, MAX_ROWS).forEach((r, i) => {
|
rows.slice(0, MAX_ROWS).forEach((r, i) => {
|
||||||
html += '<tr><th>' + (i + 1) + '</th>';
|
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>';
|
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) => {
|
list.forEach((s, i) => {
|
||||||
const g = grid(s.rows);
|
const g = grid(s.rows, s.cols);
|
||||||
html += '<div class="dv-pane" data-pane="' + i + '"' + (i ? ' hidden' : '') + '>' + g.html +
|
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>';
|
(g.cut ? '<p class="dv-note">показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов</p>' : '') + '</div>';
|
||||||
});
|
});
|
||||||
return html;
|
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>';
|
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};
|
||||||
})();
|
})();
|
||||||
@@ -45,6 +45,10 @@
|
|||||||
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
.ic { display:inline-block; width:18px; text-align:center; margin-right:6px; }
|
||||||
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
.sz { color:var(--muted); font-variant-numeric:tabular-nums; }
|
||||||
.act { display:flex; gap:6px; justify-content:flex-end; }
|
.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-sz { width:120px; }
|
||||||
table.fm .c-mt { width:160px; }
|
table.fm .c-mt { width:160px; }
|
||||||
table.fm .c-act { width:160px; }
|
table.fm .c-act { width:160px; }
|
||||||
@@ -208,8 +212,8 @@
|
|||||||
<table class="fm">
|
<table class="fm">
|
||||||
<thead><tr>
|
<thead><tr>
|
||||||
<th style="width:28px"><input type="checkbox" id="all"></th>
|
<th style="width:28px"><input type="checkbox" id="all"></th>
|
||||||
<th>Имя</th><th class="sz c-sz">Размер</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">Изменён</th><th class="c-act"></th>
|
<th class="sz c-mt srt" data-sort="mtime">Изменён<i></i></th><th class="c-act"></th>
|
||||||
</tr></thead>
|
</tr></thead>
|
||||||
<tbody id="list"></tbody>
|
<tbody id="list"></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -326,10 +330,49 @@
|
|||||||
$('path').value = cwd; render();
|
$('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() {
|
function render() {
|
||||||
|
showSort();
|
||||||
const tb = $('list'); tb.innerHTML = '';
|
const tb = $('list'); tb.innerHTML = '';
|
||||||
updateSel();
|
updateSel();
|
||||||
for (const e of entries) {
|
for (const e of sorted()) {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
const isZip = !e.is_dir && /\.zip$/i.test(e.name);
|
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));
|
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 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 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
|
const DOC_CAP = 32 * 1024 * 1024; // the biggest document drawn here
|
||||||
|
|
||||||
// Highlighting by the file's name, then its extension, then its #! line.
|
// Highlighting by the file's name, then its extension, then its #! line.
|
||||||
@@ -564,6 +607,7 @@
|
|||||||
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
: ext === 'xlsx' ? await DocView.xlsx(buf)
|
||||||
: ext === 'ods' ? await DocView.ods(buf)
|
: ext === 'ods' ? await DocView.ods(buf)
|
||||||
: ext === 'doc' ? DocView.doc(buf)
|
: ext === 'doc' ? DocView.doc(buf)
|
||||||
|
: /^(db|sqlite3?|db3)$/.test(ext) ? DocView.sqlite(buf)
|
||||||
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
: DocView.csv(new TextDecoder().decode(buf), ext === 'tsv');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
html = '<p class="dv-note">не удалось показать: ' + esc(err.message) + '</p>';
|
||||||
|
|||||||
Reference in new issue
Block a user