feat: make file picker embeddable
This commit is contained in:
@@ -7,6 +7,9 @@ __pycache__/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
!dist/
|
||||
!dist/file-picker.esm.js
|
||||
!dist/file-picker.iife.js
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
|
||||
@@ -9,7 +9,7 @@ backend.
|
||||
|
||||
| Слой | Ответственность |
|
||||
|---|---|
|
||||
| Конфиг | `allowedExt` |
|
||||
| Конфиг | `allowedExt`, `labels`, `layout`, `limits` |
|
||||
| Picker | таблица, дедупликация, ZIP, рекурсивный обход папки |
|
||||
| Интегрирующее приложение | получает leaf-файлы через `getFiles()` |
|
||||
|
||||
@@ -50,16 +50,17 @@ python site/app.py
|
||||
|
||||
## Как интегрировать picker в другой проект
|
||||
|
||||
1. Скопировать папку `upload/` в свой проект.
|
||||
2. Задать конфиг по образцу `upload/config.example.json`.
|
||||
3. Подключить локальный `fflate` до picker-модуля.
|
||||
4. Вызвать `initUploadTable({ ...cfg, fileInputEl, folderInputEl, tableBodyEl, countEl })`.
|
||||
5. Получить выбранные leaf-файлы через `table.getFiles()`; каждый элемент содержит
|
||||
1. Скопировать `upload/frontend/` и один из собранных файлов `dist/` в свой проект.
|
||||
2. Подключить `file-picker.iife.js` через `<script>` и вызвать `FilePicker.initFilePicker`, либо импортировать ESM-бандл.
|
||||
3. Передать `mount`, `allowedExt`, а при необходимости `labels`, `layout`, `limits` и `onChange`.
|
||||
4. Получить выбранные leaf-файлы через `picker.getFiles()`; каждый элемент содержит
|
||||
`path`, `name`, `size` и исходный `file`.
|
||||
5. После удаления компонента вызвать `picker.destroy()`.
|
||||
|
||||
Разрешённые расширения по умолчанию: `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
|
||||
Разрешённые расширения демо: `.pdf`, `.doc`, `.docx`, `.txt`, `.md`.
|
||||
ZIP-файлы используются как контейнеры и раскрываются в браузере; сам ZIP не
|
||||
возвращается как leaf-файл. Ограничения размера и количества файлов не заданы.
|
||||
возвращается как leaf-файл. По умолчанию действуют ограничения: 1000 entries,
|
||||
100 MiB суммарно, 50 MiB на entry и глубина 20.
|
||||
|
||||
## Legacy
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { build } from 'esbuild';
|
||||
|
||||
const shared = {
|
||||
entryPoints: ['upload/frontend/index.js'],
|
||||
bundle: true,
|
||||
minify: false,
|
||||
sourcemap: false,
|
||||
logLevel: 'info',
|
||||
};
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
format: 'esm',
|
||||
outfile: 'dist/file-picker.esm.js',
|
||||
});
|
||||
|
||||
await build({
|
||||
...shared,
|
||||
format: 'iife',
|
||||
globalName: 'FilePicker',
|
||||
outfile: 'dist/file-picker.iife.js',
|
||||
});
|
||||
Vendored
+964
@@ -0,0 +1,964 @@
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j = b[i]; j < b[i + 1]; ++j) {
|
||||
r[j] = j - b[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x = (x & 52428) >> 2 | (x & 13107) << 2;
|
||||
x = (x & 61680) >> 4 | (x & 3855) << 4;
|
||||
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
|
||||
}
|
||||
var x;
|
||||
var i;
|
||||
var hMap = (function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
|
||||
co[rev[v] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
});
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v.length)
|
||||
e = v.length;
|
||||
return new u8(v.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b = fleb[i];
|
||||
add = bits(dat, pos, (1 << b) - 1) + fl[i];
|
||||
pos += b;
|
||||
}
|
||||
var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
|
||||
if (!d)
|
||||
err(3);
|
||||
pos += d & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var b2 = function(d, b) {
|
||||
return d[b] | d[b + 1] << 8;
|
||||
};
|
||||
var b4 = function(d, b) {
|
||||
return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
|
||||
};
|
||||
var b8 = function(d, b) {
|
||||
return b4(d, b) + b4(d, b + 4) * 4294967296;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
var dutf8 = function(d) {
|
||||
for (var r = "", i = 0; ; ) {
|
||||
var c = d[i++];
|
||||
var eb = (c > 127) + (c > 223) + (c > 239);
|
||||
if (i + eb > d.length)
|
||||
return { s: r, r: slc(d, i - 1) };
|
||||
if (!eb)
|
||||
r += String.fromCharCode(c);
|
||||
else if (eb == 3) {
|
||||
c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
|
||||
} else if (eb & 1)
|
||||
r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
|
||||
else
|
||||
r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
|
||||
}
|
||||
};
|
||||
function strFromU8(dat, latin1) {
|
||||
if (latin1) {
|
||||
var r = "";
|
||||
for (var i = 0; i < dat.length; i += 16384)
|
||||
r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
|
||||
return r;
|
||||
} else if (td) {
|
||||
return td.decode(dat);
|
||||
} else {
|
||||
var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
|
||||
if (r.length)
|
||||
err(8);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
var slzh = function(d, b) {
|
||||
return b + 30 + b2(d, b + 26) + b2(d, b + 28);
|
||||
};
|
||||
var zh = function(d, b, z) {
|
||||
var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
|
||||
var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
|
||||
return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
|
||||
};
|
||||
var z64hs = function(d, b, l, z, sc, su, off) {
|
||||
var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
|
||||
var nf = nsc + nsu + noff;
|
||||
if (z && nf) {
|
||||
for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
|
||||
if (b2(d, b) == 1) {
|
||||
return [
|
||||
nsc ? b8(d, b + 4 + 8 * nsu) : sc,
|
||||
nsu ? b8(d, b + 4) : su,
|
||||
noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
|
||||
1
|
||||
];
|
||||
}
|
||||
}
|
||||
if (z < 2)
|
||||
err(13);
|
||||
}
|
||||
return [sc, su, off, 0];
|
||||
};
|
||||
function unzipSync(data, opts) {
|
||||
var files = {};
|
||||
var e = data.length - 22;
|
||||
for (; b4(data, e) != 101010256; --e) {
|
||||
if (!e || data.length - e > 65558)
|
||||
err(13);
|
||||
}
|
||||
;
|
||||
var c = b2(data, e + 8);
|
||||
if (!c)
|
||||
return {};
|
||||
var o = b4(data, e + 16);
|
||||
var z = b4(data, e - 20) == 117853008;
|
||||
if (z) {
|
||||
var ze = b4(data, e - 12);
|
||||
z = b4(data, ze) == 101075792;
|
||||
if (z) {
|
||||
c = b4(data, ze + 32);
|
||||
o = b4(data, ze + 48);
|
||||
}
|
||||
}
|
||||
var fltr = opts && opts.filter;
|
||||
for (var i = 0; i < c; ++i) {
|
||||
var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
|
||||
o = no;
|
||||
if (!fltr || fltr({
|
||||
name: fn,
|
||||
size: sc,
|
||||
originalSize: su,
|
||||
compression: c_2
|
||||
})) {
|
||||
if (!c_2)
|
||||
files[fn] = slc(data, b, b + sc);
|
||||
else if (c_2 == 8)
|
||||
files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
|
||||
else
|
||||
err(14, "unknown compression type " + c_2);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// upload/frontend/table/rebase_tree.js
|
||||
function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
|
||||
// upload/frontend/zip/list_zip_files.js
|
||||
var DEFAULT_LIMITS = {
|
||||
maxEntries: 1e3,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20
|
||||
};
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name);
|
||||
}
|
||||
function safeEntryParts(entryName) {
|
||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||
const parts = entryName.split("/");
|
||||
if (parts.some((part) => !part || part === "." || part === "..")) return null;
|
||||
return parts;
|
||||
}
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||
}
|
||||
function addPath(root, parts, fileNode) {
|
||||
let current = root;
|
||||
parts.forEach((part, index) => {
|
||||
const last = index === parts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? fileNode : node("folder", part, `${current.path}/${part}`);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
}
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP");
|
||||
const entries = unzipSync(data, {
|
||||
filter: (entry) => {
|
||||
budget.entries += 1;
|
||||
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const root = node("zip", zipName, zipName);
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
if (entryName.endsWith("/")) continue;
|
||||
const parts = safeEntryParts(entryName);
|
||||
if (!parts) continue;
|
||||
const normalizedName = parts.join("/");
|
||||
if (normalizedName.toLowerCase().endsWith(".zip")) {
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
|
||||
if (nested) {
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = parts.at(-1);
|
||||
addPath(root, parts, nested);
|
||||
}
|
||||
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
||||
const path = `${zipName}/${normalizedName}`;
|
||||
const file = makeFile(entryData, path);
|
||||
addPath(root, parts, node("file", parts.at(-1), path, [], file));
|
||||
}
|
||||
}
|
||||
return root.children.length ? root : null;
|
||||
}
|
||||
async function listZipFiles(file, allowedExt, customLimits = {}) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const limits = { ...DEFAULT_LIMITS, ...customLimits };
|
||||
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
|
||||
}
|
||||
|
||||
// upload/frontend/table/add_file_with_dedup.js
|
||||
function addFileWithDedup(state, fileNode) {
|
||||
const accept = (node2) => {
|
||||
if (node2.kind === "file") {
|
||||
const key = `${node2.path}\0${node2.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node2;
|
||||
}
|
||||
node2.children = node2.children.map(accept).filter(Boolean);
|
||||
return node2.children.length ? node2 : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// upload/frontend/table/esc.js
|
||||
function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character]);
|
||||
}
|
||||
|
||||
// upload/frontend/table/fs.js
|
||||
function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// upload/frontend/table/render.js
|
||||
var DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${fs(bytes)}`
|
||||
},
|
||||
layout: { columns: ["path", "size", "status"] }
|
||||
};
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...cfg.labels || {},
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...cfg.labels?.columns || {}
|
||||
}
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...cfg.layout || {} }
|
||||
};
|
||||
}
|
||||
function renderNode(node2, depth, cfg) {
|
||||
const isGroup = node2.kind !== "file";
|
||||
const padding = depth * 4;
|
||||
const name = esc(node2.name);
|
||||
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
|
||||
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
||||
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
||||
};
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node2.expanded) return row;
|
||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||
}
|
||||
function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length ? normalized.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length ? state.nodes.map((node2) => renderNode(node2, 0, normalized)).join("") : `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
const visit = (node2) => {
|
||||
if (node2.kind === "file") files.push(node2);
|
||||
else node2.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
function findNode(nodes, id) {
|
||||
for (const node2 of nodes) {
|
||||
if (node2.id === id) return { node: node2, nodes };
|
||||
const found = findNode(node2.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node2) => {
|
||||
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
|
||||
else flattenFiles(node2.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_files_change.js
|
||||
async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "file",
|
||||
name: file.name,
|
||||
path: file.name,
|
||||
file,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_folder_change.js
|
||||
function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
const roots = /* @__PURE__ */ new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
const parts = (file.webkitRelativePath || file.name).split("/");
|
||||
const relativePath = parts.slice(1).join("/") || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
roots.set(rootName, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "folder",
|
||||
name: rootName,
|
||||
path: rootName,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
const addToFolder = (node2) => {
|
||||
let current = root;
|
||||
const nodeParts = node2.path.split("/").slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node2 : {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "folder",
|
||||
name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join("/")}`,
|
||||
children: [],
|
||||
expanded: true
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith(".zip")) {
|
||||
try {
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addToFolder({
|
||||
id: crypto.randomUUID(),
|
||||
kind: "file",
|
||||
name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`,
|
||||
file,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
}
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
elements.folderInputEl.value = "";
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/index.js
|
||||
var DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B",
|
||||
pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
||||
clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B`
|
||||
},
|
||||
layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] },
|
||||
limits: {
|
||||
maxEntries: 1e3,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20
|
||||
}
|
||||
};
|
||||
function resolveMount(mount) {
|
||||
const element = typeof mount === "string" ? document.querySelector(mount) : mount;
|
||||
if (!element) throw new Error("File picker mount element \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D");
|
||||
return element;
|
||||
}
|
||||
function normalizeConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
||||
labels: {
|
||||
...DEFAULTS.labels,
|
||||
...config.labels || {},
|
||||
columns: { ...DEFAULTS.labels.columns, ...config.labels?.columns || {} }
|
||||
},
|
||||
layout: { ...DEFAULTS.layout, ...config.layout || {} },
|
||||
limits: { ...DEFAULTS.limits, ...config.limits || {} }
|
||||
};
|
||||
}
|
||||
function injectStyles() {
|
||||
if (document.querySelector("style[data-file-picker]")) return;
|
||||
const style = document.createElement("style");
|
||||
style.dataset.filePicker = "";
|
||||
style.textContent = `
|
||||
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
||||
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
||||
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
||||
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
||||
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
||||
.file-picker .fp-count { min-height: 22px; }
|
||||
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
||||
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
||||
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
||||
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
function buildMarkup(root, cfg) {
|
||||
const controls = cfg.layout.controls || [];
|
||||
const control = (name, id, label, className = "") => controls.includes(name) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
${control("files", "fp-files-btn", cfg.labels.pickFiles)}
|
||||
${control("folder", "fp-folder-btn", cfg.labels.pickFolder, "secondary")}
|
||||
${control("clear", "fp-clear-btn", cfg.labels.clear, "quiet")}
|
||||
</div>
|
||||
<p class="fp-status" role="status"></p>
|
||||
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
||||
<p class="fp-count"></p>
|
||||
</div>`;
|
||||
}
|
||||
function notify(cfg, state) {
|
||||
if (typeof cfg.onChange === "function") cfg.onChange([...flattenFiles(state.nodes)]);
|
||||
}
|
||||
function removeMetadata(state, node2) {
|
||||
if (node2.kind === "file") {
|
||||
state.fileKeys.delete(`${node2.path}\0${node2.file.size}`);
|
||||
return;
|
||||
}
|
||||
node2.children.forEach((child) => removeMetadata(state, child));
|
||||
}
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return false;
|
||||
removeMetadata(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
return true;
|
||||
}
|
||||
function initFilePicker(config) {
|
||||
const cfg = normalizeConfig(config);
|
||||
const mount = resolveMount(cfg.mount);
|
||||
injectStyles();
|
||||
buildMarkup(mount, cfg);
|
||||
const root = mount.firstElementChild;
|
||||
const elements = {
|
||||
fileInputEl: root.querySelector(".fp-file-input"),
|
||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||
countEl: root.querySelector(".fp-count")
|
||||
};
|
||||
const state = { nodes: [], fileKeys: /* @__PURE__ */ new Set(), busy: false };
|
||||
const listeners = [];
|
||||
const listen = (target, event, handler) => {
|
||||
target.addEventListener(event, handler);
|
||||
listeners.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
const redraw = () => render(state, elements, cfg);
|
||||
const filesChange = onFilesChange(state, cfg, elements);
|
||||
const folderChange = onFolderChange(state, cfg, elements);
|
||||
listen(elements.fileInputEl, "change", async () => {
|
||||
await filesChange();
|
||||
notify(cfg, state);
|
||||
});
|
||||
listen(elements.folderInputEl, "change", async () => {
|
||||
await folderChange();
|
||||
notify(cfg, state);
|
||||
});
|
||||
listen(elements.tableBodyEl, "click", (event) => {
|
||||
const toggle = event.target.closest("[data-toggle]");
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest("[data-remove]");
|
||||
if (remove) {
|
||||
if (removeNode(state, remove.dataset.remove)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (root.querySelector("#fp-files-btn")) listen(root.querySelector("#fp-files-btn"), "click", () => elements.fileInputEl.click());
|
||||
if (root.querySelector("#fp-folder-btn")) listen(root.querySelector("#fp-folder-btn"), "click", () => elements.folderInputEl.click());
|
||||
if (root.querySelector("#fp-clear-btn")) listen(root.querySelector("#fp-clear-btn"), "click", () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
redraw();
|
||||
return {
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
addFiles: async (files) => {
|
||||
await addFiles(state, cfg, files, elements);
|
||||
notify(cfg, state);
|
||||
},
|
||||
getFiles: () => [...flattenFiles(state.nodes)],
|
||||
remove: (id) => {
|
||||
if (removeNode(state, id)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
render: redraw,
|
||||
destroy: () => {
|
||||
listeners.forEach((removeListener) => removeListener());
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
mount.replaceChildren();
|
||||
}
|
||||
};
|
||||
}
|
||||
export {
|
||||
DEFAULTS,
|
||||
initFilePicker
|
||||
};
|
||||
Vendored
+988
@@ -0,0 +1,988 @@
|
||||
var FilePicker = (() => {
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
||||
|
||||
// upload/frontend/index.js
|
||||
var index_exports = {};
|
||||
__export(index_exports, {
|
||||
DEFAULTS: () => DEFAULTS,
|
||||
initFilePicker: () => initFilePicker
|
||||
});
|
||||
|
||||
// node_modules/fflate/esm/browser.js
|
||||
var u8 = Uint8Array;
|
||||
var u16 = Uint16Array;
|
||||
var i32 = Int32Array;
|
||||
var fleb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
/* unused */
|
||||
0,
|
||||
0,
|
||||
/* impossible */
|
||||
0
|
||||
]);
|
||||
var fdeb = new u8([
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
4,
|
||||
4,
|
||||
5,
|
||||
5,
|
||||
6,
|
||||
6,
|
||||
7,
|
||||
7,
|
||||
8,
|
||||
8,
|
||||
9,
|
||||
9,
|
||||
10,
|
||||
10,
|
||||
11,
|
||||
11,
|
||||
12,
|
||||
12,
|
||||
13,
|
||||
13,
|
||||
/* unused */
|
||||
0,
|
||||
0
|
||||
]);
|
||||
var clim = new u8([16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15]);
|
||||
var freb = function(eb, start) {
|
||||
var b = new u16(31);
|
||||
for (var i = 0; i < 31; ++i) {
|
||||
b[i] = start += 1 << eb[i - 1];
|
||||
}
|
||||
var r = new i32(b[30]);
|
||||
for (var i = 1; i < 30; ++i) {
|
||||
for (var j = b[i]; j < b[i + 1]; ++j) {
|
||||
r[j] = j - b[i] << 5 | i;
|
||||
}
|
||||
}
|
||||
return { b, r };
|
||||
};
|
||||
var _a = freb(fleb, 2);
|
||||
var fl = _a.b;
|
||||
var revfl = _a.r;
|
||||
fl[28] = 258, revfl[258] = 28;
|
||||
var _b = freb(fdeb, 0);
|
||||
var fd = _b.b;
|
||||
var revfd = _b.r;
|
||||
var rev = new u16(32768);
|
||||
for (i = 0; i < 32768; ++i) {
|
||||
x = (i & 43690) >> 1 | (i & 21845) << 1;
|
||||
x = (x & 52428) >> 2 | (x & 13107) << 2;
|
||||
x = (x & 61680) >> 4 | (x & 3855) << 4;
|
||||
rev[i] = ((x & 65280) >> 8 | (x & 255) << 8) >> 1;
|
||||
}
|
||||
var x;
|
||||
var i;
|
||||
var hMap = (function(cd, mb, r) {
|
||||
var s = cd.length;
|
||||
var i = 0;
|
||||
var l = new u16(mb);
|
||||
for (; i < s; ++i) {
|
||||
if (cd[i])
|
||||
++l[cd[i] - 1];
|
||||
}
|
||||
var le = new u16(mb);
|
||||
for (i = 1; i < mb; ++i) {
|
||||
le[i] = le[i - 1] + l[i - 1] << 1;
|
||||
}
|
||||
var co;
|
||||
if (r) {
|
||||
co = new u16(1 << mb);
|
||||
var rvb = 15 - mb;
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
var sv = i << 4 | cd[i];
|
||||
var r_1 = mb - cd[i];
|
||||
var v = le[cd[i] - 1]++ << r_1;
|
||||
for (var m = v | (1 << r_1) - 1; v <= m; ++v) {
|
||||
co[rev[v] >> rvb] = sv;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
co = new u16(s);
|
||||
for (i = 0; i < s; ++i) {
|
||||
if (cd[i]) {
|
||||
co[i] = rev[le[cd[i] - 1]++] >> 15 - cd[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return co;
|
||||
});
|
||||
var flt = new u8(288);
|
||||
for (i = 0; i < 144; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
for (i = 144; i < 256; ++i)
|
||||
flt[i] = 9;
|
||||
var i;
|
||||
for (i = 256; i < 280; ++i)
|
||||
flt[i] = 7;
|
||||
var i;
|
||||
for (i = 280; i < 288; ++i)
|
||||
flt[i] = 8;
|
||||
var i;
|
||||
var fdt = new u8(32);
|
||||
for (i = 0; i < 32; ++i)
|
||||
fdt[i] = 5;
|
||||
var i;
|
||||
var flrm = /* @__PURE__ */ hMap(flt, 9, 1);
|
||||
var fdrm = /* @__PURE__ */ hMap(fdt, 5, 1);
|
||||
var max = function(a) {
|
||||
var m = a[0];
|
||||
for (var i = 1; i < a.length; ++i) {
|
||||
if (a[i] > m)
|
||||
m = a[i];
|
||||
}
|
||||
return m;
|
||||
};
|
||||
var bits = function(d, p, m) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8) >> (p & 7) & m;
|
||||
};
|
||||
var bits16 = function(d, p) {
|
||||
var o = p / 8 | 0;
|
||||
return (d[o] | d[o + 1] << 8 | d[o + 2] << 16) >> (p & 7);
|
||||
};
|
||||
var shft = function(p) {
|
||||
return (p + 7) / 8 | 0;
|
||||
};
|
||||
var slc = function(v, s, e) {
|
||||
if (s == null || s < 0)
|
||||
s = 0;
|
||||
if (e == null || e > v.length)
|
||||
e = v.length;
|
||||
return new u8(v.subarray(s, e));
|
||||
};
|
||||
var ec = [
|
||||
"unexpected EOF",
|
||||
"invalid block type",
|
||||
"invalid length/literal",
|
||||
"invalid distance",
|
||||
"stream finished",
|
||||
"no stream handler",
|
||||
,
|
||||
// determined by compression function
|
||||
"no callback",
|
||||
"invalid UTF-8 data",
|
||||
"extra field too long",
|
||||
"date not in range 1980-2099",
|
||||
"filename too long",
|
||||
"stream finishing",
|
||||
"invalid zip data"
|
||||
// determined by unknown compression method
|
||||
];
|
||||
var err = function(ind, msg, nt) {
|
||||
var e = new Error(msg || ec[ind]);
|
||||
e.code = ind;
|
||||
if (Error.captureStackTrace)
|
||||
Error.captureStackTrace(e, err);
|
||||
if (!nt)
|
||||
throw e;
|
||||
return e;
|
||||
};
|
||||
var inflt = function(dat, st, buf, dict) {
|
||||
var sl = dat.length, dl = dict ? dict.length : 0;
|
||||
if (!sl || st.f && !st.l)
|
||||
return buf || new u8(0);
|
||||
var noBuf = !buf;
|
||||
var resize = noBuf || st.i != 2;
|
||||
var noSt = st.i;
|
||||
if (noBuf)
|
||||
buf = new u8(sl * 3);
|
||||
var cbuf = function(l2) {
|
||||
var bl = buf.length;
|
||||
if (l2 > bl) {
|
||||
var nbuf = new u8(Math.max(bl * 2, l2));
|
||||
nbuf.set(buf);
|
||||
buf = nbuf;
|
||||
}
|
||||
};
|
||||
var final = st.f || 0, pos = st.p || 0, bt = st.b || 0, lm = st.l, dm = st.d, lbt = st.m, dbt = st.n;
|
||||
var tbts = sl * 8;
|
||||
do {
|
||||
if (!lm) {
|
||||
final = bits(dat, pos, 1);
|
||||
var type = bits(dat, pos + 1, 3);
|
||||
pos += 3;
|
||||
if (!type) {
|
||||
var s = shft(pos) + 4, l = dat[s - 4] | dat[s - 3] << 8, t = s + l;
|
||||
if (t > sl) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + l);
|
||||
buf.set(dat.subarray(s, t), bt);
|
||||
st.b = bt += l, st.p = pos = t * 8, st.f = final;
|
||||
continue;
|
||||
} else if (type == 1)
|
||||
lm = flrm, dm = fdrm, lbt = 9, dbt = 5;
|
||||
else if (type == 2) {
|
||||
var hLit = bits(dat, pos, 31) + 257, hcLen = bits(dat, pos + 10, 15) + 4;
|
||||
var tl = hLit + bits(dat, pos + 5, 31) + 1;
|
||||
pos += 14;
|
||||
var ldt = new u8(tl);
|
||||
var clt = new u8(19);
|
||||
for (var i = 0; i < hcLen; ++i) {
|
||||
clt[clim[i]] = bits(dat, pos + i * 3, 7);
|
||||
}
|
||||
pos += hcLen * 3;
|
||||
var clb = max(clt), clbmsk = (1 << clb) - 1;
|
||||
var clm = hMap(clt, clb, 1);
|
||||
for (var i = 0; i < tl; ) {
|
||||
var r = clm[bits(dat, pos, clbmsk)];
|
||||
pos += r & 15;
|
||||
var s = r >> 4;
|
||||
if (s < 16) {
|
||||
ldt[i++] = s;
|
||||
} else {
|
||||
var c = 0, n = 0;
|
||||
if (s == 16)
|
||||
n = 3 + bits(dat, pos, 3), pos += 2, c = ldt[i - 1];
|
||||
else if (s == 17)
|
||||
n = 3 + bits(dat, pos, 7), pos += 3;
|
||||
else if (s == 18)
|
||||
n = 11 + bits(dat, pos, 127), pos += 7;
|
||||
while (n--)
|
||||
ldt[i++] = c;
|
||||
}
|
||||
}
|
||||
var lt = ldt.subarray(0, hLit), dt = ldt.subarray(hLit);
|
||||
lbt = max(lt);
|
||||
dbt = max(dt);
|
||||
lm = hMap(lt, lbt, 1);
|
||||
dm = hMap(dt, dbt, 1);
|
||||
} else
|
||||
err(1);
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var lms = (1 << lbt) - 1, dms = (1 << dbt) - 1;
|
||||
var lpos = pos;
|
||||
for (; ; lpos = pos) {
|
||||
var c = lm[bits16(dat, pos) & lms], sym = c >> 4;
|
||||
pos += c & 15;
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (!c)
|
||||
err(2);
|
||||
if (sym < 256)
|
||||
buf[bt++] = sym;
|
||||
else if (sym == 256) {
|
||||
lpos = pos, lm = null;
|
||||
break;
|
||||
} else {
|
||||
var add = sym - 254;
|
||||
if (sym > 264) {
|
||||
var i = sym - 257, b = fleb[i];
|
||||
add = bits(dat, pos, (1 << b) - 1) + fl[i];
|
||||
pos += b;
|
||||
}
|
||||
var d = dm[bits16(dat, pos) & dms], dsym = d >> 4;
|
||||
if (!d)
|
||||
err(3);
|
||||
pos += d & 15;
|
||||
var dt = fd[dsym];
|
||||
if (dsym > 3) {
|
||||
var b = fdeb[dsym];
|
||||
dt += bits16(dat, pos) & (1 << b) - 1, pos += b;
|
||||
}
|
||||
if (pos > tbts) {
|
||||
if (noSt)
|
||||
err(0);
|
||||
break;
|
||||
}
|
||||
if (resize)
|
||||
cbuf(bt + 131072);
|
||||
var end = bt + add;
|
||||
if (bt < dt) {
|
||||
var shift = dl - dt, dend = Math.min(dt, end);
|
||||
if (shift + bt < 0)
|
||||
err(3);
|
||||
for (; bt < dend; ++bt)
|
||||
buf[bt] = dict[shift + bt];
|
||||
}
|
||||
for (; bt < end; ++bt)
|
||||
buf[bt] = buf[bt - dt];
|
||||
}
|
||||
}
|
||||
st.l = lm, st.p = lpos, st.b = bt, st.f = final;
|
||||
if (lm)
|
||||
final = 1, st.m = lbt, st.d = dm, st.n = dbt;
|
||||
} while (!final);
|
||||
return bt != buf.length && noBuf ? slc(buf, 0, bt) : buf.subarray(0, bt);
|
||||
};
|
||||
var et = /* @__PURE__ */ new u8(0);
|
||||
var b2 = function(d, b) {
|
||||
return d[b] | d[b + 1] << 8;
|
||||
};
|
||||
var b4 = function(d, b) {
|
||||
return (d[b] | d[b + 1] << 8 | d[b + 2] << 16 | d[b + 3] << 24) >>> 0;
|
||||
};
|
||||
var b8 = function(d, b) {
|
||||
return b4(d, b) + b4(d, b + 4) * 4294967296;
|
||||
};
|
||||
function inflateSync(data, opts) {
|
||||
return inflt(data, { i: 2 }, opts && opts.out, opts && opts.dictionary);
|
||||
}
|
||||
var td = typeof TextDecoder != "undefined" && /* @__PURE__ */ new TextDecoder();
|
||||
var tds = 0;
|
||||
try {
|
||||
td.decode(et, { stream: true });
|
||||
tds = 1;
|
||||
} catch (e) {
|
||||
}
|
||||
var dutf8 = function(d) {
|
||||
for (var r = "", i = 0; ; ) {
|
||||
var c = d[i++];
|
||||
var eb = (c > 127) + (c > 223) + (c > 239);
|
||||
if (i + eb > d.length)
|
||||
return { s: r, r: slc(d, i - 1) };
|
||||
if (!eb)
|
||||
r += String.fromCharCode(c);
|
||||
else if (eb == 3) {
|
||||
c = ((c & 15) << 18 | (d[i++] & 63) << 12 | (d[i++] & 63) << 6 | d[i++] & 63) - 65536, r += String.fromCharCode(55296 | c >> 10, 56320 | c & 1023);
|
||||
} else if (eb & 1)
|
||||
r += String.fromCharCode((c & 31) << 6 | d[i++] & 63);
|
||||
else
|
||||
r += String.fromCharCode((c & 15) << 12 | (d[i++] & 63) << 6 | d[i++] & 63);
|
||||
}
|
||||
};
|
||||
function strFromU8(dat, latin1) {
|
||||
if (latin1) {
|
||||
var r = "";
|
||||
for (var i = 0; i < dat.length; i += 16384)
|
||||
r += String.fromCharCode.apply(null, dat.subarray(i, i + 16384));
|
||||
return r;
|
||||
} else if (td) {
|
||||
return td.decode(dat);
|
||||
} else {
|
||||
var _a2 = dutf8(dat), s = _a2.s, r = _a2.r;
|
||||
if (r.length)
|
||||
err(8);
|
||||
return s;
|
||||
}
|
||||
}
|
||||
var slzh = function(d, b) {
|
||||
return b + 30 + b2(d, b + 26) + b2(d, b + 28);
|
||||
};
|
||||
var zh = function(d, b, z) {
|
||||
var fnl = b2(d, b + 28), efl = b2(d, b + 30), fn = strFromU8(d.subarray(b + 46, b + 46 + fnl), !(b2(d, b + 8) & 2048)), es = b + 46 + fnl;
|
||||
var _a2 = z64hs(d, es, efl, z, b4(d, b + 20), b4(d, b + 24), b4(d, b + 42)), sc = _a2[0], su = _a2[1], off = _a2[2];
|
||||
return [b2(d, b + 10), sc, su, fn, es + efl + b2(d, b + 32), off];
|
||||
};
|
||||
var z64hs = function(d, b, l, z, sc, su, off) {
|
||||
var nsc = sc == 4294967295, nsu = su == 4294967295, noff = off == 4294967295, e = b + l;
|
||||
var nf = nsc + nsu + noff;
|
||||
if (z && nf) {
|
||||
for (; b + 4 < e; b += 4 + b2(d, b + 2)) {
|
||||
if (b2(d, b) == 1) {
|
||||
return [
|
||||
nsc ? b8(d, b + 4 + 8 * nsu) : sc,
|
||||
nsu ? b8(d, b + 4) : su,
|
||||
noff ? b8(d, b + 4 + 8 * (nsu + nsc)) : off,
|
||||
1
|
||||
];
|
||||
}
|
||||
}
|
||||
if (z < 2)
|
||||
err(13);
|
||||
}
|
||||
return [sc, su, off, 0];
|
||||
};
|
||||
function unzipSync(data, opts) {
|
||||
var files = {};
|
||||
var e = data.length - 22;
|
||||
for (; b4(data, e) != 101010256; --e) {
|
||||
if (!e || data.length - e > 65558)
|
||||
err(13);
|
||||
}
|
||||
;
|
||||
var c = b2(data, e + 8);
|
||||
if (!c)
|
||||
return {};
|
||||
var o = b4(data, e + 16);
|
||||
var z = b4(data, e - 20) == 117853008;
|
||||
if (z) {
|
||||
var ze = b4(data, e - 12);
|
||||
z = b4(data, ze) == 101075792;
|
||||
if (z) {
|
||||
c = b4(data, ze + 32);
|
||||
o = b4(data, ze + 48);
|
||||
}
|
||||
}
|
||||
var fltr = opts && opts.filter;
|
||||
for (var i = 0; i < c; ++i) {
|
||||
var _a2 = zh(data, o, z), c_2 = _a2[0], sc = _a2[1], su = _a2[2], fn = _a2[3], no = _a2[4], off = _a2[5], b = slzh(data, off);
|
||||
o = no;
|
||||
if (!fltr || fltr({
|
||||
name: fn,
|
||||
size: sc,
|
||||
originalSize: su,
|
||||
compression: c_2
|
||||
})) {
|
||||
if (!c_2)
|
||||
files[fn] = slc(data, b, b + sc);
|
||||
else if (c_2 == 8)
|
||||
files[fn] = inflateSync(data.subarray(b, b + sc), { out: new u8(su) });
|
||||
else
|
||||
err(14, "unknown compression type " + c_2);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// upload/frontend/table/rebase_tree.js
|
||||
function rebaseTree(root, prefix) {
|
||||
root.path = `${prefix}/${root.path}`;
|
||||
if (root.file) root.file = new File([root.file], root.path, { lastModified: root.file.lastModified });
|
||||
root.children.forEach((child) => rebaseTree(child, prefix));
|
||||
return root;
|
||||
}
|
||||
|
||||
// upload/frontend/zip/list_zip_files.js
|
||||
var DEFAULT_LIMITS = {
|
||||
maxEntries: 1e3,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20
|
||||
};
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
return allowedExt.some((extension) => lowerName.endsWith(extension.toLowerCase()));
|
||||
}
|
||||
function makeFile(data, name) {
|
||||
return new File([data], name);
|
||||
}
|
||||
function safeEntryParts(entryName) {
|
||||
if (!entryName || entryName.startsWith("/") || entryName.includes("\\")) return null;
|
||||
const parts = entryName.split("/");
|
||||
if (parts.some((part) => !part || part === "." || part === "..")) return null;
|
||||
return parts;
|
||||
}
|
||||
function node(kind, name, path, children = [], file = null) {
|
||||
return { id: crypto.randomUUID(), kind, name, path, children, file, expanded: true };
|
||||
}
|
||||
function addPath(root, parts, fileNode) {
|
||||
let current = root;
|
||||
parts.forEach((part, index) => {
|
||||
const last = index === parts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? fileNode : node("folder", part, `${current.path}/${part}`);
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
}
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
if (depth > limits.maxDepth) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u0433\u043B\u0443\u0431\u043E\u043A\u0430\u044F \u0432\u043B\u043E\u0436\u0435\u043D\u043D\u043E\u0441\u0442\u044C ZIP");
|
||||
const entries = unzipSync(data, {
|
||||
filter: (entry) => {
|
||||
budget.entries += 1;
|
||||
if (budget.entries > limits.maxEntries) throw new Error("\u0421\u043B\u0438\u0448\u043A\u043E\u043C \u043C\u043D\u043E\u0433\u043E ZIP entries");
|
||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||
throw new Error("\u041F\u0440\u0435\u0432\u044B\u0448\u0435\u043D \u0441\u0443\u043C\u043C\u0430\u0440\u043D\u044B\u0439 \u0440\u0430\u0437\u043C\u0435\u0440 \u0440\u0430\u0441\u043F\u0430\u043A\u043E\u0432\u0430\u043D\u043D\u044B\u0445 ZIP entries");
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const root = node("zip", zipName, zipName);
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
if (entryName.endsWith("/")) continue;
|
||||
const parts = safeEntryParts(entryName);
|
||||
if (!parts) continue;
|
||||
const normalizedName = parts.join("/");
|
||||
if (normalizedName.toLowerCase().endsWith(".zip")) {
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
|
||||
if (nested) {
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = parts.at(-1);
|
||||
addPath(root, parts, nested);
|
||||
}
|
||||
} else if (extensionAllowed(normalizedName, allowedExt)) {
|
||||
const path = `${zipName}/${normalizedName}`;
|
||||
const file = makeFile(entryData, path);
|
||||
addPath(root, parts, node("file", parts.at(-1), path, [], file));
|
||||
}
|
||||
}
|
||||
return root.children.length ? root : null;
|
||||
}
|
||||
async function listZipFiles(file, allowedExt, customLimits = {}) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
const limits = { ...DEFAULT_LIMITS, ...customLimits };
|
||||
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
|
||||
}
|
||||
|
||||
// upload/frontend/table/add_file_with_dedup.js
|
||||
function addFileWithDedup(state, fileNode) {
|
||||
const accept = (node2) => {
|
||||
if (node2.kind === "file") {
|
||||
const key = `${node2.path}\0${node2.file.size}`;
|
||||
if (state.fileKeys.has(key)) return null;
|
||||
state.fileKeys.add(key);
|
||||
return node2;
|
||||
}
|
||||
node2.children = node2.children.map(accept).filter(Boolean);
|
||||
return node2.children.length ? node2 : null;
|
||||
};
|
||||
const accepted = accept(fileNode);
|
||||
if (!accepted || accepted.kind !== "file" && !accepted.children.length) return false;
|
||||
state.nodes.push(accepted);
|
||||
return true;
|
||||
}
|
||||
|
||||
// upload/frontend/table/esc.js
|
||||
function esc(value) {
|
||||
return String(value).replace(/[&<>"']/g, (character) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'"
|
||||
})[character]);
|
||||
}
|
||||
|
||||
// upload/frontend/table/fs.js
|
||||
function fs(bytes) {
|
||||
return bytes < 1024 ? `${bytes} B` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// upload/frontend/table/render.js
|
||||
var DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${fs(bytes)}`
|
||||
},
|
||||
layout: { columns: ["path", "size", "status"] }
|
||||
};
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...cfg.labels || {},
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...cfg.labels?.columns || {}
|
||||
}
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...cfg.layout || {} }
|
||||
};
|
||||
}
|
||||
function renderNode(node2, depth, cfg) {
|
||||
const isGroup = node2.kind !== "file";
|
||||
const padding = depth * 4;
|
||||
const name = esc(node2.name);
|
||||
const action = isGroup ? `<button class="tree-name tree-group tree-${node2.kind}" style="padding-left:${padding}ch" data-toggle="${node2.id}" aria-expanded="${node2.expanded}"><span class="tree-chevron">${node2.expanded ? "\u25BC" : "\u25B6"}</span>${name}</button>` : `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node2.id}" aria-label="${esc(cfg.labels.remove)} ${name}">\xD7</button>`;
|
||||
const pathAttribute = node2.kind === "file" ? ` data-path="${esc(node2.path)}"` : "";
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node2.kind === "file" ? fs(node2.file.size) : "",
|
||||
status: node2.error ? esc(node2.error) : node2.kind === "file" ? esc(cfg.labels.statusReady) : ""
|
||||
};
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node2.kind}"${pathAttribute}>` + columns.map((column) => `<td>${cells[column] || ""}</td>`).join("") + `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node2.expanded) return row;
|
||||
return row + node2.children.map((child) => renderNode(child, depth + 1, cfg)).join("");
|
||||
}
|
||||
function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length ? normalized.layout.columns : Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length ? state.nodes.map((node2) => renderNode(node2, 0, normalized)).join("") : `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
const visit = (node2) => {
|
||||
if (node2.kind === "file") files.push(node2);
|
||||
else node2.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
const totalBytes = files.reduce((sum, node2) => sum + node2.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
function findNode(nodes, id) {
|
||||
for (const node2 of nodes) {
|
||||
if (node2.id === id) return { node: node2, nodes };
|
||||
const found = findNode(node2.children || [], id);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function flattenFiles(nodes, result = []) {
|
||||
nodes.forEach((node2) => {
|
||||
if (node2.kind === "file") result.push({ path: node2.path, name: node2.name, size: node2.file.size, file: node2.file });
|
||||
else flattenFiles(node2.children, result);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_files_change.js
|
||||
async function addFiles(state, cfg, files, elements) {
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.name.toLowerCase().endsWith(".zip")) {
|
||||
if (!cfg.allowedExt.some((extension) => file.name.toLowerCase().endsWith(extension.toLowerCase()))) {
|
||||
continue;
|
||||
}
|
||||
addFileWithDedup(state, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "file",
|
||||
name: file.name,
|
||||
path: file.name,
|
||||
file,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
function onFilesChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
await addFiles(state, cfg, elements.fileInputEl.files, elements);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/table/on_folder_change.js
|
||||
function onFolderChange(state, cfg, elements) {
|
||||
return async () => {
|
||||
if (state.busy) return;
|
||||
state.busy = true;
|
||||
try {
|
||||
const roots = /* @__PURE__ */ new Map();
|
||||
for (const file of Array.from(elements.folderInputEl.files)) {
|
||||
const parts = (file.webkitRelativePath || file.name).split("/");
|
||||
const relativePath = parts.slice(1).join("/") || file.name;
|
||||
const lowerPath = relativePath.toLowerCase();
|
||||
const rootName = parts[0] || file.name;
|
||||
if (!roots.has(rootName)) {
|
||||
roots.set(rootName, {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "folder",
|
||||
name: rootName,
|
||||
path: rootName,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
const root = roots.get(rootName);
|
||||
const addToFolder = (node2) => {
|
||||
let current = root;
|
||||
const nodeParts = node2.path.split("/").slice(1);
|
||||
nodeParts.forEach((part, index) => {
|
||||
const last = index === nodeParts.length - 1;
|
||||
let child = current.children.find((item) => item.name === part);
|
||||
if (!child) {
|
||||
child = last ? node2 : {
|
||||
id: crypto.randomUUID(),
|
||||
kind: "folder",
|
||||
name: part,
|
||||
path: `${rootName}/${nodeParts.slice(0, index + 1).join("/")}`,
|
||||
children: [],
|
||||
expanded: true
|
||||
};
|
||||
current.children.push(child);
|
||||
}
|
||||
current = child;
|
||||
});
|
||||
};
|
||||
if (lowerPath.endsWith(".zip")) {
|
||||
try {
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof cfg.onError === "function") cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
addToFolder({
|
||||
id: crypto.randomUUID(),
|
||||
kind: "file",
|
||||
name: parts.at(-1),
|
||||
path: `${rootName}/${relativePath}`,
|
||||
file,
|
||||
children: [],
|
||||
expanded: true
|
||||
});
|
||||
}
|
||||
}
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
elements.folderInputEl.value = "";
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// upload/frontend/index.js
|
||||
var DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u0444\u0430\u0439\u043B\u044B",
|
||||
pickFolder: "\u0412\u044B\u0431\u0440\u0430\u0442\u044C \u043F\u0430\u043F\u043A\u0443",
|
||||
clear: "\u041E\u0447\u0438\u0441\u0442\u0438\u0442\u044C",
|
||||
empty: "\u041D\u0435\u0442 \u0432\u044B\u0431\u0440\u0430\u043D\u043D\u044B\u0445 \u0444\u0430\u0439\u043B\u043E\u0432",
|
||||
statusReady: "\u0433\u043E\u0442\u043E\u0432",
|
||||
remove: "\u0423\u0434\u0430\u043B\u0438\u0442\u044C",
|
||||
columns: { path: "\u041F\u0443\u0442\u044C", size: "\u0420\u0430\u0437\u043C\u0435\u0440", status: "\u0421\u0442\u0430\u0442\u0443\u0441" },
|
||||
count: (count, bytes) => `${count} \u0444\u0430\u0439\u043B\u043E\u0432 \xB7 ${bytes} B`
|
||||
},
|
||||
layout: { columns: ["path", "size", "status"], controls: ["files", "folder", "clear"] },
|
||||
limits: {
|
||||
maxEntries: 1e3,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20
|
||||
}
|
||||
};
|
||||
function resolveMount(mount) {
|
||||
const element = typeof mount === "string" ? document.querySelector(mount) : mount;
|
||||
if (!element) throw new Error("File picker mount element \u043D\u0435 \u043D\u0430\u0439\u0434\u0435\u043D");
|
||||
return element;
|
||||
}
|
||||
function normalizeConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
||||
labels: {
|
||||
...DEFAULTS.labels,
|
||||
...config.labels || {},
|
||||
columns: { ...DEFAULTS.labels.columns, ...config.labels?.columns || {} }
|
||||
},
|
||||
layout: { ...DEFAULTS.layout, ...config.layout || {} },
|
||||
limits: { ...DEFAULTS.limits, ...config.limits || {} }
|
||||
};
|
||||
}
|
||||
function injectStyles() {
|
||||
if (document.querySelector("style[data-file-picker]")) return;
|
||||
const style = document.createElement("style");
|
||||
style.dataset.filePicker = "";
|
||||
style.textContent = `
|
||||
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
||||
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
||||
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
||||
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
||||
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
||||
.file-picker .fp-count { min-height: 22px; }
|
||||
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
||||
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
||||
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
||||
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
function buildMarkup(root, cfg) {
|
||||
const controls = cfg.layout.controls || [];
|
||||
const control = (name, id, label, className = "") => controls.includes(name) ? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : "";
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join("");
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ""}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, ".zip"].join(","))}" webkitdirectory directory multiple hidden>
|
||||
${control("files", "fp-files-btn", cfg.labels.pickFiles)}
|
||||
${control("folder", "fp-folder-btn", cfg.labels.pickFolder, "secondary")}
|
||||
${control("clear", "fp-clear-btn", cfg.labels.clear, "quiet")}
|
||||
</div>
|
||||
<p class="fp-status" role="status"></p>
|
||||
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
||||
<p class="fp-count"></p>
|
||||
</div>`;
|
||||
}
|
||||
function notify(cfg, state) {
|
||||
if (typeof cfg.onChange === "function") cfg.onChange([...flattenFiles(state.nodes)]);
|
||||
}
|
||||
function removeMetadata(state, node2) {
|
||||
if (node2.kind === "file") {
|
||||
state.fileKeys.delete(`${node2.path}\0${node2.file.size}`);
|
||||
return;
|
||||
}
|
||||
node2.children.forEach((child) => removeMetadata(state, child));
|
||||
}
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return false;
|
||||
removeMetadata(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
return true;
|
||||
}
|
||||
function initFilePicker(config) {
|
||||
const cfg = normalizeConfig(config);
|
||||
const mount = resolveMount(cfg.mount);
|
||||
injectStyles();
|
||||
buildMarkup(mount, cfg);
|
||||
const root = mount.firstElementChild;
|
||||
const elements = {
|
||||
fileInputEl: root.querySelector(".fp-file-input"),
|
||||
folderInputEl: root.querySelector(".fp-folder-input"),
|
||||
tableBodyEl: root.querySelector(".fp-table-body"),
|
||||
countEl: root.querySelector(".fp-count")
|
||||
};
|
||||
const state = { nodes: [], fileKeys: /* @__PURE__ */ new Set(), busy: false };
|
||||
const listeners = [];
|
||||
const listen = (target, event, handler) => {
|
||||
target.addEventListener(event, handler);
|
||||
listeners.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
const redraw = () => render(state, elements, cfg);
|
||||
const filesChange = onFilesChange(state, cfg, elements);
|
||||
const folderChange = onFolderChange(state, cfg, elements);
|
||||
listen(elements.fileInputEl, "change", async () => {
|
||||
await filesChange();
|
||||
notify(cfg, state);
|
||||
});
|
||||
listen(elements.folderInputEl, "change", async () => {
|
||||
await folderChange();
|
||||
notify(cfg, state);
|
||||
});
|
||||
listen(elements.tableBodyEl, "click", (event) => {
|
||||
const toggle = event.target.closest("[data-toggle]");
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest("[data-remove]");
|
||||
if (remove) {
|
||||
if (removeNode(state, remove.dataset.remove)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (root.querySelector("#fp-files-btn")) listen(root.querySelector("#fp-files-btn"), "click", () => elements.fileInputEl.click());
|
||||
if (root.querySelector("#fp-folder-btn")) listen(root.querySelector("#fp-folder-btn"), "click", () => elements.folderInputEl.click());
|
||||
if (root.querySelector("#fp-clear-btn")) listen(root.querySelector("#fp-clear-btn"), "click", () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
redraw();
|
||||
return {
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
addFiles: async (files) => {
|
||||
await addFiles(state, cfg, files, elements);
|
||||
notify(cfg, state);
|
||||
},
|
||||
getFiles: () => [...flattenFiles(state.nodes)],
|
||||
remove: (id) => {
|
||||
if (removeNode(state, id)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
render: redraw,
|
||||
destroy: () => {
|
||||
listeners.forEach((removeListener) => removeListener());
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
mount.replaceChildren();
|
||||
}
|
||||
};
|
||||
}
|
||||
return __toCommonJS(index_exports);
|
||||
})();
|
||||
+27
-12
@@ -8,7 +8,8 @@
|
||||
```text
|
||||
site/app.py
|
||||
-> site/templates/index.html
|
||||
-> upload/frontend/table/init_upload_table.js
|
||||
-> dist/file-picker.iife.js
|
||||
-> upload/frontend/index.js
|
||||
-> table/on_files_change.js
|
||||
-> table/on_folder_change.js
|
||||
-> table/add_file_with_dedup.js
|
||||
@@ -33,7 +34,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
| `children` | `Array` | Дочерние узлы группы. |
|
||||
| `expanded` | `boolean` | Видимость дочерних строк в таблице. |
|
||||
|
||||
Внутреннее состояние `initUploadTable`:
|
||||
Внутреннее состояние picker-а:
|
||||
|
||||
| Поле | Назначение |
|
||||
|---|---|
|
||||
@@ -82,7 +83,8 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
| `addFiles(state, cfg, files, elements)` | state, `allowedExt`, FileList/Array, DOM | `Promise<void>` | Фильтрует обычные файлы, раскрывает ZIP, вызывает render. |
|
||||
| `onFilesChange(state, cfg, elements)` | state, config, DOM | async callback | Блокирует повторные события через `busy` и вызывает `addFiles`. |
|
||||
|
||||
Ошибка отдельного ZIP пропускается. `finally` обязательно освобождает `busy`.
|
||||
Ошибка отдельного ZIP пропускается и передаётся в `cfg.onError(error, file)`, если
|
||||
callback задан. `finally` обязательно освобождает `busy`.
|
||||
|
||||
### `on_folder_change.js`
|
||||
|
||||
@@ -105,7 +107,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
`render` считает все leaf-файлы, включая свернутые группы. `renderNode` показывает
|
||||
дочерние строки только при `expanded === true`.
|
||||
|
||||
### `init_upload_table.js`
|
||||
### `init_upload_table.js` (legacy)
|
||||
|
||||
| Функция | Вход | Выход | Назначение |
|
||||
|---|---|---|---|
|
||||
@@ -113,7 +115,7 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
| `removeNode(state, id)` | state и id | `void` | Удаляет узел из родительского массива. Внутренняя функция. |
|
||||
| `initUploadTable(cfg)` | `allowedExt` и четыре DOM-элемента | API-объект | Создаёт state, события, render и публичные операции. |
|
||||
|
||||
Публичный API:
|
||||
Публичный API legacy-совместимости:
|
||||
|
||||
| Метод | Вход | Результат |
|
||||
|---|---|---|
|
||||
@@ -137,11 +139,24 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
| `node(kind, name, path, children, file)` | Метаданные узла | node | Создаёт узел. Внутренняя. |
|
||||
| `addPath(root, parts, fileNode)` | Дерево, сегменты, узел | `void` | Создаёт папки и вставляет узел. Внутренняя. |
|
||||
| `listEntries(data, zipName, allowedExt, depth)` | ZIP bytes и контекст | `Promise<node|null>` | Рекурсивно обрабатывает ZIP. Внутренняя. |
|
||||
| `listZipFiles(file, allowedExt)` | browser File и расширения | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
|
||||
| `listZipFiles(file, allowedExt, limits)` | browser File, расширения и limits | `Promise<node|null>` | Публичная точка входа ZIP-парсера. |
|
||||
|
||||
Безопасность: отклоняются абсолютные пути, backslash, пустые сегменты, `.` и
|
||||
`..`; глубина nested ZIP ограничена 20. ZIP без разрешённых leaf-файлов возвращает
|
||||
`null` и не создаёт пустую группу.
|
||||
`..`; применяются лимиты `maxEntries`, `maxTotalBytes`, `maxEntryBytes` и
|
||||
`maxDepth` с безопасными значениями по умолчанию. ZIP без разрешённых leaf-файлов
|
||||
возвращает `null` и не создаёт пустую группу.
|
||||
|
||||
## `upload/frontend/index.js`
|
||||
|
||||
`initFilePicker({ mount, allowedExt, labels, layout, limits, onChange, onError })`
|
||||
строит весь DOM внутри HTMLElement или CSS-селектора `mount`, подключает inputs,
|
||||
таблицу и обработчики. Возвращаемый API: `pickFiles`, `pickFolder`, `addFiles`,
|
||||
`getFiles`, `remove`, `clear`, `render`, `destroy`. `getFiles` и `onChange`
|
||||
возвращают копии массива leaf-файлов. `destroy` снимает слушатели и очищает mount.
|
||||
|
||||
IIFE-бандл экспортирует этот API как `FilePicker.initFilePicker`; ESM-бандл
|
||||
экспортирует функцию напрямую. `fflate` включён в оба bundle, поэтому глобальный
|
||||
vendor script не требуется.
|
||||
|
||||
## Flask и HTML
|
||||
|
||||
@@ -153,13 +168,13 @@ Flask отдаёт страницу и ES-модули. Все выбранны
|
||||
| `VERSION` | константа | Версия страницы |
|
||||
| `index()` / `/` | HTTP GET | HTML с version и config |
|
||||
| `health()` / `/health` | HTTP GET | `ok`, HTTP 200 |
|
||||
| `upload_frontend(filename)` | HTTP GET и относительный путь | ES/CSS-файл из `upload/frontend` |
|
||||
| `upload_frontend(filename)` | HTTP GET и относительный путь | Legacy-файл из `upload/frontend` |
|
||||
| `file_picker_dist(filename)` | HTTP GET и имя bundle | Bundle из `dist` |
|
||||
|
||||
### `site/templates/index.html`
|
||||
|
||||
Шаблон создаёт два скрытых input: обычный file input и `webkitdirectory`,
|
||||
передаёт четыре DOM-элемента в `initUploadTable`, подключает локальный `fflate`
|
||||
до ES-модуля и связывает три кнопки с `pickFiles`, `pickFolder` и `clear`.
|
||||
Шаблон создаёт mount, подключает IIFE bundle с query-параметром версии для
|
||||
сброса browser cache и вызывает `FilePicker.initFilePicker`.
|
||||
|
||||
## Legacy
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
# План: picker → универсальный встраиваемый компонент
|
||||
|
||||
Документ для разработчика (GPT Luna). Все факты верифицированы по текущему коду
|
||||
(`upload-platform` на коммите `aec8ad4`, ветка `master`). Кодить строго по этому плану,
|
||||
не вносить изменений за рамками перечисленных пунктов.
|
||||
|
||||
## Цель
|
||||
|
||||
Превратить слой выбора файлов в переиспользуемый компонент/АПИ, который встраивается
|
||||
в любое приложение (включая managed Flask `drhider`) одной строкой. На входе задаются:
|
||||
типы файлов, порядок/скрытие колонок, все надписи, тема. Контент файлов на сервер не уходит.
|
||||
|
||||
## Верифицированное текущее состояние
|
||||
|
||||
- Язык: vanilla JS, ES-модули. Flask — только статический сервер.
|
||||
- Структура `upload/frontend/`:
|
||||
- `table/init_upload_table.js` — `initUploadTable(cfg)` ТРЕБУЕТ готовые DOM-элементы
|
||||
`fileInputEl`, `folderInputEl`, `tableBodyEl`, `countEl`. Возвращает
|
||||
`{pickFiles, pickFolder, addFiles, getFiles, remove, render, clear}`.
|
||||
- `table/on_files_change.js` — `addFiles`, `onFilesChange` (фильтр расширений, ZIP).
|
||||
- `table/on_folder_change.js` — `onFolderChange` (webkitdirectory).
|
||||
- `table/add_file_with_dedup.js` — `addFileWithDedup` (дедуп `path + NUL + size`).
|
||||
- `table/render.js` — `renderNode`, `render`, `findNode`, `flattenFiles`.
|
||||
ЗАШИТЫ строки: `'готов'`, `'Удалить ${name}'`, `'Нет выбранных файлов'`,
|
||||
`'${n} файлов · ${bytes}'`.
|
||||
- `table/rebase_tree.js`, `table/esc.js` (HTML-экранирование), `table/fs.js` (байты).
|
||||
- `table/set_status.js` — LEGACY, не используется, НЕ трогать.
|
||||
- `zip/list_zip_files.js` — `listZipFiles`; на строке 65 вызывает ГЛОБАЛЬНУЮ
|
||||
`fflate.unzipSync(data)`. Содержит `safeEntryParts` (path traversal), лимит глубины 20.
|
||||
- `site/app.py` — маршруты `/`, `/health`, `/upload-frontend/<path:filename>`
|
||||
(`send_from_directory(ROOT/"upload"/"frontend", filename)`). VERSION `0.1.10`.
|
||||
- `site/templates/index.html` — demo: глобально грузит `vendor/fflate.min.js`,
|
||||
импортирует модуль по АБСОЛЮТНОМУ пути `/upload-frontend/table/init_upload_table.js`,
|
||||
содержит зашитые `<thead>` «Путь/Размер/Статус» и кнопки.
|
||||
- `site/static/vendor/fflate.min.js` — локальная глобальная `fflate`.
|
||||
- `node_modules/fflate` есть, но корневого `package.json` НЕТ.
|
||||
- `requirements.txt` — Flask>=3.0, gunicorn, requests.
|
||||
|
||||
## Жёсткие связи (что убираем)
|
||||
|
||||
1. Глобальная зависимость `fflate` (реальная — `list_zip_files.js:65`).
|
||||
2. Абсолютный путь импорта `/upload-frontend/...` (в `index.html` + маршрут в `app.py`).
|
||||
3. Зашитые надписи/колонки в `render.js` и `<thead>`.
|
||||
4. Принудительный DOM-контракт `initUploadTable`.
|
||||
5. Нет сборки/единой точки входа.
|
||||
|
||||
## Целевая архитектура
|
||||
|
||||
Единая точка входа `initFilePicker(config)`:
|
||||
|
||||
- сама строит DOM внутри `config.mount` (не требует готовых элементов);
|
||||
- переиспользует существующие модули (дедуп, ZIP, рендер, обход папок);
|
||||
- возвращает API с `destroy()`.
|
||||
|
||||
Один бандл (ESM + IIFE) с вшитым `fflate` и инжектом CSS. Без Shadow DOM, без системы тем,
|
||||
без npm-пайплайна (dist коммитится в git).
|
||||
|
||||
## Схема конфига `initFilePicker(config)`
|
||||
|
||||
```js
|
||||
initFilePicker({
|
||||
mount, // HTMLElement | CSS-селектор (обязательно)
|
||||
allowedExt: ['.pdf', '.txt'],// обязательно; сопоставление регистронезависимое
|
||||
labels: {
|
||||
pickFiles: 'Выбрать файлы',
|
||||
pickFolder: 'Выбрать папку',
|
||||
clear: 'Очистить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить', // подставляется в aria-label и title
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (n, bytes) => `${n} файлов · ${formatBytes(bytes)}`, // необязательно
|
||||
},
|
||||
layout: {
|
||||
columns: ['path', 'size', 'status'], // порядок + скрытие (пустой массив = все)
|
||||
controls: ['files', 'folder', 'clear'],
|
||||
theme: null, // строка-класс для кастомизации (не система тем)
|
||||
},
|
||||
limits: { // защита ZIP (обязательные значения по умолчанию)
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024, // суммарный распакованный размер
|
||||
maxEntryBytes: 50 * 1024 * 1024, // размер одного entry
|
||||
maxDepth: 20,
|
||||
},
|
||||
onChange(files) {}, // вызывается после каждого изменения дерева
|
||||
onError(err) {}, // необязательно
|
||||
});
|
||||
|
||||
// Возвращает:
|
||||
// { pickFiles, pickFolder, addFiles, getFiles, remove, clear, render, destroy }
|
||||
// getFiles() возвращает копию массива (не внутренний state).
|
||||
// destroy() снимает слушатели, чистит DOM и state.
|
||||
```
|
||||
|
||||
## Пошаговые изменения по файлам
|
||||
|
||||
### 1. `upload/frontend/zip/list_zip_files.js`
|
||||
- Заменить глобальный `fflate.unzipSync(data)` на импорт: `import { unzipSync } from 'fflate'`.
|
||||
- Убрать вызов глобальной `fflate`; использовать `unzipSync(data, { filter })`.
|
||||
`filter` получает entry-метаданные `{ name, size, originalSize, compression }`
|
||||
и возвращает `false` для пропуска ДО распаковки. Через `filter` реализовать:
|
||||
- счётчик обработанных entries > `limits.maxEntries` → выбросить ошибку/остановиться;
|
||||
- `originalSize > limits.maxEntryBytes` → пропустить entry;
|
||||
- накопительный распакованный размер > `limits.maxTotalBytes` → выбросить ошибку
|
||||
(защита от zip-бомбы).
|
||||
- `listZipFiles(file, allowedExt, limits)` — принять `limits`, прокинуть в `listEntries`.
|
||||
- Лимит `depth > limits.maxDepth` вместо зашитого `20`.
|
||||
- Сохранить `safeEntryParts` без изменений.
|
||||
|
||||
### 2. `upload/frontend/table/render.js`
|
||||
- `renderNode(node, depth, cfg)` и `render(state, elements, cfg)` принимают `cfg`
|
||||
(labels + layout). Заменить зашитые строки на `cfg.labels.*`:
|
||||
- `'готов'` → `cfg.labels.statusReady`;
|
||||
- `'Удалить ${name}'` → `${cfg.labels.remove} ${name}`;
|
||||
- `'Нет выбранных файлов'` → `cfg.labels.empty`;
|
||||
- счётчик → `cfg.labels.count(files.length, totalBytes)` или дефолт.
|
||||
- Колонки: рендерить `<td>` только для колонок из `layout.columns` в заданном порядке;
|
||||
при скрытой колонке не выводить её ячейку и соответствующий `<th>`.
|
||||
- `flattenFiles` оставить как есть (он уже возвращает новые объекты), но в
|
||||
`getFiles()` дополнительно отдавать `[...flattenFiles(state.nodes)]` (копия массива).
|
||||
|
||||
### 3. `upload/frontend/table/on_files_change.js` и `on_folder_change.js`
|
||||
- Прокинуть `limits` в `listZipFiles(file, cfg.allowedExt, cfg.limits)`.
|
||||
- Остальное (фильтр, busy, ZIP-catch) без изменений.
|
||||
|
||||
### 4. НОВЫЙ `upload/frontend/index.js` (единая точка входа)
|
||||
- Экспортировать `initFilePicker(config)`.
|
||||
- Внутри: построить разметку (кнопки, скрытые `<input type=file>`, таблица, счётчик)
|
||||
внутри `config.mount`, с подписями из `config.labels` и набором кнопок из
|
||||
`config.layout.controls`.
|
||||
- Создать state `{ nodes, fileKeys: new Set(), busy }`.
|
||||
- Переиспользовать существующие функции: навесить `onFilesChange`/`onFolderChange`
|
||||
на созданные inputs, делегирование кликов (toggle/remove) как в `init_upload_table.js`.
|
||||
- Вызывать `config.onChange` после каждой перерисовки с копией `getFiles()`.
|
||||
- Реализовать `destroy()`: снять все слушатели, очистить `mount.innerHTML`, обнулить state.
|
||||
- Оставить `initUploadTable` рабочим (обратная совместимость demo), но demo перевести на `initFilePicker`.
|
||||
- Инжектировать CSS: `import cssText from './style.css'` (через esbuild loader `text`),
|
||||
при первом вызове вставить `<style data-file-picker>` в `<head>` один раз.
|
||||
|
||||
### 5. НОВЫЙ `upload/frontend/style.css`
|
||||
- Перенести релевантные стили из `site/static/style.css`, ВСЕ селекторы обернуть
|
||||
под корневой класс `.file-picker` (префикс-изоляция, без Shadow DOM).
|
||||
- Классы: `.file-picker`, `.fp-toolbar`, `.fp-table`, `.fp-tree-*`, `.fp-remove-btn`,
|
||||
`.fp-status`, `.fp-count`. Поддержать `config.layout.theme` как доп. класс на контейнере.
|
||||
|
||||
### 6. Сборка
|
||||
- Добавить `package.json` (devDependency `esbuild`).
|
||||
- `build`-скрипт собирает из `upload/frontend/index.js`:
|
||||
- `dist/file-picker.esm.js` (format=esm, fflate inline);
|
||||
- `dist/file-picker.iife.js` (format=iife, глобал `FilePicker`, fflate inline);
|
||||
- CSS инлайнится через loader `text`.
|
||||
- `dist/` коммитится в git.
|
||||
|
||||
### 7. Demo `site/`
|
||||
- `site/templates/index.html`: убрать глобальный `<script fflate>` и абсолютный import;
|
||||
подключить собранный бандл (`dist/file-picker.iife.js` или ESM) и вызвать
|
||||
`initFilePicker({ mount, allowedExt: {{ config|tojson }}.allowedExt, ... })`.
|
||||
- `site/app.py`: убрать или оставить маршрут `/upload-frontend/...` (рекомендуется
|
||||
оставить для совместимости, но demo использует бандл); добавить раздачу `dist/`
|
||||
при необходимости. VERSION поднять (см. ниже).
|
||||
|
||||
### 8. Документация
|
||||
- Обновить `README.md`, `upload/README.md`, `docs/CODE-REFERENCE.md` под новый API
|
||||
`initFilePicker` и бандл. Пометить `initUploadTable`/`set_status.js` как legacy.
|
||||
|
||||
## Требования безопасности (обязательно сохранить)
|
||||
|
||||
- Экранирование всех имён/путей через `esc` перед `innerHTML` (уже есть — не сломать).
|
||||
- Path traversal в ZIP (`safeEntryParts`) — без изменений.
|
||||
- Новые лимиты ZIP из п.1 — обязательны (zip-бомба).
|
||||
- `getFiles()`/`onChange` отдают копию, а не внутренний state.
|
||||
- Не полагаться на абсолютные URL; пути бандла/ассетов задаются интегратором.
|
||||
|
||||
## Критерии приёмки
|
||||
|
||||
1. `initFilePicker({ mount, allowedExt, labels, layout, onChange })` встраивается
|
||||
в пустую страницу одной строкой и работает (файл/папка/ZIP, дедуп, удаление, очистка).
|
||||
2. Глобальной `fflate` больше нет (только внутри бандла).
|
||||
3. Нет ни одного абсолютного импорта `/upload-frontend/...`.
|
||||
4. Надписи и порядок/скрытие колонок меняются через `labels`/`layout` без правки кода.
|
||||
5. `destroy()` полностью чистит DOM и слушатели; повторный `initFilePicker` в тот же
|
||||
`mount` работает.
|
||||
6. ZIP-бомба/превышение лимитов не вешает браузер (срабатывают `limits`).
|
||||
7. `getFiles()` возвращает копию; мутация результата не ломает state.
|
||||
8. Demo в `site/` работает через бандл; `/health` → 200.
|
||||
|
||||
## Что НЕ делать
|
||||
|
||||
- Не вводить Shadow DOM, не делать систему тем/плагинов, не публиковать в npm/CDN.
|
||||
- Не трогать `set_status.js`, `site/static/vendor/fflate.min.js` (после перехода на
|
||||
бандл тег можно удалить из index.html, но файл vendor можно оставить).
|
||||
- Не менять формат возвращаемых leaf-объектов (`{ path, name, size, file }`).
|
||||
|
||||
## Порядок коммитов
|
||||
|
||||
Логические шаги (после каждого — `node --check`/сборка + `git diff --check`):
|
||||
1. ZIP-лимиты + импорт fflate (п.1, п.3).
|
||||
2. Параметризация render/labels/layout (п.2).
|
||||
3. Новая точка входа `initFilePicker` + CSS + destroy (п.4, п.5).
|
||||
4. Сборка esbuild (п.6).
|
||||
5. Demo на бандл (п.7).
|
||||
6. Документация (п.8) + bump VERSION в `site/app.py`.
|
||||
Generated
+480
@@ -0,0 +1,480 @@
|
||||
{
|
||||
"name": "upload-platform-file-picker",
|
||||
"version": "0.1.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "upload-platform-file-picker",
|
||||
"version": "0.1.10",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "upload-platform-file-picker",
|
||||
"private": true,
|
||||
"version": "0.1.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
+7
-1
@@ -10,7 +10,7 @@ with (ROOT / "config.json").open(encoding="utf-8") as config_file:
|
||||
CONFIG = json.load(config_file)
|
||||
|
||||
|
||||
VERSION = "0.1.10"
|
||||
VERSION = "0.1.11"
|
||||
|
||||
# Flask нужен здесь только как статический сервер HTML, CSS и ES-модулей.
|
||||
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||
@@ -34,5 +34,11 @@ def upload_frontend(filename):
|
||||
return send_from_directory(ROOT / "upload" / "frontend", filename)
|
||||
|
||||
|
||||
@app.get("/file-picker/<path:filename>")
|
||||
def file_picker_dist(filename):
|
||||
"""Отдаёт собранный универсальный picker-бандл из каталога dist."""
|
||||
return send_from_directory(ROOT / "dist", filename)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=False, host="0.0.0.0", port=5000)
|
||||
@@ -17,44 +17,15 @@
|
||||
</header>
|
||||
<!-- Скрытые input дают браузеру FileList; кнопки ниже программно вызывают click(). -->
|
||||
<section class="toolbar" aria-label="Выбор файлов">
|
||||
<input id="file-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" multiple hidden>
|
||||
<input id="folder-input" type="file" accept=".pdf,.doc,.docx,.txt,.md,.zip" webkitdirectory directory multiple hidden>
|
||||
<button id="files-btn" type="button">Выбрать файлы</button>
|
||||
<button id="folder-btn" type="button" class="secondary">Выбрать папку</button>
|
||||
<button id="clear-btn" type="button" class="quiet">Очистить</button>
|
||||
<div id="file-picker"></div>
|
||||
</section>
|
||||
<!-- Сюда могут выводиться сообщения интегратора; таблица использует отдельный render(). -->
|
||||
<p id="status" class="status" role="status"></p>
|
||||
<!-- tableBodyEl является обязательным DOM-контрактом initUploadTable. -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Путь</th><th>Размер</th><th>Статус</th><th></th></tr></thead>
|
||||
<tbody id="table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- countEl обновляется render() и включает leaf-файлы даже в свернутых группах. -->
|
||||
<p id="count" class="count"></p>
|
||||
</main>
|
||||
<!-- fflate должен быть загружен до ES-модулей, потому что ZIP-парсер использует global fflate. -->
|
||||
<script src="{{ url_for('static', filename='vendor/fflate.min.js') }}"></script>
|
||||
<script type="module">
|
||||
// Конфигурация приходит из Flask; DOM-ссылки передаются в переиспользуемый picker.
|
||||
import { initUploadTable } from "/upload-frontend/table/init_upload_table.js";
|
||||
|
||||
const cfg = {{ config|tojson }};
|
||||
const table = initUploadTable({
|
||||
...cfg,
|
||||
fileInputEl: document.querySelector('#file-input'),
|
||||
folderInputEl: document.querySelector('#folder-input'),
|
||||
tableBodyEl: document.querySelector('#table-body'),
|
||||
countEl: document.querySelector('#count'),
|
||||
<script src="{{ url_for('file_picker_dist', filename='file-picker.iife.js', v=version) }}"></script>
|
||||
<script>
|
||||
FilePicker.initFilePicker({
|
||||
mount: '#file-picker',
|
||||
allowedExt: {{ config.allowedExt|tojson }},
|
||||
});
|
||||
// status зарезервирован для интегратора; picker сейчас обновляет только таблицу и count.
|
||||
const status = document.querySelector('#status');
|
||||
// Кнопки не содержат собственной логики выбора: весь state остаётся внутри table API.
|
||||
document.querySelector('#files-btn').onclick = () => table.pickFiles();
|
||||
document.querySelector('#folder-btn').onclick = () => table.pickFolder();
|
||||
document.querySelector('#clear-btn').onclick = () => table.clear();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+9
-9
@@ -1,23 +1,25 @@
|
||||
# Upload module
|
||||
|
||||
Скопируйте каталог `upload/` в проект и подключите picker:
|
||||
Скопируйте `upload/frontend/` и подключите собранный picker:
|
||||
|
||||
- `initUploadTable(config)` из `frontend/table/init_upload_table.js`;
|
||||
- локальный `fflate` до загрузки picker-модуля.
|
||||
- IIFE: `FilePicker.initFilePicker(config)`;
|
||||
- ESM: `import { initFilePicker } from './file-picker.esm.js'`.
|
||||
|
||||
`config` должен содержать `allowedExt`, а также ссылки на DOM-элементы
|
||||
`fileInputEl`, `folderInputEl`, `tableBodyEl` и `countEl`.
|
||||
`config` должен содержать `mount` как DOM-элемент или CSS-селектор. Основные
|
||||
параметры: `allowedExt`, `labels`, `layout`, `limits` и `onChange`.
|
||||
|
||||
Публичный API таблицы:
|
||||
|
||||
- `pickFiles()` и `pickFolder()` — открыть системный выбор;
|
||||
- `getFiles()` — получить leaf-файлы с `path`, `name`, `size` и `file`;
|
||||
- `addFiles(files)` — добавить файлы программно;
|
||||
- `remove(id)` и `clear()` — удалить узел или очистить дерево.
|
||||
- `remove(id)` и `clear()` — удалить узел или очистить дерево;
|
||||
- `render()` и `destroy()` — перерисовать или удалить экземпляр.
|
||||
|
||||
Picker поддерживает разрешённые документы, папки и вложенные ZIP, хранит
|
||||
исходные browser `File` objects и выполняет дедупликацию по пути и размеру.
|
||||
Загрузка на VM, backend sessions и upload API не используются.
|
||||
Загрузка на VM, backend sessions и upload API не используются. ZIP обрабатывается
|
||||
локально через встроенный в bundle `fflate`; отдельный vendor script не требуется.
|
||||
|
||||
## Legacy
|
||||
|
||||
@@ -25,7 +27,5 @@ Picker поддерживает разрешённые документы, па
|
||||
предыдущей архитектуре и сохранены как legacy-остатки. Для текущей picker-only
|
||||
интеграции они не нужны.
|
||||
|
||||
Для ZIP перед ES-модулями загрузите локальный `fflate` из `site/static/vendor/`.
|
||||
|
||||
Подробная таблица функций, входов, выходов и побочных эффектов находится в
|
||||
[`../docs/CODE-REFERENCE.md`](../docs/CODE-REFERENCE.md).
|
||||
@@ -0,0 +1,189 @@
|
||||
import { addFiles, onFilesChange } from './table/on_files_change.js';
|
||||
import { onFolderChange } from './table/on_folder_change.js';
|
||||
import { findNode, flattenFiles, render } from './table/render.js';
|
||||
import { esc } from './table/esc.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
allowedExt: [],
|
||||
labels: {
|
||||
pickFiles: 'Выбрать файлы',
|
||||
pickFolder: 'Выбрать папку',
|
||||
clear: 'Очистить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${bytes} B`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'], controls: ['files', 'folder', 'clear'] },
|
||||
limits: {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
},
|
||||
};
|
||||
|
||||
function resolveMount(mount) {
|
||||
const element = typeof mount === 'string' ? document.querySelector(mount) : mount;
|
||||
if (!element) throw new Error('File picker mount element не найден');
|
||||
return element;
|
||||
}
|
||||
|
||||
function normalizeConfig(config) {
|
||||
return {
|
||||
...config,
|
||||
allowedExt: config.allowedExt || DEFAULTS.allowedExt,
|
||||
labels: {
|
||||
...DEFAULTS.labels,
|
||||
...(config.labels || {}),
|
||||
columns: { ...DEFAULTS.labels.columns, ...(config.labels?.columns || {}) },
|
||||
},
|
||||
layout: { ...DEFAULTS.layout, ...(config.layout || {}) },
|
||||
limits: { ...DEFAULTS.limits, ...(config.limits || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function injectStyles() {
|
||||
if (document.querySelector('style[data-file-picker]')) return;
|
||||
const style = document.createElement('style');
|
||||
style.dataset.filePicker = '';
|
||||
style.textContent = `
|
||||
.file-picker { color: #17211b; font-family: Georgia, 'Times New Roman', serif; }
|
||||
.file-picker .fp-toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
|
||||
.file-picker .fp-toolbar button { border: 0; border-radius: 4px; padding: 12px 17px; color: #f7faf4; background: #1f5a3b; font: 700 13px sans-serif; cursor: pointer; }
|
||||
.file-picker .fp-toolbar button.secondary { color: #1f5a3b; background: #cbdcc9; }
|
||||
.file-picker .fp-toolbar button.quiet { color: #5d6a61; background: transparent; }
|
||||
.file-picker .fp-table-wrap { overflow-x: auto; border-top: 1px solid #b9c7ba; }
|
||||
.file-picker .fp-table { width: 100%; border-collapse: collapse; font: 14px/1.4 sans-serif; }
|
||||
.file-picker .fp-table th, .file-picker .fp-table td { padding: 14px 10px; border-bottom: 1px solid #cbd5cc; text-align: left; }
|
||||
.file-picker .fp-table th { color: #56745f; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
|
||||
.file-picker .fp-status, .file-picker .fp-count { color: #5d6a61; font: 13px sans-serif; }
|
||||
.file-picker .fp-count { min-height: 22px; }
|
||||
.file-picker .tree-name { display: inline-flex; align-items: center; gap: 8px; min-width: 240px; padding-top: 0; padding-bottom: 0; border: 0; background: transparent; color: #17211b; font: inherit; text-align: left; }
|
||||
.file-picker .tree-group { padding-left: 0; cursor: pointer; }
|
||||
.file-picker .tree-chevron { display: inline-block; width: 12px; color: #78917d; font-size: 10px; }
|
||||
.file-picker .remove-btn { padding: 2px 7px; color: #8c5148; background: transparent; font: 20px/1 sans-serif; }
|
||||
.file-picker .tree-row td:first-child { white-space: nowrap; }
|
||||
@media (max-width: 600px) { .file-picker .fp-toolbar button { flex: 1 1 42%; } }
|
||||
`;
|
||||
document.head.append(style);
|
||||
}
|
||||
|
||||
function buildMarkup(root, cfg) {
|
||||
const controls = cfg.layout.controls || [];
|
||||
const control = (name, id, label, className = '') => controls.includes(name)
|
||||
? `<button id="${id}" type="button" class="${className}">${esc(label)}</button>` : '';
|
||||
const columns = cfg.layout.columns?.length ? cfg.layout.columns : Object.keys(cfg.labels.columns);
|
||||
const headings = columns.map((column) => `<th>${esc(cfg.labels.columns[column] || column)}</th>`).join('');
|
||||
root.innerHTML = `
|
||||
<div class="file-picker${cfg.layout.theme ? ` ${esc(cfg.layout.theme)}` : ''}">
|
||||
<div class="fp-toolbar" aria-label="${esc(cfg.labels.pickFiles)}">
|
||||
<input class="fp-file-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" multiple hidden>
|
||||
<input class="fp-folder-input" type="file" accept="${esc([...cfg.allowedExt, '.zip'].join(','))}" webkitdirectory directory multiple hidden>
|
||||
${control('files', 'fp-files-btn', cfg.labels.pickFiles)}
|
||||
${control('folder', 'fp-folder-btn', cfg.labels.pickFolder, 'secondary')}
|
||||
${control('clear', 'fp-clear-btn', cfg.labels.clear, 'quiet')}
|
||||
</div>
|
||||
<p class="fp-status" role="status"></p>
|
||||
<div class="fp-table-wrap"><table class="fp-table"><thead><tr>${headings}<th></th></tr></thead><tbody class="fp-table-body"></tbody></table></div>
|
||||
<p class="fp-count"></p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function notify(cfg, state) {
|
||||
if (typeof cfg.onChange === 'function') cfg.onChange([...flattenFiles(state.nodes)]);
|
||||
}
|
||||
|
||||
function removeMetadata(state, node) {
|
||||
if (node.kind === 'file') {
|
||||
state.fileKeys.delete(`${node.path}\u0000${node.file.size}`);
|
||||
return;
|
||||
}
|
||||
node.children.forEach((child) => removeMetadata(state, child));
|
||||
}
|
||||
|
||||
function removeNode(state, id) {
|
||||
const found = findNode(state.nodes, id);
|
||||
if (!found) return false;
|
||||
removeMetadata(state, found.node);
|
||||
found.nodes.splice(found.nodes.indexOf(found.node), 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function initFilePicker(config) {
|
||||
const cfg = normalizeConfig(config);
|
||||
const mount = resolveMount(cfg.mount);
|
||||
injectStyles();
|
||||
buildMarkup(mount, cfg);
|
||||
const root = mount.firstElementChild;
|
||||
const elements = {
|
||||
fileInputEl: root.querySelector('.fp-file-input'),
|
||||
folderInputEl: root.querySelector('.fp-folder-input'),
|
||||
tableBodyEl: root.querySelector('.fp-table-body'),
|
||||
countEl: root.querySelector('.fp-count'),
|
||||
};
|
||||
const state = { nodes: [], fileKeys: new Set(), busy: false };
|
||||
const listeners = [];
|
||||
const listen = (target, event, handler) => {
|
||||
target.addEventListener(event, handler);
|
||||
listeners.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
const redraw = () => render(state, elements, cfg);
|
||||
const filesChange = onFilesChange(state, cfg, elements);
|
||||
const folderChange = onFolderChange(state, cfg, elements);
|
||||
listen(elements.fileInputEl, 'change', async () => { await filesChange(); notify(cfg, state); });
|
||||
listen(elements.folderInputEl, 'change', async () => { await folderChange(); notify(cfg, state); });
|
||||
listen(elements.tableBodyEl, 'click', (event) => {
|
||||
const toggle = event.target.closest('[data-toggle]');
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
redraw();
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
if (removeNode(state, remove.dataset.remove)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (root.querySelector('#fp-files-btn')) listen(root.querySelector('#fp-files-btn'), 'click', () => elements.fileInputEl.click());
|
||||
if (root.querySelector('#fp-folder-btn')) listen(root.querySelector('#fp-folder-btn'), 'click', () => elements.folderInputEl.click());
|
||||
if (root.querySelector('#fp-clear-btn')) listen(root.querySelector('#fp-clear-btn'), 'click', () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
});
|
||||
redraw();
|
||||
return {
|
||||
pickFiles: () => elements.fileInputEl.click(),
|
||||
pickFolder: () => elements.folderInputEl.click(),
|
||||
addFiles: async (files) => { await addFiles(state, cfg, files, elements); notify(cfg, state); },
|
||||
getFiles: () => [...flattenFiles(state.nodes)],
|
||||
remove: (id) => {
|
||||
if (removeNode(state, id)) {
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
}
|
||||
},
|
||||
clear: () => {
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
redraw();
|
||||
notify(cfg, state);
|
||||
},
|
||||
render: redraw,
|
||||
destroy: () => {
|
||||
listeners.forEach((removeListener) => removeListener());
|
||||
state.nodes = [];
|
||||
state.fileKeys.clear();
|
||||
mount.replaceChildren();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULTS };
|
||||
@@ -63,13 +63,13 @@ export function initUploadTable(cfg) {
|
||||
if (toggle) {
|
||||
const found = findNode(state.nodes, toggle.dataset.toggle);
|
||||
if (found) found.node.expanded = !found.node.expanded;
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
return;
|
||||
}
|
||||
const remove = event.target.closest('[data-remove]');
|
||||
if (remove) {
|
||||
removeNode(state, remove.dataset.remove);
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export function initUploadTable(cfg) {
|
||||
getFiles: () => flattenFiles(state.nodes),
|
||||
remove: (id) => {
|
||||
removeNode(state, id);
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
},
|
||||
render: () => render(state, elements),
|
||||
clear: () => {
|
||||
|
||||
@@ -30,15 +30,16 @@ export async function addFiles(state, cfg, files, elements) {
|
||||
}
|
||||
try {
|
||||
// listZipFiles возвращает дерево только с разрешёнными leaf-файлами.
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt);
|
||||
const zipTree = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zipTree) addFileWithDedup(state, zipTree);
|
||||
} catch (error) {
|
||||
// Битый или небезопасный ZIP пропускается, чтобы не блокировать picker.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Единая перерисовка после всей пачки предотвращает промежуточные состояния UI.
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -53,13 +53,14 @@ export function onFolderChange(state, cfg, elements) {
|
||||
if (lowerPath.endsWith('.zip')) {
|
||||
try {
|
||||
// ZIP rebased на имя выбранной папки перед вставкой в общий root.
|
||||
const zip = await listZipFiles(file, cfg.allowedExt);
|
||||
const zip = await listZipFiles(file, cfg.allowedExt, cfg.limits);
|
||||
if (zip) {
|
||||
rebaseTree(zip, rootName);
|
||||
addToFolder(zip);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка одного ZIP не должна терять остальные файлы каталога.
|
||||
if (typeof cfg.onError === 'function') cfg.onError(error, file);
|
||||
continue;
|
||||
}
|
||||
} else if (cfg.allowedExt.some((extension) => lowerPath.endsWith(extension))) {
|
||||
@@ -71,7 +72,7 @@ export function onFolderChange(state, cfg, elements) {
|
||||
roots.forEach((root) => addFileWithDedup(state, root));
|
||||
// Сброс value позволяет выбрать тот же каталог повторно.
|
||||
elements.folderInputEl.value = '';
|
||||
render(state, elements);
|
||||
render(state, elements, cfg);
|
||||
} finally {
|
||||
state.busy = false;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,31 @@
|
||||
import { esc } from './esc.js';
|
||||
import { fs } from './fs.js';
|
||||
|
||||
const DEFAULT_RENDER_CONFIG = {
|
||||
labels: {
|
||||
statusReady: 'готов',
|
||||
remove: 'Удалить',
|
||||
empty: 'Нет выбранных файлов',
|
||||
columns: { path: 'Путь', size: 'Размер', status: 'Статус' },
|
||||
count: (count, bytes) => `${count} файлов · ${fs(bytes)}`,
|
||||
},
|
||||
layout: { columns: ['path', 'size', 'status'] },
|
||||
};
|
||||
|
||||
function renderConfig(cfg = {}) {
|
||||
return {
|
||||
labels: {
|
||||
...DEFAULT_RENDER_CONFIG.labels,
|
||||
...(cfg.labels || {}),
|
||||
columns: {
|
||||
...DEFAULT_RENDER_CONFIG.labels.columns,
|
||||
...(cfg.labels?.columns || {}),
|
||||
},
|
||||
},
|
||||
layout: { ...DEFAULT_RENDER_CONFIG.layout, ...(cfg.layout || {}) },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Рендерит один узел дерева и его раскрытых потомков в HTML-строки таблицы.
|
||||
*
|
||||
@@ -12,7 +37,7 @@ import { fs } from './fs.js';
|
||||
* Имена и пути проходят через esc до вставки в innerHTML. Группа получает
|
||||
* кнопку раскрытия, leaf-файл — размер, статус и data-path для будущего API статусов.
|
||||
*/
|
||||
function renderNode(node, depth) {
|
||||
function renderNode(node, depth, cfg) {
|
||||
const isGroup = node.kind !== 'file';
|
||||
const padding = depth * 4;
|
||||
const name = esc(node.name);
|
||||
@@ -20,14 +45,22 @@ function renderNode(node, depth) {
|
||||
? `<button class="tree-name tree-group tree-${node.kind}" style="padding-left:${padding}ch" data-toggle="${node.id}" `
|
||||
+ `aria-expanded="${node.expanded}"><span class="tree-chevron">${node.expanded ? '▼' : '▶'}</span>${name}</button>`
|
||||
: `<span class="tree-name" style="padding-left:${padding}ch">${name}</span>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="Удалить ${name}">×</button>`;
|
||||
const remove = `<button class="remove-btn" type="button" data-remove="${node.id}" aria-label="${esc(cfg.labels.remove)} ${name}">×</button>`;
|
||||
const pathAttribute = node.kind === 'file' ? ` data-path="${esc(node.path)}"` : '';
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}><td>${action}</td>`
|
||||
+ `<td>${node.kind === 'file' ? fs(node.file.size) : ''}</td>`
|
||||
+ `<td>${node.error ? esc(node.error) : node.kind === 'file' ? 'готов' : ''}</td><td>${remove}</td></tr>`;
|
||||
const cells = {
|
||||
path: action,
|
||||
size: node.kind === 'file' ? fs(node.file.size) : '',
|
||||
status: node.error ? esc(node.error) : node.kind === 'file' ? esc(cfg.labels.statusReady) : '',
|
||||
};
|
||||
const columns = cfg.layout.columns?.length
|
||||
? cfg.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
const row = `<tr class="tree-row tree-${node.kind}"${pathAttribute}>`
|
||||
+ columns.map((column) => `<td>${cells[column] || ''}</td>`).join('')
|
||||
+ `<td>${remove}</td></tr>`;
|
||||
if (!isGroup || !node.expanded) return row;
|
||||
// Дочерние строки добавляются только для раскрытой группы.
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1)).join('');
|
||||
return row + node.children.map((child) => renderNode(child, depth + 1, cfg)).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,10 +73,14 @@ function renderNode(node, depth) {
|
||||
* Обход для счётчика независим от раскрытия: свернутая группа всё равно входит
|
||||
* в количество leaf-файлов и суммарный размер.
|
||||
*/
|
||||
export function render(state, elements) {
|
||||
export function render(state, elements, cfg) {
|
||||
const normalized = renderConfig(cfg);
|
||||
const columns = normalized.layout.columns?.length
|
||||
? normalized.layout.columns
|
||||
: Object.keys(DEFAULT_RENDER_CONFIG.labels.columns);
|
||||
elements.tableBodyEl.innerHTML = state.nodes.length
|
||||
? state.nodes.map((node) => renderNode(node, 0)).join('')
|
||||
: '<tr><td colspan="4">Нет выбранных файлов</td></tr>';
|
||||
? state.nodes.map((node) => renderNode(node, 0, normalized)).join('')
|
||||
: `<tr><td colspan="${columns.length + 1}">${esc(normalized.labels.empty)}</td></tr>`;
|
||||
const files = [];
|
||||
// Собирает все leaf-файлы, включая содержимое свернутых групп.
|
||||
const visit = (node) => {
|
||||
@@ -51,7 +88,8 @@ export function render(state, elements) {
|
||||
else node.children.forEach(visit);
|
||||
};
|
||||
state.nodes.forEach(visit);
|
||||
elements.countEl.textContent = `${files.length} файлов · ${fs(files.reduce((sum, node) => sum + node.file.size, 0))}`;
|
||||
const totalBytes = files.reduce((sum, node) => sum + node.file.size, 0);
|
||||
elements.countEl.textContent = normalized.labels.count(files.length, totalBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Построить дерево ZIP с файлами только разрешённых расширений.
|
||||
import { unzipSync } from 'fflate';
|
||||
import { rebaseTree } from '../table/rebase_tree.js';
|
||||
|
||||
const DEFAULT_LIMITS = {
|
||||
maxEntries: 1000,
|
||||
maxTotalBytes: 100 * 1024 * 1024,
|
||||
maxEntryBytes: 50 * 1024 * 1024,
|
||||
maxDepth: 20,
|
||||
};
|
||||
|
||||
/** Возвращает true, если имя заканчивается одним из разрешённых расширений. */
|
||||
function extensionAllowed(name, allowedExt) {
|
||||
const lowerName = name.toLowerCase();
|
||||
@@ -59,10 +67,21 @@ function addPath(root, parts, fileNode) {
|
||||
* @returns {Promise<object|null>} Дерево или null, если разрешённых leaf нет.
|
||||
* @throws {Error} При превышении глубины или повреждённом ZIP.
|
||||
*/
|
||||
async function listEntries(data, zipName, allowedExt, depth) {
|
||||
async function listEntries(data, zipName, allowedExt, depth, limits, budget) {
|
||||
// Ограничение глубины защищает браузер от бесконечной/чрезмерной рекурсии.
|
||||
if (depth > 20) throw new Error('Слишком глубокая вложенность ZIP');
|
||||
const entries = fflate.unzipSync(data);
|
||||
if (depth > limits.maxDepth) throw new Error('Слишком глубокая вложенность ZIP');
|
||||
const entries = unzipSync(data, {
|
||||
filter: (entry) => {
|
||||
budget.entries += 1;
|
||||
if (budget.entries > limits.maxEntries) throw new Error('Слишком много ZIP entries');
|
||||
if (entry.originalSize > limits.maxEntryBytes) return false;
|
||||
if (budget.totalBytes + entry.originalSize > limits.maxTotalBytes) {
|
||||
throw new Error('Превышен суммарный размер распакованных ZIP entries');
|
||||
}
|
||||
budget.totalBytes += entry.originalSize;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const root = node('zip', zipName, zipName);
|
||||
|
||||
for (const [entryName, entryData] of Object.entries(entries)) {
|
||||
@@ -73,7 +92,7 @@ async function listEntries(data, zipName, allowedExt, depth) {
|
||||
const normalizedName = parts.join('/');
|
||||
if (normalizedName.toLowerCase().endsWith('.zip')) {
|
||||
// Вложенный ZIP раскрывается только если внутри есть разрешённые leaf-файлы.
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1);
|
||||
const nested = await listEntries(entryData, entryName, allowedExt, depth + 1, limits, budget);
|
||||
if (nested) {
|
||||
rebaseTree(nested, zipName);
|
||||
nested.name = parts.at(-1);
|
||||
@@ -94,9 +113,11 @@ async function listEntries(data, zipName, allowedExt, depth) {
|
||||
*
|
||||
* @param {File} file Browser File с ZIP-содержимым.
|
||||
* @param {string[]} allowedExt Разрешённые расширения.
|
||||
* @param {object} customLimits Ограничения распаковки.
|
||||
* @returns {Promise<object|null>} Корневой zip-узел или null для пустого результата.
|
||||
*/
|
||||
export async function listZipFiles(file, allowedExt) {
|
||||
export async function listZipFiles(file, allowedExt, customLimits = {}) {
|
||||
const data = new Uint8Array(await file.arrayBuffer());
|
||||
return listEntries(data, file.name, allowedExt, 0);
|
||||
const limits = { ...DEFAULT_LIMITS, ...customLimits };
|
||||
return listEntries(data, file.name, allowedExt, 0, limits, { entries: 0, totalBytes: 0 });
|
||||
}
|
||||
Reference in New Issue
Block a user