diff --git a/internal/server/web/static/docview.js b/internal/server/web/static/docview.js index a6631ff..9d8466b 100644 --- a/internal/server/web/static/docview.js +++ b/internal/server/web/static/docview.js @@ -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 = '
'; - for (let c = 0; c < cols; c++) html += ''; + for (let c = 0; c < cols; c++) html += ''; html += ''; rows.slice(0, MAX_ROWS).forEach((r, i) => { html += ''; @@ -170,8 +170,8 @@ html += '
' + list.map((s, i) => '').join('') + '
'; } list.forEach((s, i) => { - const g = grid(s.rows); - html += '
' + g.html + + const g = grid(s.rows, s.cols); + html += '
' + (s.note ? '

' + esc(s.note) + '

' : '') + g.html + (g.cut ? '

показаны первые ' + MAX_ROWS + ' строк и ' + MAX_COLS + ' столбцов

' : '') + '
'; }); return html; @@ -387,5 +387,142 @@ text.split(/\r\n|\r|\n|\x07/).filter(l => l.trim()).map(l => '

' + esc(l) + '

').join('') + '
'; } - 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 '

в базе нет таблиц

'; + return sheets(list); + } + + window.DocView = {docx, xlsx, ods, odt, csv, doc, sqlite, bindTabs}; })(); diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index 2d6fbb1..4625aa1 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -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 @@
' + colName(c) + '' + esc(names && names[c] != null ? names[c] : colName(c)) + '
' + (i + 1) + '
- - + +
ИмяРазмерИзменёнИмяРазмерИзменён
@@ -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 = '

не удалось показать: ' + esc(err.message) + '

';